// 글 생성기 (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
: 다른 사이트 디렉터리(템플릿 복제본)의 데이터·글을 쓴다. 워커가 병원별 작업 폴더로 돌릴 때.
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 이 필요합니다'); 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(`(? 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(`(?= 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') || '(없음)'}
`;
}