- 게이트: DISCLOSURE_TOP_MISSING, APPROVED_*, UNAPPROVED_IN_INDEXABLE_BUILD, PRESS_VOICE 규칙화, 픽스처 추가 (16/16) - 생성기: --site 옵션, 병원 확인 답변(clinicAnswers) 근거 [A] - default_briefs.mjs 첫 배치 자동 기획, workers/supporters-build/run.mjs - 랜딩: SAMPLE REPORT 라벨 "실제 사례"로 (haewon 지시) - v2 §11-6·§11-7 기록, E2E 리포트 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
150 lines
9.9 KiB
JavaScript
150 lines
9.9 KiB
JavaScript
// 글 생성기의 근거 컨텍스트 조립. v2 §4 근거 프로토콜의 순서를 그대로 입력 구조로 만든다.
|
|
// 1 병원 공식 페이지 원문 (evidence/<clinic>/pages) → [C1..]
|
|
// 2 쇼츠 정리 답 (videos.json.shortsInfo[].answer) → [S1..]
|
|
// (선택) 자막 전사 brief.evidence.transcripts → [T1..]
|
|
// 3 원장 기고·인터뷰 기사 (news.json, 제목·매체·날짜만) → [N1..]
|
|
// 4 규제·연구 원문 (brief.evidence.regulation) → [R1..]
|
|
// 5 플랫폼 집계·팩트 시트 (factSheet.json) → [F]
|
|
// 없는 근거는 missing 으로 돌려주고, 모델에게는 "없다"고 알린다. 사실을 만들어 채우지 않는다.
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
export function normUrl(u) {
|
|
try {
|
|
const x = new URL(u);
|
|
let s = x.host.replace(/^www\./, '') + x.pathname;
|
|
try { s = decodeURIComponent(s); } catch { /* 그대로 */ }
|
|
return s.replace(/\/+$/, '').toLowerCase();
|
|
} catch { return String(u).replace(/\/+$/, '').toLowerCase(); }
|
|
}
|
|
|
|
export function loadEvidence(evDir) {
|
|
const index = JSON.parse(readFileSync(join(evDir, 'index.json'), 'utf8'));
|
|
const byUrl = new Map();
|
|
const pages = [];
|
|
const pagesDir = join(evDir, 'pages');
|
|
for (const f of readdirSync(pagesDir).filter((x) => x.endsWith('.json'))) {
|
|
const p = JSON.parse(readFileSync(join(pagesDir, f), 'utf8'));
|
|
pages.push(p);
|
|
for (const u of [p.url, p.requestedUrl]) if (u) byUrl.set(normUrl(u), p);
|
|
}
|
|
const doctorsPath = join(evDir, 'doctors.json');
|
|
const doctors = existsSync(doctorsPath) ? JSON.parse(readFileSync(doctorsPath, 'utf8')).doctors ?? [] : [];
|
|
const homePath = join(evDir, 'home_text.txt');
|
|
const homeText = existsSync(homePath) ? readFileSync(homePath, 'utf8') : '';
|
|
return { clinic: index.clinic, start: index.start, fetchedAt: index.fetchedAt, pages, byUrl, doctors, homeText };
|
|
}
|
|
|
|
/** 페이지 items → 읽기용 텍스트. 표는 마크다운 행으로. */
|
|
export function pageToText(p, { maxChars = 9000 } = {}) {
|
|
const lines = [];
|
|
for (const it of p.items ?? []) {
|
|
if (!it?.text && it?.kind !== 'table') continue;
|
|
if (it.kind === 'h') lines.push(`${'#'.repeat(Math.min(Math.max(it.level ?? 2, 2), 4))} ${it.text}`);
|
|
else if (it.kind === 'li') lines.push(`- ${it.text}`);
|
|
else if (it.kind === 'table') for (const row of it.rows ?? []) lines.push('| ' + row.map((c) => String(c).replace(/\|/g, '/')).join(' | ') + ' |');
|
|
else lines.push(it.text);
|
|
}
|
|
let t = lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
if (t.length > maxChars) t = t.slice(0, maxChars) + `\n…(이 페이지는 ${t.length - maxChars}자가 더 있으나 생략됨. 생략된 부분의 사실은 쓰지 않는다)`;
|
|
return t;
|
|
}
|
|
|
|
const SOURCE_TYPE_LABEL = { clinic: '병원 제공', doctor: '원장 설명', product: '제품 정보', regulation: '규제·연구', platform: '플랫폼 집계', secondary: '2차 자료' };
|
|
|
|
/**
|
|
* brief 하나에 대한 컨텍스트 문자열과 sources 배열을 만든다.
|
|
* brief.evidence = { pages: [url], shorts: [videoId], transcripts: [{id,title,speaker,text}], news: [url], regulation: [{label,url,note}], platform: bool }
|
|
*/
|
|
export function buildContext(brief, { ev, fact, authors, videos, news, answers, today }) {
|
|
const parts = [];
|
|
const missing = [];
|
|
const sources = [];
|
|
const evd = brief.evidence ?? {};
|
|
const clinicName = fact?.shortName ?? fact?.name ?? ev.clinic;
|
|
const seenSrc = new Set();
|
|
const pushSource = (s) => { const k = normUrl(s.url); if (seenSrc.has(k)) return; seenSrc.add(k); sources.push(s); };
|
|
|
|
// 1. 병원 공식 페이지
|
|
const cparts = [];
|
|
(evd.pages ?? []).forEach((url, i) => {
|
|
const p = ev.byUrl.get(normUrl(url));
|
|
if (!p) { missing.push(`병원 페이지가 근거 수집 결과에 없음: ${url}`); return; }
|
|
const imgText = Array.isArray(p.flags) ? p.flags.includes('textInImages') : Boolean(p.flags?.textInImages);
|
|
if (!p.items?.length) { missing.push(`병원 페이지 본문 0자${imgText ? ' (이미지 글자)' : ''}: ${url}`); return; }
|
|
cparts.push(`### [C${cparts.length + 1}] ${p.title ?? ''}\nURL: ${p.url}\n수집일: ${p.fetchedAt}\n\n${pageToText(p)}`);
|
|
pushSource({ label: `${clinicName} 홈페이지 · ${cleanTitle(p.title, clinicName)}`, url: p.url, accessed: p.fetchedAt ?? today, type: 'clinic' });
|
|
});
|
|
parts.push(`## 1. 병원 공식 페이지 원문 (문장을 그대로 옮기지 말 것. 40자 이상 연속 일치는 발행 차단)\n\n${cparts.length ? cparts.join('\n\n') : '(제공된 페이지 없음)'}`);
|
|
|
|
// 2. 쇼츠 정리 답 + 자막
|
|
const sparts = [];
|
|
(evd.shorts ?? []).forEach((id) => {
|
|
const s = (videos?.shortsInfo ?? []).find((x) => x.id === id);
|
|
if (!s?.answer) { missing.push(`쇼츠 정리 답 없음: ${id}`); return; }
|
|
sparts.push(`### [S${sparts.length + 1}] 영상 「${s.title}」 (게시 ${s.published ?? '?'}, 조회수 ${fmtNum(s.views)} · 확인 ${videos.fetchedAt})\n편집자가 정리한 한 문단(이 문단의 범위 안에서만 영상 내용을 쓴다):\n${s.answer}`);
|
|
pushSource({ label: `유튜브 · ${s.title}`, url: `https://www.youtube.com/watch?v=${s.id}`, accessed: videos.fetchedAt ?? today, type: 'doctor' });
|
|
});
|
|
(evd.transcripts ?? []).forEach((t) => {
|
|
if (!t?.text) return;
|
|
sparts.push(`### [T${sparts.length + 1}] 영상 「${t.title ?? t.id}」 자막 전사${t.speaker ? ` (화자: ${t.speaker})` : ''}\n${String(t.text).slice(0, 12000)}`);
|
|
pushSource({ label: `유튜브 · ${t.title ?? t.id}${t.speaker ? ` (${t.speaker})` : ''}`, url: `https://www.youtube.com/watch?v=${t.id}`, accessed: today, type: 'doctor' });
|
|
});
|
|
parts.push(`## 2. 원장 영상 근거 (여기 없는 발언은 만들지 않는다)\n\n${sparts.length ? sparts.join('\n\n') : '(영상 근거 없음. 영상 발언을 인용하지 않는다)'}`);
|
|
|
|
// 3. 기사
|
|
const nparts = [];
|
|
(evd.news ?? []).forEach((url) => {
|
|
const n = (news?.items ?? []).find((x) => normUrl(x.url) === normUrl(url));
|
|
if (!n) { missing.push(`뉴스 항목 없음: ${url}`); return; }
|
|
nparts.push(`### [N${nparts.length + 1}] ${n.outlet} · ${n.date} · 「${n.title}」 (${n.kind === 'release' ? '병원 보도자료' : n.kind === 'column' ? '원장 기고·인터뷰' : '언론 언급'})\n본문은 제공되지 않는다. 제목·매체·날짜 범위에서만 언급할 수 있다.`);
|
|
pushSource({ label: `${n.outlet} · ${n.title} (${n.date})`, url: n.url, accessed: today, type: n.kind === 'release' ? 'clinic' : n.kind === 'column' ? 'doctor' : 'secondary' });
|
|
});
|
|
parts.push(`## 3. 기사\n\n${nparts.length ? nparts.join('\n\n') : '(기사 근거 없음)'}`);
|
|
|
|
// 4. 규제·연구 원문
|
|
const rparts = [];
|
|
(evd.regulation ?? []).forEach((r) => {
|
|
if (!r?.url) return;
|
|
rparts.push(`### [R${rparts.length + 1}] ${r.label}\nURL: ${r.url}\n확인된 내용: ${r.note ?? '요약 없음. 이 자료의 수치·결론을 인용하지 말고, 「원문에서 확인」으로 링크만 안내한다.'}`);
|
|
pushSource({ label: r.label, url: r.url, accessed: r.accessed ?? today, type: 'regulation' });
|
|
});
|
|
parts.push(`## 4. 규제·연구 원문\n\n${rparts.length ? rparts.join('\n\n') : '(규제·연구 근거 없음. FDA·ISO·승인·연구를 언급하지 않는다)'}`);
|
|
|
|
// 4b. 병원이 서면으로 확인해 준 답변 (apply_inputs → clinicAnswers.json). §6 4~7항. 병원 제공 근거로 친다.
|
|
const aparts = (answers?.items ?? []).map((a, i) => `### [A${i + 1}] ${a.question ?? a.key} (병원 확인 ${a.at}${a.by ? `, ${a.by}` : ''})\n${a.answer}`);
|
|
if (aparts.length) {
|
|
parts.push(`## 4b. 병원 확인 답변 (병원이 서면으로 확인한 값. "병원 확인 기준"으로 인용한다)\n\n${aparts.join('\n\n')}`);
|
|
pushSource({ label: `${clinicName} 서면 확인 답변 (${(answers.items.at(-1)?.at) ?? today})`, url: fact?.url ?? '', accessed: today, type: 'clinic' });
|
|
}
|
|
|
|
// 5. 팩트 시트 (플랫폼 집계 포함)
|
|
if (fact) {
|
|
const slim = { ...fact };
|
|
delete slim._comment;
|
|
parts.push(`## 5. 병원 팩트 시트 (확인일 ${fact.checkedAt ?? '?'}, 다음 확인 ${fact.nextCheck ?? '?'}). 플랫폼 집계 수치를 쓰면 같은 문장에 확인일을 붙인다\n\n${JSON.stringify(slim, null, 1).slice(0, 7000)}`);
|
|
const surf = fact.surfaces ?? {};
|
|
for (const [k, s] of Object.entries(surf)) if (s?.url && evd.platform) pushSource({ label: `${s.label ?? k} · ${clinicName}`, url: s.url, accessed: s.checkedAt ?? fact.checkedAt ?? today, type: 'platform' });
|
|
}
|
|
|
|
// 6. 의료진 (이름·직함만. 약력은 authors.json 에 있는 것만)
|
|
const phys = Object.entries(authors?.physicians ?? {}).map(([id, p]) => `- ${id}: ${p.name} · ${p.title}${p.specialty ? ` · ${p.specialty}` : ''}`);
|
|
parts.push(`## 6. 의료진 (이 목록 밖의 원장 이름을 쓰지 않는다)\n\n${phys.join('\n') || '(등록된 의료진 없음)'}`);
|
|
|
|
// 7. 관련 영상 목록 (제목·화자만)
|
|
const vids = (brief.videos ?? []).map((v) => `- ${v.id} 「${v.title}」${v.speaker ? ` · ${v.speaker}` : ''}${v.published ? ` · ${v.published}` : ''}${v.note ? ` · ${v.note}` : ''}`);
|
|
parts.push(`## 7. 글에 붙는 영상 목록 (제목·화자만 안다. 내용은 2절에 있는 것만 쓴다)\n\n${vids.join('\n') || '(없음)'}`);
|
|
|
|
// brief 가 sources 를 직접 주면 그것을 우선(정답지 회귀용). type 없는 항목은 규칙상 실패이므로 secondary 로 채우고 경고.
|
|
const finalSources = brief.sources?.length
|
|
? brief.sources.map((s) => ({ ...s, accessed: s.accessed ?? today, type: s.type ?? 'secondary' }))
|
|
: sources;
|
|
|
|
return { text: parts.join('\n\n'), sources: finalSources, missing, typeLabel: SOURCE_TYPE_LABEL };
|
|
}
|
|
|
|
function cleanTitle(t, clinicName) {
|
|
return String(t ?? '').replace(new RegExp(`\\s*\\|\\s*${clinicName}.*$`), '').replace(/\s*\|\s*뷰성형외과.*$/, '').trim() || '페이지';
|
|
}
|
|
function fmtNum(n) { return typeof n === 'number' ? n.toLocaleString('ko-KR') : String(n ?? '?'); }
|