가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/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>
206 lines
9.8 KiB
TypeScript
206 lines
9.8 KiB
TypeScript
/**
|
||
* 지역 이야기 아이템의 **프롬프트** — "이 JSON 을 무엇으로 받아 오나".
|
||
*
|
||
* ★ 왜 shared 인가
|
||
* 쓰는 쪽이 둘이 됐다. 사장님이 [콘텐츠] 탭에서 복사해 ChatGPT 에 붙여넣는 프롬프트와,
|
||
* 서버가 지역 단위로 한 번 돌려 채우는 생성 잡(`services/story_service.py`)이 **같은 문장**을
|
||
* 써야 한다. 두 벌로 두면 "빌더에서 뽑은 것과 서버가 채운 것의 모양이 다르다"가 조용히 생긴다
|
||
* — 읽는 쪽 계약을 shared 에 둔 것(`section-data.ts`)과 같은 이유다.
|
||
*
|
||
* ★ 백엔드는 이 파일을 직접 못 읽는다(파이썬이다). `npm run export:prompts` 가
|
||
* `solution/backend/services/prompts/section_prompts.json` 으로 뽑고, 백엔드는 그 산출물을 읽는다.
|
||
* **손으로 고치지 않는다** — 고칠 자리는 여기 하나다.
|
||
*
|
||
* 폼 칸(`fields`)·라벨처럼 빌더 UI 만 쓰는 것은 여기 없다(`canvas/dataSpec.ts`).
|
||
*/
|
||
|
||
/** 아이템 종류. `area_contents.kind` · JSON 봉투의 `kind` 와 같은 값이다. */
|
||
export type StoryKind = 'songs' | 'people' | 'chronicle' | 'postcard' | 'quiz';
|
||
|
||
export const STORY_KINDS: StoryKind[] = ['songs', 'people', 'chronicle', 'postcard', 'quiz'];
|
||
|
||
export interface SectionPromptSpec {
|
||
kind: StoryKind;
|
||
/** 화면에 쓰는 이름. 생성 잡의 로그·어드민 목록도 이 이름을 쓴다. */
|
||
label: string;
|
||
/** 무엇을 시키나. 머리(업소·지역)와 공통 규칙은 `buildSectionPrompt` 가 붙인다. */
|
||
task: string;
|
||
/** 그 아이템에만 걸리는 금지·형식 규칙. */
|
||
rules: string;
|
||
/** 한 번에 받아 올 항목 수의 상한. 프롬프트의 숫자와 같아야 한다 — 어긋나면 잘라 버리게 된다. */
|
||
maxItems: number;
|
||
}
|
||
|
||
/**
|
||
* 모든 아이템에 걸리는 규칙.
|
||
*
|
||
* ★ 2번(빈 값을 지어내지 않는다)과 3번(열리는 출처)이 이 레포의 절대규칙을 프롬프트로 옮긴 것이다.
|
||
* 모델이 이걸 어기면 검증기가 뒤에서 걸러야 하는데, 걸러진 항목은 결국 화면에서 빈자리가 된다.
|
||
*/
|
||
export const SECTION_PROMPT_RULES = `
|
||
[공통 규칙]
|
||
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
|
||
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.
|
||
3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.
|
||
4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. 애매한 걸 "확인" 으로 올리지 않는다.
|
||
5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.
|
||
6. 설명 문장은 항목당 두 문장을 넘기지 않는다.
|
||
7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.
|
||
`;
|
||
|
||
export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
|
||
songs: {
|
||
kind: 'songs',
|
||
label: '가요 다방',
|
||
maxItems: 8,
|
||
task: `[해야 할 일]
|
||
[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.
|
||
1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.
|
||
|
||
[스키마]
|
||
{ "kind":"songs", "version":1, "title":"가요 다방", "subtitle":"...", "items":[
|
||
{ "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡",
|
||
"year":1966, "label":"음반사", "labelColor":"#d4551f",
|
||
"story":"곡의 배경 (두 문장 이내, 가사 없이)",
|
||
"connection":"[업소]와 이 곡을 잇는 한 문장",
|
||
"verified":"확인|확인필요",
|
||
"source":{"name":"출처명","url":"https://..."} } ] }`,
|
||
rules: `
|
||
[이 아이템만의 규칙]
|
||
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
|
||
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
|
||
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.
|
||
`,
|
||
},
|
||
|
||
people: {
|
||
kind: 'people',
|
||
label: '인물 열전',
|
||
maxItems: 10,
|
||
task: `[해야 할 일]
|
||
[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.
|
||
문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.
|
||
|
||
[스키마]
|
||
{ "kind":"people", "version":1, "title":"인물 열전", "items":[
|
||
{ "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가",
|
||
"oneLine":"한 문장 소개", "imageQuery":"사진 검색어",
|
||
"verified":"확인|확인필요",
|
||
"source":{"name":"출처명","url":"https://..."} } ] }`,
|
||
rules: `
|
||
[이 아이템만의 규칙]
|
||
· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다.
|
||
· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.
|
||
· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.
|
||
`,
|
||
},
|
||
|
||
chronicle: {
|
||
kind: 'chronicle',
|
||
label: '시간의 골목',
|
||
maxItems: 14,
|
||
task: `[해야 할 일]
|
||
[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.
|
||
가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.
|
||
|
||
[스키마]
|
||
{ "kind":"chronicle", "version":1, "title":"시간의 골목", "items":[
|
||
{ "year":1899, "title":"사건 이름", "summary":"두 문장 이내",
|
||
"place":"지금 가 볼 수 있는 자리", "turning":true,
|
||
"verified":"확인|확인필요",
|
||
"source":{"name":"출처명","url":"https://..."} } ] }`,
|
||
rules: `
|
||
[이 아이템만의 규칙]
|
||
· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.
|
||
· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.
|
||
· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.
|
||
`,
|
||
},
|
||
|
||
postcard: {
|
||
kind: 'postcard',
|
||
label: '오늘의 엽서',
|
||
maxItems: 12,
|
||
task: `[해야 할 일]
|
||
[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.
|
||
사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.
|
||
|
||
[스키마]
|
||
{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[
|
||
{ "line":"한 문장", "hashtags":["#태그"], "place":"장소",
|
||
"postmark":"소인에 찍을 짧은 지명",
|
||
"verified":"확인|확인필요",
|
||
"source":{"name":"출처명","url":"https://..."} } ] }`,
|
||
rules: `
|
||
[이 아이템만의 규칙]
|
||
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
|
||
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
|
||
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.
|
||
`,
|
||
},
|
||
|
||
quiz: {
|
||
kind: 'quiz',
|
||
label: '뒤집어 보는 질문',
|
||
maxItems: 12,
|
||
task: `[해야 할 일]
|
||
[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.
|
||
질문은 검색하면 바로 나오는 단답형이 아니라 "왜" 와 "어떻게" 를 묻는 것으로 한다.
|
||
|
||
[스키마]
|
||
{ "kind":"quiz", "version":1, "title":"뒤집어 보는 질문", "items":[
|
||
{ "question":"질문 한 문장", "hint":"두 문장 이내 힌트",
|
||
"topic":"관련 장소·주제", "level":"초등|중등|어른",
|
||
"verified":"확인|확인필요",
|
||
"source":{"name":"출처명","url":"https://..."} } ] }`,
|
||
rules: `
|
||
[이 아이템만의 규칙]
|
||
· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.
|
||
· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.
|
||
· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.
|
||
`,
|
||
},
|
||
};
|
||
|
||
export interface SectionPromptContext {
|
||
/** 업소 이름. 빈 값이면 [업소] 줄을 아예 빼고 지역만으로 묻는다. */
|
||
storeName?: string;
|
||
/** 업종 라벨('숙박' · '카페'). 업소 이름이 있을 때만 쓴다. */
|
||
industryLabel?: string;
|
||
/** 지명("전북 군산시"). 이 값이 없으면 프롬프트를 만들 수 없다. */
|
||
region: string;
|
||
/** 도로명 주소. 지명과 다를 때만 한 줄 더 붙는다. */
|
||
address?: string;
|
||
}
|
||
|
||
/**
|
||
* 붙여넣으면 바로 답이 나오는 프롬프트.
|
||
*
|
||
* ★ `[지역]` 같은 빈칸을 남기지 않는다. 빈칸이 있으면 사장님이 못 채우고 그대로 보내고,
|
||
* 모델은 빈칸을 지명으로 착각해 엉뚱한 곳 이야기를 지어낸다.
|
||
* ★ 서버가 지역 단위로 돌 때는 업소가 없다(같은 지역 사이트가 나눠 쓰는 값이다).
|
||
* 그때는 [업소] 줄을 빼고 `connection` 같은 업소 연결 필드는 자연히 비게 둔다 —
|
||
* 업소 이름을 하나 골라 넣으면 그 집 이야기가 옆집 사이트에 실린다.
|
||
*/
|
||
export function buildSectionPrompt(spec: SectionPromptSpec, ctx: SectionPromptContext): string {
|
||
const region = ctx.region.trim();
|
||
const store = (ctx.storeName ?? '').trim();
|
||
const address = (ctx.address ?? '').trim();
|
||
|
||
const lines = [`너는 지역 콘텐츠 리서처다. 아래 조건에 맞는 JSON 하나만 출력한다.`, ''];
|
||
if (store) lines.push(`[업소] ${store}${ctx.industryLabel ? ` (${ctx.industryLabel})` : ''}`);
|
||
lines.push(`[지역] ${region}`);
|
||
if (address && address !== region) lines.push(`[주소] ${address}`);
|
||
lines.push('');
|
||
lines.push(
|
||
store
|
||
? `아래 '해야 할 일'에서 [지역] = ${region}, [업소] = ${store}.`
|
||
: `아래 '해야 할 일'에서 [지역] = ${region}. 업소가 지정되지 않았으므로 특정 업소를 가리키는 문장(connection 등)은 쓰지 않는다.`,
|
||
);
|
||
lines.push('');
|
||
|
||
return `${lines.join('\n')}
|
||
${spec.task}
|
||
${SECTION_PROMPT_RULES}${spec.rules}`;
|
||
}
|