// 글 생성기의 근거 컨텍스트 조립. v2 §4 근거 프로토콜의 순서를 그대로 입력 구조로 만든다. // 1 병원 공식 페이지 원문 (evidence//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 ?? '?'); }