Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/agent/prompts/independent-solver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion packages/agent/prompts/problem-generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/agents/generator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
});

Expand Down
9 changes: 6 additions & 3 deletions packages/agent/src/schemas/generated-problem.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/server/sse/wire-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`;
}
Expand Down
77 changes: 49 additions & 28 deletions packages/agent/src/steps/sympy-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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): <body>` 형태로 던지므로(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,
Expand All @@ -142,53 +155,61 @@ 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<string, unknown> = 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") {
return {
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,
};
}

Expand Down
98 changes: 98 additions & 0 deletions packages/agent/tests/sympy-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,86 @@ 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("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" }) },
Expand Down Expand Up @@ -353,6 +433,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" }),
Expand Down
Loading