From 4b7d1c6c8af233f6cd8ffc9569904b707af8c617 Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Thu, 11 Jun 2026 00:37:39 +0900 Subject: [PATCH 1/6] feat: real generation agent transition (Phases 0-3 + 4-2/4-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: DETERMINISTIC_FALLBACK env (off|last-resort|first), SSE wire exposes gates+generation_model+refined_by, scripts/eval-generation.ts harness, baseline doc (pure-LLM 0/8 verified). Phase 1: template demoted to last-resort, per-attempt temperature schedule, generateObject schema-failure retry, gate-specific retry hints + counterexample, intent fallback no longer blanket must_preserve:true, SOLVER_MODEL env, web 템플릿 폴백 badge. Phase 2: math-engine multiprocessing 5s hard-timeout, equals()+numeric-sampling equivalence, /verify-answer (number|expression|equation|solution_set|inequality), lark LaTeX parsing, async endpoints; agent 3-state gates (passed|failed|skipped|unverified), honest sympy/multiple-choice verification (no more non-empty=>passed), re-solve mismatch->retry/reject, equation-extractor unicode+LaTeX, domain.md I-V4 update + web 기호검증불가 badge. Phase 3-1: techniques_used deterministic objective-gate comparison. Phase 4-2: silent catches record skipped_reason. Phase 4-4: env-gated LLM_E2E integration test. Verified: agent typecheck clean, 236 unit + 5 integration tests pass; web typecheck clean; 29 pytest pass, ruff clean. --- docs/specs/domain.md | 4 +- docs/specs/remediation-plan.md | 115 +++ packages/agent/.env.example | 4 + packages/agent/prompts/problem-generator.md | 29 +- packages/agent/src/agents/generator-agent.ts | 107 ++- packages/agent/src/config/env.ts | 14 + packages/agent/src/index.ts | 19 +- .../agent/src/policies/acceptance-policy.ts | 21 +- packages/agent/src/policies/retry-policy.ts | 127 ++- .../src/schemas/generated-problem.schema.ts | 51 ++ .../src/schemas/source-problem.schema.ts | 18 + .../agent/src/schemas/verification.schema.ts | 19 +- .../agent/src/schemas/wire-format.schema.ts | 34 + .../agent/src/server/sse/progress-stream.ts | 4 +- packages/agent/src/server/sse/wire-adapter.ts | 20 + .../agent/src/steps/independent-resolve.ts | 13 +- packages/agent/src/steps/intent-extraction.ts | 22 +- packages/agent/src/steps/objective-mapping.ts | 121 ++- .../steps/problem-generation-deterministic.ts | 1 + .../agent/src/steps/problem-generation.ts | 32 +- .../agent/src/steps/sympy-verification.ts | 421 +++++++-- .../agent/src/tools/answer-equivalence.ts | 251 ++++- .../agent/src/tools/equation-extractor.ts | 187 +++- packages/agent/src/tools/latex-formatter.ts | 44 + .../src/workflows/verification-workflow.ts | 88 +- packages/agent/tests/backend-contract.test.ts | 14 +- .../tests/deterministic-fallback.test.ts | 194 ++++ packages/agent/tests/env.test.ts | 21 + .../agent/tests/equation-extractor.test.ts | 37 +- packages/agent/tests/generator-agent.test.ts | 144 +++ .../independent-resolve-choice-labels.test.ts | 1 + ...olve-geometry-function-equivalence.test.ts | 1 + .../tests/independent-resolve-labels.test.ts | 1 + ...ependent-resolve-radicals-decimals.test.ts | 1 + ...dependent-resolve-text-equivalence.test.ts | 1 + .../agent/tests/independent-resolve.test.ts | 1 + .../agent/tests/integration/generate.test.ts | 281 ++++++ .../tests/integration/real-llm-e2e.test.ts | 309 +++++++ .../tests/intent-extraction-fallback.test.ts | 152 +++ packages/agent/tests/invariants.test.ts | 119 +++ packages/agent/tests/latex-formatter.test.ts | 73 ++ .../tests/objective-mapping-function.test.ts | 1 + .../agent/tests/objective-mapping.test.ts | 48 + packages/agent/tests/placeholder.test.ts | 45 + packages/agent/tests/policies.test.ts | 89 +- ...eration-geometry-statistics-guards.test.ts | 1 + .../problem-generation-topic-guards.test.ts | 1 + .../agent/tests/problem-generation.test.ts | 6 +- .../agent/tests/sympy-verification.test.ts | 146 ++- .../verification-workflow-last-resort.test.ts | 251 +++++ packages/agent/tests/wire-adapter.test.ts | 192 ++++ packages/math-engine/pyproject.toml | 1 + packages/math-engine/run.py | 16 +- packages/math-engine/src/main.py | 2 +- packages/math-engine/src/routers/math.py | 535 ++++++++++- .../tests/test_real_verification.py | 107 +++ packages/math-engine/uv.lock | 11 + packages/web/DESIGN.md | 27 + packages/web/app/app/new/result/mock.ts | 3 + packages/web/app/app/new/result/view.tsx | 60 ++ packages/web/app/globals.css | 15 + packages/web/hooks/use-verification-stream.ts | 40 + scripts/eval-generation.ts | 866 ++++++++++++++++++ 63 files changed, 5367 insertions(+), 212 deletions(-) create mode 100644 docs/specs/remediation-plan.md create mode 100644 packages/agent/tests/deterministic-fallback.test.ts create mode 100644 packages/agent/tests/generator-agent.test.ts create mode 100644 packages/agent/tests/integration/generate.test.ts create mode 100644 packages/agent/tests/integration/real-llm-e2e.test.ts create mode 100644 packages/agent/tests/intent-extraction-fallback.test.ts create mode 100644 packages/agent/tests/invariants.test.ts create mode 100644 packages/agent/tests/latex-formatter.test.ts create mode 100644 packages/agent/tests/verification-workflow-last-resort.test.ts create mode 100644 packages/agent/tests/wire-adapter.test.ts create mode 100644 packages/math-engine/tests/test_real_verification.py create mode 100644 scripts/eval-generation.ts diff --git a/docs/specs/domain.md b/docs/specs/domain.md index 72946dc..4604dcc 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -147,7 +147,7 @@ type SurfaceConstraints = { ```ts type GateResult = { step: "rag" | "intent" | "generate" | "sympy_verify" | "re_solve" | "objective_map"; - status: "passed" | "failed" | "skipped"; + status: "passed" | "failed" | "skipped" | "unverified"; duration_ms: number; evidence?: unknown; // step별 다름 failure_detail?: { code: string; message: string }; @@ -168,7 +168,7 @@ type HumanFailureReason = { - (I-V1) `gates`는 정확히 6개. 누락된 단계는 `status: "skipped"`로 명시. - (I-V2) `overall == "verified"`는 `gates[3] (sympy_verify).status == "passed"` **and** `gates[5] (objective_map).status == "passed"` 일 때만 가능 (결정론 게이트). - (I-V3) `gates[1] (intent)`와 `gates[2] (generate)`가 passed라도 `gates[3] (sympy_verify)`가 failed면 `overall != "verified"` (D-1: LLM은 정답 판단 X). -- (I-V4) `overall == "warning"`은 SymPy는 통과했으나 `independent_resolve`(gates[4])가 불일치할 때만 사용 — 사용자에게 *주의* 라벨로 노출. +- (I-V4) `overall == "warning"`은 SymPy가 `passed` 또는 `unverified`인 상태에서 `independent_resolve`(gates[4])가 불일치했거나, 비결정 가능(non-decidable) 게이트가 `unverified`일 때 사용 — 사용자에게 *주의* 라벨로 노출. 단, 재시도 상한의 최종 attempt에서도 `re_solve` 불일치가 지속되면 `warning`이 아니라 `rejected`다. - (I-V5) `attempt_count > 3`이면 강제 `overall == "rejected"` (Q-5 잠정 정책, D-9에서 확정 예정). --- diff --git a/docs/specs/remediation-plan.md b/docs/specs/remediation-plan.md new file mode 100644 index 0000000..871154e --- /dev/null +++ b/docs/specs/remediation-plan.md @@ -0,0 +1,115 @@ +# Remediation Plan — Real Generation Agent Transition + +| | | +|---|---| +| Status | Active (implementation) | +| Last updated | 2026-06-10 | +| Branch | `feat/agent-real-generation-remediation` | +| Goal | 12 demo units where the LLM actually generates problems, gates pass *honestly*, and quality is provable in numbers. | +| Principle | Each phase builds on the previous phase's **measurement**. Measure first, then fix ("재고 나서 고친다"). | + +> This doc tracks the engineering execution of the transition from a template-first +> demo to a real LLM-generation product. File/line anchors below were captured from a +> full codebase survey (2026-06-10) and may drift — re-grep before editing. + +## Environment status (verified 2026-06-10) + +- `packages/agent/.env` exists: `LLM_PROVIDER=cliproxy`, `CLIPROXY_BASE_URL=http://localhost:8317/v1`, `LLM_MODEL=gpt-5.5(xhigh)`. +- CLIProxyAPI router reachable on `:8317` (401 on unauthenticated `/v1/models`; agent holds a key). +- math-engine healthy on `:16180`; agent up on `:31415`. +- Python **3.11.14** → `ProcessPoolExecutor.kill_workers()` (3.14+) unavailable. Hard SymPy timeout must use `multiprocessing.Process` + `terminate()`. +- AI SDK pinned `ai@^4.3.0` → v4 usage naming `{promptTokens, completionTokens, totalTokens}`; `NoObjectGeneratedError`, `experimental_repairText`, `abortSignal`, `temperature` all available on `generateObject`. + +## 12 demo units (eval target) + +`9수01-01`, `9수01-05`, `9수02-01`, `9수02-03`, `9수02-06`, `9수02-07`, `9수02-08`, `9수02-09`, `9수02-10`, `9수03-02`, `9수03-04`, `9수04-05`. + +## Invariant impact (domain.md must be updated where flagged) + +- Adding gate status `unverified` extends `GateStatusSchema` (`passed|failed|skipped` → `+unverified`). Touches **I-V1** model and **I-V4** semantics → update `domain.md` §2.4. +- 2-9 changes re-solve mismatch from `warning`-pass to retry-then-reject → revises **I-V4** intent → update `domain.md`. +- 1-4 intent fallback must still satisfy **I-I2** (≥1 `must_preserve:true`). +- 3-1/3-2/3-3 enforce **I-G2/I-G3** deterministically at the gate. + +--- + +## Phase 0 — Baseline measurement (prerequisite for all decisions) + +| # | Task | Target | +|---|---|---| +| 0-1 | `DETERMINISTIC_FALLBACK=off\|last-resort\|first` (default `first`) env, threaded to workflow. | `config/env.ts` L8-32, `index.ts` L39-103, `steps/problem-generation.ts` L58-63 | +| 0-1b | **Observability prereq**: expose verification `gates` + `generation_metadata.{model,refined_by}` + usage in the SSE result wire (needed by eval AND Phase 1-1 badge; no behavior change). | `wire-adapter.ts` L96-115, `wire-format.schema.ts` L45-72, `progress-event.schema.ts` | +| 0-2 | `scripts/eval-generation.ts`: 12 units × {structural,conceptual} × 5 → per-gate pass rate, failure reason, duration, `refined_by`, model → JSONL + summary MD. Template on `scripts/sweep-generate.mjs`. | `scripts/eval-generation.ts` (new) | +| 0-3 | Run once with `DETERMINISTIC_FALLBACK=off` → `docs/eval/baseline-.md`. **Requires live LLM; numbers never fabricated.** | — | + +**Done when**: a per-unit LLM-generation success table exists as the comparison baseline. + +> **Baseline result (2026-06-10, see `docs/eval/baseline-2026-06-10.md`)**: pure-LLM (`off`) verified = **0/8 (0%)**. Failure modes: `no-result` pipeline exception (silently swallowed; from `generateProblem` re-throwing a `generateObject` schema failure) and 160s timeouts (slow `xhigh` model). Confirms the demo passes only via the template short-circuit. This is the number Phase 1+ must move. + +## Phase 1 — Generation path recovery + +| # | Task | Target | +|---|---|---| +| 1-1 | Demote template to last-resort; surface `generation_metadata.model="deterministic-topic-generator"` in SSE result; web "템플릿 폴백" badge. | `problem-generation.ts` L58-63,140-153, `deterministic.ts`, wire-adapter, web `result/view.tsx` L38-74 | +| 1-2 | Per-attempt temperature schedule (0.35→0.6→0.85); gate-specific retry hints; previous failed candidate as counterexample in prompt. | `retry-policy.ts` L19-45, `generator-agent.ts` L66-72, `prompts/problem-generator.md` | +| 1-3 | `generateObject` schema-failure immediate single retry (error as hint) before bubbling to workflow failure. | `generator-agent.ts` L66-72 | +| 1-4 | Intent fallback: stop guessing `must_preserve:true` for all dims → inherit strategy default dims; if guessed, set `fallback:true` evidence → warning. Keep I-I2. | `intent-extraction.ts` L69-110 | +| 1-5 | `SOLVER_MODEL` env (defaults to `LLM_MODEL`) so re-solve uses a different model. | `config/env.ts`, `index.ts` L42-80, `config/models.ts` | + +**Done when**: re-run eval shows `deterministic-topic-generator` in `refined_by` < 10%, LLM success measurably improved vs baseline. + +## Phase 2 — Verification becomes real verification + +### math-engine (Python) — parallelizable with TS +| # | Task | Target | +|---|---|---| +| 2-1 | Per-request hard timeout via `multiprocessing.Process` + `terminate()` (5s) — Python 3.11, no `kill_workers`. | `routers/math.py`, `main.py` | +| 2-2 | Equivalence `simplify(a-b)==0` → `expr.equals(0)` + numeric-sampling fallback (lambdify mpmath, N trials). | `math.py` L78-86 | +| 2-3 | New `/verify-answer` with `answer_type: number\|expression\|equation\|solution_set\|inequality` (x=2 vs x-2=0; {-2,2} vs ±2; 1/2 vs 0.5). | `math.py`, models | +| 2-4 | LaTeX parse layer: `parse_latex(..., backend="lark")` + add `lark` dep; `\frac` etc → SymPy. Removes agent regex workarounds. | `math.py` L19-23, `pyproject.toml` | +| 2-5 | `async def` endpoints delegating to executor; uvicorn workers. | `main.py`, `run.py` | + +### agent (TypeScript) +| # | Task | Target | +|---|---|---| +| 2-6 | 3-state gates: add `unverified` to `GateStatusSchema`. SymPy-blind types (Korean answer, geometry, stats) → `unverified` (not non-empty⇒passed). acceptance: `unverified`→`warning`. web badge "기호 검증 불가". **update domain.md.** | `verification.schema.ts` L24, `sympy-verification.ts` L70-90, `acceptance-policy.ts`, web | +| 2-7 | Multiple-choice verification: extract correct option value → compare to SymPy solution; 5 distractors mutually non-equivalent (block duplicate answers). | `sympy-verification.ts` L71-76, `answer-equivalence.ts` | +| 2-8 | `equation-extractor` unicode ops (≤ ≥ ÷) + LaTeX inline; extraction failure → `unverified` (not throw→failed). | `equation-extractor.ts` | +| 2-9 | Re-solve mismatch → retry trigger; final-attempt mismatch → `reject` (was warning-pass). | `acceptance-policy.ts` L21, `retry-policy.ts` | + +**Done when**: zero "non-empty⇒passed" paths; every candidate is honestly `passed|unverified|failed`; deliberately-wrong-answer test cases all reject. + +## Phase 3 — Isomorphism from prompt to gate + +| # | Task | Target | +|---|---|---| +| 3-1 | `techniques_used: string[]` (enum from strategy vocab) in generator output → deterministic set compare with `required_techniques`. | `generated-problem.schema.ts`, `problem-generator.md`, `objective-mapping.ts` | +| 3-2 | Mode surface check: structural=number-masked skeleton must match source; conceptual=skeleton must differ. | `objective-mapping.ts` L135-141 | +| 3-3 | Enforce `must_preserve` dims deterministically (answer type, technique, difficulty markers) at the gate, independent of LLM critic. | `objective-mapping.ts` | + +**Done when**: injecting a different-technique problem (e.g. quadratic-formula item into a factoring unit) is rejected at the objective gate. + +## Phase 4 — Trust & operational infra + +| # | Task | Target | +|---|---|---| +| 4-1 | `withTimeout` + `AbortController` → propagate `abortSignal` to AI SDK + math-engine fetch; real cancel on timeout. | `timeout-policy.ts`, agents/clients | +| 4-2 | Remove 5 silent catches → record `skipped_reason` in gate evidence. | `objective-mapping.ts` L206, `problem-generation.ts` L190-193, `answer-equivalence.ts` L79-84 | +| 4-3 | Aggregate AI SDK `usage` per workflow into result event; cap calls per workflow. | wire/SSE schema, workflow | +| 4-4 | `LLM_E2E=1`-gated real integration test: 1 unit end-to-end → verified. | `tests/integration/` | +| 4-5 | Wire S5/S6 to real `useVerificationStream` results; remove mock. | `result/page.tsx` L35-46, `export/page.tsx` L45-60 | +| 4-6 | Corpus text injection defense: `…` wrap + "ignore instructions" directive. | `problem-generator.md`, prompt-loader | +| 4-7 | (Deferred) RAG embeddings, request seed reproducibility, rate limit. | post-demo | + +## Dependency order + +``` +Phase 0 (measure) ──→ Phase 1 (generation) ──→ Phase 3 (isomorphism) + └→ Phase 2 (verification) ──┘ +Phase 4: 4-1/4-2 alongside Phase 1; rest at the end. +``` + +- Phase 0+1 = the first bundle; alone yields an "AI generates problems" product. +- Phase 2 splits into Python (2-1..2-5) and agent (2-6..2-9), parallelizable. +- If schedule is tight, cut 3-2/3-3 and 4-6/4-7 first — **never cut 2-6 (3-state)**; honesty of the "verified" claim is this project's reason to exist. +- Re-run `scripts/eval-generation.ts` at each phase boundary; keep the comparison table for the final presentation (improvement story, not a template demo). diff --git a/packages/agent/.env.example b/packages/agent/.env.example index 12318b2..e5c9136 100644 --- a/packages/agent/.env.example +++ b/packages/agent/.env.example @@ -29,3 +29,7 @@ CLIPROXY_MODEL=gpt-5.5(xhigh) LLM_BASE_URL=http://localhost:8317/v1 LLM_API_KEY=dummy-key LLM_MODEL=gpt-5.5(xhigh) + +# (옵션) 독립 재풀이(D-5 ReSolveSpecialist) 전용 모델. 생성과 다른 모델을 쓰면 +# 동일 오답 재현 가능성이 낮아짐. 미설정 시 LLM_MODEL 과 동일한 인스턴스를 공유. +# SOLVER_MODEL=claude-sonnet-4.5 diff --git a/packages/agent/prompts/problem-generator.md b/packages/agent/prompts/problem-generator.md index 4a77f44..b893bd7 100644 --- a/packages/agent/prompts/problem-generator.md +++ b/packages/agent/prompts/problem-generator.md @@ -1,6 +1,6 @@ --- id: problem-generator -version: 0.1.0 +version: 0.1.2 model: gpt-4o temperature: 0.35 max_tokens: 2000 @@ -11,8 +11,10 @@ variables: - refs - strategy - refinementHint + - counterexample + - schemaError owner: 비할당 -updated: 2026-05-18 +updated: 2026-06-10 --- # Role @@ -74,6 +76,25 @@ updated: 2026-05-18 {{refinementHint}} {{/if}} +{{#if counterexample}} +# Counterexample (반복 금지) + +이전 검증에서 실패한 후보를 그대로 반복하지 말 것. 아래 문제·정답 조합과 다른 계수, 조건, 정답을 설계하라. + +{{counterexample}} +{{/if}} + +{{#if schemaError}} +# Schema repair hint (즉시 재시도) + +직전 응답이 JSON schema 검증에 실패했다. 오류: {{schemaError}} + +- 반드시 JSON object 하나만 출력하라. +- `question_text`, `expected_answer`, `techniques_used`, `proposed_solution_trace` 네 필드를 모두 채워라. +- `techniques_used`는 문자열 배열이며 Strategy의 `techniques.required_at_least_one_of`와 Intent의 필수 기법 어휘에서 실제 풀이에 사용한 기법 id만 고른다. 예: `factoring`, `quadratic_formula`, `completing_the_square`. +- JSON 문자열 안의 수식은 raw backslash 없이 plain text로 써라. +{{/if}} + # Output JSON으로만 응답. @@ -82,7 +103,9 @@ JSON으로만 응답. - 수식은 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": "분배법칙으로 각 항을 곱해 동류항을 정리한다." }` +- `techniques_used`는 실제 사용한 풀이 기법 id 배열이다. Strategy 어휘에 있는 `factoring`, `quadratic_formula`, `completing_the_square` 같은 snake_case id를 우선 사용하고, 공백·한글 설명 문장은 넣지 않는다. +- structural 모드에서는 필수 기법을 모두 포함해야 한다. conceptual 모드에서는 필수·관련 기법 중 실제 사용한 것을 최소 1개 이상 포함한다. +- 예시 출력: `{ "question_text": "다항식 (x + 3)(x - 5)를 전개하시오.", "expected_answer": "x**2 - 2*x - 15", "techniques_used": ["polynomial_expansion"], "proposed_solution_trace": "분배법칙으로 각 항을 곱해 동류항을 정리한다." }` - `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시하되, 수식도 JSON 안전한 plain text 표기만 사용한다. - 결과 문제는 source problem과 달라야 하며, structural/conceptual 모드 차이가 풀이 설명에 드러나야 한다. diff --git a/packages/agent/src/agents/generator-agent.ts b/packages/agent/src/agents/generator-agent.ts index ccb50ee..790359f 100644 --- a/packages/agent/src/agents/generator-agent.ts +++ b/packages/agent/src/agents/generator-agent.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; -import { generateObject, type LanguageModel } from "ai"; +import { generateObject, NoObjectGeneratedError, type LanguageModel } from "ai"; import { z } from "zod"; import type { @@ -22,6 +22,7 @@ export interface GeneratorAgentInput { strategy: Strategy | null; attempt: number; refinementHint?: string; + counterexample?: string; } export interface GeneratorAgent { @@ -44,31 +45,52 @@ const LlmGeneratedCandidateSchema = z.object({ .string() .min(1) .describe("Exact answer in a compact plain-text math format"), + techniques_used: z + .array(z.string()) + .default([]) + .describe("Technique ids used in the solution, selected from the strategy vocabulary"), proposed_solution_trace: z .string() .min(1) .describe("Korean solution trace explaining the structural/conceptual transform"), }); +type LlmGeneratedCandidate = z.infer; + +export function temperatureForGeneratorAttempt( + attempt: number, + firstAttemptTemperature = 0.35, +): number { + if (attempt <= 1) return firstAttemptTemperature; + if (attempt === 2) return 0.6; + return 0.85; +} + export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent { return { async generate(input) { const prompt = await deps.prompts.load(deps.promptId); const generationKind = generationKindForTopic(getGenerateRequestTopicCode(input.request)); - const rendered = prompt.render({ + const temperature = temperatureForGeneratorAttempt( + input.attempt, + prompt.metadata.temperature, + ); + const basePromptVars = { request: input.request, generationKind, intent: input.intent, refs: input.refs, strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2), refinementHint: input.refinementHint, - }); - const { object } = await generateObject({ + counterexample: input.counterexample, + }; + const object = await generateCandidateObject({ model: deps.model, - schema: LlmGeneratedCandidateSchema, - mode: "json", - temperature: prompt.metadata.temperature, - prompt: rendered, + prompt: prompt.render(basePromptVars), + temperature, + retryPromptForSchemaError(schemaError) { + return prompt.render({ ...basePromptVars, schemaError }); + }, }); return { @@ -77,12 +99,13 @@ export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent { generation_kind: generationKind, question_text: object.question_text, expected_answer: object.expected_answer, + techniques_used: object.techniques_used ?? [], proposed_solution_trace: object.proposed_solution_trace, source_refs: input.refs.map((ref) => ref.item_id), inferred_intent: input.intent, generation_metadata: { model: deps.modelId, - temperature: prompt.metadata.temperature, + temperature, prompt_id: prompt.metadata.id, prompt_version: prompt.metadata.version, attempt: input.attempt, @@ -92,3 +115,69 @@ export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent { }, }; } + +interface GenerateCandidateObjectInput { + model: LanguageModel; + prompt: string; + temperature: number; + retryPromptForSchemaError(schemaError: string): string; +} + +async function generateCandidateObject( + input: GenerateCandidateObjectInput, +): Promise { + try { + const { object } = await generateObject({ + model: input.model, + schema: LlmGeneratedCandidateSchema, + mode: "json", + temperature: input.temperature, + prompt: input.prompt, + }); + return object; + } catch (error) { + if (!isSchemaGenerationFailure(error)) throw error; + const { object } = await generateObject({ + model: input.model, + schema: LlmGeneratedCandidateSchema, + mode: "json", + temperature: input.temperature, + prompt: input.retryPromptForSchemaError(schemaFailureMessage(error)), + }); + return object; + } +} + +function isSchemaGenerationFailure(error: unknown): boolean { + if (NoObjectGeneratedError.isInstance(error)) return true; + if (error instanceof z.ZodError) return true; + if (isTypeValidationError(error)) return true; + const cause = causeOf(error); + return cause === undefined ? false : isSchemaGenerationFailure(cause); +} + +function schemaFailureMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (hasMessage(error) && typeof error.message === "string") return error.message; + return String(error); +} + +function isTypeValidationError(error: unknown): boolean { + return hasName(error) && error.name === "TypeValidationError"; +} + +function causeOf(error: unknown): unknown | undefined { + return hasCause(error) ? error.cause : undefined; +} + +function hasCause(value: unknown): value is { readonly cause?: unknown } { + return typeof value === "object" && value !== null && "cause" in value; +} + +function hasName(value: unknown): value is { readonly name?: unknown } { + return typeof value === "object" && value !== null && "name" in value; +} + +function hasMessage(value: unknown): value is { readonly message?: unknown } { + return typeof value === "object" && value !== null && "message" in value; +} diff --git a/packages/agent/src/config/env.ts b/packages/agent/src/config/env.ts index 9f235ff..e349ffe 100644 --- a/packages/agent/src/config/env.ts +++ b/packages/agent/src/config/env.ts @@ -17,6 +17,11 @@ export const EnvSchema = z.object({ LLM_BASE_URL: z.string().url().optional(), LLM_API_KEY: z.string().min(1).optional(), LLM_MODEL: z.string().min(1).optional(), + /** Optional override for the independent re-solve agent (D-5 ReSolveSpecialist). + * When set to a model id different from `LLM_MODEL`, the solver agent is built + * with a separate LanguageModel instance to decorrelate errors with generation. + * When unset, the solver shares the generator's resolved model. */ + SOLVER_MODEL: z.string().min(1).optional(), OPENAI_API_KEY: z.string().min(1).optional(), OPENAI_MODEL: z.string().min(1).optional(), CLIPROXY_BASE_URL: z.string().url().optional(), @@ -29,10 +34,19 @@ export const EnvSchema = z.object({ MAX_RETRIES: z.coerce.number().int().min(1).max(10).default(3), PER_STEP_TIMEOUT_MS: z.coerce.number().int().min(1000).default(30000), + + /** `first` = template short-circuits LLM when refs exist (current behavior). + * `off` = always go through LLM generator path; never substitute template. + * `last-resort` = placeholder, currently behaves like `first` (see TODO 1-1a). */ + DETERMINISTIC_FALLBACK: z + .enum(["off", "last-resort", "first"]) + .default("first"), }); export type Env = z.infer; +export type DeterministicFallbackMode = Env["DETERMINISTIC_FALLBACK"]; + export function loadEnv(): Env { const parsed = EnvSchema.safeParse({ ...readDotenvFiles(), ...process.env }); if (!parsed.success) { diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index abea692..f8d6411 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -49,6 +49,18 @@ export async function main(): Promise { allowedHosts: ["localhost", "127.0.0.1"], }) : undefined; + const solverModel = env.SOLVER_MODEL ?? llmModel; + const solverLlm = llm === undefined + ? undefined + : solverModel === llmModel + ? llm + : resolveLanguageModel({ + kind: llmKind, + modelId: solverModel, + baseUrl: llmBaseUrl, + apiKey: llmApiKey ?? "openmath-local", + allowedHosts: ["localhost", "127.0.0.1"], + }); const generator = llm === undefined ? undefined : createGeneratorAgent({ @@ -73,11 +85,11 @@ export async function main(): Promise { promptId: "refiner", generator, }); - const solver = llm === undefined + const solver = solverLlm === undefined ? undefined : createSolverAgent({ - model: llm, - modelId: llmModel, + model: solverLlm, + modelId: solverModel, promptId: "independent-solver", prompts, }); @@ -100,6 +112,7 @@ export async function main(): Promise { workflowOptions: { maxRetries: env.MAX_RETRIES, perStepTimeoutMs: env.PER_STEP_TIMEOUT_MS, + deterministicFallback: env.DETERMINISTIC_FALLBACK, }, }); diff --git a/packages/agent/src/policies/acceptance-policy.ts b/packages/agent/src/policies/acceptance-policy.ts index f8141b4..e133074 100644 --- a/packages/agent/src/policies/acceptance-policy.ts +++ b/packages/agent/src/policies/acceptance-policy.ts @@ -6,20 +6,33 @@ export interface AcceptancePolicy { decide(gates: GateResult[], attemptCount: number): OverallVerdict; } -export function createAcceptancePolicy(): AcceptancePolicy { +export interface AcceptancePolicyOptions { + maxAttempts?: number; +} + +export function createAcceptancePolicy(opts?: AcceptancePolicyOptions): AcceptancePolicy { + const maxAttempts = opts?.maxAttempts ?? 3; return { decide(gates, attemptCount) { - if (attemptCount > 3) return "rejected"; + if (attemptCount > maxAttempts) return "rejected"; const byStep = new Map(gates.map((gate) => [gate.step, gate])); const sympy = byStep.get("sympy_verify"); const objective = byStep.get("objective_map"); const reSolve = byStep.get("re_solve"); + if (sympy?.status === "failed") return "rejected"; + if (objective?.status === "failed") return "rejected"; + if (gates.some((gate) => gate.status === "failed" && gate.step !== "re_solve")) { + return "rejected"; + } + if (reSolve?.status === "failed") { + return attemptCount >= maxAttempts ? "rejected" : "warning"; + } + if (sympy?.status === "unverified") return "warning"; + if (gates.some((gate) => gate.status === "unverified")) return "warning"; if (sympy?.status !== "passed") return "rejected"; if (objective?.status !== "passed") return "rejected"; - if (reSolve?.status === "failed") return "warning"; - if (gates.some((gate) => gate.status === "failed")) return "rejected"; return "verified"; }, diff --git a/packages/agent/src/policies/retry-policy.ts b/packages/agent/src/policies/retry-policy.ts index 6a57577..2707719 100644 --- a/packages/agent/src/policies/retry-policy.ts +++ b/packages/agent/src/policies/retry-policy.ts @@ -1,11 +1,12 @@ /** Retry policy — D-5 inner loop. Verification attempt_count ≤ 3 (I-V5, Q-5 잠정). */ -import type { Verification } from "../schemas/index.js"; +import type { GateResult, Verification } from "../schemas/index.js"; export interface RetryDecision { shouldRetry: boolean; nextAttempt: number; refinementHint?: string; + counterexample?: string; } export interface RetryPolicy { @@ -26,20 +27,132 @@ export function createBoundedRetryPolicy( decide(verification) { const nextAttempt = verification.attempt_count + 1; const shouldRetry = - verification.overall !== "verified" && nextAttempt <= opts.maxAttempts; + nextAttempt <= opts.maxAttempts && isRetryableVerification(verification); return { shouldRetry, nextAttempt, - refinementHint: firstFailureMessage(verification), + refinementHint: refinementHintFor(verification), + counterexample: counterexampleFor(verification), }; }, }; } -function firstFailureMessage(verification: Verification): string | undefined { - if (verification.failure_reason !== undefined) { - return verification.failure_reason.user_message_ko; +function isRetryableVerification(verification: Verification): boolean { + if (verification.overall === "verified") return false; + if (verification.gates.some((gate) => gate.step === "re_solve" && gate.status === "failed")) { + return true; } + if (verification.gates.some((gate) => gate.status === "failed")) return true; + return verification.overall === "rejected"; +} + +function refinementHintFor(verification: Verification): string | undefined { const failed = verification.gates.find((gate) => gate.status === "failed"); - return failed?.failure_detail?.message; + if (failed === undefined) return verification.failure_reason?.user_message_ko; + return ( + gateSpecificHint(failed) ?? + verification.failure_reason?.user_message_ko ?? + failed.failure_detail?.message + ); +} + +function gateSpecificHint(gate: GateResult): string | undefined { + if (gate.step === "sympy_verify") return sympyHint(gate); + if (gate.step === "re_solve") return reSolveHint(gate); + if (gate.step === "objective_map") { + return withFailureMessage( + gate, + "학습목표 매칭 실패: 보존 평가 차원과 금지 기법을 다시 확인하라", + ); + } + if (gate.step === "generate") { + return withFailureMessage( + gate, + "생성 실패: JSON 형식, 문제 유형, 정답 필드를 지켜 다시 생성하라", + ); + } + if (gate.step === "intent") { + return withFailureMessage(gate, "의도 추출 실패: 원문 학습목표와 평가 차원을 더 명확히 반영하라"); + } + if (gate.step === "rag") { + return withFailureMessage(gate, "참조 문제 검색 실패: 선택한 단원과 성취기준에 맞는 변형 기준을 다시 확인하라"); + } + return gate.failure_detail?.message; +} + +function sympyHint(gate: GateResult): string { + const evidence = recordFrom(gate.evidence); + const expected = stringField(evidence, [ + "expected_answer", + "expectedAnswer", + "expected", + ]); + const derived = stringField(evidence, [ + "sympy_answer", + "sympyAnswer", + "derived_answer", + "derivedAnswer", + "actual_answer", + "actualAnswer", + "engine_result", + "engineResult", + ]); + if (expected !== undefined && derived !== undefined) { + return `SymPy 검산 불일치: 기대답 ${expected}, 엔진 결과 ${derived} — 계수를 다시 검산하라`; + } + return withFailureMessage( + gate, + "SymPy 검산 실패: 문제 식과 expected_answer가 같은 해를 갖도록 계수와 정답을 다시 계산하라", + ); +} + +function reSolveHint(gate: GateResult): string { + const evidence = recordFrom(gate.evidence); + const expected = stringField(evidence, ["expected_answer", "expectedAnswer", "expected"]); + const derived = stringField(evidence, ["derived_answer", "derivedAnswer", "actual_answer", "actualAnswer"]); + if (expected !== undefined && derived !== undefined) { + return `독립 풀이 불일치: 기대답 ${expected}, 재풀이 결과 ${derived} — 풀이 추적과 정답을 다시 맞춰라`; + } + return withFailureMessage(gate, "독립 풀이 불일치: 풀이 추적과 정답이 일관되도록 다시 생성하라"); +} + +function withFailureMessage(gate: GateResult, action: string): string { + const message = gate.failure_detail?.message; + return message === undefined ? action : `${action}. 세부 원인: ${message}`; +} + +function counterexampleFor(verification: Verification): string | undefined { + const generateGate = verification.gates.find((gate) => gate.step === "generate"); + const evidence = recordFrom(generateGate?.evidence); + const candidate = recordFrom(evidence?.candidate); + const question = + stringField(evidence, ["question_text", "questionText", "question"]) ?? + stringField(candidate, ["question_text", "questionText", "question"]); + const answer = + stringField(evidence, ["expected_answer", "expectedAnswer", "answer"]) ?? + stringField(candidate, ["expected_answer", "expectedAnswer", "answer"]); + if (question === undefined || answer === undefined) return undefined; + return [ + "이전 실패 후보(반복 금지):", + `문제: ${question}`, + `정답: ${answer}`, + ].join("\n"); +} + +function recordFrom(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringField( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + if (record === undefined) return undefined; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return undefined; } diff --git a/packages/agent/src/schemas/generated-problem.schema.ts b/packages/agent/src/schemas/generated-problem.schema.ts index 92670dc..c4b5cd1 100644 --- a/packages/agent/src/schemas/generated-problem.schema.ts +++ b/packages/agent/src/schemas/generated-problem.schema.ts @@ -38,6 +38,7 @@ export const GeneratedProblemSchema = z.object({ question_text: z.string().min(1), // LaTeX expected_answer: z.string().min(1), // LaTeX expected_choices: z.array(z.string()).optional(), // objective일 때 + techniques_used: z.array(z.string()).default([]), proposed_solution_trace: z.string(), source_refs: z.array(z.string()), // SourceProblem.item_id[] @@ -47,3 +48,53 @@ export const GeneratedProblemSchema = z.object({ }); export type GeneratedProblem = z.infer; + +export function assertGeneratedProblemInvariants( + problem: GeneratedProblem, + intent: z.infer, +): void { + if (problem.inferred_intent.objective_code !== intent.objective_code) { + throw new Error( + `I-G1 violated: generated problem ${problem.candidate_id} inferred objective ${problem.inferred_intent.objective_code} does not match intent ${intent.objective_code}`, + ); + } + if (!sameNormalizedSet(problem.inferred_intent.required_techniques, intent.required_techniques)) { + throw new Error(`I-G2 violated: generated problem ${problem.candidate_id} changed required_techniques`); + } + if (problem.mode === "conceptual" && !preservesMustPreserveDimensions(problem, intent)) { + throw new Error(`I-G3 violated: conceptual generated problem ${problem.candidate_id} changed must_preserve dimensions`); + } +} + +function preservesMustPreserveDimensions( + problem: GeneratedProblem, + intent: z.infer, +): boolean { + const candidateDimensions = new Set( + problem.inferred_intent.evaluation_dimensions + .filter((dimension) => dimension.must_preserve) + .map((dimension) => normalizedDimensionKey(dimension.id, dimension.description)), + ); + return intent.evaluation_dimensions + .filter((dimension) => dimension.must_preserve) + .every((dimension) => candidateDimensions.has(normalizedDimensionKey(dimension.id, dimension.description))); +} + +function sameNormalizedSet(left: readonly string[], right: readonly string[]): boolean { + const normalizedLeft = normalizedSet(left); + const normalizedRight = normalizedSet(right); + if (normalizedLeft.length !== normalizedRight.length) return false; + return normalizedLeft.every((value, index) => value === normalizedRight[index]); +} + +function normalizedSet(values: readonly string[]): string[] { + return [...new Set(values.map(normalizeToken).filter((value) => value.length > 0))].sort(); +} + +function normalizedDimensionKey(id: string, description: string): string { + return `${normalizeToken(id)}:${normalizeToken(description)}`; +} + +function normalizeToken(value: string): string { + return value.trim().toLowerCase().replace(/[\s-]+/g, "_"); +} diff --git a/packages/agent/src/schemas/source-problem.schema.ts b/packages/agent/src/schemas/source-problem.schema.ts index 5b121a4..1ed92f2 100644 --- a/packages/agent/src/schemas/source-problem.schema.ts +++ b/packages/agent/src/schemas/source-problem.schema.ts @@ -54,3 +54,21 @@ export const SourceProblemSchema = z.object({ }); export type SourceProblem = z.infer; + +export function assertSourceProblemInvariants(problem: SourceProblem): void { + if (problem.question_text.trim().length === 0) { + throw new Error(`I-S1 violated: source problem ${problem.item_id} needs non-empty question_text`); + } + if (problem.achievement_standard === null || problem.achievement_standard.trim().length === 0) { + throw new Error(`I-S2 violated: source problem ${problem.item_id} needs achievement_standard`); + } + if (/\\(?:d?frac|sqrt|cdot|left|right|pi|pm)/u.test(problem.question_text)) { + throw new Error(`I-S3 violated: source problem ${problem.item_id} question_text must be normalized plain text`); + } + if (problem.explanation_text === null || problem.explanation_text.trim().length === 0) { + throw new Error(`I-S4 violated: source problem ${problem.item_id} needs explanation_text`); + } + if (problem.grade === null) { + throw new Error(`I-S5 violated: source problem ${problem.item_id} needs grade`); + } +} diff --git a/packages/agent/src/schemas/verification.schema.ts b/packages/agent/src/schemas/verification.schema.ts index 7683bfa..4b29bc2 100644 --- a/packages/agent/src/schemas/verification.schema.ts +++ b/packages/agent/src/schemas/verification.schema.ts @@ -21,7 +21,7 @@ export const StepNameSchema = z.enum([ ]); export type StepName = z.infer; -export const GateStatusSchema = z.enum(["passed", "failed", "skipped"]); +export const GateStatusSchema = z.enum(["passed", "failed", "skipped", "unverified"]); export type GateStatus = z.infer; export const GateResultSchema = z.object({ @@ -104,16 +104,23 @@ export function assertVerificationInvariants(v: Verification): void { ); } - // I-V4: warning은 sympy passed이고 re_solve mismatch일 때만 + // I-V4: warning은 re_solve mismatch 또는 non-decidable gate를 정직하게 노출할 때만 if (v.overall === "warning") { - if (sympy?.status !== "passed") { + const hardFailed = v.gates.some( + (gate) => gate.status === "failed" && gate.step !== "re_solve", + ); + if (hardFailed) { throw new Error( - `I-V4 violated: warning requires sympy_verify=passed (got ${sympy?.status})`, + "I-V4 violated: warning cannot mask failed deterministic gates other than re_solve", ); } - if (reSolve?.status !== "failed") { + const reSolveMismatchWarning = + (sympy?.status === "passed" || sympy?.status === "unverified") && + reSolve?.status === "failed"; + const unverifiedGateWarning = v.gates.some((gate) => gate.status === "unverified"); + if (!reSolveMismatchWarning && !unverifiedGateWarning) { throw new Error( - `I-V4 violated: warning requires re_solve=failed (got ${reSolve?.status})`, + `I-V4 violated: warning requires re_solve mismatch or an unverified gate (sympy=${sympy?.status}, re_solve=${reSolve?.status})`, ); } } diff --git a/packages/agent/src/schemas/wire-format.schema.ts b/packages/agent/src/schemas/wire-format.schema.ts index b5df397..912a4e6 100644 --- a/packages/agent/src/schemas/wire-format.schema.ts +++ b/packages/agent/src/schemas/wire-format.schema.ts @@ -42,6 +42,35 @@ export type WireVerificationStatus = z.infer< typeof WireVerificationStatusSchema >; +export const WireOverallVerdictSchema = z.enum([ + "verified", + "rejected", + "warning", +]); +export type WireOverallVerdict = z.infer; + +export const WireGateStepSchema = z.enum([ + "rag", + "intent", + "generate", + "sympy_verify", + "re_solve", + "objective_map", +]); +export type WireGateStep = z.infer; + +export const WireGateStatusSchema = z.enum(["passed", "failed", "skipped", "unverified"]); +export type WireGateStatus = z.infer; + +export const WireGateSchema = z.object({ + step: WireGateStepSchema, + status: WireGateStatusSchema, + duration_ms: z.number().int().min(0), + failure_code: z.string().optional(), + failure_message: z.string().optional(), +}); +export type WireGate = z.infer; + export const WireResultProblemSchema = z.object({ id: z.string().min(1), question_latex: z.string().min(1), @@ -51,6 +80,11 @@ export const WireResultProblemSchema = z.object({ preserved_dimensions: z.array(z.string()), source_refs: z.array(z.string()), verification_status: WireVerificationStatusSchema, + overall: WireOverallVerdictSchema, + gates: z.array(WireGateSchema), + attempt_count: z.number().int().min(1), + generation_model: z.string().min(1), + refined_by: z.array(z.string()), }); export type WireResultProblem = z.infer; diff --git a/packages/agent/src/server/sse/progress-stream.ts b/packages/agent/src/server/sse/progress-stream.ts index 1d8ba98..9d2fc8f 100644 --- a/packages/agent/src/server/sse/progress-stream.ts +++ b/packages/agent/src/server/sse/progress-stream.ts @@ -17,12 +17,12 @@ export async function pipeProgressToSse( data: JSON.stringify(wire.data), }); } - } catch { + } catch (err) { const wire = toWireSseEvent({ type: "error", stage: "orchestrator", code: "workflow_exception", - message: "Verification workflow failed before streaming completed", + message: `Verification workflow failed before streaming completed: ${err instanceof Error ? err.message : String(err)}`, recoverable: false, timestamp: new Date().toISOString(), }); diff --git a/packages/agent/src/server/sse/wire-adapter.ts b/packages/agent/src/server/sse/wire-adapter.ts index c4e48a9..ad1e2eb 100644 --- a/packages/agent/src/server/sse/wire-adapter.ts +++ b/packages/agent/src/server/sse/wire-adapter.ts @@ -6,6 +6,7 @@ */ import type { + GateResult, GeneratedProblem, ProgressEvent, ResultEvent, @@ -13,6 +14,7 @@ import type { StepName, Verification, WireErrorEvent, + WireGate, WireResultEvent, WireResultProblem, WireSseEvent, @@ -93,6 +95,19 @@ function preservedDimensions(problem: GeneratedProblem): string[] { .map((dimension) => dimension.description); } +function toWireGate(gate: GateResult): WireGate { + const wire: WireGate = { + step: gate.step, + status: gate.status, + duration_ms: gate.duration_ms, + }; + if (gate.failure_detail !== undefined) { + wire.failure_code = gate.failure_detail.code; + wire.failure_message = gate.failure_detail.message; + } + return wire; +} + export function toWireResultProblem( problem: GeneratedProblem, verification: Verification, @@ -105,6 +120,11 @@ export function toWireResultProblem( preserved_dimensions: preservedDimensions(problem), source_refs: problem.source_refs, verification_status: mapVerificationStatus(verification.overall), + overall: verification.overall, + gates: verification.gates.map(toWireGate), + attempt_count: verification.attempt_count, + generation_model: problem.generation_metadata.model, + refined_by: problem.generation_metadata.refined_by ?? [], }; } diff --git a/packages/agent/src/steps/independent-resolve.ts b/packages/agent/src/steps/independent-resolve.ts index 1282bcd..38c53fc 100644 --- a/packages/agent/src/steps/independent-resolve.ts +++ b/packages/agent/src/steps/independent-resolve.ts @@ -4,7 +4,7 @@ import type { SolverAgent } from "../agents/index.js"; import type { GateResult, GeneratedProblem, SolveAttempt } from "../schemas/index.js"; import type { MathEngineClient } from "../tools/math-engine-client.js"; -import { sameAnswer } from "../tools/answer-equivalence.js"; +import { sameAnswer, type AnswerEquivalenceDebug } from "../tools/answer-equivalence.js"; import { withTimeout } from "../policies/timeout-policy.js"; export interface IndependentResolveDeps { @@ -33,7 +33,13 @@ export async function independentResolve( () => deps.solver.solve(input.candidate), { ms: deps.perStepTimeoutMs ?? 30_000, label: "re_solve" }, ); - const matches = await sameAnswer(deps.mathEngine, input.candidate, attempt.derived_answer); + const answerDebug: AnswerEquivalenceDebug = { skippedReasons: [] }; + const matches = await sameAnswer( + deps.mathEngine, + input.candidate, + attempt.derived_answer, + answerDebug, + ); return { data: attempt, gate: { @@ -45,6 +51,9 @@ export async function independentResolve( derived_answer: attempt.derived_answer, expected_answer: input.candidate.expected_answer, sympy_status: input.sympyGate.status, + ...(answerDebug.skippedReasons.length === 0 + ? {} + : { answer_equivalence_skipped_reasons: answerDebug.skippedReasons }), }, failure_detail: matches ? undefined diff --git a/packages/agent/src/steps/intent-extraction.ts b/packages/agent/src/steps/intent-extraction.ts index 60622de..b8aed42 100644 --- a/packages/agent/src/steps/intent-extraction.ts +++ b/packages/agent/src/steps/intent-extraction.ts @@ -69,6 +69,7 @@ export async function extractIntent( } catch (err) { const fallback = buildSeedIntent(input.request, input.strategy, input.refs); assertIntentInvariants(fallback); + const dimensionsSource = input.strategy === null ? "guessed" : "strategy"; return { data: fallback, gate: { @@ -78,6 +79,7 @@ export async function extractIntent( evidence: { objective_code: fallback.objective_code, fallback: true, + dimensions_source: dimensionsSource, llm_error: err instanceof Error ? err.message : String(err), }, }, @@ -101,13 +103,7 @@ function buildSeedIntent( objective_description: strategy?.title ?? first.problem.topic_name, evaluation_dimensions: strategy?.evaluation_dimensions ?? - (request.dims.length > 0 - ? request.dims.map((description, index) => ({ - id: String.fromCharCode(65 + index), - description, - must_preserve: true, - })) - : [{ id: "A", description: first.problem.topic_name, must_preserve: true }]), + buildGuessedEvaluationDimensions(request, first.problem.topic_name), required_techniques: strategy?.techniques.required_at_least_one_of ?? [], forbidden_techniques: strategy?.techniques.forbidden ?? [], surface_constraints: { @@ -116,3 +112,15 @@ function buildSeedIntent( }, }; } + +function buildGuessedEvaluationDimensions( + request: GenerateRequest, + fallbackDescription: string, +): Intent["evaluation_dimensions"] { + const descriptions = request.dims.length > 0 ? request.dims : [fallbackDescription]; + return descriptions.map((description, index) => ({ + id: String.fromCharCode(65 + index), + description, + must_preserve: index === 0, + })); +} diff --git a/packages/agent/src/steps/objective-mapping.ts b/packages/agent/src/steps/objective-mapping.ts index aae7b71..93700b1 100644 --- a/packages/agent/src/steps/objective-mapping.ts +++ b/packages/agent/src/steps/objective-mapping.ts @@ -39,15 +39,39 @@ export interface ObjectiveMappingOutput { gate: GateResult; } +interface ObjectiveFailure { + readonly code: string; + readonly message: string; +} + +interface TechniqueEvidence { + readonly required_techniques: string[]; + readonly related_techniques: string[]; + readonly techniques_used: string[]; + readonly missing_required_techniques: string[]; + readonly overlapping_techniques: string[]; +} + +interface DeterministicEvaluation { + readonly failures: ObjectiveFailure[]; + readonly techniqueEvidence: TechniqueEvidence; +} + +interface NuanceLoadResult { + readonly nuance: ObjectiveMappingNuance | null; + readonly skippedReason?: string; +} + export async function mapObjective( deps: ObjectiveMappingDeps, input: ObjectiveMappingInput, ): Promise { const started = Date.now(); - const failures = evaluateDeterministically(input); - const nuance = await loadNuance(deps, input); + const deterministic = evaluateDeterministically(input); + const nuanceResult = await loadNuance(deps, input); + const failures = deterministic.failures; return { - data: nuance, + data: nuanceResult.nuance, gate: { step: "objective_map", status: failures.length === 0 ? "passed" : "failed", @@ -59,7 +83,11 @@ export async function mapObjective( strategy_code: input.strategy?.code ?? null, difficulty: input.request.difficulty, problem_type: input.request.problem_type, - llm_nuance: nuance, + ...deterministic.techniqueEvidence, + llm_nuance: nuanceResult.nuance, + ...(nuanceResult.skippedReason === undefined + ? {} + : { nuance_skipped_reason: nuanceResult.skippedReason }), }, failure_detail: failures.length === 0 @@ -72,11 +100,12 @@ export async function mapObjective( }; } -function evaluateDeterministically(input: ObjectiveMappingInput): Array<{ code: string; message: string }> { - const failures: Array<{ code: string; message: string }> = []; +function evaluateDeterministically(input: ObjectiveMappingInput): DeterministicEvaluation { + const failures: ObjectiveFailure[] = []; const { request, strategy, refs, candidate } = input; const requestTopicCode = getGenerateRequestTopicCode(request); const expectedKind = generationKindForTopic(requestTopicCode); + const techniqueCheck = compareTechniqueSets(input); if ( input.intent.objective_code !== requestTopicCode && @@ -140,7 +169,70 @@ function evaluateDeterministically(input: ObjectiveMappingInput): Array<{ code: }); } - return failures; + failures.push(...techniqueCheck.failures); + + return { + failures, + techniqueEvidence: techniqueCheck.evidence, + }; +} + +function compareTechniqueSets(input: ObjectiveMappingInput): { + readonly failures: ObjectiveFailure[]; + readonly evidence: TechniqueEvidence; +} { + const required = normalizedTechniqueSet(input.intent.required_techniques); + const related = normalizedTechniqueSet([ + ...input.intent.required_techniques, + ...(input.strategy?.techniques.required_at_least_one_of ?? []), + ]); + const used = normalizedTechniqueSet(input.candidate.techniques_used ?? []); + const usedSet = new Set(used); + const missingRequired = required.filter((technique) => !usedSet.has(technique)); + const overlapping = related.filter((technique) => usedSet.has(technique)); + const failures: ObjectiveFailure[] = []; + + if (input.request.mode === "conceptual") { + if (related.length > 0 && overlapping.length === 0) { + failures.push({ + code: "technique_mismatch", + message: `Candidate techniques ${formatTechniqueList(used)} do not overlap required or related techniques ${formatTechniqueList(related)}`, + }); + } + } else if (missingRequired.length > 0) { + failures.push({ + code: "technique_mismatch", + message: `Candidate techniques ${formatTechniqueList(used)} do not cover required techniques ${formatTechniqueList(missingRequired)}`, + }); + } + + return { + failures, + evidence: { + required_techniques: required, + related_techniques: related, + techniques_used: used, + missing_required_techniques: missingRequired, + overlapping_techniques: overlapping, + }, + }; +} + +function normalizedTechniqueSet(values: readonly string[]): string[] { + const techniques = new Set(); + for (const value of values) { + const technique = normalizeTechnique(value); + if (technique.length > 0) techniques.add(technique); + } + return [...techniques].sort(); +} + +function normalizeTechnique(value: string): string { + return value.trim().toLowerCase().replace(/[\s-]+/g, "_"); +} + +function formatTechniqueList(techniques: readonly string[]): string { + return techniques.length === 0 ? "[]" : `[${techniques.join(", ")}]`; } function candidateSupportsRequestedTopic(input: ObjectiveMappingInput): boolean { @@ -183,12 +275,12 @@ function topicAliasQueries(topicCode: string): string[] { async function loadNuance( deps: ObjectiveMappingDeps, input: ObjectiveMappingInput, -): Promise { +): Promise { const llm = deps.llm; const prompts = deps.prompts; - if (llm === undefined || prompts === undefined) return null; + if (llm === undefined || prompts === undefined) return { nuance: null }; try { - return await withTimeout(async () => { + const nuance = await withTimeout(async () => { const prompt = await prompts.load("objective-mapper"); const rendered = prompt.render({ candidate: input.candidate, @@ -204,11 +296,16 @@ async function loadNuance( }); return object; }, { ms: deps.perStepTimeoutMs ?? 30_000, label: "objective_map_llm" }); - } catch { - return null; + return { nuance }; + } catch (err) { + return { nuance: null, skippedReason: errorMessage(err) }; } } +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + function sameMathText(left: string, right: string): boolean { return normalizeMathText(left) === normalizeMathText(right); } diff --git a/packages/agent/src/steps/problem-generation-deterministic.ts b/packages/agent/src/steps/problem-generation-deterministic.ts index 597e2f6..aea0419 100644 --- a/packages/agent/src/steps/problem-generation-deterministic.ts +++ b/packages/agent/src/steps/problem-generation-deterministic.ts @@ -18,6 +18,7 @@ export function deterministicInitialCandidate(input: { mode: input.request.mode === "conceptual" ? "conceptual" : "structural", generation_kind: generationKindForTopic(topicCode), ...template, + techniques_used: input.intent.required_techniques, source_refs: input.refs.map((ref) => ref.item_id), inferred_intent: input.intent, generation_metadata: { diff --git a/packages/agent/src/steps/problem-generation.ts b/packages/agent/src/steps/problem-generation.ts index 4e85879..6031651 100644 --- a/packages/agent/src/steps/problem-generation.ts +++ b/packages/agent/src/steps/problem-generation.ts @@ -6,6 +6,7 @@ import type { GeneratorAgent, RefinerAgent, } from "../agents/index.js"; +import type { DeterministicFallbackMode } from "../config/env.js"; import type { GateResult, GenerateRequest, @@ -40,6 +41,8 @@ export interface ProblemGenerationInput { strategy: Strategy | null; attempt: number; refinementHint?: string; + counterexample?: string; + deterministicFallback?: DeterministicFallbackMode; } export interface ProblemGenerationOutput { @@ -54,9 +57,13 @@ export async function generateProblem( ): Promise { const started = Date.now(); const refinedBy: string[] = []; + const normalizationSkippedReasons: string[] = []; try { const candidate = await withTimeout(async () => { - const initial = input.refs.length > 0 ? deterministicInitialCandidate(input) : null; + const fallbackMode: DeterministicFallbackMode = input.deterministicFallback ?? "first"; + const useTemplateFirst = fallbackMode === "first"; + const initial = + useTemplateFirst && input.refs.length > 0 ? deterministicInitialCandidate(input) : null; if (initial !== null) { refinedBy.push("deterministic-topic-generator"); return initial; @@ -68,8 +75,9 @@ export async function generateProblem( strategy: input.strategy, attempt: input.attempt, refinementHint: input.refinementHint, + counterexample: input.counterexample, }); - current = await normalizeExpectedAnswer(deps.mathEngine, current); + current = await normalizeExpectedAnswer(deps.mathEngine, current, normalizationSkippedReasons); const rounds = deps.maxCriticRounds ?? 2; for (let round = 0; round < rounds; round += 1) { @@ -90,7 +98,7 @@ export async function generateProblem( attempt: input.attempt, hints: guardHints, }); - current = await normalizeExpectedAnswer(deps.mathEngine, current); + current = await normalizeExpectedAnswer(deps.mathEngine, current, normalizationSkippedReasons); refinedBy.push("refiner"); continue; } @@ -111,7 +119,7 @@ export async function generateProblem( attempt: input.attempt, hints: critique.hints, }); - current = await normalizeExpectedAnswer(deps.mathEngine, current); + current = await normalizeExpectedAnswer(deps.mathEngine, current, normalizationSkippedReasons); refinedBy.push("refiner"); } return current; @@ -131,8 +139,13 @@ export async function generateProblem( duration_ms: Date.now() - started, evidence: { candidate_id: candidate.candidate_id, + question_text: candidate.question_text, + expected_answer: candidate.expected_answer, model: candidate.generation_metadata.model, refined_by: refinedBy, + ...(normalizationSkippedReasons.length === 0 + ? {} + : { normalization_skipped_reasons: normalizationSkippedReasons }), }, }, refined_by: refinedBy, @@ -165,12 +178,13 @@ function formatCandidateLatex(candidate: GeneratedProblem): GeneratedProblem { async function normalizeExpectedAnswer( mathEngine: MathEngineClient, candidate: GeneratedProblem, + skippedReasons: string[], ): Promise { if (candidate.generation_kind !== "equation") return candidate; if (hasChoiceMarkers(candidate.question_text)) return candidate; const equation = extractEquationText(candidate.question_text); if (equation === null) return candidate; - const solved = await solveForNormalization(mathEngine, equation); + const solved = await solveForNormalization(mathEngine, equation, skippedReasons); if (solved === null) return candidate; if (solved.solutions.length === 0) return candidate; return { @@ -186,10 +200,16 @@ function hasChoiceMarkers(text: string): boolean { async function solveForNormalization( mathEngine: MathEngineClient, equation: string, + skippedReasons: string[], ): Promise<{ readonly solutions: readonly string[] } | null> { try { return await mathEngine.solve({ equation }); - } catch { + } catch (err) { + skippedReasons.push(`answer normalization skipped: ${errorMessage(err)}`); return null; } } + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/agent/src/steps/sympy-verification.ts b/packages/agent/src/steps/sympy-verification.ts index 4dfaf0d..3b6c9f5 100644 --- a/packages/agent/src/steps/sympy-verification.ts +++ b/packages/agent/src/steps/sympy-verification.ts @@ -2,6 +2,16 @@ import type { GateResult, GeneratedProblem } from "../schemas/index.js"; import { withTimeout } from "../policies/timeout-policy.js"; +import { + choiceIndexFromAnswer, + choiceOptionsFromExpectedChoices, + decideAnswerEquivalence, + decideAnswerMatchesSolutions, + extractChoiceOptions, + stripChoicePrefix, + type AnswerEquivalenceDecision, + type ChoiceOption, +} from "../tools/answer-equivalence.js"; import { extractEquationText } from "../tools/equation-extractor.js"; import type { MathEngineClient } from "../tools/math-engine-client.js"; @@ -18,9 +28,27 @@ export interface SympyVerificationOutput { gate: GateResult; } +type SympyCheckStatus = "passed" | "failed" | "unverified"; + type VerificationCheck = { - readonly passed: boolean; + readonly status: SympyCheckStatus; readonly verificationKind: GeneratedProblem["generation_kind"]; + readonly expectedAnswer?: string; + readonly sympyAnswer?: string; + readonly reason?: string; + readonly failureCode?: string; + readonly failureMessage?: string; + readonly evidence?: Record; +}; + +type SymbolicCheckability = + | { readonly checkable: true } + | { readonly checkable: false; readonly reason: string; readonly evidence?: Record }; + +const NO_EXTRACTABLE_EQUATION_REASON = + "Question text does not contain an extractable equation for SymPy"; +const NO_EXTRACTABLE_EQUATION_EVIDENCE: Record = { + extraction: "no_extractable_equation", }; export async function verifyWithSympy( @@ -36,15 +64,10 @@ export async function verifyWithSympy( return { gate: { step: "sympy_verify", - status: check.passed ? "passed" : "failed", + status: check.status, duration_ms: Date.now() - started, - evidence: { engine: "sympy", verification_kind: check.verificationKind }, - failure_detail: check.passed - ? undefined - : { - code: "sympy_solution_mismatch", - message: "SymPy solution did not match the expected answer", - }, + evidence: sympyEvidence(check), + failure_detail: failureDetailFor(check), }, }; } catch (err) { @@ -67,86 +90,361 @@ async function verifyCandidate( mathEngine: MathEngineClient, candidate: GeneratedProblem, ): Promise { + const checkability = classifySymbolicCheckability(candidate); + if (!checkability.checkable) { + return unverifiedCheck(candidate, checkability.reason, checkability.evidence); + } + if (candidate.generation_kind === "equation") { if (isChoiceStyleCandidate(candidate)) { - return { - passed: candidate.question_text.trim().length > 0 && candidate.expected_answer.trim().length > 0, - verificationKind: candidate.generation_kind, - }; + return verifyChoiceCandidate(mathEngine, candidate); } + return verifyEquationCandidate(mathEngine, candidate); + } + + return unverifiedCheck( + candidate, + `No deterministic SymPy verifier is implemented for generation_kind=${candidate.generation_kind}`, + ); +} + +function classifySymbolicCheckability(candidate: GeneratedProblem): SymbolicCheckability { + if (candidate.generation_kind !== "equation") { return { - passed: await verifyEquationCandidate(mathEngine, candidate), - verificationKind: candidate.generation_kind, + checkable: false, + reason: + "SymPy verification requires a checkable equation; non-equation candidates rely on independent re-solve", + evidence: { generation_kind: candidate.generation_kind }, + }; + } + + const equation = extractEquationText(candidate.question_text); + if (equation === null) { + return { + checkable: false, + reason: NO_EXTRACTABLE_EQUATION_REASON, + evidence: NO_EXTRACTABLE_EQUATION_EVIDENCE, }; } - if (candidate.generation_kind === "expression") { - await verifyExpressionAnswer(mathEngine, candidate.expected_answer); + if (isChoiceStyleCandidate(candidate) && resolveChoiceOptions(candidate).length === 0) { + return { + checkable: false, + reason: "Multiple-choice equation has no parseable expected_choices/options", + evidence: { equation }, + }; } - return { - passed: candidate.question_text.trim().length > 0 && candidate.expected_answer.trim().length > 0, - verificationKind: candidate.generation_kind, - }; + return { checkable: true }; } -async function verifyExpressionAnswer( +async function verifyEquationCandidate( mathEngine: MathEngineClient, - answer: string, -): Promise { - const parts = parseExpressionAnswerParts(answer); - for (const part of parts) { - await mathEngine.simplify({ expr: part }); + candidate: GeneratedProblem, +): Promise { + const equation = extractEquationText(candidate.question_text); + if (equation === null) { + return unverifiedCheck( + candidate, + NO_EXTRACTABLE_EQUATION_REASON, + NO_EXTRACTABLE_EQUATION_EVIDENCE, + ); } -} -function parseExpressionAnswerParts(answer: string): string[] { - return answer - .replace(/(^|\s)\([1-9]\)\s+(?=\S)/gu, "$1;") - .split(/[,;]|또는|or|\n/u) - .map((part) => cleanExpressionAnswerPart(part)) - .filter((part) => /[0-9a-zA-Z]/u.test(part) && !/[가-힣]/u.test(part)); -} + const solved = await solveEquation(mathEngine, equation); + if (solved.solutions.length === 0) { + return unverifiedCheck(candidate, solved.reason, { equation }); + } + + const expected = parseExpectedSolutions(candidate.expected_answer); + if (expected.length === 0) { + return unverifiedCheck( + candidate, + "Expected answer contains no parseable symbolic solutions", + { equation, sympy_solutions: solved.solutions }, + candidate.expected_answer, + solved.solutions.join(", "), + ); + } + + const actualCanonical = await tryCanonicalizeAll(mathEngine, solved.solutions); + const expectedCanonical = await tryCanonicalizeAll(mathEngine, expected); + if (actualCanonical === null || expectedCanonical === null) { + return unverifiedCheck( + candidate, + "math-engine could not canonicalize the declared answer or SymPy solutions", + { equation, expected_solutions: expected, sympy_solutions: solved.solutions }, + expected.join(", "), + solved.solutions.join(", "), + ); + } -function cleanExpressionAnswerPart(part: string): string { - return part - .trim() - .replace(/^[①②③④⑤⑥⑦⑧⑨]\s*/u, "") - .replace(/(?<=\d)\s*(모둠|개|명|cm|kcal|도)$/u, "") - .trim(); + const passed = sameSet(actualCanonical, expectedCanonical); + return { + status: passed ? "passed" : "failed", + verificationKind: candidate.generation_kind, + expectedAnswer: expected.join(", "), + sympyAnswer: solved.solutions.join(", "), + failureCode: passed ? undefined : "sympy_solution_mismatch", + failureMessage: passed + ? undefined + : "SymPy solution did not match the expected answer", + evidence: { + equation, + expected_canonical: expectedCanonical, + sympy_canonical: actualCanonical, + }, + }; } -async function verifyEquationCandidate( +async function verifyChoiceCandidate( mathEngine: MathEngineClient, candidate: GeneratedProblem, -): Promise { +): Promise { const equation = extractEquationText(candidate.question_text); if (equation === null) { - throw new Error("Equation verification requires an equation candidate"); + return unverifiedCheck( + candidate, + NO_EXTRACTABLE_EQUATION_REASON, + NO_EXTRACTABLE_EQUATION_EVIDENCE, + ); } - const solved = await mathEngine.solve({ equation }); + const choices = resolveChoiceOptions(candidate); + if (choices.length < 2) { + return unverifiedCheck(candidate, "Multiple-choice verification requires at least two parseable options", { + equation, + choice_count: choices.length, + }); + } + + const correctChoice = selectDeclaredCorrectChoice(candidate, choices); + if (correctChoice === null) { + return unverifiedCheck( + candidate, + "Declared expected_answer does not identify one of the provided choices", + { equation, expected_answer: candidate.expected_answer, choices: choices.map(formatChoice) }, + ); + } + + const solved = await solveEquation(mathEngine, equation); if (solved.solutions.length === 0) { - throw new Error("SymPy returned no solutions"); + return unverifiedCheck(candidate, solved.reason, { + equation, + correct_choice: formatChoice(correctChoice), + }); } - const expected = parseExpectedSolutions(candidate.expected_answer); - if (expected.length === 0) { - throw new Error("Expected answer contains no parseable solutions"); + const correctDecision = await decideAnswerMatchesSolutions( + mathEngine, + correctChoice.body, + solved.solutions, + ); + if (correctDecision.status === "undecidable") { + return unverifiedCheck( + candidate, + `Could not compare declared correct choice with SymPy solutions: ${decisionReason(correctDecision)}`, + { + equation, + correct_choice: formatChoice(correctChoice), + sympy_solutions: solved.solutions, + }, + correctChoice.body, + solved.solutions.join(", "), + ); + } + if (correctDecision.status === "not_equivalent") { + return { + status: "failed", + verificationKind: candidate.generation_kind, + expectedAnswer: correctChoice.body, + sympyAnswer: solved.solutions.join(", "), + failureCode: "multiple_choice_correct_mismatch", + failureMessage: `Declared correct choice ${formatChoice(correctChoice)} does not match SymPy solutions ${solved.solutions.join(", ")}`, + evidence: { + equation, + correct_choice: formatChoice(correctChoice), + sympy_solutions: solved.solutions, + equivalence: correctDecision, + }, + }; } - const actualCanonical = await canonicalizeAll(mathEngine, solved.solutions); - const expectedCanonical = await canonicalizeAll(mathEngine, expected); - return sameSet(actualCanonical, expectedCanonical); + const pairCheck = await findChoicePairIssue(mathEngine, choices); + if (pairCheck !== null && pairCheck.status === "equivalent") { + return { + status: "failed", + verificationKind: candidate.generation_kind, + expectedAnswer: correctChoice.body, + sympyAnswer: solved.solutions.join(", "), + failureCode: "multiple_choice_duplicate_equivalent_options", + failureMessage: `Choice options ${formatChoice(pairCheck.left)} and ${formatChoice(pairCheck.right)} are equivalent duplicates`, + evidence: { + equation, + duplicate_choices: [formatChoice(pairCheck.left), formatChoice(pairCheck.right)], + equivalence: pairCheck.decision, + }, + }; + } + if (pairCheck !== null && pairCheck.status === "undecidable") { + return unverifiedCheck( + candidate, + `Could not prove choices non-equivalent: ${decisionReason(pairCheck.decision)}`, + { + equation, + undecidable_choices: [formatChoice(pairCheck.left), formatChoice(pairCheck.right)], + equivalence: pairCheck.decision, + }, + correctChoice.body, + solved.solutions.join(", "), + ); + } + + return { + status: "passed", + verificationKind: candidate.generation_kind, + expectedAnswer: correctChoice.body, + sympyAnswer: solved.solutions.join(", "), + evidence: { + equation, + correct_choice: formatChoice(correctChoice), + choice_count: choices.length, + }, + }; +} + +async function solveEquation( + mathEngine: MathEngineClient, + equation: string, +): Promise<{ readonly solutions: string[]; readonly reason: string }> { + try { + const solved = await mathEngine.solve({ equation }); + return { + solutions: solved.solutions, + reason: + solved.solutions.length === 0 + ? "math-engine returned no symbolic solutions" + : "math-engine solved the equation", + }; + } catch (err) { + return { + solutions: [], + reason: `math-engine could not solve the equation: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +function unverifiedCheck( + candidate: GeneratedProblem, + reason: string, + evidence?: Record, + expectedAnswer?: string, + sympyAnswer?: string, +): VerificationCheck { + return { + status: "unverified", + verificationKind: candidate.generation_kind, + reason, + expectedAnswer, + sympyAnswer, + evidence, + }; +} + +function sympyEvidence(check: VerificationCheck): Record { + return { + engine: "sympy", + verification_kind: check.verificationKind, + status: check.status, + ...(check.expectedAnswer === undefined ? {} : { expected_answer: check.expectedAnswer }), + ...(check.sympyAnswer === undefined ? {} : { sympy_answer: check.sympyAnswer }), + ...(check.reason === undefined ? {} : { reason: check.reason }), + ...(check.evidence ?? {}), + }; +} + +function failureDetailFor(check: VerificationCheck): GateResult["failure_detail"] { + if (check.status === "passed") return undefined; + if (check.status === "unverified") { + return { + code: "sympy_unverified", + message: check.reason ?? "SymPy could not decide this candidate", + }; + } + return { + code: check.failureCode ?? "sympy_solution_mismatch", + message: check.failureMessage ?? "SymPy solution did not match the expected answer", + }; } function isChoiceStyleCandidate(candidate: GeneratedProblem): boolean { return ( - /[①②③④⑤⑥⑦⑧⑨]|\([1-9]\)|[1-9][).]/u.test(candidate.question_text) && - /^(?:[①②③④⑤⑥⑦⑧⑨]|\([1-9]\)|[1-9][).])\s*/u.test(candidate.expected_answer.trim()) + (candidate.expected_choices !== undefined && candidate.expected_choices.length > 0) || + (/[①②③④⑤⑥⑦⑧⑨]|\([1-9]\)|[1-9][).]/u.test(candidate.question_text) && + /^(?:[①②③④⑤⑥⑦⑧⑨]|\([1-9]\)|[1-9][).]|[1-9]번)\s*/u.test(candidate.expected_answer.trim())) ); } +function resolveChoiceOptions(candidate: GeneratedProblem): ChoiceOption[] { + const expectedChoices = choiceOptionsFromExpectedChoices(candidate.expected_choices); + return expectedChoices.length > 0 ? expectedChoices : extractChoiceOptions(candidate.question_text); +} + +function selectDeclaredCorrectChoice( + candidate: GeneratedProblem, + choices: readonly ChoiceOption[], +): ChoiceOption | null { + const expectedIndex = choiceIndexFromAnswer(candidate.expected_answer); + if (expectedIndex !== null) { + return choices.find((choice) => choice.index === expectedIndex) ?? null; + } + + const expectedBody = stripChoicePrefix(candidate.expected_answer); + return choices.find((choice) => samePlainChoiceBody(choice.body, expectedBody)) ?? null; +} + +type ChoicePairIssue = { + readonly status: "equivalent" | "undecidable"; + readonly left: ChoiceOption; + readonly right: ChoiceOption; + readonly decision: AnswerEquivalenceDecision; +}; + +async function findChoicePairIssue( + mathEngine: MathEngineClient, + choices: readonly ChoiceOption[], +): Promise { + let firstUndecidable: ChoicePairIssue | null = null; + for (let leftIndex = 0; leftIndex < choices.length; leftIndex += 1) { + const left = choices[leftIndex]; + if (left === undefined) continue; + for (let rightIndex = leftIndex + 1; rightIndex < choices.length; rightIndex += 1) { + const right = choices[rightIndex]; + if (right === undefined) continue; + const decision = await decideAnswerEquivalence(mathEngine, left.body, right.body); + if (decision.status === "equivalent") { + return { status: "equivalent", left, right, decision }; + } + if (decision.status === "undecidable" && firstUndecidable === null) { + firstUndecidable = { status: "undecidable", left, right, decision }; + } + } + } + return firstUndecidable; +} + +function decisionReason(decision: AnswerEquivalenceDecision): string { + return decision.reason ?? decision.status; +} + +function formatChoice(choice: ChoiceOption): string { + return `${choice.label} ${choice.body}`.trim(); +} + +function samePlainChoiceBody(left: string, right: string): boolean { + return stripChoicePrefix(left).replace(/\s+/g, "") === stripChoicePrefix(right).replace(/\s+/g, ""); +} + function parseExpectedSolutions(answer: string): string[] { return answer .split(/[,;]|또는|or/) @@ -156,9 +454,20 @@ function parseExpectedSolutions(answer: string): string[] { .filter((part) => part.length > 0); } +async function tryCanonicalizeAll( + mathEngine: MathEngineClient, + expressions: readonly string[], +): Promise { + try { + return await canonicalizeAll(mathEngine, expressions); + } catch (_err) { + return null; + } +} + async function canonicalizeAll( mathEngine: MathEngineClient, - expressions: string[], + expressions: readonly string[], ): Promise { const canonical = await Promise.all( expressions.map(async (expr) => { @@ -169,7 +478,7 @@ async function canonicalizeAll( return canonical.sort(); } -function sameSet(left: string[], right: string[]): boolean { +function sameSet(left: readonly string[], right: readonly string[]): boolean { if (left.length !== right.length) return false; return left.every((value, index) => value === right[index]); } diff --git a/packages/agent/src/tools/answer-equivalence.ts b/packages/agent/src/tools/answer-equivalence.ts index c25e12e..17af6e5 100644 --- a/packages/agent/src/tools/answer-equivalence.ts +++ b/packages/agent/src/tools/answer-equivalence.ts @@ -3,10 +3,123 @@ import type { MathEngineClient } from "./math-engine-client.js"; import { extractEquationText } from "./equation-extractor.js"; import { fractionFromRepeatingDecimalQuestion, sameFractionText } from "./repeating-decimal.js"; +export type AnswerEquivalenceStatus = "equivalent" | "not_equivalent" | "undecidable"; + +export interface AnswerEquivalenceDecision { + status: AnswerEquivalenceStatus; + reason?: string; + leftCanonical?: readonly string[]; + rightCanonical?: readonly string[]; +} + +export interface AnswerEquivalenceDebug { + skippedReasons: string[]; +} + +export interface ChoiceOption { + readonly label: string; + readonly body: string; + readonly index: number; +} + +export async function decideAnswerEquivalence( + mathEngine: MathEngineClient, + left: string, + right: string, +): Promise { + const debug: AnswerEquivalenceDebug = { skippedReasons: [] }; + const leftAnswers = parseAnswers(left); + const rightAnswers = parseAnswers(right); + if (leftAnswers.length === 0 || rightAnswers.length === 0) { + if (normalizeAnswerText(left) === normalizeAnswerText(right)) { + return { status: "equivalent" }; + } + return { status: "undecidable", reason: "answer contains no parseable symbolic parts" }; + } + if (sameNormalizedSet(leftAnswers, rightAnswers)) return { status: "equivalent" }; + + const canonical = await tryCanonicalizeBoth(mathEngine, leftAnswers, rightAnswers, debug); + if (canonical !== null) { + if (sameOrderedCanonicalSet(canonical.left, canonical.right)) { + return { status: "equivalent", leftCanonical: canonical.left, rightCanonical: canonical.right }; + } + if (leftAnswers.length === 1 && rightAnswers.length === 1) { + const verifyDecision = await tryVerifyPair(mathEngine, leftAnswers[0] ?? "", rightAnswers[0] ?? "", debug); + if (verifyDecision?.status === "equivalent") return verifyDecision; + } + return { + status: "not_equivalent", + leftCanonical: canonical.left, + rightCanonical: canonical.right, + }; + } + + if (leftAnswers.length === 1 && rightAnswers.length === 1) { + const verifyDecision = await tryVerifyPair(mathEngine, leftAnswers[0] ?? "", rightAnswers[0] ?? "", debug); + if (verifyDecision !== null) return verifyDecision; + } + + return { status: "undecidable", reason: withSkippedReasons("math-engine could not compare the answers", debug) }; +} + +export async function decideAnswerMatchesSolutions( + mathEngine: MathEngineClient, + answer: string, + solutions: readonly string[], +): Promise { + const debug: AnswerEquivalenceDebug = { skippedReasons: [] }; + if (solutions.length === 0) { + return { status: "undecidable", reason: "math-engine returned no symbolic solutions" }; + } + const answerParts = parseAnswers(answer); + if (answerParts.length === 0) { + return { status: "undecidable", reason: "declared answer contains no parseable symbolic parts" }; + } + if (sameNormalizedSet(answerParts, solutions)) return { status: "equivalent" }; + + const canonical = await tryCanonicalizeBoth(mathEngine, answerParts, solutions, debug); + if (canonical !== null) { + if (sameOrderedCanonicalSet(canonical.left, canonical.right)) { + return { status: "equivalent", leftCanonical: canonical.left, rightCanonical: canonical.right }; + } + if (answerParts.length === 1 && solutions.length === 1) { + const verifyDecision = await tryVerifyPair(mathEngine, answerParts[0] ?? "", solutions[0] ?? "", debug); + if (verifyDecision?.status === "equivalent") return verifyDecision; + } + return { + status: "not_equivalent", + leftCanonical: canonical.left, + rightCanonical: canonical.right, + }; + } + + if (answerParts.length === 1 && solutions.length === 1) { + const verifyDecision = await tryVerifyPair(mathEngine, answerParts[0] ?? "", solutions[0] ?? "", debug); + if (verifyDecision !== null) return verifyDecision; + } + + return { status: "undecidable", reason: withSkippedReasons("math-engine could not compare answer to solutions", debug) }; +} + +export function choiceOptionsFromExpectedChoices( + expectedChoices: readonly string[] | undefined, +): ChoiceOption[] { + if (expectedChoices === undefined) return []; + return expectedChoices + .map((choice, index) => { + const label = choiceLabelFromAnswer(choice) ?? circledChoiceLabel(index); + const labelIndex = choiceIndex(label); + const body = stripChoicePrefix(choice); + return { label, body: body.length > 0 ? body : choice.trim(), index: labelIndex ?? index }; + }) + .filter((choice) => choice.body.length > 0); +} + export async function sameAnswer( mathEngine: MathEngineClient, candidate: GeneratedProblem, derivedAnswer: string, + debug?: AnswerEquivalenceDebug, ): Promise { const expectedGraphAnswer = normalizeGraphAnswer(candidate.expected_answer); const derivedGraphAnswer = normalizeGraphAnswer(derivedAnswer); @@ -35,15 +148,15 @@ export async function sameAnswer( expectedAlternatives.length > expected.length || derivedAlternatives.length > derived.length ) { - if (await answerListsOverlap(mathEngine, expectedAlternatives, derivedAlternatives)) return true; + if (await answerListsOverlap(mathEngine, expectedAlternatives, derivedAlternatives, debug)) return true; } if (sameNormalizedSet(expected, derived)) return true; - if (await expectedMatchesEquationSolve(mathEngine, candidate, expected)) return true; + if (await expectedMatchesEquationSolve(mathEngine, candidate, expected, debug)) return true; if (expectedMatchesRepeatingDecimalQuestion(candidate, expected)) return true; - const expectedCanonical = await tryCanonicalizeAll(mathEngine, expected); - const derivedCanonical = await tryCanonicalizeAll(mathEngine, derived); + const expectedCanonical = await tryCanonicalizeAll(mathEngine, expected, debug); + const derivedCanonical = await tryCanonicalizeAll(mathEngine, derived, debug); if (expectedCanonical === null || derivedCanonical === null) return false; if (expectedCanonical.length !== derivedCanonical.length) return false; return expectedCanonical.every((value, index) => value === derivedCanonical[index]); @@ -66,6 +179,33 @@ function parseAnswers(answer: string): string[] { .filter((part) => part.length > 0 && !/^(?:예|네)$/u.test(part) && !/^[abAB]\s*=\s*\d+$/u.test(part) && !/해가\s*아(?:님|니다)/u.test(part)); } +async function tryCanonicalizeBoth( + mathEngine: MathEngineClient, + left: readonly string[], + right: readonly string[], + debug?: AnswerEquivalenceDebug, +): Promise<{ readonly left: string[]; readonly right: string[] } | null> { + const leftCanonical = await tryCanonicalizeAll(mathEngine, left, debug); + const rightCanonical = await tryCanonicalizeAll(mathEngine, right, debug); + if (leftCanonical === null || rightCanonical === null) return null; + return { left: leftCanonical, right: rightCanonical }; +} + +async function tryVerifyPair( + mathEngine: MathEngineClient, + left: string, + right: string, + debug?: AnswerEquivalenceDebug, +): Promise { + try { + const verified = await mathEngine.verify({ expr1: left, expr2: right }); + return { status: verified.equivalent ? "equivalent" : "not_equivalent" }; + } catch (err) { + recordSkippedReason(debug, `verify pair skipped: ${errorMessage(err)}`); + return null; + } +} + async function canonicalizeAll(mathEngine: MathEngineClient, answers: readonly string[]): Promise { const canonical = await Promise.all( answers.map(async (answer) => { @@ -76,14 +216,36 @@ async function canonicalizeAll(mathEngine: MathEngineClient, answers: readonly s return canonical.sort(); } -async function tryCanonicalizeAll(mathEngine: MathEngineClient, answers: readonly string[]): Promise { +async function tryCanonicalizeAll( + mathEngine: MathEngineClient, + answers: readonly string[], + debug?: AnswerEquivalenceDebug, +): Promise { try { return await canonicalizeAll(mathEngine, answers); - } catch { + } catch (err) { + recordSkippedReason(debug, `canonicalize skipped: ${errorMessage(err)}`); return null; } } +function recordSkippedReason(debug: AnswerEquivalenceDebug | undefined, reason: string): void { + debug?.skippedReasons.push(reason); +} + +function withSkippedReasons(reason: string, debug: AnswerEquivalenceDebug): string { + if (debug.skippedReasons.length === 0) return reason; + return `${reason}; skipped: ${uniqueMessages(debug.skippedReasons).join("; ")}`; +} + +function uniqueMessages(messages: readonly string[]): string[] { + return [...new Set(messages)]; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + function sameNormalizedSet(left: readonly string[], right: readonly string[]): boolean { const normalizedLeft = left.map((answer) => normalizeAnswerText(answer)).sort(); const normalizedRight = right.map((answer) => normalizeAnswerText(answer)).sort(); @@ -91,22 +253,28 @@ function sameNormalizedSet(left: readonly string[], right: readonly string[]): b return normalizedLeft.every((answer, index) => answer === normalizedRight[index]); } +function sameOrderedCanonicalSet(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) return false; + return left.every((answer, index) => answer === right[index]); +} + async function answerListsOverlap( mathEngine: MathEngineClient, expected: readonly string[], derived: readonly string[], + debug?: AnswerEquivalenceDebug, ): Promise { for (const expectedAnswer of expected) { for (const derivedAnswer of derived) { if (normalizeAnswerText(expectedAnswer) === normalizeAnswerText(derivedAnswer)) { return true; } - if (await sameCanonicalAnswer(mathEngine, expectedAnswer, derivedAnswer)) { + if (await sameCanonicalAnswer(mathEngine, expectedAnswer, derivedAnswer, debug)) { return true; } if ( - await sameLinearFactorRoot(mathEngine, expectedAnswer, derivedAnswer) || - await sameLinearFactorRoot(mathEngine, derivedAnswer, expectedAnswer) + await sameLinearFactorRoot(mathEngine, expectedAnswer, derivedAnswer, debug) || + await sameLinearFactorRoot(mathEngine, derivedAnswer, expectedAnswer, debug) ) { return true; } @@ -115,17 +283,28 @@ async function answerListsOverlap( return false; } -async function sameCanonicalAnswer(mathEngine: MathEngineClient, left: string, right: string): Promise { - const canonical = await tryCanonicalizeAll(mathEngine, [left, right]); +async function sameCanonicalAnswer( + mathEngine: MathEngineClient, + left: string, + right: string, + debug?: AnswerEquivalenceDebug, +): Promise { + const canonical = await tryCanonicalizeAll(mathEngine, [left, right], debug); if (canonical !== null && canonical[0] === canonical[1]) return true; try { return (await mathEngine.verify({ expr1: left, expr2: right })).equivalent; - } catch { + } catch (err) { + recordSkippedReason(debug, `canonical-answer verify skipped: ${errorMessage(err)}`); return false; } } -async function sameLinearFactorRoot(mathEngine: MathEngineClient, factorAnswer: string, rootAnswer: string): Promise { +async function sameLinearFactorRoot( + mathEngine: MathEngineClient, + factorAnswer: string, + rootAnswer: string, + debug?: AnswerEquivalenceDebug, +): Promise { if (!/[a-zA-Z]/u.test(factorAnswer)) return false; try { const solved = await mathEngine.solve({ equation: `${factorAnswer}=0` }); @@ -133,7 +312,8 @@ async function sameLinearFactorRoot(mathEngine: MathEngineClient, factorAnswer: const roots = await canonicalizeAll(mathEngine, solved.solutions); const answers = await canonicalizeAll(mathEngine, parseAnswers(rootAnswer)); return roots.some((root) => answers.includes(root)); - } catch { + } catch (err) { + recordSkippedReason(debug, `linear-factor comparison skipped: ${errorMessage(err)}`); return false; } } @@ -142,6 +322,7 @@ async function expectedMatchesEquationSolve( mathEngine: MathEngineClient, candidate: GeneratedProblem, expected: readonly string[], + debug?: AnswerEquivalenceDebug, ): Promise { if (candidate.generation_kind !== "equation") return false; const equation = extractEquationText(candidate.question_text); @@ -149,8 +330,9 @@ async function expectedMatchesEquationSolve( try { const solved = await mathEngine.solve({ equation }); if (solved.solutions.length === 0) return false; - return sameNormalizedSet(expected, solved.solutions) || await sameCanonicalSets(mathEngine, expected, solved.solutions); - } catch { + return sameNormalizedSet(expected, solved.solutions) || await sameCanonicalSets(mathEngine, expected, solved.solutions, debug); + } catch (err) { + recordSkippedReason(debug, `equation-solve comparison skipped: ${errorMessage(err)}`); return false; } } @@ -159,9 +341,10 @@ async function sameCanonicalSets( mathEngine: MathEngineClient, left: readonly string[], right: readonly string[], + debug?: AnswerEquivalenceDebug, ): Promise { - const leftCanonical = await tryCanonicalizeAll(mathEngine, left); - const rightCanonical = await tryCanonicalizeAll(mathEngine, right); + const leftCanonical = await tryCanonicalizeAll(mathEngine, left, debug); + const rightCanonical = await tryCanonicalizeAll(mathEngine, right, debug); if (leftCanonical === null || rightCanonical === null) return false; if (leftCanonical.length !== rightCanonical.length) return false; return leftCanonical.every((value, index) => value === rightCanonical[index]); @@ -204,13 +387,24 @@ function uniqueAnswers(answers: readonly string[]): string[] { return [...new Set(answers)]; } -function extractChoices(question: string): Array<{ readonly label: string; readonly body: string }> { +export function extractChoiceOptions(question: string): ChoiceOption[] { const labelPattern = "(? ({ - label: match[1] ?? "", - body: (match[2] ?? "").trim(), - })).filter((choice) => choiceIndex(choice.label) !== null && choice.body.length > 0); + return Array.from(question.matchAll(pattern)) + .map((match) => { + const label = match[1] ?? ""; + const index = choiceIndex(label); + return { + label, + body: (match[2] ?? "").trim(), + index: index ?? -1, + }; + }) + .filter((choice) => choice.index >= 0 && choice.body.length > 0); +} + +function extractChoices(question: string): ChoiceOption[] { + return extractChoiceOptions(question); } function choiceLabelsMatch(label: string, answer: string): boolean { @@ -225,11 +419,20 @@ function choiceLabelsMatch(label: string, answer: string): boolean { ); } -function stripChoicePrefix(answer: string): string { +export function stripChoicePrefix(answer: string): string { const labelPattern = /^(?:[①②③④⑤⑥⑦⑧⑨]|\([1-9]\)|[1-9][).]|[1-9]번)\s*/u; return answer.replace(labelPattern, "").trim(); } +export function choiceLabelFromAnswer(answer: string): string | null { + return answer.trim().match(/^(?:[①②③④⑤⑥⑦⑧⑨]|\([1-9]\)|[1-9][).]|[1-9]번)/u)?.[0] ?? null; +} + +export function choiceIndexFromAnswer(answer: string): number | null { + const label = choiceLabelFromAnswer(answer); + return label === null ? null : choiceIndex(label); +} + function choiceIndex(label: string): number | null { const circled = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨"]; const circledIndex = circled.indexOf(label); diff --git a/packages/agent/src/tools/equation-extractor.ts b/packages/agent/src/tools/equation-extractor.ts index 6b204d8..c1b18c0 100644 --- a/packages/agent/src/tools/equation-extractor.ts +++ b/packages/agent/src/tools/equation-extractor.ts @@ -1,13 +1,190 @@ -const EQUATION_PATTERN = /([0-9A-Za-z\\{}\s+\-*/^().]+=[0-9A-Za-z\\{}\s+\-*/^().]+)/u; +const EQUATION_PATTERN = /([0-9A-Za-z\s+\-*\/^().{}]+(?:<=|>=|!=|=|<|>)[0-9A-Za-z\s+\-*\/^().{}]+)/u; +const COMPARISON_PATTERN = /(?:<=|>=|!=|=|<|>)/u; + +const UNICODE_OPERATOR_REPLACEMENTS: readonly (readonly [string, string])[] = [ + ["≤", "<="], + ["≥", ">="], + ["≠", "!="], + ["÷", "/"], + ["×", "*"], + ["−", "-"], + ["·", "*"], +]; + +const SUPERSCRIPT_REPLACEMENTS: Readonly> = { + "⁰": "0", + "¹": "1", + "²": "2", + "³": "3", + "⁴": "4", + "⁵": "5", + "⁶": "6", + "⁷": "7", + "⁸": "8", + "⁹": "9", + "⁺": "+", + "⁻": "-", +}; export function extractEquationText(text: string): string | null { - const match = text.match(EQUATION_PATTERN); + const normalized = normalizeMathText(text); + const match = normalized.match(EQUATION_PATTERN); const equation = match?.[1]?.trim(); - if (equation === undefined || !equation.includes("=")) return null; - return equation + if (equation === undefined || !COMPARISON_PATTERN.test(equation)) return null; + + const cleaned = equation .replace(/^[\s.,:;!?]+/u, "") .replace(/\.\s*\(.+$/u, "") .replace(/\.\s*\($/u, "") .replace(/\.$/u, "") - .replace(/\s+/g, " "); + .replace(/\s+/g, " ") + .trim(); + + return COMPARISON_PATTERN.test(cleaned) ? cleaned : null; +} + +function normalizeMathText(text: string): string { + return normalizeUnicodeSuperscripts( + normalizeUnicodeOperators( + replaceLatexShorthands( + replaceBracedPowers(replaceLatexFractions(stripInlineLatexWrappers(text))), + ), + ), + ).replace(/\s+/g, " "); +} + +function stripInlineLatexWrappers(text: string): string { + return text.replace(/\\\(|\\\)|\\\[|\\\]/g, " ").replace(/\$/g, " "); +} + +function replaceLatexShorthands(text: string): string { + return text + .replace(/\\left|\\right/g, "") + .replace(/\\times(?![A-Za-z])/g, "*") + .replace(/\\div(?![A-Za-z])/g, "/") + .replace(/\\cdot(?![A-Za-z])/g, "*") + .replace(/\\leq?(?![A-Za-z])/g, "<=") + .replace(/\\geq?(?![A-Za-z])/g, ">=") + .replace(/\\ne(?![A-Za-z])/g, "!=") + .replace(/\\[,;!]/g, " "); +} + +function replaceLatexFractions(text: string): string { + const needle = "\\frac"; + let output = ""; + let index = 0; + + while (index < text.length) { + const start = text.indexOf(needle, index); + if (start === -1) { + output += text.slice(index); + break; + } + + output += text.slice(index, start); + const parts = readRequiredBraceParts(text, start + needle.length, 2); + if (parts === null) { + output += needle; + index = start + needle.length; + continue; + } + + const numerator = parts.values[0] ?? ""; + const denominator = parts.values[1] ?? ""; + output += `(${normalizeMathText(numerator)})/(${normalizeMathText(denominator)})`; + index = parts.endIndex + 1; + } + + return output; +} + +function replaceBracedPowers(text: string): string { + let output = ""; + let index = 0; + + while (index < text.length) { + const start = text.indexOf("^", index); + if (start === -1) { + output += text.slice(index); + break; + } + + output += text.slice(index, start); + const braceStart = skipSpaces(text, start + 1); + if (text[braceStart] !== "{") { + output += text.slice(start, braceStart); + index = braceStart; + continue; + } + + const endIndex = findMatchingBrace(text, braceStart); + if (endIndex === -1) { + output += text.slice(start); + break; + } + + output += `^(${normalizeMathText(text.slice(braceStart + 1, endIndex))})`; + index = endIndex + 1; + } + + return output; +} + +function normalizeUnicodeOperators(text: string): string { + let normalized = text; + for (const [source, replacement] of UNICODE_OPERATOR_REPLACEMENTS) { + normalized = normalized.split(source).join(replacement); + } + return normalized; +} + +function normalizeUnicodeSuperscripts(text: string): string { + return text.replace(/[⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻]+/gu, (match) => { + const exponent = Array.from(match) + .map((char) => SUPERSCRIPT_REPLACEMENTS[char] ?? "") + .join(""); + return exponent.length > 0 ? `^(${exponent})` : match; + }); +} + +function readRequiredBraceParts( + text: string, + startIndex: number, + partCount: number, +): { readonly values: readonly string[]; readonly endIndex: number } | null { + const values: string[] = []; + let index = skipSpaces(text, startIndex); + let endIndex = index; + + while (values.length < partCount) { + if (text[index] !== "{") return null; + endIndex = findMatchingBrace(text, index); + if (endIndex === -1) return null; + values.push(text.slice(index + 1, endIndex)); + index = skipSpaces(text, endIndex + 1); + } + + return { values, endIndex }; +} + +function skipSpaces(text: string, startIndex: number): number { + let index = startIndex; + while (text[index] === " ") { + index += 1; + } + return index; +} + +function findMatchingBrace(text: string, openIndex: number): number { + let depth = 0; + for (let index = openIndex; index < text.length; index += 1) { + const char = text[index]; + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + + return -1; } diff --git a/packages/agent/src/tools/latex-formatter.ts b/packages/agent/src/tools/latex-formatter.ts index 68a6394..dbcd912 100644 --- a/packages/agent/src/tools/latex-formatter.ts +++ b/packages/agent/src/tools/latex-formatter.ts @@ -4,6 +4,50 @@ export function formatLatex(value: string): string { return formatMultiplication(formatFractions(formatSqrt(formatPowers(value)))); } +export function toSympyExpr(value: string): string { + let expression = stripMathDelimiters(extractMathSegment(value)) + .replace(/\s*\([^)]*[가-힣%]+[^)]*\)\s*$/u, "") + .trim(); + expression = expression + .replace(/\\left|\\right/g, "") + .replace(/\\d?frac\{([^{}]+)\}\{([^{}]+)\}/g, "(($1)/($2))") + .replace(/\\sqrt\{([^{}]+)\}/g, "sqrt($1)") + .replace(/\\cdot/g, "*") + .replace(/\\div/g, " / ") + .replace(/\\leq?|≤/g, "<=") + .replace(/\\geq?|≥/g, ">=") + .replace(/\\pi/g, "pi") + .replace(/\\pm\s*([^\s,]+)/g, "-$1, $1") + .replace(/\^/g, "**"); + expression = extractEquationText(expression) + .replace(/\s*([=<>]=?)\s*/g, "$1") + .replace(/\s*,\s*/g, ", ") + .replace(/\s+/g, " ") + .trim(); + return expression; +} + +function extractMathSegment(value: string): string { + const dollar = value.match(/\$([^$]+)\$/u)?.[1]; + if (dollar !== undefined) return dollar; + const inline = value.match(/\\\(([\s\S]+?)\\\)/u)?.[1]; + if (inline !== undefined) return inline; + return value; +} + +function stripMathDelimiters(value: string): string { + return value + .trim() + .replace(/^\$([\s\S]+)\$$/u, "$1") + .replace(/^\\\(([\s\S]+)\\\)$/u, "$1") + .replace(/^\\\[([\s\S]+)\\\]$/u, "$1"); +} + +function extractEquationText(value: string): string { + const equation = value.match(/[A-Za-z]\s*=\s*[^.。]+/u)?.[0]; + return equation ?? value; +} + function formatPowers(value: string): string { return value.replace(/\*\*([A-Za-z0-9]+|\([^()]+\))/g, (_, exponent: string) => { const body = exponent.startsWith("(") && exponent.endsWith(")") diff --git a/packages/agent/src/workflows/verification-workflow.ts b/packages/agent/src/workflows/verification-workflow.ts index d53a80a..46155b9 100644 --- a/packages/agent/src/workflows/verification-workflow.ts +++ b/packages/agent/src/workflows/verification-workflow.ts @@ -9,11 +9,14 @@ import type { RefinerAgent, SolverAgent, } from "../agents/index.js"; +import type { DeterministicFallbackMode } from "../config/env.js"; import { createAcceptancePolicy } from "../policies/acceptance-policy.js"; import { createBoundedRetryPolicy } from "../policies/retry-policy.js"; import type { GateResult, GenerateRequest, + GeneratedProblem, + Intent, ProgressEvent, RagResult, StepName, @@ -26,6 +29,7 @@ import { extractIntent } from "../steps/intent-extraction.js"; import { independentResolve } from "../steps/independent-resolve.js"; import { mapObjective } from "../steps/objective-mapping.js"; import { generateProblem } from "../steps/problem-generation.js"; +import { deterministicInitialCandidate } from "../steps/problem-generation-deterministic.js"; import { ragSearch } from "../steps/rag-search.js"; import { verifyWithSympy } from "../steps/sympy-verification.js"; import type { MathEngineClient } from "../tools/math-engine-client.js"; @@ -50,6 +54,7 @@ export interface VerificationWorkflowDeps { export interface RunOptions { maxRetries?: number; perStepTimeoutMs?: number; + deterministicFallback?: DeterministicFallbackMode; } export type WorkflowYield = ProgressEvent; @@ -67,8 +72,11 @@ export async function* runVerificationWorkflow( const timestamp = () => new Date().toISOString(); const perStepTimeoutMs = options?.perStepTimeoutMs ?? 30_000; - const retryPolicy = createBoundedRetryPolicy({ maxAttempts: options?.maxRetries ?? 3 }); - const acceptance = createAcceptancePolicy(); + const deterministicFallback: DeterministicFallbackMode = + options?.deterministicFallback ?? "first"; + const maxAttempts = options?.maxRetries ?? 3; + const retryPolicy = createBoundedRetryPolicy({ maxAttempts }); + const acceptance = createAcceptancePolicy({ maxAttempts }); const verifications: Verification[] = []; yield step("rag", "start", timestamp()); @@ -108,6 +116,7 @@ export async function* runVerificationWorkflow( let attempt = 1; let refinementHint: string | undefined; + let counterexample: string | undefined; while (true) { yield step("generate", "start", timestamp()); const generation = await generateProblem( @@ -119,7 +128,16 @@ export async function* runVerificationWorkflow( perStepTimeoutMs, maxCriticRounds: 2, }, - { request, intent: intentStep.data, refs, strategy, attempt, refinementHint }, + { + request, + intent: intentStep.data, + refs, + strategy, + attempt, + refinementHint, + counterexample, + deterministicFallback, + }, ); const candidate = generation.data; yield step("generate", "done", timestamp(), generation.gate); @@ -164,15 +182,28 @@ export async function* runVerificationWorkflow( const retry = retryPolicy.decide(verification); if (!retry.shouldRetry) { + const finalResult = selectFinalResult({ + deterministicFallback, + request, + intent: intentStep.data, + refs, + attempt, + candidate, + verification, + }); + if (finalResult.verification !== verification) { + verifications[verifications.length - 1] = finalResult.verification; + } yield { type: "result", - candidates: [{ problem: candidate, verification }], + candidates: [{ problem: finalResult.problem, verification: finalResult.verification }], timestamp: timestamp(), }; return { verifications }; } refinementHint = retry.refinementHint; + counterexample = retry.counterexample; attempt = retry.nextAttempt; yield { type: "retry", @@ -183,6 +214,55 @@ export async function* runVerificationWorkflow( } } +function selectFinalResult(input: { + readonly deterministicFallback: DeterministicFallbackMode; + readonly request: GenerateRequest; + readonly intent: Intent; + readonly refs: readonly RagResult[]; + readonly attempt: number; + readonly candidate: GeneratedProblem; + readonly verification: Verification; +}): { problem: GeneratedProblem; verification: Verification } { + if (input.deterministicFallback !== "last-resort") { + return { problem: input.candidate, verification: input.verification }; + } + if (input.verification.overall !== "rejected") { + return { problem: input.candidate, verification: input.verification }; + } + + const fallback = deterministicInitialCandidate({ + request: input.request, + intent: input.intent, + refs: input.refs, + attempt: input.attempt, + }); + if (fallback === null) { + return { problem: input.candidate, verification: input.verification }; + } + + const problem = withDeterministicGeneratorMarker(fallback); + const verification = { ...input.verification, candidate_id: problem.candidate_id }; + assertVerificationInvariants(verification); + return { problem, verification }; +} + +function withDeterministicGeneratorMarker(candidate: GeneratedProblem): GeneratedProblem { + return { + ...candidate, + generation_metadata: { + ...candidate.generation_metadata, + refined_by: uniqueRefiners([ + ...(candidate.generation_metadata.refined_by ?? []), + "deterministic-topic-generator", + ]), + }, + }; +} + +function uniqueRefiners(refiners: readonly string[]): string[] { + return [...new Set(refiners)]; +} + function step( stepName: StepName, status: StepStatus, diff --git a/packages/agent/tests/backend-contract.test.ts b/packages/agent/tests/backend-contract.test.ts index 6e3d7e8..a887048 100644 --- a/packages/agent/tests/backend-contract.test.ts +++ b/packages/agent/tests/backend-contract.test.ts @@ -153,6 +153,7 @@ describe("SSE wire adapter", () => { generation_kind: "equation", question_text: "x**2 - 5*x + 6 = 0", expected_answer: "2, 3", + techniques_used: ["factorization"], proposed_solution_trace: "(x - 2)(x - 3) = 0", source_refs: ["seed-9수02-09-001"], inferred_intent: { @@ -347,6 +348,7 @@ describe("SymPy verification fail-closed behavior", () => { generation_kind: "equation", question_text: "x**2 - 5*x + 6 = 0", expected_answer: "2, 3", + techniques_used: ["factorization"], proposed_solution_trace: "internal trace", source_refs: ["seed-9수02-09-001"], inferred_intent: { @@ -400,7 +402,7 @@ describe("SymPy verification fail-closed behavior", () => { expect(result.gate.status).toBe("passed"); }); - it("routes expression candidates through expression verification", async () => { + it("marks expression candidates unverified instead of syntax-only passed", async () => { const expressionCandidate: GeneratedProblem = { ...candidate, candidate_id: "00000000-0000-0000-0000-000000000005", @@ -414,13 +416,13 @@ describe("SymPy verification fail-closed behavior", () => { { candidate: expressionCandidate }, ); - expect(result.gate.status).toBe("passed"); + expect(result.gate.status).toBe("unverified"); expect(result.gate.evidence).toMatchObject({ verification_kind: "expression", }); }); - it("routes choice-label equation candidates through non-solve verification", async () => { + it("marks choice-label equation candidates unverified when choices are not decidable", async () => { const choiceCandidate: GeneratedProblem = { ...candidate, question_text: @@ -439,15 +441,15 @@ describe("SymPy verification fail-closed behavior", () => { { candidate: choiceCandidate }, ); - expect(result.gate.status).toBe("passed"); + expect(result.gate.status).toBe("unverified"); }); - it("fails empty solution sets", async () => { + it("marks empty solution sets unverified because SymPy returned no decidable result", async () => { const result = await verifyWithSympy( { mathEngine: fakeMathEngine([]) }, { candidate }, ); - expect(result.gate.status).toBe("failed"); + expect(result.gate.status).toBe("unverified"); }); it("fails partial solution matches", async () => { diff --git a/packages/agent/tests/deterministic-fallback.test.ts b/packages/agent/tests/deterministic-fallback.test.ts new file mode 100644 index 0000000..5d6bc60 --- /dev/null +++ b/packages/agent/tests/deterministic-fallback.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + ConstraintCriticAgent, + GeneratorAgent, + RefinerAgent, +} from "../src/agents/index.js"; +import { EnvSchema } from "../src/config/env.js"; +import type { GenerateRequest, GeneratedProblem, Intent, RagResult } from "../src/schemas/index.js"; +import { generateProblem } from "../src/steps/problem-generation.js"; +import type { MathEngineClient } from "../src/tools/math-engine-client.js"; + +const intent: Intent = { + objective_code: "9수02-10", + objective_description: "이차방정식을 활용하여 문제를 해결한다.", + evaluation_dimensions: [{ id: "A", description: "활용", must_preserve: true }], + required_techniques: ["quadratic_equation"], + forbidden_techniques: [], + surface_constraints: { difficulty: "medium", problem_type: "objective" }, +}; + +const request: GenerateRequest = { + grade: 3, + topic: "9수02-10", + topic_name: "이차방정식의 활용", + mode: "structural", + school_level: "middle", + dims: ["A"], + count: 5, + difficulty: "medium", + problem_type: "objective", +}; + +const refs: RagResult[] = [ + { + item_id: "ref-1", + match_reason: "hybrid", + problem: { + item_id: "ref-1", + source_dataset: "111", + split: "train", + source_label_type: "problem_label", + school_level: "middle", + grade: 3, + semester: null, + topic_code: "9수02-10", + topic_name: "이차방정식의 활용", + achievement_standard: "이차방정식을 활용하여 문제를 해결한다.", + question_text: "이차방정식 활용 문제", + answer_text: "2", + explanation_text: null, + choice_blocks: [], + problem_type_norm: "objective", + difficulty_norm: "medium", + question_image_relpath: null, + answer_image_relpath: null, + question_json_relpath: null, + answer_json_relpath: null, + }, + }, +]; + +const llmCandidate: GeneratedProblem = { + candidate_id: "00000000-0000-0000-0000-000000000099", + mode: "structural", + generation_kind: "equation", + question_text: "LLM이 직접 생성한 문제. x^2 - 5x + 6 = 0", + expected_answer: "2, 3", + techniques_used: ["quadratic_equation"], + proposed_solution_trace: "LLM이 풀이를 작성한다.", + source_refs: ["ref-1"], + inferred_intent: intent, + generation_metadata: { + model: "llm-test-model", + temperature: 0, + prompt_id: "problem-generator", + prompt_version: "0.0.0", + attempt: 1, + generated_at: "2026-06-06T00:00:00.000Z", + }, +}; + +const critic: ConstraintCriticAgent = { + critique: async () => ({ passes: true, hints: [] }), +}; + +const refiner: RefinerAgent = { + refine: async () => llmCandidate, +}; + +const mathEngine: MathEngineClient = { + health: async () => ({ status: "ok", engine: "sympy" }), + solve: async () => { + throw new Error("math-engine rejected extracted equation"); + }, + verify: async () => ({ equivalent: true, diff: "0" }), + simplify: async ({ expr }) => ({ simplified: expr }), + differentiate: async () => ({ derivative: "" }), + limit: async () => ({ limit: "" }), +}; + +function makeGenerator(): { agent: GeneratorAgent; spy: ReturnType } { + const spy = vi.fn(async () => llmCandidate); + return { agent: { generate: spy }, spy }; +} + +describe("DETERMINISTIC_FALLBACK env parsing", () => { + it("defaults to 'first' when unset", () => { + const parsed = EnvSchema.parse({}); + expect(parsed.DETERMINISTIC_FALLBACK).toBe("first"); + }); + + it("accepts 'off'", () => { + const parsed = EnvSchema.parse({ DETERMINISTIC_FALLBACK: "off" }); + expect(parsed.DETERMINISTIC_FALLBACK).toBe("off"); + }); + + it("accepts 'last-resort'", () => { + const parsed = EnvSchema.parse({ DETERMINISTIC_FALLBACK: "last-resort" }); + expect(parsed.DETERMINISTIC_FALLBACK).toBe("last-resort"); + }); + + it("accepts 'first'", () => { + const parsed = EnvSchema.parse({ DETERMINISTIC_FALLBACK: "first" }); + expect(parsed.DETERMINISTIC_FALLBACK).toBe("first"); + }); + + it("rejects unknown values", () => { + expect(() => EnvSchema.parse({ DETERMINISTIC_FALLBACK: "always" })).toThrow(); + }); +}); + +describe("generateProblem deterministicFallback behavior", () => { + it("uses the deterministic template when mode='first' and refs exist", async () => { + const { agent: generator, spy } = makeGenerator(); + + const result = await generateProblem( + { generator, critic, refiner, mathEngine }, + { request, intent, refs, strategy: null, attempt: 1, deterministicFallback: "first" }, + ); + + expect(spy).not.toHaveBeenCalled(); + expect(result.refined_by).toContain("deterministic-topic-generator"); + expect(result.data.generation_metadata.model).toBe("deterministic-topic-generator"); + expect(result.gate.status).toBe("passed"); + }); + + it("preserves 'first' behavior when deterministicFallback is omitted (default)", async () => { + const { agent: generator, spy } = makeGenerator(); + + const result = await generateProblem( + { generator, critic, refiner, mathEngine }, + { request, intent, refs, strategy: null, attempt: 1 }, + ); + + expect(spy).not.toHaveBeenCalled(); + expect(result.data.generation_metadata.model).toBe("deterministic-topic-generator"); + }); + + it("skips the deterministic template when mode='off' (goes through LLM generator)", async () => { + const { agent: generator, spy } = makeGenerator(); + + const result = await generateProblem( + { generator, critic, refiner, mathEngine }, + { request, intent, refs, strategy: null, attempt: 1, deterministicFallback: "off" }, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result.refined_by).not.toContain("deterministic-topic-generator"); + expect(result.data.generation_metadata.model).toBe("llm-test-model"); + expect(result.data.candidate_id).toBe(llmCandidate.candidate_id); + }); + + it("skips the deterministic template up-front when mode='last-resort'", async () => { + const { agent: generator, spy } = makeGenerator(); + + const result = await generateProblem( + { generator, critic, refiner, mathEngine }, + { + request, + intent, + refs, + strategy: null, + attempt: 1, + deterministicFallback: "last-resort", + }, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result.refined_by).not.toContain("deterministic-topic-generator"); + expect(result.data.generation_metadata.model).toBe("llm-test-model"); + expect(result.data.candidate_id).toBe(llmCandidate.candidate_id); + }); +}); diff --git a/packages/agent/tests/env.test.ts b/packages/agent/tests/env.test.ts index 79ce3ff..37ccb14 100644 --- a/packages/agent/tests/env.test.ts +++ b/packages/agent/tests/env.test.ts @@ -13,6 +13,7 @@ const ENV_KEYS = [ "LLM_BASE_URL", "LLM_API_KEY", "LLM_MODEL", + "SOLVER_MODEL", ] as const; const savedEnv = new Map(); @@ -63,4 +64,24 @@ describe("loadEnv", () => { expect(env.CLIPROXY_API_KEY).toBe("test-key"); expect(env.CLIPROXY_MODEL).toBe("test-model"); }); + + it("leaves SOLVER_MODEL undefined when unset", () => { + process.chdir(tempRoot); + const env = loadEnv(); + expect(env.SOLVER_MODEL).toBeUndefined(); + }); + + it("parses SOLVER_MODEL when set via dotenv", () => { + const agentDir = join(tempRoot, "packages", "agent"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + join(agentDir, ".env"), + ["SOLVER_MODEL=claude-sonnet-4.5"].join("\n"), + ); + process.chdir(tempRoot); + + const env = loadEnv(); + + expect(env.SOLVER_MODEL).toBe("claude-sonnet-4.5"); + }); }); diff --git a/packages/agent/tests/equation-extractor.test.ts b/packages/agent/tests/equation-extractor.test.ts index 06d1afb..67dd66f 100644 --- a/packages/agent/tests/equation-extractor.test.ts +++ b/packages/agent/tests/equation-extractor.test.ts @@ -5,7 +5,7 @@ import { extractEquationText } from "../src/tools/equation-extractor.js"; describe("extractEquationText", () => { it("extracts a solvable equation from Korean problem prose", () => { expect(extractEquationText("다음 방정식을 풀어라. x^{2} - 5x + 6 = 0")).toBe( - "x^{2} - 5x + 6 = 0", + "x^(2) - 5x + 6 = 0", ); }); @@ -18,7 +18,40 @@ describe("extractEquationText", () => { it("drops trailing Korean instructions after the equation", () => { expect( extractEquationText("다음 이차방정식의 해를 구하시오. 4 x^{2} - 4 x - 3 = 0. (구한 해를 원래 식에 대입하여 확인할 것)"), - ).toBe("4 x^{2} - 4 x - 3 = 0"); + ).toBe("4 x^(2) - 4 x - 3 = 0"); + }); + + it.each([ + { label: "≤", text: "다음 부등식을 풀어라. x² − 3x ≤ 0", expected: "x^(2) - 3x <= 0" }, + { label: "≥", text: "다음 부등식을 풀어라. 2×x ≥ 6", expected: "2*x >= 6" }, + { label: "≠", text: "조건을 만족하시오. x ≠ 0", expected: "x != 0" }, + { label: "÷", text: "다음 식을 풀어라. x ÷ 2 = 3", expected: "x / 2 = 3" }, + { label: "·", text: "다음 식을 풀어라. 3·x = 9", expected: "3*x = 9" }, + ])("normalizes unicode math operator $label", ({ text, expected }) => { + expect(extractEquationText(text)).toBe(expected); + }); + + it.each([ + { label: "\\( ... \\)", text: "다음 방정식을 풀어라. \\( x + 1 = 2 \\)", expected: "x + 1 = 2" }, + { label: "$...$", text: "다음 방정식을 풀어라. $x + 1 = 2$", expected: "x + 1 = 2" }, + { label: "\\[ ... \\]", text: "다음 방정식을 풀어라. \\[ x + 1 = 2 \\]", expected: "x + 1 = 2" }, + ])("strips inline LaTeX wrapper $label", ({ text, expected }) => { + expect(extractEquationText(text)).toBe(expected); + }); + + it.each([ + { label: "\\frac", text: "다음 방정식을 풀어라. \\frac{x}{2}=3", expected: "(x)/(2)=3" }, + { label: "\\times", text: "다음 방정식을 풀어라. 2 \\times x = 6", expected: "2 * x = 6" }, + { label: "\\div", text: "다음 방정식을 풀어라. x \\div 2 = 3", expected: "x / 2 = 3" }, + { label: "\\cdot", text: "다음 방정식을 풀어라. 3\\cdot x = 9", expected: "3* x = 9" }, + { label: "\\le", text: "다음 부등식을 풀어라. x \\le 2", expected: "x <= 2" }, + { label: "\\leq", text: "다음 부등식을 풀어라. x \\leq 2", expected: "x <= 2" }, + { label: "\\ge", text: "다음 부등식을 풀어라. x \\ge 2", expected: "x >= 2" }, + { label: "\\geq", text: "다음 부등식을 풀어라. x \\geq 2", expected: "x >= 2" }, + { label: "\\ne", text: "조건을 만족하시오. x \\ne 0", expected: "x != 0" }, + { label: "^{...}", text: "다음 방정식을 풀어라. x^{3}=8", expected: "x^(3)=8" }, + ])("normalizes LaTeX form $label", ({ text, expected }) => { + expect(extractEquationText(text)).toBe(expected); }); it("returns null when a problem has no equation", () => { diff --git a/packages/agent/tests/generator-agent.test.ts b/packages/agent/tests/generator-agent.test.ts new file mode 100644 index 0000000..33f8b8a --- /dev/null +++ b/packages/agent/tests/generator-agent.test.ts @@ -0,0 +1,144 @@ +import type { LanguageModel } from "ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createGeneratorAgent, + temperatureForGeneratorAttempt, +} from "../src/agents/generator-agent.js"; +import type { GenerateRequest, Intent } from "../src/schemas/index.js"; +import type { LoadedPrompt, PromptLoader } from "../src/tools/prompt-loader.js"; + +const aiMock = vi.hoisted(() => ({ + generateObject: vi.fn(), + NoObjectGeneratedError: { + isInstance(error: unknown) { + return error instanceof Error && error.name === "NoObjectGeneratedError"; + }, + }, +})); + +vi.mock("ai", () => ({ + generateObject: aiMock.generateObject, + NoObjectGeneratedError: aiMock.NoObjectGeneratedError, +})); + +const request: GenerateRequest = { + grade: 3, + topic: "9수02-10", + topic_name: "이차방정식의 활용", + mode: "structural", + school_level: "middle", + dims: ["A"], + count: 1, + difficulty: "medium", + problem_type: "objective", + source_problem_text: "x**2 - 5*x + 6 = 0의 해를 구하시오.", +}; + +const intent: Intent = { + objective_code: "9수02-10", + objective_description: "이차방정식을 활용하여 문제를 해결한다.", + evaluation_dimensions: [{ id: "A", description: "이차방정식 활용", must_preserve: true }], + required_techniques: ["quadratic_equation"], + forbidden_techniques: [], + surface_constraints: { difficulty: "medium", problem_type: "objective" }, +}; + +const modelObject = { + question_text: "다음 방정식을 풀어라. x**2 - 7*x + 12 = 0", + expected_answer: "3, 4", + proposed_solution_trace: "인수분해하여 두 근을 구한다.", +}; + +beforeEach(() => { + aiMock.generateObject.mockReset(); +}); + +describe("temperatureForGeneratorAttempt", () => { + it("escalates and clamps the attempt temperature schedule", () => { + expect(temperatureForGeneratorAttempt(1)).toBe(0.35); + expect(temperatureForGeneratorAttempt(2)).toBe(0.6); + expect(temperatureForGeneratorAttempt(3)).toBe(0.85); + expect(temperatureForGeneratorAttempt(4)).toBe(0.85); + }); +}); + +describe("createGeneratorAgent", () => { + it("passes the scheduled temperature into generateObject", async () => { + aiMock.generateObject.mockResolvedValue({ object: modelObject }); + const render = vi.fn(() => "rendered prompt"); + const agent = createGeneratorAgent({ + model: {} as LanguageModel, + modelId: "test-model", + promptId: "problem-generator", + prompts: promptLoader(render), + }); + + const generated = await agent.generate({ + request, + intent, + refs: [], + strategy: null, + attempt: 2, + }); + + expect(generated.generation_metadata.temperature).toBe(0.6); + expect(generated.techniques_used).toEqual([]); + expect(aiMock.generateObject).toHaveBeenCalledWith( + expect.objectContaining({ temperature: 0.6 }), + ); + }); + + it("immediately retries a schema failure once with a schema hint", async () => { + const schemaError = new Error("question_text is required"); + schemaError.name = "NoObjectGeneratedError"; + aiMock.generateObject + .mockRejectedValueOnce(schemaError) + .mockResolvedValueOnce({ object: modelObject }); + const render = vi.fn((vars: Record) => JSON.stringify(vars)); + const agent = createGeneratorAgent({ + model: {} as LanguageModel, + modelId: "test-model", + promptId: "problem-generator", + prompts: promptLoader(render), + }); + + const generated = await agent.generate({ + request, + intent, + refs: [], + strategy: null, + attempt: 1, + }); + + expect(generated.question_text).toBe(modelObject.question_text); + expect(aiMock.generateObject).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(2); + expect(render.mock.calls[1]?.[0]).toMatchObject({ + schemaError: "question_text is required", + }); + expect(aiMock.generateObject.mock.calls[1]?.[0]).toMatchObject({ + prompt: expect.stringContaining("question_text is required"), + }); + }); +}); + +function promptLoader(render: LoadedPrompt["render"]): PromptLoader { + return { + load: async () => ({ + metadata: { + id: "problem-generator", + version: "0.1.1", + model: "test-model", + temperature: 0.35, + max_tokens: 2000, + schema: "GeneratedProblemSchema", + variables: ["request", "intent", "refs", "strategy", "refinementHint"], + owner: "비할당", + updated: "2026-06-10", + }, + rawBody: "", + render, + }), + }; +} diff --git a/packages/agent/tests/independent-resolve-choice-labels.test.ts b/packages/agent/tests/independent-resolve-choice-labels.test.ts index 5b283e7..944e021 100644 --- a/packages/agent/tests/independent-resolve-choice-labels.test.ts +++ b/packages/agent/tests/independent-resolve-choice-labels.test.ts @@ -11,6 +11,7 @@ const candidate: GeneratedProblem = { generation_kind: "expression", question_text: "다음 중 옳은 것을 고르시오.", expected_answer: "3번", + techniques_used: ["choice_selection"], proposed_solution_trace: "선택지를 비교한다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/independent-resolve-geometry-function-equivalence.test.ts b/packages/agent/tests/independent-resolve-geometry-function-equivalence.test.ts index 00990e3..c662c78 100644 --- a/packages/agent/tests/independent-resolve-geometry-function-equivalence.test.ts +++ b/packages/agent/tests/independent-resolve-geometry-function-equivalence.test.ts @@ -11,6 +11,7 @@ const candidate: GeneratedProblem = { generation_kind: "geometry", question_text: "값을 구하시오.", expected_answer: "13cm, 90도", + techniques_used: ["geometry"], proposed_solution_trace: "성질을 이용한다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/independent-resolve-labels.test.ts b/packages/agent/tests/independent-resolve-labels.test.ts index 4e568f2..e139852 100644 --- a/packages/agent/tests/independent-resolve-labels.test.ts +++ b/packages/agent/tests/independent-resolve-labels.test.ts @@ -11,6 +11,7 @@ const candidate: GeneratedProblem = { generation_kind: "expression", question_text: "계산 과정 ㈎, ㈏에 쓰인 법칙을 쓰시오.", expected_answer: "덧셈의 교환법칙, 덧셈의 결합법칙", + techniques_used: ["addition_laws"], proposed_solution_trace: "순서대로 교환법칙과 결합법칙이다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/independent-resolve-radicals-decimals.test.ts b/packages/agent/tests/independent-resolve-radicals-decimals.test.ts index a5fedbf..0aeab58 100644 --- a/packages/agent/tests/independent-resolve-radicals-decimals.test.ts +++ b/packages/agent/tests/independent-resolve-radicals-decimals.test.ts @@ -11,6 +11,7 @@ const candidate: GeneratedProblem = { generation_kind: "expression", question_text: "제곱근을 정리하시오.", expected_answer: "2 sqrt(7)", + techniques_used: ["radical_simplification"], proposed_solution_trace: "근호를 정리한다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/independent-resolve-text-equivalence.test.ts b/packages/agent/tests/independent-resolve-text-equivalence.test.ts index 84bc6d0..1edff00 100644 --- a/packages/agent/tests/independent-resolve-text-equivalence.test.ts +++ b/packages/agent/tests/independent-resolve-text-equivalence.test.ts @@ -11,6 +11,7 @@ const candidate: GeneratedProblem = { generation_kind: "geometry", question_text: "대응하는 선분을 쓰시오.", expected_answer: "선분 ST", + techniques_used: ["similarity"], proposed_solution_trace: "대응 관계를 확인한다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/independent-resolve.test.ts b/packages/agent/tests/independent-resolve.test.ts index 7910b44..425ca3b 100644 --- a/packages/agent/tests/independent-resolve.test.ts +++ b/packages/agent/tests/independent-resolve.test.ts @@ -12,6 +12,7 @@ const candidate: GeneratedProblem = { question_text: "다음 중 두 다항식 4 x^{2} - 4 x - 15, 6 x^{2} - 11 x - 10의 공통 인수는? ① 2 x+3 ② 2 x-5 ③ 3 x+2 ④ x-5 ⑤ x+3", expected_answer: "②", + techniques_used: ["factorization"], proposed_solution_trace: "두 다항식을 인수분해하면 공통 인수는 2x-5이다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/integration/generate.test.ts b/packages/agent/tests/integration/generate.test.ts new file mode 100644 index 0000000..f290850 --- /dev/null +++ b/packages/agent/tests/integration/generate.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from "vitest"; +import type { LanguageModel } from "ai"; + +import type { + ConstraintCriticAgent, + GeneratorAgent, + RefinerAgent, + SolverAgent, +} from "../../src/agents/index.js"; +import { createApp } from "../../src/server/app.js"; +import type { + GenerateRequest, + GeneratedProblem, + Intent, + RagResult, + SourceProblem, + Strategy, + WireSseEvent, +} from "../../src/schemas/index.js"; +import type { MathEngineClient, PromptLoader, RagClient, StrategyLoader } from "../../src/tools/index.js"; + +const intent: Intent = { + objective_code: "9수02-09", + objective_description: "이차방정식을 풀 수 있다", + evaluation_dimensions: [ + { id: "A", description: "수식 전개", must_preserve: true }, + { id: "B", description: "해 검증", must_preserve: true }, + ], + required_techniques: ["factorization"], + forbidden_techniques: [], + surface_constraints: { difficulty: "medium", problem_type: "objective" }, +}; + +vi.mock("ai", () => ({ + generateObject: vi.fn(async () => ({ object: intent })), +})); + +const sourceProblem: SourceProblem = { + item_id: "seed-1", + source_dataset: "110", + split: "train", + source_label_type: "text", + school_level: "middle", + grade: 3, + semester: 1, + topic_code: "9수02-09", + topic_name: "이차방정식", + achievement_standard: "9수02-09", + question_text: "x**2 - 5*x + 6 = 0", + answer_text: "2, 3", + explanation_text: "인수분해한다.", + choice_blocks: null, + problem_type_norm: "objective", + difficulty_norm: "medium", + question_image_relpath: null, + answer_image_relpath: null, + question_json_relpath: null, + answer_json_relpath: null, +}; + +const strategy: Strategy = { + code: "9수02-09", + title: "이차방정식", + difficulty_range: ["easy", "medium", "hard"], + problem_types_supported: ["objective", "short_answer"], + evaluation_dimensions: intent.evaluation_dimensions, + techniques: { required_at_least_one_of: ["factorization"], forbidden: [] }, + structural_transforms: [{ id: "s1", description: "계수 변형" }], + conceptual_transforms: [], +}; + +describe("POST /api/generate integration", () => { + it("streams six completed steps and a passing result", async () => { + const app = createTestApp({ answer: "2, 5", solverAnswer: "2, 5" }); + const events = await postGenerate(app.fetch); + + expect(events.filter((event) => event.event === "step").map((event) => event.data.status)).toEqual([ + "started", "completed", + "started", "completed", + "started", "completed", + "started", "completed", + "started", "completed", + "started", "completed", + ]); + const result = events.find((event) => event.event === "result"); + expect(result?.data[0]?.verification_status).toBe("pass"); + }); + + it("terminates early with rag error when no refs exist", async () => { + const app = createTestApp({ refs: [], answer: "2, 5", solverAnswer: "2, 5" }); + const events = await postGenerate(app.fetch); + const error = events.find((event) => event.event === "error"); + + expect(error?.data.stage).toBe("rag"); + expect(events.some((event) => event.event === "result")).toBe(false); + }); + + it("rejects a candidate whose techniques do not match the required techniques", async () => { + const app = createTestApp({ answer: "2, 5", solverAnswer: "2, 5", techniques: ["quadratic_formula"] }); + const events = await postGenerate(app.fetch); + const objectiveFailed = events.find( + (event) => event.event === "step" && event.data.index === 6 && event.data.status === "failed", + ); + const result = events.find((event) => event.event === "result"); + + expect(objectiveFailed).toBeDefined(); + expect(result?.data[0]?.verification_status).toBe("fail"); + }); + + it("stops cleanly when aborted mid-stream", async () => { + const app = createTestApp({ answer: "2, 5", solverAnswer: "2, 5" }); + const controller = new AbortController(); + const response = await app.fetch(generateRequest(), { signal: controller.signal }); + const reader = response.body?.getReader(); + expect(reader).toBeDefined(); + const first = await reader?.read(); + controller.abort(); + await reader?.cancel(); + expect(first?.done).toBe(false); + }); +}); + +function createTestApp(opts: { + refs?: RagResult[]; + answer: string; + solverAnswer: string; + techniques?: string[]; +}): ReturnType { + const refs = opts.refs ?? [{ item_id: "seed-1", similarity: 1, problem: sourceProblem, match_reason: "hybrid" }]; + const generated: GeneratedProblem = { + candidate_id: "00000000-0000-0000-0000-000000000001", + mode: "structural", + generation_kind: "equation", + question_text: "x**2 - 7*x + 10 = 0", + expected_answer: opts.answer, + techniques_used: opts.techniques ?? ["factorization"], + proposed_solution_trace: "인수분해한다.", + source_refs: ["seed-1"], + inferred_intent: intent, + generation_metadata: { + model: "fake", + temperature: 0, + prompt_id: "problem-generator", + prompt_version: "0.1.0", + attempt: 1, + generated_at: "2026-06-02T00:00:00.000Z", + }, + }; + + const generator: GeneratorAgent = { async generate() { return generated; } }; + const critic: ConstraintCriticAgent = { async critique() { return { passes: true, hints: [] }; } }; + const refiner: RefinerAgent = { async refine(input) { return input.prior; } }; + const solver: SolverAgent = { + async solve() { + return { derived_answer: opts.solverAnswer, trace: "독립 풀이", confidence: "high" }; + }, + }; + + return createApp({ + mathEngine: fakeMathEngine(), + workflow: { + rag: fakeRag(refs), + mathEngine: fakeMathEngine(), + prompts: fakePrompts(), + strategies: fakeStrategies(), + intentModel: fakeModel(), + generator, + critic, + refiner, + solver, + }, + // "off" so the generator mock runs instead of the default template short-circuit. + workflowOptions: { maxRetries: 1, perStepTimeoutMs: 1000, deterministicFallback: "off" }, + }); +} + +function generateRequest(): Request { + const body: GenerateRequest = { + grade: 3, + topic: "9수02-09", + mode: "structural", + school_level: "middle", + dims: ["수식 전개", "해 검증"], + count: 5, + difficulty: "medium", + problem_type: "objective", + }; + return new Request("http://localhost/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function postGenerate(fetch: typeof globalThis.fetch): Promise { + const response = await fetch(generateRequest()); + const text = await response.text(); + return parseSse(text); +} + +function parseSse(text: string): WireSseEvent[] { + return text + .trim() + .split(/\n\n/) + .filter(Boolean) + .map((chunk) => { + const eventLine = chunk.split("\n").find((line) => line.startsWith("event: ")); + const dataLine = chunk.split("\n").find((line) => line.startsWith("data: ")); + if (eventLine === undefined || dataLine === undefined) { + throw new Error(`Invalid SSE chunk: ${chunk}`); + } + const eventName = eventLine.slice("event: ".length); + const data = JSON.parse(dataLine.slice("data: ".length)) as unknown; + return { event: eventName, data } as WireSseEvent; + }); +} + +function fakeRag(refs: RagResult[]): RagClient { + return { async search() { return refs; } }; +} + +function fakePrompts(): PromptLoader { + return { + async load(id) { + return { + metadata: { + id, + version: "0.1.0", + model: "fake", + temperature: 0, + variables: [], + owner: "test", + updated: "2026-06-02", + }, + rawBody: "", + render() { return "{}"; }, + }; + }, + }; +} + +function fakeStrategies(): StrategyLoader { + return { async load() { return strategy; } }; +} + +function fakeMathEngine(): MathEngineClient { + return { + async health() { return { status: "ok", engine: "sympy" }; }, + async solve() { return { solutions: ["2", "5"] }; }, + async verify(req) { + const norm = (s: string) => s.replace(/\s+/g, ""); + return { equivalent: norm(req.expr1) === norm(req.expr2), diff: "0" }; + }, + async simplify(req) { return { simplified: req.expr.replace(/\s+/g, "") }; }, + async differentiate() { return { derivative: "0" }; }, + async limit() { return { limit: "0" }; }, + }; +} + +function fakeModel(): LanguageModel { + return { + specificationVersion: "v1", + provider: "fake", + modelId: "fake", + defaultObjectGenerationMode: "json", + async doGenerate() { + return { + text: JSON.stringify(intent), + finishReason: "stop", + usage: { promptTokens: 1, completionTokens: 1 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + }; + }, + async doStream() { + return { + stream: new ReadableStream(), + rawCall: { rawPrompt: null, rawSettings: {} }, + }; + }, + }; +} diff --git a/packages/agent/tests/integration/real-llm-e2e.test.ts b/packages/agent/tests/integration/real-llm-e2e.test.ts new file mode 100644 index 0000000..df60812 --- /dev/null +++ b/packages/agent/tests/integration/real-llm-e2e.test.ts @@ -0,0 +1,309 @@ +/** + * Real-LLM end-to-end smoke (plan task 4-4). + * + * Drives ONE demo unit through the live `runVerificationWorkflow` against the + * real LLM (per `.env`) and the real `math-engine` at `MATH_ENGINE_URL`. This + * is the safety net that proves the full 6-gate pipeline produces a terminal + * verdict for the capstone demo unit (`9수02-09` / 중3 / structural). + * + * Skipped by default — only runs when `LLM_E2E=1` is set. CI and the normal + * `pnpm -F @openmath/agent test:integration` run will mark it skipped to + * avoid burning LLM budget. The orchestrator runs it with `LLM_E2E=1` before + * the demo when `pnpm dev:all` is up. + * + * Set-up the test expects (when enabled): + * - `packages/agent/.env` has valid `LLM_*` (or `CLIPROXY_*` / `OPENAI_*`) + * - `math-engine` is reachable at `MATH_ENGINE_URL` (default localhost:16180) + * - The CLIProxyAPI / OpenAI provider can answer in <240s + */ + +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + createConstraintCriticAgent, + createGeneratorAgent, + createRefinerAgent, + createSolverAgent, +} from "../../src/agents/index.js"; +import { DEFAULT_MODELS, loadEnv } from "../../src/config/index.js"; +import type { + GenerateRequest, + ProgressEvent, + ResultEvent, +} from "../../src/schemas/index.js"; +import { + createFsPromptLoader, + createFsStrategyLoader, + createInMemoryRagClient, + createMathEngineClient, + resolveLanguageModel, +} from "../../src/tools/index.js"; +import { + runVerificationWorkflow, + type VerificationWorkflowDeps, +} from "../../src/workflows/verification-workflow.js"; + +const E2E_ENABLED = process.env.LLM_E2E === "1"; + +/** Per-test budget. The real LLM (CLIProxyAPI / GPT-5.5(xhigh)) is slow. */ +const E2E_TEST_TIMEOUT_MS = 240_000; + +/** Outer wall-clock guard. Bigger than the test timeout — if vitest's own + * timeout fires first that's fine; this exists so a stuck network/LLM + * call surfaces as a clear assertion error instead of a vitest hang. */ +const E2E_OVERALL_TIMEOUT_MS = 230_000; + +describe.skipIf(!E2E_ENABLED)("real LLM end-to-end (LLM_E2E=1)", () => { + it( + "drives one demo unit through runVerificationWorkflow and produces a terminal verdict", + async () => { + const { workflowDeps, request, runOptions } = await buildRealWorkflow(); + + const result = await drainWorkflowWithDeadline( + workflowDeps, + request, + runOptions, + E2E_OVERALL_TIMEOUT_MS, + ); + + const resultEvent = result.events.find( + (event): event is ResultEvent => event.type === "result", + ); + const errorEvents = result.events.filter( + (event) => event.type === "error", + ); + + if (resultEvent === undefined) { + throw new Error( + `runVerificationWorkflow ended without a result event. errors=${JSON.stringify( + errorEvents, + )}, events=${result.events.length}`, + ); + } + + expect(resultEvent.candidates.length).toBeGreaterThan(0); + const [candidate] = resultEvent.candidates; + if (candidate === undefined) { + throw new Error("result event had no candidate problem"); + } + + expect(candidate.problem.question_text.trim().length).toBeGreaterThan(0); + expect(candidate.problem.expected_answer.trim().length).toBeGreaterThan(0); + + const overall = candidate.verification.overall; + expect(["verified", "warning", "rejected"]).toContain(overall); + + if (overall === "verified") { + const sympy = candidate.verification.gates.find( + (gate) => gate.step === "sympy_verify", + ); + const objMap = candidate.verification.gates.find( + (gate) => gate.step === "objective_map", + ); + expect(sympy?.status).toBe("passed"); + expect(objMap?.status).toBe("passed"); + } + }, + E2E_TEST_TIMEOUT_MS, + ); +}); + +interface RealWorkflowBundle { + workflowDeps: VerificationWorkflowDeps; + request: GenerateRequest; + runOptions: NonNullable[2]>; +} + +/** Mirror `src/index.ts` wiring with one delta: force + * `DETERMINISTIC_FALLBACK=off` so the LLM generator path is exercised + * end-to-end (no template short-circuit). */ +async function buildRealWorkflow(): Promise { + const env = loadEnv(); + + const mathEngine = createMathEngineClient({ + baseUrl: env.MATH_ENGINE_URL, + timeoutMs: env.PER_STEP_TIMEOUT_MS, + retry: { attempts: 2, backoffMs: 100 }, + }); + + const prompts = createFsPromptLoader({ promptsDir: resolve(env.PROMPTS_DIR) }); + const strategies = createFsStrategyLoader({ + strategiesDir: resolve(env.STRATEGIES_DIR), + }); + + const rag = createInMemoryRagClient({ + jsonlPath: resolve( + env.CORPUS_JSONL ?? "./data/corpus/math-sample-unified-v1.jsonl", + ), + }); + await rag.warmup?.(); + + const llm = buildLanguageModel(env); + if (llm === undefined) { + throw new Error( + "LLM_E2E=1 requires a configured LLM provider. Populate packages/agent/.env (LLM_BASE_URL/LLM_API_KEY/LLM_MODEL or OPENAI_API_KEY).", + ); + } + const llmModelId = pickLlmModelId(env); + + const solverModelId = env.SOLVER_MODEL ?? llmModelId; + const solverLlm = + solverModelId === llmModelId + ? llm + : resolveLanguageModel({ + kind: pickLlmKind(env), + modelId: solverModelId, + baseUrl: pickBaseUrl(env), + apiKey: pickApiKey(env) ?? "openmath-local", + allowedHosts: ["localhost", "127.0.0.1"], + }); + + const generator = createGeneratorAgent({ + model: llm, + modelId: llmModelId, + promptId: "problem-generator", + prompts, + }); + const critic = createConstraintCriticAgent({ + model: llm, + modelId: llmModelId, + promptId: "constraint-critic", + prompts, + }); + const refiner = createRefinerAgent({ + model: llm, + modelId: llmModelId, + promptId: "refiner", + generator, + }); + const solver = createSolverAgent({ + model: solverLlm, + modelId: solverModelId, + promptId: "independent-solver", + prompts, + }); + + const workflowDeps: VerificationWorkflowDeps = { + rag, + mathEngine, + prompts, + strategies, + intentModel: llm, + generator, + critic, + refiner, + solver, + objectiveLlm: llm, + }; + + // Demo unit: 중3 / 이차방정식 / structural (per DEMO_SCOPE.md). + const request: GenerateRequest = { + mode: "structural", + school_level: "middle", + grade: 3, + topic: "9수02-09", + topic_code: "9수02-09", + dims: [], + count: 1, + difficulty: "medium", + problem_type: "objective", + }; + + return { + workflowDeps, + request, + runOptions: { + maxRetries: env.MAX_RETRIES, + perStepTimeoutMs: env.PER_STEP_TIMEOUT_MS, + // Force LLM path. Do NOT short-circuit to the deterministic template. + deterministicFallback: "off", + }, + }; +} + +function buildLanguageModel(env: ReturnType) { + const kind = pickLlmKind(env); + const baseUrl = pickBaseUrl(env); + const apiKey = pickApiKey(env); + const modelId = pickLlmModelId(env); + if (baseUrl === undefined && apiKey === undefined) { + return undefined; + } + return resolveLanguageModel({ + kind, + modelId, + baseUrl, + apiKey: apiKey ?? "openmath-local", + allowedHosts: ["localhost", "127.0.0.1"], + }); +} + +function pickLlmKind(env: ReturnType) { + return env.LLM_PROVIDER === "cliproxy" ? "openai-compatible" : env.LLM_PROVIDER; +} + +function pickBaseUrl(env: ReturnType) { + return env.LLM_BASE_URL ?? env.CLIPROXY_BASE_URL; +} + +function pickApiKey(env: ReturnType) { + return env.LLM_API_KEY ?? env.CLIPROXY_API_KEY ?? env.OPENAI_API_KEY; +} + +function pickLlmModelId(env: ReturnType) { + return ( + env.LLM_MODEL ?? + env.CLIPROXY_MODEL ?? + env.OPENAI_MODEL ?? + DEFAULT_MODELS.generator + ); +} + +interface DrainResult { + events: ProgressEvent[]; +} + +/** Drain the workflow generator with a hard wall-clock deadline. If the LLM + * or math-engine is unreachable the deadline turns a silent hang into a loud + * error pointing at the stuck step. */ +async function drainWorkflowWithDeadline( + deps: VerificationWorkflowDeps, + request: GenerateRequest, + options: NonNullable[2]>, + deadlineMs: number, +): Promise { + const events: ProgressEvent[] = []; + const generator = runVerificationWorkflow(deps, request, options); + + const deadline = new Promise((_, reject) => { + setTimeout( + () => + reject( + new Error( + `runVerificationWorkflow exceeded ${deadlineMs}ms (last event: ${ + describeLastEvent(events) ?? "none" + }). LLM or math-engine likely unreachable.`, + ), + ), + deadlineMs, + ).unref(); + }); + + while (true) { + const next = await Promise.race([generator.next(), deadline]); + if (next.done === true) { + break; + } + events.push(next.value); + } + return { events }; +} + +function describeLastEvent(events: readonly ProgressEvent[]): string | undefined { + const last = events.at(-1); + if (last === undefined) return undefined; + if (last.type === "step") return `step ${last.step}/${last.status}`; + return last.type; +} diff --git a/packages/agent/tests/intent-extraction-fallback.test.ts b/packages/agent/tests/intent-extraction-fallback.test.ts new file mode 100644 index 0000000..fb9ed34 --- /dev/null +++ b/packages/agent/tests/intent-extraction-fallback.test.ts @@ -0,0 +1,152 @@ +import { generateObject } from "ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PromptLoader } from "../src/tools/prompt-loader.js"; +import { resolveLanguageModel } from "../src/tools/llm-provider.js"; +import { + assertIntentInvariants, + type GenerateRequest, + type RagResult, + type Strategy, +} from "../src/schemas/index.js"; +import { extractIntent } from "../src/steps/intent-extraction.js"; + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateObject: vi.fn(), + }; +}); + +const generateObjectMock = vi.mocked(generateObject); + +const model = resolveLanguageModel({ + kind: "openai", + modelId: "test-model", + apiKey: "test-key", +}); + +const prompts: PromptLoader = { + load: async () => ({ + metadata: { + id: "intent-extraction", + version: "0.0.0", + model: "test-model", + temperature: 0, + variables: [], + owner: "test", + updated: "2026-06-10", + }, + rawBody: "Extract intent", + render: () => "Extract intent", + }), +}; + +const request: GenerateRequest = { + mode: "structural", + school_level: "middle", + grade: 3, + topic: "9수02-09", + topic_name: "이차방정식", + dims: ["인수분해", "판별식", "근과 계수"], + count: 1, + difficulty: "medium", + problem_type: "objective", +}; + +const refs: RagResult[] = [ + { + item_id: "ref-1", + match_reason: "hybrid", + problem: { + item_id: "ref-1", + source_dataset: "111", + split: "train", + source_label_type: "problem_label", + school_level: "middle", + grade: 3, + semester: null, + topic_code: "9수02-09", + topic_name: "이차방정식", + achievement_standard: null, + question_text: "x^2 - 5x + 6 = 0을 푸시오.", + answer_text: "2, 3", + explanation_text: null, + choice_blocks: [], + problem_type_norm: "objective", + difficulty_norm: "medium", + question_image_relpath: null, + answer_image_relpath: null, + question_json_relpath: null, + answer_json_relpath: null, + }, + }, +]; + +const strategy: Strategy = { + code: "9수02-09", + title: "이차방정식의 풀이", + school_level: "middle", + grade: 3, + techniques: { + required_at_least_one_of: ["factorization"], + forbidden: ["graphing"], + }, + evaluation_dimensions: [ + { id: "S1", description: "인수분해로 풀기", must_preserve: true }, + { id: "S2", description: "판별식 해석", must_preserve: false }, + { id: "S3", description: "근과 계수 관계", must_preserve: false }, + ], + difficulty_range: ["easy", "medium"], + problem_types_supported: ["objective", "short_answer"], + structural_transforms: [], + conceptual_transforms: [], +}; + +describe("extractIntent fallback", () => { + beforeEach(() => { + generateObjectMock.mockReset(); + generateObjectMock.mockRejectedValue(new Error("intent llm failed")); + }); + + it("inherits strategy dimensions verbatim when the LLM intent call fails", async () => { + const result = await extractIntent( + { model, prompts }, + { request, refs, strategy }, + ); + + expect(result.data.evaluation_dimensions).toBe(strategy.evaluation_dimensions); + expect(result.data.evaluation_dimensions).toEqual([ + { id: "S1", description: "인수분해로 풀기", must_preserve: true }, + { id: "S2", description: "판별식 해석", must_preserve: false }, + { id: "S3", description: "근과 계수 관계", must_preserve: false }, + ]); + expect(result.gate.status).toBe("passed"); + expect(result.gate.evidence).toMatchObject({ + fallback: true, + dimensions_source: "strategy", + }); + assertIntentInvariants(result.data); + }); + + it("guesses request dimensions with only the first dimension marked must_preserve", async () => { + const result = await extractIntent( + { model, prompts }, + { request, refs, strategy: null }, + ); + + expect(result.data.evaluation_dimensions).toEqual([ + { id: "A", description: "인수분해", must_preserve: true }, + { id: "B", description: "판별식", must_preserve: false }, + { id: "C", description: "근과 계수", must_preserve: false }, + ]); + expect(result.data.evaluation_dimensions.filter((dimension) => dimension.must_preserve)).toHaveLength(1); + expect(result.gate.status).toBe("passed"); + expect(result.gate.evidence).toMatchObject({ + fallback: true, + dimensions_source: "guessed", + }); + assertIntentInvariants(result.data); + }); +}); diff --git a/packages/agent/tests/invariants.test.ts b/packages/agent/tests/invariants.test.ts new file mode 100644 index 0000000..bb70e66 --- /dev/null +++ b/packages/agent/tests/invariants.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import type { GeneratedProblem, Intent, SourceProblem } from "../src/schemas/index.js"; +import { + assertGeneratedProblemInvariants, + assertSourceProblemInvariants, +} from "../src/schemas/index.js"; + +const sourceBase: SourceProblem = { + item_id: "seed-1", + source_dataset: "110", + split: "train", + source_label_type: "text", + school_level: "middle", + grade: 3, + semester: 1, + topic_code: "9수02-09", + topic_name: "이차방정식", + achievement_standard: "9수02-09", + question_text: "x + 1 = 2", + answer_text: "1", + explanation_text: "양변에서 1을 뺀다.", + choice_blocks: null, + problem_type_norm: "short_answer", + difficulty_norm: "easy", + question_image_relpath: null, + answer_image_relpath: null, + question_json_relpath: null, + answer_json_relpath: null, +}; + +const intentBase: Intent = { + objective_code: "9수02-09", + objective_description: "이차방정식을 풀 수 있다", + evaluation_dimensions: [ + { id: "A", description: "인수분해", must_preserve: true }, + { id: "B", description: "해 검증", must_preserve: false }, + ], + required_techniques: ["factorization"], + forbidden_techniques: [], + surface_constraints: { difficulty: "medium", problem_type: "short_answer" }, +}; + +const generatedBase: GeneratedProblem = { + candidate_id: "00000000-0000-0000-0000-000000000001", + mode: "structural", + generation_kind: "equation", + question_text: "x^{2} - 7x + 10 = 0", + expected_answer: "2, 5", + techniques_used: ["factorization"], + proposed_solution_trace: "인수분해한다.", + source_refs: ["seed-1"], + inferred_intent: intentBase, + generation_metadata: { + model: "test", + temperature: 0, + prompt_id: "problem-generator", + prompt_version: "0.1.0", + attempt: 1, + generated_at: "2026-06-02T00:00:00.000Z", + }, +}; + +describe("assertSourceProblemInvariants", () => { + it("passes a valid source problem", () => { + expect(() => assertSourceProblemInvariants(sourceBase)).not.toThrow(); + }); + + it("fails I-S1", () => { + expect(() => assertSourceProblemInvariants({ ...sourceBase, question_text: "" })).toThrow(/I-S1/); + }); + + it("fails I-S2", () => { + expect(() => assertSourceProblemInvariants({ ...sourceBase, achievement_standard: null })).toThrow(/I-S2/); + }); + + it("fails I-S3", () => { + expect(() => assertSourceProblemInvariants({ ...sourceBase, question_text: "\\dfrac{1}{2}" })).toThrow(/I-S3/); + }); + + it("fails I-S4", () => { + expect(() => assertSourceProblemInvariants({ ...sourceBase, explanation_text: null })).toThrow(/I-S4/); + }); + + it("fails I-S5", () => { + expect(() => assertSourceProblemInvariants({ ...sourceBase, grade: null })).toThrow(/I-S5/); + }); +}); + +describe("assertGeneratedProblemInvariants", () => { + it("passes a valid generated problem", () => { + expect(() => assertGeneratedProblemInvariants(generatedBase, intentBase)).not.toThrow(); + }); + + it("fails I-G1", () => { + expect(() => assertGeneratedProblemInvariants({ + ...generatedBase, + inferred_intent: { ...intentBase, objective_code: "9수02-10" }, + }, intentBase)).toThrow(/I-G1/); + }); + + it("fails I-G2", () => { + expect(() => assertGeneratedProblemInvariants({ + ...generatedBase, + inferred_intent: { ...intentBase, required_techniques: ["formula"] }, + }, intentBase)).toThrow(/I-G2/); + }); + + it("fails I-G3", () => { + expect(() => assertGeneratedProblemInvariants({ + ...generatedBase, + mode: "conceptual", + inferred_intent: { + ...intentBase, + evaluation_dimensions: [{ id: "A", description: "완전제곱식", must_preserve: true }], + }, + }, intentBase)).toThrow(/I-G3/); + }); +}); diff --git a/packages/agent/tests/latex-formatter.test.ts b/packages/agent/tests/latex-formatter.test.ts new file mode 100644 index 0000000..5e9b6da --- /dev/null +++ b/packages/agent/tests/latex-formatter.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { formatLatex, toSympyExpr } from "../src/tools/index.js"; + +describe("formatLatex", () => { + it("formats powers", () => { + expect(formatLatex("x**2 - 5 = 0")).toBe("x^{2} - 5 = 0"); + }); + + it("formats multiplication", () => { + expect(formatLatex("2*x + 3*y = 0")).toBe("2 x + 3 y = 0"); + }); + + it("formats square roots", () => { + expect(formatLatex("sqrt(7), -sqrt(x + 1)")).toBe("\\sqrt{7}, -\\sqrt{x + 1}"); + }); + + it("formats fractions", () => { + expect(formatLatex("frac(x + 1, 2)")).toBe("\\frac{x + 1}{2}"); + }); + + it("formats mixed expressions", () => { + expect(formatLatex("2*x**2 + sqrt(frac(1, 4)) = 3")).toBe( + "2 x^{2} + \\sqrt{\\frac{1}{4}} = 3", + ); + }); + + it("preserves identity strings", () => { + expect(formatLatex("x + 1 = 2")).toBe("x + 1 = 2"); + }); +}); + +describe("toSympyExpr", () => { + it("strips inline math delimiters", () => { + expect(toSympyExpr("$x=2$")).toBe("x=2"); + expect(toSympyExpr("\\(x+1=3\\)")).toBe("x+1=3"); + }); + + it("converts LaTeX fractions while preserving equations", () => { + expect(toSympyExpr("\\frac{1}{5}(2x+1)=-x+3")).toBe("((1)/(5))(2x+1)=-x+3"); + }); + + it("extracts displayed equations from prose", () => { + expect(toSympyExpr("방정식 $\\frac{1}{5}(2x+1)=-x+3$ 를 푸시오.")).toBe( + "((1)/(5))(2x+1)=-x+3", + ); + }); + + it("converts square roots", () => { + expect(toSympyExpr("\\sqrt{2}")).toBe("sqrt(2)"); + }); + + it("converts common operators and constants", () => { + expect(toSympyExpr("\\left(x^2 \\cdot \\pi\\right)")).toBe("(x**2 * pi)"); + }); + + it("preserves bare numbers and extracts answer equations from prose", () => { + expect(toSympyExpr("2")).toBe("2"); + expect(toSympyExpr("x=2")).toBe("x=2"); + expect(toSympyExpr("해는 x=2")).toBe("x=2"); + expect(toSympyExpr("x = 2 (개)")).toBe("x=2"); + }); + + it("handles multiple roots and plus-minus notation", () => { + expect(toSympyExpr("x = -2, 2")).toBe("x=-2, 2"); + expect(toSympyExpr("x=\\pm 2")).toBe("x=-2, 2"); + }); + + it("converts display delimiters, dfrac, inequalities, and division", () => { + expect(toSympyExpr("$\\dfrac{x}{2} \\le 3$")).toBe("((x)/(2))<=3"); + expect(toSympyExpr("6 \\div 3 \\ge 2")).toBe("6 / 3>=2"); + }); +}); diff --git a/packages/agent/tests/objective-mapping-function.test.ts b/packages/agent/tests/objective-mapping-function.test.ts index 2b7376e..9675d84 100644 --- a/packages/agent/tests/objective-mapping-function.test.ts +++ b/packages/agent/tests/objective-mapping-function.test.ts @@ -60,6 +60,7 @@ const candidate: GeneratedProblem = { generation_kind: "function", question_text: "좌표평면에서 직선 l은 점 (0, -2)를 지나고 점 (3, 4)를 지난다. 다음 중 직선 l의 식으로 알맞은 것은?", expected_answer: "③", + techniques_used: ["linear_function"], proposed_solution_trace: "두 좌표에서 기울기와 y절편을 구해 함수식 y = 2x - 2를 찾는다.", source_refs: ["ref-1"], inferred_intent: intent, diff --git a/packages/agent/tests/objective-mapping.test.ts b/packages/agent/tests/objective-mapping.test.ts index cc462bd..b2c05d2 100644 --- a/packages/agent/tests/objective-mapping.test.ts +++ b/packages/agent/tests/objective-mapping.test.ts @@ -1,3 +1,4 @@ +import type { LanguageModel } from "ai"; import { describe, expect, it } from "vitest"; import type { @@ -67,6 +68,7 @@ const candidate: GeneratedProblem = { generation_kind: "geometry", question_text: "직각삼각형 ABC에서 cos A = 4/5일 때 sin A의 값을 구하시오.", expected_answer: "3/5", + techniques_used: ["trigonometric_ratio"], proposed_solution_trace: "sin**2 A + cos**2 A = 1을 이용한다.", source_refs: ["ref-1"], inferred_intent: intent, @@ -90,6 +92,52 @@ describe("mapObjective", () => { expect(result.gate.status).toBe("passed"); }); + it("fails structural candidates missing a required technique", async () => { + const result = await mapObjective( + {}, + { request, refs, candidate: { ...candidate, techniques_used: [] }, intent, strategy: null }, + ); + + expect(result.gate.status).toBe("failed"); + expect(result.gate.failure_detail?.code).toBe("technique_mismatch"); + }); + + it("passes structural candidates covering required techniques after normalization", async () => { + const result = await mapObjective( + {}, + { + request, + refs, + candidate: { ...candidate, techniques_used: [" Trigonometric Ratio "] }, + intent, + strategy: null, + }, + ); + + expect(result.gate.status).toBe("passed"); + expect(result.gate.evidence).toMatchObject({ + techniques_used: ["trigonometric_ratio"], + missing_required_techniques: [], + }); + }); + + it("records why LLM nuance was skipped", async () => { + const result = await mapObjective( + { + llm: {} as LanguageModel, + prompts: { + load: async () => { + throw new Error("prompt unavailable"); + }, + }, + }, + { request, refs, candidate, intent, strategy: null }, + ); + + expect(result.gate.status).toBe("passed"); + expect(result.gate.evidence).toMatchObject({ nuance_skipped_reason: "prompt unavailable" }); + }); + it("fails when candidate generation kind does not match the requested topic", async () => { const result = await mapObjective( {}, diff --git a/packages/agent/tests/placeholder.test.ts b/packages/agent/tests/placeholder.test.ts index 15b35dd..56ebebc 100644 --- a/packages/agent/tests/placeholder.test.ts +++ b/packages/agent/tests/placeholder.test.ts @@ -63,6 +63,51 @@ describe("schemas/verification invariants", () => { ).toThrow(/I-V[23]/); }); + it("accepts unverified gates for warning but never for verified", () => { + const warningGates = [...baseGates]; + warningGates[3] = { ...warningGates[3], status: "unverified" }; + + const parsed = VerificationSchema.safeParse({ + candidate_id: "00000000-0000-0000-0000-000000000000", + overall: "warning", + gates: warningGates, + attempt_count: 1, + }); + expect(parsed.success).toBe(true); + expect(() => + assertVerificationInvariants({ + candidate_id: "00000000-0000-0000-0000-000000000000", + overall: "warning", + gates: warningGates, + attempt_count: 1, + }), + ).not.toThrow(); + + expect(() => + assertVerificationInvariants({ + candidate_id: "00000000-0000-0000-0000-000000000000", + overall: "verified", + gates: warningGates, + attempt_count: 1, + }), + ).toThrow(/I-V2/); + }); + + it("rejects warning if a deterministic non-re_solve gate failed", () => { + const gates = [...baseGates]; + gates[3] = { ...gates[3], status: "unverified" }; + gates[5] = { ...gates[5], status: "failed" }; + + expect(() => + assertVerificationInvariants({ + candidate_id: "00000000-0000-0000-0000-000000000000", + overall: "warning", + gates, + attempt_count: 1, + }), + ).toThrow(/I-V4/); + }); + it("I-V5: attempt_count > 3 must be rejected", () => { expect(() => assertVerificationInvariants({ diff --git a/packages/agent/tests/policies.test.ts b/packages/agent/tests/policies.test.ts index 506aa2b..e330b02 100644 --- a/packages/agent/tests/policies.test.ts +++ b/packages/agent/tests/policies.test.ts @@ -30,12 +30,22 @@ describe("acceptance policy", () => { expect(policy.decide(gates, 1)).toBe("rejected"); }); - it("returns warning for independent re-solve mismatch after SymPy pass", () => { + it("warns for re-solve mismatch while attempts remain and rejects on the final attempt", () => { const policy = createAcceptancePolicy(); const gates = passedGates.map((gate) => gate.step === "re_solve" ? { ...gate, status: "failed" as const } : gate, ); expect(policy.decide(gates, 1)).toBe("warning"); + expect(policy.decide(gates, 3)).toBe("rejected"); + }); + + it("maps unverified SymPy to warning instead of verified", () => { + const policy = createAcceptancePolicy(); + const gates = passedGates.map((gate) => + gate.step === "sympy_verify" ? { ...gate, status: "unverified" as const } : gate, + ); + + expect(policy.decide(gates, 1)).toBe("warning"); }); }); @@ -64,6 +74,83 @@ describe("retry policy", () => { }; expect(policy.decide(verification).shouldRetry).toBe(false); }); + + it("retries re-solve mismatch while attempts remain", () => { + const policy = createBoundedRetryPolicy({ maxAttempts: 3 }); + const verification: Verification = { + candidate_id: "00000000-0000-0000-0000-000000000006", + overall: "warning", + gates: passedGates.map((gate) => + gate.step === "re_solve" ? { ...gate, status: "failed" as const } : gate, + ), + attempt_count: 2, + }; + + expect(policy.decide(verification)).toMatchObject({ + shouldRetry: true, + nextAttempt: 3, + }); + }); + + it("does not retry unverified-only warnings", () => { + const policy = createBoundedRetryPolicy({ maxAttempts: 3 }); + const verification: Verification = { + candidate_id: "00000000-0000-0000-0000-000000000007", + overall: "warning", + gates: passedGates.map((gate) => + gate.step === "sympy_verify" ? { ...gate, status: "unverified" as const } : gate, + ), + attempt_count: 1, + }; + + expect(policy.decide(verification).shouldRetry).toBe(false); + }); + + it("emits a SymPy-specific hint and previous-candidate counterexample", () => { + const policy = createBoundedRetryPolicy({ maxAttempts: 3 }); + const gates: GateResult[] = passedGates.map((gate) => { + if (gate.step === "generate") { + return { + ...gate, + evidence: { + question_text: "다음 방정식을 풀어라. x**2 - 5*x + 6 = 0", + expected_answer: "1, 6", + }, + }; + } + if (gate.step === "sympy_verify") { + return { + ...gate, + status: "failed" as const, + evidence: { + engine: "sympy", + expected_answer: "1, 6", + sympy_answer: "2, 3", + }, + failure_detail: { + code: "sympy_solution_mismatch", + message: "SymPy solution did not match the expected answer", + }, + }; + } + return gate; + }); + const verification: Verification = { + candidate_id: "00000000-0000-0000-0000-000000000004", + overall: "rejected", + gates, + attempt_count: 1, + }; + + const decision = policy.decide(verification); + + expect(decision.refinementHint).toBe( + "SymPy 검산 불일치: 기대답 1, 6, 엔진 결과 2, 3 — 계수를 다시 검산하라", + ); + expect(decision.counterexample).toContain("이전 실패 후보(반복 금지)"); + expect(decision.counterexample).toContain("x**2 - 5*x + 6 = 0"); + expect(decision.counterexample).toContain("정답: 1, 6"); + }); }); describe("timeout policy", () => { diff --git a/packages/agent/tests/problem-generation-geometry-statistics-guards.test.ts b/packages/agent/tests/problem-generation-geometry-statistics-guards.test.ts index 10e5765..8bacf3e 100644 --- a/packages/agent/tests/problem-generation-geometry-statistics-guards.test.ts +++ b/packages/agent/tests/problem-generation-geometry-statistics-guards.test.ts @@ -11,6 +11,7 @@ const candidate: GeneratedProblem = { generation_kind: "geometry", question_text: "삼각형이 닮음일 때 값을 구하시오.", expected_answer: "1", + techniques_used: ["similarity"], proposed_solution_trace: "대응 관계를 확인한다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/problem-generation-topic-guards.test.ts b/packages/agent/tests/problem-generation-topic-guards.test.ts index 2084b5b..c644377 100644 --- a/packages/agent/tests/problem-generation-topic-guards.test.ts +++ b/packages/agent/tests/problem-generation-topic-guards.test.ts @@ -15,6 +15,7 @@ const candidate: GeneratedProblem = { generation_kind: "expression", question_text: "다음 식을 계산하시오.", expected_answer: "1", + techniques_used: ["calculation"], proposed_solution_trace: "식을 정리한다.", source_refs: ["ref-1"], inferred_intent: { diff --git a/packages/agent/tests/problem-generation.test.ts b/packages/agent/tests/problem-generation.test.ts index 185b1a8..185593b 100644 --- a/packages/agent/tests/problem-generation.test.ts +++ b/packages/agent/tests/problem-generation.test.ts @@ -13,8 +13,9 @@ const candidate: GeneratedProblem = { candidate_id: "00000000-0000-0000-0000-000000000010", mode: "structural", generation_kind: "equation", - question_text: "다음 방정식을 풀어라. (x - 5)(x + 2) = 0", + question_text: "다음 방정식을 풀어라. x**2 - 3*x - 10 = 0", expected_answer: "5, -2", + techniques_used: ["quadratic_equation"], proposed_solution_trace: "인수분해된 식에서 해를 구한다.", source_refs: ["ref-1"], inferred_intent: { @@ -83,6 +84,9 @@ describe("generateProblem", () => { expect(result.gate.status).toBe("passed"); expect(result.data.expected_answer).toBe("5, -2"); + expect(result.gate.evidence).toMatchObject({ + normalization_skipped_reasons: ["answer normalization skipped: math-engine rejected extracted equation"], + }); }); it("does not normalize answers for choice-style equation candidates", async () => { diff --git a/packages/agent/tests/sympy-verification.test.ts b/packages/agent/tests/sympy-verification.test.ts index 43c7bfc..b18d980 100644 --- a/packages/agent/tests/sympy-verification.test.ts +++ b/packages/agent/tests/sympy-verification.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { GeneratedProblem } from "../src/schemas/index.js"; +import type { GateResult, GeneratedProblem } from "../src/schemas/index.js"; +import { createAcceptancePolicy } from "../src/policies/index.js"; import { verifyWithSympy } from "../src/steps/sympy-verification.js"; import type { MathEngineClient } from "../src/tools/math-engine-client.js"; @@ -10,6 +11,7 @@ const expressionCandidate: GeneratedProblem = { generation_kind: "expression", question_text: "a, b를 사용한 식과 대입한 값을 구하여라.", expected_answer: "4 a + 4 b + 270; 526 kcal", + techniques_used: ["substitution"], proposed_solution_trace: "식을 세우고 값을 대입한다.", source_refs: ["ref-1"], inferred_intent: { @@ -31,7 +33,7 @@ const expressionCandidate: GeneratedProblem = { }; describe("verifyWithSympy", () => { - it("passes expression answers with multiple parts and units", async () => { + it("marks expression answers with units as unverified instead of syntax-only passed", async () => { const mathEngine: MathEngineClient = { health: async () => ({ status: "ok", engine: "sympy" }), solve: async () => ({ solutions: [] }), @@ -51,10 +53,13 @@ describe("verifyWithSympy", () => { { candidate: expressionCandidate }, ); - expect(result.gate.status).toBe("passed"); + expect(result.gate.status).toBe("unverified"); + expect(result.gate.evidence).toMatchObject({ + reason: expect.stringContaining("requires a checkable equation"), + }); }); - it("skips textual objective answer bodies for expression candidates", async () => { + it("marks textual objective answer bodies as unverified instead of non-empty passed", async () => { const mathEngine: MathEngineClient = { health: async () => ({ status: "ok", engine: "sympy" }), solve: async () => ({ solutions: [] }), @@ -77,6 +82,137 @@ describe("verifyWithSympy", () => { }, ); - expect(result.gate.status).toBe("passed"); + expect(result.gate.status).toBe("unverified"); + }); + + it("fails a wrong expected equation answer so acceptance rejects the final verdict", async () => { + const mathEngine = createEquationMathEngine({ solutions: ["2", "3"] }); + + const result = await verifyWithSympy( + { mathEngine }, + { + candidate: { + ...expressionCandidate, + generation_kind: "equation", + question_text: "다음 방정식을 풀어라. x**2 - 5*x + 6 = 0", + expected_answer: "1, 6", + }, + }, + ); + const gates = passedGates().map((gate) => + gate.step === "sympy_verify" ? result.gate : gate, + ); + + expect(result.gate.status).toBe("failed"); + expect(createAcceptancePolicy().decide(gates, 3)).toBe("rejected"); + }); + + it("marks equation candidates with no extractable equation as unverified instead of failed", async () => { + const mathEngine: MathEngineClient = { + health: async () => ({ status: "ok", engine: "sympy" }), + solve: async () => { + throw new Error("solve should not be called when extraction fails"); + }, + verify: async () => ({ equivalent: true, diff: "0" }), + simplify: async ({ expr }) => ({ simplified: expr.replace(/\s+/g, "") }), + differentiate: async () => ({ derivative: "" }), + limit: async () => ({ limit: "" }), + }; + + const result = await verifyWithSympy( + { mathEngine }, + { + candidate: { + ...expressionCandidate, + generation_kind: "equation", + question_text: "다음 문장제의 조건을 읽고 알맞은 값을 구하시오.", + expected_answer: "3", + }, + }, + ); + + expect(result.gate.status).toBe("unverified"); + expect(result.gate.failure_detail?.code).toBe("sympy_unverified"); + expect(result.gate.evidence).toMatchObject({ + extraction: "no_extractable_equation", + reason: expect.stringContaining("extractable equation"), + }); + }); + + it("maps Korean geometry answers SymPy cannot check to warning, never verified", async () => { + const result = await verifyWithSympy( + { mathEngine: createEquationMathEngine({ solutions: [] }) }, + { + candidate: { + ...expressionCandidate, + generation_kind: "geometry", + question_text: "마름모 ABCD에서 대응하는 선분을 쓰시오.", + expected_answer: "선분 ST", + }, + }, + ); + const gates = passedGates().map((gate) => + gate.step === "sympy_verify" ? result.gate : gate, + ); + + expect(result.gate.status).toBe("unverified"); + expect(createAcceptancePolicy().decide(gates, 1)).toBe("warning"); + }); + + it("fails multiple-choice when the labeled correct option does not match SymPy", async () => { + const result = await verifyWithSympy( + { mathEngine: createEquationMathEngine({ solutions: ["2"] }) }, + { + candidate: { + ...expressionCandidate, + generation_kind: "equation", + question_text: "다음 방정식을 풀어라. x - 2 = 0. ① 1 ② 2 ③ 3 ④ 4", + expected_answer: "④", + expected_choices: ["① 1", "② 2", "③ 3", "④ 4"], + }, + }, + ); + + expect(result.gate.status).toBe("failed"); + expect(result.gate.failure_detail?.code).toBe("multiple_choice_correct_mismatch"); + expect(result.gate.evidence).toMatchObject({ expected_answer: "4", sympy_answer: "2" }); + }); + + it("fails multiple-choice when two options are symbolically equivalent", async () => { + const result = await verifyWithSympy( + { mathEngine: createEquationMathEngine({ solutions: ["2", "3"] }) }, + { + candidate: { + ...expressionCandidate, + generation_kind: "equation", + question_text: "다음 방정식을 풀어라. x**2 - 5*x + 6 = 0. ① 2, 3 ② 1 ③ 1 ④ 4", + expected_answer: "①", + expected_choices: ["① 2, 3", "② 1", "③ 1", "④ 4"], + }, + }, + ); + + expect(result.gate.status).toBe("failed"); + expect(result.gate.failure_detail?.code).toBe("multiple_choice_duplicate_equivalent_options"); }); }); + +function createEquationMathEngine(opts: { readonly solutions: string[] }): MathEngineClient { + return { + health: async () => ({ status: "ok", engine: "sympy" }), + solve: async () => ({ solutions: opts.solutions }), + verify: async ({ expr1, expr2 }) => ({ + equivalent: expr1.replace(/\s+/g, "") === expr2.replace(/\s+/g, ""), + diff: "0", + }), + simplify: async ({ expr }) => ({ simplified: expr.replace(/\s+/g, "") }), + differentiate: async () => ({ derivative: "" }), + limit: async () => ({ limit: "" }), + }; +} + +function passedGates(): GateResult[] { + return ["rag", "intent", "generate", "sympy_verify", "re_solve", "objective_map"].map( + (step) => ({ step, status: "passed" as const, duration_ms: 1 }), + ); +} diff --git a/packages/agent/tests/verification-workflow-last-resort.test.ts b/packages/agent/tests/verification-workflow-last-resort.test.ts new file mode 100644 index 0000000..6dbc7c3 --- /dev/null +++ b/packages/agent/tests/verification-workflow-last-resort.test.ts @@ -0,0 +1,251 @@ +import { MockLanguageModelV1 } from "ai/test"; +import { describe, expect, it, vi } from "vitest"; + +import type { + ConstraintCriticAgent, + GeneratorAgent, + RefinerAgent, + SolverAgent, +} from "../src/agents/index.js"; +import type { + GenerateRequest, + GeneratedProblem, + ProgressEvent, + RagResult, + Strategy, +} from "../src/schemas/index.js"; +import type { MathEngineClient } from "../src/tools/math-engine-client.js"; +import type { PromptLoader } from "../src/tools/prompt-loader.js"; +import type { RagClient } from "../src/tools/rag-client.js"; +import type { StrategyLoader } from "../src/tools/schema-loader.js"; +import { runVerificationWorkflow } from "../src/workflows/verification-workflow.js"; + +const request: GenerateRequest = { + grade: 3, + topic: "9수02-10", + topic_name: "이차방정식의 활용", + mode: "structural", + school_level: "middle", + dims: ["활용"], + count: 5, + difficulty: "medium", + problem_type: "objective", +}; + +const strategy: Strategy = { + code: "9수02-10", + title: "이차방정식의 활용", + school_level: "middle", + grade: 3, + techniques: { + required_at_least_one_of: ["quadratic_equation"], + forbidden: [], + }, + evaluation_dimensions: [{ id: "A", description: "활용", must_preserve: true }], + difficulty_range: ["medium"], + problem_types_supported: ["objective"], + structural_transforms: [{ kind: "coefficient_swap", range: [1, 9], exclude_zero: true }], + conceptual_transforms: [], +}; + +const refs: RagResult[] = [ + { + item_id: "ref-1", + match_reason: "hybrid", + problem: { + item_id: "ref-1", + source_dataset: "111", + split: "train", + source_label_type: "problem_label", + school_level: "middle", + grade: 3, + semester: null, + topic_code: "9수02-10", + topic_name: "이차방정식의 활용", + achievement_standard: "이차방정식을 활용하여 문제를 해결한다.", + question_text: "가로가 세로보다 3cm 긴 직사각형의 넓이가 10cm^2이다.", + answer_text: "2cm, 5cm", + explanation_text: null, + choice_blocks: [], + problem_type_norm: "objective", + difficulty_norm: "medium", + question_image_relpath: null, + answer_image_relpath: null, + question_json_relpath: null, + answer_json_relpath: null, + }, + }, +]; + +const critic: ConstraintCriticAgent = { + critique: async () => ({ passes: true, hints: [] }), +}; + +const refiner: RefinerAgent = { + refine: async (input) => input.prior, +}; + +const solver: SolverAgent = { + solve: async (candidate) => ({ + derived_answer: candidate.expected_answer, + trace: "독립 풀이가 같은 답을 얻었다.", + confidence: "high", + }), +}; + +const mathEngine: MathEngineClient = { + health: async () => ({ status: "ok", engine: "sympy" }), + solve: async () => ({ solutions: ["2", "3"] }), + verify: async () => ({ equivalent: true, diff: "0" }), + simplify: async ({ expr }) => ({ simplified: expr }), + differentiate: async () => ({ derivative: "" }), + limit: async () => ({ limit: "" }), +}; + +const rag: RagClient = { + search: async () => refs, +}; + +const prompts: PromptLoader = { + load: async (id) => { + throw new Error(`prompt ${id} is not used in workflow last-resort tests`); + }, +}; + +const strategies: StrategyLoader = { + load: async () => strategy, + loadAll: async () => [strategy], +}; + +describe("runVerificationWorkflow last-resort deterministic fallback", () => { + it("emits the deterministic template only after retries are exhausted", async () => { + const generate = vi.fn(async (input) => + makeCandidate({ + attempt: input.attempt, + candidateId: candidateId(200 + input.attempt), + generationKind: "geometry", + questionText: "원 O에서 중심각 AOB가 80도일 때 원주각 ACB의 크기를 구하시오.", + expectedAnswer: "40도", + model: "llm-test-model", + }), + ); + + const result = await runToResult({ generate }, { maxRetries: 2 }); + const problem = result.candidates[0]?.problem; + const verification = result.candidates[0]?.verification; + + expect(generate).toHaveBeenCalledTimes(2); + expect(problem?.generation_metadata.model).toBe("deterministic-topic-generator"); + expect(problem?.generation_metadata.refined_by ?? []).toContain("deterministic-topic-generator"); + expect(problem?.question_text).toContain("직사각형"); + expect(verification?.candidate_id).toBe(problem?.candidate_id); + expect(verification?.overall).toBe("rejected"); + }); + + it("does not use the deterministic template when the LLM candidate verifies", async () => { + const generate = vi.fn(async (input) => + makeCandidate({ + attempt: input.attempt, + candidateId: candidateId(300 + input.attempt), + generationKind: "equation", + questionText: + "다음 중 이차방정식 x^2 - 5x + 6 = 0의 해는? ① 2, 3 ② 1, 6 ③ -2, -3", + expectedAnswer: "①", + expectedChoices: ["① 2, 3", "② 1, 6", "③ -2, -3"], + model: "llm-test-model", + }), + ); + + const result = await runToResult({ generate }, { maxRetries: 2 }); + const problem = result.candidates[0]?.problem; + const verification = result.candidates[0]?.verification; + + expect(generate).toHaveBeenCalledTimes(1); + expect(problem?.generation_metadata.model).toBe("llm-test-model"); + expect(problem?.generation_metadata.refined_by ?? []).not.toContain("deterministic-topic-generator"); + expect(verification?.candidate_id).toBe(problem?.candidate_id); + expect(verification?.overall).toBe("verified"); + }); +}); + +async function runToResult( + generator: GeneratorAgent, + options: { readonly maxRetries: number }, +): Promise> { + const events: ProgressEvent[] = []; + for await (const event of runVerificationWorkflow( + { + rag, + mathEngine, + prompts, + strategies, + intentModel: new MockLanguageModelV1(), + generator, + critic, + refiner, + solver, + }, + request, + { + deterministicFallback: "last-resort", + maxRetries: options.maxRetries, + perStepTimeoutMs: 1_000, + }, + )) { + events.push(event); + } + + const result = events.find((event) => event.type === "result"); + if (result === undefined) { + throw new Error("workflow did not emit a result event"); + } + if (result.type !== "result") { + throw new Error(`expected result event, got ${result.type}`); + } + return result; +} + +function makeCandidate(input: { + readonly attempt: number; + readonly candidateId: string; + readonly generationKind: GeneratedProblem["generation_kind"]; + readonly questionText: string; + readonly expectedAnswer: string; + readonly expectedChoices?: string[]; + readonly model: string; +}): GeneratedProblem { + return { + candidate_id: input.candidateId, + mode: "structural", + generation_kind: input.generationKind, + question_text: input.questionText, + expected_answer: input.expectedAnswer, + expected_choices: input.expectedChoices, + techniques_used: strategy.techniques.required_at_least_one_of, + proposed_solution_trace: "후보 풀이 trace", + source_refs: ["ref-1"], + inferred_intent: { + objective_code: strategy.code, + objective_description: strategy.title, + evaluation_dimensions: strategy.evaluation_dimensions, + required_techniques: strategy.techniques.required_at_least_one_of, + forbidden_techniques: strategy.techniques.forbidden, + surface_constraints: { + difficulty: request.difficulty, + problem_type: request.problem_type, + }, + }, + generation_metadata: { + model: input.model, + temperature: 0, + prompt_id: "problem-generator", + prompt_version: "0.0.0", + attempt: input.attempt, + generated_at: "2026-06-10T00:00:00.000Z", + }, + }; +} + +function candidateId(value: number): string { + return `00000000-0000-0000-0000-${String(value).padStart(12, "0")}`; +} diff --git a/packages/agent/tests/wire-adapter.test.ts b/packages/agent/tests/wire-adapter.test.ts new file mode 100644 index 0000000..2ee0594 --- /dev/null +++ b/packages/agent/tests/wire-adapter.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; + +import type { + GateResult, + GeneratedProblem, + Verification, +} from "../src/schemas/index.js"; +import { + WireResultProblemSchema, +} from "../src/schemas/index.js"; +import { toWireResultProblem } from "../src/server/sse/wire-adapter.js"; + +const baseProblem: GeneratedProblem = { + candidate_id: "00000000-0000-0000-0000-000000000010", + mode: "structural", + generation_kind: "equation", + question_text: "x**2 - 5*x + 6 = 0", + expected_answer: "2, 3", + techniques_used: ["factorization"], + proposed_solution_trace: "(x - 2)(x - 3) = 0", + source_refs: ["seed-9수02-09-001"], + inferred_intent: { + objective_code: "9수02-09", + objective_description: "이차방정식을 풀 수 있다", + evaluation_dimensions: [ + { + id: "A", + description: "이차식을 인수분해하여 해를 구한다", + must_preserve: true, + }, + { + id: "B", + description: "근의 공식의 적용 흐름을 설명한다", + must_preserve: false, + }, + ], + required_techniques: ["factorization"], + forbidden_techniques: [], + surface_constraints: { difficulty: "easy", problem_type: "short_answer" }, + }, + generation_metadata: { + model: "gpt-5.5(xhigh)", + temperature: 0.4, + prompt_id: "problem-generator", + prompt_version: "0.3.0", + attempt: 2, + generated_at: "2026-05-21T00:00:00.000Z", + refined_by: ["constraint-critic", "refiner"], + }, +}; + +function makeVerification(overrides?: Partial): Verification { + const gates: GateResult[] = [ + { step: "rag", status: "passed", duration_ms: 12 }, + { step: "intent", status: "passed", duration_ms: 34 }, + { step: "generate", status: "passed", duration_ms: 880 }, + { step: "sympy_verify", status: "passed", duration_ms: 56 }, + { + step: "re_solve", + status: "failed", + duration_ms: 410, + failure_detail: { + code: "re_solve_mismatch", + message: "Independent resolve disagreed with expected answer", + }, + }, + { step: "objective_map", status: "passed", duration_ms: 21 }, + ]; + return { + candidate_id: baseProblem.candidate_id, + overall: "warning", + gates, + attempt_count: 2, + ...overrides, + }; +} + +describe("toWireResultProblem — verification + provenance projection", () => { + it("projects overall, gates, attempt_count, model, and refined_by", () => { + const verification = makeVerification(); + + const wire = toWireResultProblem(baseProblem, verification); + + expect(wire.overall).toBe("warning"); + expect(wire.verification_status).toBe("partial"); + expect(wire.attempt_count).toBe(2); + expect(wire.generation_model).toBe("gpt-5.5(xhigh)"); + expect(wire.refined_by).toEqual(["constraint-critic", "refiner"]); + + expect(wire.gates).toHaveLength(6); + expect(wire.gates.map((g) => g.step)).toEqual([ + "rag", + "intent", + "generate", + "sympy_verify", + "re_solve", + "objective_map", + ]); + expect(wire.gates[0]).toEqual({ + step: "rag", + status: "passed", + duration_ms: 12, + }); + expect(wire.gates[4]).toEqual({ + step: "re_solve", + status: "failed", + duration_ms: 410, + failure_code: "re_solve_mismatch", + failure_message: "Independent resolve disagreed with expected answer", + }); + }); + + it("omits failure_code/message on passing gates and emits empty refined_by when absent", () => { + const problem: GeneratedProblem = { + ...baseProblem, + generation_metadata: { + ...baseProblem.generation_metadata, + model: "seed", + refined_by: undefined, + }, + }; + const verification = makeVerification({ + overall: "verified", + attempt_count: 1, + gates: [ + { step: "rag", status: "passed", duration_ms: 1 }, + { step: "intent", status: "passed", duration_ms: 1 }, + { step: "generate", status: "passed", duration_ms: 1 }, + { step: "sympy_verify", status: "passed", duration_ms: 1 }, + { step: "re_solve", status: "passed", duration_ms: 1 }, + { step: "objective_map", status: "passed", duration_ms: 1 }, + ], + }); + + const wire = toWireResultProblem(problem, verification); + + expect(wire.overall).toBe("verified"); + expect(wire.verification_status).toBe("pass"); + expect(wire.generation_model).toBe("seed"); + expect(wire.refined_by).toEqual([]); + for (const gate of wire.gates) { + expect(gate.failure_code).toBeUndefined(); + expect(gate.failure_message).toBeUndefined(); + } + }); + + it("keeps the existing public fields stable", () => { + const wire = toWireResultProblem(baseProblem, makeVerification()); + + expect(wire.id).toBe(baseProblem.candidate_id); + expect(wire.question_latex).toBe(baseProblem.question_text); + expect(wire.answer_latex).toBe(baseProblem.expected_answer); + expect(wire.isomorphism).toBe("structural"); + expect(wire.preserved_dimensions).toEqual([ + "이차식을 인수분해하여 해를 구한다", + ]); + expect(wire.source_refs).toEqual(["seed-9수02-09-001"]); + expect(wire.explanation_latex).toBeUndefined(); + }); + + it("satisfies WireResultProblemSchema parsing (rejected case)", () => { + const verification = makeVerification({ + overall: "rejected", + attempt_count: 4, + gates: [ + { step: "rag", status: "passed", duration_ms: 1 }, + { step: "intent", status: "passed", duration_ms: 1 }, + { step: "generate", status: "passed", duration_ms: 1 }, + { + step: "sympy_verify", + status: "failed", + duration_ms: 1, + failure_detail: { + code: "sympy_solution_set_mismatch", + message: "Solutions disagreed with declared answer", + }, + }, + { step: "re_solve", status: "skipped", duration_ms: 0 }, + { step: "objective_map", status: "skipped", duration_ms: 0 }, + ], + }); + + const wire = toWireResultProblem(baseProblem, verification); + const parsed = WireResultProblemSchema.parse(wire); + + expect(parsed.overall).toBe("rejected"); + expect(parsed.verification_status).toBe("fail"); + expect(parsed.attempt_count).toBe(4); + expect(parsed.gates[3]?.failure_code).toBe("sympy_solution_set_mismatch"); + expect(parsed.gates[5]?.status).toBe("skipped"); + }); +}); diff --git a/packages/math-engine/pyproject.toml b/packages/math-engine/pyproject.toml index 6f911f5..b43d61c 100644 --- a/packages/math-engine/pyproject.toml +++ b/packages/math-engine/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "uvicorn[standard]>=0.34.0", "sympy>=1.13.0", "pydantic>=2.10.0", + "lark>=1.3.1", ] [project.optional-dependencies] diff --git a/packages/math-engine/run.py b/packages/math-engine/run.py index 19415be..f3d1209 100644 --- a/packages/math-engine/run.py +++ b/packages/math-engine/run.py @@ -1,9 +1,23 @@ +import os + import uvicorn + +def _env_flag(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.lower() not in {"0", "false", "no", "off"} + + if __name__ == "__main__": + workers = int(os.getenv("WORKERS", "2")) + reload_enabled = _env_flag("RELOAD", True) and workers <= 1 + uvicorn.run( "src.main:app", host="0.0.0.0", port=16180, - reload=True, + reload=reload_enabled, + workers=1 if reload_enabled else workers, ) diff --git a/packages/math-engine/src/main.py b/packages/math-engine/src/main.py index d12fd6d..e59540f 100644 --- a/packages/math-engine/src/main.py +++ b/packages/math-engine/src/main.py @@ -21,5 +21,5 @@ @app.get("/health") -def health(): +async def health(): return {"status": "ok", "engine": "sympy"} diff --git a/packages/math-engine/src/routers/math.py b/packages/math-engine/src/routers/math.py index aec2abb..caaab35 100644 --- a/packages/math-engine/src/routers/math.py +++ b/packages/math-engine/src/routers/math.py @@ -1,12 +1,37 @@ +# pyright: reportAny=false +# pyright: reportArgumentType=false +# pyright: reportAttributeAccessIssue=false +# pyright: reportCallIssue=false +# pyright: reportExplicitAny=false +# pyright: reportMissingTypeArgument=false +# pyright: reportMissingTypeStubs=false +# pyright: reportOperatorIssue=false +# pyright: reportOptionalMemberAccess=false +# pyright: reportOptionalOperand=false +# pyright: reportUnknownArgumentType=false +# pyright: reportUnknownLambdaType=false +# pyright: reportUnknownMemberType=false +# pyright: reportUnknownParameterType=false +# pyright: reportUnknownVariableType=false +import asyncio +import multiprocessing +import queue +import random +from collections.abc import Callable +from typing import Any, Literal + from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from sympy import Eq, diff, limit, oo, simplify, solve, symbols +from sympy import Eq, diff, lambdify, limit, oo, simplify, solve, symbols +from sympy.core.relational import Equality, Relational +from sympy.parsing.latex import parse_latex from sympy.parsing.sympy_parser import ( convert_xor, implicit_multiplication_application, parse_expr, standard_transformations, ) +from sympy.solvers.inequalities import solve_univariate_inequality router = APIRouter(tags=["math"]) @@ -15,6 +40,105 @@ convert_xor, ) +COMPUTE_TIMEOUT_SECONDS = 5.0 +NUMERIC_SAMPLE_COUNT = 8 +NUMERIC_SAMPLE_TOLERANCE = 1e-9 +SPAWN_CONTEXT = multiprocessing.get_context("spawn") + + +class ComputationTimeoutError(TimeoutError): + pass + + +def _process_worker( + result_queue: multiprocessing.Queue, + fn: Callable[..., Any], + args: tuple[Any, ...], +) -> None: + try: + result = fn(*args) + except HTTPException as e: + result_queue.put( + { + "kind": "http_error", + "status_code": e.status_code, + "detail": e.detail, + } + ) + except Exception as e: + result_queue.put( + { + "kind": "error", + "error_type": type(e).__name__, + "detail": str(e), + } + ) + else: + result_queue.put({"kind": "ok", "result": result}) + + +def run_with_timeout( + fn: Callable[..., Any], + args: tuple[Any, ...] = (), + timeout: float = COMPUTE_TIMEOUT_SECONDS, +) -> Any: + result_queue = SPAWN_CONTEXT.Queue() + proc = SPAWN_CONTEXT.Process(target=_process_worker, args=(result_queue, fn, args)) + + try: + proc.start() + proc.join(timeout) + + if proc.is_alive(): + proc.terminate() + proc.join(1) + if proc.is_alive(): + proc.kill() + proc.join() + raise ComputationTimeoutError( + f"SymPy computation timed out after {timeout}s" + ) + + try: + payload = result_queue.get(timeout=1) + except queue.Empty as e: + raise HTTPException( + status_code=500, + detail=( + "SymPy worker exited without a result " + f"(exitcode={proc.exitcode})" + ), + ) from e + finally: + result_queue.close() + result_queue.join_thread() + + if payload["kind"] == "ok": + return payload["result"] + + if payload["kind"] == "http_error": + raise HTTPException( + status_code=payload["status_code"], + detail=payload["detail"], + ) + + raise HTTPException( + status_code=500, + detail=f"{payload['error_type']}: {payload['detail']}", + ) + + +async def _run_compute(fn: Callable[..., Any], *args: Any) -> Any: + try: + return await asyncio.to_thread( + run_with_timeout, + fn, + args, + COMPUTE_TIMEOUT_SECONDS, + ) + except ComputationTimeoutError as e: + raise HTTPException(status_code=504, detail=str(e)) from e + def safe_parse(expr_str: str): try: @@ -23,6 +147,237 @@ def safe_parse(expr_str: str): raise HTTPException(status_code=400, detail=f"Parse error: {e}") from e +def _looks_like_latex(expr_str: str) -> bool: + return any(token in expr_str for token in ("\\", "^", "_", "{")) + + +def parse_math(expr_str: str): + normalized = expr_str.strip() + if not normalized: + raise HTTPException(status_code=400, detail="Parse error: empty expression") + + if not _looks_like_latex(normalized): + return safe_parse(normalized) + + try: + return parse_latex(normalized, backend="lark") + except Exception as latex_error: + try: + return safe_parse(normalized) + except HTTPException as parse_error: + raise HTTPException( + status_code=400, + detail=( + f"Parse error: {parse_error.detail}; " + f"LaTeX parse error: {latex_error}" + ), + ) from parse_error + + +def parse_equation(equation: str): + if "=" in equation: + left, right = equation.split("=", 1) + return Eq(parse_math(left.strip()), parse_math(right.strip())) + return parse_math(equation) + + +def _equation_normal_form(equation: str): + if "=" in equation: + left, right = equation.split("=", 1) + return parse_math(left.strip()) - parse_math(right.strip()) + + parsed = parse_math(equation) + if isinstance(parsed, Equality): + return parsed.lhs - parsed.rhs + return parsed + + +def _near_zero(value: Any, tolerance: float = NUMERIC_SAMPLE_TOLERANCE) -> bool: + try: + return abs(complex(value)) <= tolerance + except (TypeError, ValueError, OverflowError): + return abs(float(value)) <= tolerance + + +def _numeric_samples_equal_zero( + difference: Any, + samples: int = NUMERIC_SAMPLE_COUNT, + tolerance: float = NUMERIC_SAMPLE_TOLERANCE, +) -> bool: + free_symbols = sorted(difference.free_symbols, key=lambda symbol: symbol.name) + if not free_symbols: + try: + return _near_zero(difference.evalf(), tolerance) + except (TypeError, ValueError, OverflowError): + return False + + rng = random.Random(1729) + numeric_fn = lambdify(free_symbols, difference, "mpmath") + successful_samples = 0 + + for _ in range(samples * 4): + sample_point = [rng.uniform(-10, 10) for _ in free_symbols] + try: + value = numeric_fn(*sample_point) + is_zero = _near_zero(value, tolerance) + except Exception: + continue + + if not is_zero: + return False + + successful_samples += 1 + if successful_samples == samples: + return True + + return False + + +def _expressions_equivalent(expr1: Any, expr2: Any) -> tuple[bool, str]: + difference = expr1 - expr2 + symbolic_result = difference.equals(0) + + if symbolic_result is None: + is_equivalent = _numeric_samples_equal_zero(difference) + else: + is_equivalent = bool(symbolic_result) + + try: + diff_string = str(simplify(difference)) + except Exception: + diff_string = str(difference) + + return is_equivalent, diff_string + + +def _parse_solution_token(token: str) -> list[Any]: + normalized = token.strip() + if "=" in normalized: + normalized = normalized.split("=", 1)[1].strip() + + plus_minus_markers = ("±", "\\pm", "+/-") + for marker in plus_minus_markers: + if marker in normalized: + value = parse_math(normalized.split(marker, 1)[1].strip()) + return [simplify(value), simplify(-value)] + + return [simplify(parse_math(normalized))] + + +def _parse_solution_set(solution_set: str) -> list[Any]: + normalized = solution_set.strip() + normalized = normalized.replace("$", "") + normalized = normalized.replace("\\left", "").replace("\\right", "") + normalized = normalized.replace("\\{", "{").replace("\\}", "}") + + if normalized.startswith("{") and normalized.endswith("}"): + normalized = normalized[1:-1] + + if not normalized.strip(): + return [] + + values: list[Any] = [] + for token in normalized.replace(";", ",").split(","): + stripped = token.strip() + if stripped: + values.extend(_parse_solution_token(stripped)) + + return _dedupe_equivalent(values) + + +def _dedupe_equivalent(values: list[Any]) -> list[Any]: + deduped: list[Any] = [] + for value in values: + if not any(_expressions_equivalent(value, existing)[0] for existing in deduped): + deduped.append(value) + return deduped + + +def _sets_equivalent(expected: list[Any], actual: list[Any]) -> bool: + if len(expected) != len(actual): + return False + + unmatched_actual = list(actual) + for expected_value in expected: + match_index = next( + ( + index + for index, actual_value in enumerate(unmatched_actual) + if _expressions_equivalent(expected_value, actual_value)[0] + ), + None, + ) + if match_index is None: + return False + unmatched_actual.pop(match_index) + + return True + + +def _parse_inequality(inequality: str) -> Relational: + parsed = parse_math(inequality) + if not isinstance(parsed, Relational) or isinstance(parsed, Equality): + raise HTTPException(status_code=400, detail="Expected an inequality") + return parsed + + +def _inequalities_equivalent(expected: Relational, actual: Relational) -> bool: + direct_result = expected.equals(actual) + if direct_result is True: + return True + + if expected.canonical == actual.canonical: + return True + + + free_symbols = sorted( + expected.free_symbols.union(actual.free_symbols), + key=lambda symbol: symbol.name, + ) + if len(free_symbols) != 1: + return False + + variable = free_symbols[0] + try: + expected_set = solve_univariate_inequality( + expected, + variable, + relational=False, + ) + actual_set = solve_univariate_inequality(actual, variable, relational=False) + except (AttributeError, NotImplementedError, TypeError, ValueError): + return False + + return expected_set == actual_set + + +def _equations_equivalent(expected: str, actual: str) -> bool: + expected_normal = _equation_normal_form(expected) + actual_normal = _equation_normal_form(actual) + + if _expressions_equivalent(expected_normal, actual_normal)[0]: + return True + + if _expressions_equivalent(expected_normal, -actual_normal)[0]: + return True + + free_symbols = sorted( + expected_normal.free_symbols.union(actual_normal.free_symbols), + key=lambda symbol: symbol.name, + ) + if len(free_symbols) != 1: + return False + + variable = free_symbols[0] + try: + expected_solutions = _dedupe_equivalent(solve(Eq(expected_normal, 0), variable)) + actual_solutions = _dedupe_equivalent(solve(Eq(actual_normal, 0), variable)) + except (NotImplementedError, TypeError, ValueError): + return False + + return _sets_equivalent(expected_solutions, actual_solutions) + + class SolveRequest(BaseModel): equation: str | list[str] variable: str | list[str] = "x" @@ -32,37 +387,36 @@ class SolveResponse(BaseModel): solutions: list[str] -@router.post("/solve", response_model=SolveResponse) -def solve_equation(req: SolveRequest): - if isinstance(req.variable, list): - variables = symbols(" ".join(req.variable)) +def _compute_solve( + equation: str | list[str], + variable: str | list[str], +) -> dict[str, Any]: + if isinstance(variable, list): + variables = symbols(" ".join(variable)) if not isinstance(variables, tuple): variables = (variables,) else: - variables = (symbols(req.variable),) + variables = (symbols(variable),) - if isinstance(req.equation, list): - equations = [parse_equation(equation) for equation in req.equation] + if isinstance(equation, list): + equations = [parse_equation(item) for item in equation] solutions = solve(equations, variables, dict=True) - return SolveResponse( - solutions=[ + return { + "solutions": [ ", ".join(f"{str(var)}={str(solution[var])}" for var in variables) for solution in solutions ] - ) + } - expr = parse_equation(req.equation) + expr = parse_equation(equation) var = variables[0] solutions = solve(expr, var) - return SolveResponse(solutions=[str(s) for s in solutions]) + return {"solutions": [str(solution) for solution in solutions]} -def parse_equation(equation: str): - if "=" in equation: - left, right = equation.split("=", 1) - return Eq(safe_parse(left.strip()), safe_parse(right.strip())) - else: - return safe_parse(equation) +@router.post("/solve", response_model=SolveResponse) +async def solve_equation(req: SolveRequest): + return await _run_compute(_compute_solve, req.equation, req.variable) class VerifyRequest(BaseModel): @@ -75,15 +429,98 @@ class VerifyResponse(BaseModel): diff: str +def _compute_verify(expr1: str, expr2: str) -> dict[str, Any]: + e1 = parse_math(expr1) + e2 = parse_math(expr2) + is_equivalent, difference = _expressions_equivalent(e1, e2) + return {"equivalent": is_equivalent, "diff": difference} + + @router.post("/verify", response_model=VerifyResponse) -def verify_equivalence(req: VerifyRequest): - e1 = safe_parse(req.expr1) - e2 = safe_parse(req.expr2) +async def verify_equivalence(req: VerifyRequest): + return await _run_compute(_compute_verify, req.expr1, req.expr2) + + +class VerifyAnswerRequest(BaseModel): + expected: str + actual: str + answer_type: Literal[ + "number", + "expression", + "equation", + "solution_set", + "inequality", + ] + - difference = simplify(e1 - e2) - is_equivalent = difference == 0 +class VerifyAnswerResponse(BaseModel): + equivalent: bool + detail: str + + +def _compute_verify_answer( + expected: str, + actual: str, + answer_type: str, +) -> dict[str, Any]: + if answer_type in {"number", "expression"}: + is_equivalent, difference = _expressions_equivalent( + parse_math(expected), + parse_math(actual), + ) + return { + "equivalent": is_equivalent, + "detail": "answers are equivalent" + if is_equivalent + else f"diff={difference}", + } + + if answer_type == "equation": + is_equivalent = _equations_equivalent(expected, actual) + return { + "equivalent": is_equivalent, + "detail": "equations are equivalent" + if is_equivalent + else "equations have different solution sets", + } + + if answer_type == "solution_set": + expected_set = _parse_solution_set(expected) + actual_set = _parse_solution_set(actual) + is_equivalent = _sets_equivalent(expected_set, actual_set) + return { + "equivalent": is_equivalent, + "detail": "solution sets are equivalent" + if is_equivalent + else f"expected={expected_set}, actual={actual_set}", + } + + if answer_type == "inequality": + is_equivalent = _inequalities_equivalent( + _parse_inequality(expected), + _parse_inequality(actual), + ) + return { + "equivalent": is_equivalent, + "detail": "inequalities are equivalent" + if is_equivalent + else "inequalities describe different regions", + } + + raise HTTPException( + status_code=400, + detail=f"Unsupported answer_type: {answer_type}", + ) - return VerifyResponse(equivalent=is_equivalent, diff=str(difference)) + +@router.post("/verify-answer", response_model=VerifyAnswerResponse) +async def verify_answer(req: VerifyAnswerRequest): + return await _run_compute( + _compute_verify_answer, + req.expected, + req.actual, + req.answer_type, + ) class SimplifyRequest(BaseModel): @@ -94,11 +531,15 @@ class SimplifyResponse(BaseModel): simplified: str +def _compute_simplify(expr: str) -> dict[str, Any]: + parsed = parse_math(expr) + result = simplify(parsed) + return {"simplified": str(result)} + + @router.post("/simplify", response_model=SimplifyResponse) -def simplify_expression(req: SimplifyRequest): - expr = safe_parse(req.expr) - result = simplify(expr) - return SimplifyResponse(simplified=str(result)) +async def simplify_expression(req: SimplifyRequest): + return await _run_compute(_compute_simplify, req.expr) class DifferentiateRequest(BaseModel): @@ -110,12 +551,16 @@ class DifferentiateResponse(BaseModel): derivative: str +def _compute_differentiate(expr: str, variable: str) -> dict[str, Any]: + var = symbols(variable) + parsed = parse_math(expr) + result = diff(parsed, var) + return {"derivative": str(result)} + + @router.post("/differentiate", response_model=DifferentiateResponse) -def differentiate_expression(req: DifferentiateRequest): - var = symbols(req.variable) - expr = safe_parse(req.expr) - result = diff(expr, var) - return DifferentiateResponse(derivative=str(result)) +async def differentiate_expression(req: DifferentiateRequest): + return await _run_compute(_compute_differentiate, req.expr, req.variable) class LimitRequest(BaseModel): @@ -128,17 +573,21 @@ class LimitResponse(BaseModel): limit: str -@router.post("/limit", response_model=LimitResponse) -def compute_limit(req: LimitRequest): - var = symbols(req.variable) - expr = safe_parse(req.expr) +def _compute_limit(expr: str, variable: str, point: str) -> dict[str, Any]: + var = symbols(variable) + parsed = parse_math(expr) - if req.point == "oo": + if point == "oo": pt = oo - elif req.point == "-oo": + elif point == "-oo": pt = -oo else: - pt = safe_parse(req.point) + pt = parse_math(point) - result = limit(expr, var, pt) - return LimitResponse(limit=str(result)) + result = limit(parsed, var, pt) + return {"limit": str(result)} + + +@router.post("/limit", response_model=LimitResponse) +async def compute_limit(req: LimitRequest): + return await _run_compute(_compute_limit, req.expr, req.variable, req.point) diff --git a/packages/math-engine/tests/test_real_verification.py b/packages/math-engine/tests/test_real_verification.py new file mode 100644 index 0000000..7e3e505 --- /dev/null +++ b/packages/math-engine/tests/test_real_verification.py @@ -0,0 +1,107 @@ +# pyright: reportAny=false +# pyright: reportMissingParameterType=false +# pyright: reportMissingTypeStubs=false +# pyright: reportUnknownArgumentType=false +# pyright: reportUnknownMemberType=false +# pyright: reportUnknownParameterType=false +# pyright: reportUnknownVariableType=false +from sympy import simplify, symbols + +from src.routers import math as math_router + + +def test_parse_math_accepts_latex_fraction(): + x = symbols("x") + parsed = math_router.parse_math(r"\frac{1}{2}+x^2") + assert simplify(parsed - (x**2 + 1 / 2)) == 0 + + +def test_verify_uses_sampling_when_equals_is_undecidable(client): + response = client.post( + "/verify", + json={"expr1": "Abs(x)**2", "expr2": "x**2"}, + ) + + assert response.status_code == 200 + assert response.json()["equivalent"] is True + + +def test_verify_answer_number_fraction_decimal(client): + response = client.post( + "/verify-answer", + json={"expected": "1/2", "actual": "0.5", "answer_type": "number"}, + ) + + assert response.status_code == 200 + assert response.json()["equivalent"] is True + + +def test_verify_answer_expression_latex(client): + response = client.post( + "/verify-answer", + json={ + "expected": r"(x+1)^2", + "actual": "x**2 + 2*x + 1", + "answer_type": "expression", + }, + ) + + assert response.status_code == 200 + assert response.json()["equivalent"] is True + + +def test_verify_answer_equation_normalizes_solution_form(client): + response = client.post( + "/verify-answer", + json={"expected": "x=2", "actual": "x-2=0", "answer_type": "equation"}, + ) + + assert response.status_code == 200 + assert response.json()["equivalent"] is True + + +def test_verify_answer_solution_set_accepts_comma_order(client): + response = client.post( + "/verify-answer", + json={ + "expected": "{-2, 2}", + "actual": "2, -2", + "answer_type": "solution_set", + }, + ) + + assert response.status_code == 200 + assert response.json()["equivalent"] is True + + +def test_verify_answer_solution_set_accepts_plus_minus(client): + response = client.post( + "/verify-answer", + json={ + "expected": "±2", + "actual": "2, -2", + "answer_type": "solution_set", + }, + ) + + assert response.status_code == 200 + assert response.json()["equivalent"] is True + + +def test_verify_answer_inequality_canonicalizes_direction(client): + response = client.post( + "/verify-answer", + json={"expected": "x > 2", "actual": "2 < x", "answer_type": "inequality"}, + ) + + assert response.status_code == 200 + assert response.json()["equivalent"] is True + + +def test_endpoint_timeout_returns_504(client, monkeypatch): + monkeypatch.setattr(math_router, "COMPUTE_TIMEOUT_SECONDS", 0.001) + + response = client.post("/simplify", json={"expr": "x + 1"}) + + assert response.status_code == 504 + assert "timed out" in response.json()["detail"] diff --git a/packages/math-engine/uv.lock b/packages/math-engine/uv.lock index 6aa093a..8958866 100644 --- a/packages/math-engine/uv.lock +++ b/packages/math-engine/uv.lock @@ -170,6 +170,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -185,6 +194,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi" }, + { name = "lark" }, { name = "pydantic" }, { name = "sympy" }, { name = "uvicorn", extra = ["standard"] }, @@ -208,6 +218,7 @@ dev = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, + { name = "lark", specifier = ">=1.3.1" }, { name = "pydantic", specifier = ">=2.10.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, diff --git a/packages/web/DESIGN.md b/packages/web/DESIGN.md index 727dcb3..57f5b85 100644 --- a/packages/web/DESIGN.md +++ b/packages/web/DESIGN.md @@ -411,6 +411,22 @@ components: rounded: "{rounded.pill}" padding: "4px 10px" + badge-fallback: + backgroundColor: "{colors.warn-100}" + textColor: "{colors.warn-deep}" + typography: "{typography.caption-md}" + rounded: "{rounded.pill}" + padding: "4px 10px" + border: "1px dashed {colors.warn-deep}" + + badge-unverified: + backgroundColor: "{colors.canvas-pure}" + textColor: "{colors.warn-deep}" + typography: "{typography.caption-md}" + rounded: "{rounded.pill}" + padding: "4px 10px" + border: "1px solid {colors.warn-deep}" + intent-checkbox: backgroundColor: "{colors.canvas-pure}" textColor: "{colors.ink}" @@ -816,6 +832,17 @@ OpenMath 는 *사진을 사용하지 않는다*. 사진의 자리에는 다음 - Background `{colors.X-100}`, text `{colors.X-deep}`, type `{typography.caption-md}`, rounded `{rounded.pill}`, padding `4px 10px`. - 모든 result-card head 에 동형 종류 / 검증 상태 badge 표기. 사용자가 한눈에 식별. +**`badge-fallback`** — 결정론 템플릿 출처(provenance) 시그널 +- Background `{colors.warn-100}`, text `{colors.warn-deep}`, type `{typography.caption-md}`, rounded `{rounded.pill}`, padding `4px 10px`, **1px dashed `{colors.warn-deep}` border**. +- LLM 이 아닌 결정론 템플릿(`deterministic-topic-generator`)이 후보를 만들었을 때만 result-card head 의 pass/warn/concept/fail badge 옆에 *추가로* 노출. dashed border 가 `{component.badge-warn}` (solid, 동일 amber 팔레트) 과 시각적으로 구분되어, "검증 결과의 부분 통과" 와 "LLM 미관여 폴백" 을 분리. +- 문구는 `⚙ 템플릿 폴백`. 이 badge 가 보이면 사용자(강사)는 해당 문항이 LLM-검증 경로가 아닌 결정론 fallback 으로 만들어졌음을 인지. + +**`badge-unverified`** — 기호 검증 불가 (3-state gate model) 시그널 +- Background `{colors.canvas-pure}`, text `{colors.warn-deep}`, type `{typography.caption-md}`, rounded `{rounded.pill}`, padding `4px 10px`, **1px solid `{colors.warn-deep}` border** (hollow / outlined 변형). +- agent 의 3-state gate model 에서 어떤 gate(특히 `sympy_verify`)가 `status: "unverified"` 로 emit 됐을 때만 result-card head 의 pass/warn/concept/fail/fallback badge *옆에 추가로* 노출. SymPy 가 해당 문항의 정답을 기호적으로 검증할 수 없었고 독립 재풀이로만 확인됐다는 caveat — verified 처럼 보이지 않도록 사용자(강사)에게 명시. +- Warn 팔레트(amber)를 유지하되 `{component.badge-warn}`(solid filled, "부분 통과")·`{component.badge-fallback}`(solid filled + dashed border, "LLM 미관여")와 시각적으로 구분되도록 hollow(`canvas-pure` bg + solid border)로 변별. 빈 안쪽 = "공식 기호 검증이 채워지지 못함" 의미. +- 문구는 `⊘ 기호 검증 불가`, `title="재풀이로만 확인됨 — SymPy 기호 검증을 수행할 수 없었습니다"`. 1개 문항에 여러 unverified gate 가 있어도 badge 는 1회 노출(orthogonal flag). + **`intent-checkbox`** + **`intent-checkbox-active`** — 평가 차원 선택 (S3-B) - Default: bg `{colors.canvas-pure}`, padding `14px 18px`, rounded `{rounded.sm}`, 1px `{colors.hairline}`, body-md ink. - Active: bg → `{colors.soft-cloud}`, border → 2px `{colors.ink}` (padding `13px 17px` 으로 compensate). diff --git a/packages/web/app/app/new/result/mock.ts b/packages/web/app/app/new/result/mock.ts index dc8511b..00ad8b5 100644 --- a/packages/web/app/app/new/result/mock.ts +++ b/packages/web/app/app/new/result/mock.ts @@ -22,6 +22,9 @@ export type ResultProblem = { answerLatex: string; solutionLatex: string; failReason: string | null; + generationModel?: string; + refinedBy?: string[]; + gates?: Array<{ step: string; status: string }>; }; type TopicTemplate = { diff --git a/packages/web/app/app/new/result/view.tsx b/packages/web/app/app/new/result/view.tsx index 1daa406..4a75ceb 100644 --- a/packages/web/app/app/new/result/view.tsx +++ b/packages/web/app/app/new/result/view.tsx @@ -26,8 +26,15 @@ type StoredProblem = { isomorphism: "structural" | "conceptual"; preserved_dimensions: string[]; verification_status: "pass" | "partial" | "fail"; + generation_model?: string; + refined_by?: string[]; + gates?: Array<{ step: string; status: string }>; + overall?: string; }; +const TEMPLATE_GENERATOR_ID = "deterministic-topic-generator"; +const UNVERIFIED_GATE_STATUS = "unverified"; + const filters: { value: Filter; label: string }[] = [ { value: "all", label: "전체" }, { value: "structural", label: "구조동형" }, @@ -86,6 +93,13 @@ function matchesFilter(p: ResultProblem, filter: Filter): boolean { } } +function parseStoredGate(raw: unknown): { step: string; status: string } | null { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const g = raw as Record; + if (typeof g.step !== "string" || typeof g.status !== "string") return null; + return { step: g.step, status: g.status }; +} + function parseStoredProblem(raw: unknown): StoredProblem | null { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; const o = raw as Record; @@ -94,6 +108,14 @@ function parseStoredProblem(raw: unknown): StoredProblem | null { if (typeof o.answer_latex !== "string") return null; if (o.isomorphism !== "structural" && o.isomorphism !== "conceptual") return null; if (o.verification_status !== "pass" && o.verification_status !== "partial" && o.verification_status !== "fail") return null; + const refinedBy = Array.isArray(o.refined_by) + ? o.refined_by.filter((r): r is string => typeof r === "string") + : undefined; + const gates = Array.isArray(o.gates) + ? o.gates + .map(parseStoredGate) + .filter((g): g is { step: string; status: string } => g !== null) + : undefined; return { id: o.id, question_latex: o.question_latex, @@ -104,6 +126,10 @@ function parseStoredProblem(raw: unknown): StoredProblem | null { ? o.preserved_dimensions.filter((d): d is string => typeof d === "string") : [], verification_status: o.verification_status, + generation_model: typeof o.generation_model === "string" ? o.generation_model : undefined, + refined_by: refinedBy, + gates, + overall: typeof o.overall === "string" ? o.overall : undefined, }; } @@ -118,9 +144,22 @@ function toResultProblem(problem: StoredProblem, index: number): ResultProblem { answerLatex: problem.answer_latex, solutionLatex: problem.explanation_latex ?? "검증 파이프라인에서 생성된 문항입니다.", failReason: status === "fail" ? "검증 실패 — 채택할 수 없습니다." : null, + generationModel: problem.generation_model, + refinedBy: problem.refined_by, + gates: problem.gates, }; } +function isTemplateFallback(p: ResultProblem): boolean { + if (p.generationModel === TEMPLATE_GENERATOR_ID) return true; + if (p.refinedBy?.includes(TEMPLATE_GENERATOR_ID) === true) return true; + return false; +} + +function hasUnverifiedGate(p: ResultProblem): boolean { + return p.gates?.some((g) => g.status === UNVERIFIED_GATE_STATUS) === true; +} + export function ResultView({ grade, schoolLevel, @@ -272,6 +311,8 @@ export function ResultView({ const badge = badgeFor(p); const isAdopted = adopted.has(p.id); const failed = p.status === "fail"; + const fallback = isTemplateFallback(p); + const unverified = hasUnverifiedGate(p); return (