[fix] solution/backend: 에이전트 재검증 결함 정리 — 옮기기 합치기·모르는 방향·낮/밤 시각·만원·첫 번째
추가한 코드를 다시 읽다가 테스트가 못 잡던 여섯 가지가 나왔다. 둘은 조용히 반대로 동작했다 —
"소개 앞쪽으로" 가 소개 뒤로 갔고, "낮 3시" 가 03:00 으로 저장됐다.
- tools: move_section 의 target 제거 — 옮기기는 차례가 뜻이라 "맨 위로, 그리고 한 칸 아래로" 를
합치면 두 번째가 아니라 원래 자리에서 한 칸 아래가 됐다. 인자까지 같은 것만 합친다
- tools: 못 알아들은 where 는 되묻는다 — 예전 표기(to 만)는 where 가 비었을 때만 읽는다
- tools: 낮은 오후, 밤·새벽 12시는 자정 — '낮 3시' 03:00 · '밤 12시' 12:00 이던 것
- tools: '만원'·'천원'(앞 숫자 없음)을 1로 읽는다. '만 오천원' 은 여전히 되묻는다
- tools: '첫 번째' 순번, toggle 이름 구분에서 '·' 제외('공간 · 좌석 안내' 가 쪼개질 수 있었다)
- runtime: skipped 이름의 줄바꿈·연속 공백을 한 칸으로
- docs/AGENT.md: 값 형식 표 · 옮기기는 합치지 않음 · 모르는 방향은 되묻기
테스트 12건 추가, 에이전트·카카오 186 passed. 전체 965 passed / 47 failed —
실패 목록은 변경 전과 동일(gemini·openai 키 미설정, search_console, weather_notes). pyflakes 새 경고 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
36b818eda3
commit
091d5d9c0e
@ -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` 로 두고, 카톡은 발행
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
|
||||
@ -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": "옮길 부분의 이름",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user