diff --git a/docs/AGENT.md b/docs/AGENT.md index 6b92d46..0d13509 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -137,7 +137,7 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 ### 페이지 구성 (2026-09-28) -"후기 빼줘" · "사진 갤러리 맨 위로" 처럼 **화면 구성**을 바꾼다. 구성은 `sites.theme.sections` +"날씨 빼줘" · "사진 갤러리 맨 위로" 처럼 **화면 구성**을 바꾼다. 구성은 `sites.theme.sections` 배열 하나이고, **배열 순서가 곧 발행본의 섹션 순서**다. ★ 목록은 `site_payload._sections` 를 **그대로 쓴다** — 발행본이 쓰는 바로 그 함수다. @@ -152,6 +152,38 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 ★ **`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) "객실 사진 내려줘" · "대표 사진 수영장으로 바꿔줘". 지목은 Vision 이 만든 **라벨·alt** 로 한다. diff --git a/solution/backend/services/agent/runtime.py b/solution/backend/services/agent/runtime.py index e539774..9199660 100644 --- a/solution/backend/services/agent/runtime.py +++ b/solution/backend/services/agent/runtime.py @@ -108,6 +108,7 @@ async def _choose(place, fields, facts, sections, photos, message) -> dict: sections=sections, photos=photos, message=message, + pinned=registry.PINNED, ), response_schema=prompt.RESPONSE_SCHEMA, temperature=0.0, diff --git a/solution/backend/services/agent/tools.py b/solution/backend/services/agent/tools.py index 00fb618..443246e 100644 --- a/solution/backend/services/agent/tools.py +++ b/solution/backend/services/agent/tools.py @@ -321,61 +321,204 @@ def _switch(raw: str) -> bool | None: return None -def _layout_line(row: dict) -> str: - mark = "켜짐" if row["enabled"] else "꺼짐" - lock = " (항상 켜짐)" if row["locked"] else "" - return f"· {row['name']} — {mark}{lock}" +# ★ 발행본(site/src/pages/HomePage.tsx)은 이 둘을 배열 순서와 상관없이 그린다 — 히어로는 늘 +# 맨 위, SNS 게시글은 늘 맨 아래. 옮기게 두면 "옮겼습니다" 라고 말하는데 화면은 그대로다. +PINNED = {"hero": "맨 위", "social": "맨 아래"} + +# 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: + """보이는 순서에 번호를 붙이고, 꺼진 것은 따로 모은다. + + ★ 번호는 move_section 의 'N번째' 와 같은 순서다(_visible_rows) — 목록에서 본 번호로 + 말했는데 다른 자리로 가면 사장님은 기능이 고장난 줄 안다.""" rows, _theme = await _sections_of(ctx) - body = "\n".join(_layout_line(r) for r in rows) - return f"지금 홈페이지는 위에서부터 이 순서입니다.\n{body}" + hidden = [r["name"] for r in rows if not r["enabled"]] + 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: + """켜거나 끈다. name 에 쉼표로 여럿을 받는다("사진 갤러리, 날씨"). + + ★ 여럿 중 하나라도 못 찾거나 끌 수 없으면 **아무것도 바꾸지 않는다.** 일부만 끄면 + 사장님은 무엇이 꺼졌는지 다시 확인해야 한다 — 한 요청은 한꺼번에 되거나 안 된다.""" rows, theme = await _sections_of(ctx) - row = _find_section(rows, _arg(args, "name")) - if row is None: + wanted = [w.strip() for w in re.split(r"[,·、]", _arg(args, "name")) if w.strip()] + 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("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.") + names = ", ".join(r["name"] for r in found) on = _switch(_arg(args, "enabled")) if on is None: - raise ToolRejected(f"{row['name']} 을(를) 켤지 끌지 알려 주세요.") + raise ToolRejected(f"{names} 을(를) 켤지 끌지 알려 주세요.") # ★ 잠긴 섹션은 끌 수 없다. SEO·필수 마크업 때문에 잠긴 것이라, 끄면 발행 게이트에 걸린다 # (site_payload._sections 가 어차피 켜서 내보낸다 — 화면만 거짓말하게 된다). - if row["locked"] and not on: - raise ToolRejected(f"{row['name']} 은(는) 홈페이지에 꼭 있어야 하는 부분이라 끌 수 없어요.") - if row["enabled"] == on: - return Unchanged(f"{row['name']} 은(는) 이미 {'켜져' if on else '꺼져'} 있어요.") + locked = [r["name"] for r in found if r["locked"]] + if locked and not on: + tail = " 아무것도 바꾸지 않았어요." if len(found) > 1 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) - 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: - """★ 배열 순서가 곧 발행본의 섹션 순서다(site_payload._sections).""" + """★ 배열 순서가 곧 발행본의 섹션 순서다(site_payload._sections). + + where: 맨 위 · 맨 아래 · 앞 · 뒤 · 위로/아래로(count 칸) · 번째(count 번째) · 바꾸기. + where 가 비면 예전 표기(to 에 '맨 위' · '맨 아래' · 그 뒤에 올 이름)로 읽는다.""" rows, theme = await _sections_of(ctx) row = _find_section(rows, _arg(args, "name")) if row is None: raise ToolRejected("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.") + if row["id"] in PINNED: + raise ToolRejected(f"{row['name']} 은(는) 항상 {PINNED[row['id']]}에 서는 부분이라 옮길 수 없어요.") to = _arg(args, "to") - rest = [r for r in rows if r["id"] != row["id"]] - if to in ("맨 위", "처음", "위", "top", "first"): - moved, where = [row] + rest, "맨 위로" - elif to in ("맨 아래", "마지막", "아래", "bottom", "last"): - moved, where = rest + [row], "맨 아래로" + where = _where(_arg(args, "where")) or _LEGACY_TO.get(to) or ("after" if to else None) + if where is None: + raise ToolRejected("어디로 옮길지 알려 주세요. 예) 맨 위로 · 소개 다음으로 · 한 칸 위로 · 세 번째로") + + 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: - anchor = _find_section(rest, to) + anchor = _find_section([r for r in rows if r["id"] != row["id"]], to) if anchor is None: raise ToolRejected("어디로 옮길지 못 찾았어요. '소개 다음으로' 처럼 말씀해 주세요.") - at = rest.index(anchor) + 1 - moved, where = rest[:at] + [row] + rest[at:], f"{anchor['name']} 다음으로" + moved, said = _move_by_anchor(rows, row, anchor, where) 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", grade=ToolGrade.READ, title="화면 구성 보기", - summary="홈페이지가 어떤 부분들로 어떤 순서로 되어 있는지 보여준다.", + summary="홈페이지가 어떤 부분들로 어떤 순서로 되어 있는지, 무엇이 꺼져 있는지 보여준다.", + args={"only": "꺼진 부분만 보려면 '꺼진'(선택)"}, run=_list_sections, ), Tool( @@ -556,8 +700,8 @@ REGISTRY: dict[str, Tool] = { republish=True, describe=_describe_toggle, target=("name",), - summary="홈페이지의 한 부분을 켜거나 끈다(예: 후기, 사진 갤러리, 예약 안내).", - args={"name": "그 부분의 이름", "enabled": "켜면 true, 끄면 false"}, + summary="홈페이지의 부분을 켜거나 끈다(숨기기). 여러 부분을 한꺼번에 할 수 있다.", + args={"name": "그 부분의 이름 — 여럿이면 쉼표로 이어서", "enabled": "켜면 true, 끄면 false"}, run=_toggle_section, ), Tool( @@ -567,7 +711,12 @@ REGISTRY: dict[str, Tool] = { describe=_describe_move, target=("name",), summary="홈페이지에서 한 부분의 위치(순서)를 바꾼다.", - args={"name": "옮길 부분의 이름", "to": "'맨 위' · '맨 아래' · 또는 그 뒤에 올 부분의 이름"}, + args={ + "name": "옮길 부분의 이름", + "where": "'맨 위' · '맨 아래' · '앞' · '뒤' · '위로' · '아래로' · '번째' · '바꾸기' 중 하나", + "to": "앞·뒤·바꾸기의 기준이 되는 부분의 이름", + "count": "위로·아래로는 칸 수, 번째는 순번(숫자)", + }, run=_move_section, ), Tool( diff --git a/solution/backend/services/prompts/agent.py b/solution/backend/services/prompts/agent.py index 751447c..aa8a706 100644 --- a/solution/backend/services/prompts/agent.py +++ b/solution/backend/services/prompts/agent.py @@ -29,8 +29,11 @@ _ACTION = { "name": {"type": "string"}, "to": {"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"], @@ -72,8 +75,19 @@ def _field_line(field: dict) -> str: 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], - sections: list[dict], photos: list[str], message: str) -> str: + sections: list[dict], photos: list[str], message: str, pinned: dict | None = None) -> str: """사장님 발화 → 도구 하나. ★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면 @@ -96,6 +110,8 @@ def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], fact 할 수 있는 것과 섞여 있으면 할 수 있는 것은 actions 에, 할 수 없는 것은 skipped 에 담는다. 말없이 빠뜨리지 않는다. 할 수 없는 요청이 없으면 skipped 는 빈 배열이다. - 같은 것을 고쳐 말하면("3시… 아니 4시로") 마지막 것 하나만 담는다. +- 여러 부분을 한꺼번에 켜거나 끄면 toggle_section 하나에 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)} 홈페이지를 이루는 부분들 — 위에서부터의 순서다(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 은 이 중 하나): {chr(10).join(photos) if photos else "(없음)"} diff --git a/solution/backend/tests/test_agent_runtime.py b/solution/backend/tests/test_agent_runtime.py index 2f4e7dd..c6f5f74 100644 --- a/solution/backend/tests/test_agent_runtime.py +++ b/solution/backend/tests/test_agent_runtime.py @@ -879,3 +879,185 @@ def test_응답_스키마에_못_한_요청_칸이_있다(): assert "skipped" in prompt.RESPONSE_SCHEMA["properties"] 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_