+
어느 단원인가요?
@@ -106,40 +149,119 @@ export function TopicPicker({ grade }: Props) {
})}
- {visible.length === 0 ? (
-
- 해당 학년 · 영역에 등록된 단원이 없습니다.
-
- ) : (
-
+ )}
+
+
+
+ {selectedTopic === null || selectedExamples === undefined ? (
+
+
+ 단원을 선택하면 예시 문제를 보여드려요
+
+
+ 실제 출제될 동형 문제가 어떤 모습인지 미리 가늠할 수 있도록
+ 해당 단원의 corpus 예시를 보여줍니다.
+
+
+ ) : (
+ <>
+
+
+
+ {selectedTopic.name}
+
+ {selectedTopic.code}
+
+
+ {selectedTopic.achievement}
+
+
+ 예시 {selectedExamples.examples.length} 문항 · corpus 후보
+ {" "}
+ {selectedExamples.candidate_pool.toLocaleString()} 건 중
+ 추출
+
+
+
+
+ {selectedExamples.examples.map((ex, idx) => (
+
+
+ 예시 {idx + 1}
+
+ {difficultyLabel(ex.difficulty)}
+
+
+ {problemTypeLabel(ex.problem_type)}
+
+
+
+
+
+
+ 정답
+
+
+
+
+
+ ))}
+
+
+
+ 예시는 corpus 의 기존 문제이며, 실제 출제 시 LLM 이 동형
+ 문제를 새로 생성하고 SymPy 가 검증합니다.
+
+ >
+ )}
+
+
diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css
index 7fbaf28..0392476 100644
--- a/packages/web/app/globals.css
+++ b/packages/web/app/globals.css
@@ -779,6 +779,9 @@
background: var(--color-canvas-pure);
min-height: 100vh;
}
+ .app-canvas .container-app {
+ max-width: 1600px;
+ }
.container-app {
max-width: 1280px;
margin: 0 auto;
@@ -1041,9 +1044,10 @@
flex-shrink: 0;
}
- /* page-body — S1~S6 공통 본문 영역 */
+ /* page-body — S1~S6 공통 본문 영역. padding shorthand 사용 금지: container-app 의 좌우 padding(40px) 을 덮어쓰면 좁은 뷰포트에서 콘텐츠가 가장자리에 붙음. */
.page-body {
- padding: 48px 0 56px;
+ padding-top: 48px;
+ padding-bottom: 56px;
}
.page-title {
font-family: var(--font-serif);
@@ -1293,6 +1297,20 @@
}
}
+ /* reference-list (S3-B) — 기준 문항 라디오 1-column. corpus 문제 길이가 가변이라 카드 높이 들쭉날쭉 + KaTeX inline 폭 보존 위해 stack 레이아웃. */
+ .reference-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+ .reference-list .intent-radio-card .label-desc {
+ overflow-wrap: anywhere;
+ }
+ .reference-list .intent-radio-card .label-desc .katex {
+ max-width: 100%;
+ overflow-x: auto;
+ }
+
/* dimension-list (S3-B vertical stack) */
.dimension-list {
display: flex;
@@ -2512,4 +2530,228 @@
transform: rotateY(0) !important;
}
}
+
+ /* topic-picker (S2) — 2-column: 좌 단원 카드 / 우 예시 패널. 좁은 폭에서도 2-col 유지하여 옆에 같이 보이도록. min-width:0 으로 컬럼 자유 축소. */
+ .topic-picker .topic-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
+ gap: clamp(16px, 2vw, 32px);
+ align-items: start;
+ }
+ .topic-picker .topic-grid-1col {
+ grid-template-columns: 1fr;
+ }
+ @media (max-width: 768px) {
+ .topic-picker .topic-layout {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+ }
+
+ /* example-panel — sticky 우측 패널. max-height = 페이지헤더(breadcrumb+title+subtitle+filter ≈ 300px) + action-bar(80px) 차감하여 짧은 뷰포트(vh~650)에서도 잘리지 않음. 내부 .example-list 만 스크롤. min-height 는 빈 상태(empty)에만 적용. */
+ .example-panel {
+ position: sticky;
+ top: 24px;
+ max-height: calc(100dvh - 400px);
+ background: var(--color-canvas-pure);
+ border: 1px solid var(--color-rule);
+ border-radius: 12px;
+ padding: 16px 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow: hidden;
+ }
+ @media (max-width: 768px) {
+ .example-panel {
+ position: static;
+ max-height: none;
+ overflow: visible;
+ }
+ }
+ .example-panel:has(.example-panel-empty) {
+ min-height: 240px;
+ }
+
+ .example-panel-empty {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 32px 16px;
+ text-align: center;
+ color: var(--color-ink-3);
+ }
+ .example-panel-empty-title {
+ font-family: var(--font-serif);
+ font-size: 18px;
+ font-weight: 500;
+ color: var(--color-ink-2);
+ margin: 0;
+ }
+ .example-panel-empty-desc {
+ font-family: var(--font-sans);
+ font-size: 13px;
+ line-height: 1.55;
+ margin: 0;
+ }
+
+ .example-panel-header {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--color-rule-soft);
+ }
+ .example-panel-title-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+ .example-panel-title {
+ font-family: var(--font-serif);
+ font-size: 22px;
+ font-weight: 500;
+ color: var(--color-ink);
+ margin: 0;
+ line-height: 1.2;
+ }
+ .example-panel-desc {
+ font-family: var(--font-sans);
+ font-size: 13px;
+ color: var(--color-ink-3);
+ line-height: 1.55;
+ margin: 0;
+ }
+ .example-panel-meta {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--color-ink-4);
+ letter-spacing: 0.02em;
+ margin: 0;
+ }
+
+ .example-list {
+ list-style: none;
+ padding: 0 4px 0 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ flex: 1 1 auto;
+ min-height: 0;
+ }
+ .example-card {
+ background: var(--color-canvas);
+ border: 1px solid var(--color-rule-soft);
+ border-radius: 8px;
+ padding: 12px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .example-card-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+ .example-index {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--color-ink-3);
+ letter-spacing: 0.04em;
+ }
+ .example-badge {
+ font-family: var(--font-sans);
+ font-size: 10.5px;
+ font-weight: 500;
+ padding: 2px 7px;
+ border-radius: 4px;
+ line-height: 1.4;
+ letter-spacing: 0.02em;
+ }
+ .example-badge-diff {
+ background: var(--color-pass-50);
+ color: var(--color-pass-deep);
+ }
+ .example-badge-type {
+ background: var(--color-canvas-2);
+ color: var(--color-ink-2);
+ }
+ .example-question {
+ font-family: var(--font-sans);
+ font-size: 13.5px;
+ line-height: 1.65;
+ color: var(--color-ink);
+ }
+ .example-answer {
+ display: flex;
+ gap: 10px;
+ align-items: baseline;
+ padding-top: 8px;
+ border-top: 1px dashed var(--color-rule-soft);
+ }
+ .example-answer-label {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--color-ink-3);
+ letter-spacing: 0.04em;
+ flex-shrink: 0;
+ }
+ .example-answer-value {
+ font-family: var(--font-sans);
+ font-size: 13px;
+ color: var(--color-ink-2);
+ line-height: 1.5;
+ }
+ .example-panel-footer {
+ font-family: var(--font-sans);
+ font-size: 11.5px;
+ color: var(--color-ink-4);
+ line-height: 1.55;
+ padding-top: 12px;
+ border-top: 1px solid var(--color-rule-soft);
+ }
+
+ @media (max-height: 800px) {
+ .topic-picker.page-body { padding-top: 24px; padding-bottom: 32px; }
+ .topic-picker .page-title { font-size: 26px; margin-bottom: 4px; }
+ .topic-picker .page-subtitle { font-size: 13.5px; margin-bottom: 16px; max-width: none; }
+ .topic-picker .filter-row { margin-bottom: 16px; }
+ .topic-picker .filter-chip { height: 30px; padding: 6px 14px; font-size: 12px; }
+ .topic-picker .topic-layout { gap: 20px; }
+ .topic-picker .intent-radio-card { padding: 12px 14px; font-size: 14.5px; }
+ .topic-picker .intent-radio-card .label-main { font-size: 14.5px; }
+ .topic-picker .intent-radio-card .label-desc { font-size: 12.5px; margin-top: 2px; }
+
+ .example-panel { max-height: calc(100dvh - 300px); padding: 14px 16px; gap: 8px; }
+ .example-panel-header { padding-bottom: 10px; gap: 6px; }
+ .example-panel-title { font-size: 18px; }
+ .example-panel-desc { font-size: 12px; }
+ .example-panel-meta { font-size: 10.5px; }
+ .example-card { padding: 10px 12px; gap: 6px; }
+ .example-card-meta { gap: 6px; }
+ .example-question { font-size: 12.5px; line-height: 1.55; }
+ .example-answer { padding-top: 6px; gap: 8px; }
+ .example-answer-value { font-size: 12px; }
+ .example-panel-footer { font-size: 10.5px; padding-top: 8px; }
+ }
+
+ @media (max-height: 680px) {
+ .topic-picker.page-body { padding-top: 16px; padding-bottom: 24px; }
+ .topic-picker .page-title { font-size: 22px; margin-bottom: 2px; }
+ .topic-picker .page-subtitle { display: none; }
+ .topic-picker .filter-row { margin-bottom: 12px; }
+ .topic-picker .filter-chip { height: 28px; padding: 5px 12px; }
+ .topic-picker .topic-layout { gap: 16px; }
+ .topic-picker .intent-radio-card { padding: 10px 12px; }
+
+ .example-panel { max-height: calc(100dvh - 220px); padding: 12px 14px; gap: 6px; }
+ .example-panel-title { font-size: 16px; }
+ .example-card { padding: 8px 10px; gap: 5px; }
+ .example-question { font-size: 12px; line-height: 1.5; }
+ .example-panel-footer { padding-top: 6px; }
+ }
}
diff --git a/scripts/bake-topic-examples.mjs b/scripts/bake-topic-examples.mjs
new file mode 100644
index 0000000..15ccd63
--- /dev/null
+++ b/scripts/bake-topic-examples.mjs
@@ -0,0 +1,273 @@
+#!/usr/bin/env node
+/**
+ * bake-topic-examples.mjs
+ *
+ * Demo path 단원(3개)에 대해 corpus 에서 양질의 예시 문제를 추출하여
+ * `packages/web/app/app/new/topic/examples.json` 으로 굽는다.
+ *
+ * 정공법(D-13 mapping YAML) 전 임시 인라인 mini-매핑.
+ * 향후 `packages/agent/data/curriculum/mapping-2022-to-2015.yaml` 로 이관.
+ *
+ * 사용법:
+ * node scripts/bake-topic-examples.mjs
+ * CORPUS_JSONL=... node scripts/bake-topic-examples.mjs # 경로 override
+ */
+
+import { createReadStream } from "node:fs";
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { createInterface } from "node:readline";
+
+const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
+const OUT_PATH = resolve(
+ ROOT,
+ "packages/web/app/app/new/topic/examples.json",
+);
+const CORPUS = process.env.CORPUS_JSONL ?? resolve(
+ "/Users/tw81512/dev/AI_HUB_data/rag_problem_generation_dataset/openmath_rag_records.jsonl",
+);
+const PER_UNIT = 3;
+
+/** UI 2022 코드 → corpus 매칭 룰 (topic_name keyword + grade).
+ * D-13 매핑 도입 전 임시. 매칭 못 찾는 단원은 examples.json 에 미포함.
+ */
+const DEMO_UNITS = [
+ { ui_code: "9수01-01", ui_name: "소인수분해", grade: 1,
+ topic_includes: ["소인수", "소수와 합성수", "약수", "배수", "최대공약수", "최소공배수"],
+ topic_excludes: ["방정식", "함수", "유리수"] },
+ { ui_code: "9수01-02", ui_name: "정수와 유리수", grade: 1,
+ topic_includes: ["정수와 유리수", "수직선", "절댓값", "대소 관계"],
+ topic_excludes: ["방정식", "함수", "사칙"] },
+ { ui_code: "9수01-03", ui_name: "유리수의 사칙연산", grade: 1,
+ topic_includes: ["혼합 계산", "유리수의 덧셈", "유리수의 뺄셈", "유리수의 곱셈", "유리수의 나눗셈", "사칙계산"],
+ topic_excludes: ["방정식", "일차식", "함수"] },
+ { ui_code: "9수02-01", ui_name: "문자의 사용과 식의 값", grade: 1,
+ topic_includes: ["문자의 사용", "식의 값", "대입"],
+ topic_excludes: ["방정식", "함수", "일차식"] },
+ { ui_code: "9수02-02", ui_name: "일차식의 계산", grade: 1,
+ topic_includes: ["일차식", "동류항"],
+ topic_excludes: ["방정식", "함수"] },
+ { ui_code: "9수02-03", ui_name: "일차방정식", grade: 1,
+ topic_includes: ["일차방정식", "방정식의 해", "방정식의 뜻"],
+ topic_excludes: ["이차", "연립", "부등식", "활용"] },
+ { ui_code: "9수02-04", ui_name: "일차방정식의 활용", grade: 1,
+ topic_includes: ["일차방정식", "활용"],
+ topic_excludes: ["이차", "함수", "부등식"] },
+ { ui_code: "9수03-01", ui_name: "함수의 개념", grade: 1,
+ topic_includes: ["함수", "함숫값"],
+ topic_excludes: ["일차함수", "이차함수", "방정식"] },
+ { ui_code: "9수04-01", ui_name: "기본 도형과 작도", grade: 1,
+ topic_includes: ["작도", "맞꼭지각", "동위각", "엇각", "기본 도형"],
+ topic_excludes: ["방정식", "함수"] },
+ { ui_code: "9수05-01", ui_name: "자료의 정리와 해석", grade: 1,
+ topic_includes: ["줄기와 잎", "도수분포", "히스토그램", "상대도수"],
+ topic_excludes: ["함수", "방정식"] },
+
+ { ui_code: "9수01-04", ui_name: "유리수와 순환소수", grade: 2,
+ topic_includes: ["순환소수", "유한소수", "기약분수", "무한소수"],
+ topic_excludes: ["방정식", "함수"] },
+ { ui_code: "9수02-05", ui_name: "식의 계산", grade: 2,
+ topic_includes: ["지수법칙", "다항식의 곱셈", "다항식의 나눗셈", "다항식의 덧셈"],
+ topic_excludes: ["이차", "이차함수", "방정식의 해"] },
+ { ui_code: "9수02-06", ui_name: "일차부등식", grade: 2,
+ topic_includes: ["부등식", "일차부등식"],
+ topic_excludes: ["함수", "이차"] },
+ { ui_code: "9수02-07", ui_name: "연립일차방정식", grade: 2,
+ topic_includes: ["연립", "연립방정식", "가감법", "대입법"],
+ topic_excludes: ["이차", "함수"] },
+ { ui_code: "9수03-02", ui_name: "일차함수와 그래프", grade: 2,
+ topic_includes: ["일차함수", "기울기", "y절편"],
+ topic_excludes: ["이차", "활용", "방정식의 활용"] },
+ { ui_code: "9수03-03", ui_name: "일차함수의 활용", grade: 2,
+ topic_includes: ["일차함수", "활용"],
+ topic_excludes: ["이차", "방정식"] },
+ { ui_code: "9수04-02", ui_name: "삼각형의 성질", grade: 2,
+ topic_includes: ["이등변삼각형", "삼각형의 성질", "외심", "내심"],
+ topic_excludes: ["함수", "방정식", "닮음"] },
+ { ui_code: "9수04-03", ui_name: "사각형의 성질", grade: 2,
+ topic_includes: ["평행사변형", "직사각형", "마름모", "정사각형", "사각형의 성질"],
+ topic_excludes: ["함수", "방정식", "닮음"] },
+ { ui_code: "9수04-04", ui_name: "도형의 닮음", grade: 2,
+ topic_includes: ["닮음", "닮음비", "평행선 사이의 선분"],
+ topic_excludes: ["함수", "방정식"] },
+ { ui_code: "9수05-02", ui_name: "경우의 수와 확률", grade: 2,
+ topic_includes: ["경우의 수", "확률", "사건"],
+ topic_excludes: ["함수", "방정식", "표준편차", "대푯값"] },
+
+ { ui_code: "9수01-05", ui_name: "제곱근과 실수", grade: 3,
+ topic_includes: ["제곱근", "근호", "무리수", "실수의 분류", "실수의 대소"],
+ topic_excludes: ["함수", "방정식", "도형", "삼각비"] },
+ { ui_code: "9수01-06", ui_name: "근호를 포함한 식의 계산", grade: 3,
+ topic_includes: ["근호를 포함한", "근호", "분모의 유리화"],
+ topic_excludes: ["함수", "방정식", "도형", "삼각비"] },
+ { ui_code: "9수02-08", ui_name: "다항식의 곱셈과 인수분해", grade: 3,
+ topic_includes: ["곱셈 공식", "곱셈공식", "인수분해", "다항식의 곱셈"],
+ topic_excludes: ["이차방정식", "이차함수", "삼각비"] },
+ { ui_code: "9수02-09", ui_name: "이차방정식", grade: 3,
+ topic_includes: ["이차방정식"],
+ topic_excludes: ["함수", "그래프", "활용"] },
+ { ui_code: "9수02-10", ui_name: "이차방정식의 활용", grade: 3,
+ topic_includes: ["이차방정식", "활용"],
+ topic_excludes: ["함수", "그래프"] },
+ { ui_code: "9수03-04", ui_name: "이차함수와 그래프", grade: 3,
+ topic_includes: ["이차함수"],
+ topic_excludes: ["방정식의 해", "삼각비"] },
+ { ui_code: "9수04-05", ui_name: "삼각비", grade: 3,
+ topic_includes: ["삼각비"],
+ topic_excludes: ["함수", "방정식"] },
+ { ui_code: "9수04-06", ui_name: "원과 직선의 위치 관계", grade: 3,
+ topic_includes: ["원의 접선", "원의 현", "원과 직선"],
+ topic_excludes: ["원주각", "함수", "방정식"] },
+ { ui_code: "9수04-07", ui_name: "원주각", grade: 3,
+ topic_includes: ["원주각"],
+ topic_excludes: ["함수", "방정식"] },
+ { ui_code: "9수05-03", ui_name: "대푯값과 산포도", grade: 3,
+ topic_includes: ["평균", "중앙값", "최빈값", "분산", "표준편차", "산포도"],
+ topic_excludes: ["함수", "방정식", "도형"] },
+];
+
+function topicNameMatches(topicName, includes, excludes) {
+ if (!topicName) return false;
+ const tn = topicName;
+ if (!includes.some((kw) => tn.includes(kw))) return false;
+ if (excludes.some((kw) => tn.includes(kw))) return false;
+ return true;
+}
+
+/** preview 로 보여줄 수 있는 문제인지. */
+function isUsablePreview(record) {
+ const p = record.problem ?? {};
+ const q = (p.question_text ?? "").trim();
+ const a = (p.answer_text ?? "").trim();
+ if (q.length < 25 || q.length > 280) return false;
+ if (!a) return false;
+ if ((record.quality ?? {}).is_usable === false) return false;
+ // 객관식 답이 ①~⑤ 만 있는 경우 미리보기 가치 낮음 → 제외
+ if (/^[①②③④⑤]$/.test(a)) return false;
+ // 이미지 의존 문제는 텍스트가 "다음 그림 / 도형 / <보기>" 등으로 시작하면 제외 (preview 부적합).
+ // 이미지 metadata 만 있고 텍스트가 자족적인 경우는 통과.
+ if ((record.media ?? {}).question_image_relpath) {
+ if (/^(다음\s*그림|아래\s*그림|그림과\s*같이|<\s*보기\s*>|다음\s*도형|다음\s*표)/.test(q)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** 단일 단원에 대한 후보를 점수 정렬 후 PER_UNIT 개 선택. */
+function pickForUnit(candidates) {
+ // 점수: easy=2, medium=1, hard=0 + 답 길이 적당함 보너스
+ const scored = candidates.map((c) => {
+ const d = c.problem.difficulty;
+ let score = d === "easy" ? 2 : d === "medium" ? 1 : 0;
+ const aLen = (c.problem.answer_text ?? "").length;
+ if (aLen >= 1 && aLen <= 40) score += 1;
+ const eLen = (c.problem.explanation_text ?? "").length;
+ if (eLen > 0) score += 0.5;
+ return { c, score };
+ });
+
+ // difficulty 분산: easy / medium 골고루
+ scored.sort((a, b) => b.score - a.score);
+ const picked = [];
+ const seen = new Set();
+ // 1차 패스: 쉬운 것부터 다양한 topic_name 으로
+ for (const { c } of scored) {
+ if (picked.length >= PER_UNIT) break;
+ const tn = c.curriculum.topic_name ?? "";
+ if (seen.has(tn)) continue;
+ picked.push(c);
+ seen.add(tn);
+ }
+ // 2차 패스: 부족하면 점수순으로 채움
+ for (const { c } of scored) {
+ if (picked.length >= PER_UNIT) break;
+ if (!picked.includes(c)) picked.push(c);
+ }
+ return picked;
+}
+
+async function main() {
+ console.log(`[bake] corpus: ${CORPUS}`);
+ console.log(`[bake] out: ${OUT_PATH}`);
+
+ /** ui_code -> candidates[] */
+ const buckets = new Map(DEMO_UNITS.map((u) => [u.ui_code, []]));
+
+ const rl = createInterface({
+ input: createReadStream(CORPUS, { encoding: "utf8" }),
+ crlfDelay: Infinity,
+ });
+
+ let total = 0;
+ for await (const line of rl) {
+ if (!line.trim()) continue;
+ total += 1;
+ let r;
+ try {
+ r = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ const c = r.curriculum ?? {};
+ if (c.school_level !== "middle") continue;
+ if (!isUsablePreview(r)) continue;
+
+ for (const unit of DEMO_UNITS) {
+ if (c.grade !== unit.grade) continue;
+ if (
+ !topicNameMatches(
+ c.topic_name,
+ unit.topic_includes,
+ unit.topic_excludes,
+ )
+ ) {
+ continue;
+ }
+ buckets.get(unit.ui_code).push(r);
+ break; // 한 record 는 한 단원에만
+ }
+ }
+
+ console.log(`[bake] scanned ${total.toLocaleString()} records`);
+
+ /** out 구조: { "": [{ id, ... }, ...] } */
+ const out = {
+ generated_at: new Date().toISOString(),
+ corpus_source: CORPUS.split("/").pop(),
+ note: "임시 mini-매핑 기반. D-13 도입 후 mapping-2022-to-2015.yaml 로 이관.",
+ units: {},
+ };
+
+ for (const unit of DEMO_UNITS) {
+ const candidates = buckets.get(unit.ui_code);
+ const picked = pickForUnit(candidates);
+ out.units[unit.ui_code] = {
+ ui_name: unit.ui_name,
+ grade: unit.grade,
+ candidate_pool: candidates.length,
+ examples: picked.map((r) => ({
+ id: r.id.problem_id,
+ achievement_code_2015: r.curriculum.achievement_code,
+ topic_name_2015: r.curriculum.topic_name,
+ difficulty: r.problem.difficulty,
+ problem_type: r.problem.problem_type,
+ question_text: (r.problem.question_text ?? "").trim(),
+ answer_text: (r.problem.answer_text ?? "").trim(),
+ })),
+ };
+ console.log(
+ `[bake] ${unit.ui_code} (${unit.ui_name}): pool=${candidates.length}, picked=${picked.length}`,
+ );
+ }
+
+ await mkdir(dirname(OUT_PATH), { recursive: true });
+ await writeFile(OUT_PATH, JSON.stringify(out, null, 2) + "\n");
+ console.log(`[bake] wrote ${OUT_PATH}`);
+}
+
+main().catch((err) => {
+ console.error("[bake] FAILED:", err);
+ process.exit(1);
+});