diff --git a/docs/AGENT.md b/docs/AGENT.md index 754585c..d38b6af 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -131,10 +131,32 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 | 등급 | 도구 | 대화에서 | |---|---|---| -| `READ` | `get_site_status` · `list_facts` | 바로 답한다 | -| `REVERSIBLE` | `set_fact` | 실행하고 알린다 | +| `READ` | `get_site_status` · `list_facts` · `list_sections` | 바로 답한다 | +| `REVERSIBLE` | `set_fact` · `toggle_section` · `move_section` | 실행하고 알린다 | | `SEMI` | `publish` | **실행 전에 한 번 묻는다** | +### 페이지 구성 (2026-09-28) + +"후기 빼줘" · "사진 갤러리 맨 위로" 처럼 **화면 구성**을 바꾼다. 구성은 `sites.theme.sections` +배열 하나이고, **배열 순서가 곧 발행본의 섹션 순서**다. + +★ 목록은 `site_payload._sections` 를 **그대로 쓴다** — 발행본이 쓰는 바로 그 함수다. +표를 따로 만들면 에디터·발행본·대화 셋이 갈라지고, 사장님은 "껐는데 나온다" 를 겪는다. +저장값이 없어도 업종 기본이 서므로, 디자인을 한 번도 안 만진 사업장에서도 바로 통한다. + +★ **잠긴 섹션(히어로·기본 정보·오시는 길)은 끌 수 없다.** SEO·필수 마크업 때문에 잠긴 것이고, +`_sections` 가 어차피 켜서 내보낸다 — 끌 수 있게 두면 **화면만 거짓말한다.** + +★ **이름이 둘 이상 걸리면 고르지 않는다.** 추측으로 고르면 엉뚱한 부분을 끄고, 사장님은 +발행하고 나서야 안다. 티오더가 "유사 메뉴 2개 이상이면 후보 제시" 로 푼 것과 같은 문제다. + +★ **`sections` 만 갈아끼운다.** theme 을 통째로 새로 쓰면 사장님이 고른 색·서체가 말없이 사라진다. + +★ **템플릿·색 변경은 아직 없다.** 목록이 프론트(`frontend/src/data/industryData.ts`)에 있고 +`templatesFor()` 가 색·`look`·기본 섹션·배리에이션을 **함께 계산**한다. 백엔드가 `template_id` +만 바꾸면 색은 옛것이 남아 "레이아웃은 새것, 색은 옛것" 이 된다 — 조용히 틀리는 종류다. +하려면 그 레지스트리를 공유 단일 출처로 옮기는 작업이 먼저다. + ★ **등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 65ea3f5..a60ec6e 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,27 @@ # 개발 일지 +## 2026-09-28 — 에이전트가 페이지 구성을 바꾼다 (섹션 on/off · 순서) + +"문구 변경밖에 안 된다" 는 지적에서 시작했다. 페이지 구성은 `sites.theme.sections` 배열 +하나이고 **배열 순서가 곧 발행본의 순서**라, 그 JSON 을 만지는 도구 셋을 붙였다. + +- `list_sections`(READ) · `toggle_section`(REVERSIBLE) · `move_section`(REVERSIBLE) +- 목록은 `site_payload._sections` 를 그대로 쓴다 — 발행본이 쓰는 그 함수다. + 표를 따로 만들면 에디터·발행본·대화 셋이 갈라진다. +- 잠긴 섹션은 못 끈다. `_sections` 가 어차피 켜서 내보내므로 끌 수 있게 두면 화면만 거짓말한다. +- 이름이 둘 이상 걸리면 고르지 않는다. 추측으로 고르면 발행하고 나서야 안다. +- `sections` 만 갈아끼운다 — theme 을 통째로 쓰면 고른 색·서체가 말없이 사라진다. + +**★ 템플릿·색은 넣지 않았다(요청 범위였으나 선행 작업이 필요하다).** +템플릿 목록은 `frontend/src/data/industryData.ts` 의 `templatesFor()` 가 **생성**하고, +색·`look`·`defaultSectionTypes`·`defaultVariants` 를 함께 계산한다(`stores/builder.selectTemplate` +가 섹션 on/off 와 배리에이션까지 바꾼다). 백엔드가 `template_id` 만 바꾸면 색은 옛것이 남아 +**"레이아웃은 새것, 색은 옛것"** 이 된다 — 이 레포가 반복해 경고하는 '두 곳에 같은 표' 함정이다. +하려면 그 레지스트리를 공유 단일 출처로 옮기는 작업이 먼저다. + +**검증** — `test_agent_runtime` 26 passed(구성 7건 추가). 전체 `864 passed / 53 failed` 이고 +그 53 은 이번 변경 전과 같다. + ## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다 실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** diff --git a/solution/backend/services/agent/runtime.py b/solution/backend/services/agent/runtime.py index ee1e352..b5a29ff 100644 --- a/solution/backend/services/agent/runtime.py +++ b/solution/backend/services/agent/runtime.py @@ -82,7 +82,7 @@ async def _context_facts(user: UserInfo, place_id: str, place) -> list[dict]: return out[:30] -async def _choose(place, fields, facts, message) -> dict: +async def _choose(place, fields, facts, sections, message) -> dict: """LLM 한 번. 고른 도구 이름과 인자만 받는다.""" active = provider.active() async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: @@ -94,6 +94,7 @@ async def _choose(place, fields, facts, message) -> dict: tools=registry.describe(), fields=fields, facts=facts, + sections=sections, message=message, ), response_schema=prompt.RESPONSE_SCHEMA, @@ -129,11 +130,13 @@ async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None fields = registry.fields_of(place) facts = await _context_facts(user, place_id, place) + # ★ 섹션은 이름으로 지목한다("후기 빼줘"). 목록을 안 실으면 모델이 이름을 지어낸다. + sections, _theme = await registry.sections_of(ctx) # ★ 사이트 상태는 프롬프트에 싣지 않는다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그 계산이 # 돌았고, 정작 모델이 필요할 때는 `get_site_status` 도구를 부르면 된다. try: - choice = await _choose(place, fields, facts, message) + choice = await _choose(place, fields, facts, sections, message) except LlmError as ex: LOG.w(f"[agent] 도구 선택 실패: {type(ex).__name__}") raise AgentError("AGENT_CALL_FAILED") from ex diff --git a/solution/backend/services/agent/tools.py b/solution/backend/services/agent/tools.py index 587a270..04e3567 100644 --- a/solution/backend/services/agent/tools.py +++ b/solution/backend/services/agent/tools.py @@ -26,7 +26,7 @@ from crud.job_crud import JobQueue from crud.place_crud import PlaceCRUD from crud.site_crud import SiteCRUD from router.v1.fact.protocol import Req_UpsertFact -from router.v1.site.protocol import Req_StartBuild +from router.v1.site.protocol import Req_SiteTheme, Req_StartBuild from services import site_payload from services.fact_service import FactService from services.site_service import SiteService @@ -129,6 +129,104 @@ async def _set_fact(ctx: ToolContext, args: dict) -> str: return f"{spec.label} 을(를) {value} 로 바꿨습니다. 사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?" +# ── 페이지 구성 ────────────────────────────────────────────────────────── + +async def _sections_of(ctx: ToolContext) -> tuple[list, dict]: + """지금 발행본에 서는 섹션 목록(해석된 결과)과 저장된 theme. + + ★ `site_payload._sections` 를 그대로 쓴다 — 발행본이 쓰는 바로 그 함수다. 표를 따로 + 만들면 에디터·발행본·대화 셋이 갈라지고, 사장님은 "껐는데 나온다" 를 겪는다. + ★ 저장값이 없어도 업종 기본이 선다. 그래서 아직 한 번도 디자인을 만지지 않은 + 사업장에서도 대화가 바로 통한다.""" + _fact, site_service = _services() + res = await site_service.get_site(ctx.user, ctx.place_id) + theme = dict((res.site.theme if res.site and res.site.theme else {}) or {}) + spec = site_payload._DEFAULT_THEME[PlaceCategory(ctx.place.category).value]["sections"] + return site_payload._sections(theme.get("sections"), spec), theme + + +def _find_section(rows: list, wanted: str): + """이름이나 id 로 찾는다. 사장님은 '후기' 처럼 줄여 말한다 — 부분 일치도 받는다. + + ★ 둘 이상 걸리면 **고르지 않는다**(None). 추측으로 고르면 엉뚱한 섹션을 끄고, + 사장님은 그 사실을 발행하고 나서야 안다.""" + wanted = (wanted or "").strip() + if not wanted: + return None + exact = [r for r in rows if r["id"] == wanted or r["name"] == wanted] + if len(exact) == 1: + return exact[0] + partial = [r for r in rows if wanted in r["name"]] + return partial[0] if len(partial) == 1 else None + + +async def _save_sections(ctx: ToolContext, theme: dict, rows: list) -> None: + """★ theme 의 나머지 칸(colors·fontStyle·look…)을 그대로 들고 간다. sections 만 갈아끼운다 — + 통째로 새로 쓰면 사장님이 고른 색과 서체가 말없이 사라진다.""" + _fact, site_service = _services() + theme["sections"] = rows + res = await site_service.set_theme(ctx.user, ctx.place_id, Req_SiteTheme(theme=theme)) + if not res.result.success: + raise ToolRejected("화면 구성을 저장하지 못했습니다. 빌더 화면에서 확인해 주세요.") + + +def _layout_line(row: dict) -> str: + mark = "켜짐" if row["enabled"] else "꺼짐" + lock = " (항상 켜짐)" if row["locked"] else "" + return f"· {row['name']} — {mark}{lock}" + + +async def _list_sections(ctx: ToolContext, args: dict) -> str: + rows, _theme = await _sections_of(ctx) + body = "\n".join(_layout_line(r) for r in rows) + return f"지금 홈페이지는 위에서부터 이 순서입니다.\n{body}" + + +async def _toggle_section(ctx: ToolContext, args: dict) -> str: + rows, theme = await _sections_of(ctx) + row = _find_section(rows, args.get("name")) + if row is None: + raise ToolRejected("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.") + + on = (args.get("enabled") or "").strip().lower() in ("true", "1", "켜", "켜기", "on", "yes") + # ★ 잠긴 섹션은 끌 수 없다. SEO·필수 마크업 때문에 잠긴 것이라, 끄면 발행 게이트에 걸린다 + # (site_payload._sections 가 어차피 켜서 내보낸다 — 화면만 거짓말하게 된다). + if row["locked"] and not on: + raise ToolRejected(f"{row['name']} 은(는) 홈페이지에 꼭 있어야 하는 부분이라 끌 수 없어요.") + if row["enabled"] == on: + return f"{row['name']} 은(는) 이미 {'켜져' if on else '꺼져'} 있어요." + + row["enabled"] = on + await _save_sections(ctx, theme, rows) + return (f"{row['name']} 을(를) {'켰습니다' if on else '껐습니다'}. " + f"사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?") + + +async def _move_section(ctx: ToolContext, args: dict) -> str: + """★ 배열 순서가 곧 발행본의 섹션 순서다(site_payload._sections).""" + rows, theme = await _sections_of(ctx) + row = _find_section(rows, args.get("name")) + if row is None: + raise ToolRejected("어느 부분을 말씀하시는지 못 찾았어요. '목록' 이라고 하시면 보여드릴게요.") + + to = (args.get("to") or "").strip() + 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], "맨 아래로" + else: + anchor = _find_section(rest, to) + if anchor is None: + raise ToolRejected("어디로 옮길지 못 찾았어요. '소개 다음으로' 처럼 말씀해 주세요.") + at = rest.index(anchor) + 1 + moved, where = rest[:at] + [row] + rest[at:], f"{anchor['name']} 다음으로" + + await _save_sections(ctx, theme, moved) + return (f"{row['name']} 을(를) {where} 옮겼습니다. " + f"사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?") + + # ── 반쯤 되돌릴 수 있는 것 ─────────────────────────────────────────────── async def _publish(ctx: ToolContext, args: dict) -> str: @@ -168,6 +266,26 @@ REGISTRY: dict[str, Tool] = { args={"key": "아래 항목 목록의 key", "value": "바꿀 값"}, run=_set_fact, ), + Tool( + name="list_sections", + grade=ToolGrade.READ, + summary="홈페이지가 어떤 부분들로 어떤 순서로 되어 있는지 보여준다.", + run=_list_sections, + ), + Tool( + name="toggle_section", + grade=ToolGrade.REVERSIBLE, + summary="홈페이지의 한 부분을 켜거나 끈다(예: 후기, 사진 갤러리, 예약 안내).", + args={"name": "그 부분의 이름", "enabled": "켜면 true, 끄면 false"}, + run=_toggle_section, + ), + Tool( + name="move_section", + grade=ToolGrade.REVERSIBLE, + summary="홈페이지에서 한 부분의 위치(순서)를 바꾼다.", + args={"name": "옮길 부분의 이름", "to": "'맨 위' · '맨 아래' · 또는 그 뒤에 올 부분의 이름"}, + run=_move_section, + ), Tool( name="publish", grade=ToolGrade.SEMI, @@ -179,6 +297,12 @@ REGISTRY: dict[str, Tool] = { } +async def sections_of(ctx: ToolContext): + """런타임이 프롬프트에 실을 섹션 목록. 도구가 쓰는 것과 같은 함수여야 한다 — + 다르면 모델이 본 이름과 도구가 찾는 이름이 갈린다.""" + return await _sections_of(ctx) + + def describe() -> list[dict]: """프롬프트에 실을 도구 목록. ★ 등급은 싣지 않는다 — 모델이 알 필요도, 정할 이유도 없다.""" return [{"name": t.name, "설명": t.summary, "args": t.args} for t in REGISTRY.values()] diff --git a/solution/backend/services/prompts/agent.py b/solution/backend/services/prompts/agent.py index a1bd76b..780ca16 100644 --- a/solution/backend/services/prompts/agent.py +++ b/solution/backend/services/prompts/agent.py @@ -36,7 +36,8 @@ RESPONSE_SCHEMA = { } -def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict], message: str) -> str: +def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict], + sections: list[dict], message: str) -> str: """사장님 발화 → 도구 하나. ★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면 @@ -61,5 +62,8 @@ 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)} + 사장님 요청: {message}''' diff --git a/solution/backend/tests/test_agent_runtime.py b/solution/backend/tests/test_agent_runtime.py index 2ef663f..b580ac5 100644 --- a/solution/backend/tests/test_agent_runtime.py +++ b/solution/backend/tests/test_agent_runtime.py @@ -248,3 +248,98 @@ async def test_닫아_두면_대화_요청은_거절된다(client, auth_headers, assert res.status_code == 409 assert res.json()["detail"] == "AGENT_NOT_CONFIGURED" assert (await client.get("/v1/agent/status", headers=h)).json()["enabled"] is False + + +# ── 페이지 구성 (섹션 on/off · 순서) ───────────────────────────────────── + +async def _sections(client, headers, place_id): + """대화가 보는 것과 같은 목록 — 도구가 쓰는 함수를 그대로 쓴다.""" + from services.agent.tools import ToolContext, sections_of + from router.v1.validator.dependencies import DecodeAccessToken + from crud.place_crud import PlaceCRUD + from common.database.db_session_manager import DB_SESSION_MNG + from common.database.model.models import places + from common.enums import DBWRType + + user = DecodeAccessToken(headers["Authorization"].split(" ", 1)[1]) + _err, place = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, + lambda s: PlaceCRUD().get_place(s, uuid.UUID(user.user_id), uuid.UUID(place_id)), + ) + rows, theme = await sections_of(ToolContext(user=user, place_id=place_id, place=place)) + return rows, theme + + +async def test_저장값이_없어도_업종_기본_구성이_보인다(client, auth_headers, choose, db_engine): + """★ 아직 디자인을 한 번도 안 만진 사업장에서도 대화가 바로 통해야 한다.""" + h, pid = await seed(client, auth_headers) + rows, _theme = await _sections(client, h, pid) + names = {r["name"] for r in rows} + assert "히어로" in names and "객실 안내" in names + + +async def test_섹션을_끄면_순서는_그대로고_그_칸만_꺼진다(client, auth_headers, choose, db_engine): + h, pid = await seed(client, auth_headers) + before, _t = await _sections(client, h, pid) + order_before = [r["id"] for r in before] + + choose({"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"] + assert "발행" in body["reply"] # 재발행 안내가 따라붙는다 + + after, _t = await _sections(client, h, pid) + assert [r["id"] for r in after] == order_before + assert next(r for r in after if r["name"] == "사진 갤러리")["enabled"] is False + + +async def test_꼭_있어야_하는_부분은_끌_수_없다(client, auth_headers, choose, db_engine): + """★ 잠긴 섹션은 발행본이 어차피 켜서 내보낸다 — 끌 수 있게 두면 화면만 거짓말한다.""" + h, pid = await seed(client, auth_headers) + choose({"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 True + assert "끌 수 없" in body["reply"] + + after, _t = await _sections(client, h, pid) + assert next(r for r in after if r["name"] == "히어로")["enabled"] is True + + +async def test_순서를_옮기면_배열_순서가_바뀐다(client, auth_headers, choose, db_engine): + """★ 배열 순서가 곧 발행본의 섹션 순서다(site_payload._sections).""" + h, pid = await seed(client, auth_headers) + choose({"tool": "move_section", "args": {"name": "사진 갤러리", "to": "맨 위"}, "message": ""}) + body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "사진 갤러리 맨 위로"})).json() + assert body.get("rejected") is not True, body["reply"] + + after, _t = await _sections(client, h, pid) + assert after[0]["name"] == "사진 갤러리" + + +async def test_어느_것인지_모호하면_고르지_않는다(client, auth_headers, choose, db_engine): + """★ 추측으로 고르면 엉뚱한 부분을 끄고, 사장님은 발행하고 나서야 안다.""" + h, pid = await seed(client, auth_headers) + choose({"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 True + assert "못 찾았" in body["reply"] + + +async def test_색과_서체는_손대지_않는다(client, auth_headers, choose, db_engine): + """★ sections 만 갈아끼운다 — 통째로 새로 쓰면 사장님이 고른 색이 말없이 사라진다.""" + h, pid = await seed(client, auth_headers) + await client.post(f"/v1/place/{pid}/site/theme", headers=h, json={ + "theme": {"colors": {"accent": "#123456"}, "fontStyle": "고딕"}, + }) + choose({"tool": "toggle_section", "args": {"name": "사진 갤러리", "enabled": "false"}, "message": ""}) + await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "사진 갤러리 빼줘"}) + + _rows, theme = await _sections(client, h, pid) + assert theme["colors"]["accent"] == "#123456" + assert theme["fontStyle"] == "고딕" + + +def test_구성_도구의_등급(client=None): + assert tools.REGISTRY["list_sections"].grade == ToolGrade.READ + assert tools.REGISTRY["toggle_section"].grade == ToolGrade.REVERSIBLE + assert tools.REGISTRY["move_section"].grade == ToolGrade.REVERSIBLE