-
Notifications
You must be signed in to change notification settings - Fork 0
Improve problem generation quality and verification UI #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,10 @@ updated: 2026-05-18 | |
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt content changed without bumping the prompt version, so downstream prompt metadata will not reflect this new behavior. Prompt for AI agents |
||
| {{candidate.question_text}} | ||
|
|
||
| # Generation Kind | ||
|
|
||
| {{candidate.generation_kind}} | ||
|
|
||
| # Task | ||
|
|
||
| 1. 풀이 과정을 단계별로 기술 | ||
|
|
@@ -28,12 +32,13 @@ updated: 2026-05-18 | |
| # Output | ||
|
|
||
| `SolveAttempt` JSON: | ||
| - `derived_answer: string` (LaTeX) | ||
| - `derived_answer: string` (문제 유형에 맞는 최종 정답. 방정식 해는 `2, -5`, 확률은 `3/8`, 통계값은 `평균 12`, 기하는 `60도`처럼 간결히 쓴다) | ||
| - `trace: string` | ||
| - `confidence: "high" | "medium" | "low"` | ||
|
|
||
| # Constraints | ||
|
|
||
| - 원본 풀이를 *모른다* 고 가정. 새로 풀어내는 것이 핵심 | ||
| - 도구 (계산기) 없이 손풀이 추론으로 | ||
| - 정답 판단은 시스템(SymPy) 이 한다. 당신은 풀이 trace만 제공 (D-1) | ||
| - 정답 판단은 시스템 검증기가 한다. 당신은 풀이 trace만 제공 (D-1) | ||
| - JSON 문자열 안에서 raw backslash를 쓰지 말 것. `\(`, `\sqrt`, `\cdot` 같은 LaTeX 명령 대신 `sqrt(7)`, `5*x` 표기를 사용 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,26 @@ updated: 2026-05-18 | |
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Bump the prompt version when changing this prompt body; leaving Prompt for AI agents |
||
| `{{request.mode}}` — `structural` 이면 같은 풀이법, 다른 숫자/표현. `conceptual` 이면 같은 학습 목표·평가 차원, 다른 풀이 경로. | ||
|
|
||
| # Generation Kind | ||
|
|
||
| `{{generationKind}}` | ||
|
|
||
| - `equation`: 등식의 해를 구하는 문제. `question_text`는 SymPy가 풀 수 있는 식을 포함해도 된다. | ||
| - `inequality`: 부등식의 해 또는 해집합을 구하는 문제. 등식으로 바꾸지 말 것. | ||
| - `system`: 미지수가 둘 이상인 연립방정식 문제. 단일 `x` 방정식으로 축소하지 말 것. | ||
| - `expression`: 식의 값, 식의 계산, 소인수분해, 근호 계산, 인수분해처럼 식을 변형하거나 계산하는 문제. 해를 구하는 방정식으로 바꾸지 말 것. | ||
| 유리수와 순환소수 단원에서는 점을 찍어 순환마디를 고르는 객관식 문제를 피하고, 순환소수를 분수로 바꾸거나 순환마디/소수 전개를 plain text로 답하게 할 것. | ||
| - `function`: 함수값, 그래프의 성질, 일차/이차함수 식 찾기 문제. 단순 방정식 풀이로 바꾸지 말 것. | ||
| 여러 명제의 참/거짓을 모두 고르는 문제는 만들지 말 것. 함수값, 기울기, 절편, 그래프 위의 한 점, 함수식 중 하나를 단일 정답으로 묻는 문제를 우선한다. | ||
| 일차함수 `y = a*x + b`에서 `a > 0`이면 `x`가 증가할 때 `y`도 증가하고, `a < 0`이면 `y`는 감소한다. | ||
| 이차함수 그래프 방향은 `위로 열린다`, `아래로 열린다`로 쓰고 `위로 볼록`, `아래로 볼록` 표현은 쓰지 말 것. | ||
| `y = a*x**2 + ...`에서 `a > 0`이면 `위로 열린다`, `a < 0`이면 `아래로 열린다`. | ||
| - `geometry`: 도형, 삼각비, 원의 성질을 이용하는 문제. 필요한 길이/각/비를 구하게 하되 단원 맥락을 유지할 것. | ||
| 보이지 않는 그림에 의존하는 복잡한 배치, 내접사각형+접선+중심각처럼 성질이 여러 개 겹치는 새 상황은 피할 것. | ||
| 그림 없이도 텍스트만으로 결정되는 표준 관계 하나를 묻고, 정답 산출에 필요한 길이·각 조건을 모두 명시할 것. | ||
| - `probability`: 경우의 수나 확률을 구하는 문제. | ||
| - `statistics`: 자료의 평균, 중앙값, 최빈값, 분산, 표준편차, 도수분포 해석 문제. | ||
|
|
||
| # Selected Source Problem | ||
|
|
||
| 이 문제를 반드시 변형의 기준으로 삼아라: `{{request.source_problem_text}}` | ||
|
|
@@ -57,12 +77,13 @@ updated: 2026-05-18 | |
| # Output | ||
|
|
||
| JSON으로만 응답. | ||
| - `question_text`는 KaTeX가 바로 렌더링할 수 있는 LaTeX `x` 방정식 문자열이어야 한다. `**`, `*`를 쓰지 말고 지수는 `x^{2}`, 곱셈은 `5x`, 제곱근은 `\\sqrt{7}`처럼 쓴다. | ||
| - `expected_answer`도 LaTeX로 쓴다. 예: `2, 5`, `\\sqrt{7}, -\\sqrt{7}`. | ||
| - 예시 출력: `{ "question_text": "x^{2} - 7x + 10 = 0", "expected_answer": "2, 5", "proposed_solution_trace": "인수분해하면 (x - 2)(x - 5) = 0이므로 x = 2 또는 x = 5이다." }` | ||
| - 서버가 SymPy로 다시 계산해 최종 정답을 검증한다. | ||
| - `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시한다. | ||
| - 풀이 가능하고 답이 실수인 중학교 수준 방정식만 생성한다. | ||
| - JSON 문자열 안에서 raw backslash를 쓰지 말 것. `\(`, `\sqrt`, `\cdot` 같은 LaTeX 명령을 쓰면 안 된다. | ||
| - `question_text`는 `{{generationKind}}` 유형에 맞는 문제 문장이어야 한다. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Declare Prompt for AI agents |
||
| - 수식은 JSON 안전한 plain text로 쓴다. 지수는 `x**2`, 곱셈은 `5*x`, 제곱근은 `sqrt(7)`처럼 쓴다. | ||
| - 방정식 단원이 아닌데 해를 구하는 `x` 방정식으로 바꾸면 안 된다. | ||
| - `expected_answer`는 정답만 간결하게 쓴다. 방정식 해는 `2, 5`, 통계값은 `평균 12`, 확률은 `3/8`, 기하는 `60도`처럼 쓴다. | ||
| - 예시 출력: `{ "question_text": "다항식 (x + 3)(x - 5)를 전개하시오.", "expected_answer": "x**2 - 2*x - 15", "proposed_solution_trace": "분배법칙으로 각 항을 곱해 동류항을 정리한다." }` | ||
| - `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시하되, 수식도 JSON 안전한 plain text 표기만 사용한다. | ||
| - 결과 문제는 source problem과 달라야 하며, structural/conceptual 모드 차이가 풀이 설명에 드러나야 한다. | ||
|
|
||
| # TODO | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { dirname, join, resolve } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| import { generateObject } from "ai"; | ||
| import { z } from "zod"; | ||
|
|
||
| import { DEFAULT_MODELS } from "../src/config/models.js"; | ||
| import { loadEnv } from "../src/config/env.js"; | ||
| import { resolveLanguageModel } from "../src/tools/llm-provider.js"; | ||
|
|
||
| type Mode = "structural" | "conceptual"; | ||
| type Status = "PASS" | "WARN" | "FAIL"; | ||
|
|
||
| type SweepRow = { | ||
| readonly grade: number; | ||
| readonly code: string; | ||
| readonly name: string; | ||
| readonly question: string; | ||
| readonly answer: string; | ||
| readonly status: string; | ||
| }; | ||
|
|
||
| type Topic = { | ||
| readonly code: string; | ||
| readonly achievement: string; | ||
| }; | ||
|
|
||
| type JudgeRow = SweepRow & { | ||
| readonly mode: Mode; | ||
| readonly status: Status; | ||
| readonly scores: { | ||
| readonly math_correctness: number; | ||
| readonly topic_fit: number; | ||
| readonly isomorphism_fit: number; | ||
| readonly clarity: number; | ||
| }; | ||
| readonly reasons: readonly string[]; | ||
| readonly suggested_revision: string | null; | ||
| }; | ||
|
|
||
| const JudgeSchema = z.object({ | ||
| status: z.enum(["PASS", "WARN", "FAIL"]), | ||
| scores: z.object({ | ||
| math_correctness: z.number().int().min(1).max(5), | ||
| topic_fit: z.number().int().min(1).max(5), | ||
| isomorphism_fit: z.number().int().min(1).max(5), | ||
| clarity: z.number().int().min(1).max(5), | ||
| }), | ||
| reasons: z.array(z.string()).max(4), | ||
| suggested_revision: z.string().nullable(), | ||
| }); | ||
|
|
||
| const rootDir = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); | ||
| const args = parseArgs(process.argv.slice(2)); | ||
| const topics = await loadTopics(); | ||
| const env = loadEnv(); | ||
| const kind = env.LLM_PROVIDER === "cliproxy" ? "openai-compatible" : env.LLM_PROVIDER; | ||
| const modelId = env.LLM_MODEL ?? env.CLIPROXY_MODEL ?? env.OPENAI_MODEL ?? DEFAULT_MODELS.generator; | ||
| const model = resolveLanguageModel({ | ||
| kind, | ||
| modelId, | ||
| baseUrl: env.LLM_BASE_URL ?? env.CLIPROXY_BASE_URL, | ||
| apiKey: env.LLM_API_KEY ?? env.CLIPROXY_API_KEY ?? env.OPENAI_API_KEY ?? "openmath-local", | ||
| allowedHosts: ["localhost", "127.0.0.1"], | ||
| }); | ||
|
|
||
| const rows = [ | ||
| ...await loadSweep(args.structural, "structural"), | ||
| ...await loadSweep(args.conceptual, "conceptual"), | ||
| ]; | ||
| const judged = await runPool(rows, args.concurrency, (row) => judge(row, topics)); | ||
| const report = { | ||
| generated_at: new Date().toISOString(), | ||
| structural_report: args.structural, | ||
| conceptual_report: args.conceptual, | ||
| total: judged.length, | ||
| pass: judged.filter((row) => row.status === "PASS").length, | ||
| warn: judged.filter((row) => row.status === "WARN").length, | ||
| fail: judged.filter((row) => row.status === "FAIL").length, | ||
| rows: judged, | ||
| }; | ||
|
|
||
| const stamp = report.generated_at.replaceAll(":", "").replaceAll(".", ""); | ||
| const outDir = join(rootDir, "reports/quality"); | ||
| await mkdir(outDir, { recursive: true }); | ||
| const jsonPath = join(outDir, `quality-judge-${stamp}.json`); | ||
| const mdPath = join(outDir, `quality-judge-${stamp}.md`); | ||
| await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`); | ||
| await writeFile(mdPath, renderMarkdown(report.rows)); | ||
| console.log(`JUDGE PASS ${report.pass}/${report.total} WARN ${report.warn} FAIL ${report.fail}`); | ||
| console.log(mdPath); | ||
| console.log(jsonPath); | ||
| if (report.fail > 0) process.exitCode = 1; | ||
|
|
||
| function parseArgs(argv: readonly string[]): { readonly structural: string; readonly conceptual: string; readonly concurrency: number } { | ||
| let structural = ""; | ||
| let conceptual = ""; | ||
| let concurrency = 3; | ||
| for (let index = 0; index < argv.length; index += 1) { | ||
| const arg = argv[index]; | ||
| const next = argv[index + 1]; | ||
| if (arg === "--structural" && next !== undefined) { | ||
| structural = resolve(rootDir, next); | ||
| index += 1; | ||
| } else if (arg === "--conceptual" && next !== undefined) { | ||
| conceptual = resolve(rootDir, next); | ||
| index += 1; | ||
| } else if (arg === "--concurrency" && next !== undefined) { | ||
| concurrency = Number.parseInt(next, 10); | ||
| index += 1; | ||
| } else { | ||
| throw new Error(`Unknown argument: ${arg ?? ""}`); | ||
| } | ||
| } | ||
| if (structural.length === 0 || conceptual.length === 0) throw new Error("--structural and --conceptual are required"); | ||
| if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 8) throw new Error("--concurrency must be between 1 and 8"); | ||
| return { structural, conceptual, concurrency }; | ||
| } | ||
|
|
||
| async function loadTopics(): Promise<ReadonlyMap<string, Topic>> { | ||
| const source = await readFile(join(rootDir, "packages/web/app/app/new/topic/data.ts"), "utf8"); | ||
| const pattern = /\{\s*grade:\s*(?:1|2|3),\s*category:\s*"[^"]+",\s*code:\s*"([^"]+)",\s*name:\s*"[^"]+",\s*achievement:\s*"([^"]+)",\s*\}/gms; | ||
| return new Map(Array.from(source.matchAll(pattern)).map((match) => [ | ||
| match[1] ?? "", | ||
| { code: match[1] ?? "", achievement: (match[2] ?? "").replace(/\s+/g, " ").trim() }, | ||
| ])); | ||
| } | ||
|
|
||
| async function loadSweep(path: string, mode: Mode): Promise<readonly (SweepRow & { readonly mode: Mode })[]> { | ||
| const parsed = JSON.parse(await readFile(path, "utf8")) as { readonly rows?: readonly SweepRow[] }; | ||
| const rows = parsed.rows ?? []; | ||
| if (rows.length !== 30) throw new Error(`${path} must contain 30 rows`); | ||
| return rows.map((row) => ({ ...row, mode })); | ||
| } | ||
|
|
||
| async function judge(row: SweepRow & { readonly mode: Mode }, topics: ReadonlyMap<string, Topic>): Promise<JudgeRow> { | ||
| const topic = topics.get(row.code); | ||
| for (let attempt = 1; attempt <= 2; attempt += 1) { | ||
| try { | ||
| const { object } = await generateObject({ | ||
| model, | ||
| schema: JudgeSchema, | ||
| mode: "json", | ||
| temperature: 0, | ||
| prompt: renderPrompt(row, topic?.achievement ?? "", attempt), | ||
| }); | ||
| return { ...row, ...object }; | ||
| } catch (error) { | ||
| if (attempt === 2) { | ||
| return fallbackJudge(row, error); | ||
| } | ||
| } | ||
| } | ||
| return fallbackJudge(row, new Error("unreachable judge retry state")); | ||
| } | ||
|
|
||
| function renderPrompt(row: SweepRow & { readonly mode: Mode }, achievement: string, attempt: number): string { | ||
| return [ | ||
| "너는 한국 중학교 수학 출제 품질을 엄격히 검수하는 교사다.", | ||
| "문항을 PASS/WARN/FAIL로 판정하라.", | ||
| "PASS는 바로 학생에게 제공 가능, WARN은 사용 가능하지만 개선 권장, FAIL은 오답/단원 불일치/동형성 실패/애매한 문항이다.", | ||
| "구조동형은 풀이 구조가 같은지, 개념동형은 같은 성취기준 안에서 상황/표현/풀이 관점이 충분히 바뀌었는지 평가하라.", | ||
| "자동 검증은 이미 통과했으므로, 교육 품질과 문장 명확성에 집중하라.", | ||
| "JSON 문자열에는 백슬래시, LaTeX, \\( \\), \\sqrt 같은 표기를 절대 쓰지 말고 plain Korean text만 써라.", | ||
| attempt === 1 ? "" : "이전 응답은 JSON 파싱에 실패했다. 이번에는 reasons와 suggested_revision에 백슬래시를 절대 넣지 마라.", | ||
| "", | ||
| `학년: ${row.grade}`, | ||
| `단원: ${row.code} ${row.name}`, | ||
| `성취기준: ${achievement}`, | ||
| `모드: ${row.mode}`, | ||
| `문제: ${row.question}`, | ||
| `정답: ${row.answer}`, | ||
| ].join("\n"); | ||
| } | ||
|
|
||
| function fallbackJudge(row: SweepRow & { readonly mode: Mode }, error: unknown): JudgeRow { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return { | ||
| ...row, | ||
| status: "WARN", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Judge parse/runtime failures are downgraded to WARN, so a broken audit run can still exit 0 and look successful. Prompt for AI agents |
||
| scores: { math_correctness: 3, topic_fit: 3, isomorphism_fit: 3, clarity: 3 }, | ||
| reasons: [`LLM judge parse failed: ${message.slice(0, 120)}`], | ||
| suggested_revision: "Judge 응답 파싱 실패. 사람이 직접 확인해야 합니다.", | ||
| }; | ||
| } | ||
|
|
||
| async function runPool<T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> { | ||
| const results: R[] = []; | ||
| let cursor = 0; | ||
| async function runNext(): Promise<void> { | ||
| while (cursor < items.length) { | ||
| const current = cursor; | ||
| cursor += 1; | ||
| results[current] = await worker(items[current] as T); | ||
| } | ||
| } | ||
| await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runNext())); | ||
| return results; | ||
| } | ||
|
|
||
| function renderMarkdown(rows: readonly JudgeRow[]): string { | ||
| const lines = [ | ||
| "# OpenMath LLM Quality Judge", | ||
| "", | ||
| "| Grade | Code | Topic | Mode | Status | Scores | Reasons | Suggested Revision | Question | Answer |", | ||
| "|---:|---|---|---|---|---|---|---|---|---|", | ||
| ]; | ||
| for (const row of rows) { | ||
| lines.push(`| ${row.grade} | ${row.code} | ${escapeMd(row.name)} | ${row.mode} | ${row.status} | ${scoreText(row)} | ${escapeMd(row.reasons.join("; "))} | ${escapeMd(row.suggested_revision ?? "")} | ${escapeMd(row.question)} | ${escapeMd(row.answer)} |`); | ||
| } | ||
| return `${lines.join("\n")}\n`; | ||
| } | ||
|
|
||
| function scoreText(row: JudgeRow): string { | ||
| const s = row.scores; | ||
| return `math ${s.math_correctness}, topic ${s.topic_fit}, iso ${s.isomorphism_fit}, clarity ${s.clarity}`; | ||
| } | ||
|
|
||
| function escapeMd(value: string): string { | ||
| return value.replaceAll("|", "\\|").replaceAll("\n", " ").slice(0, 260); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -43,14 +43,39 @@ export function createConstraintCriticAgent( | |||||
| intent: input.intent, | ||||||
| strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2), | ||||||
| }); | ||||||
| const { object } = await generateObject({ | ||||||
| model: deps.model, | ||||||
| schema: CritiqueSchema, | ||||||
| mode: "json", | ||||||
| temperature: prompt.metadata.temperature, | ||||||
| prompt: rendered, | ||||||
| }); | ||||||
| return object; | ||||||
| try { | ||||||
| const { object } = await generateObject({ | ||||||
| model: deps.model, | ||||||
| schema: CritiqueSchema, | ||||||
| mode: "json", | ||||||
| temperature: prompt.metadata.temperature, | ||||||
| prompt: rendered, | ||||||
| }); | ||||||
| return object; | ||||||
| } catch (error) { | ||||||
| const text = modelTextFromError(error); | ||||||
| if (text !== null) { | ||||||
| return parseCritiqueJson(text); | ||||||
| } | ||||||
| throw error; | ||||||
| } | ||||||
| }, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| export function parseCritiqueJson(text: string): Critique { | ||||||
| return CritiqueSchema.parse(JSON.parse(escapeRawBackslashes(text))); | ||||||
| } | ||||||
|
|
||||||
| function escapeRawBackslashes(text: string): string { | ||||||
| return text.replace(/\\(?!["\\/bfnrtu])/g, "\\\\"); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Backslash-repair regex over-escapes already-valid Prompt for AI agents
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| function modelTextFromError(error: unknown): string | null { | ||||||
| if (!hasTextProperty(error)) return null; | ||||||
| return typeof error.text === "string" ? error.text : null; | ||||||
| } | ||||||
|
|
||||||
| function hasTextProperty(value: unknown): value is { readonly text?: unknown } { | ||||||
| return typeof value === "object" && value !== null && "text" in value; | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Prompt behavior changed without bumping the prompt version. This breaks the repo’s prompt-versioning contract and can blur generation metadata/tracking.
Prompt for AI agents