"맨 위 · 맨 아래 · 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>
793 lines
39 KiB
Python
793 lines
39 KiB
Python
"""도구 레지스트리 — 에이전트가 할 수 있는 일의 **전부**가 여기 있다.
|
|
|
|
★★ 도구는 반드시 `services/*` 를 통과한다. `crud`·`models` 를 직접 부르면 업종 스키마
|
|
검증 · 출처 필수 · 정정본 보호 · 소유자 범위가 통째로 사라지는데, **아무 증상이 없다** —
|
|
값은 들어가고 빌드는 성공하고 화면도 뜬다. `collect_service.store_facts` 가
|
|
"크롤러가 우회할 수 있는 뒷문을 만들지 않는다" 로 막아 둔 그 문이고, 에이전트에게만
|
|
열어 줄 이유가 없다.
|
|
|
|
★ 결과 문구는 도구가 만든다. LLM 이 쓰게 두면 **하지 않은 일을 했다고 말할 수 있고**,
|
|
사장님에게는 그 말이 사실로 보인다.
|
|
|
|
★ 등급은 여기서 못 박는다. LLM 이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 절차를
|
|
건너뛴다 — 되돌릴 수 없는 행위일수록 그 값을 모델에 맡기면 안 된다.
|
|
"""
|
|
|
|
import re
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Awaitable, Callable
|
|
|
|
from common.category_schema.loader import get_schema
|
|
from common.enums import ErrorType, PlaceCategory, SourceType
|
|
from common.models.gmodel import UserInfo
|
|
from crud.fact_crud import FactCRUD
|
|
from crud.job_crud import JobQueue
|
|
from crud.media_crud import MediaCRUD
|
|
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_SiteTheme, Req_StartBuild
|
|
from services import site_payload
|
|
from services.fact_service import FactService
|
|
from services.media_service import MediaService
|
|
from services.site_service import SiteService
|
|
|
|
|
|
class ToolGrade(str, Enum):
|
|
"""되돌릴 수 있느냐가 승인 강도를 정한다 — 분류가 아니라 동작을 가르는 값이다."""
|
|
|
|
READ = "READ" # 승인 없음
|
|
REVERSIBLE = "REVERSIBLE" # 실행하고 알린다. 사장님이 다시 고치면 된다
|
|
SEMI = "SEMI" # 실행 전에 한 번 묻는다(되돌릴 수는 있으나 그 사이 밖에서 읽힌다)
|
|
|
|
|
|
@dataclass
|
|
class ToolContext:
|
|
user: UserInfo
|
|
place_id: str
|
|
place: object
|
|
|
|
|
|
@dataclass
|
|
class Tool:
|
|
name: str
|
|
grade: ToolGrade
|
|
summary: str
|
|
args: dict = field(default_factory=dict)
|
|
run: Callable[[ToolContext, dict], Awaitable[str]] = None
|
|
# SEMI 도구가 실행 전에 사장님께 보일 문장.
|
|
confirm: str = ""
|
|
# ★ 이 도구가 바꾼 것은 **재발행해야 사이트에 반영된다.** 안내 문구는 도구가 아니라
|
|
# 런타임이 **한 번만** 붙인다 — 도구마다 문장에 박아 두면 한 발화로 셋을 고쳤을 때
|
|
# 같은 말이 세 번 나온다.
|
|
republish: bool = False
|
|
# 사장님께 "아직 안 했어요" 라고 알릴 때의 이름. describe 가 있으면 인자로 더 좁혀 말한다.
|
|
title: str = ""
|
|
describe: Callable[[ToolContext, dict], str] | None = None
|
|
# ★ 무엇을 겨누는가 — 이 인자들이 같으면 같은 요청이다. 런타임이 "3시… 아니 4시" 를
|
|
# 마지막 하나로 합칠 때 쓴다. None 이면 인자 전체가 같을 때만 같은 요청이다.
|
|
target: tuple[str, ...] | None = None
|
|
|
|
|
|
class Unchanged(str):
|
|
"""도구가 할 일이 없었다는 표시 — "이미 켜져 있어요".
|
|
|
|
★ 문자열 그대로 쓰이고, 런타임은 이 표시로 **바뀐 것이 없음**을 안다. 모르면 재발행 안내가
|
|
붙고 카톡에는 발행 대기까지 걸려, 사장님은 무언가 바뀐 줄 안다."""
|
|
|
|
|
|
def _services():
|
|
"""서비스는 매 호출 새로 만든다 — 라우터가 Depends 로 받는 것과 같은 수명이다.
|
|
|
|
★ Depends 기본값에 기대지 않고 의존을 손으로 넣는다. FastAPI 밖에서 부르면
|
|
기본값이 `Depends(...)` 객체 그대로라 서비스가 조용히 엉뚱한 것을 들고 돈다."""
|
|
place_crud = PlaceCRUD()
|
|
return FactService(FactCRUD(), place_crud), SiteService(SiteCRUD(), place_crud, JobQueue())
|
|
|
|
|
|
def _media_service() -> MediaService:
|
|
return MediaService(MediaCRUD(), PlaceCRUD())
|
|
|
|
|
|
def _arg(args: dict, name: str) -> str:
|
|
"""인자 하나를 문자열로. ★ 모델은 스키마를 어길 수 있다 — true 를 불리언으로, 요금을 숫자로
|
|
보낸다. `(args.get(x) or "").strip()` 은 거기서 AttributeError 로 죽고, 사장님에게는
|
|
"처리할 수 없어요" 로만 보인다."""
|
|
value = args.get(name)
|
|
return "" if value is None else str(value).strip()
|
|
|
|
|
|
# ── 값 형식 ─────────────────────────────────────────────────────────────
|
|
#
|
|
# ★ 저장 형식은 수집 어댑터와 같다 — bool "true"/"false" · time "HH:MM" · number 숫자만.
|
|
# 렌더러(shared/src/lib/facts.ts factBool)는 'true' 만 참으로 읽는다. "가능" 으로 저장하면
|
|
# 화면에는 원문 "가능" 이 뜨는데 구조화 데이터는 거짓이 된다 — 빌드도 성공하는 조용한 틀림이다.
|
|
# ★ 알아볼 수 없으면 저장하지 않고 되묻는다. 추측해서 넣은 값이 곧 1차 출처가 된다.
|
|
|
|
_TRUE_WORDS = {"true", "1", "y", "yes", "o", "가능", "있음", "있어요", "돼요", "됩니다", "허용", "네", "예"}
|
|
_FALSE_WORDS = {"false", "0", "n", "no", "x", "불가", "불가능", "없음", "없어요", "안돼요", "안됩니다", "금지", "아니요"}
|
|
|
|
# "15:00" · "오후 3시" · "15시 30분" · "3시 반"
|
|
_TIME = re.compile(r"^(오전|오후|아침|낮|저녁|밤|새벽)?(\d{1,2})(?::(\d{2})|시(?:(\d{1,2})분|(반))?)$")
|
|
_PM_WORDS = {"오후", "저녁", "밤"}
|
|
_AM_WORDS = {"오전", "아침", "새벽"}
|
|
|
|
# "2만 5천원" · "20,000원" · "1.5시간"
|
|
_NUMBER = re.compile(r"^(?:(\d+)만)?(?:(\d+)천)?(\d+(?:\.\d+)?)?$")
|
|
_NUMBER_UNITS = re.compile(r"(원|명|대|개|분|시간|실|석|인|층|평|㎡)$")
|
|
|
|
|
|
def _parse_bool(raw: str) -> str | None:
|
|
word = re.sub(r"\s+", "", raw.lower())
|
|
if word in _TRUE_WORDS:
|
|
return "true"
|
|
if word in _FALSE_WORDS:
|
|
return "false"
|
|
return None
|
|
|
|
|
|
def _parse_time(raw: str) -> tuple[str | None, bool]:
|
|
"""(HH:MM, 모호한가). 모호함은 틀림과 다르게 묻는다 — "오전인지 오후인지" 가 답을 끌어낸다.
|
|
|
|
★ 콜론 표기는 24시간으로 읽는다(프롬프트가 그렇게 시킨다). '3시' 처럼 한국어로 1~11시만
|
|
말하면 오전·오후를 모른다 — 체크인 03:00 을 넣으면 손님이 새벽에 온다."""
|
|
m = _TIME.fullmatch(re.sub(r"\s+", "", raw))
|
|
if not m:
|
|
return None, False
|
|
when, hour, minute = m.group(1), int(m.group(2)), int(m.group(3) or m.group(4) or (30 if m.group(5) else 0))
|
|
if when in _PM_WORDS and hour < 12:
|
|
hour += 12
|
|
elif when in _AM_WORDS and hour == 12:
|
|
hour = 0
|
|
elif when is None and m.group(3) is None and 1 <= hour <= 11:
|
|
return None, True
|
|
if hour > 23 or minute > 59:
|
|
return None, False
|
|
return f"{hour:02d}:{minute:02d}", False
|
|
|
|
|
|
def _parse_number(raw: str) -> str | None:
|
|
word = re.sub(r"[\s,]", "", raw)
|
|
if word == "무료":
|
|
return "0"
|
|
word = _NUMBER_UNITS.sub("", word)
|
|
m = _NUMBER.fullmatch(word)
|
|
if not word or not m:
|
|
return None
|
|
man, cheon, rest = m.group(1), m.group(2), m.group(3)
|
|
if (man or cheon) and rest and "." in rest:
|
|
return None
|
|
value = int(man or 0) * 10000 + int(cheon or 0) * 1000
|
|
if man or cheon:
|
|
return str(value + int(rest or 0))
|
|
number = float(rest)
|
|
return str(int(number)) if number.is_integer() else rest
|
|
|
|
|
|
def _normalize(spec, raw: str) -> str:
|
|
"""스키마 형식으로 맞춘 값. 못 맞추면 ToolRejected — 사장님께 보일 문장을 담는다."""
|
|
if spec.type == "bool":
|
|
value = _parse_bool(raw)
|
|
if value is None:
|
|
raise ToolRejected(f"{spec.label} 은(는) 가능·불가로만 정할 수 있어요. 어느 쪽인지 알려 주세요.")
|
|
return value
|
|
if spec.type == "time":
|
|
value, vague = _parse_time(raw)
|
|
if vague:
|
|
raise ToolRejected(f"{spec.label} 을(를) 오전인지 오후인지 알려 주세요. 예) 오후 3시")
|
|
if value is None:
|
|
raise ToolRejected(f"{spec.label} 은(는) 시각으로 알려 주세요. 예) 15:00")
|
|
return value
|
|
if spec.type == "number":
|
|
value = _parse_number(raw)
|
|
if value is None:
|
|
raise ToolRejected(f"{spec.label} 은(는) 숫자로 알려 주세요. 예) 20000")
|
|
return value
|
|
return raw
|
|
|
|
|
|
def _display(spec, value: str) -> str:
|
|
"""사장님께 말할 값. ★ 발행본과 같은 말로 한다(site/src/lib/derive.ts displayValue) —
|
|
"true 로 바꿨습니다" 는 사장님이 못 읽는다."""
|
|
if spec.type == "bool":
|
|
if spec.label.endswith("여부"):
|
|
return "있음" if value == "true" else "없음"
|
|
return "가능" if value == "true" else "불가"
|
|
if spec.type == "number" and spec.unit:
|
|
return f"{value}{spec.unit}"
|
|
return value
|
|
|
|
|
|
# ── 읽기 ────────────────────────────────────────────────────────────────
|
|
|
|
async def _get_site_status(ctx: ToolContext, args: dict) -> str:
|
|
_fact, site_service = _services()
|
|
res = await site_service.get_site(ctx.user, ctx.place_id)
|
|
site = res.site
|
|
if site is None or site.published_at is None:
|
|
return "아직 발행 전입니다. 준비가 되면 발행해 드릴게요."
|
|
# ★ 주소는 site_payload 의 함수로 만든다. 문자열로 조립하면 canonical 과 갈린다
|
|
# (CLAUDE.md '슬러그 규칙은 두 곳에 있고 같아야 한다').
|
|
url = f"{site_payload.publish_origin()}/s/{site_payload.publish_slug(ctx.place, site)}"
|
|
when = site.published_at.strftime("%Y-%m-%d %H:%M")
|
|
return f"발행되어 있습니다.\n주소: {url}\n마지막 발행: {when}"
|
|
|
|
|
|
async def _list_facts(ctx: ToolContext, args: dict) -> str:
|
|
fact_service, _site = _services()
|
|
res = await fact_service.list_facts(ctx.user, ctx.place_id, publishable_only=True)
|
|
rows = [f for f in (res.facts or []) if (f.value or "").strip()]
|
|
schema = get_schema(PlaceCategory(ctx.place.category))
|
|
keyword = _arg(args, "keyword")
|
|
if keyword:
|
|
rows = [f for f in rows if keyword in f.key or keyword in ((schema.get(f.key).label if schema.get(f.key) else ""))]
|
|
if not rows:
|
|
return "저장된 가게 정보가 아직 없습니다." if not keyword else f"'{keyword}' 로 찾은 정보가 없습니다."
|
|
lines = []
|
|
for f in rows[:20]:
|
|
spec = schema.get(f.key)
|
|
lines.append(f"· {spec.label if spec else f.key}: {f.value}")
|
|
more = f"\n(그 밖에 {len(rows) - 20}개 더 있습니다)" if len(rows) > 20 else ""
|
|
return "지금 저장된 정보입니다.\n" + "\n".join(lines) + more
|
|
|
|
|
|
# ── 되돌릴 수 있는 쓰기 ──────────────────────────────────────────────────
|
|
|
|
async def _set_fact(ctx: ToolContext, args: dict) -> str:
|
|
key, value = _arg(args, "key"), _arg(args, "value")
|
|
if not key or not value:
|
|
raise ToolRejected("무엇을 어떤 값으로 바꿀지 알려 주세요.")
|
|
|
|
schema = get_schema(PlaceCategory(ctx.place.category))
|
|
spec = schema.get(key)
|
|
# ★ LLM 이 없는 key 를 지어낼 수 있다. 스키마가 최종 판정이다.
|
|
if spec is None:
|
|
raise ToolRejected("그 항목은 이 가게에서 쓰지 않는 정보라 고칠 수 없어요.")
|
|
if spec.scope != "place":
|
|
raise ToolRejected(f"{spec.label} 은 객실·메뉴마다 다른 값이라 대화로는 아직 고칠 수 없어요.")
|
|
value = _normalize(spec, value)
|
|
|
|
fact_service, _site = _services()
|
|
# ★ FactService 를 그대로 통과시킨다. source_type=OWNER 라 노출값을 즉시 교체하고,
|
|
# 정정본 잠금·업종 스키마 검증이 전부 거기서 걸린다.
|
|
res = await fact_service.upsert_fact(
|
|
ctx.user, ctx.place_id, Req_UpsertFact(key=key, value=value, source_type=SourceType.OWNER)
|
|
)
|
|
if not res.result.success:
|
|
raise ToolRejected("그 값을 저장하지 못했습니다. 형식을 확인해 주세요.")
|
|
|
|
# ★ fact 는 바뀌었지만 사이트는 안 바뀐다. 이 한 줄이 빠지면 사장님은 반영된 줄 알고
|
|
# 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
|
return f"{spec.label} 을(를) {_display(spec, 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("화면 구성을 저장하지 못했습니다. 빌더 화면에서 확인해 주세요.")
|
|
|
|
|
|
_ON_WORDS = {"true", "1", "on", "yes", "켜", "켜기", "켜줘", "보이기", "보여줘", "넣기", "넣어줘"}
|
|
_OFF_WORDS = {"false", "0", "off", "no", "꺼", "끄기", "꺼줘", "빼기", "빼줘", "숨기기", "숨겨줘"}
|
|
|
|
|
|
def _switch(raw: str) -> bool | None:
|
|
"""켜기 True · 끄기 False · 모르면 None.
|
|
|
|
★ 모르는 말을 '끄기' 로 읽지 않는다. 스키마가 모든 인자를 필수로 받아 모델이 enabled 를
|
|
"" 로 채울 수 있는데, 그걸 끄기로 읽으면 "후기 다시 보여줘" 가 후기를 끈다."""
|
|
word = re.sub(r"\s+", "", raw.lower())
|
|
if word in _ON_WORDS:
|
|
return True
|
|
if word in _OFF_WORDS:
|
|
return False
|
|
return None
|
|
|
|
|
|
# ★ 발행본(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)
|
|
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)
|
|
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"{names} 을(를) 켤지 끌지 알려 주세요.")
|
|
# ★ 잠긴 섹션은 끌 수 없다. SEO·필수 마크업 때문에 잠긴 것이라, 끄면 발행 게이트에 걸린다
|
|
# (site_payload._sections 가 어차피 켜서 내보낸다 — 화면만 거짓말하게 된다).
|
|
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}")
|
|
|
|
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)
|
|
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).
|
|
|
|
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")
|
|
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([r for r in rows if r["id"] != row["id"]], to)
|
|
if anchor is None:
|
|
raise ToolRejected("어디로 옮길지 못 찾았어요. '소개 다음으로' 처럼 말씀해 주세요.")
|
|
moved, said = _move_by_anchor(rows, row, anchor, where)
|
|
|
|
await _save_sections(ctx, theme, moved)
|
|
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']} 와(과) 자리를 바꿔"
|
|
|
|
|
|
# ── 사진 ─────────────────────────────────────────────────────────────────
|
|
#
|
|
# ★ 업로드·교체는 없다. 이미지 재게시 권리가 미결이라 저장 경로를 일부러 안 만들어 뒀다
|
|
# (docs/DECISIONS.md 1-2 · 5-3). 아래는 **이미 있는 사진의 노출과 순서**만 바꾼다.
|
|
|
|
def _photo_name(row) -> str:
|
|
"""사장님이 부를 이름. Vision 이 만든 라벨·alt 가 유일한 단서다."""
|
|
return (row.label or "").strip() or (row.alt_text or "").strip() or "이름 없는 사진"
|
|
|
|
|
|
async def _photos(ctx: ToolContext) -> list:
|
|
res = await _media_service().list_media(ctx.user, ctx.place_id)
|
|
return list(res.media or [])
|
|
|
|
|
|
def _primary(rows: list):
|
|
"""발행본의 대표 사진 — 나가는 사진 중 객실·메뉴 전용이 아닌 첫 장(site_payload.primary_media).
|
|
|
|
★ 목록의 첫 장이 아니다. 첫 장이 내린 사진이면 발행본에는 없고, 대표는 그 다음 장이다."""
|
|
return next((r for r in rows if r.publishable and not r.unit_id), None)
|
|
|
|
|
|
def _find_photo(rows: list, wanted: str):
|
|
"""★ 둘 이상 걸리면 고르지 않는다 — 추측으로 내리면 엉뚱한 사진이 사라지고,
|
|
사장님은 발행하고 나서야 안다(섹션과 같은 규칙).
|
|
★ 이름이 정확히 맞는 한 장이 있으면 그걸 고른다 — '객실' 과 '객실 욕실' 이 있을 때
|
|
'객실' 은 모호하지 않다(섹션의 _find_section 과 같은 순서)."""
|
|
wanted = (wanted or "").strip()
|
|
if not wanted:
|
|
return None
|
|
exact = [r for r in rows if _photo_name(r) == wanted]
|
|
if len(exact) == 1:
|
|
return exact[0]
|
|
hits = [r for r in rows if wanted in _photo_name(r)]
|
|
return hits[0] if len(hits) == 1 else None
|
|
|
|
|
|
def _pick_photo(rows: list, wanted: str):
|
|
"""(사진, 나가는가). 나가는 사진에서 먼저 찾고, 없으면 안 나가는 사진에서 찾는다.
|
|
|
|
★ 한꺼번에 찾지 않는다. 내린 '객실' 과 나가는 '객실' 이 함께 있으면 모호해져서
|
|
사장님이 가리킨 나가는 사진을 못 내린다."""
|
|
shown = _find_photo([r for r in rows if r.publishable], wanted)
|
|
if shown is not None:
|
|
return shown, True
|
|
return _find_photo([r for r in rows if not r.publishable], wanted), False
|
|
|
|
|
|
async def _list_photos(ctx: ToolContext, args: dict) -> str:
|
|
rows = await _photos(ctx)
|
|
if not rows:
|
|
return "아직 등록된 사진이 없어요."
|
|
primary = _primary(rows)
|
|
lines = []
|
|
for r in rows[:15]:
|
|
where = " (객실·메뉴 전용)" if r.unit_id else ""
|
|
mark = "" if r.publishable else " — 지금은 안 나감"
|
|
head = "대표 " if r is primary else ""
|
|
lines.append(f"· {head}{_photo_name(r)}{where}{mark}")
|
|
more = f"\n(그 밖에 {len(rows) - 15}장 더)" if len(rows) > 15 else ""
|
|
return "홈페이지에 있는 사진입니다.\n" + "\n".join(lines) + more
|
|
|
|
|
|
async def _hide_photo(ctx: ToolContext, args: dict) -> str:
|
|
rows = await _photos(ctx)
|
|
row, shown = _pick_photo(rows, _arg(args, "name"))
|
|
if row is None:
|
|
raise ToolRejected("어느 사진을 말씀하시는지 못 찾았어요. '사진 목록' 이라고 하시면 보여드릴게요.")
|
|
if not shown:
|
|
return Unchanged(f"'{_photo_name(row)}' 사진은 이미 사이트에 안 나가고 있어요.")
|
|
res = await _media_service().hide_media(ctx.user, ctx.place_id, str(row.media_id))
|
|
if not res.result.success:
|
|
raise ToolRejected("그 사진을 내리지 못했습니다. 빌더 화면에서 확인해 주세요.")
|
|
return f"'{_photo_name(row)}' 사진을 내렸습니다."
|
|
|
|
|
|
async def _set_primary_photo(ctx: ToolContext, args: dict) -> str:
|
|
rows = await _photos(ctx)
|
|
row, shown = _pick_photo(rows, _arg(args, "name"))
|
|
if row is None:
|
|
raise ToolRejected("어느 사진을 말씀하시는지 못 찾았어요. '사진 목록' 이라고 하시면 보여드릴게요.")
|
|
# ★ 안 나가는 사진의 순서만 당기면 "바꿨습니다" 라고 말하는데 발행본의 대표는 그대로다.
|
|
if not shown:
|
|
raise ToolRejected(f"'{_photo_name(row)}' 은(는) 지금 사이트에 안 나가는 사진이라 대표로 쓸 수 없어요.")
|
|
if row.unit_id:
|
|
raise ToolRejected(f"'{_photo_name(row)}' 은(는) 객실·메뉴 전용 사진이라 대표로 쓸 수 없어요.")
|
|
res = await _media_service().set_primary(ctx.user, ctx.place_id, str(row.media_id))
|
|
if not res.result.success:
|
|
raise ToolRejected("대표 사진을 바꾸지 못했습니다. 빌더 화면에서 확인해 주세요.")
|
|
return f"대표 사진을 '{_photo_name(row)}' 으로 바꿨습니다."
|
|
|
|
|
|
# ── 반쯤 되돌릴 수 있는 것 ───────────────────────────────────────────────
|
|
|
|
async def _publish(ctx: ToolContext, args: dict) -> str:
|
|
_fact, site_service = _services()
|
|
res = await site_service.start_build(ctx.user, ctx.place_id, Req_StartBuild(publish=True))
|
|
if not res.result.success:
|
|
if res.result.code == ErrorType.PLACE_NOT_VERIFIED.value:
|
|
raise ToolRejected("가게 확인이 끝나지 않아 발행할 수 없어요. 빌더 화면에서 가게 정보를 먼저 확인해 주세요.")
|
|
raise ToolRejected("발행을 시작하지 못했습니다. 빌더 화면에서 확인해 주세요.")
|
|
return "발행을 시작했습니다. 1분쯤 걸리고, 끝나면 사이트에 반영됩니다."
|
|
|
|
|
|
# ── 남은 요청의 이름 ─────────────────────────────────────────────────────
|
|
#
|
|
# ★ 코드가 만든다. 멈춘 뒤 남은 요청을 알릴 때 모델 문장을 실으면 하지 않은 일을 한 것처럼
|
|
# 말할 수 있다. 인자에서 이름만 빌려 쓴다.
|
|
|
|
def _describe_fact(ctx: ToolContext, args: dict) -> str:
|
|
spec = get_schema(PlaceCategory(ctx.place.category)).get(_arg(args, "key"))
|
|
return f"{spec.label if spec else '가게 정보'} 변경"
|
|
|
|
|
|
def _describe_toggle(ctx: ToolContext, args: dict) -> str:
|
|
on = _switch(_arg(args, "enabled"))
|
|
verb = "켜기" if on else "끄기" if on is False else "켜기·끄기"
|
|
return f"{_arg(args, 'name') or '화면 구성'} {verb}"
|
|
|
|
|
|
def _describe_move(ctx: ToolContext, args: dict) -> str:
|
|
return f"{_arg(args, 'name') or '화면 구성'} 옮기기"
|
|
|
|
|
|
def _describe_hide(ctx: ToolContext, args: dict) -> str:
|
|
name = _arg(args, "name")
|
|
return f"'{name}' 사진 내리기" if name else "사진 내리기"
|
|
|
|
|
|
def describe_action(ctx: ToolContext, tool: Tool, args: dict) -> str:
|
|
return tool.describe(ctx, args) if tool.describe else tool.title
|
|
|
|
|
|
class ToolRejected(RuntimeError):
|
|
"""도구가 실행을 거절했다 — 사장님께 그대로 보여 줄 한국어 문장을 담는다."""
|
|
|
|
|
|
REGISTRY: dict[str, Tool] = {
|
|
t.name: t
|
|
for t in [
|
|
Tool(
|
|
name="get_site_status",
|
|
grade=ToolGrade.READ,
|
|
title="발행 상태 보기",
|
|
summary="홈페이지가 발행됐는지, 주소와 마지막 발행 시각을 알려준다.",
|
|
run=_get_site_status,
|
|
),
|
|
Tool(
|
|
name="list_facts",
|
|
grade=ToolGrade.READ,
|
|
title="가게 정보 보기",
|
|
summary="지금 저장된 가게 정보를 보여준다.",
|
|
args={"keyword": "찾고 싶은 항목이 있으면 그 말(선택)"},
|
|
run=_list_facts,
|
|
),
|
|
Tool(
|
|
name="set_fact",
|
|
grade=ToolGrade.REVERSIBLE,
|
|
republish=True,
|
|
describe=_describe_fact,
|
|
target=("key",),
|
|
summary="가게 정보 한 항목을 고친다. 사이트에 반영되려면 발행이 따로 필요하다.",
|
|
args={"key": "아래 항목 목록의 key", "value": "바꿀 값"},
|
|
run=_set_fact,
|
|
),
|
|
Tool(
|
|
name="list_sections",
|
|
grade=ToolGrade.READ,
|
|
title="화면 구성 보기",
|
|
summary="홈페이지가 어떤 부분들로 어떤 순서로 되어 있는지, 무엇이 꺼져 있는지 보여준다.",
|
|
args={"only": "꺼진 부분만 보려면 '꺼진'(선택)"},
|
|
run=_list_sections,
|
|
),
|
|
Tool(
|
|
name="toggle_section",
|
|
grade=ToolGrade.REVERSIBLE,
|
|
republish=True,
|
|
describe=_describe_toggle,
|
|
target=("name",),
|
|
summary="홈페이지의 부분을 켜거나 끈다(숨기기). 여러 부분을 한꺼번에 할 수 있다.",
|
|
args={"name": "그 부분의 이름 — 여럿이면 쉼표로 이어서", "enabled": "켜면 true, 끄면 false"},
|
|
run=_toggle_section,
|
|
),
|
|
Tool(
|
|
name="move_section",
|
|
grade=ToolGrade.REVERSIBLE,
|
|
republish=True,
|
|
describe=_describe_move,
|
|
target=("name",),
|
|
summary="홈페이지에서 한 부분의 위치(순서)를 바꾼다.",
|
|
args={
|
|
"name": "옮길 부분의 이름",
|
|
"where": "'맨 위' · '맨 아래' · '앞' · '뒤' · '위로' · '아래로' · '번째' · '바꾸기' 중 하나",
|
|
"to": "앞·뒤·바꾸기의 기준이 되는 부분의 이름",
|
|
"count": "위로·아래로는 칸 수, 번째는 순번(숫자)",
|
|
},
|
|
run=_move_section,
|
|
),
|
|
Tool(
|
|
name="list_photos",
|
|
grade=ToolGrade.READ,
|
|
title="사진 목록 보기",
|
|
summary="홈페이지에 올라가 있는 사진 목록을 보여준다(맨 앞이 대표 사진).",
|
|
run=_list_photos,
|
|
),
|
|
Tool(
|
|
name="hide_photo",
|
|
grade=ToolGrade.REVERSIBLE,
|
|
republish=True,
|
|
describe=_describe_hide,
|
|
target=("name",),
|
|
summary="사진 한 장을 홈페이지에서 내린다. 새 사진을 올리는 것은 아직 못 한다.",
|
|
args={"name": "그 사진의 이름(라벨)"},
|
|
run=_hide_photo,
|
|
),
|
|
Tool(
|
|
name="set_primary_photo",
|
|
grade=ToolGrade.REVERSIBLE,
|
|
republish=True,
|
|
title="대표 사진 바꾸기",
|
|
# 대표는 한 장이다 — 두 번 말하면 마지막 것이 남는다.
|
|
target=(),
|
|
summary="대표 사진을 바꾼다(검색 결과와 목록 카드에 나오는 그림).",
|
|
args={"name": "대표로 쓸 사진의 이름(라벨)"},
|
|
run=_set_primary_photo,
|
|
),
|
|
Tool(
|
|
name="publish",
|
|
grade=ToolGrade.SEMI,
|
|
title="발행",
|
|
target=(),
|
|
summary="바뀐 내용을 홈페이지에 반영한다(재발행).",
|
|
run=_publish,
|
|
confirm="지금 홈페이지를 다시 발행할까요? 바뀐 내용이 손님에게 보이게 됩니다.",
|
|
),
|
|
]
|
|
}
|
|
|
|
|
|
async def sections_of(ctx: ToolContext):
|
|
"""런타임이 프롬프트에 실을 섹션 목록. 도구가 쓰는 것과 같은 함수여야 한다 —
|
|
다르면 모델이 본 이름과 도구가 찾는 이름이 갈린다."""
|
|
return await _sections_of(ctx)
|
|
|
|
|
|
async def photo_names(ctx: ToolContext) -> list[str]:
|
|
"""런타임이 프롬프트에 실을 사진 이름. 도구가 찾는 이름과 **같은 함수**로 만든다 —
|
|
다르면 모델이 본 이름과 도구가 찾는 이름이 갈린다."""
|
|
# ★ 나가는 사진만, 대표를 맨 앞에 싣는다 — 프롬프트는 '맨 앞이 대표' 라고 말한다.
|
|
# 내린 사진이 섞이면 모델이 그걸 대표로 고르고, 객실 전용 사진이 앞에 있으면 대표를 잘못 안다.
|
|
rows = [r for r in await _photos(ctx) if r.publishable]
|
|
primary = _primary(rows)
|
|
if primary is not None:
|
|
rows = [primary] + [r for r in rows if r is not primary]
|
|
return [_photo_name(r) for r in rows[:15]]
|
|
|
|
|
|
def describe() -> list[dict]:
|
|
"""프롬프트에 실을 도구 목록. ★ 등급은 싣지 않는다 — 모델이 알 필요도, 정할 이유도 없다."""
|
|
return [{"name": t.name, "설명": t.summary, "args": t.args} for t in REGISTRY.values()]
|
|
|
|
|
|
def fields_of(place) -> list[dict]:
|
|
schema = get_schema(PlaceCategory(place.category))
|
|
return [
|
|
{"key": k, "label": spec.label, "type": spec.type, "unit": spec.unit}
|
|
for k, spec in schema.fields.items()
|
|
if spec.scope == "place"
|
|
]
|