o2o-infinith-demo/templates/supporters-astro/scripts/generate_posts.mjs
Haewon Kam 7e515b70ae feat(supporters): 승인 UI·입력 반영·워커 — §7-5 ClinicInputsPanel + supporter_builds/inputs 마이그레이션 + apply_inputs, §12-2 상단 고지·개설자 승인·색인 빌드 필터, §7-6 워커 E2E(뷰성형외과 URL → 5편 → 게이트 0오류)
- 게이트: 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>
2026-09-07 17:07:06 +09:00

360 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'));
const maxCostUsd = Number(opt('max-cost-usd', '0')) || Infinity;
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;
spent += row.costUsd;
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; row.costUsd += costOf(u, m); }
// ---------- 리포트 ----------
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)),
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.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.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') || '(없음)'}
`;
}