From f09418b06a8f802491a811327df2a412d3bf518a Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Wed, 10 Jun 2026 18:32:43 +0900 Subject: [PATCH 1/6] Add source-problems read endpoint --- packages/agent/src/index.ts | 1 + packages/agent/src/server/app.ts | 4 + .../src/server/routes/source-problems.ts | 81 ++++++++ .../agent/tests/source-problems-route.test.ts | 185 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 packages/agent/src/server/routes/source-problems.ts create mode 100644 packages/agent/tests/source-problems-route.test.ts 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); + }); +}); From 2f4882c4f220abe81c290bd6fda4b6e875acdd80 Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Wed, 10 Jun 2026 18:32:44 +0900 Subject: [PATCH 2/6] Add corpus fetch and verification storage-key helpers --- .../verification-storage-key.test.ts | 84 +++++++++ packages/web/lib/source-problems-client.ts | 166 ++++++++++++++++++ packages/web/lib/verification-storage-key.ts | 22 +++ packages/web/tsconfig.json | 2 +- 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 packages/web/__tests__/verification-storage-key.test.ts create mode 100644 packages/web/lib/source-problems-client.ts create mode 100644 packages/web/lib/verification-storage-key.ts 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/lib/source-problems-client.ts b/packages/web/lib/source-problems-client.ts new file mode 100644 index 0000000..f09684a --- /dev/null +++ b/packages/web/lib/source-problems-client.ts @@ -0,0 +1,166 @@ +/* ───────────────────────────────────────────────────────────── + * source-problems-client — agent `/api/source-problems` 의 fetch 래퍼. + * + * Dual-use: + * (1) Server Component (intent/page.tsx) 가 SSR 초기 30개 fetch. + * (2) Client component (intent/picker.tsx) 가 difficulty 칩 클릭마다 refetch. + * + * 두 경우 모두 `fetch` + `process.env.NEXT_PUBLIC_AGENT_URL` 만 사용 — Next + * 가 NEXT_PUBLIC_ 변수를 클라이언트 번들에 인라인하므로 별도 marker 불필요. + * CORS 는 agent 가 :27182 에서 GET 허용. + * + * 본 모듈은 throw 하지 않는다. agent 부재·404·malformed body 모두 + * 빈 배열 + warn. UI 가 empty-state 또는 retry 를 자체적으로 그린다. + * ──────────────────────────────────────────────────────────── */ + +export type Difficulty = "easy" | "medium" | "hard"; + +export type SourceProblem = { + item_id: string; + question_text: string; + answer_text: string; + topic_name: string; + topic_code: string | null; + difficulty_norm: Difficulty; + problem_type_norm: string; + explanation_text: string | null; + choice_blocks: string[] | null; +}; + +function getAgentBaseUrl(): string { + const env = + typeof process !== "undefined" ? process.env.NEXT_PUBLIC_AGENT_URL : undefined; + return (env ?? "http://localhost:31415").replace(/\/$/, ""); +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isDifficulty(v: unknown): v is Difficulty { + return v === "easy" || v === "medium" || v === "hard"; +} + +function asStringArrayOrNull(v: unknown): string[] | null { + if (v === null) return null; + if (!Array.isArray(v)) return null; + const out: string[] = []; + for (const item of v) { + if (typeof item !== "string") return null; + out.push(item); + } + return out; +} + +function asSourceProblem(raw: unknown): SourceProblem | null { + if (!isObject(raw)) return null; + const { + item_id, + question_text, + answer_text, + topic_name, + topic_code, + difficulty_norm, + problem_type_norm, + explanation_text, + choice_blocks, + } = raw; + + if (typeof item_id !== "string" || item_id.length === 0) return null; + if (typeof question_text !== "string") return null; + if (typeof answer_text !== "string") return null; + if (typeof topic_name !== "string") return null; + if (topic_code !== null && typeof topic_code !== "string") return null; + if (!isDifficulty(difficulty_norm)) return null; + if (typeof problem_type_norm !== "string") return null; + if (explanation_text !== null && typeof explanation_text !== "string") return null; + + const blocks = + choice_blocks === undefined ? null : asStringArrayOrNull(choice_blocks); + /* choice_blocks 는 string[] | null 이어야 하므로 undefined 도 null 로 강등. */ + + return { + item_id, + question_text, + answer_text, + topic_name, + topic_code, + difficulty_norm, + problem_type_norm, + explanation_text, + choice_blocks: blocks, + }; +} + +type GetArgs = { + schoolLevel: string; + grade: number | null; + topicCode: string; + difficulty?: Difficulty; + limit?: number; +}; + +export async function getSourceProblems( + args: GetArgs, +): Promise { + const params = new URLSearchParams(); + params.set("school_level", args.schoolLevel); + params.set("grade", args.grade === null ? "null" : String(args.grade)); + params.set("topic_code", args.topicCode); + if (args.difficulty !== undefined) { + params.set("difficulty", args.difficulty); + } + const limit = args.limit === undefined ? 30 : args.limit; + params.set("limit", String(limit)); + + const url = `${getAgentBaseUrl()}/api/source-problems?${params.toString()}`; + + let response: Response; + try { + response = await fetch(url, { cache: "no-store" }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn(`[source-problems] fetch failed for ${url}: ${reason}`); + return []; + } + + if (!response.ok) { + console.warn( + `[source-problems] non-2xx (${response.status} ${response.statusText}) for ${url}`, + ); + return []; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.warn(`[source-problems] JSON parse failed for ${url}: ${reason}`); + return []; + } + + if (!Array.isArray(payload)) { + console.warn( + `[source-problems] expected array, received ${typeof payload} for ${url}`, + ); + return []; + } + + const out: SourceProblem[] = []; + let skipped = 0; + for (const item of payload) { + const sp = asSourceProblem(item); + if (sp === null) { + skipped += 1; + continue; + } + out.push(sp); + } + if (skipped > 0) { + console.warn( + `[source-problems] skipped ${skipped}/${payload.length} malformed entries for ${url}`, + ); + } + return out; +} diff --git a/packages/web/lib/verification-storage-key.ts b/packages/web/lib/verification-storage-key.ts new file mode 100644 index 0000000..15b33ec --- /dev/null +++ b/packages/web/lib/verification-storage-key.ts @@ -0,0 +1,22 @@ +export type VerificationStorageKeyParts = { + grade: 1 | 2 | 3 | null; + schoolLevel: "middle" | "high"; + topic: string; + topicName: string; + mode: "structural" | "conceptual"; + sourceItemId: string; +}; + +export function verificationStorageKey( + p: VerificationStorageKeyParts, +): string { + return [ + "openmath:verification-result", + p.grade, + p.schoolLevel, + p.topic, + p.topicName, + p.mode, + p.sourceItemId, + ].join("|"); +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 1ee9409..3847f1b 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -24,5 +24,5 @@ "**/*.tsx", ".next/types/**/*.ts" ], - "exclude": ["node_modules", ".next"] + "exclude": ["node_modules", ".next", "__tests__"] } From 3ea47dacf6a202cd9bdd549bf1fd965b64a1bb87 Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Wed, 10 Jun 2026 18:32:45 +0900 Subject: [PATCH 3/6] Replace intent dimensions with corpus-backed problem picker --- packages/web/app/app/new/export/page.tsx | 11 +- packages/web/app/app/new/export/view.tsx | 82 ++-- packages/web/app/app/new/intent/page.tsx | 39 +- packages/web/app/app/new/intent/picker.tsx | 445 +++++++++++------- packages/web/app/app/new/result/mock.ts | 25 +- packages/web/app/app/new/result/page.tsx | 21 +- packages/web/app/app/new/result/view.tsx | 103 ++-- packages/web/app/app/new/topic/data.ts | 157 ------ packages/web/app/app/new/verify/page.tsx | 17 +- packages/web/app/app/new/verify/view.tsx | 131 ++++-- packages/web/app/globals.css | 120 ++--- packages/web/hooks/use-verification-stream.ts | 41 +- 12 files changed, 547 insertions(+), 645 deletions(-) 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 ( <>