[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:
parent
e35ce311d3
commit
ea9ae1ac2a
@ -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`
|
||||
만 바꾸면 색은 옛것이 남아 "레이아웃은 새것, 색은 옛것" 이 된다 — 조용히 틀리는 종류다.
|
||||
하려면 그 레지스트리를 공유 단일 출처로 옮기는 작업이 먼저다.
|
||||
|
||||
★ **등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인
|
||||
절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.
|
||||
|
||||
|
||||
@ -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초 벽을 콜백으로 넘는다
|
||||
|
||||
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()]
|
||||
|
||||
@ -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}'''
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user