o2o-infinith-demo/supporters/scripts/gen/evidence.mjs
Haewon Kam aa5b05227f feat(supporters): 회복 일정 플래너를 템플릿·워커에 통합 (/plan·/en/plan), /recovery·/stay 를 /plan 으로 정리
- 템플릿: Planner.astro, lib/plan.ts·tour.ts, styles/plan.css, planStrings, pages plan·en/plan·404. Base 내비 회복 일정 → /plan, 언어 짝 /plan↔/en/plan, 옛 주소 리다이렉트(vercel.json), 사이트맵
- 워커 planner 단계(recovery 다음): scripts/build_planner_data.mjs 가 업종별 기본 규칙표(scripts/template/planner/procedures.plastic|derm.json)에 병원 시술 페이지 원문(recoveryNotes)을 matchKeywords 로 붙이고, 장소는 briefs/<clinic>/planner.places.json(큐레이션) 또는 범용 기본표(관광공사 기준 좌표)로 만든다
- 브리프: viewclinic·oracle 큐레이션 장소. 빈 템플릿(관광 데이터 없음)도 빌드·검증 통과(plan.test 14건)
- 이전 세션의 미커밋 작업(피부과 수집·OCR·게이트·언어 스위치, stay 페이지 제거)도 이 커밋에 함께 들어감. docs/prd 변경은 제외

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 11:27:59 +09:00

160 lines
11 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 { /* 그대로 */ }
s = s.replace(/\/+$/, '').toLowerCase();
// 쿼리가 곧 페이지인 사이트(오라클 index.php?doc=N)를 위해 추적 파라미터를 뺀 쿼리를 키에 넣는다. collect_evidence.normalize 와 같은 규칙.
// 2026-09-10: 쿼리를 지우자 시술 페이지 84개가 마지막 한 페이지(휴지기탈모)로 겹쳐 글 근거가 전부 틀렸다.
const keep = [...x.searchParams.entries()].filter(([k]) => !/^(utm_|fbclid|gclid|ref$|_ga)/i.test(k)).sort(([a], [b]) => a.localeCompare(b));
if (keep.length) s += '?' + keep.map(([k, v]) => `${k}=${v}`).join('&').toLowerCase();
return s;
} 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 };
}
/** ocr_evidence.mjs 가 붙인 OCR 섹션(source: "ocr"). 이미지 글자를 기계로 읽은 것이라 원문(HTML)과 구분해 표기한다 */
export const ocrSections = (p) => (p.sections ?? []).filter((s) => s?.source === 'ocr' && s.text);
/** 페이지 items → 읽기용 텍스트. 표는 마크다운 행으로. OCR 섹션은 뒤에 출처 표기와 함께 붙인다. */
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);
}
for (const s of ocrSections(p)) lines.push('', `[병원 페이지 이미지 글자 OCR] ${s.heading ?? ''} (이미지 ${s.images?.length ?? 1}장을 기계로 읽은 병원 페이지 원문. 병원이 공개한 자료로 취급해 근거로 쓴다. 글자가 깨졌거나 문맥이 맞지 않는 부분만 쓰지 않고, 수치·제품명은 문장이 온전할 때만 옮긴다)`, s.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);
const ocr = ocrSections(p);
if (!p.items?.length && !ocr.length) { missing.push(`병원 페이지 본문 0자${imgText ? ' (이미지 글자, OCR 없음)' : ''}: ${url}`); return; }
cparts.push(`### [C${cparts.length + 1}] ${p.title ?? ''}\nURL: ${p.url}\n수집일: ${p.fetchedAt}${ocr.length ? ' · 본문 일부 또는 전부가 이미지 글자 OCR' : ''}\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 ?? '?'); }