가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.
**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
(같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.
**자리 가르기**
area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
**첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.
검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
6.7 KiB
Python
145 lines
6.7 KiB
Python
"""지역 이야기 생성 — 응답 해석과 payload 경계.
|
|
|
|
★ 실호출은 하지 않는다. `APP_ENV=test` 면 .env 를 안 읽어 키가 비고, 이 테스트가 검증하는 건
|
|
"모델이 뭐라고 답했을 때 무엇을 남기는가" 다 — 그건 고정 응답으로 전부 재현된다.
|
|
"""
|
|
import os
|
|
|
|
os.environ.setdefault("APP_ENV", "test")
|
|
|
|
from common.enums import LocalContentType # noqa: E402
|
|
from services.grounding import story as grounding # noqa: E402
|
|
from services.prompts import story as prompts # noqa: E402
|
|
from services.site_payload import _local # noqa: E402
|
|
|
|
|
|
def _reply(content: str, search_results=None) -> dict:
|
|
return {
|
|
"choices": [{"message": {"content": content}}],
|
|
"search_results": search_results or [],
|
|
}
|
|
|
|
|
|
# ── 프롬프트 ────────────────────────────────────────────────────────────
|
|
def test_프롬프트는_shared_산출물에서_온다():
|
|
"""다섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다."""
|
|
assert set(prompts.kinds()) == {"songs", "people", "chronicle", "postcard", "quiz"}
|
|
text = prompts.build_prompt("songs", "전북 군산시")
|
|
assert "[지역] 전북 군산시" in text
|
|
assert "[지역]을 노래한 대중가요" in text # task 원문
|
|
assert "[공통 규칙]" in text
|
|
# ★ 빈칸이 남으면 모델이 그걸 지명으로 읽는다.
|
|
assert "(주소를" not in text and "(가게" not in text
|
|
|
|
|
|
def test_지역_생성은_업소를_가리키지_않는다():
|
|
"""지역 단위 값이라 특정 업소 문장을 못 쓰게 못박는다 — 옆집 사이트에도 실리는 값이다."""
|
|
assert "업소가 지정되지 않았으므로" in prompts.build_prompt("songs", "전북 군산시")
|
|
|
|
|
|
# ── 응답 해석 ────────────────────────────────────────────────────────────
|
|
def test_출처가_없으면_항목을_버린다():
|
|
payload = _reply('{"kind":"songs","items":[{"title":"금강 나그네"}]}')
|
|
items, dropped = grounding.parse_items(payload, "songs", 8)
|
|
assert items == []
|
|
assert any("출처가 없다" in d for d in dropped)
|
|
|
|
|
|
def test_검색결과를_대체_출처로_쓰되_확인필요로_내린다():
|
|
"""search_results 는 이번 **검색 전체**의 출처지 그 항목의 근거가 아니다."""
|
|
payload = _reply(
|
|
'{"kind":"songs","items":[{"title":"금강 나그네","verified":"확인"}]}',
|
|
search_results=[{"title": "세계일보", "url": "https://example.com/a"}],
|
|
)
|
|
items, _ = grounding.parse_items(payload, "songs", 8)
|
|
assert len(items) == 1
|
|
assert items[0]["source"]["url"] == "https://example.com/a"
|
|
assert items[0]["verified"] == "확인필요"
|
|
|
|
|
|
def test_항목_출처가_있으면_확인을_유지한다():
|
|
payload = _reply(
|
|
'{"kind":"songs","items":[{"title":"금강 나그네","verified":"확인",'
|
|
'"source":{"name":"세계일보","url":"https://example.com/song"}}]}'
|
|
)
|
|
items, _ = grounding.parse_items(payload, "songs", 8)
|
|
assert items[0]["verified"] == "확인"
|
|
|
|
|
|
def test_열리지_않는_출처는_없는_것으로_친다():
|
|
""""검색결과 참조" 같은 문자열이 링크가 되면 눌러도 아무 데도 안 간다."""
|
|
payload = _reply(
|
|
'{"kind":"songs","items":[{"title":"금강 나그네","source":{"name":"검색","url":"검색결과 참조"}}]}'
|
|
)
|
|
items, dropped = grounding.parse_items(payload, "songs", 8)
|
|
assert items == []
|
|
assert any("출처가 없다" in d for d in dropped)
|
|
|
|
|
|
def test_이름칸이_없는_항목만_버리고_나머지는_살린다():
|
|
"""한 줄 때문에 지역 하나가 통째로 비면 다음 재생성까지 빈 채로 남는다."""
|
|
payload = _reply(
|
|
'{"kind":"people","items":['
|
|
'{"name":"채만식","source":{"name":"한국민족문화대백과","url":"https://example.com/1"}},'
|
|
'{"role":"소설가","source":{"name":"x","url":"https://example.com/2"}},'
|
|
'{"name":"고은","source":{"name":"y","url":"https://example.com/3"}}]}'
|
|
)
|
|
items, dropped = grounding.parse_items(payload, "people", 10)
|
|
assert [i["name"] for i in items] == ["채만식", "고은"]
|
|
assert any("name 가 없다" in d for d in dropped)
|
|
|
|
|
|
def test_코드펜스를_둘러도_읽는다():
|
|
"""규칙 1 로 금지했지만 모델은 종종 어긴다."""
|
|
payload = _reply(
|
|
'```json\n{"kind":"quiz","items":[{"question":"왜 군산에 일본식 가옥이 남았을까?",'
|
|
'"source":{"name":"군산시","url":"https://example.com/q"}}]}\n```'
|
|
)
|
|
items, _ = grounding.parse_items(payload, "quiz", 12)
|
|
assert len(items) == 1
|
|
|
|
|
|
def test_상한을_넘으면_자른다():
|
|
rows = ",".join(
|
|
f'{{"line":"문장{i}","source":{{"name":"x","url":"https://example.com/{i}"}}}}' for i in range(20)
|
|
)
|
|
items, _ = grounding.parse_items(_reply(f'{{"kind":"postcard","items":[{rows}]}}'), "postcard", 12)
|
|
assert len(items) == 12
|
|
|
|
|
|
def test_JSON_이_아니면_전부_버리고_이유를_남긴다():
|
|
items, dropped = grounding.parse_items(_reply("죄송합니다. 정보를 찾지 못했습니다."), "songs", 8)
|
|
assert items == []
|
|
assert dropped and "JSON 이 아니다" in dropped[0]
|
|
|
|
|
|
# ── payload 경계 ─────────────────────────────────────────────────────────
|
|
def test_스냅샷의_이야기가_payload_로_나간다():
|
|
"""★ 항목 모양을 바꾸지 않는다 — 사장님이 붙여넣은 같은 종류의 JSON 과 한 배열로 이어진다."""
|
|
snapshot = {
|
|
"region_code": "52군산시",
|
|
"contents": [
|
|
{
|
|
"content_type": LocalContentType.STORY.value,
|
|
"kind": "songs",
|
|
"title": "가요 다방",
|
|
"body": {
|
|
"kind": "songs",
|
|
"version": 1,
|
|
"title": "가요 다방",
|
|
"items": [{"title": "금강 나그네", "artist": "이미자"}],
|
|
},
|
|
"collected_at": "2026-09-09T00:00:00+00:00",
|
|
}
|
|
],
|
|
}
|
|
local, synced_at = _local(snapshot, None, None)
|
|
assert local["story"]["songs"] == [{"title": "금강 나그네", "artist": "이미자"}]
|
|
assert synced_at == "2026-09-09T00:00:00+00:00"
|
|
|
|
|
|
def test_이야기가_없으면_story_키_자체가_없다():
|
|
"""빈 배열을 만들지 않는다 — 렌더러가 '있는데 비었다'와 '없다'를 구분한다."""
|
|
local, _ = _local({"region_code": "52군산시", "contents": []}, None, None)
|
|
assert "story" not in local
|