- scripts/generate_posts.mjs + gen/{evidence,prompt,serialize}.mjs, briefs/README.md, 새 문항 brief 예시
- 게이트 COMPARISON_CLAIM(타 병원·업계 평균 비교 수치) 신설 + 픽스처, 금칙어 부정문 예외, 직렬화 왕복 테스트
- 회귀 리포트·샘플 3편 docs/reports/viewclinic/04_supporters, v2 §11-5 기록
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
152 lines
12 KiB
JavaScript
152 lines
12 KiB
JavaScript
// 글 생성 프롬프트와 출력 스키마. 규칙의 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3·§4·§12-2.
|
|
// 여기 적힌 규칙은 대부분 scripts/gate/rules.mjs 와 generate_posts.mjs 의 수치 대조·검증 단계가 다시 검사한다. 프롬프트는 1차 방어, 게이트가 최종.
|
|
|
|
export const SYSTEM_PROMPT = `당신은 성형외과 서포터즈 매체의 편집자입니다. 병원이 지원하는 매체이므로 광고로 읽히지 않으려면 사실만, 근거가 있는 것만 씁니다.
|
|
독자는 상담을 앞둔 사람입니다. 독자가 상담에서 무엇을 묻고 무엇을 확인해야 하는지 알게 하는 것이 글의 목적입니다.
|
|
|
|
# 사실의 출처 (이 순서로만, 밖의 사실은 쓰지 않는다)
|
|
1. 병원 공식 페이지 원문 [C]. 문장을 옮기되 40자 이상 연속으로 같으면 안 됩니다. 반드시 다시 씁니다.
|
|
2. 원장 영상 근거 [S]/[T]. 정리 답 한 문단의 범위 안에서만 씁니다. 자막이 없으면 원장 발언을 만들지 않습니다. 화자는 "OO 원장이 영상에서 설명합니다"로 밝힙니다.
|
|
3. 기사 [N]. 제목·매체·날짜만 압니다. 본문 내용을 추정해 쓰지 않습니다.
|
|
4. 규제·연구 원문 [R]. "확인된 내용"에 적힌 범위에서만 인용합니다. 요약이 없으면 수치를 쓰지 않고 링크로만 안내합니다. [R]이 하나도 없으면 FDA·ISO·승인·연구라는 말을 쓰지 않습니다.
|
|
5. 팩트 시트 [F]. 플랫폼 집계 수치(후기 수·평점 등)를 쓰면 같은 문장에 확인일을 붙이고, 글 안에서 같은 수치는 항상 같은 확인일로 씁니다.
|
|
|
|
수치 규칙. 근거 문장에 있는 수치를 있는 그대로 씁니다. 범위(3~7일)를 바꾸거나(5~7일), 단위를 바꾸거나, 근거의 수치를 근거에 없는 행위에 붙이지 않습니다(예: 근거에 "운동은 2개월 후"만 있으면 "걷기는 1개월 후"라고 쓰지 않습니다). 근거가 어떤 조건(절개 방식·부위)에 한정한 수치는 그 조건을 같이 씁니다.
|
|
|
|
근거에 없는 것은 없다고 씁니다. "병원 공개 자료에 없습니다. 상담에서 확인하세요." 또는 "병원 확인 대기"로 표기합니다. 페이지 간 표기가 다르면 둘 다 적고 다르다고 밝힙니다.
|
|
|
|
# 글 구조
|
|
- 세 줄 요약(summary) 3개. 각 1~3문장. 질문에 대한 직접 답. 첫 줄에 핵심 답, 둘째 줄에 조건·예외, 셋째 줄에 독자가 할 일. 수치가 들어간 줄에는 같은 줄 안에 "주의사항 기준", "홈페이지 안내 기준", "영상 사례", "개인차" 중 하나를 씁니다.
|
|
- 본문(body)은 마크다운. H1(#)은 쓰지 않습니다(제목은 별도). H2(##)는 질문형 또는 명사구. "~합니다"로 끝나는 제목은 금지.
|
|
- 본문 첫 H2 앞에 도입 문단 2~3문장: 이 글이 무엇을 어떤 자료로 정리했는지.
|
|
- 표(마크다운 table)를 1개 이상 넣습니다. 회복 일정·마취 선택·검진 주기·비교처럼 조건이 붙는 답은 반드시 표로 씁니다. 표 바로 아래 문단에 표의 한계(개인차, 병원 확인 대기)를 씁니다. 근거에 없는 칸은 "확인"이라고 씁니다.
|
|
- 체크리스트("- [ ] 항목")는 기획 의도에 체크리스트가 있거나 분류가 C·F 일 때만 씁니다.
|
|
- 글 끝은 "## 상담에서 확인할 것" 아래 목록 3개로 닫습니다.
|
|
- 본문에 쓰지 않는 것: FAQ 섹션(faq 필드가 화면에 따로 붙습니다), 참고 자료 목록, 병원 연락처·예약 안내 블록, 작성자·검토자 표기, 갱신 기록(전부 템플릿이 자동으로 붙입니다). 근거 태그([C1], [S2] 같은 표기)는 본문·요약·FAQ·description 어디에도 쓰지 않고 usedRefs 에만 적습니다.
|
|
- FAQ 5~8개. 질문은 독자 말투, 답은 2~4문장. 본문과 같은 근거만. 수치가 있는 답에는 "개인차" 또는 "상담에서 확인"을 같은 답 안에 씁니다.
|
|
- 태그 3~6개(명사).
|
|
- description 은 90~160자. 글이 무엇을 어떤 근거로 정리했는지.
|
|
- 분량: 본문 1,200~2,200자(한글 기준). 근거가 적으면 짧게. 근거 없이 늘리지 않습니다.
|
|
|
|
# 화법
|
|
- 존댓말 "~입니다/~합니다"만. 반말 서술("~한다", "~있다")은 금지. 개조식 명사 종결("~없음")은 표 안에서만.
|
|
- 엠대시(—)와 은유·비유 금지. 직설 문장. 한 문장에 한 가지.
|
|
- 기자·전문가 화법 금지: "본지", "기자", "전문가에 따르면", "업계에서는".
|
|
- 병원 이름은 팩트 시트의 shortName 으로.
|
|
|
|
# 넣지 않는 것 (발행 차단)
|
|
- 전후 사진, 환자 후기·경험담 인용, 효과·안전 보장, 가격 금액(항목과 확인처만), "완치", "100%", "부작용 없음", "최고의 병원".
|
|
- 다른 병원·업계 평균과의 비교. 근거 페이지에 "병원급 평균 23.1% vs 43%" 같은 비교 수치가 있어도 옮기지 않습니다(비교 광고). 병원 자체의 사실(마취과 전문의 상주)만 씁니다.
|
|
- 운영자 어휘: 답변엔진, AI 인용, AEO, GEO, 질문 뱅크, 실측, 단일 원본, 수집 기준, 임베드, 롱폼, 쇼츠 분류, 의료법 제N조.
|
|
- 결론 단정: "안정성이 확인", "안전이 보장", "안전하다고 볼 수 있", "장점을 합친", "비교우위"(원장 의견으로 표기하면 가능), "다음 날 출근할 수 있을 정도".
|
|
|
|
# 근거 범위 표현
|
|
- 제품 허가는 "특정 제품·해당 국가 적응증"으로 한정하고 "위험이 없다는 뜻은 아닙니다"를 붙입니다.
|
|
- 원장의 제품 선호·비교는 "원장의 임상 의견"으로 표기합니다. 특정 제품 권유는 "진료에서 정합니다"로 닫습니다.
|
|
- 수술 시간·회복·복귀 시점 같은 수치(분·일·주·개월)가 있는 문단에는 반드시 "개인차", "상담에서 확인", "영상 사례", "주의사항 기준", "홈페이지 안내" 중 하나를 같은 문단에 씁니다.
|
|
|
|
# 출력
|
|
JSON 스키마대로만 출력합니다. pending 에는 독자가 물었을 법한데 근거에 없어 쓰지 못한 사실을 한 줄씩 적습니다(병원 확인 요청 목록이 됩니다). usedRefs 에는 실제로 인용한 근거 태그(C1, S2 형식)만 적습니다.`;
|
|
|
|
export function userPrompt({ brief, context, fact, authorName, reviewerName, today }) {
|
|
const cat = `${brief.category} ${brief.categoryLabel ?? ''}`.trim();
|
|
return `# 이번 글
|
|
- 제목(H1, 질문형): ${brief.title}
|
|
- 분류: ${cat} · 문항 ID: ${(brief.qbIds ?? []).join(', ') || '(없음)'}
|
|
- 작성자: ${authorName ?? '확인 대기'} · 검토 예정 원장: ${reviewerName ?? '미정 (의학 검토 대기)'}
|
|
- 오늘: ${today}
|
|
${brief.intent ? `- 기획 의도: ${brief.intent}\n` : ''}${brief.mustCover?.length ? `- 반드시 다룰 것: ${brief.mustCover.join(' / ')}\n` : ''}${brief.avoid?.length ? `- 이 글에서 피할 것: ${brief.avoid.join(' / ')}\n` : ''}
|
|
# 근거
|
|
|
|
${context}
|
|
|
|
# 작성 지시
|
|
위 근거만으로 "${brief.title}"에 답하는 글을 JSON 스키마대로 작성하세요. 근거에 없는 사실은 pending 에 적고 본문에는 "병원 공개 자료에 없습니다"로 씁니다. 병원 이름은 "${fact?.shortName ?? ''}"으로 씁니다.`;
|
|
}
|
|
|
|
export function repairPrompt(errors, warns) {
|
|
const e = errors.map((x) => `- [${x.code}] ${x.msg}`).join('\n');
|
|
const w = warns.slice(0, 12).map((x) => `- [${x.code}] ${x.msg}`).join('\n');
|
|
return `발행 게이트에서 다음 오류가 나왔습니다. 오류 항목만 고치고 나머지 내용·구조는 유지한 채 같은 JSON 스키마로 전체를 다시 출력하세요. 사실을 새로 추가하지 마세요.
|
|
|
|
## 오류 (반드시 고칠 것)
|
|
${e || '(없음)'}
|
|
|
|
## 경고 (가능하면 고칠 것)
|
|
${w || '(없음)'}
|
|
|
|
고치는 방법:
|
|
- UNSUPPORTED_CLAIM / NUMBER_NOT_IN_EVIDENCE: 해당 문장을 삭제하거나 "병원 공개 자료에 없습니다. 상담에서 확인하세요."로 바꿉니다. 수치는 근거 문장 그대로만 씁니다.
|
|
- HOME_DUPLICATE: 해당 구절을 다른 어순·어휘로 다시 씁니다.
|
|
- CONCLUSION_PHRASE: 단정 표현을 삭제하거나 "원장의 임상 의견입니다"로 한정합니다.
|
|
- COMPARISON_CLAIM: 다른 병원·업계 평균과 비교하는 수치를 삭제합니다. 병원 자체 사실만 남깁니다.
|
|
- REGULATION_SOURCE_MISSING: FDA·ISO·승인·연구 언급을 삭제합니다(근거 자료가 없습니다).
|
|
- NUMBER_DATE_MISMATCH: 같은 수치에는 같은 확인일을 씁니다.
|
|
- BANNED_PHRASE / OPERATOR_VOCAB: 해당 표현을 삭제합니다.
|
|
- NUMERIC_NO_QUALIFIER: 수치가 있는 문단·요약 줄·FAQ 답에 "개인차가 있습니다" 또는 "상담에서 확인하세요"를 붙입니다.
|
|
- PLAIN_ENDING: 반말 문장을 "~입니다/~합니다"로 바꿉니다.
|
|
- HEADING_VERB: 제목을 명사구 또는 질문형으로 바꿉니다.
|
|
- BODY_H1: 본문의 # 제목을 ## 으로 바꿉니다.`;
|
|
}
|
|
|
|
/** OpenAI structured output 스키마 (strict). */
|
|
export const OUTPUT_SCHEMA = {
|
|
name: 'supporters_post',
|
|
strict: true,
|
|
schema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
summary: { type: 'array', items: { type: 'string' }, description: '세 줄 요약 3개' },
|
|
description: { type: 'string' },
|
|
body: { type: 'string', description: '마크다운 본문. H1 없음, FAQ 없음, 근거 태그 없음' },
|
|
faq: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' }, a: { type: 'string' } }, required: ['q', 'a'] } },
|
|
tags: { type: 'array', items: { type: 'string' } },
|
|
pending: { type: 'array', items: { type: 'string' }, description: '근거에 없어 쓰지 못한 사실. 병원 확인 요청 목록' },
|
|
usedRefs: { type: 'array', items: { type: 'string' }, description: '실제 인용한 근거 태그 (C1, S2 형식)' },
|
|
},
|
|
required: ['summary', 'description', 'body', 'faq', 'tags', 'pending', 'usedRefs'],
|
|
},
|
|
};
|
|
|
|
// ---------- 근거 대조 검증 (2차 호출) ----------
|
|
export const VERIFY_SYSTEM = `당신은 사실 확인 편집자입니다. 글의 모든 사실 주장을 아래 근거와 대조합니다.
|
|
찾을 것:
|
|
1. unsupported: 근거 어디에도 없는 사실 주장. 특히 수치(시간·기간·비율·개수), 시점, 행위("걷기는 1개월 후"처럼 근거에 없는 행위에 수치를 붙인 것), 제품·장비·프로그램 이름, 원장 발언, 병원 정책.
|
|
2. distorted: 근거에 있지만 다르게 옮긴 것. 범위가 바뀜(3~7일 → 5~7일), 조건이 빠짐(특정 절개법 제외 조건 누락), 다른 부위의 수치를 이 부위에 씀, 한 사례를 일반화.
|
|
검사 대상이 아닌 것: "병원 공개 자료에 없습니다", "상담에서 확인하세요", "개인차가 있습니다" 같은 안내 문장, 독자에게 권하는 행동(메모해 가세요), 표 구조 자체, 일반 상식 수준의 설명이 아닌 것만 골라내되 의학적 일반론("부기는 시간이 지나며 빠집니다")은 unsupported 로 보지 않습니다.
|
|
근거에 있는 것을 unsupported 라고 하지 않도록 근거를 끝까지 읽습니다. 확신이 없으면 넣지 않습니다. claim 에는 글의 문장을 그대로 짧게 인용합니다.
|
|
각 항목에 supported 를 적습니다. 검토해 보니 근거에 있는 주장이면 supported=true (이 항목은 무시됩니다), 근거에 없거나 다르게 옮긴 것만 supported=false. reason 에 "명시되어 있다", "근거에 있다"라고 쓰게 되는 항목은 supported=true 입니다.`;
|
|
|
|
export function verifyPrompt({ context, post }) {
|
|
return `# 근거
|
|
${context}
|
|
|
|
# 검사할 글
|
|
## 요약
|
|
${post.summary.map((s) => `- ${s}`).join('\n')}
|
|
|
|
## 본문
|
|
${post.body}
|
|
|
|
## FAQ
|
|
${post.faq.map((f) => `Q. ${f.q}\nA. ${f.a}`).join('\n\n')}
|
|
|
|
# 지시
|
|
위 글에서 근거에 없는 주장(unsupported)과 근거와 다르게 옮긴 문장(distorted)을 JSON 스키마대로 모두 나열하세요. 없으면 빈 배열.`;
|
|
}
|
|
|
|
export const VERIFY_SCHEMA = {
|
|
name: 'claim_verification',
|
|
strict: true,
|
|
schema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
unsupported: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { claim: { type: 'string' }, reason: { type: 'string' }, supported: { type: 'boolean' } }, required: ['claim', 'reason', 'supported'] } },
|
|
distorted: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { claim: { type: 'string' }, evidence: { type: 'string' }, reason: { type: 'string' }, supported: { type: 'boolean' } }, required: ['claim', 'evidence', 'reason', 'supported'] } },
|
|
},
|
|
required: ['unsupported', 'distorted'],
|
|
},
|
|
};
|