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..8c53892 100644 --- a/packages/agent/src/steps/sympy-verification.ts +++ b/packages/agent/src/steps/sympy-verification.ts @@ -123,9 +123,22 @@ async function verifyCandidate( ); } -/** 후보의 verification_expression을 /evaluate로 평가해 선언 정답과 대조한다. - * 식이 없으면 null (호출자가 기존 unverified 폴백 유지). 평가 실패는 unverified — - * 식은 LLM 산출물이라 구문 오류가 수학 오류를 뜻하지 않는다. */ +/** /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 /\(422\)/.test(message) && 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 +155,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