o2o-infinith-demo/supporters/scripts/generate_posts.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

363 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 글 생성기 (v2 §7-4). 기획 목록(brief) + 근거(evidence) → GPT-4 계열 → frontmatter 규격 md + 소스 게이트 + 근거 대조 + 비용 리포트.
//
// node scripts/generate_posts.mjs --clinic viewclinic --from-golden 정답지 18편의 문항·근거로 재생성 (회귀)
// node scripts/generate_posts.mjs --clinic viewclinic --brief briefs/x.json 기획 목록으로 생성
// 옵션: --only a,b --limit N --model gpt-4.1 --out DIR --dry-run(프롬프트만 저장) --mock(정답지를 모델 출력으로 대체, API 없이 파이프라인 검증)
// --no-verify(근거 대조 2차 호출 생략) --verify-model gpt-4.1 --warn-threshold 4(경고가 이 수 이상이면 수정 호출)
// --max-cost-usd 3 --price-in 2 --price-out 8 (USD / 1M tokens) --today YYYY-MM-DD --temperature 0.3
//
// 흐름: 컨텍스트(§4 순서) → 생성 → 후처리(근거 태그·중복 섹션 제거) → 소스 게이트 + 수치 대조 → 근거 대조(모델) → 오류면 수정 1회 → 저장 → 리포트
// 원칙: sources·videos·검토자 후보는 brief 에서 결정론적으로 조립하고 모델은 요약·본문·FAQ·태그만 쓴다.
// 검토 상태는 항상 pending (§3-7 사람 게이트). reviewedAt 은 생성기가 절대 쓰지 않는다.
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
import { join, dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as R from './gate/rules.mjs';
import { loadEvidence, buildContext } from './gen/evidence.mjs';
import { SYSTEM_PROMPT, userPrompt, repairPrompt, OUTPUT_SCHEMA, VERIFY_SYSTEM, verifyPrompt, VERIFY_SCHEMA } from './gen/prompt.mjs';
import { toMarkdown, gatePost } from './gen/serialize.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const SUP = here('../');
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); if (i >= 0 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1]; const eq = args.find((a) => a.startsWith(`--${k}=`)); return eq ? eq.slice(k.length + 3) : d; };
const flag = (k) => args.includes(`--${k}`);
const clinic = opt('clinic', 'viewclinic');
const today = opt('today', new Date().toISOString().slice(0, 10));
const model = opt('model', 'gpt-4.1');
const verifyModel = opt('verify-model', model);
const temperature = Number(opt('temperature', '0.3'));
const limit = Number(opt('limit', '0')) || Infinity;
const only = opt('only', '') ? opt('only').split(',').map((s) => s.trim()).filter(Boolean) : null;
const dryRun = flag('dry-run');
const mock = flag('mock');
const doVerify = !flag('no-verify') && !mock;
const warnThreshold = Number(opt('warn-threshold', '4'));
const maxRepairs = Number(opt('max-repairs', '2'));
// 단가 상한 (v2 §9-5): haewon 결정 2026-09-07, 한 번 실행에 $10. --max-cost-usd 0 이면 상한 없음.
const maxCostUsd = opt('max-cost-usd') === '0' ? Infinity : Number(opt('max-cost-usd', '10'));
const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 13).replace('T', '-');
const OUT = resolve(opt('out', join(SUP, 'generated', clinic, stamp)));
// 공개 가격표 (USD / 1M tokens, 2025 목록가). 실제 청구와 다를 수 있다. --price-in/--price-out 으로 덮어쓴다.
const PRICES = { 'gpt-4.1': [2, 8], 'gpt-4.1-mini': [0.4, 1.6], 'gpt-4.1-nano': [0.1, 0.4], 'gpt-4o': [2.5, 10], 'gpt-4o-mini': [0.15, 0.6] };
const priceOf = (m) => PRICES[m] ?? [Number(opt('price-in', 0)), Number(opt('price-out', 0))];
const priceIn = Number(opt('price-in', priceOf(model)[0]));
const priceOut = Number(opt('price-out', priceOf(model)[1]));
// ---------- .env (저장소 루트에서 위로 탐색) ----------
function loadEnv() {
let dir = SUP;
for (let i = 0; i < 4; i++) {
const p = join(dir, '.env');
if (existsSync(p)) {
for (const line of readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
dir = dirname(dir);
}
}
loadEnv();
// ---------- 데이터 ----------
// --site <dir> : 다른 사이트 디렉터리(템플릿 복제본)의 데이터·글을 쓴다. 워커가 병원별 작업 폴더로 돌릴 때.
const SITE = resolve(opt('site', SUP));
const DATA = join(SITE, 'src/data');
const POSTS = join(SITE, 'src/content/posts');
const EV = resolve(opt('evidence', join(SUP, '..', 'evidence', clinic)));
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
const fact = readJson(join(DATA, 'factSheet.json'));
const authors = readJson(join(DATA, 'authors.json'));
const videos = readJson(join(DATA, 'videos.json'));
const news = existsSync(join(DATA, 'news.json')) ? readJson(join(DATA, 'news.json')) : { items: [] };
const answers = existsSync(join(DATA, 'clinicAnswers.json')) ? readJson(join(DATA, 'clinicAnswers.json')) : { items: [] };
if (!existsSync(join(EV, 'index.json'))) { console.error(`근거 없음: ${EV}. 먼저 node scripts/collect_evidence.mjs --clinic ${clinic} --url <홈>`); process.exit(1); }
const ev = loadEvidence(EV);
const authorId = opt('author', Object.keys(authors.supporters ?? {})[0]);
const clinicHost = (() => { try { return new URL(fact.url).host.replace(/^www\./, ''); } catch { return ''; } })();
const CATEGORY_LABEL = { A: '병원 소개', B: '방문·예약', C: '선택 기준', D: '시술 정보', E: '안전·마취', F: '비용·이벤트', G: '후기·평판' };
// ---------- brief ----------
function briefsFromGolden() {
const shortsIds = new Set((videos.shortsInfo ?? []).map((s) => s.id));
return readdirSync(POSTS).filter((f) => f.endsWith('.md')).sort().map((f) => {
const { data } = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
const srcs = data.sources ?? [];
const isClinic = (u) => { try { return new URL(u).host.replace(/^www\./, '') === clinicHost; } catch { return false; } };
const isYt = (u) => /youtube\.com|youtu\.be/.test(u);
const vids = [...(data.videos ?? []), ...(data.video ? [data.video] : [])];
return {
id: basename(f, '.md'),
title: data.title, category: data.category, categoryLabel: data.categoryLabel, qbIds: data.qbIds ?? [],
reviewerCandidate: data.reviewer,
sources: srcs,
videos: data.videos ?? [], hero: data.hero, gallery: data.gallery ?? [], thumbnail: data.thumbnail,
evidence: {
pages: srcs.filter((s) => isClinic(s.url)).map((s) => s.url),
shorts: [...new Set(vids.map((v) => v.id).filter((id) => shortsIds.has(id)))],
news: srcs.filter((s) => !isClinic(s.url) && !isYt(s.url) && ['clinic', 'doctor', 'secondary'].includes(s.type)).map((s) => s.url),
regulation: srcs.filter((s) => s.type === 'regulation').map((s) => ({ label: s.label, url: s.url })),
platform: srcs.some((s) => s.type === 'platform'),
},
golden: f,
};
});
}
function briefsFromFile(p) { const j = readJson(resolve(p)); return Array.isArray(j) ? j : j.posts ?? []; }
let briefs = flag('from-golden') ? briefsFromGolden() : opt('brief') ? briefsFromFile(opt('brief')) : null;
if (!briefs) { console.error('--from-golden 또는 --brief <file> 이 필요합니다'); process.exit(1); }
if (only) briefs = briefs.filter((b) => only.includes(b.id));
briefs = briefs.slice(0, limit);
// ---------- 모델 호출 ----------
async function callModel(messages, { schema = OUTPUT_SCHEMA, useModel = model, temp = temperature } = {}) {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY 없음 (.env)');
const body = { model: useModel, temperature: temp, messages, response_format: { type: 'json_schema', json_schema: schema } };
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body) });
if (res.status === 429 || res.status >= 500) { lastErr = new Error(`${res.status} ${await res.text()}`); await new Promise((r) => setTimeout(r, 4000 * (attempt + 1))); continue; }
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const j = await res.json();
const msg = j.choices?.[0]?.message;
if (msg?.refusal) throw new Error(`모델 거부: ${msg.refusal}`);
return { out: JSON.parse(msg.content), raw: msg.content, usage: j.usage ?? {}, model: useModel };
}
throw lastErr;
}
const costOf = (u, m) => { const [i, o] = m === model ? [priceIn, priceOut] : priceOf(m); return ((u.prompt_tokens ?? 0) * i + (u.completion_tokens ?? 0) * o) / 1e6; };
// 단가 상한 정책 (v2 §9-5 미결). 지금은 누적 비용이 --max-cost-usd 를 넘으면 남은 글을 건너뛴다.
function shouldStop({ spentUsd }) { return spentUsd >= maxCostUsd; }
// ---------- 후처리 ----------
const REF_TAG = /\s*\[(?:[CSTNRF]\d*)\](?:\s*\[(?:[CSTNRF]\d*)\])*/g;
const DROP_SECTIONS = /^#{2,3}\s*(FAQ|자주 묻는 질문|자주 하는 질문|참고 자료|참고 링크|참고|상담 예약 및 문의|상담 예약|문의|병원 정보|병원 연락처|작성·검토|갱신 기록)[^\n]*\n[\s\S]*?(?=^##\s|(?![\s\S]))/gm;
function postProcess(out) {
const strip = (s) => String(s ?? '').replace(REF_TAG, '').replace(/[ \t]+$/gm, '');
return {
...out,
summary: (out.summary ?? []).map(strip).filter(Boolean),
description: strip(out.description),
faq: (out.faq ?? []).map((f) => ({ q: strip(f.q), a: strip(f.a) })),
body: strip(out.body).replace(DROP_SECTIONS, '').replace(/\n{3,}/g, '\n\n').trim(),
tags: (out.tags ?? []).map((t) => String(t).trim()).filter(Boolean),
pending: (out.pending ?? []).map((t) => String(t).trim()).filter(Boolean),
usedRefs: (out.usedRefs ?? []).map((x) => String(x).replace(/[\[\]]/g, '').trim()).filter(Boolean),
};
}
// ---------- 수치 대조 (결정론) ----------
// 글의 "숫자+단위" 토큰이 근거 컨텍스트에 그대로 있어야 한다. 범위(3~7일)는 통째로, 없으면 양 끝(3일·7일)이 각각 있으면 경고.
const NUM = '(\\d{1,3}(?:,\\d{3})+|\\d+(?:\\.\\d+)?)';
const NUM_UNIT = new RegExp(`(?<![\\d,.])${NUM}(?:\\s*[~\\-–〜]\\s*${NUM})?\\s*(분|일|주|개월|달|년|%|회|cc|㎛|mm|명|층|개|건|점|kg|시간)`, 'g');
const COUNT_UNIT = /^(명|개|건|점|회|층)$/; // 팩트 시트 JSON 에는 단위 없이 숫자만 있을 수 있다
const normNum = (s) => String(s).replace(/\s+/g, '').replace(/[–〜\-]/g, '~').replace(/(\d)달/g, '$1개월').replace(/(\d)(?:,(\d{3}))+/g, (m) => m.replace(/,/g, ''));
function checkNumbersGrounded(texts, contextText) {
const ctx = normNum(contextText);
const bare = (n) => new RegExp(`(?<![\\d.])${normNum(n)}(?![\\d.])`).test(ctx);
const out = [];
const seen = new Set();
for (const { field, text } of texts) {
for (const m of String(text).matchAll(NUM_UNIT)) {
const [raw, a, b, unit] = m;
const before = text.slice(Math.max(0, m.index - 1), m.index);
if (unit === '년' && Number(a) >= 1900) continue; // 연도
if (/월/.test(before) && unit === '일') continue; // 9월 4일
if (unit === '일' && /^\d{4}-\d{2}-/.test(text.slice(Math.max(0, m.index - 8), m.index))) continue;
const token = normNum(raw);
if (seen.has(token)) continue;
seen.add(token);
if (ctx.includes(token)) continue;
if (!b && COUNT_UNIT.test(unit) && bare(a)) continue; // "901건" vs 팩트 시트의 901
if (b && ctx.includes(normNum(a + unit)) && ctx.includes(normNum(b + unit))) { out.push({ level: R.W, code: 'RANGE_RECOMBINED', msg: `${field}: 범위 "${raw}" 는 근거에 통째로 없고 양 끝만 있음` }); continue; }
out.push({ level: R.E, code: 'NUMBER_NOT_IN_EVIDENCE', msg: `${field}: 근거에 없는 수치 "${raw}"` });
}
}
return out;
}
// ---------- frontmatter 조립 ----------
function assemble(brief, out, sources, pending) {
const reviewer = brief.reviewerCandidate && authors.physicians?.[brief.reviewerCandidate] ? brief.reviewerCandidate : undefined;
const note = `자동 생성 초안 (${mock ? '정답지 대체' : model}). 의학 검토 대기${pending.length ? `. 병원 확인 대기 ${pending.length}` : ''}`;
const data = {
title: brief.title,
category: brief.category,
categoryLabel: brief.categoryLabel ?? CATEGORY_LABEL[brief.category] ?? '',
qbIds: brief.qbIds ?? [],
summary: out.summary,
description: out.description,
author: authorId,
...(reviewer ? { reviewer } : {}),
reviewStatus: 'pending',
datePublished: today,
dateModified: today,
...(brief.hero ? { hero: brief.hero } : {}),
faq: out.faq,
sources,
tags: out.tags,
history: [{ date: today, note }],
videos: brief.videos ?? [],
gallery: brief.gallery ?? [],
...(brief.thumbnail ? { thumbnail: brief.thumbnail } : {}),
};
let body = String(out.body).trim();
if (pending.length) body += `\n\n## 병원 확인 대기\n\n이 글을 쓰면서 병원 공개 자료에서 찾지 못한 항목입니다. 병원이 확인해 주면 본문에 반영하고 갱신 기록에 남깁니다.\n\n${pending.map((p) => `- ${p}`).join('\n')}`;
return { data, body };
}
function goldenOutput(brief) {
const { data, body } = R.parseMarkdown(readFileSync(join(POSTS, brief.golden), 'utf8'));
return { summary: data.summary, description: data.description, body, faq: data.faq ?? [], tags: data.tags ?? [], pending: [], usedRefs: [] };
}
/** 한 편을 검사한다: 소스 게이트 + 수치 대조 (+ 모델 근거 대조). */
async function inspect(brief, out, ctx, row) {
const { data, body } = assemble(brief, out, ctx.sources, out.pending ?? []);
const md = toMarkdown(data, body);
const g = gatePost(md, gateCtx);
const texts = [...out.summary.map((s) => ({ field: 'summary', text: s })), { field: 'body', text: out.body }, ...out.faq.flatMap((f) => [{ field: 'faq.q', text: f.q }, { field: 'faq.a', text: f.a }])];
const nums = mock ? [] : checkNumbersGrounded(texts, ctx.text);
let verify = null;
if (doVerify) {
const r = await callModel([{ role: 'system', content: VERIFY_SYSTEM }, { role: 'user', content: verifyPrompt({ context: ctx.text, post: out }) }], { schema: VERIFY_SCHEMA, useModel: verifyModel, temp: 0 });
addUsage(row, r.usage, r.model);
verify = r.out;
}
// 검증기가 "근거에 있다"고 적으면서 올린 항목은 오탐이다. supported=true 또는 그런 사유 문구는 버린다.
const SELF_CONTRADICT = /명시되어 있|근거에 있|일치합니다|일치함|확인됨|부합|있으므로 문제|근거에 명시/;
const real = (list) => (list ?? []).filter((u) => !u.supported && !SELF_CONTRADICT.test(u.reason ?? ''));
const verifyErrors = [
...real(verify?.unsupported).map((u) => ({ level: R.E, code: 'UNSUPPORTED_CLAIM', msg: `근거 없음: "${u.claim.slice(0, 80)}" (${u.reason.slice(0, 60)})` })),
...real(verify?.distorted).map((u) => ({ level: R.E, code: 'DISTORTED_CLAIM', msg: `근거와 다름: "${u.claim.slice(0, 80)}" ← 근거 "${u.evidence.slice(0, 50)}" (${u.reason.slice(0, 50)})` })),
];
if (verify) verify.dropped = (verify.unsupported?.length ?? 0) + (verify.distorted?.length ?? 0) - verifyErrors.length;
const errors = [...g.errors, ...nums.filter((x) => x.level === R.E), ...verifyErrors];
const warns = [...g.warns, ...nums.filter((x) => x.level === R.W)];
return { md, errors, warns, stats: g.stats, verify };
}
// ---------- 실행 ----------
mkdirSync(join(OUT, 'posts'), { recursive: true });
mkdirSync(join(OUT, 'prompts'), { recursive: true });
writeFileSync(join(OUT, 'briefs.json'), JSON.stringify(briefs, null, 1));
const gateCtx = { physicians: authors.physicians, supporters: authors.supporters, homeText: ev.homeText };
const report = { clinic, model: mock ? 'mock' : model, verifyModel: doVerify ? verifyModel : null, today, startedAt: new Date().toISOString(), priceIn, priceOut, warnThreshold, posts: [] };
let spent = 0;
for (const brief of briefs) {
const t0 = Date.now();
const row = { id: brief.id, title: brief.title, category: brief.category, calls: 0, usage: { prompt_tokens: 0, completion_tokens: 0 }, costUsd: 0 };
report.posts.push(row);
if (shouldStop({ spentUsd: spent })) { row.status = 'skipped_cost_cap'; console.log(` ${brief.id}: 비용 상한 도달, 건너뜀`); continue; }
const ctx = buildContext(brief, { ev, fact, authors, videos, news, answers, today });
row.missingEvidence = ctx.missing;
row.contextChars = ctx.text.length;
const reviewerName = brief.reviewerCandidate && authors.physicians?.[brief.reviewerCandidate]?.name;
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt({ brief, context: ctx.text, fact, authorName: authors.supporters?.[authorId]?.name, reviewerName: reviewerName ? `${reviewerName} 원장` : null, today }) },
];
writeFileSync(join(OUT, 'prompts', `${brief.id}.txt`), messages.map((m) => `### ${m.role}\n${m.content}`).join('\n\n'));
if (dryRun) { row.status = 'dry_run'; console.log(` · ${brief.id}: 프롬프트 ${ctx.text.length}자 저장${ctx.missing.length ? ` · 근거 누락 ${ctx.missing.length}` : ''}`); continue; }
try {
let out, raw;
if (mock) { out = goldenOutput(brief); raw = JSON.stringify(out); }
else { const r = await callModel(messages); out = postProcess(r.out); raw = r.raw; addUsage(row, r.usage, r.model); }
let res = await inspect(brief, out, ctx, row);
row.firstPass = { errors: res.errors.map((e) => e.code), warns: res.warns.length, unsupported: res.verify?.unsupported?.length ?? 0, distorted: res.verify?.distorted?.length ?? 0, verifierDropped: res.verify?.dropped ?? 0 };
// 수정 최대 N회(--max-repairs, 기본 2): 오류가 있거나 경고가 임계 이상이면 오류·경고 목록을 되돌려 같은 스키마로 재출력
let convo = [...messages, { role: 'assistant', content: raw }];
for (let k = 0; k < maxRepairs && !mock && (res.errors.length || res.warns.length >= warnThreshold); k++) {
const fix = { role: 'user', content: repairPrompt(res.errors, res.warns) };
const r2 = await callModel([...convo, fix]);
addUsage(row, r2.usage, r2.model);
convo = [...convo, fix, { role: 'assistant', content: r2.raw }];
out = postProcess(r2.out);
res = await inspect(brief, out, ctx, row);
row.repaired = (row.repaired ?? 0) + 1;
}
writeFileSync(join(OUT, 'posts', `${brief.id}.md`), res.md);
const hard = res.errors.filter((e) => !['UNSUPPORTED_CLAIM', 'DISTORTED_CLAIM'].includes(e.code));
const soft = res.errors.filter((e) => ['UNSUPPORTED_CLAIM', 'DISTORTED_CLAIM'].includes(e.code));
row.status = hard.length ? 'gate_failed' : soft.length ? 'ok_needs_review' : 'ok';
row.errors = hard.map((e) => `[${e.code}] ${e.msg}`);
row.claimFlags = soft.map((e) => `[${e.code}] ${e.msg}`);
row.warnings = res.warns.map((w) => `[${w.code}] ${w.msg}`);
row.stats = res.stats;
row.pending = out.pending ?? [];
row.usedRefs = out.usedRefs ?? [];
row.seconds = Math.round((Date.now() - t0) / 100) / 10;
const mark = hard.length ? '✗' : soft.length ? '△' : '✓';
console.log(` ${mark} ${brief.id}: 오류 ${hard.length} 근거플래그 ${soft.length} 경고 ${res.warns.length} · 본문 ${res.stats.hangul}자 표 ${res.stats.tables} FAQ ${res.stats.faq} · 확인 대기 ${row.pending.length} · 호출 ${row.calls}${row.repaired ? '(수정)' : ''} · $${row.costUsd.toFixed(3)} · ${row.seconds}s`);
for (const e of hard) console.log(` ✗ [${e.code}] ${e.msg}`);
for (const e of soft) console.log(` △ [${e.code}] ${e.msg}`);
} catch (e) {
row.status = 'error'; row.error = String(e.message ?? e);
console.error(`${brief.id}: ${row.error.slice(0, 300)}`);
}
}
// 토큰을 쓴 시점에 누적한다. 호출이 실패해도 과금은 이미 일어났으므로 총액과 상한 판정에 들어가야 한다.
function addUsage(row, u, m) { row.calls++; row.usage.prompt_tokens += u.prompt_tokens ?? 0; row.usage.completion_tokens += u.completion_tokens ?? 0; const c = costOf(u, m); row.costUsd += c; spent += c; }
// ---------- 리포트 ----------
report.finishedAt = new Date().toISOString();
report.totalCostUsd = Math.round(spent * 1000) / 1000;
const st = (s) => report.posts.filter((p) => p.status === s).length;
report.summary = {
total: report.posts.length, ok: st('ok'), okNeedsReview: st('ok_needs_review'), gateFailed: st('gate_failed'), error: st('error'),
repaired: report.posts.filter((p) => p.repaired).length,
avgCostUsd: avg(report.posts.filter((p) => p.costUsd).map((p) => p.costUsd)),
// 총액에 포함되지만 글로 남지 않은 비용(실패·게이트 차단). 채택 편당 원가를 따로 보려면 이 값을 뺀다.
discardedCostUsd: Math.round(report.posts.filter((p) => ['error', 'gate_failed'].includes(p.status)).reduce((n, p) => n + p.costUsd, 0) * 1000) / 1000,
avgSeconds: avg(report.posts.filter((p) => p.seconds).map((p) => p.seconds)),
firstPassErrorCodes: count(report.posts.flatMap((p) => p.firstPass?.errors ?? [])),
warningCodes: count(report.posts.flatMap((p) => (p.warnings ?? []).map((w) => w.match(/^\[(\w+)\]/)?.[1]))),
errorCodes: count(report.posts.flatMap((p) => (p.errors ?? []).map((w) => w.match(/^\[(\w+)\]/)?.[1]))),
claimFlags: report.posts.reduce((n, p) => n + (p.claimFlags?.length ?? 0), 0),
};
writeFileSync(join(OUT, 'report.json'), JSON.stringify(report, null, 1));
writeFileSync(join(OUT, 'report.md'), reportMd(report));
console.log(`\n통과 ${report.summary.ok} · 근거 플래그 남음 ${report.summary.okNeedsReview} · 게이트 실패 ${report.summary.gateFailed} · 오류 ${report.summary.error} / ${report.summary.total} · 수정 호출 ${report.summary.repaired} · 총 $${report.totalCostUsd}${report.summary.discardedCostUsd ? ` (폐기 $${report.summary.discardedCostUsd})` : ''} (편당 $${report.summary.avgCostUsd}) · ${OUT}`);
if (report.summary.gateFailed || report.summary.error) process.exitCode = 1;
function avg(a) { return a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : 0; }
function count(list) { return list.filter(Boolean).reduce((m, k) => ((m[k] = (m[k] ?? 0) + 1), m), {}); }
function reportMd(r) {
const rows = r.posts.map((p) => `| ${p.id} | ${p.category} | ${p.status} | ${p.errors?.length ?? ''} | ${p.claimFlags?.length ?? ''} | ${p.warnings?.length ?? ''} | ${p.stats?.hangul ?? ''} | ${p.stats?.tables ?? ''} | ${p.stats?.faq ?? ''} | ${p.pending?.length ?? ''} | ${p.calls}${p.repaired ? '+' : ''} | ${p.usage.prompt_tokens}/${p.usage.completion_tokens} | ${p.costUsd ? p.costUsd.toFixed(3) : ''} | ${p.seconds ?? ''} |`);
const flags = r.posts.filter((p) => p.claimFlags?.length).map((p) => `### ${p.id}\n${p.claimFlags.map((x) => `- ${x}`).join('\n')}`);
const pend = r.posts.filter((p) => p.pending?.length).map((p) => `### ${p.id}\n${p.pending.map((x) => `- ${x}`).join('\n')}`);
const fp = Object.entries(r.summary.firstPassErrorCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음';
return `# 글 생성 리포트 · ${r.clinic} · ${r.model}${r.verifyModel ? ` (대조 ${r.verifyModel})` : ''} · ${r.today}
통과 ${r.summary.ok} · 근거 플래그 남음 ${r.summary.okNeedsReview} · 게이트 실패 ${r.summary.gateFailed} · 오류 ${r.summary.error} / ${r.summary.total} · 수정 호출 ${r.summary.repaired}편 · 총 $${r.totalCostUsd}${r.summary.discardedCostUsd ? ` (폐기 $${r.summary.discardedCostUsd})` : ''} · 편당 $${r.summary.avgCostUsd} · 편당 ${r.summary.avgSeconds}s
가격 기준 USD/1M tokens 입력 ${r.priceIn} · 출력 ${r.priceOut} (목록가, 실제 청구와 다를 수 있음)
1차 출력 오류 분포(수정 전): ${fp}
최종 경고 분포: ${Object.entries(r.summary.warningCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음'}
최종 오류 분포: ${Object.entries(r.summary.errorCodes).map(([k, v]) => `${k}×${v}`).join(', ') || '없음'}
상태: ok = 게이트·수치·근거 대조 모두 통과 / ok_needs_review = 게이트 통과, 모델 근거 대조 플래그 남음(사람 확인) / gate_failed = 배포 차단
| 글 | 분류 | 상태 | 오류 | 근거 플래그 | 경고 | 본문(한글) | 표 | FAQ | 확인 대기 | 호출 | 토큰 in/out | USD | 초 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
${rows.join('\n')}
## 근거 대조 플래그 (사람이 확인할 것)
${flags.join('\n\n') || '(없음)'}
## 병원 확인 대기 (모델이 근거에서 찾지 못한 것)
${pend.join('\n\n') || '(없음)'}
`;
}