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 ? (
    -
    +
    CANDIDATE PREVIEW - +
    ) : 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 ( + + ); + })} + + ); +}