/** * A-2 덧붙이기 — 운영 L1/L2 생성 문서(recent400 collect.json)의 컴포넌트마다 `title` · `subtitle` 만 붙인다. * 나머지 칸은 건드리지 않는다(붙인 뒤 두 칸을 지우면 원본과 JSON 이 같은지 검증). * * 실행: node --env-file=/.env.local --import tsx add-component-titles.mts [--limit N] [--dry] [--conc 8] * 모델 gpt-5.6-luna · reasoning medium (운영 생성과 같은 모델·설정 · 지시문은 실험용). * 단가(메모리 openai-model-pricing): 입력 $0.20 · 출력 $1.20 per 1M. */ import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; const [inPath, outPath, ...rest] = process.argv.slice(2); const flag = (n: string) => rest.includes(n); const opt = (n: string, d: number) => { const i = rest.indexOf(n); return i >= 0 ? Number(rest[i + 1]) : d; }; const LIMIT = opt('--limit', Infinity); const CONC = opt('--conc', 8); const DRY = flag('--dry'); const MODEL = 'gpt-5.6-luna'; const EFFORT = 'medium'; const PRICE_IN = 0.2 / 1e6, PRICE_OUT = 1.2 / 1e6; const keys = [process.env.OPENAI_API_KEY_1, process.env.OPENAI_API_KEY_2].filter((k): k is string => !!k); if (!DRY && keys.length === 0) throw new Error('OPENAI_API_KEY_1 없음'); const SYSTEM = `너는 시각 요약(Visual Summary) 문서의 각 컴포넌트에 붙일 제목과 부제를 정한다. 입력: 원문과, 그 원문으로 이미 만든 시각 요약 문서(JSON, 컴포넌트 목록). 할 일: 컴포넌트마다 그 컴포넌트를 슬라이드 한 덩어리로 보여 줄 때 위에 얹을 짧은 제목(title)과, 있으면 좋은 한 줄 부제(subtitle)를 정한다. 규칙: - 원문의 언어를 따른다. 원문에 있는 말을 우선 쓰고, 없는 사실을 지어내지 않는다. - title: 20자 안팎의 명사구. 컴포넌트가 하나뿐이면 문서 title 과 같거나 비슷해도 된다. - subtitle: 원문에 그 컴포넌트를 설명하는 짧은 문장·기준·단위·출처가 있을 때만 40자 안팎으로. 없으면 null. - 컴포넌트 안의 항목(entity) 글을 그대로 되풀이하지 않는다. - 문서의 다른 칸은 바꾸지 않는다. 제목·부제만 낸다. 출력(JSON 하나): {"components": [{"index": 0, "title": "...", "subtitle": "..." 또는 null}, ...]} — 입력 컴포넌트 수와 순서 그대로.`; type Doc = { title?: string; components: Array> }; type Entry = { search_key: string; input_text: string; ok: boolean; generated?: Record }; const collect: Entry[] = JSON.parse(readFileSync(inPath!, 'utf8')); const jobs: Array<{ e: Entry; lv: string; doc: Doc }> = []; for (const e of collect) for (const [lv, v] of Object.entries(e.generated ?? {})) if (v?.document?.components?.length) jobs.push({ e, lv, doc: v.document }); const todo = jobs.slice(0, LIMIT); const estIn = todo.reduce((s, j) => s + (SYSTEM.length + j.e.input_text.length + JSON.stringify(j.doc).length) / 2.2, 0); console.log(`문서 ${jobs.length} · 이번 ${todo.length} · 입력 추정 ${Math.round(estIn)} 토큰 ≈ $${(estIn * PRICE_IN).toFixed(2)} + 출력(추론 포함 문서당 약 1.5k) ≈ $${(todo.length * 1500 * PRICE_OUT).toFixed(2)}`); if (DRY) process.exit(0); const logPath = outPath + '.calls.jsonl'; let inTok = 0, outTok = 0, fail = 0, done = 0, k = 0; async function call(j: typeof todo[number]): Promise { const user = `원문:\n${j.e.input_text}\n\n시각 요약 문서(JSON):\n${JSON.stringify(j.doc)}`; for (let t = 0; t < 4; t++) { try { const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { authorization: `Bearer ${keys[k++ % keys.length]}`, 'content-type': 'application/json' }, body: JSON.stringify({ model: MODEL, reasoning_effort: EFFORT, response_format: { type: 'json_object' }, messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: user }] }), }); if (!res.ok) throw new Error(`http ${res.status} ${(await res.text()).slice(0, 200)}`); const body = await res.json() as { choices: Array<{ message: { content: string } }>; usage?: { prompt_tokens: number; completion_tokens: number } }; inTok += body.usage?.prompt_tokens ?? 0; outTok += body.usage?.completion_tokens ?? 0; const parsed = JSON.parse(body.choices[0]!.message.content) as { components: Array<{ index: number; title?: unknown; subtitle?: unknown }> }; const n = j.doc.components.length; if (!Array.isArray(parsed.components) || parsed.components.length !== n) throw new Error(`컴포넌트 수 ${parsed.components?.length} ≠ ${n}`); const before = JSON.stringify(j.doc); parsed.components.forEach((c, i) => { const comp = j.doc.components[i]!; if (typeof c.title === 'string' && c.title.trim()) comp.title = c.title.trim(); if (typeof c.subtitle === 'string' && c.subtitle.trim()) comp.subtitle = c.subtitle.trim(); }); // 보존 검증 — 붙인 두 칸을 지우면 원본과 같아야 한다. const stripped = JSON.parse(JSON.stringify(j.doc)) as Doc; for (const c of stripped.components) { delete c.title; delete c.subtitle; } if (JSON.stringify(stripped) !== before) { throw new Error('보존 검증 실패'); } appendFileSync(logPath, JSON.stringify({ key: j.e.search_key, lv: j.lv, usage: body.usage, out: parsed.components }) + '\n'); done++; if (done % 100 === 0) console.log(`${done}/${todo.length} · $${(inTok * PRICE_IN + outTok * PRICE_OUT).toFixed(3)}`); return; } catch (err) { if (t === 3) { fail++; appendFileSync(logPath, JSON.stringify({ key: j.e.search_key, lv: j.lv, error: String(err).slice(0, 300) }) + '\n'); return; } await new Promise(r => setTimeout(r, 2000 * (t + 1))); } } } let next = 0; await Promise.all(Array.from({ length: CONC }, async () => { while (next < todo.length) await call(todo[next++]!); })); writeFileSync(outPath!, JSON.stringify(collect)); const withTitle = todo.reduce((s, j) => s + j.doc.components.filter(c => typeof c.title === 'string').length, 0); const withSub = todo.reduce((s, j) => s + j.doc.components.filter(c => typeof c.subtitle === 'string').length, 0); const comps = todo.reduce((s, j) => s + j.doc.components.length, 0); console.log(JSON.stringify({ docs: todo.length, done, fail, comps, withTitle, withSub, inTok, outTok, cost_usd: +(inTok * PRICE_IN + outTok * PRICE_OUT).toFixed(4) }));