// 글 생성기 (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') || '(없음)'} `; }