Skip to content

feat: 첨부 문제 동형 출제(이 문제처럼) + UI 용어 정리#26

Merged
smilebank7 merged 4 commits into
mainfrom
feat/attach-problem-flow
Jun 11, 2026
Merged

feat: 첨부 문제 동형 출제(이 문제처럼) + UI 용어 정리#26
smilebank7 merged 4 commits into
mainfrom
feat/attach-problem-flow

Conversation

@smilebank7

@smilebank7 smilebank7 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

무엇을

지금까지는 DB 예시에서 고른 문제로만 동형 출제가 가능했는데, 사용자가 가진 문제를 직접 첨부(텍스트 붙여넣기 / 사진)해서 같은 유형의 문제를 만드는 플로우를 추가합니다 — "이 문제처럼".

핵심 설계

source_problem_text 한 필드가 이미 검색 → 생성 → 목표매핑을 관통합니다(예시에서 고른 문제도 이 필드로 변형됨). 따라서 이번 작업은 생성 파이프라인 재설계가 아니라, 그 필드를 채우는 "수집 레이어"를 새로 붙이는 것입니다. 6단계 검증 파이프라인은 무변경.

백엔드 — POST /api/extract

  • extractor-agent: 이미지(비전 모델) · 텍스트 → question_text / choices / answer / figure_dependent / confidence
  • classifier-agent: 추출 문제 → 교육과정 카탈로그(43단원)로 학년·단원 자동 인식 후 코드 스냅(코드 → 이름 → 폴백, 확신 강등)
  • 그림 의존 · 저신뢰 · 단원 불확실은 warnings 로 사용자에게 평문 안내
  • 비전 모델은 EXTRACT_MODEL env 로 분리 가능(미설정 시 LLM_MODEL 재사용). 모델 미설정 시 503, 텍스트 경로는 항상 동작

프론트 — /app/new/attach

  • 단일 화면 3단계: 입력(텍스트/사진 토글) → 문제 읽기 → 확인·수정(KaTeX 미리보기 + 학년·단원·유형 드롭다운 + 대안 단원 칩 + 경고)
  • 확인 후 기존 openmath:intent-source + verify URL 계약으로 합류 → 결과·PDF 화면 그대로 재사용
  • S0 "이 문제처럼" 카드 활성화

UI 내부 용어 평문화

사용자에게 안 와닿는 내부 용어 제거(요청 반영):

  • corpus → 예시·기출/교과 문제, LLM → AI, RAG 검색 → 비슷한 문제 찾기, 의도 추출 → 출제 의도 분석
  • SymPy · 동형 은 브랜드/신뢰 용어로 의도적 유지

검증

  • 백엔드: extract 단위 12 + 전체 275 테스트 통과, typecheck 0
  • 웹: typecheck 0, next build 성공(/app/new/attach 라우트 생성 확인)
  • math-engine: pytest 36 통과(무변경)

후속(범위 밖)

  • PDF 업로드: 1페이지 래스터화 → 동일 비전 경로로 추가 예정
  • 이미지 OCR 품질은 배포된 비전 모델에 의존 — 운영에서 EXTRACT_MODEL 설정 필요

🤖 Generated with Claude Code


Summary by cubic

Adds “이 문제처럼” so users can paste text or upload an image, we read the problem, auto-detect grade/topic, and create an isomorphic problem. The attached flow prefers the problem’s inferred kind and continues even with zero corpus matches; follow-up fixes harden kind gating, SSE cache keys, alternative dedup, and error messaging.

  • New Features

    • Backend: POST /api/extract reads text/image → extraction + catalog classification with alternatives and warnings. Classification failure returns 200 with an empty topic for manual pick. Classifier emits generation_kind; generator uses it only when source_origin: "attached" and adds an attached prompt block so attached problems override refs. RAG no longer aborts on 0 refs for attached flow; SSE shows “비슷한 예시 없음 — 첨부 문제만으로 변형”. Unsupported image error lists allowed types (PNG · JPG · WEBP · HEIC).
    • Frontend: /app/new/attach 3-step flow (input → read → confirm/edit) leaves the topic unselected when confidence < 0.5, and passes source_origin + generation_kind to verify. SSE input key now includes sourceOrigin and generationKind to avoid stale runs. UI wording updated (“비슷한 문제 찾기”, “출제 의도 분석”, “학습 목표 점검”). Workspace card for “이 문제처럼” is active.
  • Migration

    • Set EXTRACT_MODEL to a vision-capable model for image uploads; falls back to LLM_MODEL when unset.
    • Callers may send source_origin: "attached" and optional generation_kind in POST /api/generate when coming from the attach flow. Ensure NEXT_PUBLIC_AGENT_URL points to the agent service. No DB changes.

Written for commit c6d8b4e. Summary will update on new commits.

Review in cubic

DB 예시 선택 대신 사용자가 가진 문제를 첨부해 동형 출제하는 플로우 추가.

백엔드 (POST /api/extract):
- extractor-agent: 이미지(비전)·텍스트 → question_text/choices/answer/figure_dependent
- classifier-agent: 추출 문제 → 교육과정 카탈로그(43단원) 자동 인식·스냅
- source_problem_text 는 이미 검색/생성/목표매핑을 관통 → 기존 6단계 파이프라인 무변경

프론트 (/app/new/attach):
- 입력(텍스트/이미지) → 문제 읽기 → 학년·단원 자동 인식 → 확인·수정 → verify 합류
- S0 '이 문제처럼' 카드 활성화

UI 내부 용어 평문화:
- corpus → 예시·기출/교과, LLM → AI, RAG 검색 → 비슷한 문제 찾기,
  의도 추출 → 출제 의도 분석 (SymPy·동형 은 브랜드 용어로 유지)

검증: 백엔드 extract 12 + 전체 275 테스트 통과, 웹 typecheck·build 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 25 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/web/app/app/new/attach/view.tsx">

<violation number="1" location="packages/web/app/app/new/attach/view.tsx:372">
P3: Screen-reader helper text gives a wrong requirement ("학년을 선택하세요") even when grade is not required or the action is already valid.</violation>

<violation number="2" location="packages/web/app/app/new/attach/view.tsx:398">
P3: The mode toggle uses incomplete/mixed ARIA tab semantics, which can confuse assistive technology.</violation>
</file>

<file name="packages/agent/src/server/routes/extract.ts">

<violation number="1" location="packages/agent/src/server/routes/extract.ts:62">
P3: The media-type error message is inconsistent with actual allowed types (HEIC/HEIF are accepted but not mentioned).</violation>
</file>

<file name="packages/agent/src/agents/classifier-agent.ts">

<violation number="1" location="packages/agent/src/agents/classifier-agent.ts:80">
P3: Alternative topics are not deduplicated, so repeated LLM candidates can propagate duplicate UI options.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/agent/src/server/routes/extract.ts Outdated
Comment thread packages/agent/src/agents/classifier-agent.ts
Comment thread packages/agent/src/index.ts Outdated
Comment thread packages/web/app/app/new/attach/view.tsx
Comment thread packages/web/app/app/new/attach/view.tsx
Comment thread packages/agent/src/server/routes/extract.ts Outdated
Comment thread packages/agent/src/agents/classifier-agent.ts Outdated
리뷰 피드백 반영:
- P1: 분류 실패 시 502 대신 단원 빈 채 200 반환 → 추출 결과 보존하고
  확인 화면에서 수동 선택 가능 (manualPickClassification).
- P2: 카탈로그 밖일 때 학교급을 raw 코드로만 추정하지 않고, 유효한
  대안이 있으면 그 첫 번째로 강등 매칭 (실제 학교급·학년 사용).
- P3: extractor/solver 모델 해석 중복을 buildAgentModel 헬퍼로 통합.

테스트 +2 (분류 실패 200, 대안 강등 매칭), 전체 277 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/agent/src/agents/classifier-agent.ts Outdated
첨부 플로우 견고화 (전부 source_origin=attached 게이트, 코퍼스 플로우 불변):

- B: generation_kind 를 토픽이 아니라 첨부 문제에서 추론(분류기 출력) →
  토픽 오분류로부터 문제 '종류' 보호. generator 가 토픽 파생값보다 우선 사용.
- A: 생성기 프롬프트에 첨부 우선 블록 — refs 와 충돌 시 첨부 문제를 따르고,
  refs 가 비거나 동떨어져도 첨부 문제만으로 변형.
- D: 코퍼스 매칭 0개여도 중단 안 함. 기존엔 RAG 0 refs 가 치명적 에러로
  생성을 abort 했으나, 첨부 source-only 는 진행(intent seed 폴백을 refs
  없이도 동작하게 수정). '비슷한 예시 없음' 솔직 표시.
- C: 확인 화면에서 자동 인식 확신 낮으면 단원 미리 선택 안 하고 강제 확인.
- 부수: 화면 step 이름 평문화(RAG 검색 → 비슷한 문제 찾기 등).

테스트 +3 (kind override, 분류기 kind, 첨부 source-only 비중단),
전체 280 단위 + 6 통합 통과. 웹 typecheck·build 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 17 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/agent/src/agents/generator-agent.ts Outdated
Comment thread packages/web/hooks/use-verification-stream.ts
cubic 2차 리뷰 반영:
- P2: SSE inputKey 에 sourceOrigin·generationKind 추가 — 이 필드만 바뀔 때
  재요청이 누락되던 캐시키 버그 수정.
- P2: generation_kind override 를 source_origin === 'attached' 에만 적용.
- P3: 분류기 대안 topic_code 중복 제거 + promoted 폴백을 slice 대신 코드 필터.
- P3: extract 415 메시지를 허용 형식(HEIC 포함)과 일치하게 수정.

전체 280 테스트 통과, 에이전트·웹 typecheck 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@smilebank7 smilebank7 merged commit 62f2a41 into main Jun 11, 2026
5 checks passed

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 36 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/agent/src/agents/classifier-agent.ts">

<violation number="1" location="packages/agent/src/agents/classifier-agent.ts:31">
P2: The prompt instructs the LLM to return at most 2 alternatives, but the `LlmClassificationSchema` does not enforce this limit on the `alternatives` array. If the model returns more than 2 entries, the frontend could render an unbounded number of chip buttons, breaking the UI contract. Consider adding `.max(2)` to the Zod array definition to make the schema match the prompt instruction and protect the UI boundary.</violation>

<violation number="2" location="packages/agent/src/agents/classifier-agent.ts:122">
P2: Name-fallback classifications are capped at exactly 0.5, but the extract route warns only when `classification.confidence < 0.5` (strict). This means users won't be warned when the classifier had to fall back from an invalid code to a name match, even though that path is explicitly treated as uncertain ("약간 강등"). For consistency with the alternative-promotion (0.4) and no-match (0.3) paths — both of which correctly trigger the warning — the name-fallback cap should be below the threshold, or the comparison should include the boundary.</violation>
</file>

<file name="packages/agent/src/server/routes/extract.ts">

<violation number="1" location="packages/agent/src/server/routes/extract.ts:34">
P2: HEIC/HEIF are accepted and advertised without evidence the vision model path supports those raw formats. The extractor passes raw bytes directly to the AI SDK's `generateObject`, which forwards them to the configured vision model. Most common vision APIs do not support HEIC/HEIF natively, so users uploading these formats will pass validation but hit a generic 502 extraction failure. Consider either removing these MIME types from `ALLOWED_IMAGE_TYPES` or adding transcoding to a supported format (e.g. JPEG) before passing to the model.</violation>

<violation number="2" location="packages/agent/src/server/routes/extract.ts:71">
P2: The `extractor.extract()` and `classifier.classify()` calls do not propagate the request's `AbortSignal`, so client disconnects cannot cancel long-running LLM/vision-model calls. This wastes server resources and LLM capacity, especially for the image-extraction path which can be slow and expensive.

Both agents already accept an optional `signal` and forward it to `generateObject`, so the route only needs to pass it through:
- `extractor.extract({ ..., signal: c.req.raw.signal })`
- `classifier.classify({ extraction, signal: c.req.raw.signal })`</violation>
</file>

<file name="packages/agent/src/schemas/curriculum-topics.ts">

<violation number="1" location="packages/agent/src/schemas/curriculum-topics.ts:4">
P2: The `CURRICULUM_TOPICS` catalog duplicates the same 43 topic codes and names already defined in the frontend (`packages/web/app/app/new/topic/data.ts`) and also referenced by `TOPIC_KIND_BY_CODE` in the agent package. Maintaining three separate hardcoded copies across packages is brittle: a rename, addition, or removal in one location can silently drift from the others and break the attach-problem classification flow. The comment claims synchronization is guaranteed by tests, but the existing extract tests only validate internal properties (unique codes, generation-kind mapping) and do not assert cross-package equality with the frontend topic data or the generation-kind code set. Consider extracting the shared code↔name catalog into a shared package (or at minimum adding a cross-package snapshot/sync test) so all three consumers stay consistent automatically.</violation>

<violation number="2" location="packages/agent/src/schemas/curriculum-topics.ts:89">
P2: The `findCurriculumTopicByName` fallback uses a loose bidirectional substring match that returns the first array match. If the classifier emits a vague name such as "방정식", "함수", or "인수분해", this silently maps to an arbitrary first topic (e.g., "일차방정식" before "이차방정식") rather than surfacing ambiguity to the user. Consider narrowing the fallback so the trimmed input must contain the full topic name (`trimmed.includes(topic.name)`). This prevents short, ambiguous substrings from silently snapping to the wrong curriculum topic while still matching longer LLM outputs that embed the exact topic name.</violation>
</file>

<file name="packages/web/app/app/new/attach/view.tsx">

<violation number="1" location="packages/web/app/app/new/attach/view.tsx:366">
P2: The create button always references `create-reason` via `aria-describedby`, but the description text becomes '학년을 선택하세요.' whenever the button is in an enabled state (question text and topic are both set). This means screen-reader users hear an actionable button described as blocked by a missing grade—even when the grade is already selected (middle school) or intentionally null (high school, where no grade selector exists). Consider conditionally attaching `aria-describedby` only when `!canCreate`, or adjusting the description logic to match the actual disabled reason.</violation>
</file>

<file name="packages/web/hooks/use-verification-stream.ts">

<violation number="1" location="packages/web/hooks/use-verification-stream.ts:370">
P2: The `inputKey` was correctly updated to treat `sourceOrigin` and `generationKind` as stream identifiers, but the sessionStorage key used to persist and read back results still omits both fields. If a user generates first with an attached problem and then with a corpus source (or with different `generationKind` values) while keeping the same topic, grade, and mode, the second run will overwrite the first one's cached results in sessionStorage. The result and export pages will then retrieve the wrong cached data because they use the same `verificationStorageKey` that doesn't distinguish these dimensions.

To fix this, `VerificationStorageKeyParts` and `verificationStorageKey` should include `sourceOrigin` and `generationKind`, and all callers — the `setItem` here, plus the `getItem` calls in `result/view.tsx` and `export/view.tsx` — should pass them.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

problem_type: ProblemTypeSchema,
difficulty: DifficultySchema,
confidence: z.number().min(0).max(1),
alternatives: z.array(ClassificationAlternativeSchema).default([]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The prompt instructs the LLM to return at most 2 alternatives, but the LlmClassificationSchema does not enforce this limit on the alternatives array. If the model returns more than 2 entries, the frontend could render an unbounded number of chip buttons, breaking the UI contract. Consider adding .max(2) to the Zod array definition to make the schema match the prompt instruction and protect the UI boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/src/agents/classifier-agent.ts, line 31:

<comment>The prompt instructs the LLM to return at most 2 alternatives, but the `LlmClassificationSchema` does not enforce this limit on the `alternatives` array. If the model returns more than 2 entries, the frontend could render an unbounded number of chip buttons, breaking the UI contract. Consider adding `.max(2)` to the Zod array definition to make the schema match the prompt instruction and protect the UI boundary.</comment>

<file context>
@@ -0,0 +1,148 @@
+  problem_type: ProblemTypeSchema,
+  difficulty: DifficultySchema,
+  confidence: z.number().min(0).max(1),
+  alternatives: z.array(ClassificationAlternativeSchema).default([]),
+});
+
</file context>

}

// 코드는 못 맞췄지만 이름으로 맞춘 경우 확신을 약간 강등.
const confidence = byCode === undefined ? Math.min(raw.confidence, 0.5) : raw.confidence;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Name-fallback classifications are capped at exactly 0.5, but the extract route warns only when classification.confidence < 0.5 (strict). This means users won't be warned when the classifier had to fall back from an invalid code to a name match, even though that path is explicitly treated as uncertain ("약간 강등"). For consistency with the alternative-promotion (0.4) and no-match (0.3) paths — both of which correctly trigger the warning — the name-fallback cap should be below the threshold, or the comparison should include the boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/src/agents/classifier-agent.ts, line 122:

<comment>Name-fallback classifications are capped at exactly 0.5, but the extract route warns only when `classification.confidence < 0.5` (strict). This means users won't be warned when the classifier had to fall back from an invalid code to a name match, even though that path is explicitly treated as uncertain ("약간 강등"). For consistency with the alternative-promotion (0.4) and no-match (0.3) paths — both of which correctly trigger the warning — the name-fallback cap should be below the threshold, or the comparison should include the boundary.</comment>

<file context>
@@ -0,0 +1,148 @@
+  }
+
+  // 코드는 못 맞췄지만 이름으로 맞춘 경우 확신을 약간 강등.
+  const confidence = byCode === undefined ? Math.min(raw.confidence, 0.5) : raw.confidence;
+  return {
+    school_level: matched.school_level,
</file context>
Suggested change
const confidence = byCode === undefined ? Math.min(raw.confidence, 0.5) : raw.confidence;
const confidence = byCode === undefined ? Math.min(raw.confidence, 0.49) : raw.confidence;

if (buffer.byteLength > MAX_IMAGE_BYTES) {
return c.json({ error: "payload_too_large", message: "이미지는 10MB 이하만 올릴 수 있어요." }, 413);
}
extraction = await extractor.extract({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The extractor.extract() and classifier.classify() calls do not propagate the request's AbortSignal, so client disconnects cannot cancel long-running LLM/vision-model calls. This wastes server resources and LLM capacity, especially for the image-extraction path which can be slow and expensive.

Both agents already accept an optional signal and forward it to generateObject, so the route only needs to pass it through:

  • extractor.extract({ ..., signal: c.req.raw.signal })
  • classifier.classify({ extraction, signal: c.req.raw.signal })
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/src/server/routes/extract.ts, line 71:

<comment>The `extractor.extract()` and `classifier.classify()` calls do not propagate the request's `AbortSignal`, so client disconnects cannot cancel long-running LLM/vision-model calls. This wastes server resources and LLM capacity, especially for the image-extraction path which can be slow and expensive.

Both agents already accept an optional `signal` and forward it to `generateObject`, so the route only needs to pass it through:
- `extractor.extract({ ..., signal: c.req.raw.signal })`
- `classifier.classify({ extraction, signal: c.req.raw.signal })`</comment>

<file context>
@@ -0,0 +1,146 @@
+        if (buffer.byteLength > MAX_IMAGE_BYTES) {
+          return c.json({ error: "payload_too_large", message: "이미지는 10MB 이하만 올릴 수 있어요." }, 413);
+        }
+        extraction = await extractor.extract({
+          kind: "image",
+          bytes: new Uint8Array(buffer),
</file context>

"image/jpeg",
"image/jpg",
"image/webp",
"image/heic",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: HEIC/HEIF are accepted and advertised without evidence the vision model path supports those raw formats. The extractor passes raw bytes directly to the AI SDK's generateObject, which forwards them to the configured vision model. Most common vision APIs do not support HEIC/HEIF natively, so users uploading these formats will pass validation but hit a generic 502 extraction failure. Consider either removing these MIME types from ALLOWED_IMAGE_TYPES or adding transcoding to a supported format (e.g. JPEG) before passing to the model.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/src/server/routes/extract.ts, line 34:

<comment>HEIC/HEIF are accepted and advertised without evidence the vision model path supports those raw formats. The extractor passes raw bytes directly to the AI SDK's `generateObject`, which forwards them to the configured vision model. Most common vision APIs do not support HEIC/HEIF natively, so users uploading these formats will pass validation but hit a generic 502 extraction failure. Consider either removing these MIME types from `ALLOWED_IMAGE_TYPES` or adding transcoding to a supported format (e.g. JPEG) before passing to the model.</comment>

<file context>
@@ -0,0 +1,146 @@
+  "image/jpeg",
+  "image/jpg",
+  "image/webp",
+  "image/heic",
+  "image/heif",
+]);
</file context>

@@ -0,0 +1,117 @@
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The CURRICULUM_TOPICS catalog duplicates the same 43 topic codes and names already defined in the frontend (packages/web/app/app/new/topic/data.ts) and also referenced by TOPIC_KIND_BY_CODE in the agent package. Maintaining three separate hardcoded copies across packages is brittle: a rename, addition, or removal in one location can silently drift from the others and break the attach-problem classification flow. The comment claims synchronization is guaranteed by tests, but the existing extract tests only validate internal properties (unique codes, generation-kind mapping) and do not assert cross-package equality with the frontend topic data or the generation-kind code set. Consider extracting the shared code↔name catalog into a shared package (or at minimum adding a cross-package snapshot/sync test) so all three consumers stay consistent automatically.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/src/schemas/curriculum-topics.ts, line 4:

<comment>The `CURRICULUM_TOPICS` catalog duplicates the same 43 topic codes and names already defined in the frontend (`packages/web/app/app/new/topic/data.ts`) and also referenced by `TOPIC_KIND_BY_CODE` in the agent package. Maintaining three separate hardcoded copies across packages is brittle: a rename, addition, or removal in one location can silently drift from the others and break the attach-problem classification flow. The comment claims synchronization is guaranteed by tests, but the existing extract tests only validate internal properties (unique codes, generation-kind mapping) and do not assert cross-package equality with the frontend topic data or the generation-kind code set. Consider extracting the shared code↔name catalog into a shared package (or at minimum adding a cross-package snapshot/sync test) so all three consumers stay consistent automatically.</comment>

<file context>
@@ -0,0 +1,117 @@
+/**
+ * 교육과정 단원 카탈로그 — 중1~중3 + 고등 공통수학 (총 43개).
+ *
+ * 첨부 문제 분류(classifier-agent)가 이 코드 집합 안에서만 단원을 고르고,
+ * extract 라우트가 분류 결과를 유효한 코드로 스냅한다. FE `packages/web/app/app/new/topic/data.ts`
+ * 와 동일한 집합이어야 한다 (코드↔이름↔학년). 동기화 테스트로 보장.
</file context>

if (trimmed.length === 0) return undefined;
const exact = CURRICULUM_TOPICS.find((topic) => topic.name === trimmed);
if (exact !== undefined) return exact;
return CURRICULUM_TOPICS.find(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The findCurriculumTopicByName fallback uses a loose bidirectional substring match that returns the first array match. If the classifier emits a vague name such as "방정식", "함수", or "인수분해", this silently maps to an arbitrary first topic (e.g., "일차방정식" before "이차방정식") rather than surfacing ambiguity to the user. Consider narrowing the fallback so the trimmed input must contain the full topic name (trimmed.includes(topic.name)). This prevents short, ambiguous substrings from silently snapping to the wrong curriculum topic while still matching longer LLM outputs that embed the exact topic name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/agent/src/schemas/curriculum-topics.ts, line 89:

<comment>The `findCurriculumTopicByName` fallback uses a loose bidirectional substring match that returns the first array match. If the classifier emits a vague name such as "방정식", "함수", or "인수분해", this silently maps to an arbitrary first topic (e.g., "일차방정식" before "이차방정식") rather than surfacing ambiguity to the user. Consider narrowing the fallback so the trimmed input must contain the full topic name (`trimmed.includes(topic.name)`). This prevents short, ambiguous substrings from silently snapping to the wrong curriculum topic while still matching longer LLM outputs that embed the exact topic name.</comment>

<file context>
@@ -0,0 +1,117 @@
+  if (trimmed.length === 0) return undefined;
+  const exact = CURRICULUM_TOPICS.find((topic) => topic.name === trimmed);
+  if (exact !== undefined) return exact;
+  return CURRICULUM_TOPICS.find(
+    (topic) => topic.name.includes(trimmed) || trimmed.includes(topic.name),
+  );
</file context>

className="btn btn-primary"
onClick={handleCreate}
disabled={!canCreate}
aria-describedby="create-reason"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The create button always references create-reason via aria-describedby, but the description text becomes '학년을 선택하세요.' whenever the button is in an enabled state (question text and topic are both set). This means screen-reader users hear an actionable button described as blocked by a missing grade—even when the grade is already selected (middle school) or intentionally null (high school, where no grade selector exists). Consider conditionally attaching aria-describedby only when !canCreate, or adjusting the description logic to match the actual disabled reason.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/web/app/app/new/attach/view.tsx, line 366:

<comment>The create button always references `create-reason` via `aria-describedby`, but the description text becomes '학년을 선택하세요.' whenever the button is in an enabled state (question text and topic are both set). This means screen-reader users hear an actionable button described as blocked by a missing grade—even when the grade is already selected (middle school) or intentionally null (high school, where no grade selector exists). Consider conditionally attaching `aria-describedby` only when `!canCreate`, or adjusting the description logic to match the actual disabled reason.</comment>

<file context>
@@ -0,0 +1,499 @@
+                className="btn btn-primary"
+                onClick={handleCreate}
+                disabled={!canCreate}
+                aria-describedby="create-reason"
+              >
+                <span>이 문제로 만들기</span>
</file context>
Suggested change
aria-describedby="create-reason"
aria-describedby={!canCreate ? "create-reason" : undefined}

input === null
? null
: `${input.schoolLevel}|${input.grade ?? "common"}|${input.topic}|${input.topicName}|${input.mode}|${input.sourceItemId}|${input.sourceProblemText ?? ""}|${input.endpoint ?? ""}`;
: `${input.schoolLevel}|${input.grade ?? "common"}|${input.topic}|${input.topicName}|${input.mode}|${input.sourceItemId}|${input.sourceProblemText ?? ""}|${input.sourceOrigin ?? "corpus"}|${input.generationKind ?? ""}|${input.endpoint ?? ""}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The inputKey was correctly updated to treat sourceOrigin and generationKind as stream identifiers, but the sessionStorage key used to persist and read back results still omits both fields. If a user generates first with an attached problem and then with a corpus source (or with different generationKind values) while keeping the same topic, grade, and mode, the second run will overwrite the first one's cached results in sessionStorage. The result and export pages will then retrieve the wrong cached data because they use the same verificationStorageKey that doesn't distinguish these dimensions.

To fix this, VerificationStorageKeyParts and verificationStorageKey should include sourceOrigin and generationKind, and all callers — the setItem here, plus the getItem calls in result/view.tsx and export/view.tsx — should pass them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/web/hooks/use-verification-stream.ts, line 370:

<comment>The `inputKey` was correctly updated to treat `sourceOrigin` and `generationKind` as stream identifiers, but the sessionStorage key used to persist and read back results still omits both fields. If a user generates first with an attached problem and then with a corpus source (or with different `generationKind` values) while keeping the same topic, grade, and mode, the second run will overwrite the first one's cached results in sessionStorage. The result and export pages will then retrieve the wrong cached data because they use the same `verificationStorageKey` that doesn't distinguish these dimensions.

To fix this, `VerificationStorageKeyParts` and `verificationStorageKey` should include `sourceOrigin` and `generationKind`, and all callers — the `setItem` here, plus the `getItem` calls in `result/view.tsx` and `export/view.tsx` — should pass them.</comment>

<file context>
@@ -363,7 +367,7 @@ export function useVerificationStream(
     input === null
       ? null
-      : `${input.schoolLevel}|${input.grade ?? "common"}|${input.topic}|${input.topicName}|${input.mode}|${input.sourceItemId}|${input.sourceProblemText ?? ""}|${input.endpoint ?? ""}`;
+      : `${input.schoolLevel}|${input.grade ?? "common"}|${input.topic}|${input.topicName}|${input.mode}|${input.sourceItemId}|${input.sourceProblemText ?? ""}|${input.sourceOrigin ?? "corpus"}|${input.generationKind ?? ""}|${input.endpoint ?? ""}`;
 
   /* effect 본문이 input 의 *최신* 값을 읽어야 하지만 deps 로 넣으면 가드가
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant