o2o-site-AEO/solution/backend/tests/test_story_generation.py
Mina Choi 328d9e18ee [feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:00 +09:00

164 lines
7.6 KiB
Python

"""지역 이야기 생성 — 응답 해석과 payload 경계.
★ 실호출은 하지 않는다. `APP_ENV=test` 면 .env 를 안 읽어 키가 비고, 이 테스트가 검증하는 건
"모델이 뭐라고 답했을 때 무엇을 남기는가" 다 — 그건 고정 응답으로 전부 재현된다.
"""
import os
os.environ.setdefault("APP_ENV", "test")
from common.enums import STORY_KINDS, 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", "daily", "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_생성_목록과_읽는_목록이_같다():
"""산출물(section_prompts.json) · 파이썬 상수 · 항목 이름칸 셋이 어긋나면 조용히 틀린다.
★ `daily` 가 실제로 이렇게 빠져 있었다 — 프롬프트가 빌더에만 손으로 적혀 있어서
서버는 그 종류를 몰랐고, 렌더러의 탭 자리는 영영 빈칸이었다(2026-09-10).
"""
assert tuple(prompts.kinds()) == STORY_KINDS
assert set(grounding._TITLE_KEY) == set(STORY_KINDS)
def test_일력_프롬프트가_붙는다():
"""'옛 항구' 설명이 약속한 일력 — MM-DD 스키마와 연도 금지 규칙까지 실려야 한다."""
text = prompts.build_prompt("daily", "전북 군산시")
assert "[지역] 전북 군산시" in text
assert '"kind":"daily"' in text
assert "연도를 넣지 않는다" in text
assert prompts.max_items("daily") == 30
# ── 응답 해석 ────────────────────────────────────────────────────────────
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