From f66ac4aab93ad83377d12bbe24bba24ca68e42f3 Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Thu, 11 Jun 2026 15:31:08 +0900 Subject: [PATCH 1/2] feat(agent): symbolic verification for expression-kind answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비-equation kind 중 정답이 다항식인 식 전개·간단히 유형은 verification_expression을 /evaluate로 보내도 422("not a number")로 거부돼 항상 unverified("기호 검증 불가")였다. 이제 verifyByExpression이 /evaluate의 non-numeric 422 시그널을 감지하면 원식을 선언 정답과 simplify+verify로 기호 동치 비교한다. 통과 시 passed(초록), 불일치 시 failed(numeric 경로와 대칭). 진짜 엔진 장애(타임아웃·5xx)는 시그널이 없으므로 기존대로 unverified로 남겨 일시적 장애가 reject로 이어지지 않게 했다. 프롬프트·스키마·도구 설명을 갱신해 생성기·재풀이기가 식 답일 때 정답을 베끼지 말고 전개 전 원식을 verification_expression으로 내도록 유도한다. - sympy-verification.ts: isNonNumericEvaluateError 게이트 + symbolic 동치 폴백 - wire-adapter.ts: symbolic_check 통과 시 "검증식 기호 동치 확인" 요약 - problem-generator.md / independent-solver.md / generator-agent.ts: 식 답 원식 지시 - tests: symbolic 통과/실패/엔진장애 폴백 3개 추가 (262 pass) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/prompts/independent-solver.md | 2 +- packages/agent/prompts/problem-generator.md | 5 +- packages/agent/src/agents/generator-agent.ts | 2 +- .../src/schemas/generated-problem.schema.ts | 9 ++- packages/agent/src/server/sse/wire-adapter.ts | 3 + .../agent/src/steps/sympy-verification.ts | 74 +++++++++++------- .../agent/tests/sympy-verification.test.ts | 77 +++++++++++++++++++ 7 files changed, 138 insertions(+), 34 deletions(-) diff --git a/packages/agent/prompts/independent-solver.md b/packages/agent/prompts/independent-solver.md index 9f40a65..d0b9bba 100644 --- a/packages/agent/prompts/independent-solver.md +++ b/packages/agent/prompts/independent-solver.md @@ -35,7 +35,7 @@ updated: 2026-06-11 - `derived_answer: string` (문제 유형에 맞는 최종 정답. 방정식 해는 `2, -5`, 확률은 `3/8`, 통계값은 `평균 12`, 기하는 `60도`처럼 간결히 쓴다) - `trace: string` - `confidence: "high" | "medium" | "low"` -- `verification_expression: string` (선택) — 당신의 풀이가 정답에 도달하는 *계산식 하나*. SymPy 평가 가능 표기만: `factorial(4)`, `binomial(10, 3)`, `factorial(5)/factorial(5-2)`, `*`, `/`, `+`, `-`, `**`. 이 식의 값이 `derived_answer`와 일치해야 한다. 정답이 단일 수치/수식 값이 아니면 생략. `4!` 표기, 한글, 단위 금지. +- `verification_expression: string` (선택) — 당신의 풀이가 정답에 도달하는 *식 하나*. SymPy 파싱 가능 표기만: `factorial(4)`, `binomial(10, 3)`, `factorial(5)/factorial(5-2)`, `*`, `/`, `+`, `-`, `**`. `4!` 표기, 한글, 단위 금지. 정답이 **숫자**면 그 값에 도달하는 계산식을 쓰고(평가값이 `derived_answer`와 일치), 정답이 **문자식**(전개·간단히 등)이면 답을 베끼지 말고 문제 조건의 *전개 전 원식*을 써서 simplify하면 `derived_answer`와 기호적으로 같게 한다. 변수 곱셈은 `x*(x+1)`처럼 `*`를 명시한다. 정답이 단일 수치/수식 값이 아니면 생략. # Constraints diff --git a/packages/agent/prompts/problem-generator.md b/packages/agent/prompts/problem-generator.md index fc5bc2b..2e9e04c 100644 --- a/packages/agent/prompts/problem-generator.md +++ b/packages/agent/prompts/problem-generator.md @@ -107,7 +107,10 @@ JSON으로만 응답. - structural 모드에서는 필수 기법을 모두 포함해야 한다. conceptual 모드에서는 필수·관련 기법 중 실제 사용한 것을 최소 1개 이상 포함한다. - 예시 출력: `{ "question_text": "서로 다른 5권의 책 중 3권을 골라 일렬로 꽂는 방법의 수는?", "expected_answer": "60", "techniques_used": ["permutation"], "proposed_solution_trace": "5권 중 3권을 순서 있게 배열하므로 5P3 = 5*4*3 = 60이다.", "verification_expression": "factorial(5)/factorial(5-3)" }` - `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시하되, 수식도 JSON 안전한 plain text 표기만 사용한다. -- `verification_expression`은 정답에 도달하는 *계산식 하나*다. SymPy가 평가할 수 있는 표기만 쓴다: 팩토리얼은 `factorial(4)`, 조합은 `binomial(10, 3)`, 순열은 `factorial(5)/factorial(5-2)`, 사칙연산은 `*`, `/`, `+`, `-`, 지수는 `**`. 이 식을 평가한 값이 `expected_answer`와 정확히 일치해야 한다. 예: 정답이 `864`이고 풀이가 3!*3!*4!이면 `"verification_expression": "factorial(3)*factorial(3)*factorial(4)"`. 정답이 단일 수치/수식 값이 아닐 때(서술형, 참/거짓 등)만 생략한다. `4!` 같은 느낌표 표기, 한글, 단위는 절대 넣지 않는다. +- `verification_expression`은 정답에 도달하는 *식 하나*다. SymPy가 파싱할 수 있는 표기만 쓴다: 팩토리얼은 `factorial(4)`, 조합은 `binomial(10, 3)`, 순열은 `factorial(5)/factorial(5-2)`, 사칙연산은 `*`, `/`, `+`, `-`, 지수는 `**`. `4!` 같은 느낌표 표기, 한글, 단위는 절대 넣지 않는다. 두 가지 경우가 있다: + - **정답이 숫자**(경우의 수, 확률, 통계값 등)이면: 그 숫자에 도달하는 *계산식*을 쓴다. 평가한 값이 `expected_answer`와 정확히 일치해야 한다. 예: 정답이 `864`이고 풀이가 3!*3!*4!이면 `"verification_expression": "factorial(3)*factorial(3)*factorial(4)"`. + - **정답이 문자식**(식의 전개·간단히, 곱셈공식, 인수분해 등)이면: 정답을 그대로 베끼지 말고, 문제 조건에서 세운 *전개하기 전의 원식*을 쓴다. 이 원식을 SymPy가 simplify하면 `expected_answer`와 기호적으로 같아야 한다 — 답을 베끼면 검증이 무의미하다. 예: "가로 x+4, 세로 x-1, 높이 x+2인 직육면체 A의 부피에서 ... 를 뺀 식"이고 정답이 `3x+8`이면, 정답이 아니라 조건의 원식 `"verification_expression": "x**3 + 5*x*(x+1) - (x+4)*(x-1)*(x+2)"`을 쓴다. 변수는 `x*(x+1)`처럼 곱셈 `*`를 반드시 명시한다(`x(x+1)` 금지). + - 정답이 단일 수치/수식 값이 아닐 때(서술형, 참/거짓, 그래프 설명 등)만 생략한다. - 결과 문제는 source problem과 달라야 하며, structural/conceptual 모드 차이가 풀이 설명에 드러나야 한다. # TODO diff --git a/packages/agent/src/agents/generator-agent.ts b/packages/agent/src/agents/generator-agent.ts index d1bbfaa..b1267f2 100644 --- a/packages/agent/src/agents/generator-agent.ts +++ b/packages/agent/src/agents/generator-agent.ts @@ -59,7 +59,7 @@ export const LlmGeneratedCandidateSchema = z.object({ .min(1) .optional() .describe( - "SymPy-evaluable arithmetic expression whose value equals expected_answer, e.g. factorial(3)*factorial(3)*factorial(4). Omit only when the answer is not a single numeric/symbolic value", + "SymPy-parseable expression that deterministically reaches expected_answer. Numeric answers: an arithmetic expression whose value equals it, e.g. factorial(3)*factorial(3)*factorial(4). Algebraic answers (expand/simplify): the un-expanded source expression from the problem setup (NOT the answer copied) that SymPy-simplifies to expected_answer, e.g. x**3 + 5*x*(x+1) - (x+4)*(x-1)*(x+2). Use explicit * for multiplication. Omit only when the answer is not a single numeric/algebraic value", ), }); diff --git a/packages/agent/src/schemas/generated-problem.schema.ts b/packages/agent/src/schemas/generated-problem.schema.ts index 7e642ac..b96a503 100644 --- a/packages/agent/src/schemas/generated-problem.schema.ts +++ b/packages/agent/src/schemas/generated-problem.schema.ts @@ -40,9 +40,12 @@ export const GeneratedProblemSchema = z.object({ expected_choices: z.array(z.string()).optional(), // objective일 때 techniques_used: z.array(z.string()).default([]), proposed_solution_trace: z.string(), - /** 정답에 도달하는 SymPy 평가식 (예: "factorial(3)*factorial(3)*factorial(4)"). - * sympy_verify가 math-engine /evaluate로 평가해 expected_answer와 대조한다 — - * equation이 아닌 kind(경우의 수 등)도 결정론 검증이 가능해진다. */ + /** 정답에 도달하는 SymPy 식. sympy_verify가 expected_answer와 결정론적으로 대조한다. + * - 숫자 답(경우의 수 등): 평가식 (예: "factorial(3)*factorial(3)*factorial(4)") → + * /evaluate로 수치 평가 후 대조. + * - 식 답(전개·간단히 등): 정답과 독립인 *미전개 원식* + * (예: "x**3 + 5*x*(x+1) - (x+4)*(x-1)*(x+2)") → simplify+verify로 기호 동치 대조. + * equation이 아닌 kind도 이 식으로 결정론 검증이 가능해진다. */ verification_expression: z.string().min(1).optional(), source_refs: z.array(z.string()), // SourceProblem.item_id[] diff --git a/packages/agent/src/server/sse/wire-adapter.ts b/packages/agent/src/server/sse/wire-adapter.ts index b21c9e8..ffa96bd 100644 --- a/packages/agent/src/server/sse/wire-adapter.ts +++ b/packages/agent/src/server/sse/wire-adapter.ts @@ -124,6 +124,9 @@ function sympySummary( return `기호 검증 불가${kindLabel} — 독립 재풀이로 확인`; } const sympyAnswer = asString(evidence["sympy_answer"]); + if (evidence["symbolic_check"] === true) { + return "검증식 기호 동치 확인"; + } if (evidence["expression_check"] === true) { return sympyAnswer === null ? "검증식 평가 일치" : `검증식 평가 일치 (값: ${sympyAnswer})`; } diff --git a/packages/agent/src/steps/sympy-verification.ts b/packages/agent/src/steps/sympy-verification.ts index a423a25..bf34476 100644 --- a/packages/agent/src/steps/sympy-verification.ts +++ b/packages/agent/src/steps/sympy-verification.ts @@ -123,9 +123,19 @@ async function verifyCandidate( ); } -/** 후보의 verification_expression을 /evaluate로 평가해 선언 정답과 대조한다. - * 식이 없으면 null (호출자가 기존 unverified 폴백 유지). 평가 실패는 unverified — - * 식은 LLM 산출물이라 구문 오류가 수학 오류를 뜻하지 않는다. */ +/** /evaluate가 식이 숫자로 떨어지지 않아 422로 거부했는지 — math.py `_compute_evaluate`의 + * "Expression did not evaluate to a number" 시그널. 이 경우만 기호 동치 경로로 폴백한다. */ +function isNonNumericEvaluateError(message: string): boolean { + return message.includes("did not evaluate to a number"); +} + +/** 후보의 verification_expression을 선언 정답과 대조한다. 두 경로: + * - 숫자로 떨어지는 식(경우의 수 등)은 /evaluate로 평가값을 구해 대조한다. + * - 변수가 든 식(식 전개·간단히 등)은 /evaluate가 422를 던지므로, 원식 자체를 + * 선언 정답과 기호 동치(simplify+verify)로 대조한다. + * 식이 없으면 null (호출자가 기존 unverified 폴백 유지). 동치 판정 자체가 불가능하면 + * (파싱 실패 등) unverified — 식은 LLM 산출물이라 구문 오류가 수학 오류를 뜻하지 않는다. + * 깔끔히 not_equivalent로 판정되면 failed(numeric·symbolic 동일). */ async function verifyByExpression( mathEngine: MathEngineClient, candidate: GeneratedProblem, @@ -142,25 +152,40 @@ async function verifyByExpression( ); } - let evaluatedValue: string; + let evaluatedValue: string | null = null; + let evaluateError: string | null = null; try { evaluatedValue = (await mathEngine.evaluate({ expr: expression })).value; } catch (err) { - return unverifiedCheck( - candidate, - `math-engine could not evaluate the verification expression: ${err instanceof Error ? err.message : String(err)}`, - { verification_expression: expression }, - ); + evaluateError = err instanceof Error ? err.message : String(err); + // /evaluate가 "숫자가 아닌 식"이라며 422로 거부한 경우만 기호 동치 경로로 폴백한다. + // 그 외(타임아웃·5xx·파싱 오류 등)는 진짜 엔진 장애이므로 unverified로 남긴다 — + // 식은 LLM 산출물이라 일시적 장애를 "틀린 문제"로 reject하면 안 된다. + if (!isNonNumericEvaluateError(evaluateError)) { + return unverifiedCheck( + candidate, + `math-engine could not evaluate the verification expression: ${evaluateError}`, + { verification_expression: expression }, + ); + } } - const decision = await decideAnswerEquivalence(mathEngine, declared, evaluatedValue); + const isSymbolic = evaluatedValue === null; + const compared = evaluatedValue ?? expression; + const baseEvidence: Record = isSymbolic + ? { expression_check: true, symbolic_check: true, verification_expression: expression } + : { expression_check: true, verification_expression: expression, evaluated_value: evaluatedValue }; + + const decision = await decideAnswerEquivalence(mathEngine, declared, compared); if (decision.status === "undecidable") { return unverifiedCheck( candidate, - `Could not compare declared answer with evaluated verification expression: ${decision.reason ?? "undecidable"}`, - { expression_check: true, verification_expression: expression, evaluated_value: evaluatedValue }, + isSymbolic + ? `Could not symbolically compare declared answer with the verification expression${evaluateError === null ? "" : ` (evaluate: ${evaluateError})`}: ${decision.reason ?? "undecidable"}` + : `Could not compare declared answer with evaluated verification expression: ${decision.reason ?? "undecidable"}`, + baseEvidence, declared, - evaluatedValue, + compared, ); } if (decision.status === "not_equivalent") { @@ -168,27 +193,20 @@ async function verifyByExpression( status: "failed", verificationKind: candidate.generation_kind, expectedAnswer: declared, - sympyAnswer: evaluatedValue, - failureCode: "expression_value_mismatch", - failureMessage: `Verification expression evaluates to ${evaluatedValue}, which does not match the declared answer ${declared}`, - evidence: { - expression_check: true, - verification_expression: expression, - evaluated_value: evaluatedValue, - equivalence: decision, - }, + sympyAnswer: compared, + failureCode: isSymbolic ? "expression_symbolic_mismatch" : "expression_value_mismatch", + failureMessage: isSymbolic + ? `Verification expression ${expression} is not symbolically equivalent to the declared answer ${declared}` + : `Verification expression evaluates to ${compared}, which does not match the declared answer ${declared}`, + evidence: { ...baseEvidence, equivalence: decision }, }; } return { status: "passed", verificationKind: candidate.generation_kind, expectedAnswer: declared, - sympyAnswer: evaluatedValue, - evidence: { - expression_check: true, - verification_expression: expression, - evaluated_value: evaluatedValue, - }, + sympyAnswer: compared, + evidence: baseEvidence, }; } diff --git a/packages/agent/tests/sympy-verification.test.ts b/packages/agent/tests/sympy-verification.test.ts index be8e639..9d73fbb 100644 --- a/packages/agent/tests/sympy-verification.test.ts +++ b/packages/agent/tests/sympy-verification.test.ts @@ -270,6 +270,65 @@ describe("verifyWithSympy expression check", () => { }); }); + const expansionCandidate: GeneratedProblem = { + ...expressionCandidate, + generation_kind: "expression", + question_text: + "가로 x+4, 세로 x-1, 높이 x+2인 직육면체 A의 부피에서 ... 를 뺀 식을 간단히 하여라.", + expected_answer: "3x+8", + verification_expression: "x**3 + 5*x*(x+1) - (x+4)*(x-1)*(x+2)", + }; + + it("passes an expression candidate whose un-simplified verification expression is symbolically equivalent", async () => { + const result = await verifyWithSympy( + { mathEngine: createSymbolicMathEngine({ equivalent: true }) }, + { candidate: expansionCandidate }, + ); + const gates = passedGates().map((gate) => + gate.step === "sympy_verify" ? result.gate : gate, + ); + + expect(result.gate.status).toBe("passed"); + expect(result.gate.evidence).toMatchObject({ + expression_check: true, + symbolic_check: true, + verification_expression: "x**3 + 5*x*(x+1) - (x+4)*(x-1)*(x+2)", + }); + expect(createAcceptancePolicy().decide(gates, 1)).toBe("verified"); + }); + + it("fails when the verification expression is not symbolically equivalent to the declared answer", async () => { + const result = await verifyWithSympy( + { mathEngine: createSymbolicMathEngine({ equivalent: false }) }, + { candidate: expansionCandidate }, + ); + const gates = passedGates().map((gate) => + gate.step === "sympy_verify" ? result.gate : gate, + ); + + expect(result.gate.status).toBe("failed"); + expect(result.gate.failure_detail?.code).toBe("expression_symbolic_mismatch"); + expect(createAcceptancePolicy().decide(gates, 1)).toBe("rejected"); + }); + + it("keeps a genuine engine failure on the verification expression as unverified, not symbolic-failed", async () => { + const explodingEngine: MathEngineClient = { + ...createSymbolicMathEngine({ equivalent: false }), + evaluate: async () => { + throw new Error("math-engine /evaluate failed (500): engine crashed"); + }, + }; + const result = await verifyWithSympy( + { mathEngine: explodingEngine }, + { candidate: expansionCandidate }, + ); + + expect(result.gate.status).toBe("unverified"); + expect(result.gate.evidence).toMatchObject({ + reason: expect.stringContaining("could not evaluate the verification expression"), + }); + }); + it("keeps candidates without a verification expression on the unverified fallback", async () => { const result = await verifyWithSympy( { mathEngine: createExpressionMathEngine({ value: "864" }) }, @@ -353,6 +412,24 @@ function createExpressionMathEngine(opts: { }; } +/** /evaluate가 "숫자가 아님" 422로 거부하는 식 답 후보를 모사한다. simplify는 식 답을 + * 정규화하지 못해(다항식 미전개) verify pair 경로로 떨어지고, verify가 SymPy 기호 동치를 대신한다. */ +function createSymbolicMathEngine(opts: { readonly equivalent: boolean }): MathEngineClient { + return { + health: async () => ({ status: "ok", engine: "sympy" }), + solve: async () => ({ solutions: [] }), + verify: async () => ({ equivalent: opts.equivalent, diff: opts.equivalent ? "0" : "3*x + 8" }), + simplify: async ({ expr }) => ({ simplified: expr.replace(/\s+/g, "") }), + evaluate: async () => { + throw new Error( + "math-engine /evaluate failed (422): Expression did not evaluate to a number: 3*x + 8", + ); + }, + differentiate: async () => ({ derivative: "" }), + limit: async () => ({ limit: "" }), + }; +} + function createEquationMathEngine(opts: { readonly solutions: string[] }): MathEngineClient { return { health: async () => ({ status: "ok", engine: "sympy" }), From c22c10c22a0ff80af0b51386cfe2140c2a5f49d3 Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Thu, 11 Jun 2026 15:44:40 +0900 Subject: [PATCH 2/2] fix(agent): require HTTP 422, not just error text, for symbolic fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic 리뷰 지적(P2): isNonNumericEvaluateError가 에러 문구만 봐서 진짜 엔진 장애가 symbolic 비교로 오분류될 수 있다. /evaluate 클라이언트 에러 포맷의 "(422)" 상태코드와 "did not evaluate to a number" detail을 모두 요구하도록 강화. 둘 중 하나라도 어긋나면 보수적으로 unverified에 머문다(false-fail 방지). 422 아닌데 같은 문구를 담은 5xx도 unverified로 남는지 가드 테스트 추가. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/src/steps/sympy-verification.ts | 9 +++++--- .../agent/tests/sympy-verification.test.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/steps/sympy-verification.ts b/packages/agent/src/steps/sympy-verification.ts index bf34476..8c53892 100644 --- a/packages/agent/src/steps/sympy-verification.ts +++ b/packages/agent/src/steps/sympy-verification.ts @@ -123,10 +123,13 @@ async function verifyCandidate( ); } -/** /evaluate가 식이 숫자로 떨어지지 않아 422로 거부했는지 — math.py `_compute_evaluate`의 - * "Expression did not evaluate to a number" 시그널. 이 경우만 기호 동치 경로로 폴백한다. */ +/** /evaluate가 식이 숫자로 떨어지지 않아 422로 거부했는지. math.py `_compute_evaluate`는 + * HTTP 422 + "Expression did not evaluate to a number" detail로만 이 경우를 알린다. + * client는 `math-engine /evaluate failed (422): ` 형태로 던지므로(math-engine-client.ts), + * 상태코드 422와 detail 문구를 모두 요구해 진짜 엔진 장애(5xx·타임아웃 등)가 symbolic으로 + * 오분류되지 않게 한다. 둘 중 하나라도 어긋나면 보수적으로 unverified에 머문다(false-fail 방지). */ function isNonNumericEvaluateError(message: string): boolean { - return message.includes("did not evaluate to a number"); + return /\(422\)/.test(message) && message.includes("did not evaluate to a number"); } /** 후보의 verification_expression을 선언 정답과 대조한다. 두 경로: diff --git a/packages/agent/tests/sympy-verification.test.ts b/packages/agent/tests/sympy-verification.test.ts index 9d73fbb..7b558a1 100644 --- a/packages/agent/tests/sympy-verification.test.ts +++ b/packages/agent/tests/sympy-verification.test.ts @@ -329,6 +329,27 @@ describe("verifyWithSympy expression check", () => { }); }); + it("requires the 422 status, not just the error text, before taking the symbolic path", async () => { + // 5xx 본문이 우연히 같은 문구를 담아도 422가 아니면 진짜 엔진 장애로 보고 unverified에 머문다. + const phraseButNot422: MathEngineClient = { + ...createSymbolicMathEngine({ equivalent: false }), + evaluate: async () => { + throw new Error( + "math-engine /evaluate failed (500): did not evaluate to a number (proxy error)", + ); + }, + }; + const result = await verifyWithSympy( + { mathEngine: phraseButNot422 }, + { candidate: expansionCandidate }, + ); + + expect(result.gate.status).toBe("unverified"); + expect(result.gate.evidence).toMatchObject({ + reason: expect.stringContaining("could not evaluate the verification expression"), + }); + }); + it("keeps candidates without a verification expression on the unverified fallback", async () => { const result = await verifyWithSympy( { mathEngine: createExpressionMathEngine({ value: "864" }) },