[feat] solution/backend: 에이전트가 페이지 구성을 바꾼다 — 섹션 on/off·순서

"문구 변경밖에 안 된다" 는 지적에서 시작했다. 페이지 구성은 sites.theme.sections
배열 하나이고 배열 순서가 곧 발행본의 순서라, 그 JSON 을 만지는 도구 셋을 붙였다.

- list_sections(READ) · toggle_section(REVERSIBLE) · move_section(REVERSIBLE)
- 목록은 site_payload._sections 를 그대로 쓴다 — 발행본이 쓰는 그 함수다.
  표를 따로 만들면 에디터·발행본·대화 셋이 갈라지고 "껐는데 나온다" 가 된다
- 잠긴 섹션은 못 끈다. _sections 가 어차피 켜서 내보내므로 끌 수 있게 두면
  화면만 거짓말한다
- 이름이 둘 이상 걸리면 고르지 않는다 — 추측으로 고르면 발행하고 나서야 안다
- sections 만 갈아끼운다 — theme 을 통째로 쓰면 고른 색·서체가 말없이 사라진다

★ 템플릿·색은 넣지 않았다. templatesFor() 가 색·look·기본 섹션·배리에이션을 함께
계산해서, 백엔드가 template_id 만 바꾸면 "레이아웃은 새것, 색은 옛것" 이 된다 —
레지스트리를 공유 단일 출처로 옮기는 작업이 먼저다(docs/AGENT.md).

test_agent_runtime 26 passed(구성 7건 추가).
전체 864 passed / 53 failed — 53 은 이번 변경 전과 동일. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-28 11:50:38 +09:00
parent e35ce311d3
commit ea9ae1ac2a
6 changed files with 276 additions and 6 deletions

View File

@ -131,10 +131,32 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
| 등급 | 도구 | 대화에서 | | 등급 | 도구 | 대화에서 |
|---|---|---| |---|---|---|
| `READ` | `get_site_status` · `list_facts` | 바로 답한다 | | `READ` | `get_site_status` · `list_facts` · `list_sections` | 바로 답한다 |
| `REVERSIBLE` | `set_fact` | 실행하고 알린다 | | `REVERSIBLE` | `set_fact` · `toggle_section` · `move_section` | 실행하고 알린다 |
| `SEMI` | `publish` | **실행 전에 한 번 묻는다** | | `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`
만 바꾸면 색은 옛것이 남아 "레이아웃은 새것, 색은 옛것" 이 된다 — 조용히 틀리는 종류다.
하려면 그 레지스트리를 공유 단일 출처로 옮기는 작업이 먼저다.
★ **등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 ★ **등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인
절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다. 절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.

View File

@ -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초 벽을 콜백으로 넘는다 ## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** 실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**

View File

@ -82,7 +82,7 @@ async def _context_facts(user: UserInfo, place_id: str, place) -> list[dict]:
return out[:30] return out[:30]
async def _choose(place, fields, facts, message) -> dict: async def _choose(place, fields, facts, sections, message) -> dict:
"""LLM 한 번. 고른 도구 이름과 인자만 받는다.""" """LLM 한 번. 고른 도구 이름과 인자만 받는다."""
active = provider.active() active = provider.active()
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
@ -94,6 +94,7 @@ async def _choose(place, fields, facts, message) -> dict:
tools=registry.describe(), tools=registry.describe(),
fields=fields, fields=fields,
facts=facts, facts=facts,
sections=sections,
message=message, message=message,
), ),
response_schema=prompt.RESPONSE_SCHEMA, 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) fields = registry.fields_of(place)
facts = await _context_facts(user, place_id, place) facts = await _context_facts(user, place_id, place)
# ★ 섹션은 이름으로 지목한다("후기 빼줘"). 목록을 안 실으면 모델이 이름을 지어낸다.
sections, _theme = await registry.sections_of(ctx)
# ★ 사이트 상태는 프롬프트에 싣지 않는다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그 계산이 # ★ 사이트 상태는 프롬프트에 싣지 않는다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그 계산이
# 돌았고, 정작 모델이 필요할 때는 `get_site_status` 도구를 부르면 된다. # 돌았고, 정작 모델이 필요할 때는 `get_site_status` 도구를 부르면 된다.
try: try:
choice = await _choose(place, fields, facts, message) choice = await _choose(place, fields, facts, sections, message)
except LlmError as ex: except LlmError as ex:
LOG.w(f"[agent] 도구 선택 실패: {type(ex).__name__}") LOG.w(f"[agent] 도구 선택 실패: {type(ex).__name__}")
raise AgentError("AGENT_CALL_FAILED") from ex raise AgentError("AGENT_CALL_FAILED") from ex

View File

@ -26,7 +26,7 @@ from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.site_crud import SiteCRUD from crud.site_crud import SiteCRUD
from router.v1.fact.protocol import Req_UpsertFact 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 import site_payload
from services.fact_service import FactService from services.fact_service import FactService
from services.site_service import SiteService from services.site_service import SiteService
@ -129,6 +129,104 @@ async def _set_fact(ctx: ToolContext, args: dict) -> str:
return f"{spec.label} 을(를) {value} 로 바꿨습니다. 사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?" 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: async def _publish(ctx: ToolContext, args: dict) -> str:
@ -168,6 +266,26 @@ REGISTRY: dict[str, Tool] = {
args={"key": "아래 항목 목록의 key", "value": "바꿀 값"}, args={"key": "아래 항목 목록의 key", "value": "바꿀 값"},
run=_set_fact, 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( Tool(
name="publish", name="publish",
grade=ToolGrade.SEMI, 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]: def describe() -> list[dict]:
"""프롬프트에 실을 도구 목록. ★ 등급은 싣지 않는다 — 모델이 알 필요도, 정할 이유도 없다.""" """프롬프트에 실을 도구 목록. ★ 등급은 싣지 않는다 — 모델이 알 필요도, 정할 이유도 없다."""
return [{"name": t.name, "설명": t.summary, "args": t.args} for t in REGISTRY.values()] return [{"name": t.name, "설명": t.summary, "args": t.args} for t in REGISTRY.values()]

View File

@ -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개 이상이면 ★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면
@ -61,5 +62,8 @@ 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 은 이 중 하나):
{chr(10).join(f"{s['name']}" + (" [끄기 불가]" if s['locked'] else "") + (" (꺼짐)" if not s['enabled'] else "") for s in sections)}
사장님 요청: 사장님 요청:
{message}''' {message}'''

View File

@ -248,3 +248,98 @@ async def test_닫아_두면_대화_요청은_거절된다(client, auth_headers,
assert res.status_code == 409 assert res.status_code == 409
assert res.json()["detail"] == "AGENT_NOT_CONFIGURED" assert res.json()["detail"] == "AGENT_NOT_CONFIGURED"
assert (await client.get("/v1/agent/status", headers=h)).json()["enabled"] is False 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