o2o-site-AEO/solution/backend/services/prompts/story.py
Mina Choi c4af53613e [feat] solution,postgres-init: 지역 이야기를 서버가 채운다 · 공용과 개인화를 이름으로 가른다
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/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>
2026-09-09 17:08:31 +09:00

62 lines
2.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""지역 이야기 프롬프트 계약 — 문장은 여기서 만들지 않고 **읽어 온다**.
★ 단일 출처는 `solution/shared/src/lib/section-prompts.ts` 다.
사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와 서버가 도는 프롬프트가 같아야 한다 —
갈리면 "빌더에서 뽑은 것과 자동으로 채워진 것의 모양이 다르다"가 조용히 생긴다.
이 파일이 읽는 `section_prompts.json` 은 `npm run export:prompts` 산출물이고 커밋된다.
**손으로 고치지 않는다.**
"""
import json
from functools import lru_cache
from pathlib import Path
_SPEC_PATH = Path(__file__).with_name("section_prompts.json")
SYSTEM_PROMPT = (
"너는 지역 콘텐츠 리서처다. 검색으로 확인한 것만 쓰고, 확인하지 못한 값은 필드를 통째로 뺀다. "
"JSON 하나만 출력한다."
)
@lru_cache(maxsize=1)
def _spec() -> dict:
"""산출물 로드. 없으면 즉시 터뜨린다 — 프롬프트 없이 도는 생성 잡은 빈 값을 쓴다."""
try:
return json.loads(_SPEC_PATH.read_text(encoding="utf-8"))
except FileNotFoundError as ex: # pragma: no cover - 배포 누락은 기동 시 바로 드러난다
raise RuntimeError(
f"{_SPEC_PATH.name} 이 없다 — 레포 루트에서 `npm run export:prompts` 를 돌려야 한다"
) from ex
def kinds() -> list[str]:
return list(_spec()["specs"].keys())
def label(kind: str) -> str:
return _spec()["specs"][kind]["label"]
def max_items(kind: str) -> int:
return int(_spec()["specs"][kind]["maxItems"])
def build_prompt(kind: str, region: str) -> str:
"""지역 하나 × 종류 하나의 프롬프트.
★ 업소 이름을 넣지 않는다. 이 값은 **지역**에 붙어 같은 지역 사이트가 나눠 쓴다 —
업소 하나를 골라 넣으면 그 집 이야기가 옆집 사이트에 실린다.
`shared/section-prompts.ts` 의 `buildSectionPrompt` 가 같은 분기를 갖고 있다.
"""
spec = _spec()["specs"].get(kind)
if spec is None:
raise ValueError(f"모르는 지역 이야기 종류: {kind}")
head = (
"너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다.\n"
f"\n[지역] {region}\n"
f"\n아래 '해야 할 일'에서 [지역] = {region}. "
"업소가 지정되지 않았으므로 특정 업소를 가리키는 문장(connection 등)은 쓰지 않는다.\n"
)
return f"{head}\n{spec['task']}\n{_spec()['rules']}{spec['rules']}"