[feat] solution/backend: 대화로 섹션 옮기기·숨기기 확장 — 앞·한 칸·N번째·자리 바꾸기 · 여러 개 끄기 · 꺼진 목록
"맨 위 · 맨 아래 · X 다음으로" 만 되고 "X 앞으로" "한 칸 위로" "세 번째로" "자리 바꿔줘" 는
거절됐다. 발행본(HomePage.tsx)은 히어로를 늘 맨 위, SNS 를 늘 맨 아래에 그리는데 대화는
그 둘을 옮기고 "옮겼습니다" 라고 답했다 — 화면은 그대로였다.
- tools: move_section 에 where(맨 위·맨 아래·앞·뒤·위로·아래로·번째·바꾸기)·count —
where 가 비면 예전 to 표기로 읽는다. 한 칸·N번째는 보이는 순서로 센다(꺼진 부분과 자리만
바꾸는 헛이동 방지). 없는 순번·꺼진 부분의 칸 이동은 거절, 이미 그 자리면 Unchanged
- tools: PINNED(히어로 맨 위 · SNS 맨 아래) — 옮기기와 그 둘을 기준으로 한 앞·뒤·바꾸기를 막는다.
히어로 다음은 맨 위, SNS 앞은 맨 아래로 읽는다
- tools: toggle_section 이 쉼표로 여럿을 받는다 — 하나라도 못 찾거나 잠겼으면 아무것도 안 바꾼다
- tools: list_sections 는 보이는 순서에 번호(= N번째 기준)를 붙이고 꺼진 것을 모은다, only=꺼진
- prompts/runtime: 스키마에 where·count·only, 섹션 줄에 [항상 맨 위]·[항상 맨 아래] —
목록은 tools.PINNED 하나를 runtime 이 넘긴다(prompts 는 services 를 import 하지 않는다)
- docs/AGENT.md: 옮기기·숨기기 표와 근거. 동작하지 않던 예시("후기 빼줘") 교체 —
이용 후기는 섹션 목록에 없고 발행본이 늘 그린다
테스트 21건 추가, 에이전트·카카오 174 passed. pyflakes 새 경고 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b48be7e81
commit
36b818eda3
@ -137,7 +137,7 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
|
|||||||
|
|
||||||
### 페이지 구성 (2026-09-28)
|
### 페이지 구성 (2026-09-28)
|
||||||
|
|
||||||
"후기 빼줘" · "사진 갤러리 맨 위로" 처럼 **화면 구성**을 바꾼다. 구성은 `sites.theme.sections`
|
"날씨 빼줘" · "사진 갤러리 맨 위로" 처럼 **화면 구성**을 바꾼다. 구성은 `sites.theme.sections`
|
||||||
배열 하나이고, **배열 순서가 곧 발행본의 섹션 순서**다.
|
배열 하나이고, **배열 순서가 곧 발행본의 섹션 순서**다.
|
||||||
|
|
||||||
★ 목록은 `site_payload._sections` 를 **그대로 쓴다** — 발행본이 쓰는 바로 그 함수다.
|
★ 목록은 `site_payload._sections` 를 **그대로 쓴다** — 발행본이 쓰는 바로 그 함수다.
|
||||||
@ -152,6 +152,38 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
|
|||||||
|
|
||||||
★ **`sections` 만 갈아끼운다.** theme 을 통째로 새로 쓰면 사장님이 고른 색·서체가 말없이 사라진다.
|
★ **`sections` 만 갈아끼운다.** theme 을 통째로 새로 쓰면 사장님이 고른 색·서체가 말없이 사라진다.
|
||||||
|
|
||||||
|
#### 옮기기 · 숨기기 (2026-09-29)
|
||||||
|
|
||||||
|
| 말 | `move_section` 인자 | 세는 기준 |
|
||||||
|
|---|---|---|
|
||||||
|
| "갤러리 맨 위로 / 맨 아래로" | `where=맨 위 · 맨 아래` | 배열 |
|
||||||
|
| "갤러리를 소개 앞으로 / 다음으로" | `where=앞 · 뒤`, `to=소개` | 배열 |
|
||||||
|
| "갤러리 한 칸 위로 / 두 칸 아래로" | `where=위로 · 아래로`, `count=1 · 두` | **보이는 순서** |
|
||||||
|
| "날씨 세 번째로" | `where=번째`, `count=3` | **보이는 순서** |
|
||||||
|
| "소개랑 갤러리 자리 바꿔줘" | `where=바꾸기`, `to=사진 갤러리` | 배열 |
|
||||||
|
|
||||||
|
`where` 가 비면 예전 표기로 읽는다(`to` 에 '맨 위' · '맨 아래' · 그 뒤에 올 이름).
|
||||||
|
|
||||||
|
★ **히어로·SNS 게시글은 옮기지 않는다**(`tools.PINNED`). 발행본(`site/src/pages/HomePage.tsx`)이
|
||||||
|
배열 순서와 상관없이 히어로를 늘 맨 위에, SNS 를 늘 맨 아래에 그린다 — 옮기게 두면 "옮겼습니다"
|
||||||
|
라고 말하는데 화면은 그대로다. 그 둘을 기준으로 삼는 것도 같다: 히어로 **다음**은 맨 위, SNS **앞**은
|
||||||
|
맨 아래로 읽고, 히어로 앞 · SNS 뒤 · 그 둘과 자리 바꾸기는 거절한다. 기본 정보·오시는 길은 잠겼어도
|
||||||
|
순서대로 그려지므로 옮길 수 있다. 프롬프트에도 `[항상 맨 위]` · `[항상 맨 아래]` 로 싣는다.
|
||||||
|
|
||||||
|
★ **'한 칸' · 'N번째' 는 보이는 순서로 센다**(켜진 것, 히어로·SNS 제외). 꺼진 부분은 화면에 없어서,
|
||||||
|
배열로 세면 꺼진 부분과 자리만 바꾸고 화면은 그대로인 이동이 생긴다. 그래서 꺼진 부분은 칸으로
|
||||||
|
옮기지 않고(켠 뒤 말하거나 '소개 다음으로' 처럼), 없는 순번(1~보이는 수 밖)은 추측하지 않고 거절한다.
|
||||||
|
`list_sections` 의 번호가 이 순서다 — 목록에서 본 번호로 말했는데 다른 자리로 가면 고장난 줄 안다.
|
||||||
|
이미 그 자리면 `Unchanged` 다(재발행을 권하지 않는다).
|
||||||
|
|
||||||
|
★ **숨기기는 끄기다 — 지우지 않는다.** 에디터에도 빼는 기능이 없고, `_sections` 가 저장값에 없는
|
||||||
|
기본 섹션을 켜서 끝에 다시 붙인다. `toggle_section` 은 `name` 에 쉼표로 여럿을 받는다("사진 갤러리, 날씨").
|
||||||
|
★ 여럿 중 **하나라도** 못 찾거나 끌 수 없으면 **아무것도 바꾸지 않는다** — 일부만 끄면 사장님은 무엇이
|
||||||
|
꺼졌는지 다시 확인해야 한다. 하나의 요청이라 상한(5개)도 하나로 센다.
|
||||||
|
`list_sections` 는 꺼진 것을 따로 모아 보여 주고, `only=꺼진` 이면 그것만 답한다("숨긴 거 뭐 있어").
|
||||||
|
|
||||||
|
⚠️ 이용 후기 · 엽서 쓰기는 섹션 목록에 없다 — 발행본이 늘 그린다. "후기 빼줘" 는 "못 찾았어요" 로 끝난다.
|
||||||
|
|
||||||
### 사진 (2026-09-28)
|
### 사진 (2026-09-28)
|
||||||
|
|
||||||
"객실 사진 내려줘" · "대표 사진 수영장으로 바꿔줘". 지목은 Vision 이 만든 **라벨·alt** 로 한다.
|
"객실 사진 내려줘" · "대표 사진 수영장으로 바꿔줘". 지목은 Vision 이 만든 **라벨·alt** 로 한다.
|
||||||
|
|||||||
@ -108,6 +108,7 @@ async def _choose(place, fields, facts, sections, photos, message) -> dict:
|
|||||||
sections=sections,
|
sections=sections,
|
||||||
photos=photos,
|
photos=photos,
|
||||||
message=message,
|
message=message,
|
||||||
|
pinned=registry.PINNED,
|
||||||
),
|
),
|
||||||
response_schema=prompt.RESPONSE_SCHEMA,
|
response_schema=prompt.RESPONSE_SCHEMA,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
|
|||||||
@ -321,61 +321,204 @@ def _switch(raw: str) -> bool | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _layout_line(row: dict) -> str:
|
# ★ 발행본(site/src/pages/HomePage.tsx)은 이 둘을 배열 순서와 상관없이 그린다 — 히어로는 늘
|
||||||
mark = "켜짐" if row["enabled"] else "꺼짐"
|
# 맨 위, SNS 게시글은 늘 맨 아래. 옮기게 두면 "옮겼습니다" 라고 말하는데 화면은 그대로다.
|
||||||
lock = " (항상 켜짐)" if row["locked"] else ""
|
PINNED = {"hero": "맨 위", "social": "맨 아래"}
|
||||||
return f"· {row['name']} — {mark}{lock}"
|
|
||||||
|
# move_section 의 where. ★ '위' 는 한 칸 위다 — '맨 위' 와 가른다(예전 to 표기는 아래 _LEGACY_TO).
|
||||||
|
_WHERE = {
|
||||||
|
"top": {"맨 위", "맨위", "처음", "top", "first"},
|
||||||
|
"bottom": {"맨 아래", "맨아래", "마지막", "bottom", "last"},
|
||||||
|
"before": {"앞", "앞으로", "before"},
|
||||||
|
"after": {"뒤", "뒤로", "다음", "다음으로", "after"},
|
||||||
|
"up": {"위", "위로", "up"},
|
||||||
|
"down": {"아래", "아래로", "down"},
|
||||||
|
"nth": {"번째", "순서", "nth"},
|
||||||
|
"swap": {"바꾸기", "자리 바꾸기", "교환", "swap"},
|
||||||
|
}
|
||||||
|
# where 가 없던 때의 표기 — to 에 '맨 위' · '맨 아래' 또는 "그 뒤에 올 부분의 이름" 이 왔다.
|
||||||
|
_LEGACY_TO = {"맨 위": "top", "처음": "top", "위": "top", "top": "top", "first": "top",
|
||||||
|
"맨 아래": "bottom", "마지막": "bottom", "아래": "bottom", "bottom": "bottom", "last": "bottom"}
|
||||||
|
_KOREAN_COUNT = {"한": 1, "하나": 1, "두": 2, "둘": 2, "세": 3, "셋": 3, "네": 4, "넷": 4,
|
||||||
|
"다섯": 5, "여섯": 6, "일곱": 7, "여덟": 8, "아홉": 9, "열": 10}
|
||||||
|
|
||||||
|
|
||||||
|
def _where(raw: str) -> str | None:
|
||||||
|
word = raw.strip().lower()
|
||||||
|
return next((key for key, words in _WHERE.items() if word in words), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _count(raw: str) -> int | None:
|
||||||
|
""""3" · "세" · "3칸" · "세 번째" → 3."""
|
||||||
|
word = re.sub(r"\s|칸|번째|번|째", "", raw)
|
||||||
|
if word.isdigit():
|
||||||
|
return int(word)
|
||||||
|
return _KOREAN_COUNT.get(word)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_rows(rows: list) -> list:
|
||||||
|
"""손님 화면의 순서 — 켜진 것, 자리가 고정된 둘을 뺀 것.
|
||||||
|
|
||||||
|
★ '한 칸 위' · 'N번째' 는 이 순서로 센다. 꺼진 부분은 화면에 없어서, 배열로 세면 꺼진
|
||||||
|
부분과 자리만 바꾸고 화면은 그대로인 이동이 생긴다."""
|
||||||
|
return [r for r in rows if r["enabled"] and r["id"] not in PINNED]
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(rows: list, row: dict, before: dict | None = None, after: dict | None = None) -> list:
|
||||||
|
rest = [r for r in rows if r["id"] != row["id"]]
|
||||||
|
at = rest.index(before) if before is not None else rest.index(after) + 1
|
||||||
|
return rest[:at] + [row] + rest[at:]
|
||||||
|
|
||||||
|
|
||||||
async def _list_sections(ctx: ToolContext, args: dict) -> str:
|
async def _list_sections(ctx: ToolContext, args: dict) -> str:
|
||||||
|
"""보이는 순서에 번호를 붙이고, 꺼진 것은 따로 모은다.
|
||||||
|
|
||||||
|
★ 번호는 move_section 의 'N번째' 와 같은 순서다(_visible_rows) — 목록에서 본 번호로
|
||||||
|
말했는데 다른 자리로 가면 사장님은 기능이 고장난 줄 안다."""
|
||||||
rows, _theme = await _sections_of(ctx)
|
rows, _theme = await _sections_of(ctx)
|
||||||
body = "\n".join(_layout_line(r) for r in rows)
|
hidden = [r["name"] for r in rows if not r["enabled"]]
|
||||||
return f"지금 홈페이지는 위에서부터 이 순서입니다.\n{body}"
|
hidden_line = f"꺼져 있는 부분: {', '.join(hidden)}" if hidden else "꺼져 있는 부분은 없어요."
|
||||||
|
if _arg(args, "only") in ("꺼진", "꺼짐", "숨긴", "숨김", "hidden", "off"):
|
||||||
|
return hidden_line
|
||||||
|
|
||||||
|
lines = [f"· {r['name']} — 항상 맨 위" for r in rows if r["id"] == "hero"]
|
||||||
|
for i, r in enumerate(_visible_rows(rows), start=1):
|
||||||
|
lines.append(f"{i}. {r['name']}" + (" (끌 수 없음)" if r["locked"] else ""))
|
||||||
|
lines += [f"· {r['name']} — 항상 맨 아래" for r in rows if r["id"] == "social" and r["enabled"]]
|
||||||
|
body = "\n".join(lines)
|
||||||
|
return f"지금 홈페이지는 위에서부터 이 순서입니다.\n{body}\n{hidden_line}"
|
||||||
|
|
||||||
|
|
||||||
async def _toggle_section(ctx: ToolContext, args: dict) -> str:
|
async def _toggle_section(ctx: ToolContext, args: dict) -> str:
|
||||||
|
"""켜거나 끈다. name 에 쉼표로 여럿을 받는다("사진 갤러리, 날씨").
|
||||||
|
|
||||||
|
★ 여럿 중 하나라도 못 찾거나 끌 수 없으면 **아무것도 바꾸지 않는다.** 일부만 끄면
|
||||||
|
사장님은 무엇이 꺼졌는지 다시 확인해야 한다 — 한 요청은 한꺼번에 되거나 안 된다."""
|
||||||
rows, theme = await _sections_of(ctx)
|
rows, theme = await _sections_of(ctx)
|
||||||
row = _find_section(rows, _arg(args, "name"))
|
wanted = [w.strip() for w in re.split(r"[,·、]", _arg(args, "name")) if w.strip()]
|
||||||
if row is None:
|
found, missing = [], []
|
||||||
|
for name in wanted:
|
||||||
|
row = _find_section(rows, name)
|
||||||
|
if row is None:
|
||||||
|
missing.append(name)
|
||||||
|
elif row not in found:
|
||||||
|
found.append(row)
|
||||||
|
if missing or not found:
|
||||||
|
if len(wanted) > 1:
|
||||||
|
raise ToolRejected(f"{', '.join(missing)} 은(는) 어느 부분인지 못 찾아서 아무것도 바꾸지 않았어요. "
|
||||||
|
f"'목록' 이라고 하시면 보여드릴게요.")
|
||||||
raise ToolRejected("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.")
|
raise ToolRejected("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.")
|
||||||
|
|
||||||
|
names = ", ".join(r["name"] for r in found)
|
||||||
on = _switch(_arg(args, "enabled"))
|
on = _switch(_arg(args, "enabled"))
|
||||||
if on is None:
|
if on is None:
|
||||||
raise ToolRejected(f"{row['name']} 을(를) 켤지 끌지 알려 주세요.")
|
raise ToolRejected(f"{names} 을(를) 켤지 끌지 알려 주세요.")
|
||||||
# ★ 잠긴 섹션은 끌 수 없다. SEO·필수 마크업 때문에 잠긴 것이라, 끄면 발행 게이트에 걸린다
|
# ★ 잠긴 섹션은 끌 수 없다. SEO·필수 마크업 때문에 잠긴 것이라, 끄면 발행 게이트에 걸린다
|
||||||
# (site_payload._sections 가 어차피 켜서 내보낸다 — 화면만 거짓말하게 된다).
|
# (site_payload._sections 가 어차피 켜서 내보낸다 — 화면만 거짓말하게 된다).
|
||||||
if row["locked"] and not on:
|
locked = [r["name"] for r in found if r["locked"]]
|
||||||
raise ToolRejected(f"{row['name']} 은(는) 홈페이지에 꼭 있어야 하는 부분이라 끌 수 없어요.")
|
if locked and not on:
|
||||||
if row["enabled"] == on:
|
tail = " 아무것도 바꾸지 않았어요." if len(found) > 1 else ""
|
||||||
return Unchanged(f"{row['name']} 은(는) 이미 {'켜져' if on else '꺼져'} 있어요.")
|
raise ToolRejected(f"{', '.join(locked)} 은(는) 홈페이지에 꼭 있어야 하는 부분이라 끌 수 없어요.{tail}")
|
||||||
|
|
||||||
row["enabled"] = on
|
todo = [r for r in found if r["enabled"] != on]
|
||||||
|
already = [r["name"] for r in found if r["enabled"] == on]
|
||||||
|
state = "켜져" if on else "꺼져"
|
||||||
|
if not todo:
|
||||||
|
return Unchanged(f"{', '.join(already)} 은(는) 이미 {state} 있어요.")
|
||||||
|
|
||||||
|
for r in todo:
|
||||||
|
r["enabled"] = on
|
||||||
await _save_sections(ctx, theme, rows)
|
await _save_sections(ctx, theme, rows)
|
||||||
return f"{row['name']} 을(를) {'켰습니다' if on else '껐습니다'}."
|
line = f"{', '.join(r['name'] for r in todo)} 을(를) {'켰습니다' if on else '껐습니다'}."
|
||||||
|
return f"{line} ({', '.join(already)} 은(는) 이미 {state} 있었어요.)" if already else line
|
||||||
|
|
||||||
|
|
||||||
async def _move_section(ctx: ToolContext, args: dict) -> str:
|
async def _move_section(ctx: ToolContext, args: dict) -> str:
|
||||||
"""★ 배열 순서가 곧 발행본의 섹션 순서다(site_payload._sections)."""
|
"""★ 배열 순서가 곧 발행본의 섹션 순서다(site_payload._sections).
|
||||||
|
|
||||||
|
where: 맨 위 · 맨 아래 · 앞 · 뒤 · 위로/아래로(count 칸) · 번째(count 번째) · 바꾸기.
|
||||||
|
where 가 비면 예전 표기(to 에 '맨 위' · '맨 아래' · 그 뒤에 올 이름)로 읽는다."""
|
||||||
rows, theme = await _sections_of(ctx)
|
rows, theme = await _sections_of(ctx)
|
||||||
row = _find_section(rows, _arg(args, "name"))
|
row = _find_section(rows, _arg(args, "name"))
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ToolRejected("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.")
|
raise ToolRejected("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.")
|
||||||
|
if row["id"] in PINNED:
|
||||||
|
raise ToolRejected(f"{row['name']} 은(는) 항상 {PINNED[row['id']]}에 서는 부분이라 옮길 수 없어요.")
|
||||||
|
|
||||||
to = _arg(args, "to")
|
to = _arg(args, "to")
|
||||||
rest = [r for r in rows if r["id"] != row["id"]]
|
where = _where(_arg(args, "where")) or _LEGACY_TO.get(to) or ("after" if to else None)
|
||||||
if to in ("맨 위", "처음", "위", "top", "first"):
|
if where is None:
|
||||||
moved, where = [row] + rest, "맨 위로"
|
raise ToolRejected("어디로 옮길지 알려 주세요. 예) 맨 위로 · 소개 다음으로 · 한 칸 위로 · 세 번째로")
|
||||||
elif to in ("맨 아래", "마지막", "아래", "bottom", "last"):
|
|
||||||
moved, where = rest + [row], "맨 아래로"
|
if where in ("up", "down", "nth"):
|
||||||
|
moved, said = _move_in_view(rows, row, where, _arg(args, "count"))
|
||||||
|
if isinstance(moved, Unchanged):
|
||||||
|
return moved
|
||||||
|
elif where == "top":
|
||||||
|
moved, said = [row] + [r for r in rows if r["id"] != row["id"]], "맨 위로"
|
||||||
|
elif where == "bottom":
|
||||||
|
moved, said = [r for r in rows if r["id"] != row["id"]] + [row], "맨 아래로"
|
||||||
else:
|
else:
|
||||||
anchor = _find_section(rest, to)
|
anchor = _find_section([r for r in rows if r["id"] != row["id"]], to)
|
||||||
if anchor is None:
|
if anchor is None:
|
||||||
raise ToolRejected("어디로 옮길지 못 찾았어요. '소개 다음으로' 처럼 말씀해 주세요.")
|
raise ToolRejected("어디로 옮길지 못 찾았어요. '소개 다음으로' 처럼 말씀해 주세요.")
|
||||||
at = rest.index(anchor) + 1
|
moved, said = _move_by_anchor(rows, row, anchor, where)
|
||||||
moved, where = rest[:at] + [row] + rest[at:], f"{anchor['name']} 다음으로"
|
|
||||||
|
|
||||||
await _save_sections(ctx, theme, moved)
|
await _save_sections(ctx, theme, moved)
|
||||||
return f"{row['name']} 을(를) {where} 옮겼습니다."
|
return f"{row['name']} 을(를) {said} 옮겼습니다."
|
||||||
|
|
||||||
|
|
||||||
|
def _move_in_view(rows: list, row: dict, where: str, raw_count: str):
|
||||||
|
"""한 칸 위·아래 · N번째 — 보이는 순서(_visible_rows)로 센다."""
|
||||||
|
if not row["enabled"]:
|
||||||
|
raise ToolRejected(f"{row['name']} 은(는) 지금 꺼져 있어 화면에 없는 부분이라 칸으로 옮길 수 없어요. "
|
||||||
|
f"켠 뒤 말씀하시거나 '소개 다음으로' 처럼 말씀해 주세요.")
|
||||||
|
view = _visible_rows(rows)
|
||||||
|
at = next(i for i, r in enumerate(view) if r["id"] == row["id"])
|
||||||
|
count = _count(raw_count) if raw_count else (None if where == "nth" else 1)
|
||||||
|
if count is None or count < 1:
|
||||||
|
raise ToolRejected("몇 칸인지, 몇 번째인지 숫자로 알려 주세요. 예) 세 번째로")
|
||||||
|
|
||||||
|
if where == "nth":
|
||||||
|
if count > len(view):
|
||||||
|
raise ToolRejected(f"1번째부터 {len(view)}번째 사이로 알려 주세요.")
|
||||||
|
goal, said = count - 1, f"{count}번째로"
|
||||||
|
elif where == "up":
|
||||||
|
goal = max(0, at - count)
|
||||||
|
said = f"{at - goal}칸 위로"
|
||||||
|
else:
|
||||||
|
goal = min(len(view) - 1, at + count)
|
||||||
|
said = f"{goal - at}칸 아래로"
|
||||||
|
if goal == at:
|
||||||
|
edge = "맨 위에" if where == "up" else "맨 아래에" if where == "down" else f"{count}번째에"
|
||||||
|
return Unchanged(f"{row['name']} 은(는) 이미 {edge} 있어요."), ""
|
||||||
|
|
||||||
|
others = [r for r in view if r["id"] != row["id"]]
|
||||||
|
if goal < len(others):
|
||||||
|
return _insert(rows, row, before=others[goal]), said
|
||||||
|
return _insert(rows, row, after=others[-1]), said
|
||||||
|
|
||||||
|
|
||||||
|
def _move_by_anchor(rows: list, row: dict, anchor: dict, where: str):
|
||||||
|
"""앞 · 뒤 · 바꾸기. ★ 기준이 고정된 부분이면 — 히어로 다음은 맨 위, SNS 앞은 맨 아래로 읽고,
|
||||||
|
그 밖(히어로 앞 · SNS 뒤 · 그 둘과 바꾸기)은 화면에 없는 자리라 거절한다."""
|
||||||
|
rest = [r for r in rows if r["id"] != row["id"]]
|
||||||
|
if anchor["id"] in PINNED:
|
||||||
|
if (anchor["id"], where) == ("hero", "after"):
|
||||||
|
return [row] + rest, "맨 위로"
|
||||||
|
if (anchor["id"], where) == ("social", "before"):
|
||||||
|
return rest + [row], "맨 아래로"
|
||||||
|
raise ToolRejected(f"{anchor['name']} 은(는) 항상 {PINNED[anchor['id']]}에 서는 부분이라 "
|
||||||
|
f"{'자리를 바꿀' if where == 'swap' else '그쪽으로 옮길'} 수 없어요.")
|
||||||
|
if where == "before":
|
||||||
|
return _insert(rows, row, before=anchor), f"{anchor['name']} 앞으로"
|
||||||
|
if where == "after":
|
||||||
|
return _insert(rows, row, after=anchor), f"{anchor['name']} 다음으로"
|
||||||
|
i = next(k for k, r in enumerate(rows) if r["id"] == row["id"])
|
||||||
|
j = next(k for k, r in enumerate(rows) if r["id"] == anchor["id"])
|
||||||
|
swapped = list(rows)
|
||||||
|
swapped[i], swapped[j] = swapped[j], swapped[i]
|
||||||
|
return swapped, f"{anchor['name']} 와(과) 자리를 바꿔"
|
||||||
|
|
||||||
|
|
||||||
# ── 사진 ─────────────────────────────────────────────────────────────────
|
# ── 사진 ─────────────────────────────────────────────────────────────────
|
||||||
@ -547,7 +690,8 @@ REGISTRY: dict[str, Tool] = {
|
|||||||
name="list_sections",
|
name="list_sections",
|
||||||
grade=ToolGrade.READ,
|
grade=ToolGrade.READ,
|
||||||
title="화면 구성 보기",
|
title="화면 구성 보기",
|
||||||
summary="홈페이지가 어떤 부분들로 어떤 순서로 되어 있는지 보여준다.",
|
summary="홈페이지가 어떤 부분들로 어떤 순서로 되어 있는지, 무엇이 꺼져 있는지 보여준다.",
|
||||||
|
args={"only": "꺼진 부분만 보려면 '꺼진'(선택)"},
|
||||||
run=_list_sections,
|
run=_list_sections,
|
||||||
),
|
),
|
||||||
Tool(
|
Tool(
|
||||||
@ -556,8 +700,8 @@ REGISTRY: dict[str, Tool] = {
|
|||||||
republish=True,
|
republish=True,
|
||||||
describe=_describe_toggle,
|
describe=_describe_toggle,
|
||||||
target=("name",),
|
target=("name",),
|
||||||
summary="홈페이지의 한 부분을 켜거나 끈다(예: 후기, 사진 갤러리, 예약 안내).",
|
summary="홈페이지의 부분을 켜거나 끈다(숨기기). 여러 부분을 한꺼번에 할 수 있다.",
|
||||||
args={"name": "그 부분의 이름", "enabled": "켜면 true, 끄면 false"},
|
args={"name": "그 부분의 이름 — 여럿이면 쉼표로 이어서", "enabled": "켜면 true, 끄면 false"},
|
||||||
run=_toggle_section,
|
run=_toggle_section,
|
||||||
),
|
),
|
||||||
Tool(
|
Tool(
|
||||||
@ -567,7 +711,12 @@ REGISTRY: dict[str, Tool] = {
|
|||||||
describe=_describe_move,
|
describe=_describe_move,
|
||||||
target=("name",),
|
target=("name",),
|
||||||
summary="홈페이지에서 한 부분의 위치(순서)를 바꾼다.",
|
summary="홈페이지에서 한 부분의 위치(순서)를 바꾼다.",
|
||||||
args={"name": "옮길 부분의 이름", "to": "'맨 위' · '맨 아래' · 또는 그 뒤에 올 부분의 이름"},
|
args={
|
||||||
|
"name": "옮길 부분의 이름",
|
||||||
|
"where": "'맨 위' · '맨 아래' · '앞' · '뒤' · '위로' · '아래로' · '번째' · '바꾸기' 중 하나",
|
||||||
|
"to": "앞·뒤·바꾸기의 기준이 되는 부분의 이름",
|
||||||
|
"count": "위로·아래로는 칸 수, 번째는 순번(숫자)",
|
||||||
|
},
|
||||||
run=_move_section,
|
run=_move_section,
|
||||||
),
|
),
|
||||||
Tool(
|
Tool(
|
||||||
|
|||||||
@ -29,8 +29,11 @@ _ACTION = {
|
|||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"to": {"type": "string"},
|
"to": {"type": "string"},
|
||||||
"enabled": {"type": "string"},
|
"enabled": {"type": "string"},
|
||||||
|
"where": {"type": "string"},
|
||||||
|
"count": {"type": "string"},
|
||||||
|
"only": {"type": "string"},
|
||||||
},
|
},
|
||||||
"required": ["key", "value", "keyword", "name", "to", "enabled"],
|
"required": ["key", "value", "keyword", "name", "to", "enabled", "where", "count", "only"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["tool", "args"],
|
"required": ["tool", "args"],
|
||||||
@ -72,8 +75,19 @@ def _field_line(field: dict) -> str:
|
|||||||
return f"{field['key']}: {field['label']} ({form})"
|
return f"{field['key']}: {field['label']} ({form})"
|
||||||
|
|
||||||
|
|
||||||
|
def _section_line(section: dict, pinned: dict) -> str:
|
||||||
|
"""★ 자리가 고정된 부분(pinned: id → '맨 위' · '맨 아래')을 알려 준다. 모르면 모델은 "히어로
|
||||||
|
맨 아래로" 를 그대로 고르고, 도구가 거절하는 데까지 한 바퀴를 헛돈다.
|
||||||
|
그 목록은 tools.PINNED 한 곳이다 — 이 모듈은 services 를 import 하지 않으므로 받아서 쓴다."""
|
||||||
|
where = pinned.get(section.get("id"))
|
||||||
|
return (section["name"]
|
||||||
|
+ (" [끄기 불가]" if section["locked"] else "")
|
||||||
|
+ (f" [항상 {where}]" if where else "")
|
||||||
|
+ (" (꺼짐)" if not section["enabled"] else ""))
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict],
|
def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict],
|
||||||
sections: list[dict], photos: list[str], message: str) -> str:
|
sections: list[dict], photos: list[str], message: str, pinned: dict | None = None) -> str:
|
||||||
"""사장님 발화 → 도구 하나.
|
"""사장님 발화 → 도구 하나.
|
||||||
|
|
||||||
★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면
|
★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면
|
||||||
@ -96,6 +110,8 @@ def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], fact
|
|||||||
할 수 있는 것과 섞여 있으면 할 수 있는 것은 actions 에, 할 수 없는 것은 skipped 에 담는다.
|
할 수 있는 것과 섞여 있으면 할 수 있는 것은 actions 에, 할 수 없는 것은 skipped 에 담는다.
|
||||||
말없이 빠뜨리지 않는다. 할 수 없는 요청이 없으면 skipped 는 빈 배열이다.
|
말없이 빠뜨리지 않는다. 할 수 없는 요청이 없으면 skipped 는 빈 배열이다.
|
||||||
- 같은 것을 고쳐 말하면("3시… 아니 4시로") 마지막 것 하나만 담는다.
|
- 같은 것을 고쳐 말하면("3시… 아니 4시로") 마지막 것 하나만 담는다.
|
||||||
|
- 여러 부분을 한꺼번에 켜거나 끄면 toggle_section 하나에 name 을 쉼표로 이어 담는다
|
||||||
|
(예: "사진 갤러리, 날씨"). [항상 맨 위] · [항상 맨 아래] 인 부분은 옮기지 않는다.
|
||||||
- 지금 고칠 수 있는 가게는 "{place_name}" 하나다. 다른 가게 이야기이거나 여러 가게를 한꺼번에
|
- 지금 고칠 수 있는 가게는 "{place_name}" 하나다. 다른 가게 이야기이거나 여러 가게를 한꺼번에
|
||||||
말하면 도구를 고르지 말고 되묻는다.
|
말하면 도구를 고르지 말고 되묻는다.
|
||||||
- 추측해서 고르지 않는다. 틀린 값을 넣는 것보다 되묻는 쪽이 낫다.
|
- 추측해서 고르지 않는다. 틀린 값을 넣는 것보다 되묻는 쪽이 낫다.
|
||||||
@ -111,7 +127,7 @@ def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], fact
|
|||||||
{json.dumps(facts, ensure_ascii=False)}
|
{json.dumps(facts, ensure_ascii=False)}
|
||||||
|
|
||||||
홈페이지를 이루는 부분들 — 위에서부터의 순서다(toggle_section·move_section 의 name 은 이 중 하나):
|
홈페이지를 이루는 부분들 — 위에서부터의 순서다(toggle_section·move_section 의 name 은 이 중 하나):
|
||||||
{chr(10).join(f"{s['name']}" + (" [끄기 불가]" if s['locked'] else "") + (" (꺼짐)" if not s['enabled'] else "") for s in sections)}
|
{chr(10).join(_section_line(s, pinned or {}) for s in sections)}
|
||||||
|
|
||||||
올라가 있는 사진 — 맨 앞이 대표 사진이다(hide_photo·set_primary_photo 의 name 은 이 중 하나):
|
올라가 있는 사진 — 맨 앞이 대표 사진이다(hide_photo·set_primary_photo 의 name 은 이 중 하나):
|
||||||
{chr(10).join(photos) if photos else "(없음)"}
|
{chr(10).join(photos) if photos else "(없음)"}
|
||||||
|
|||||||
@ -879,3 +879,185 @@ def test_응답_스키마에_못_한_요청_칸이_있다():
|
|||||||
|
|
||||||
assert "skipped" in prompt.RESPONSE_SCHEMA["properties"]
|
assert "skipped" in prompt.RESPONSE_SCHEMA["properties"]
|
||||||
assert "skipped" in prompt.RESPONSE_SCHEMA["required"]
|
assert "skipped" in prompt.RESPONSE_SCHEMA["required"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 섹션 옮기기 — 앞·한 칸·N번째·자리 바꾸기 ────────────────────────────
|
||||||
|
#
|
||||||
|
# 숙박 기본 순서에서 **화면에 보이는 것**(켜짐 · 히어로·SNS 제외):
|
||||||
|
# 소개 · 객실 안내 · 소식 · 기본 정보 · 예약 안내 · 영상 · 사진 갤러리 · 오시는 길 · …
|
||||||
|
|
||||||
|
async def _visible(client, headers, place_id) -> list[str]:
|
||||||
|
"""손님 화면의 순서 — 발행본(HomePage)은 히어로를 맨 위, SNS 를 맨 아래에 고정해 그린다."""
|
||||||
|
rows, _t = await _sections(client, headers, place_id)
|
||||||
|
return [r["name"] for r in rows if r["enabled"] and r["id"] not in ("hero", "social")]
|
||||||
|
|
||||||
|
|
||||||
|
async def _move(client, h, pid, choose, **args):
|
||||||
|
choose({"actions": [{"tool": "move_section", "args": args}], "message": ""})
|
||||||
|
return (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "옮겨줘"})).json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_어느_부분_앞으로_옮긴다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
body = await _move(client, h, pid, choose, name="사진 갤러리", where="앞", to="소개")
|
||||||
|
assert body.get("rejected") is not True, body["reply"]
|
||||||
|
assert "소개 앞으로" in body["reply"]
|
||||||
|
assert (await _visible(client, h, pid))[:2] == ["사진 갤러리", "소개"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_한_칸_위로_옮긴다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
await _move(client, h, pid, choose, name="사진 갤러리", where="위로", count="1")
|
||||||
|
order = await _visible(client, h, pid)
|
||||||
|
assert order.index("사진 갤러리") == order.index("영상") - 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_두_칸_아래로_옮긴다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
await _move(client, h, pid, choose, name="소개", where="아래로", count="두")
|
||||||
|
assert (await _visible(client, h, pid))[:3] == ["객실 안내", "소식", "소개"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_한_칸은_보이는_순서로_센다(client, auth_headers, choose, db_engine):
|
||||||
|
"""★ 꺼진 부분은 화면에 없다. 배열로 세면 꺼진 '영상' 과 자리만 바꾸고 화면은 그대로다 —
|
||||||
|
"옮겼습니다" 라고 말하는데 사장님 눈에는 아무 일도 없다."""
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
choose({"actions": [{"tool": "toggle_section", "args": {"name": "영상", "enabled": "false"}}], "message": ""})
|
||||||
|
await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "영상 빼줘"})
|
||||||
|
|
||||||
|
before = await _visible(client, h, pid)
|
||||||
|
await _move(client, h, pid, choose, name="사진 갤러리", where="위로", count="1")
|
||||||
|
after = await _visible(client, h, pid)
|
||||||
|
at = before.index("사진 갤러리")
|
||||||
|
assert after[at - 1] == "사진 갤러리" and after[at] == before[at - 1]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_이미_맨_위면_그렇다고_말하고_재발행을_권하지_않는다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
body = await _move(client, h, pid, choose, name="소개", where="위로", count="1")
|
||||||
|
assert "이미" in body["reply"]
|
||||||
|
assert body["done"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_N번째로_옮긴다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
body = await _move(client, h, pid, choose, name="날씨", where="번째", count="3")
|
||||||
|
assert body.get("rejected") is not True, body["reply"]
|
||||||
|
assert (await _visible(client, h, pid))[2] == "날씨"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_없는_순번이면_추측하지_않는다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
before = await _visible(client, h, pid)
|
||||||
|
body = await _move(client, h, pid, choose, name="날씨", where="번째", count="99")
|
||||||
|
assert body["rejected"] is True
|
||||||
|
assert await _visible(client, h, pid) == before
|
||||||
|
|
||||||
|
|
||||||
|
async def test_두_부분의_자리를_바꾼다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
before = await _visible(client, h, pid)
|
||||||
|
await _move(client, h, pid, choose, name="소개", where="바꾸기", to="사진 갤러리")
|
||||||
|
after = await _visible(client, h, pid)
|
||||||
|
assert after[before.index("소개")] == "사진 갤러리"
|
||||||
|
assert after[before.index("사진 갤러리")] == "소개"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name, where, to", [
|
||||||
|
("히어로", "아래", ""), # 발행본이 늘 맨 위에 그린다
|
||||||
|
("SNS 게시글", "위", ""), # 발행본이 늘 맨 아래에 그린다
|
||||||
|
("소개", "앞", "히어로"), # 히어로 앞은 없다
|
||||||
|
("소개", "바꾸기", "히어로"),
|
||||||
|
])
|
||||||
|
async def test_자리가_고정된_부분은_옮기지_않는다(name, where, to, client, auth_headers, choose, db_engine):
|
||||||
|
"""★ HomePage 가 히어로·SNS 를 배열과 상관없이 그린다 — 옮기게 두면 화면만 거짓말한다."""
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
rows_before, _t = await _sections(client, h, pid)
|
||||||
|
body = await _move(client, h, pid, choose, name=name, where=where, to=to)
|
||||||
|
assert body["rejected"] is True
|
||||||
|
assert "항상" in body["reply"]
|
||||||
|
rows_after, _t = await _sections(client, h, pid)
|
||||||
|
assert [r["id"] for r in rows_after] == [r["id"] for r in rows_before]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_히어로_다음은_맨_위다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
await _move(client, h, pid, choose, name="날씨", where="뒤", to="히어로")
|
||||||
|
assert (await _visible(client, h, pid))[0] == "날씨"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_꺼진_부분은_한_칸씩_옮기지_않는다(client, auth_headers, choose, db_engine):
|
||||||
|
"""화면에 없는 부분은 '한 칸 위' 가 어디인지 없다."""
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
choose({"actions": [{"tool": "toggle_section", "args": {"name": "영상", "enabled": "false"}}], "message": ""})
|
||||||
|
await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "영상 빼줘"})
|
||||||
|
body = await _move(client, h, pid, choose, name="영상", where="위로", count="1")
|
||||||
|
assert body["rejected"] is True
|
||||||
|
assert "꺼져" in body["reply"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_예전처럼_to_만_와도_옮긴다(client, auth_headers, choose, db_engine):
|
||||||
|
"""where 가 비면 to 로 읽는다 — '맨 위' · '맨 아래' · 이름(그 다음으로)."""
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
await _move(client, h, pid, choose, name="날씨", where="", count="", to="소개")
|
||||||
|
order = await _visible(client, h, pid)
|
||||||
|
assert order[order.index("소개") + 1] == "날씨"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 섹션 숨기기 — 여러 개 · 숨긴 목록 ────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_여러_부분을_한꺼번에_끈다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
choose({"actions": [{"tool": "toggle_section", "args": {"name": "사진 갤러리, 날씨", "enabled": "false"}}], "message": ""})
|
||||||
|
body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "갤러리랑 날씨 숨겨줘"})).json()
|
||||||
|
assert body.get("rejected") is not True, body["reply"]
|
||||||
|
order = await _visible(client, h, pid)
|
||||||
|
assert "사진 갤러리" not in order and "날씨" not in order
|
||||||
|
assert body["reply"].count(runtime.REPUBLISH_NOTICE) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("names, bad", [("사진 갤러리, 없는섹션", "없는섹션"), ("사진 갤러리, 히어로", "히어로")])
|
||||||
|
async def test_여럿_중_하나라도_안_되면_아무것도_바꾸지_않는다(names, bad, client, auth_headers, choose, db_engine):
|
||||||
|
"""★ 일부만 끄면 사장님은 무엇이 꺼졌는지 다시 확인해야 한다. 한 요청은 한꺼번에 되거나 안 된다."""
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
before = await _visible(client, h, pid)
|
||||||
|
choose({"actions": [{"tool": "toggle_section", "args": {"name": names, "enabled": "false"}}], "message": ""})
|
||||||
|
body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "숨겨줘"})).json()
|
||||||
|
assert body["rejected"] is True
|
||||||
|
assert bad in body["reply"]
|
||||||
|
assert await _visible(client, h, pid) == before
|
||||||
|
|
||||||
|
|
||||||
|
async def test_구성_목록은_보이는_순서에_번호를_붙이고_꺼진_것을_따로_모은다(client, auth_headers, choose, db_engine):
|
||||||
|
h, pid = await seed(client, auth_headers)
|
||||||
|
choose({"actions": [{"tool": "toggle_section", "args": {"name": "날씨", "enabled": "false"}}], "message": ""})
|
||||||
|
await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "날씨 빼줘"})
|
||||||
|
|
||||||
|
choose({"actions": [{"tool": "list_sections", "args": {}}], "message": ""})
|
||||||
|
reply = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "구성 보여줘"})).json()["reply"]
|
||||||
|
assert "1. 소개" in reply
|
||||||
|
assert "꺼져 있는 부분: 날씨" in reply
|
||||||
|
|
||||||
|
choose({"actions": [{"tool": "list_sections", "args": {"only": "꺼진"}}], "message": ""})
|
||||||
|
reply = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "숨긴 거 뭐 있어"})).json()["reply"]
|
||||||
|
assert "날씨" in reply
|
||||||
|
assert "1. 소개" not in reply
|
||||||
|
|
||||||
|
|
||||||
|
def test_옮기기_인자는_응답_스키마에_있다():
|
||||||
|
from services.prompts import agent as prompt
|
||||||
|
|
||||||
|
args = prompt.RESPONSE_SCHEMA["properties"]["actions"]["items"]["properties"]["args"]
|
||||||
|
for name in ("where", "count", "only"):
|
||||||
|
assert name in args["properties"] and name in args["required"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_프롬프트는_고정된_부분을_알려준다():
|
||||||
|
from services.prompts import agent as prompt
|
||||||
|
|
||||||
|
sections = [{"id": "hero", "name": "히어로", "enabled": True, "locked": True},
|
||||||
|
{"id": "social", "name": "SNS 게시글", "enabled": True, "locked": False}]
|
||||||
|
text_ = prompt.build_prompt(place_name="가게", tools=tools.describe(), fields=[], facts=[],
|
||||||
|
sections=sections, photos=[], message="안녕", pinned=tools.PINNED)
|
||||||
|
assert "히어로 [끄기 불가] [항상 맨 위]" in text_
|
||||||
|
assert "SNS 게시글 [항상 맨 아래]" in text_
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user