diff --git a/packages/agent/src/server/sse/wire-adapter.ts b/packages/agent/src/server/sse/wire-adapter.ts
index 71e4a9d..b21c9e8 100644
--- a/packages/agent/src/server/sse/wire-adapter.ts
+++ b/packages/agent/src/server/sse/wire-adapter.ts
@@ -10,6 +10,7 @@
* 생략한다 — summary는 항상 best-effort.
*/
+import { formatLatex } from "../../tools/latex-formatter.js";
import type {
GateResult,
GeneratedProblem,
@@ -234,10 +235,13 @@ export function toWireResultProblem(
problem: GeneratedProblem,
verification: Verification,
): WireResultProblem {
+ const solution = problem.proposed_solution_trace.trim();
return {
id: problem.candidate_id,
question_latex: problem.question_text,
answer_latex: problem.expected_answer,
+ // 풀이 trace 는 sympy 표기 그대로 오므로 표현 경계에서 LaTeX 로 변환
+ ...(solution.length === 0 ? {} : { explanation_latex: formatLatex(solution) }),
isomorphism: problem.mode,
preserved_dimensions: preservedDimensions(problem),
source_refs: problem.source_refs,
diff --git a/packages/agent/tests/backend-contract.test.ts b/packages/agent/tests/backend-contract.test.ts
index 30e442d..b60fbe0 100644
--- a/packages/agent/tests/backend-contract.test.ts
+++ b/packages/agent/tests/backend-contract.test.ts
@@ -263,7 +263,7 @@ describe("SSE wire adapter", () => {
expect(wire.data[0]?.id).toBe(problem.candidate_id);
expect(wire.data[0]?.verification_status).toBe("pass");
expect(wire.data[0]?.source_refs).toEqual(["seed-9수02-09-001"]);
- expect(wire.data[0]?.explanation_latex).toBeUndefined();
+ expect(wire.data[0]?.explanation_latex).toBe("(x - 2)(x - 3) = 0");
expect(wire.data[0]?.preserved_dimensions).toEqual([
"이차식을 인수분해하여 해를 구한다",
]);
diff --git a/packages/agent/tests/wire-adapter.test.ts b/packages/agent/tests/wire-adapter.test.ts
index 2ee0594..bd38d9c 100644
--- a/packages/agent/tests/wire-adapter.test.ts
+++ b/packages/agent/tests/wire-adapter.test.ts
@@ -155,6 +155,24 @@ describe("toWireResultProblem — verification + provenance projection", () => {
"이차식을 인수분해하여 해를 구한다",
]);
expect(wire.source_refs).toEqual(["seed-9수02-09-001"]);
+ expect(wire.explanation_latex).toBe("(x - 2)(x - 3) = 0");
+ });
+
+ it("formats the solution trace to KaTeX-safe LaTeX in explanation_latex", () => {
+ const problem: GeneratedProblem = {
+ ...baseProblem,
+ proposed_solution_trace: "판별식: x**2 - 5*x + 6 = 0, sqrt(4) = 2",
+ };
+ const wire = toWireResultProblem(problem, makeVerification());
+ expect(wire.explanation_latex).toBe("판별식: x^{2} - 5 x + 6 = 0, \\sqrt{4} = 2");
+ });
+
+ it("omits explanation_latex when the solution trace is blank", () => {
+ const problem: GeneratedProblem = {
+ ...baseProblem,
+ proposed_solution_trace: " ",
+ };
+ const wire = toWireResultProblem(problem, makeVerification());
expect(wire.explanation_latex).toBeUndefined();
});
diff --git a/packages/web/__tests__/latex-renderer.test.ts b/packages/web/__tests__/latex-renderer.test.ts
index f540d3f..b2c0927 100644
--- a/packages/web/__tests__/latex-renderer.test.ts
+++ b/packages/web/__tests__/latex-renderer.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ segmentAuto,
splitDelimited,
type Segment,
} from "../components/math/latex-renderer";
@@ -126,3 +127,56 @@ describe("splitDelimited", () => {
expect(splitDelimited("\\$ \\[ x \\]")).toEqual(expected);
});
});
+
+describe("segmentAuto", () => {
+ it("keeps pure Korean prose as a single text segment", () => {
+ const source = "어느 축구 동호회에서 12명의 선수를 배치하려고 한다.";
+ const expected: Segment[] = [{ kind: "text", value: source }];
+ expect(segmentAuto(source)).toEqual(expected);
+ });
+
+ it("extracts an undelimited equation run between Korean prose", () => {
+ expect(segmentAuto("방정식 x^{2} - 5 x + 6 = 0 의 해를 구하시오.")).toEqual([
+ { kind: "text", value: "방정식 " },
+ { kind: "inline", latex: "x^{2} - 5 x + 6 = 0" },
+ { kind: "text", value: " 의 해를 구하시오." },
+ ]);
+ });
+
+ it("extracts a LaTeX command run like \\sqrt{7}", () => {
+ expect(segmentAuto("한 변의 길이가 \\sqrt{7} 인 정사각형")).toEqual([
+ { kind: "text", value: "한 변의 길이가 " },
+ { kind: "inline", latex: "\\sqrt{7}" },
+ { kind: "text", value: " 인 정사각형" },
+ ]);
+ });
+
+ it("leaves enumerations like 'A, B, C, D' and bare numbers as text", () => {
+ const source = "학생 A, B, C, D 4명을 앞줄에 세운다. 답은 3456 이다.";
+ const expected: Segment[] = [{ kind: "text", value: source }];
+ expect(segmentAuto(source)).toEqual(expected);
+ });
+
+ it("splits trailing sentence punctuation out of a math run", () => {
+ expect(segmentAuto("따라서 x = 3, 검산 끝.")).toEqual([
+ { kind: "text", value: "따라서 " },
+ { kind: "inline", latex: "x = 3" },
+ { kind: "text", value: ", 검산 끝." },
+ ]);
+ });
+
+ it("delegates to splitDelimited when $ delimiters are present", () => {
+ expect(segmentAuto("답은 $x = 3$ 이다")).toEqual([
+ { kind: "text", value: "답은 " },
+ { kind: "inline", latex: "x = 3" },
+ { kind: "text", value: " 이다" },
+ ]);
+ });
+
+ it("preserves newlines in solution traces as text", () => {
+ const segments = segmentAuto("1단계: 경우를 나눈다\n2단계: 곱한다");
+ expect(segments).toEqual([
+ { kind: "text", value: "1단계: 경우를 나눈다\n2단계: 곱한다" },
+ ]);
+ });
+});
diff --git a/packages/web/app/app/new/export/view.tsx b/packages/web/app/app/new/export/view.tsx
index 6412668..77a2a87 100644
--- a/packages/web/app/app/new/export/view.tsx
+++ b/packages/web/app/app/new/export/view.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
-import { LatexRenderer } from "@/components/math/latex-renderer";
+import { LatexAuto } from "@/components/math/latex-renderer";
import { type Grade, type SchoolLevel, type Topic, gradeLabel } from "../topic/data";
import { verificationStorageKey } from "@/lib/verification-storage-key";
import type { ResultProblem } from "../result/types";
@@ -136,7 +136,7 @@ function ExamSheet({
{i + 1}.
-
+
))}
@@ -148,7 +148,7 @@ function ExamSheet({
{problems.map((p, i) => (
{i + 1}.
-
+
))}
diff --git a/packages/web/app/app/new/result/types.ts b/packages/web/app/app/new/result/types.ts
index 0ee73ff..71b14eb 100644
--- a/packages/web/app/app/new/result/types.ts
+++ b/packages/web/app/app/new/result/types.ts
@@ -7,7 +7,7 @@ export type ResultProblem = {
status: ResultStatus;
questionLatex: string;
answerLatex: string;
- solutionLatex: string;
+ solutionLatex: string | null;
failReason: string | null;
generationModel?: string;
refinedBy?: string[];
diff --git a/packages/web/app/app/new/result/view.tsx b/packages/web/app/app/new/result/view.tsx
index ab2e8ee..22bedc1 100644
--- a/packages/web/app/app/new/result/view.tsx
+++ b/packages/web/app/app/new/result/view.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
-import { LatexRenderer } from "@/components/math/latex-renderer";
+import { LatexAuto } from "@/components/math/latex-renderer";
import { type Grade, type SchoolLevel, type Topic, gradeLabel } from "../topic/data";
import { verificationStorageKey } from "@/lib/verification-storage-key";
import type { ResultProblem } from "./types";
@@ -142,7 +142,7 @@ function toResultProblem(problem: StoredProblem, index: number): ResultProblem {
status,
questionLatex: problem.question_latex,
answerLatex: problem.answer_latex,
- solutionLatex: problem.explanation_latex ?? "검증 파이프라인에서 생성된 문항입니다.",
+ solutionLatex: problem.explanation_latex ?? null,
failReason: status === "fail" ? "검증 실패 — 채택할 수 없습니다." : null,
generationModel: problem.generation_model,
refinedBy: problem.refined_by,
@@ -309,7 +309,13 @@ export function ResultView({
- {filters.map((f) => {
+ {filters
+ .filter(
+ (f) =>
+ f.value === "all" ||
+ displayProblems.some((p) => matchesFilter(p, f.value)),
+ )
+ .map((f) => {
const active = filter === f.value;
return (
-
-
-
-
- v2 에서 도입 예정 — 1차 MVP 에서는 비활성.
-
@@ -439,30 +412,29 @@ export function ResultView({
) : null}
-
답
-
+
-
-
- 풀이 보기
-
- ⌄
-
-
-
-
-
-
+ {p.solutionLatex !== null ? (
+
+
+ 풀이 보기
+
+ ⌄
+
+
+
+
+
+
+ ) : null}
);
diff --git a/packages/web/app/app/new/verify/view.tsx b/packages/web/app/app/new/verify/view.tsx
index 92ba3f6..0cebfd5 100644
--- a/packages/web/app/app/new/verify/view.tsx
+++ b/packages/web/app/app/new/verify/view.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
-import { LatexRenderer } from "@/components/math/latex-renderer";
+import { LatexAuto } from "@/components/math/latex-renderer";
import {
type Grade,
type SchoolLevel,
@@ -355,9 +355,9 @@ export function VerifyView({ schoolLevel, grade, topic, mode, srcRef }: Props) {
{showPreview && stream.previewLatex !== null ? (
-
) : null}
diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css
index c50a922..bf28f4f 100644
--- a/packages/web/app/globals.css
+++ b/packages/web/app/globals.css
@@ -1647,6 +1647,20 @@
margin-bottom: 12px;
}
+ /* question-stage — 산문 문항용 formula-stage 변형.
+ * 한글 문장이 주가 되므로 가운데 정렬·mono·break-all 을 풀고
+ * 자연스러운 줄바꿈(keep-all)으로 읽히게 한다. */
+ .formula-stage.question-stage {
+ display: block;
+ font-family: var(--font-sans);
+ font-size: 15.5px;
+ line-height: 1.7;
+ text-align: left;
+ word-break: keep-all;
+ overflow-wrap: anywhere;
+ overflow-x: hidden;
+ }
+
/* result-card — DESIGN.md {component.result-card} / {component.result-card-failed} */
.result-grid {
display: grid;
@@ -1822,6 +1836,9 @@
font-size: 14px;
line-height: 1.55;
color: var(--color-ink-2);
+ white-space: pre-wrap;
+ word-break: keep-all;
+ overflow-wrap: anywhere;
}
/* S6 — export layout */
diff --git a/packages/web/components/math/latex-renderer.tsx b/packages/web/components/math/latex-renderer.tsx
index 5df101e..ff7ddeb 100644
--- a/packages/web/components/math/latex-renderer.tsx
+++ b/packages/web/components/math/latex-renderer.tsx
@@ -181,3 +181,86 @@ export function LatexMixed({ source }: LatexMixedProps) {
>
);
}
+
+/* ───── LatexAuto — 구분자 없는 산문+수식 자동 분할 ─────
+ * 에이전트가 보내는 문항 본문은 한글 산문 사이에 구분자 없는 수식
+ * 조각("x^{2} - 3 x = 10", "\sqrt{7}")이 섞인 형태다. 전체를 KaTeX
+ * math mode 로 넘기면 한글 공백이 전부 사라지므로, ASCII 단어 묶음
+ * 중 수식 신호(\명령, ^{, _{, =, 피연산자 사이 연산자)가 있는 구간만
+ * 수식으로 렌더하고 나머지는 일반 텍스트로 둔다.
+ * `$...$` / `\(...\)` 구분자가 있으면 splitDelimited 에 위임.
+ */
+
+const MATH_WORD = /^[A-Za-z0-9\\^_{}+\-*/=<>().,:%|']+$/;
+const MATH_SIGNAL = /\\[a-zA-Z]+|[\^_]\{|=|[0-9A-Za-z)] ?[+\-*/] ?[0-9A-Za-z(]/;
+const TRAILING_PUNCT = /[.,]+$/;
+
+export function segmentAuto(source: string): Segment[] {
+ if (/\$|\\\(|\\\[/.test(source)) return splitDelimited(source);
+ const out: Segment[] = [];
+ let text = "";
+ const flushText = (): void => {
+ if (text.length > 0) {
+ out.push({ kind: "text", value: text });
+ text = "";
+ }
+ };
+ const tokens = source.split(/(\s+)/);
+ let i = 0;
+ while (i < tokens.length) {
+ const token = tokens[i] ?? "";
+ if (token.length === 0 || !MATH_WORD.test(token)) {
+ text += token;
+ i += 1;
+ continue;
+ }
+ // 한 칸 공백으로 이어진 수식 후보 단어들을 하나의 구간으로 묶는다
+ let end = i;
+ const words = [token];
+ while (
+ end + 2 < tokens.length &&
+ tokens[end + 1] === " " &&
+ MATH_WORD.test(tokens[end + 2] ?? "")
+ ) {
+ words.push(tokens[end + 2] ?? "");
+ end += 2;
+ }
+ const group = words.join(" ");
+ const tail = group.match(TRAILING_PUNCT)?.[0] ?? "";
+ const body = tail.length > 0 ? group.slice(0, -tail.length) : group;
+ if (body.length > 0 && MATH_SIGNAL.test(body)) {
+ flushText();
+ out.push({ kind: "inline", latex: body });
+ text += tail;
+ } else {
+ text += group;
+ }
+ i = end + 1;
+ }
+ flushText();
+ return out;
+}
+
+type LatexAutoProps = {
+ source: string;
+};
+
+export function LatexAuto({ source }: LatexAutoProps) {
+ const segments = segmentAuto(source);
+ return (
+ <>
+ {segments.map((seg, idx) => {
+ if (seg.kind === "text") {
+ return
{seg.value};
+ }
+ return (
+
+ );
+ })}
+ >
+ );
+}