diff --git a/docs/AGENT.md b/docs/AGENT.md index 0d13509..bdacbbb 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -163,6 +163,8 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 | "소개랑 갤러리 자리 바꿔줘" | `where=바꾸기`, `to=사진 갤러리` | 배열 | `where` 가 비면 예전 표기로 읽는다(`to` 에 '맨 위' · '맨 아래' · 그 뒤에 올 이름). +★ `where` 가 **왔는데 못 알아들으면** 예전 표기로 넘기지 않고 되묻는다 — `to` 만 보고 '다음으로' 옮기면 +"소개 앞쪽으로" 가 소개 뒤로 간다. 섹션을 여럿 적을 때 구분은 쉼표뿐이다(`·` 는 이름에 들어 있다). ★ **히어로·SNS 게시글은 옮기지 않는다**(`tools.PINNED`). 발행본(`site/src/pages/HomePage.tsx`)이 배열 순서와 상관없이 히어로를 늘 맨 위에, SNS 를 늘 맨 아래에 그린다 — 옮기게 두면 "옮겼습니다" @@ -236,8 +238,8 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 | 형식 | 받는 말 → 저장값 | 되묻는 경우 | |---|---|---| | bool | 가능·돼요·있음 → `true`, 불가·안 돼요·없음 → `false` | "소형견만" 처럼 가능·불가가 아닌 말 | -| time | `15:00` · 오후 3시 · 15시 30분 · 3시 반 → `HH:MM` | **"3시"(오전·오후 모름)** · 25:00 | -| number | 2만원 → `20000` · 2만 5천원 → `25000` · 20,000원 · 무료 → `0` | "문의" 처럼 숫자가 아닌 말 | +| time | `15:00` · 오후/낮 3시 · 15시 30분 · 3시 반 → `HH:MM`, 밤 12시 → `00:00` | **"3시"(오전·오후 모름)** · 25:00 | +| number | 2만원 → `20000` · 2만 5천원 → `25000` · 만원 → `10000` · 20,000원 · 무료 → `0` | "문의" · "만 오천원" 처럼 숫자가 아닌 말 | 두 겹이다 — 프롬프트가 항목마다 형식을 싣고(`key: 이름 (형식)`), 도구가 다시 맞춘다 (`tools._normalize`). ★ 모델만 믿지 않는다: 스키마를 어기고 `true` 를 불리언으로, 인자를 @@ -296,8 +298,10 @@ SEMI(publish) ★ 거기서 멈춘다 — 앞서 한 일을 함께 말하 ★ **같은 대상을 두 번 시키면 마지막 하나만 한다**("체크인 3시… 아니 4시로"). 둘 다 하면 문구에 두 값이 함께 서서 어느 쪽이 남았는지 모른다. 같은 대상인지는 `Tool.target` 이 정한다(set_fact 는 -`key`, 섹션·사진은 `name`, 대표 사진·발행은 하나뿐). 자리는 마지막 것의 자리이고, **상한을 세기 전에** -합친다 — 고쳐 말한 것까지 세면 할 수 있는 일이 잘린다. +`key`, 켜기·끄기와 사진 내리기는 `name`, 대표 사진·발행은 하나뿐). 자리는 마지막 것의 자리이고, +**상한을 세기 전에** 합친다 — 고쳐 말한 것까지 세면 할 수 있는 일이 잘린다. +★ **옮기기는 합치지 않는다**(인자까지 똑같을 때만). 차례가 뜻이다 — "날씨 맨 위로, 그리고 한 칸 아래로" +를 마지막 하나로 합치면 두 번째 자리가 아니라 원래 자리에서 한 칸 아래가 된다. ★ **바뀐 것이 없으면 재발행을 권하지 않는다.** "이미 켜져 있어요" 에 "다시 발행해야 해요" 가 붙으면 무언가 바뀐 줄 안다. 도구가 `Unchanged` 로 돌려주면 런타임은 `done=False` 로 두고, 카톡은 발행 diff --git a/solution/backend/services/agent/runtime.py b/solution/backend/services/agent/runtime.py index 9199660..31107a0 100644 --- a/solution/backend/services/agent/runtime.py +++ b/solution/backend/services/agent/runtime.py @@ -180,7 +180,8 @@ _SKIPPED_LEN = 30 def _skipped(value) -> list[str]: if not isinstance(value, list): return [] - names = [str(v).strip()[:_SKIPPED_LEN] for v in value if str(v or "").strip()] + # 줄바꿈·연속 공백은 한 칸으로 — 틀에 끼운 한 줄이 여러 줄로 쪼개지지 않게. + names = [" ".join(str(v).split())[:_SKIPPED_LEN] for v in value if str(v or "").strip()] return names[:_SKIPPED_MAX] diff --git a/solution/backend/services/agent/tools.py b/solution/backend/services/agent/tools.py index 443246e..fc9e724 100644 --- a/solution/backend/services/agent/tools.py +++ b/solution/backend/services/agent/tools.py @@ -111,11 +111,13 @@ _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 = {"오후", "저녁", "밤"} +# ★ '낮 3시' 는 15시, '밤 12시' 는 자정이다. 낮을 빼 두면 '낮 3시' 가 03:00 으로 들어간다. +_PM_WORDS = {"오후", "낮", "저녁", "밤"} _AM_WORDS = {"오전", "아침", "새벽"} +_MIDNIGHT_WORDS = {"밤", "새벽"} -# "2만 5천원" · "20,000원" · "1.5시간" -_NUMBER = re.compile(r"^(?:(\d+)만)?(?:(\d+)천)?(\d+(?:\.\d+)?)?$") +# "2만 5천원" · "20,000원" · "1.5시간" · "만원"(앞 숫자가 없으면 1) +_NUMBER = re.compile(r"^(?:(\d*)만)?(?:(\d*)천)?(\d+(?:\.\d+)?)?$") _NUMBER_UNITS = re.compile(r"(원|명|대|개|분|시간|실|석|인|층|평|㎡)$") @@ -137,7 +139,9 @@ def _parse_time(raw: str) -> tuple[str | None, bool]: 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: + if when in _MIDNIGHT_WORDS and hour == 12: + hour = 0 + elif when in _PM_WORDS and hour < 12: hour += 12 elif when in _AM_WORDS and hour == 12: hour = 0 @@ -157,10 +161,11 @@ def _parse_number(raw: str) -> str | None: 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: + if (man is not None or cheon is not None) and rest and "." in rest: return None - value = int(man or 0) * 10000 + int(cheon or 0) * 1000 - if man or cheon: + if man is not None or cheon is not None: + value = int(man or 1) * 10000 if man is not None else 0 + value += int(cheon or 1) * 1000 if cheon is not None else 0 return str(value + int(rest or 0)) number = float(rest) return str(int(number)) if number.is_integer() else rest @@ -339,7 +344,7 @@ _WHERE = { # 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, +_KOREAN_COUNT = {"첫": 1, "한": 1, "하나": 1, "두": 2, "둘": 2, "세": 3, "셋": 3, "네": 4, "넷": 4, "다섯": 5, "여섯": 6, "일곱": 7, "여덟": 8, "아홉": 9, "열": 10} @@ -395,7 +400,8 @@ async def _toggle_section(ctx: ToolContext, args: dict) -> str: ★ 여럿 중 하나라도 못 찾거나 끌 수 없으면 **아무것도 바꾸지 않는다.** 일부만 끄면 사장님은 무엇이 꺼졌는지 다시 확인해야 한다 — 한 요청은 한꺼번에 되거나 안 된다.""" rows, theme = await _sections_of(ctx) - wanted = [w.strip() for w in re.split(r"[,·、]", _arg(args, "name")) if w.strip()] + # ★ '·' 로는 나누지 않는다 — 카페·음식점 섹션 이름에 들어 있다('공간 · 좌석 안내'). + 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) @@ -445,8 +451,13 @@ async def _move_section(ctx: ToolContext, args: dict) -> str: 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) + to, said_where = _arg(args, "to"), _arg(args, "where") + # ★ 예전 표기로는 where 가 **비었을 때만** 읽는다. 못 알아들은 where 를 두고 to 만 보면 + # "소개 앞쪽으로" 가 소개 **뒤**로 간다 — 조용히 반대로 옮긴다. + if said_where: + where = _where(said_where) + else: + where = _LEGACY_TO.get(to) or ("after" if to else None) if where is None: raise ToolRejected("어디로 옮길지 알려 주세요. 예) 맨 위로 · 소개 다음으로 · 한 칸 위로 · 세 번째로") @@ -709,7 +720,9 @@ REGISTRY: dict[str, Tool] = { grade=ToolGrade.REVERSIBLE, republish=True, describe=_describe_move, - target=("name",), + # ★ target 을 두지 않는다 — 옮기기는 차례가 뜻이다. "맨 위로, 그리고 한 칸 아래로" 를 + # 마지막 하나로 합치면 두 번째 자리가 아니라 원래 자리에서 한 칸 아래가 된다. + # 인자까지 똑같은 요청만 합친다. summary="홈페이지에서 한 부분의 위치(순서)를 바꾼다.", args={ "name": "옮길 부분의 이름", diff --git a/solution/backend/tests/test_agent_runtime.py b/solution/backend/tests/test_agent_runtime.py index c6f5f74..273de51 100644 --- a/solution/backend/tests/test_agent_runtime.py +++ b/solution/backend/tests/test_agent_runtime.py @@ -1061,3 +1061,70 @@ def test_프롬프트는_고정된_부분을_알려준다(): sections=sections, photos=[], message="안녕", pinned=tools.PINNED) assert "히어로 [끄기 불가] [항상 맨 위]" in text_ assert "SNS 게시글 [항상 맨 아래]" in text_ + + +# ── 재검증에서 나온 것 (2026-09-29) ────────────────────────────────────── + +async def test_같은_부분을_두_번_옮기면_차례로_한다(client, auth_headers, choose, db_engine): + """★ 상대 이동은 합치면 결과가 달라진다 — "맨 위로, 그리고 한 칸 아래로" 는 두 번째 자리다.""" + h, pid = await seed(client, auth_headers) + choose({"actions": [ + {"tool": "move_section", "args": {"name": "날씨", "where": "맨 위"}}, + {"tool": "move_section", "args": {"name": "날씨", "where": "아래로", "count": "1"}}, + ], "message": ""}) + await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "날씨 맨 위로 그리고 한 칸 아래로"}) + assert (await _visible(client, h, pid))[1] == "날씨" + + +async def test_모르는_방향이면_다음으로_읽지_않는다(client, auth_headers, choose, db_engine): + """where 를 못 알아들었는데 to 만 보고 '다음으로' 옮기면, "소개 앞쪽으로" 가 소개 뒤로 간다.""" + h, pid = await seed(client, auth_headers) + before = await _visible(client, h, pid) + body = await _move(client, h, pid, choose, name="날씨", where="앞쪽", to="소개") + assert body["rejected"] is True + assert await _visible(client, h, pid) == before + + +async def test_이름에_가운뎃점이_있는_부분도_끈다(client, auth_headers, choose, db_engine): + """카페·음식점의 섹션 이름에는 '·' 가 있다('공간 · 좌석 안내'). 그걸 나누는 표로 쓰면 이름이 쪼개진다.""" + h = await auth_headers(f"agent-{uuid.uuid4().hex[:8]}") + pid = (await client.post("/v1/place", headers=h, json={"name": "대화카페", "category": 2})).json()["place"]["place_id"] + 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"] + rows, _t = await _sections(client, h, pid) + off = {r["name"] for r in rows if not r["enabled"]} + assert {"공간 · 좌석 안내", "날씨"} <= off + + +@pytest.mark.parametrize("said, stored", [("낮 3시", "15:00"), ("낮 12시", "12:00"), ("밤 12시", "00:00"), ("밤 9시", "21:00")]) +async def test_낮_밤도_시각으로_읽는다(said, stored, client, auth_headers, choose, db_engine): + h, pid = await seed(client, auth_headers) + choose({"actions": [{"tool": "set_fact", "args": {"key": "check_in_time", "value": said}}], "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 await _fact_value(db_engine, pid, "check_in_time") == [stored] + + +@pytest.mark.parametrize("said, stored", [("만원", "10000"), ("천원", "1000"), ("만 오천원", None)]) +async def test_앞에_숫자가_없는_만_천(said, stored, client, auth_headers, choose, db_engine): + h, pid = await seed(client, auth_headers) + choose({"actions": [{"tool": "set_fact", "args": {"key": "extra_person_fee", "value": said}}], "message": ""}) + body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "추가 요금"})).json() + if stored is None: + assert body["rejected"] is True # '오천' 은 숫자가 아니다 — 추측하지 않는다 + else: + assert await _fact_value(db_engine, pid, "extra_person_fee") == [stored] + + +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))[0] == "날씨" + + +async def test_못_한_요청_이름의_줄바꿈은_한_칸으로_편다(client, auth_headers, choose, db_engine): + h, pid = await seed(client, auth_headers) + choose({"actions": [], "skipped": ["전화번호\n변경"], "message": ""}) + reply = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "전화번호"})).json()["reply"] + assert "'전화번호 변경' 은(는)" in reply