- 현재 단원({topic.name})에 해당하는 corpus 문항이 비어 있거나
- agent 서버에 일시적인 문제가 있을 수 있습니다. 잠시 후 다시
+ 현재 단원({topic.name})에 해당하는 예시 문항이 없거나
+ 출제 서버에 일시적인 문제가 있을 수 있습니다. 잠시 후 다시
시도해 주세요.
@@ -346,7 +346,7 @@ export function IntentPicker({ schoolLevel, grade, topic, candidates }: Props) {
어떤 문제를 기준으로 만들까요?
- corpus 에서 가져온 후보 중 하나를 선택해 주세요. 구조 동형은
+ 기출 · 교과 문제 중 하나를 기준으로 선택해 주세요. 구조 동형은
풀이 구조를, 개념 동형은 학습 목표를 이 문항에서 가져옵니다.
- 아래 3 문항은 OpenMath 가 RAG 검색 → 의도 추출 → 생성 →
- SymPy 산술 검증 → 독립 재풀이 → 학습 목표 매핑의 6 단계를
+ 아래 3 문항은 OpenMath 가 비슷한 문제 찾기 → 출제 의도 분석 → 문제 생성 →
+ SymPy 산술 검증 → 독립 재풀이 → 학습 목표 점검의 6 단계를
통과시킨 예시입니다. 직접 출제하면 학년과 단원을 골라 동일한
품질의 문항을 만들 수 있습니다.
diff --git a/packages/web/components/landing/faq.tsx b/packages/web/components/landing/faq.tsx
index 76f17de..7d60181 100644
--- a/packages/web/components/landing/faq.tsx
+++ b/packages/web/components/landing/faq.tsx
@@ -7,7 +7,7 @@ const items: FaqItem[] = [
{
question: "OpenMath 은 다른 AI 출제 도구와 무엇이 다른가요?",
answer:
- "LLM 출력을 그대로 신뢰하지 않습니다. SymPy기반 기호 계산이 6단계 결정론적 게이트로 모든 문항을 검증하고, 답이 맞다고 증명한 문항만 사용자에게 노출됩니다. 화면에 보이는 ✓는 LLM 의 자신감이 아니라 독립된 계산 시스템의 결론입니다.",
+ "AI 출력을 그대로 신뢰하지 않습니다. SymPy 기반 기호 계산이 6단계 결정론적 게이트로 모든 문항을 검증하고, 답이 맞다고 증명한 문항만 사용자에게 노출됩니다. 화면에 보이는 ✓는 AI 의 자신감이 아니라 독립된 계산 시스템의 결론입니다.",
},
{
question: "구조 동형과 개념 동형은 무엇이 다른가요?",
@@ -17,7 +17,7 @@ const items: FaqItem[] = [
{
question: "한 번 출제에 시간이 얼마나 걸리나요?",
answer:
- "보통 5 ~ 30 초입니다. RAG 검색·의도 추출·문제 생성·SymPy 산술 검증·독립 재풀이·학습 목표 매핑의 6단계가 실시간으로 화면에 노출되어 진행 상황을 직접 확인할 수 있습니다.",
+ "보통 5 ~ 30 초입니다. 비슷한 문제 찾기 · 출제 의도 분석 · 문제 생성 · SymPy 산술 검증 · 독립 재풀이 · 학습 목표 점검의 6단계가 실시간으로 화면에 노출되어 진행 상황을 직접 확인할 수 있습니다.",
},
{
question: "회원가입이 필요한가요?",
diff --git a/packages/web/components/landing/feature-strip.tsx b/packages/web/components/landing/feature-strip.tsx
index 086fb1f..f430c79 100644
--- a/packages/web/components/landing/feature-strip.tsx
+++ b/packages/web/components/landing/feature-strip.tsx
@@ -2,7 +2,7 @@ const features = [
{
num: "01 / 생성",
title: "수학적으로 옳은 문제",
- body: "LLM 이 만든 답을 그대로 믿지 않습니다. SymPy 가 6단계 결정론적 게이트로 풀이를 검증한 문항만 사용자에게 노출됩니다.",
+ body: "AI 가 만든 답을 그대로 믿지 않습니다. SymPy 가 6단계 결정론적 게이트로 풀이를 검증한 문항만 사용자에게 노출됩니다.",
},
{
num: "02 / 동형성",
diff --git a/packages/web/lib/extract-client.ts b/packages/web/lib/extract-client.ts
new file mode 100644
index 0000000..193abe9
--- /dev/null
+++ b/packages/web/lib/extract-client.ts
@@ -0,0 +1,81 @@
+/* ─────────────────────────────────────────────────────────────
+ * extract-client — agent `POST /api/extract` 의 fetch 래퍼.
+ *
+ * 첨부 문제(텍스트 붙여넣기 또는 이미지)를 읽어 들이고 학년·단원을 자동
+ * 인식한다. 확인 화면(/app/new/attach)에서 호출.
+ *
+ * source-problems-client 와 같은 base URL 규칙(NEXT_PUBLIC_AGENT_URL).
+ * 실패 시 사용자에게 그대로 보여줄 한국어 메시지를 담아 throw.
+ * ──────────────────────────────────────────────────────────── */
+
+export type Extraction = {
+ question_text: string;
+ choices: string[] | null;
+ answer_text: string | null;
+ figure_dependent: boolean;
+ confidence: number;
+};
+
+export type Classification = {
+ school_level: "middle" | "high";
+ grade: 1 | 2 | 3 | null;
+ topic_code: string;
+ topic_name: string;
+ problem_type: string;
+ difficulty: "easy" | "medium" | "hard";
+ confidence: number;
+ alternatives: { topic_code: string; topic_name: string }[];
+};
+
+export type ExtractResult = {
+ extraction: Extraction;
+ classification: Classification;
+ warnings: string[];
+};
+
+function getAgentBaseUrl(): string {
+ const env =
+ typeof process !== "undefined" ? process.env.NEXT_PUBLIC_AGENT_URL : undefined;
+ return (env ?? "http://localhost:31415").replace(/\/$/, "");
+}
+
+async function postExtract(init: RequestInit): Promise {
+ const url = `${getAgentBaseUrl()}/api/extract`;
+ let response: Response;
+ try {
+ response = await fetch(url, init);
+ } catch {
+ throw new Error("출제 서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.");
+ }
+ if (!response.ok) {
+ let message = "문제를 읽지 못했어요. 다른 사진이나 텍스트로 다시 시도해 주세요.";
+ try {
+ const body: unknown = await response.json();
+ if (
+ body !== null &&
+ typeof body === "object" &&
+ typeof (body as Record).message === "string"
+ ) {
+ message = (body as Record).message;
+ }
+ } catch {
+ /* JSON 아님 — 기본 메시지 유지 */
+ }
+ throw new Error(message);
+ }
+ return (await response.json()) as ExtractResult;
+}
+
+export function extractFromText(text: string): Promise {
+ return postExtract({
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text }),
+ });
+}
+
+export function extractFromImage(file: File): Promise {
+ const form = new FormData();
+ form.set("image", file);
+ return postExtract({ method: "POST", body: form });
+}
From 1d8c94629176864de26548ab8e0bf95b6fa1eebf Mon Sep 17 00:00:00 2001
From: LeeJhin
Date: Thu, 11 Jun 2026 20:44:55 +0900
Subject: [PATCH 2/4] fix(agent): cubic review - classification non-fatal +
model helper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
리뷰 피드백 반영:
- P1: 분류 실패 시 502 대신 단원 빈 채 200 반환 → 추출 결과 보존하고
확인 화면에서 수동 선택 가능 (manualPickClassification).
- P2: 카탈로그 밖일 때 학교급을 raw 코드로만 추정하지 않고, 유효한
대안이 있으면 그 첫 번째로 강등 매칭 (실제 학교급·학년 사용).
- P3: extractor/solver 모델 해석 중복을 buildAgentModel 헬퍼로 통합.
테스트 +2 (분류 실패 200, 대안 강등 매칭), 전체 277 통과.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/agent/src/agents/classifier-agent.ts | 18 ++++++-
packages/agent/src/index.ts | 49 +++++++------------
packages/agent/src/server/routes/extract.ts | 22 +++++++--
packages/agent/tests/extract.test.ts | 29 +++++++++++
4 files changed, 83 insertions(+), 35 deletions(-)
diff --git a/packages/agent/src/agents/classifier-agent.ts b/packages/agent/src/agents/classifier-agent.ts
index f04b0fe..888ce8c 100644
--- a/packages/agent/src/agents/classifier-agent.ts
+++ b/packages/agent/src/agents/classifier-agent.ts
@@ -83,7 +83,23 @@ export function resolveClassification(raw: LlmClassification): Classification {
.map((topic) => ({ topic_code: topic.code, topic_name: topic.name }));
if (matched === undefined) {
- // 카탈로그 밖 — 사용자가 확인 화면에서 직접 고르도록 확신을 크게 낮춘다.
+ // 코드·이름 모두 카탈로그 밖. 유효한 대안이 있으면 그 첫 번째로 강등 매칭한다
+ // (학교급을 raw 코드 접두로만 추정하면 오분류 위험 — 대안의 실제 학교급을 쓴다).
+ const firstAlt = alternatives[0];
+ const promoted = firstAlt === undefined ? undefined : findCurriculumTopic(firstAlt.topic_code);
+ if (promoted !== undefined) {
+ return {
+ school_level: promoted.school_level,
+ grade: promoted.grade,
+ topic_code: promoted.code,
+ topic_name: promoted.name,
+ problem_type: raw.problem_type,
+ difficulty: raw.difficulty,
+ confidence: Math.min(raw.confidence, 0.4),
+ alternatives: alternatives.slice(1),
+ };
+ }
+ // 대안도 없음 — 단원을 비워 사용자가 확인 화면에서 직접 고르게 한다.
return {
school_level: raw.topic_code.startsWith("10") ? "high" : "middle",
grade: null,
diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts
index 62e8441..20e08f4 100644
--- a/packages/agent/src/index.ts
+++ b/packages/agent/src/index.ts
@@ -65,22 +65,25 @@ export async function main(): Promise {
logLlmCall,
)
: undefined;
+ /** 메인 LLM 과 같은 modelId 면 재사용, 다르면 새 모델을 만들어 로깅 래핑. solver/extractor 공용. */
+ const buildAgentModel = (modelId: string, label: string) =>
+ llm === undefined
+ ? undefined
+ : modelId === llmModel
+ ? llm
+ : withLlmLogging(
+ resolveLanguageModel({
+ kind: llmKind,
+ modelId,
+ baseUrl: llmBaseUrl,
+ apiKey: llmApiKey ?? "openmath-local",
+ allowedHosts: ["localhost", "127.0.0.1"],
+ }),
+ label,
+ logLlmCall,
+ );
const solverModel = env.SOLVER_MODEL ?? llmModel;
- const solverLlm = llm === undefined
- ? undefined
- : solverModel === llmModel
- ? llm
- : withLlmLogging(
- resolveLanguageModel({
- kind: llmKind,
- modelId: solverModel,
- baseUrl: llmBaseUrl,
- apiKey: llmApiKey ?? "openmath-local",
- allowedHosts: ["localhost", "127.0.0.1"],
- }),
- `${solverModel} (solver)`,
- logLlmCall,
- );
+ const solverLlm = buildAgentModel(solverModel, `${solverModel} (solver)`);
const generator = llm === undefined
? undefined
: createGeneratorAgent({
@@ -114,21 +117,7 @@ export async function main(): Promise {
prompts,
});
const extractModel = env.EXTRACT_MODEL ?? llmModel;
- const extractLlm = llm === undefined
- ? undefined
- : extractModel === llmModel
- ? llm
- : withLlmLogging(
- resolveLanguageModel({
- kind: llmKind,
- modelId: extractModel,
- baseUrl: llmBaseUrl,
- apiKey: llmApiKey ?? "openmath-local",
- allowedHosts: ["localhost", "127.0.0.1"],
- }),
- `${extractModel} (extract)`,
- logLlmCall,
- );
+ const extractLlm = buildAgentModel(extractModel, `${extractModel} (extract)`);
const extractor = extractLlm === undefined
? undefined
: createExtractorAgent({
diff --git a/packages/agent/src/server/routes/extract.ts b/packages/agent/src/server/routes/extract.ts
index caf2f18..d994307 100644
--- a/packages/agent/src/server/routes/extract.ts
+++ b/packages/agent/src/server/routes/extract.ts
@@ -99,11 +99,10 @@ export function createExtractRoute(deps: ExtractRouteDeps): Hono {
try {
classification = await classifier.classify({ extraction });
} catch (err) {
+ // 분류 실패는 치명적이지 않다 — 추출은 성공했으니 단원을 비운 채 200 으로
+ // 확인 화면을 열어 사용자가 학년 · 단원을 직접 고르게 한다 (수동 폴백).
console.error("[extract] classification failed:", err instanceof Error ? err.message : err);
- return c.json(
- { error: "classification_failed", message: "단원 자동 인식에 실패했어요. 확인 화면에서 직접 골라 주세요." },
- 502,
- );
+ classification = manualPickClassification(extraction);
}
const response: ExtractResponse = ExtractResponseSchema.parse({
@@ -130,3 +129,18 @@ function buildWarnings(extraction: Extraction, classification: Classification):
}
return warnings;
}
+
+/** 분류기가 실패했을 때의 빈 분류 — 단원을 비워 확인 화면에서 수동 선택을 유도한다. */
+function manualPickClassification(extraction: Extraction): Classification {
+ const objective = extraction.choices !== null && extraction.choices.length > 0;
+ return {
+ school_level: "middle",
+ grade: null,
+ topic_code: "",
+ topic_name: "",
+ problem_type: objective ? "objective" : "short_answer",
+ difficulty: "medium",
+ confidence: 0,
+ alternatives: [],
+ };
+}
diff --git a/packages/agent/tests/extract.test.ts b/packages/agent/tests/extract.test.ts
index 8729cce..9251a38 100644
--- a/packages/agent/tests/extract.test.ts
+++ b/packages/agent/tests/extract.test.ts
@@ -101,6 +101,20 @@ describe("resolveClassification", () => {
});
expect(r.alternatives).toEqual([{ topic_code: "9수02-10", topic_name: "이차방정식의 활용" }]);
});
+
+ it("promotes the first valid alternative when the primary code and name are invalid", () => {
+ const r = resolveClassification({
+ ...base,
+ topic_code: "없는코드",
+ topic_name: "양자역학",
+ alternatives: [{ topic_code: "9수02-10", topic_name: "이차방정식의 활용" }],
+ });
+ expect(r.topic_code).toBe("9수02-10");
+ expect(r.school_level).toBe("middle");
+ expect(r.grade).toBe(3);
+ expect(r.alternatives).toEqual([]);
+ expect(r.confidence).toBeLessThanOrEqual(0.4);
+ });
});
describe("POST /api/extract", () => {
@@ -142,6 +156,21 @@ describe("POST /api/extract", () => {
expect(body.warnings.some((w) => w.includes("단원"))).toBe(true);
});
+ it("opens manual confirm (200) when classification fails, not 502", async () => {
+ const throwingClassifier: ClassifierAgent = {
+ async classify() {
+ throw new Error("classifier model down");
+ },
+ };
+ const app = createExtractRoute({ extractor: okExtractor, classifier: throwingClassifier });
+ const res = await app.request("/api/extract", jsonReq({ text: "x" }));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ExtractResponse;
+ expect(body.extraction.question_text).toContain("이차방정식");
+ expect(body.classification.topic_code).toBe("");
+ expect(body.warnings.some((w) => w.includes("단원"))).toBe(true);
+ });
+
it("rejects an unsupported image type with 415", async () => {
const app = createExtractRoute({ extractor: okExtractor, classifier: okClassifier });
const form = new FormData();
From 8f7ca3d9332d9c57a7f57e2073852a7844399c23 Mon Sep 17 00:00:00 2001
From: LeeJhin
Date: Thu, 11 Jun 2026 21:35:14 +0900
Subject: [PATCH 3/4] feat(attach): robust attached flow - kind from problem,
source-only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
첨부 플로우 견고화 (전부 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)
---
packages/agent/prompts/problem-classifier.md | 1 +
packages/agent/prompts/problem-generator.md | 7 ++++
packages/agent/src/agents/classifier-agent.ts | 5 +++
packages/agent/src/agents/generator-agent.ts | 9 +++-
packages/agent/src/schemas/extract.schema.ts | 4 ++
.../src/schemas/generate-request.schema.ts | 9 ++++
packages/agent/src/server/sse/wire-adapter.ts | 7 ++--
packages/agent/src/steps/intent-extraction.ts | 13 +++---
.../src/workflows/verification-workflow.ts | 16 ++++++--
packages/agent/tests/backend-contract.test.ts | 4 +-
packages/agent/tests/extract.test.ts | 2 +
packages/agent/tests/generator-agent.test.ts | 39 ++++++++++++++++++
...fication-workflow-generate-failure.test.ts | 41 +++++++++++++++++++
packages/web/app/app/new/attach/view.tsx | 6 ++-
packages/web/app/app/new/verify/view.tsx | 16 +++++++-
packages/web/hooks/use-verification-stream.ts | 12 ++++--
packages/web/lib/extract-client.ts | 2 +
17 files changed, 171 insertions(+), 22 deletions(-)
diff --git a/packages/agent/prompts/problem-classifier.md b/packages/agent/prompts/problem-classifier.md
index ffedbbe..04285be 100644
--- a/packages/agent/prompts/problem-classifier.md
+++ b/packages/agent/prompts/problem-classifier.md
@@ -31,6 +31,7 @@ updated: 2026-06-11
- `topic_code`: 위 카탈로그에 **존재하는 코드**만 사용한다. 카탈로그 밖의 코드를 만들지 말 것.
- `topic_name`: 그 코드의 단원 이름(카탈로그 표기 그대로).
+- `generation_kind`: 문제의 **풀이 종류**를 8개 중에서 고른다 — `equation`(방정식 해), `inequality`(부등식), `system`(연립방정식), `expression`(식 계산·전개·인수분해·근호), `function`(함수·그래프), `geometry`(도형·삼각비), `probability`(경우의 수·확률), `statistics`(통계). 단원이 애매해도 이 종류는 **문제 형태로** 판단한다.
- `problem_type`: `objective`(객관식·보기 있음) / `short_answer`(짧은 답) / `essay`(서술형) / `subjective`(주관식) 중 하나.
- `difficulty`: `easy` / `medium` / `hard` 중 하나. 단원 표준 난이도 기준.
- `confidence`: 분류 확신(0~1). 여러 단원에 걸치거나 교육과정 밖으로 보이면 낮춘다.
diff --git a/packages/agent/prompts/problem-generator.md b/packages/agent/prompts/problem-generator.md
index 2e9e04c..57241e9 100644
--- a/packages/agent/prompts/problem-generator.md
+++ b/packages/agent/prompts/problem-generator.md
@@ -13,6 +13,7 @@ variables:
- refinementHint
- counterexample
- schemaError
+ - attached
owner: 비할당
updated: 2026-06-11
---
@@ -54,6 +55,12 @@ updated: 2026-06-11
- conceptual: 평가 차원은 보존하되 식의 형태를 바꾼다. 예: `x**2 - a = 0` 기준이면 `(x - h)**2 - a = 0` 또는 전개형 `x**2 + b*x + c = 0`처럼 다른 표현 경로를 사용한다.
- `{{request.difficulty}}` 난이도에 맞춘다. medium 이상에서는 `x**2 - a = 0`처럼 너무 직접적인 기본형만 내지 말고 한 단계 변형된 식을 낸다.
+{{#if attached}}
+# 첨부 문제 우선 (중요)
+
+기준 문제는 사용자가 직접 첨부한 문제다. 아래 References(참조문항)가 첨부 문제와 다르면 **무조건 첨부 문제를 따른다**. References 는 표현·형식 힌트로만 쓰고, 풀이 구조와 평가 요소는 첨부 문제에서 가져온다. References 가 비어 있거나 동떨어져 있어도 정상이다 — 첨부 문제 하나만으로 동형 문제를 만든다.
+{{/if}}
+
# Intent
학습 목표: {{intent.objective_description}} (`{{intent.objective_code}}`)
diff --git a/packages/agent/src/agents/classifier-agent.ts b/packages/agent/src/agents/classifier-agent.ts
index 888ce8c..5455514 100644
--- a/packages/agent/src/agents/classifier-agent.ts
+++ b/packages/agent/src/agents/classifier-agent.ts
@@ -11,6 +11,7 @@ import { z } from "zod";
import {
ClassificationAlternativeSchema,
DifficultySchema,
+ GenerationKindSchema,
ProblemTypeSchema,
findCurriculumTopic,
findCurriculumTopicByName,
@@ -23,6 +24,7 @@ import type { PromptLoader } from "../tools/prompt-loader.js";
export const LlmClassificationSchema = z.object({
topic_code: z.string().min(1),
topic_name: z.string().min(1),
+ generation_kind: GenerationKindSchema,
problem_type: ProblemTypeSchema,
difficulty: DifficultySchema,
confidence: z.number().min(0).max(1),
@@ -93,6 +95,7 @@ export function resolveClassification(raw: LlmClassification): Classification {
grade: promoted.grade,
topic_code: promoted.code,
topic_name: promoted.name,
+ generation_kind: raw.generation_kind,
problem_type: raw.problem_type,
difficulty: raw.difficulty,
confidence: Math.min(raw.confidence, 0.4),
@@ -105,6 +108,7 @@ export function resolveClassification(raw: LlmClassification): Classification {
grade: null,
topic_code: "",
topic_name: "",
+ generation_kind: raw.generation_kind,
problem_type: raw.problem_type,
difficulty: raw.difficulty,
confidence: Math.min(raw.confidence, 0.3),
@@ -119,6 +123,7 @@ export function resolveClassification(raw: LlmClassification): Classification {
grade: matched.grade,
topic_code: matched.code,
topic_name: matched.name,
+ generation_kind: raw.generation_kind,
problem_type: raw.problem_type,
difficulty: raw.difficulty,
confidence,
diff --git a/packages/agent/src/agents/generator-agent.ts b/packages/agent/src/agents/generator-agent.ts
index b1267f2..0eba82c 100644
--- a/packages/agent/src/agents/generator-agent.ts
+++ b/packages/agent/src/agents/generator-agent.ts
@@ -77,7 +77,9 @@ export function assembleGeneratedProblem(input: {
readonly promptId: string;
readonly promptVersion: string;
}): GeneratedProblem {
- const generationKind = generationKindForTopic(getGenerateRequestTopicCode(input.request));
+ const generationKind =
+ input.request.generation_kind ??
+ generationKindForTopic(getGenerateRequestTopicCode(input.request));
return {
candidate_id: randomUUID(),
mode: input.request.mode === "conceptual" ? "conceptual" : "structural",
@@ -115,7 +117,9 @@ export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent {
return {
async generate(input) {
const prompt = await deps.prompts.load(deps.promptId);
- const generationKind = generationKindForTopic(getGenerateRequestTopicCode(input.request));
+ const generationKind =
+ input.request.generation_kind ??
+ generationKindForTopic(getGenerateRequestTopicCode(input.request));
const temperature = temperatureForGeneratorAttempt(
input.attempt,
prompt.metadata.temperature,
@@ -123,6 +127,7 @@ export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent {
const basePromptVars = {
request: input.request,
generationKind,
+ attached: input.request.source_origin === "attached",
intent: input.intent,
refs: input.refs,
strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2),
diff --git a/packages/agent/src/schemas/extract.schema.ts b/packages/agent/src/schemas/extract.schema.ts
index 3473fde..528c63f 100644
--- a/packages/agent/src/schemas/extract.schema.ts
+++ b/packages/agent/src/schemas/extract.schema.ts
@@ -11,6 +11,7 @@
import { z } from "zod";
+import { GenerationKindSchema } from "./generation-kind.schema.js";
import {
DifficultySchema,
ProblemTypeSchema,
@@ -58,6 +59,9 @@ export const ClassificationSchema = z.object({
topic_name: z.string(),
problem_type: ProblemTypeSchema,
difficulty: DifficultySchema,
+ /** 문제 자체에서 추론한 풀이 종류. 생성기가 토픽 파생값보다 우선 사용(토픽 오분류 보호).
+ * 분류 실패 폴백 시에만 생략 → 토픽 파생값으로 복귀. */
+ generation_kind: GenerationKindSchema.optional(),
confidence: z.number().min(0).max(1),
alternatives: z.array(ClassificationAlternativeSchema).default([]),
});
diff --git a/packages/agent/src/schemas/generate-request.schema.ts b/packages/agent/src/schemas/generate-request.schema.ts
index c093c50..860933f 100644
--- a/packages/agent/src/schemas/generate-request.schema.ts
+++ b/packages/agent/src/schemas/generate-request.schema.ts
@@ -10,6 +10,7 @@
import { z } from "zod";
+import { GenerationKindSchema } from "./generation-kind.schema.js";
import {
DifficultySchema,
ProblemTypeSchema,
@@ -39,6 +40,14 @@ export const GenerateRequestSchema = z.object({
problem_type: ProblemTypeSchema.default("objective"),
source_problem_text: z.string().optional(),
+
+ /** "corpus" = 코퍼스에서 고른 기준 문제(토픽이 곧 ground truth).
+ * "attached" = 사용자가 첨부한 문제(토픽은 추측, 첨부 문제가 ground truth).
+ * attached 일 때 생성기는 refs 보다 source_problem_text 를 우선한다. */
+ source_origin: z.enum(["corpus", "attached"]).default("corpus"),
+ /** 첨부 플로우에서 문제 자체로부터 추론한 generation kind.
+ * 있으면 topic 파생값(generationKindForTopic)보다 우선 — 토픽 오분류로부터 종류를 보호. */
+ generation_kind: GenerationKindSchema.optional(),
}).superRefine((request, ctx) => {
if (request.school_level === "middle" && request.grade === null) {
ctx.addIssue({
diff --git a/packages/agent/src/server/sse/wire-adapter.ts b/packages/agent/src/server/sse/wire-adapter.ts
index ffa96bd..84eea30 100644
--- a/packages/agent/src/server/sse/wire-adapter.ts
+++ b/packages/agent/src/server/sse/wire-adapter.ts
@@ -30,12 +30,12 @@ import type {
} from "../../schemas/index.js";
const STEP_META: Record = {
- rag: { index: 1, name: "RAG 검색" },
- intent: { index: 2, name: "의도 추출" },
+ rag: { index: 1, name: "비슷한 문제 찾기" },
+ intent: { index: 2, name: "출제 의도 분석" },
generate: { index: 3, name: "문제 생성" },
sympy_verify: { index: 4, name: "산술 검증 (SymPy)" },
re_solve: { index: 5, name: "독립 재풀이" },
- objective_map: { index: 6, name: "학습 목표 매핑" },
+ objective_map: { index: 6, name: "학습 목표 점검" },
};
function mapStepStatus(event: StepEvent): WireStepStatus {
@@ -83,6 +83,7 @@ function successSummary(step: StepName, gate: Record): string |
function ragSummary(evidence: Record): string | null {
const refs = asNumber(evidence["refs"]);
if (refs === null) return null;
+ if (evidence["source_only"] === true) return "비슷한 예시 없음 — 첨부 문제만으로 변형";
return `참조 ${refs}문항 확보`;
}
diff --git a/packages/agent/src/steps/intent-extraction.ts b/packages/agent/src/steps/intent-extraction.ts
index 495483e..6d0fcc4 100644
--- a/packages/agent/src/steps/intent-extraction.ts
+++ b/packages/agent/src/steps/intent-extraction.ts
@@ -97,18 +97,19 @@ function buildSeedIntent(
strategy: Strategy | null,
refs: RagResult[],
): Intent {
+ // refs 가 없어도(첨부 source-only) 동작한다 — strategy·request 의 토픽 정보로 시드 의도를 만든다.
+ // corpus 플로우는 refs 가 항상 있으므로 first 기반 동작이 그대로 유지된다.
const first = refs[0];
- if (first === undefined) {
- throw new Error("Cannot extract intent without RAG refs");
- }
+ const topicName =
+ first?.problem.topic_name ?? request.topic_name ?? getGenerateRequestTopicCode(request);
const objectiveCode =
- strategy?.code ?? first.problem.achievement_standard ?? getGenerateRequestTopicCode(request);
+ strategy?.code ?? first?.problem.achievement_standard ?? getGenerateRequestTopicCode(request);
return {
objective_code: objectiveCode,
- objective_description: strategy?.title ?? first.problem.topic_name,
+ objective_description: strategy?.title ?? topicName,
evaluation_dimensions:
strategy?.evaluation_dimensions ??
- buildGuessedEvaluationDimensions(request, first.problem.topic_name),
+ buildGuessedEvaluationDimensions(request, topicName),
required_techniques: strategy?.techniques.required_at_least_one_of ?? [],
forbidden_techniques: strategy?.techniques.forbidden ?? [],
surface_constraints: {
diff --git a/packages/agent/src/workflows/verification-workflow.ts b/packages/agent/src/workflows/verification-workflow.ts
index a660fcb..11fb9a9 100644
--- a/packages/agent/src/workflows/verification-workflow.ts
+++ b/packages/agent/src/workflows/verification-workflow.ts
@@ -86,18 +86,26 @@ export async function* runVerificationWorkflow(
yield step("rag", "start", timestamp());
const ragStarted = Date.now();
const { refs } = await ragSearch({ rag: deps.rag, perStepTimeoutMs }, { request });
+ // 첨부 문제(source_origin=attached)는 첨부한 문제 자체가 변형 기준이므로,
+ // 코퍼스에 비슷한 예시가 없어도(refs=0) 중단하지 않고 source-only 로 진행한다.
+ // refs=[] 는 하위 스텝이 견딘다: strategy 는 topic_code 로 로드, intent 는 seed
+ // 폴백, generate 는 refs.length>0 일 때만 템플릿이라 refs=0 이면 LLM 생성기로 간다.
+ const attachedSourceOnly =
+ request.source_origin === "attached" &&
+ (request.source_problem_text ?? "").trim().length > 0;
+ const ragSourceOnly = refs.length === 0 && attachedSourceOnly;
const ragGate: GateResult = {
step: "rag",
- status: refs.length > 0 ? "passed" : "failed",
+ status: refs.length > 0 || attachedSourceOnly ? "passed" : "failed",
duration_ms: Date.now() - ragStarted,
- evidence: { refs: refs.length },
+ evidence: ragSourceOnly ? { refs: 0, source_only: true } : { refs: refs.length },
failure_detail:
- refs.length > 0
+ refs.length > 0 || attachedSourceOnly
? undefined
: { code: "no_refs", message: "No reference problems found" },
};
yield step("rag", "done", timestamp(), ragGate);
- if (refs.length === 0) {
+ if (refs.length === 0 && !attachedSourceOnly) {
yield {
type: "error",
stage: "rag",
diff --git a/packages/agent/tests/backend-contract.test.ts b/packages/agent/tests/backend-contract.test.ts
index b60fbe0..be413f0 100644
--- a/packages/agent/tests/backend-contract.test.ts
+++ b/packages/agent/tests/backend-contract.test.ts
@@ -105,7 +105,7 @@ describe("SSE wire adapter", () => {
event: "step",
data: {
index: 6,
- name: "학습 목표 매핑",
+ name: "학습 목표 점검",
status: "failed",
summary: "intent_topic_mismatch: Intent does not match requested topic",
},
@@ -296,7 +296,7 @@ describe("SSE wire adapter", () => {
event: "step",
data: JSON.stringify({
index: 1,
- name: "RAG 검색",
+ name: "비슷한 문제 찾기",
status: "completed",
summary: null,
}),
diff --git a/packages/agent/tests/extract.test.ts b/packages/agent/tests/extract.test.ts
index 9251a38..76a40c7 100644
--- a/packages/agent/tests/extract.test.ts
+++ b/packages/agent/tests/extract.test.ts
@@ -62,6 +62,7 @@ describe("resolveClassification", () => {
const base: LlmClassification = {
topic_code: "9수02-09",
topic_name: "이차방정식",
+ generation_kind: "equation",
problem_type: "short_answer",
difficulty: "medium",
confidence: 0.9,
@@ -75,6 +76,7 @@ describe("resolveClassification", () => {
expect(r.grade).toBe(3);
expect(r.topic_name).toBe("이차방정식");
expect(r.confidence).toBe(0.9);
+ expect(r.generation_kind).toBe("equation");
});
it("recovers via name when the code is wrong, lowering confidence", () => {
diff --git a/packages/agent/tests/generator-agent.test.ts b/packages/agent/tests/generator-agent.test.ts
index 298b7e6..93ecbe1 100644
--- a/packages/agent/tests/generator-agent.test.ts
+++ b/packages/agent/tests/generator-agent.test.ts
@@ -164,6 +164,45 @@ describe("createGeneratorAgent", () => {
prompt: expect.stringContaining("question_text is required"),
});
});
+
+ it("prefers request.generation_kind over the topic-derived kind (attached flow)", async () => {
+ aiMock.generateObject.mockResolvedValue({ object: modelObject });
+ const render = vi.fn(() => "rendered prompt");
+ const agent = createGeneratorAgent({
+ model: {} as LanguageModel,
+ modelId: "test-model",
+ promptId: "problem-generator",
+ prompts: promptLoader(render),
+ });
+
+ const generated = await agent.generate({
+ request: { ...request, source_origin: "attached", generation_kind: "geometry" },
+ intent,
+ refs: [],
+ strategy: null,
+ attempt: 1,
+ });
+
+ // 토픽 9수02-10 은 equation 으로 파생되지만, 첨부에서 추론한 kind 가 우선한다.
+ expect(generated.generation_kind).toBe("geometry");
+ expect(render.mock.calls[0]?.[0]).toMatchObject({ attached: true, generationKind: "geometry" });
+ });
+
+ it("falls back to the topic-derived kind when generation_kind is absent (corpus flow)", async () => {
+ aiMock.generateObject.mockResolvedValue({ object: modelObject });
+ const render = vi.fn(() => "rendered prompt");
+ const agent = createGeneratorAgent({
+ model: {} as LanguageModel,
+ modelId: "test-model",
+ promptId: "problem-generator",
+ prompts: promptLoader(render),
+ });
+
+ const generated = await agent.generate({ request, intent, refs: [], strategy: null, attempt: 1 });
+
+ expect(generated.generation_kind).toBe("equation");
+ expect(render.mock.calls[0]?.[0]).toMatchObject({ attached: false });
+ });
});
function promptLoader(render: LoadedPrompt["render"]): PromptLoader {
diff --git a/packages/agent/tests/verification-workflow-generate-failure.test.ts b/packages/agent/tests/verification-workflow-generate-failure.test.ts
index 9bbaea4..d5a27fd 100644
--- a/packages/agent/tests/verification-workflow-generate-failure.test.ts
+++ b/packages/agent/tests/verification-workflow-generate-failure.test.ts
@@ -199,3 +199,44 @@ describe("runVerificationWorkflow when generation itself fails", () => {
expect(events.filter((event) => event.type === "preview")).toHaveLength(0);
});
});
+
+describe("runVerificationWorkflow with an attached problem and no corpus matches", () => {
+ it("does not abort at RAG (source-only) when refs are empty", async () => {
+ const emptyRag: RagClient = { search: async () => [] };
+ const attachedRequest: GenerateRequest = {
+ ...request,
+ source_origin: "attached",
+ source_problem_text: "x**2 - 5*x + 6 = 0 의 해를 구하시오.",
+ };
+ // 생성기 자체는 던지게 둔다 — 여기서 증명할 것은 'RAG 가 중단하지 않고 생성까지 갔다'.
+ const generate = vi.fn(async () => {
+ throw new Error("stop after reaching generate");
+ });
+
+ const events: ProgressEvent[] = [];
+ for await (const event of runVerificationWorkflow(
+ {
+ rag: emptyRag,
+ mathEngine,
+ prompts,
+ strategies,
+ intentModel: new MockLanguageModelV1(),
+ generator: { generate },
+ critic,
+ refiner,
+ solver,
+ },
+ attachedRequest,
+ { deterministicFallback: "off", maxRetries: 1, perStepTimeoutMs: 1_000 },
+ )) {
+ events.push(event);
+ }
+
+ // 옛 동작이면 refs=0 에서 no_refs 로 즉시 return → generate 미호출.
+ // 새 동작: 첨부 source-only 는 중단하지 않고 진행하므로 generate 가 호출된다.
+ expect(generate).toHaveBeenCalled();
+ expect(
+ events.some((event) => event.type === "error" && event.code === "no_refs"),
+ ).toBe(false);
+ });
+});
diff --git a/packages/web/app/app/new/attach/view.tsx b/packages/web/app/app/new/attach/view.tsx
index 59fbc39..859a291 100644
--- a/packages/web/app/app/new/attach/view.tsx
+++ b/packages/web/app/app/new/attach/view.tsx
@@ -117,7 +117,9 @@ export function AttachView() {
setQuestionText(res.extraction.question_text);
setSchoolLevel(level);
setGrade(seededGrade);
- setTopicCode(scope.some((t) => t.code === c.topic_code) ? c.topic_code : "");
+ // 가드 C: 자동 인식 확신이 낮으면 단원을 미리 고르지 않고 사용자가 직접 확인하게 한다.
+ const confidentTopic = c.confidence >= 0.5 && scope.some((t) => t.code === c.topic_code);
+ setTopicCode(confidentTopic ? c.topic_code : "");
setIsoMode("structural");
};
@@ -178,6 +180,8 @@ export function AttachView() {
item_id: itemId,
question_text: questionText.trim(),
difficulty_norm: difficulty,
+ source_origin: "attached",
+ generation_kind: result?.classification.generation_kind,
}),
);
} catch (err) {
diff --git a/packages/web/app/app/new/verify/view.tsx b/packages/web/app/app/new/verify/view.tsx
index 0cebfd5..103011f 100644
--- a/packages/web/app/app/new/verify/view.tsx
+++ b/packages/web/app/app/new/verify/view.tsx
@@ -27,6 +27,8 @@ type IntentSource = {
item_id: string;
question_text: string;
difficulty_norm: "easy" | "medium" | "hard";
+ source_origin?: "corpus" | "attached";
+ generation_kind?: string;
};
const STATUS_ICON: Record = {
@@ -118,10 +120,20 @@ function parseIntentSource(raw: unknown): IntentSource | null {
) {
return null;
}
+ const source_origin =
+ o.source_origin === "attached"
+ ? "attached"
+ : o.source_origin === "corpus"
+ ? "corpus"
+ : undefined;
+ const generation_kind =
+ typeof o.generation_kind === "string" ? o.generation_kind : undefined;
return {
item_id: o.item_id,
question_text: o.question_text,
difficulty_norm: o.difficulty_norm,
+ ...(source_origin === undefined ? {} : { source_origin }),
+ ...(generation_kind === undefined ? {} : { generation_kind }),
};
}
@@ -172,8 +184,10 @@ export function VerifyView({ schoolLevel, grade, topic, mode, srcRef }: Props) {
mode,
sourceItemId,
sourceProblemText,
+ sourceOrigin: intentSource?.source_origin,
+ generationKind: intentSource?.generation_kind,
};
- }, [gateValid, schoolLevel, grade, topic, mode, sourceItemId, sourceProblemText]);
+ }, [gateValid, schoolLevel, grade, topic, mode, sourceItemId, sourceProblemText, intentSource]);
const stream = useVerificationStream(input);
const announceRef = useRef(null);
diff --git a/packages/web/hooks/use-verification-stream.ts b/packages/web/hooks/use-verification-stream.ts
index a200c45..12647a6 100644
--- a/packages/web/hooks/use-verification-stream.ts
+++ b/packages/web/hooks/use-verification-stream.ts
@@ -25,12 +25,12 @@ import { verificationStorageKey } from "@/lib/verification-storage-key";
* ──────────────────────────────────────────────────────────── */
const STEP_NAMES: readonly string[] = [
- "RAG 검색",
- "의도 추출",
+ "비슷한 문제 찾기",
+ "출제 의도 분석",
"문제 생성",
"산술 검증 (SymPy)",
"독립 재풀이",
- "학습 목표 매핑",
+ "학습 목표 점검",
] as const;
export type StepStatus = "pending" | "active" | "pass" | "fail" | "unverified";
@@ -106,6 +106,10 @@ export type StreamInput = {
mode: "structural" | "conceptual";
sourceItemId: string;
sourceProblemText?: string;
+ /** "attached" 면 첨부 문제 플로우 — 생성기가 refs 보다 첨부 문제를 우선한다. 기본 corpus. */
+ sourceOrigin?: "corpus" | "attached";
+ /** 첨부 문제에서 추론한 generation kind. 있으면 토픽 파생값보다 우선. */
+ generationKind?: string;
/** override agent endpoint. defaults to NEXT_PUBLIC_AGENT_URL or localhost:31415 */
endpoint?: string;
};
@@ -408,6 +412,8 @@ export function useVerificationStream(
mode: current.mode,
source_item_id: current.sourceItemId,
source_problem_text: current.sourceProblemText,
+ source_origin: current.sourceOrigin,
+ generation_kind: current.generationKind,
}),
signal: controller.signal,
openWhenHidden: true,
diff --git a/packages/web/lib/extract-client.ts b/packages/web/lib/extract-client.ts
index 193abe9..b0294bd 100644
--- a/packages/web/lib/extract-client.ts
+++ b/packages/web/lib/extract-client.ts
@@ -23,6 +23,8 @@ export type Classification = {
topic_name: string;
problem_type: string;
difficulty: "easy" | "medium" | "hard";
+ /** 문제에서 추론한 풀이 종류. 생성 시 토픽 파생값보다 우선(토픽 오분류 보호). */
+ generation_kind?: string;
confidence: number;
alternatives: { topic_code: string; topic_name: string }[];
};
From c6d8b4e836054772dc37953a3e5d10d4d6543896 Mon Sep 17 00:00:00 2001
From: LeeJhin
Date: Thu, 11 Jun 2026 21:42:49 +0900
Subject: [PATCH 4/4] fix(attach): address cubic - inputKey, kind gating,
dedup, msg
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
packages/agent/src/agents/classifier-agent.ts | 26 +++++++++++++++----
packages/agent/src/agents/generator-agent.ts | 10 ++++---
packages/agent/src/server/routes/extract.ts | 2 +-
packages/agent/tests/extract.test.ts | 9 ++++---
packages/web/hooks/use-verification-stream.ts | 2 +-
5 files changed, 34 insertions(+), 15 deletions(-)
diff --git a/packages/agent/src/agents/classifier-agent.ts b/packages/agent/src/agents/classifier-agent.ts
index 5455514..cf1ad80 100644
--- a/packages/agent/src/agents/classifier-agent.ts
+++ b/packages/agent/src/agents/classifier-agent.ts
@@ -79,10 +79,12 @@ export function resolveClassification(raw: LlmClassification): Classification {
const byCode = findCurriculumTopic(raw.topic_code);
const matched = byCode ?? findCurriculumTopicByName(raw.topic_name);
- const alternatives = raw.alternatives
- .map((alt) => findCurriculumTopic(alt.topic_code) ?? findCurriculumTopicByName(alt.topic_name))
- .filter((topic): topic is NonNullable => topic !== undefined)
- .map((topic) => ({ topic_code: topic.code, topic_name: topic.name }));
+ const alternatives = dedupeByCode(
+ raw.alternatives
+ .map((alt) => findCurriculumTopic(alt.topic_code) ?? findCurriculumTopicByName(alt.topic_name))
+ .filter((topic): topic is NonNullable => topic !== undefined)
+ .map((topic) => ({ topic_code: topic.code, topic_name: topic.name })),
+ );
if (matched === undefined) {
// 코드·이름 모두 카탈로그 밖. 유효한 대안이 있으면 그 첫 번째로 강등 매칭한다
@@ -99,7 +101,7 @@ export function resolveClassification(raw: LlmClassification): Classification {
problem_type: raw.problem_type,
difficulty: raw.difficulty,
confidence: Math.min(raw.confidence, 0.4),
- alternatives: alternatives.slice(1),
+ alternatives: alternatives.filter((alt) => alt.topic_code !== promoted.code),
};
}
// 대안도 없음 — 단원을 비워 사용자가 확인 화면에서 직접 고르게 한다.
@@ -130,3 +132,17 @@ export function resolveClassification(raw: LlmClassification): Classification {
alternatives: alternatives.filter((alt) => alt.topic_code !== matched.code),
};
}
+
+/** 같은 topic_code 중복 제거(첫 항목 유지) — LLM 이 같은 대안을 반복해도 UI 중복을 막는다. */
+function dedupeByCode(
+ items: { topic_code: string; topic_name: string }[],
+): { topic_code: string; topic_name: string }[] {
+ const seen = new Set();
+ const out: { topic_code: string; topic_name: string }[] = [];
+ for (const item of items) {
+ if (seen.has(item.topic_code)) continue;
+ seen.add(item.topic_code);
+ out.push(item);
+ }
+ return out;
+}
diff --git a/packages/agent/src/agents/generator-agent.ts b/packages/agent/src/agents/generator-agent.ts
index 0eba82c..daff00d 100644
--- a/packages/agent/src/agents/generator-agent.ts
+++ b/packages/agent/src/agents/generator-agent.ts
@@ -78,8 +78,9 @@ export function assembleGeneratedProblem(input: {
readonly promptVersion: string;
}): GeneratedProblem {
const generationKind =
- input.request.generation_kind ??
- generationKindForTopic(getGenerateRequestTopicCode(input.request));
+ input.request.source_origin === "attached" && input.request.generation_kind !== undefined
+ ? input.request.generation_kind
+ : generationKindForTopic(getGenerateRequestTopicCode(input.request));
return {
candidate_id: randomUUID(),
mode: input.request.mode === "conceptual" ? "conceptual" : "structural",
@@ -118,8 +119,9 @@ export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent {
async generate(input) {
const prompt = await deps.prompts.load(deps.promptId);
const generationKind =
- input.request.generation_kind ??
- generationKindForTopic(getGenerateRequestTopicCode(input.request));
+ input.request.source_origin === "attached" && input.request.generation_kind !== undefined
+ ? input.request.generation_kind
+ : generationKindForTopic(getGenerateRequestTopicCode(input.request));
const temperature = temperatureForGeneratorAttempt(
input.attempt,
prompt.metadata.temperature,
diff --git a/packages/agent/src/server/routes/extract.ts b/packages/agent/src/server/routes/extract.ts
index d994307..dd7b9e3 100644
--- a/packages/agent/src/server/routes/extract.ts
+++ b/packages/agent/src/server/routes/extract.ts
@@ -59,7 +59,7 @@ export function createExtractRoute(deps: ExtractRouteDeps): Hono {
}
const mediaType = file.type.toLowerCase();
if (!ALLOWED_IMAGE_TYPES.has(mediaType)) {
- return c.json({ error: "unsupported_media_type", message: "PNG · JPG · WEBP 이미지만 올릴 수 있어요." }, 415);
+ return c.json({ error: "unsupported_media_type", message: "지원하는 이미지 형식이 아니에요 (PNG · JPG · WEBP · HEIC)." }, 415);
}
const buffer = await file.arrayBuffer();
if (buffer.byteLength === 0) {
diff --git a/packages/agent/tests/extract.test.ts b/packages/agent/tests/extract.test.ts
index 76a40c7..80502e1 100644
--- a/packages/agent/tests/extract.test.ts
+++ b/packages/agent/tests/extract.test.ts
@@ -92,13 +92,14 @@ describe("resolveClassification", () => {
expect(r.school_level).toBe("high");
});
- it("keeps only valid alternatives and drops the matched topic", () => {
+ it("keeps only valid alternatives, dropping the matched topic, invalid codes, and duplicates", () => {
const r = resolveClassification({
...base,
alternatives: [
- { topic_code: "9수02-09", topic_name: "이차방정식" },
- { topic_code: "9수02-10", topic_name: "이차방정식의 활용" },
- { topic_code: "zzz", topic_name: "없음" },
+ { topic_code: "9수02-09", topic_name: "이차방정식" }, // == matched → dropped
+ { topic_code: "9수02-10", topic_name: "이차방정식의 활용" }, // valid
+ { topic_code: "9수02-10", topic_name: "이차방정식의 활용" }, // duplicate → dropped
+ { topic_code: "zzz", topic_name: "없음" }, // invalid → dropped
],
});
expect(r.alternatives).toEqual([{ topic_code: "9수02-10", topic_name: "이차방정식의 활용" }]);
diff --git a/packages/web/hooks/use-verification-stream.ts b/packages/web/hooks/use-verification-stream.ts
index 12647a6..3c6f76e 100644
--- a/packages/web/hooks/use-verification-stream.ts
+++ b/packages/web/hooks/use-verification-stream.ts
@@ -367,7 +367,7 @@ export function useVerificationStream(
const inputKey =
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 로 넣으면 가드가
* 무효화되어 부모 re-render 마다 SSE 재연결이 발생한다 (PR #7 리뷰).