[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:
민헌 2026-09-29 15:49:20 +09:00
parent 4b48be7e81
commit 36b818eda3
5 changed files with 413 additions and 33 deletions

View File

@ -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** 로 한다.

View File

@ -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,

View File

@ -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(

View File

@ -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 "(없음)"}

View File

@ -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_