/** * 지역 이야기 아이템의 **프롬프트** — "이 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 = { 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}`; }