diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index a55dbc8..abea692 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -84,6 +84,7 @@ export async function main(): Promise { const app = createApp({ mathEngine, + rag, workflow: { rag, mathEngine, diff --git a/packages/agent/src/server/app.ts b/packages/agent/src/server/app.ts index e60c631..faf24f2 100644 --- a/packages/agent/src/server/app.ts +++ b/packages/agent/src/server/app.ts @@ -4,12 +4,15 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import type { MathEngineClient } from "../tools/math-engine-client.js"; +import type { RagClient } from "../tools/rag-client.js"; import type { RunOptions, VerificationWorkflowDeps } from "../workflows/verification-workflow.js"; import { createGenerateRoute } from "./routes/generate.js"; import { createHealthRoute } from "./routes/health.js"; +import { createSourceProblemsRoute } from "./routes/source-problems.js"; export interface AppDeps { mathEngine: MathEngineClient; + rag: RagClient; workflow: VerificationWorkflowDeps; workflowOptions?: RunOptions; } @@ -27,6 +30,7 @@ export function createApp(deps: AppDeps): Hono { ); app.route("/", createHealthRoute(deps.mathEngine)); + app.route("/", createSourceProblemsRoute(deps.rag)); app.route("/", createGenerateRoute(deps.workflow, deps.workflowOptions)); return app; diff --git a/packages/agent/src/server/routes/source-problems.ts b/packages/agent/src/server/routes/source-problems.ts new file mode 100644 index 0000000..62f8080 --- /dev/null +++ b/packages/agent/src/server/routes/source-problems.ts @@ -0,0 +1,81 @@ +/** + * GET /api/source-problems — read-only corpus lookup. + * + * Reuses the in-memory RagClient (D-7) without LLM / generation. The web S3 + * problem picker calls this to browse SourceProblem rows by school/grade/topic. + * + * Figure-dependent rows are dropped: the corpus image path is a non-servable + * AI-Hub scan, so a statement that points at a figure/graph/table is unusable + * as text-only. See isFigureDependent for why there is no clean corpus flag. + */ + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +import { + DifficultySchema, + SchoolLevelSchema, +} from "../../schemas/source-problem.schema.js"; +import type { RagClient } from "../../tools/rag-client.js"; + +// Big k = scan whole topic (≥ largest ~1.8k rows). search() already sorts the full +// match set so this only widens the final slice; lets figure filtering fill `limit` +// on figure-heavy topics instead of returning an all-figure first-50. +const FIGURE_FILTER_POOL = 2000; + +// No reliable corpus flag for "needs a figure": media.image is on 100% of rows, and +// the VISUAL taxonomy tag also marks self-contained solids ("직육면체의 겉넓이"). So match +// only explicit references — "그림", a shown graph/도형/표 via deictic phrases, or embedded +// . Bare "그래프"/"도형"/"표"/"좌표" stay (e.g. "y=x²의 그래프의 꼭짓점" is self-contained). +const FIGURE_REFERENCE = + /그림|그래프와\s*같|(?:다음|아래|위)\s*그래프|(?:다음|아래)\s*도형|(?:다음|아래|위)의?\s*표(?:는|를|에|\s)|
{ + if (value === "common" || value === "null" || value === "") return null; + if (value === "1" || value === 1) return 1; + if (value === "2" || value === 2) return 2; + if (value === "3" || value === 3) return 3; + return value; + }, + z.union([z.literal(1), z.literal(2), z.literal(3)]).nullable(), +); + +const SourceProblemsQuerySchema = z.object({ + school_level: SchoolLevelSchema, + grade: GradeQueryParamSchema, + topic_code: z.string().min(1).optional(), + difficulty: DifficultySchema.optional(), + limit: z.coerce.number().int().min(1).max(50).default(30), +}); + +export function createSourceProblemsRoute(rag: RagClient): Hono { + const app = new Hono(); + + app.get( + "/api/source-problems", + zValidator("query", SourceProblemsQuerySchema), + async (c) => { + const q = c.req.valid("query"); + const results = await rag.search({ + school_level: q.school_level, + grade: q.grade ?? null, + topic_code: q.topic_code, + difficulty: q.difficulty, + k: FIGURE_FILTER_POOL, + }); + const problems = results + .map((r) => r.problem) + .filter((p) => !isFigureDependent(p.question_text)) + .slice(0, q.limit); + return c.json(problems); + }, + ); + + return app; +} diff --git a/packages/agent/tests/source-problems-route.test.ts b/packages/agent/tests/source-problems-route.test.ts new file mode 100644 index 0000000..7e542be --- /dev/null +++ b/packages/agent/tests/source-problems-route.test.ts @@ -0,0 +1,185 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; + +import type { + RagQuery, + RagResult, + SourceProblem, +} from "../src/schemas/index.js"; +import { + createSourceProblemsRoute, + isFigureDependent, +} from "../src/server/routes/source-problems.js"; +import type { RagClient } from "../src/tools/rag-client.js"; + +function makeProblem(overrides: { + item_id: string; + difficulty_norm: SourceProblem["difficulty_norm"]; + topic_code?: SourceProblem["topic_code"]; + school_level?: SourceProblem["school_level"]; + grade?: SourceProblem["grade"]; + question_text?: string; +}): SourceProblem { + return { + item_id: overrides.item_id, + source_dataset: "111", + split: "train", + source_label_type: "problem_label", + school_level: overrides.school_level ?? "middle", + grade: overrides.grade ?? 3, + semester: null, + topic_code: overrides.topic_code ?? "9수02-09", + topic_name: "이차방정식", + achievement_standard: "이차방정식을 풀 수 있다.", + question_text: overrides.question_text ?? `질문 ${overrides.item_id}`, + answer_text: `정답 ${overrides.item_id}`, + explanation_text: null, + choice_blocks: null, + problem_type_norm: "short_answer", + difficulty_norm: overrides.difficulty_norm, + question_image_relpath: null, + answer_image_relpath: null, + question_json_relpath: null, + answer_json_relpath: null, + }; +} + +function createMockRag(problems: SourceProblem[]): RagClient { + return { + async search(query: RagQuery): Promise { + const filtered = problems.filter((problem) => { + if (problem.school_level !== query.school_level) return false; + if (query.grade !== null && problem.grade !== query.grade) return false; + if (query.topic_code && problem.topic_code !== query.topic_code) { + return false; + } + if (query.difficulty && problem.difficulty_norm !== query.difficulty) { + return false; + } + return true; + }); + + return filtered.slice(0, query.k ?? 8).map((problem) => ({ + item_id: problem.item_id, + similarity: 0.5, + problem, + match_reason: "structural", + })); + }, + }; +} + +describe("GET /api/source-problems", () => { + const fixtures: SourceProblem[] = [ + makeProblem({ item_id: "mock-1", difficulty_norm: "easy" }), + makeProblem({ item_id: "mock-2", difficulty_norm: "medium" }), + makeProblem({ item_id: "mock-3", difficulty_norm: "hard" }), + makeProblem({ item_id: "mock-4", difficulty_norm: "hard" }), + makeProblem({ + item_id: "mock-other-topic", + difficulty_norm: "medium", + topic_code: "9수02-08", + }), + makeProblem({ + item_id: "mock-other-grade", + difficulty_norm: "easy", + grade: 2, + }), + makeProblem({ + item_id: "mock-figure", + difficulty_norm: "medium", + question_text: "다음 그림과 같이 정육면체를 잘라 낸 입체도형에서 면의 개수를 구하여라.", + }), + ]; + + function buildApp(): Hono { + const app = new Hono(); + app.route("/", createSourceProblemsRoute(createMockRag(fixtures))); + return app; + } + + it("returns source problems filtered by school_level + grade + topic_code", async () => { + const app = buildApp(); + const topic = encodeURIComponent("9수02-09"); + const res = await app.request( + `/api/source-problems?school_level=middle&grade=3&topic_code=${topic}`, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as SourceProblem[]; + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(4); + for (const item of body) { + expect(item).toHaveProperty("item_id"); + expect(item).toHaveProperty("question_text"); + expect(item).toHaveProperty("answer_text"); + expect(item).toHaveProperty("difficulty_norm"); + expect(item.topic_code).toBe("9수02-09"); + expect(item.school_level).toBe("middle"); + expect(item.grade).toBe(3); + } + }); + + it("returns 400 when school_level is missing", async () => { + const app = buildApp(); + const res = await app.request("/api/source-problems?grade=3"); + expect(res.status).toBe(400); + }); + + it("narrows results when difficulty=hard", async () => { + const app = buildApp(); + const topic = encodeURIComponent("9수02-09"); + const res = await app.request( + `/api/source-problems?school_level=middle&grade=3&topic_code=${topic}&difficulty=hard`, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as SourceProblem[]; + expect(body).toHaveLength(2); + expect(body.every((problem) => problem.difficulty_norm === "hard")).toBe(true); + }); + + it("returns [] for an unknown topic_code", async () => { + const app = buildApp(); + const topic = encodeURIComponent("9수99-99"); + const res = await app.request( + `/api/source-problems?school_level=middle&grade=3&topic_code=${topic}`, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as SourceProblem[]; + expect(body).toEqual([]); + }); + + it("excludes figure-dependent problems (statement references 그림)", async () => { + const app = buildApp(); + const topic = encodeURIComponent("9수02-09"); + const res = await app.request( + `/api/source-problems?school_level=middle&grade=3&topic_code=${topic}`, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as SourceProblem[]; + expect(body.some((p) => p.item_id === "mock-figure")).toBe(false); + expect(body.every((p) => !p.question_text.includes("그림"))).toBe(true); + }); +}); + +describe("isFigureDependent", () => { + it.each([ + ["다음 그림과 같이 정육면체를 잘라 낸 입체도형에서", true], + ["다음 그래프는 x초 후 높이 y를 나타낸 것이다.", true], + ["아래 그래프는 시간과 거리의 관계를 나타낸 것이다.", true], + ["걸린 시간과 이동 거리를 그래프와 같이 나타내면", true], + ["다음 표는 어느 반의 도수분포표이다.", true], + ["다음 도형의 둘레의 길이를 구하여라.", true], + ["성적을 정리하면
와 같다.", true], + ["모든 모서리의 합이 48이고 대각선이 3√6인 직육면체의 겉넓이를 구하시오.", false], + ["이차함수 y=2x^2+3x-2의 그래프와 x축의 교점의 x좌표를 구하시오.", false], + ["수직선 위에서 -7/5보다 큰 가장 작은 정수를 구하시오.", false], + ["a^3+b^3을 보기와 같이 변형하여 값을 구하시오.", false], + ["이차방정식 (2x+1)(x-4)=0을 풀어라.", false], + ])("%s -> %s", (text, expected) => { + expect(isFigureDependent(text)).toBe(expected); + }); +}); diff --git a/packages/web/__tests__/latex-renderer.test.ts b/packages/web/__tests__/latex-renderer.test.ts new file mode 100644 index 0000000..f540d3f --- /dev/null +++ b/packages/web/__tests__/latex-renderer.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + splitDelimited, + type Segment, +} from "../components/math/latex-renderer"; + +/* + * splitDelimited — char-by-char scanner. + * + * Dispatch order: \$ (escape) → $$ → $ → \[ → \( → text. + * Unmatched openers mirror behavior of unmatched `$`: dump the rest into the + * trailing text buffer (one merged text segment). + * + * Baseline probed against the current implementation (cases 5, 11): + * - splitDelimited("") === [] + * - splitDelimited("a $b") === [{ kind: "text", value: "a $b" }] + * + * The new `\[` / `\(` branches must mirror that exact shape on unmatched + * close — see case 11. + */ + +describe("splitDelimited", () => { + it("case 1: $x$ → one inline 'x'", () => { + const expected: Segment[] = [{ kind: "inline", latex: "x" }]; + expect(splitDelimited("$x$")).toEqual(expected); + }); + + it("case 2: $$x$$ → one block 'x'", () => { + const expected: Segment[] = [{ kind: "block", latex: "x" }]; + expect(splitDelimited("$$x$$")).toEqual(expected); + }); + + it("case 3: \\$5 → text '$5' (escape preserved)", () => { + const expected: Segment[] = [{ kind: "text", value: "$5" }]; + expect(splitDelimited("\\$5")).toEqual(expected); + }); + + it("case 4: foo → text 'foo'", () => { + const expected: Segment[] = [{ kind: "text", value: "foo" }]; + expect(splitDelimited("foo")).toEqual(expected); + }); + + it("case 5: empty string → [] (baseline behavior)", () => { + const expected: Segment[] = []; + expect(splitDelimited("")).toEqual(expected); + }); + + it("case 6: \\[ x = 1 \\] → one block ' x = 1 '", () => { + const expected: Segment[] = [{ kind: "block", latex: " x = 1 " }]; + expect(splitDelimited("\\[ x = 1 \\]")).toEqual(expected); + }); + + it("case 7: \\( x \\) → one inline ' x '", () => { + const expected: Segment[] = [{ kind: "inline", latex: " x " }]; + expect(splitDelimited("\\( x \\)")).toEqual(expected); + }); + + it("case 8: array env inside \\[ ... \\] — row-break '\\\\' preserved", () => { + // Source: \[ \begin{array}{l}a\\b\end{array} \] + // Expected block latex: " \begin{array}{l}a\\b\end{array} " + const expected: Segment[] = [ + { kind: "block", latex: " \\begin{array}{l}a\\\\b\\end{array} " }, + ]; + expect( + splitDelimited("\\[ \\begin{array}{l}a\\\\b\\end{array} \\]"), + ).toEqual(expected); + }); + + it("case 9: $a$ and \\[ b \\] → inline 'a' + text ' and ' + block ' b '", () => { + const expected: Segment[] = [ + { kind: "inline", latex: "a" }, + { kind: "text", value: " and " }, + { kind: "block", latex: " b " }, + ]; + expect(splitDelimited("$a$ and \\[ b \\]")).toEqual(expected); + }); + + it("case 10: \\( x \\) and \\[ y \\] → inline ' x ' + text ' and ' + block ' y '", () => { + const expected: Segment[] = [ + { kind: "inline", latex: " x " }, + { kind: "text", value: " and " }, + { kind: "block", latex: " y " }, + ]; + expect(splitDelimited("\\( x \\) and \\[ y \\]")).toEqual(expected); + }); + + it("case 11: unmatched \\[ → mirror $-unmatched (one merged text segment)", () => { + // Source: "hello \[ x = 1" + // Baseline for $-unmatched ("a $b"): [{ kind: "text", value: "a $b" }] + // → New \[-unmatched must produce ONE text segment containing entire string. + const expected: Segment[] = [{ kind: "text", value: "hello \\[ x = 1" }]; + expect(splitDelimited("hello \\[ x = 1")).toEqual(expected); + }); + + it("case 12: \\[ \\text{ 두 복소수 } z_1 \\] → one block with Korean \\text intact", () => { + const expected: Segment[] = [ + { kind: "block", latex: " \\text{ 두 복소수 } z_1 " }, + ]; + expect(splitDelimited("\\[ \\text{ 두 복소수 } z_1 \\]")).toEqual(expected); + }); + + it("case 13: stray \\] → text '\\] stray' (no opener, plain text)", () => { + const expected: Segment[] = [{ kind: "text", value: "\\] stray" }]; + expect(splitDelimited("\\] stray")).toEqual(expected); + }); + + it("case 14: \\[ a \\\\ b \\] → one block ' a \\\\ b ' (row-break does not close)", () => { + // Source chars: \[ ' a ' \\ ' b ' \] + // The \\ at positions 5-6 must not be mistaken for \]. + const expected: Segment[] = [{ kind: "block", latex: " a \\\\ b " }]; + expect(splitDelimited("\\[ a \\\\ b \\]")).toEqual(expected); + }); + + it("case 15: \\[ a \\( b \\) c \\] → outer block consumes inner verbatim", () => { + const expected: Segment[] = [ + { kind: "block", latex: " a \\( b \\) c " }, + ]; + expect(splitDelimited("\\[ a \\( b \\) c \\]")).toEqual(expected); + }); + + it("case 16: \\$ \\[ x \\] → text '$ ' + block ' x ' (escape + new delim coexist)", () => { + const expected: Segment[] = [ + { kind: "text", value: "$ " }, + { kind: "block", latex: " x " }, + ]; + expect(splitDelimited("\\$ \\[ x \\]")).toEqual(expected); + }); +}); diff --git a/packages/web/__tests__/verification-storage-key.test.ts b/packages/web/__tests__/verification-storage-key.test.ts new file mode 100644 index 0000000..6859eca --- /dev/null +++ b/packages/web/__tests__/verification-storage-key.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { verificationStorageKey } from "../lib/verification-storage-key"; + +describe("verificationStorageKey", () => { + it("joins fields with | for middle grade 3 with sourceItemId", () => { + expect( + verificationStorageKey({ + grade: 3, + schoolLevel: "middle", + topic: "9수02-09", + topicName: "이차방정식", + mode: "structural", + sourceItemId: "item-abc-123", + }), + ).toBe( + "openmath:verification-result|3|middle|9수02-09|이차방정식|structural|item-abc-123", + ); + }); + + it("renders null grade as empty slot (Array.prototype.join semantics)", () => { + expect( + verificationStorageKey({ + grade: null, + schoolLevel: "high", + topic: "10공수01-04", + topicName: "복소수와 이차방정식", + mode: "conceptual", + sourceItemId: "high-1", + }), + ).toBe( + "openmath:verification-result||high|10공수01-04|복소수와 이차방정식|conceptual|high-1", + ); + }); + + it("produces the same key for the same inputs (deterministic)", () => { + const args = { + grade: 1 as const, + schoolLevel: "middle" as const, + topic: "9수02-03", + topicName: "일차방정식", + mode: "structural" as const, + sourceItemId: "x", + }; + expect(verificationStorageKey(args)).toBe(verificationStorageKey(args)); + }); + + it("differs when sourceItemId changes (writer vs reader consistency)", () => { + const base = { + grade: 2 as const, + schoolLevel: "middle" as const, + topic: "9수02-07", + topicName: "연립일차방정식", + mode: "structural" as const, + }; + expect( + verificationStorageKey({ ...base, sourceItemId: "a" }), + ).not.toBe(verificationStorageKey({ ...base, sourceItemId: "b" })); + }); + + it("differs when mode toggles structural ↔ conceptual", () => { + const base = { + grade: 3 as const, + schoolLevel: "middle" as const, + topic: "9수03-04", + topicName: "이차함수와 그래프", + sourceItemId: "x-1", + }; + expect( + verificationStorageKey({ ...base, mode: "structural" }), + ).not.toBe(verificationStorageKey({ ...base, mode: "conceptual" })); + }); + + it("starts with the openmath:verification-result namespace prefix", () => { + const key = verificationStorageKey({ + grade: 1, + schoolLevel: "middle", + topic: "9수01-01", + topicName: "소인수분해", + mode: "structural", + sourceItemId: "src-1", + }); + expect(key.startsWith("openmath:verification-result|")).toBe(true); + }); +}); diff --git a/packages/web/app/app/new/export/page.tsx b/packages/web/app/app/new/export/page.tsx index df737ca..54770ea 100644 --- a/packages/web/app/app/new/export/page.tsx +++ b/packages/web/app/app/new/export/page.tsx @@ -14,9 +14,8 @@ type Props = { school?: string | string[]; topic?: string | string[]; mode?: string | string[]; - dims?: string | string[]; + srcRef?: string | string[]; adopted?: string | string[]; - source?: string | string[]; }; }; @@ -40,13 +39,12 @@ export default function ExportPage({ searchParams }: Props) { const grade = parseGrade(searchParams.grade); const topic = findTopic(pickFirst(searchParams.topic)); const mode = parseMode(pickFirst(searchParams.mode)); - const dims = parseList(pickFirst(searchParams.dims)); + const srcRef = pickFirst(searchParams.srcRef) ?? ""; const adoptedIds = parseList(pickFirst(searchParams.adopted)); - const sourceProblemText = pickFirst(searchParams.source) ?? ""; let problems: ResultProblem[] = []; if (topic !== null && mode !== null) { - const all = generateMockResults(topic, mode, dims); + const all = generateMockResults(topic, mode); problems = all.filter((p) => adoptedIds.includes(p.id)); } @@ -56,8 +54,7 @@ export default function ExportPage({ searchParams }: Props) { schoolLevel={schoolLevel} topic={topic} mode={mode} - dims={dims} - sourceProblemText={sourceProblemText} + sourceItemId={srcRef} adoptedIds={adoptedIds} problems={problems} /> diff --git a/packages/web/app/app/new/export/view.tsx b/packages/web/app/app/new/export/view.tsx index 6ec8037..e6d3760 100644 --- a/packages/web/app/app/new/export/view.tsx +++ b/packages/web/app/app/new/export/view.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { LatexRenderer } 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/mock"; function buildDefaultTitle( @@ -20,8 +21,7 @@ type Props = { grade: Grade | null; topic: Topic | null; mode: "structural" | "conceptual" | null; - dims: string[]; - sourceProblemText: string; + sourceItemId: string; adoptedIds: string[]; problems: ResultProblem[]; }; @@ -57,33 +57,12 @@ function shuffleArray(arr: readonly T[]): T[] { return out; } -function storageKey( - schoolLevel: SchoolLevel, - grade: Grade | null, - topic: Topic, - mode: "structural" | "conceptual", - dims: string[], - sourceProblemText: string, -): string { - return [ - "openmath:verification-result", - grade, - schoolLevel, - topic.code, - topic.name, - mode, - [...dims].sort().join(","), - sourceProblemText, - ].join("|"); -} - function resultHref( schoolLevel: SchoolLevel, grade: Grade | null, topic: Topic, mode: "structural" | "conceptual" | null, - dims: readonly string[], - sourceProblemText: string, + sourceItemId: string, ): string { const params = new URLSearchParams({ grade: grade === null ? "common" : String(grade), @@ -91,8 +70,7 @@ function resultHref( topic: topic.code, }); if (mode !== null) params.set("mode", mode); - if (dims.length > 0) params.set("dims", [...dims].join(",")); - if (sourceProblemText.length > 0) params.set("source", sourceProblemText); + if (sourceItemId.length > 0) params.set("srcRef", sourceItemId); return `/app/new/result?${params.toString()}`; } @@ -117,9 +95,8 @@ function parseStoredProblem(raw: unknown): StoredProblem | null { }; } -function toResultProblem(problem: StoredProblem, index: number, dims: string[]): ResultProblem { +function toResultProblem(problem: StoredProblem, index: number): ResultProblem { const status = problem.verification_status === "partial" ? "warn" : problem.verification_status; - const missingDims = dims.filter((dim) => !problem.preserved_dimensions.includes(dim)); return { id: problem.id, number: index + 1, @@ -128,8 +105,6 @@ function toResultProblem(problem: StoredProblem, index: number, dims: string[]): questionLatex: problem.question_latex, answerLatex: problem.answer_latex, solutionLatex: problem.explanation_latex ?? "검증 파이프라인에서 생성된 문항입니다.", - preservedDims: problem.preserved_dimensions, - missingDims: status === "warn" ? missingDims : [], failReason: status === "fail" ? "검증 실패 — 채택할 수 없습니다." : null, }; } @@ -188,8 +163,7 @@ export function ExportView({ grade, topic, mode, - dims, - sourceProblemText, + sourceItemId, adoptedIds, problems, }: Props) { @@ -212,21 +186,37 @@ export function ExportView({ useEffect(() => { setDisplayProblems(problems); if ((schoolLevel === "middle" && grade === null) || topic === null || mode === null) return; + if (sourceItemId === "") return; + let raw: string | null = null; try { - const raw = window.sessionStorage.getItem( - storageKey(schoolLevel, grade, topic, mode, dims, sourceProblemText), + raw = window.sessionStorage.getItem( + verificationStorageKey({ + grade, + schoolLevel, + topic: topic.code, + topicName: topic.name, + mode, + sourceItemId, + }), ); - if (raw === null) return; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return; - const stored = parsed.map(parseStoredProblem); - if (stored.some((p) => p === null)) return; - const mapped = stored.map((p, index) => toResultProblem(p as StoredProblem, index, dims)); - setDisplayProblems(mapped.filter((p) => adoptedIds.includes(p.id))); - } catch { - setDisplayProblems(problems); + } catch (err) { + console.warn("[export] sessionStorage read failed:", err); + return; + } + if (raw === null) return; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + console.warn("[export] JSON parse failed:", err); + return; } - }, [grade, topic, mode, dims, sourceProblemText, adoptedIds, problems]); + if (!Array.isArray(parsed)) return; + const stored = parsed.map(parseStoredProblem); + if (stored.some((p) => p === null)) return; + const mapped = stored.map((p, index) => toResultProblem(p as StoredProblem, index)); + setDisplayProblems(mapped.filter((p) => adoptedIds.includes(p.id))); + }, [grade, topic, mode, sourceItemId, adoptedIds, problems, schoolLevel]); /* afterprint — 사용자가 시스템 print dialog 를 닫은 시점. * 저장/취소 여부는 브라우저 API 가 노출하지 않으므로 일괄 "완료" 로 표기. @@ -269,7 +259,7 @@ export function ExportView({ } if (displayProblems.length === 0) { - const backHref = resultHref(schoolLevel, grade, topic, mode, dims, sourceProblemText); + const backHref = resultHref(schoolLevel, grade, topic, mode, sourceItemId); return ( <>