diff --git a/packages/agent/prompts/problem-classifier.md b/packages/agent/prompts/problem-classifier.md
new file mode 100644
index 0000000..04285be
--- /dev/null
+++ b/packages/agent/prompts/problem-classifier.md
@@ -0,0 +1,43 @@
+---
+id: problem-classifier
+version: 0.1.0
+model: gpt-4o
+temperature: 0.1
+max_tokens: 800
+schema: LlmClassificationSchema
+variables:
+ - catalog
+ - questionText
+ - choices
+owner: 비할당
+updated: 2026-06-11
+---
+
+# Role
+
+너는 한국 수학 교육과정 단원 분류기다. 주어진 문제를 아래 **단원 카탈로그**의 코드 중 하나로 분류하라.
+
+# 단원 카탈로그 (이 코드들 중에서만 고른다)
+
+{{catalog}}
+
+# 분류할 문제
+
+발문: {{questionText}}
+
+보기: {{choices}}
+
+# 출력 규칙
+
+- `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). 여러 단원에 걸치거나 교육과정 밖으로 보이면 낮춘다.
+- `alternatives`: 헷갈리는 다른 후보 단원(카탈로그 코드)을 최대 2개. 없으면 빈 배열.
+
+# 주의
+
+- 문제를 풀지 말고 **무엇을 평가하는 단원인지**만 판단한다.
+- 학년이 애매하면 발문의 핵심 개념(예: 이차방정식 → 중3, 순환소수 → 중2)으로 판단한다.
diff --git a/packages/agent/prompts/problem-extractor.md b/packages/agent/prompts/problem-extractor.md
new file mode 100644
index 0000000..6c76470
--- /dev/null
+++ b/packages/agent/prompts/problem-extractor.md
@@ -0,0 +1,33 @@
+---
+id: problem-extractor
+version: 0.1.0
+model: gpt-4o
+temperature: 0.1
+max_tokens: 1500
+schema: ExtractionSchema
+variables: []
+owner: 비할당
+updated: 2026-06-11
+---
+
+# Role
+
+너는 한국 중·고등학교 수학 문제를 정확히 읽어 들이는 OCR·표기 정리 도우미다.
+입력으로 **이미지 한 장** 또는 **텍스트 한 덩어리**가 주어진다. 거기서 수학 문제 **한 개**를 읽어 구조화된 JSON으로 내보내라.
+
+# 출력 규칙
+
+- `question_text`: 문제 발문과 풀이에 필요한 수식을 그대로 옮긴다.
+ - 수식은 인라인 LaTeX `$...$` 로 적는다. 예: `$x^2 - 5x + 6 = 0$`, `$\frac{2}{3}$`, `$\sqrt{12}$`.
+ - 객관식 보기(①~⑤ 등)는 여기 넣지 말고 `choices` 로 분리한다.
+ - 문제 번호("3.", "12번")·배점·출처 표시는 제거한다.
+- `choices`: 객관식이면 보기 텍스트 배열(마커 포함, 예: `["① $2$", "② $3$"]`). 객관식이 아니면 `null`.
+- `answer_text`: 원본에 정답이 함께 보이면 그 값을 옮기고, 없으면 `null`.
+- `figure_dependent`: 그림·그래프·도형·표가 있어야만 풀 수 있고 본문 텍스트만으로 답이 결정되지 않으면 `true`. 좌표·길이·각이 본문에 모두 적혀 있어 텍스트만으로 풀리면 `false`.
+- `confidence`: 읽어 들인 본문의 정확도 확신(0~1). 흐릿함·손글씨·잘림·수식 누락이 있으면 낮춘다.
+
+# 금지
+
+- 보이지 않는 내용을 추측해 채우지 말 것. 안 보이면 비우고 `confidence` 를 낮춰라.
+- 문제를 풀거나 정답을 새로 계산하지 말 것. 원본에 적힌 것만 옮긴다.
+- 여러 문제가 보이면 가장 완전한 **한 개**만 고른다.
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
new file mode 100644
index 0000000..cf1ad80
--- /dev/null
+++ b/packages/agent/src/agents/classifier-agent.ts
@@ -0,0 +1,148 @@
+/** ClassifierAgent — 추출 문제를 교육과정 카탈로그 단원으로 분류한 뒤 코드로 스냅.
+ *
+ * LLM 은 카탈로그 안의 코드를 고르고, resolveClassification 이 그 결과를
+ * 카탈로그(curriculum-topics)에 맞춰 검증·보정한다. 코드를 못 맞추면 이름으로,
+ * 그것도 안 되면 확신을 크게 낮춰 사용자가 확인 화면에서 직접 고르게 한다.
+ */
+
+import { generateObject, type LanguageModel } from "ai";
+import { z } from "zod";
+
+import {
+ ClassificationAlternativeSchema,
+ DifficultySchema,
+ GenerationKindSchema,
+ ProblemTypeSchema,
+ findCurriculumTopic,
+ findCurriculumTopicByName,
+ formatCurriculumCatalog,
+ type Classification,
+ type Extraction,
+} from "../schemas/index.js";
+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),
+ alternatives: z.array(ClassificationAlternativeSchema).default([]),
+});
+
+export type LlmClassification = z.infer;
+
+export interface ClassifierAgentInput {
+ extraction: Pick;
+ signal?: AbortSignal;
+}
+
+export interface ClassifierAgent {
+ classify(input: ClassifierAgentInput): Promise;
+}
+
+export interface ClassifierAgentDeps {
+ model: LanguageModel;
+ modelId: string;
+ promptId: string;
+ prompts: PromptLoader;
+}
+
+export function createClassifierAgent(deps: ClassifierAgentDeps): ClassifierAgent {
+ return {
+ async classify(input) {
+ const prompt = await deps.prompts.load(deps.promptId);
+ const rendered = prompt.render({
+ catalog: formatCurriculumCatalog(),
+ questionText: input.extraction.question_text,
+ choices:
+ input.extraction.choices === null || input.extraction.choices.length === 0
+ ? "(없음)"
+ : input.extraction.choices.join(" / "),
+ });
+ const { object } = await generateObject({
+ model: deps.model,
+ schema: LlmClassificationSchema,
+ mode: "json",
+ temperature: prompt.metadata.temperature,
+ prompt: rendered,
+ abortSignal: input.signal,
+ });
+ return resolveClassification(object);
+ },
+ };
+}
+
+/** LLM 출력의 topic_code 를 카탈로그로 스냅. 코드 일치 우선 → 이름 매칭 → 폴백(확신 강등). */
+export function resolveClassification(raw: LlmClassification): Classification {
+ const byCode = findCurriculumTopic(raw.topic_code);
+ const matched = byCode ?? findCurriculumTopicByName(raw.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) {
+ // 코드·이름 모두 카탈로그 밖. 유효한 대안이 있으면 그 첫 번째로 강등 매칭한다
+ // (학교급을 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,
+ generation_kind: raw.generation_kind,
+ problem_type: raw.problem_type,
+ difficulty: raw.difficulty,
+ confidence: Math.min(raw.confidence, 0.4),
+ alternatives: alternatives.filter((alt) => alt.topic_code !== promoted.code),
+ };
+ }
+ // 대안도 없음 — 단원을 비워 사용자가 확인 화면에서 직접 고르게 한다.
+ return {
+ school_level: raw.topic_code.startsWith("10") ? "high" : "middle",
+ 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),
+ alternatives,
+ };
+ }
+
+ // 코드는 못 맞췄지만 이름으로 맞춘 경우 확신을 약간 강등.
+ const confidence = byCode === undefined ? Math.min(raw.confidence, 0.5) : raw.confidence;
+ return {
+ school_level: matched.school_level,
+ 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,
+ 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/extractor-agent.ts b/packages/agent/src/agents/extractor-agent.ts
new file mode 100644
index 0000000..998c097
--- /dev/null
+++ b/packages/agent/src/agents/extractor-agent.ts
@@ -0,0 +1,76 @@
+/** ExtractorAgent — 첨부 문제(이미지 또는 텍스트)를 읽어 Extraction 으로 변환.
+ *
+ * 이미지: 비전 모델에 image 파트로 첨부. 텍스트: 프롬프트에 본문을 덧붙여 정규화.
+ * 출력 스키마는 ExtractionSchema 그대로 (메타데이터 조립 없음).
+ */
+
+import { generateObject, type LanguageModel } from "ai";
+
+import { ExtractionSchema, type Extraction } from "../schemas/index.js";
+import type { PromptLoader } from "../tools/prompt-loader.js";
+
+export interface ExtractorImageInput {
+ kind: "image";
+ bytes: Uint8Array;
+ mediaType: string;
+}
+
+export interface ExtractorTextInput {
+ kind: "text";
+ text: string;
+}
+
+export type ExtractorInput = (ExtractorImageInput | ExtractorTextInput) & {
+ signal?: AbortSignal;
+};
+
+export interface ExtractorAgent {
+ extract(input: ExtractorInput): Promise;
+}
+
+export interface ExtractorAgentDeps {
+ model: LanguageModel;
+ modelId: string;
+ promptId: string;
+ prompts: PromptLoader;
+}
+
+export function createExtractorAgent(deps: ExtractorAgentDeps): ExtractorAgent {
+ return {
+ async extract(input) {
+ const prompt = await deps.prompts.load(deps.promptId);
+ const instructions = prompt.render({});
+ const temperature = prompt.metadata.temperature;
+
+ if (input.kind === "image") {
+ const { object } = await generateObject({
+ model: deps.model,
+ schema: ExtractionSchema,
+ mode: "json",
+ temperature,
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: instructions },
+ { type: "image", image: input.bytes, mimeType: input.mediaType },
+ ],
+ },
+ ],
+ abortSignal: input.signal,
+ });
+ return object;
+ }
+
+ const { object } = await generateObject({
+ model: deps.model,
+ schema: ExtractionSchema,
+ mode: "json",
+ temperature,
+ prompt: `${instructions}\n\n# 입력 문제 (텍스트)\n\n${input.text}`,
+ abortSignal: input.signal,
+ });
+ return object;
+ },
+ };
+}
diff --git a/packages/agent/src/agents/generator-agent.ts b/packages/agent/src/agents/generator-agent.ts
index b1267f2..daff00d 100644
--- a/packages/agent/src/agents/generator-agent.ts
+++ b/packages/agent/src/agents/generator-agent.ts
@@ -77,7 +77,10 @@ export function assembleGeneratedProblem(input: {
readonly promptId: string;
readonly promptVersion: string;
}): GeneratedProblem {
- const generationKind = generationKindForTopic(getGenerateRequestTopicCode(input.request));
+ const generationKind =
+ 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",
@@ -115,7 +118,10 @@ 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.source_origin === "attached" && input.request.generation_kind !== undefined
+ ? input.request.generation_kind
+ : generationKindForTopic(getGenerateRequestTopicCode(input.request));
const temperature = temperatureForGeneratorAttempt(
input.attempt,
prompt.metadata.temperature,
@@ -123,6 +129,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/agents/index.ts b/packages/agent/src/agents/index.ts
index e08ef74..6b4080a 100644
--- a/packages/agent/src/agents/index.ts
+++ b/packages/agent/src/agents/index.ts
@@ -1,4 +1,6 @@
+export * from "./classifier-agent.js";
export * from "./constraint-critic-agent.js";
+export * from "./extractor-agent.js";
export * from "./generator-agent.js";
export * from "./refiner-agent.js";
export * from "./solver-agent.js";
diff --git a/packages/agent/src/config/env.ts b/packages/agent/src/config/env.ts
index 41272aa..8b7e7d0 100644
--- a/packages/agent/src/config/env.ts
+++ b/packages/agent/src/config/env.ts
@@ -22,6 +22,10 @@ export const EnvSchema = z.object({
* with a separate LanguageModel instance to decorrelate errors with generation.
* When unset, the solver shares the generator's resolved model. */
SOLVER_MODEL: z.string().min(1).optional(),
+ /** Optional override for the attached-problem extractor (POST /api/extract).
+ * Image upload needs a vision-capable model; set this to a vision model id
+ * when the main LLM_MODEL is text-only. When unset, reuses LLM_MODEL. */
+ EXTRACT_MODEL: z.string().min(1).optional(),
OPENAI_API_KEY: z.string().min(1).optional(),
OPENAI_MODEL: z.string().min(1).optional(),
CLIPROXY_BASE_URL: z.string().url().optional(),
diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts
index 130f61d..20e08f4 100644
--- a/packages/agent/src/index.ts
+++ b/packages/agent/src/index.ts
@@ -7,7 +7,9 @@ import { loadEnv } from "./config/index.js";
import { DEFAULT_MODELS } from "./config/models.js";
import { createApp } from "./server/app.js";
import {
+ createClassifierAgent,
createConstraintCriticAgent,
+ createExtractorAgent,
createGeneratorAgent,
createRefinerAgent,
createSolverAgent,
@@ -63,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({
@@ -111,10 +116,29 @@ export async function main(): Promise {
promptId: "independent-solver",
prompts,
});
+ const extractModel = env.EXTRACT_MODEL ?? llmModel;
+ const extractLlm = buildAgentModel(extractModel, `${extractModel} (extract)`);
+ const extractor = extractLlm === undefined
+ ? undefined
+ : createExtractorAgent({
+ model: extractLlm,
+ modelId: extractModel,
+ promptId: "problem-extractor",
+ prompts,
+ });
+ const classifier = llm === undefined
+ ? undefined
+ : createClassifierAgent({
+ model: llm,
+ modelId: llmModel,
+ promptId: "problem-classifier",
+ prompts,
+ });
const app = createApp({
mathEngine,
rag,
+ extract: { extractor, classifier },
workflow: {
rag,
mathEngine,
diff --git a/packages/agent/src/schemas/curriculum-topics.ts b/packages/agent/src/schemas/curriculum-topics.ts
new file mode 100644
index 0000000..b4a312c
--- /dev/null
+++ b/packages/agent/src/schemas/curriculum-topics.ts
@@ -0,0 +1,117 @@
+/**
+ * 교육과정 단원 카탈로그 — 중1~중3 + 고등 공통수학 (총 43개).
+ *
+ * 첨부 문제 분류(classifier-agent)가 이 코드 집합 안에서만 단원을 고르고,
+ * extract 라우트가 분류 결과를 유효한 코드로 스냅한다. FE `packages/web/app/app/new/topic/data.ts`
+ * 와 동일한 집합이어야 한다 (코드↔이름↔학년). 동기화 테스트로 보장.
+ *
+ * generation-kind.schema.ts 의 TOPIC_KIND_BY_CODE 와 코드 집합이 일치한다.
+ */
+
+import { z } from "zod";
+
+import { SchoolLevelSchema } from "./source-problem.schema.js";
+
+export const CurriculumTopicSchema = z.object({
+ school_level: SchoolLevelSchema,
+ grade: z.union([z.literal(1), z.literal(2), z.literal(3)]).nullable(),
+ code: z.string().min(1),
+ name: z.string().min(1),
+});
+
+export type CurriculumTopic = z.infer;
+
+export const CURRICULUM_TOPICS: readonly CurriculumTopic[] = [
+ /* 중1 */
+ { school_level: "middle", grade: 1, code: "9수01-01", name: "소인수분해" },
+ { school_level: "middle", grade: 1, code: "9수01-02", name: "정수와 유리수" },
+ { school_level: "middle", grade: 1, code: "9수01-03", name: "유리수의 사칙연산" },
+ { school_level: "middle", grade: 1, code: "9수02-01", name: "문자의 사용과 식의 값" },
+ { school_level: "middle", grade: 1, code: "9수02-02", name: "일차식의 계산" },
+ { school_level: "middle", grade: 1, code: "9수02-03", name: "일차방정식" },
+ { school_level: "middle", grade: 1, code: "9수02-04", name: "일차방정식의 활용" },
+ { school_level: "middle", grade: 1, code: "9수03-01", name: "함수의 개념" },
+ { school_level: "middle", grade: 1, code: "9수04-01", name: "기본 도형과 작도" },
+ { school_level: "middle", grade: 1, code: "9수05-01", name: "자료의 정리와 해석" },
+ /* 중2 */
+ { school_level: "middle", grade: 2, code: "9수01-04", name: "유리수와 순환소수" },
+ { school_level: "middle", grade: 2, code: "9수02-05", name: "식의 계산" },
+ { school_level: "middle", grade: 2, code: "9수02-06", name: "일차부등식" },
+ { school_level: "middle", grade: 2, code: "9수02-07", name: "연립일차방정식" },
+ { school_level: "middle", grade: 2, code: "9수03-02", name: "일차함수와 그래프" },
+ { school_level: "middle", grade: 2, code: "9수03-03", name: "일차함수의 활용" },
+ { school_level: "middle", grade: 2, code: "9수04-02", name: "삼각형의 성질" },
+ { school_level: "middle", grade: 2, code: "9수04-03", name: "사각형의 성질" },
+ { school_level: "middle", grade: 2, code: "9수04-04", name: "도형의 닮음" },
+ { school_level: "middle", grade: 2, code: "9수05-02", name: "경우의 수와 확률" },
+ /* 중3 */
+ { school_level: "middle", grade: 3, code: "9수01-05", name: "제곱근과 실수" },
+ { school_level: "middle", grade: 3, code: "9수01-06", name: "근호를 포함한 식의 계산" },
+ { school_level: "middle", grade: 3, code: "9수02-08", name: "다항식의 곱셈과 인수분해" },
+ { school_level: "middle", grade: 3, code: "9수02-09", name: "이차방정식" },
+ { school_level: "middle", grade: 3, code: "9수02-10", name: "이차방정식의 활용" },
+ { school_level: "middle", grade: 3, code: "9수03-04", name: "이차함수와 그래프" },
+ { school_level: "middle", grade: 3, code: "9수04-05", name: "삼각비" },
+ { school_level: "middle", grade: 3, code: "9수04-06", name: "원과 직선의 위치 관계" },
+ { school_level: "middle", grade: 3, code: "9수04-07", name: "원주각" },
+ { school_level: "middle", grade: 3, code: "9수05-03", name: "대푯값과 산포도" },
+ /* 고등 공통수학 */
+ { school_level: "high", grade: null, code: "10공수01-01", name: "다항식의 연산" },
+ { school_level: "high", grade: null, code: "10공수01-02", name: "나머지정리" },
+ { school_level: "high", grade: null, code: "10공수01-03", name: "인수분해" },
+ { school_level: "high", grade: null, code: "10공수01-04", name: "복소수와 이차방정식" },
+ { school_level: "high", grade: null, code: "10공수01-05", name: "이차방정식과 이차함수" },
+ { school_level: "high", grade: null, code: "10공수02-01", name: "직선의 방정식" },
+ { school_level: "high", grade: null, code: "10공수02-02", name: "원의 방정식" },
+ { school_level: "high", grade: null, code: "10공수03-01", name: "집합" },
+ { school_level: "high", grade: null, code: "10공수03-02", name: "명제" },
+ { school_level: "high", grade: null, code: "10공수04-01", name: "함수" },
+ { school_level: "high", grade: null, code: "10공수04-02", name: "유리함수와 무리함수" },
+ { school_level: "high", grade: null, code: "10공수05-01", name: "경우의 수" },
+ { school_level: "high", grade: null, code: "10공수05-02", name: "순열과 조합" },
+];
+
+export function gradeScopeLabel(topic: Pick): string {
+ if (topic.school_level === "high") return "고등 공통수학";
+ return topic.grade === null ? "중등" : `중${topic.grade}`;
+}
+
+export function findCurriculumTopic(code: string): CurriculumTopic | undefined {
+ return CURRICULUM_TOPICS.find((topic) => topic.code === code);
+}
+
+/** 이름 기준 느슨한 매칭 — 정확 일치 우선, 없으면 부분 포함. 분류기가 코드를 틀렸을 때의 폴백. */
+export function findCurriculumTopicByName(name: string): CurriculumTopic | undefined {
+ const trimmed = name.trim();
+ 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),
+ );
+}
+
+export function curriculumTopicsForScope(
+ schoolLevel: CurriculumTopic["school_level"],
+ grade: CurriculumTopic["grade"],
+): CurriculumTopic[] {
+ return CURRICULUM_TOPICS.filter(
+ (topic) => topic.school_level === schoolLevel && topic.grade === grade,
+ );
+}
+
+/** 분류기 프롬프트에 넣을 카탈로그 문자열. 학년별로 묶어 `- ` 라인으로 나열. */
+export function formatCurriculumCatalog(): string {
+ const groups: { label: string; items: CurriculumTopic[] }[] = [
+ { label: "중1", items: curriculumTopicsForScope("middle", 1) },
+ { label: "중2", items: curriculumTopicsForScope("middle", 2) },
+ { label: "중3", items: curriculumTopicsForScope("middle", 3) },
+ { label: "고등 공통수학", items: curriculumTopicsForScope("high", null) },
+ ];
+ return groups
+ .map((group) => {
+ const lines = group.items.map((topic) => `- ${topic.code} ${topic.name}`).join("\n");
+ return `[${group.label}]\n${lines}`;
+ })
+ .join("\n\n");
+}
diff --git a/packages/agent/src/schemas/extract.schema.ts b/packages/agent/src/schemas/extract.schema.ts
new file mode 100644
index 0000000..528c63f
--- /dev/null
+++ b/packages/agent/src/schemas/extract.schema.ts
@@ -0,0 +1,78 @@
+/**
+ * Extract — POST /api/extract 의 응답 도메인 타입.
+ *
+ * 첨부 문제(텍스트 붙여넣기 또는 이미지)를 읽어 들여 (extraction) 교육과정
+ * 단원을 자동 인식한 (classification) 결과. 사용자가 확인·수정한 뒤
+ * source_problem_text 로 기존 생성 파이프라인(POST /api/generate)에 투입된다.
+ *
+ * - ExtractionSchema 는 extractor-agent 의 generateObject 출력 스키마로도 쓰인다.
+ * - Classification 은 classifier-agent 가 카탈로그(curriculum-topics)로 스냅한 결과.
+ */
+
+import { z } from "zod";
+
+import { GenerationKindSchema } from "./generation-kind.schema.js";
+import {
+ DifficultySchema,
+ ProblemTypeSchema,
+ SchoolLevelSchema,
+} from "./source-problem.schema.js";
+
+export const ExtractionSchema = z.object({
+ question_text: z
+ .string()
+ .min(1)
+ .describe("읽어 들인 문제 본문. KaTeX 렌더 가능한 LaTeX 포함 plain text. 보기는 제외."),
+ choices: z
+ .array(z.string())
+ .nullable()
+ .default(null)
+ .describe("객관식이면 보기 배열(예: ['① ...','② ...']). 객관식이 아니면 null."),
+ answer_text: z
+ .string()
+ .nullable()
+ .default(null)
+ .describe("원본에 정답이 함께 보이면 그 정답, 없으면 null."),
+ figure_dependent: z
+ .boolean()
+ .describe("그림·그래프·표가 있어야만 풀 수 있고 본문 텍스트만으로는 결정되지 않으면 true."),
+ confidence: z
+ .number()
+ .min(0)
+ .max(1)
+ .describe("읽어 들인 본문의 정확도 확신(0~1). 흐릿함·손글씨·수식 누락이면 낮춘다."),
+});
+
+export type Extraction = z.infer;
+
+export const ClassificationAlternativeSchema = z.object({
+ topic_code: z.string(),
+ topic_name: z.string(),
+});
+
+export type ClassificationAlternative = z.infer;
+
+export const ClassificationSchema = z.object({
+ school_level: SchoolLevelSchema,
+ grade: z.union([z.literal(1), z.literal(2), z.literal(3)]).nullable(),
+ topic_code: z.string(),
+ 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([]),
+});
+
+export type Classification = z.infer;
+
+export const ExtractResponseSchema = z.object({
+ extraction: ExtractionSchema,
+ classification: ClassificationSchema,
+ /** 사용자에게 보여줄 평문 경고 (그림 의존, 단원 매칭 불확실 등). */
+ warnings: z.array(z.string()).default([]),
+});
+
+export type ExtractResponse = z.infer;
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/schemas/index.ts b/packages/agent/src/schemas/index.ts
index 2d9d46d..beb904a 100644
--- a/packages/agent/src/schemas/index.ts
+++ b/packages/agent/src/schemas/index.ts
@@ -4,6 +4,8 @@
* 사용: `import { IntentSchema, type Intent } from "@/schemas";`
*/
+export * from "./curriculum-topics.js";
+export * from "./extract.schema.js";
export * from "./generate-request.schema.js";
export * from "./generation-kind.schema.js";
export * from "./generated-problem.schema.js";
diff --git a/packages/agent/src/server/app.ts b/packages/agent/src/server/app.ts
index c499635..eb3c30d 100644
--- a/packages/agent/src/server/app.ts
+++ b/packages/agent/src/server/app.ts
@@ -7,6 +7,7 @@ import type { MathEngineClient } from "../tools/math-engine-client.js";
import type { RagClient } from "../tools/rag-client.js";
import type { RunTraceWriter } from "../tools/run-trace.js";
import type { RunOptions, VerificationWorkflowDeps } from "../workflows/verification-workflow.js";
+import { createExtractRoute, type ExtractRouteDeps } from "./routes/extract.js";
import { createGenerateRoute } from "./routes/generate.js";
import { createHealthRoute } from "./routes/health.js";
import { createSourceProblemsRoute } from "./routes/source-problems.js";
@@ -15,6 +16,7 @@ export interface AppDeps {
mathEngine: MathEngineClient;
rag: RagClient;
workflow: VerificationWorkflowDeps;
+ extract: ExtractRouteDeps;
workflowOptions?: RunOptions;
trace?: RunTraceWriter;
}
@@ -33,6 +35,7 @@ export function createApp(deps: AppDeps): Hono {
app.route("/", createHealthRoute(deps.mathEngine));
app.route("/", createSourceProblemsRoute(deps.rag));
+ app.route("/", createExtractRoute(deps.extract));
app.route("/", createGenerateRoute(deps.workflow, deps.workflowOptions, deps.trace));
return app;
diff --git a/packages/agent/src/server/routes/extract.ts b/packages/agent/src/server/routes/extract.ts
new file mode 100644
index 0000000..dd7b9e3
--- /dev/null
+++ b/packages/agent/src/server/routes/extract.ts
@@ -0,0 +1,146 @@
+/**
+ * POST /api/extract — 첨부 문제 읽기 + 단원 자동 인식.
+ *
+ * 입력: multipart/form-data(`image` 파일) 또는 application/json(`{ text }`).
+ * 처리: extractor-agent(이미지=비전, 텍스트=정규화) → classifier-agent(단원 스냅).
+ * 출력: ExtractResponse(extraction + classification + warnings).
+ *
+ * 모델 미설정 시 503. 추출/분류 실패는 502 + 평문 메시지(사용자에게 그대로 노출 가능).
+ * 생성 파이프라인(POST /api/generate)은 건드리지 않는다 — 확인 화면을 거쳐 합류.
+ */
+
+import { Hono } from "hono";
+
+import type { ClassifierAgent, ExtractorAgent } from "../../agents/index.js";
+import {
+ ExtractResponseSchema,
+ type Classification,
+ type Extraction,
+ type ExtractResponse,
+} from "../../schemas/index.js";
+import { isFigureDependent } from "./source-problems.js";
+
+export interface ExtractRouteDeps {
+ extractor?: ExtractorAgent;
+ classifier?: ClassifierAgent;
+}
+
+const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
+const ALLOWED_IMAGE_TYPES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/jpg",
+ "image/webp",
+ "image/heic",
+ "image/heif",
+]);
+const MAX_TEXT_LENGTH = 8000;
+const LOW_CONFIDENCE = 0.5;
+
+export function createExtractRoute(deps: ExtractRouteDeps): Hono {
+ const app = new Hono();
+
+ app.post("/api/extract", async (c) => {
+ const { extractor, classifier } = deps;
+ if (extractor === undefined || classifier === undefined) {
+ return c.json(
+ { error: "extraction_unavailable", message: "문제 읽기 기능을 사용할 수 없습니다 (모델 미설정)." },
+ 503,
+ );
+ }
+
+ let extraction: Extraction;
+ try {
+ const contentType = c.req.header("content-type") ?? "";
+ if (contentType.includes("multipart/form-data")) {
+ const file = (await c.req.parseBody())["image"];
+ if (!(file instanceof File)) {
+ return c.json({ error: "bad_request", message: "이미지 파일이 필요합니다." }, 400);
+ }
+ const mediaType = file.type.toLowerCase();
+ if (!ALLOWED_IMAGE_TYPES.has(mediaType)) {
+ return c.json({ error: "unsupported_media_type", message: "지원하는 이미지 형식이 아니에요 (PNG · JPG · WEBP · HEIC)." }, 415);
+ }
+ const buffer = await file.arrayBuffer();
+ if (buffer.byteLength === 0) {
+ return c.json({ error: "bad_request", message: "빈 파일입니다." }, 400);
+ }
+ 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),
+ mediaType: mediaType === "image/jpg" ? "image/jpeg" : mediaType,
+ });
+ } else {
+ const json: unknown = await c.req.json().catch(() => null);
+ const text =
+ json !== null && typeof json === "object"
+ ? (json as Record).text
+ : undefined;
+ if (typeof text !== "string" || text.trim().length === 0) {
+ return c.json({ error: "bad_request", message: "문제 텍스트가 필요합니다." }, 400);
+ }
+ if (text.length > MAX_TEXT_LENGTH) {
+ return c.json({ error: "payload_too_large", message: "문제 텍스트가 너무 깁니다." }, 413);
+ }
+ extraction = await extractor.extract({ kind: "text", text });
+ }
+ } catch (err) {
+ console.error("[extract] extraction failed:", err instanceof Error ? err.message : err);
+ return c.json(
+ { error: "extraction_failed", message: "문제를 읽지 못했어요. 다른 사진이나 텍스트로 다시 시도해 주세요." },
+ 502,
+ );
+ }
+
+ let classification: Classification;
+ try {
+ classification = await classifier.classify({ extraction });
+ } catch (err) {
+ // 분류 실패는 치명적이지 않다 — 추출은 성공했으니 단원을 비운 채 200 으로
+ // 확인 화면을 열어 사용자가 학년 · 단원을 직접 고르게 한다 (수동 폴백).
+ console.error("[extract] classification failed:", err instanceof Error ? err.message : err);
+ classification = manualPickClassification(extraction);
+ }
+
+ const response: ExtractResponse = ExtractResponseSchema.parse({
+ extraction,
+ classification,
+ warnings: buildWarnings(extraction, classification),
+ });
+ return c.json(response);
+ });
+
+ return app;
+}
+
+function buildWarnings(extraction: Extraction, classification: Classification): string[] {
+ const warnings: string[] = [];
+ if (extraction.figure_dependent || isFigureDependent(extraction.question_text)) {
+ warnings.push("그림 · 그래프가 필요한 문제 같아요. 텍스트만으로는 변형이 정확하지 않을 수 있어요.");
+ }
+ if (extraction.confidence < LOW_CONFIDENCE) {
+ warnings.push("문제를 또렷하게 읽지 못했어요. 아래 본문을 확인하고 고쳐 주세요.");
+ }
+ if (classification.topic_code === "" || classification.confidence < LOW_CONFIDENCE) {
+ warnings.push("학년 · 단원 자동 인식이 확실하지 않아요. 맞는지 확인해 주세요.");
+ }
+ 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/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
new file mode 100644
index 0000000..80502e1
--- /dev/null
+++ b/packages/agent/tests/extract.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from "vitest";
+
+import type { ClassifierAgent, ExtractorAgent } from "../src/agents/index.js";
+import { resolveClassification, type LlmClassification } from "../src/agents/classifier-agent.js";
+import { createExtractRoute } from "../src/server/routes/extract.js";
+import {
+ CURRICULUM_TOPICS,
+ GenerationKindSchema,
+ generationKindForTopic,
+ type Classification,
+ type ExtractResponse,
+ type Extraction,
+} from "../src/schemas/index.js";
+
+function extraction(over: Partial = {}): Extraction {
+ return {
+ question_text: "이차방정식 $x^2 - 5x + 6 = 0$의 두 근의 합을 구하시오.",
+ choices: null,
+ answer_text: null,
+ figure_dependent: false,
+ confidence: 0.9,
+ ...over,
+ };
+}
+
+function classification(over: Partial = {}): Classification {
+ return {
+ school_level: "middle",
+ grade: 3,
+ topic_code: "9수02-09",
+ topic_name: "이차방정식",
+ problem_type: "short_answer",
+ difficulty: "medium",
+ confidence: 0.9,
+ alternatives: [],
+ ...over,
+ };
+}
+
+const okExtractor: ExtractorAgent = { async extract() { return extraction(); } };
+const okClassifier: ClassifierAgent = { async classify() { return classification(); } };
+
+function jsonReq(body: unknown): RequestInit {
+ return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
+}
+
+describe("curriculum catalog", () => {
+ it("has 43 topics with unique codes", () => {
+ const codes = CURRICULUM_TOPICS.map((t) => t.code);
+ expect(codes.length).toBe(43);
+ expect(new Set(codes).size).toBe(codes.length);
+ });
+
+ it("every catalog code maps to a valid generation kind", () => {
+ for (const topic of CURRICULUM_TOPICS) {
+ expect(GenerationKindSchema.options).toContain(generationKindForTopic(topic.code));
+ }
+ });
+});
+
+describe("resolveClassification", () => {
+ const base: LlmClassification = {
+ topic_code: "9수02-09",
+ topic_name: "이차방정식",
+ generation_kind: "equation",
+ problem_type: "short_answer",
+ difficulty: "medium",
+ confidence: 0.9,
+ alternatives: [],
+ };
+
+ it("snaps a valid code and derives level/grade/name from the catalog", () => {
+ const r = resolveClassification(base);
+ expect(r.topic_code).toBe("9수02-09");
+ expect(r.school_level).toBe("middle");
+ 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", () => {
+ const r = resolveClassification({ ...base, topic_code: "없는코드", topic_name: "이차방정식" });
+ expect(r.topic_code).toBe("9수02-09");
+ expect(r.confidence).toBeLessThanOrEqual(0.5);
+ });
+
+ it("returns an empty topic at very low confidence when nothing matches", () => {
+ const r = resolveClassification({ ...base, topic_code: "10공수99-99", topic_name: "양자역학" });
+ expect(r.topic_code).toBe("");
+ expect(r.confidence).toBeLessThanOrEqual(0.3);
+ expect(r.school_level).toBe("high");
+ });
+
+ 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: "이차방정식" }, // == 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: "이차방정식의 활용" }]);
+ });
+
+ 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", () => {
+ it("returns 503 when models are not configured", async () => {
+ const res = await createExtractRoute({}).request("/api/extract", jsonReq({ text: "x+1=0" }));
+ expect(res.status).toBe(503);
+ });
+
+ it("reads pasted text into extraction + classification", async () => {
+ const app = createExtractRoute({ extractor: okExtractor, classifier: okClassifier });
+ const res = await app.request("/api/extract", jsonReq({ text: "이차방정식 x^2-5x+6=0" }));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ExtractResponse;
+ expect(body.extraction.question_text).toContain("이차방정식");
+ expect(body.classification.topic_code).toBe("9수02-09");
+ expect(body.warnings).toEqual([]);
+ });
+
+ it("returns 400 when text is missing", async () => {
+ const app = createExtractRoute({ extractor: okExtractor, classifier: okClassifier });
+ const res = await app.request("/api/extract", jsonReq({}));
+ expect(res.status).toBe(400);
+ });
+
+ it("warns when the extracted problem is figure-dependent", async () => {
+ const figExtractor: ExtractorAgent = { async extract() { return extraction({ figure_dependent: true }); } };
+ const app = createExtractRoute({ extractor: figExtractor, classifier: okClassifier });
+ const res = await app.request("/api/extract", jsonReq({ text: "도형 문제" }));
+ const body = (await res.json()) as ExtractResponse;
+ expect(body.warnings.some((w) => w.includes("그림"))).toBe(true);
+ });
+
+ it("warns when classification confidence is low but still returns 200", async () => {
+ const lowClassifier: ClassifierAgent = { async classify() { return classification({ confidence: 0.2 }); } };
+ const app = createExtractRoute({ extractor: okExtractor, classifier: lowClassifier });
+ const res = await app.request("/api/extract", jsonReq({ text: "x" }));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as ExtractResponse;
+ 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();
+ form.set("image", new File([new Uint8Array([1, 2, 3])], "p.gif", { type: "image/gif" }));
+ const res = await app.request("/api/extract", { method: "POST", body: form });
+ expect(res.status).toBe(415);
+ });
+});
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/integration/generate.test.ts b/packages/agent/tests/integration/generate.test.ts
index 4aa7ee7..8ae3d4c 100644
--- a/packages/agent/tests/integration/generate.test.ts
+++ b/packages/agent/tests/integration/generate.test.ts
@@ -177,6 +177,7 @@ function createTestApp(opts: {
return createApp({
mathEngine: fakeMathEngine(),
+ extract: {},
workflow: {
rag: fakeRag(refs),
mathEngine: fakeMathEngine(),
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/page.tsx b/packages/web/app/app/new/attach/page.tsx
new file mode 100644
index 0000000..32f6231
--- /dev/null
+++ b/packages/web/app/app/new/attach/page.tsx
@@ -0,0 +1,13 @@
+import type { Metadata } from "next";
+
+import { AttachView } from "./view";
+
+export const metadata: Metadata = {
+ title: "이 문제처럼 — OpenMath",
+ description:
+ "가지고 있는 문제를 붙여넣거나 사진으로 올리면 학년·단원을 자동으로 인식하고 같은 유형의 문제를 만듭니다.",
+};
+
+export default function AttachPage() {
+ return ;
+}
diff --git a/packages/web/app/app/new/attach/view.tsx b/packages/web/app/app/new/attach/view.tsx
new file mode 100644
index 0000000..859a291
--- /dev/null
+++ b/packages/web/app/app/new/attach/view.tsx
@@ -0,0 +1,499 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+
+import { LatexMixed } from "@/components/math/latex-renderer";
+import {
+ type Grade,
+ type SchoolLevel,
+ findTopic,
+ topicsForScope,
+} from "../topic/data";
+import {
+ type ExtractResult,
+ extractFromImage,
+ extractFromText,
+} from "@/lib/extract-client";
+
+type Phase = "input" | "extracting" | "confirm";
+type InputMode = "text" | "image";
+type IsoMode = "structural" | "conceptual";
+
+const isoModes: {
+ value: IsoMode;
+ label: string;
+ desc: string;
+ badgeClass: "badge-pass" | "badge-concept";
+ badgeText: string;
+}[] = [
+ {
+ value: "structural",
+ label: "구조가 같은 문제",
+ desc: "숫자 · 계수만 바꿔 원본과 같은 풀이 흐름을 따릅니다.",
+ badgeClass: "badge-pass",
+ badgeText: "Structural",
+ },
+ {
+ value: "conceptual",
+ label: "개념이 같은 문제",
+ desc: "풀이 경로는 달라도 같은 학습 목표를 평가합니다.",
+ badgeClass: "badge-concept",
+ badgeText: "Conceptual",
+ },
+];
+
+function difficultyLabel(d: "easy" | "medium" | "hard"): string {
+ if (d === "easy") return "하";
+ if (d === "medium") return "중";
+ return "상";
+}
+
+function problemTypeLabel(t: string): string {
+ if (t === "objective") return "객관식";
+ if (t === "short_answer") return "단답형";
+ if (t === "essay") return "서술형";
+ return "주관식";
+}
+
+function newAttachedId(): string {
+ const c = globalThis.crypto;
+ if (c !== undefined && typeof c.randomUUID === "function") return `attached-${c.randomUUID()}`;
+ return `attached-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
+}
+
+export function AttachView() {
+ const router = useRouter();
+
+ const [phase, setPhase] = useState("input");
+ const [inputMode, setInputMode] = useState("text");
+ const [text, setText] = useState("");
+ const [file, setFile] = useState(null);
+ const [filePreview, setFilePreview] = useState(null);
+ const [error, setError] = useState(null);
+
+ const [result, setResult] = useState(null);
+ const [questionText, setQuestionText] = useState("");
+ const [schoolLevel, setSchoolLevel] = useState("middle");
+ const [grade, setGrade] = useState(1);
+ const [topicCode, setTopicCode] = useState("");
+ const [isoMode, setIsoMode] = useState("structural");
+
+ useEffect(() => {
+ return () => {
+ if (filePreview !== null) URL.revokeObjectURL(filePreview);
+ };
+ }, [filePreview]);
+
+ const topicOptions = useMemo(
+ () => topicsForScope(schoolLevel, schoolLevel === "high" ? null : grade),
+ [schoolLevel, grade],
+ );
+
+ const alternatives = useMemo(() => {
+ if (result === null) return [];
+ return result.classification.alternatives
+ .map((alt) => findTopic(alt.topic_code))
+ .filter((topic): topic is NonNullable => topic !== null)
+ .filter((topic) => topic.code !== topicCode);
+ }, [result, topicCode]);
+
+ const onPickFile = (next: File | null) => {
+ setError(null);
+ setFile(next);
+ setFilePreview((prev) => {
+ if (prev !== null) URL.revokeObjectURL(prev);
+ return next === null ? null : URL.createObjectURL(next);
+ });
+ };
+
+ const seedConfirm = (res: ExtractResult) => {
+ const c = res.classification;
+ const level = c.school_level;
+ const seededGrade: Grade | null = level === "high" ? null : c.grade ?? 1;
+ const scope = topicsForScope(level, level === "high" ? null : seededGrade);
+ setResult(res);
+ setQuestionText(res.extraction.question_text);
+ setSchoolLevel(level);
+ setGrade(seededGrade);
+ // 가드 C: 자동 인식 확신이 낮으면 단원을 미리 고르지 않고 사용자가 직접 확인하게 한다.
+ const confidentTopic = c.confidence >= 0.5 && scope.some((t) => t.code === c.topic_code);
+ setTopicCode(confidentTopic ? c.topic_code : "");
+ setIsoMode("structural");
+ };
+
+ const runExtract = async () => {
+ if (inputMode === "text" && text.trim().length === 0) return;
+ if (inputMode === "image" && file === null) return;
+ setError(null);
+ setPhase("extracting");
+ try {
+ const res =
+ inputMode === "text"
+ ? await extractFromText(text.trim())
+ : await extractFromImage(file as File);
+ seedConfirm(res);
+ setPhase("confirm");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "문제를 읽지 못했어요. 다시 시도해 주세요.");
+ setPhase("input");
+ }
+ };
+
+ const onSchoolChange = (level: SchoolLevel) => {
+ setSchoolLevel(level);
+ setGrade(level === "high" ? null : 1);
+ setTopicCode("");
+ };
+
+ const onGradeChange = (g: Grade) => {
+ setGrade(g);
+ setTopicCode("");
+ };
+
+ const onPickAlternative = (code: string) => {
+ const topic = findTopic(code);
+ if (topic === null) return;
+ setSchoolLevel(topic.schoolLevel);
+ setGrade(topic.grade);
+ setTopicCode(topic.code);
+ };
+
+ const canExtract =
+ (inputMode === "text" && text.trim().length > 0) ||
+ (inputMode === "image" && file !== null);
+
+ const canCreate =
+ questionText.trim().length > 0 &&
+ topicCode !== "" &&
+ (schoolLevel === "high" || grade !== null);
+
+ const handleCreate = () => {
+ if (!canCreate) return;
+ const itemId = newAttachedId();
+ const difficulty = result?.classification.difficulty ?? "medium";
+ try {
+ window.sessionStorage.setItem(
+ "openmath:intent-source",
+ JSON.stringify({
+ item_id: itemId,
+ question_text: questionText.trim(),
+ difficulty_norm: difficulty,
+ source_origin: "attached",
+ generation_kind: result?.classification.generation_kind,
+ }),
+ );
+ } catch (err) {
+ console.warn("[attach] sessionStorage write failed:", err);
+ }
+ const params = new URLSearchParams();
+ params.set("school", schoolLevel);
+ params.set("grade", grade === null ? "common" : String(grade));
+ params.set("topic", topicCode);
+ params.set("mode", isoMode);
+ params.set("srcRef", itemId);
+ router.push(`/app/new/verify?${params.toString()}`);
+ };
+
+ const isExtracting = phase === "extracting";
+
+ if (phase === "confirm" && result !== null) {
+ return (
+ <>
+
+
+
+ ←
+ 워크스페이스
+
+ ·
+ 읽은 문제 확인
+
+
+
+
+ 이렇게 읽었어요 — 맞나요?
+
+ 읽은 내용과 학년 · 단원을 확인하고 필요하면 고쳐 주세요. 이 문제를 기준으로 같은 유형의 문제를 만듭니다.
+
+
+ {result.warnings.length > 0 ? (
+
+ ⚠
+
+ {result.warnings.map((w, i) => (
+ {w}
+ ))}
+
+
+ ) : null}
+
+
+ 읽은 문제
+
+ 문제 본문 (수식은 $…$ 로 표시)
+
+
+ 미리보기
+
+
+
+ 자동 인식
+ {difficultyLabel(result.classification.difficulty)}
+ {problemTypeLabel(result.classification.problem_type)}
+
+
+
+
+ 학년 · 단원
+
+
+ 학교급
+ onSchoolChange(e.target.value as SchoolLevel)}
+ >
+ 중학교
+ 고등학교 (공통수학)
+
+
+
+ {schoolLevel === "middle" ? (
+
+ 학년
+ onGradeChange(Number(e.target.value) as Grade)}
+ >
+ 중1
+ 중2
+ 중3
+
+
+ ) : null}
+
+
+ 단원
+ setTopicCode(e.target.value)}
+ >
+ 단원 선택
+ {topicOptions.map((topic) => (
+ {topic.name}
+ ))}
+
+
+
+
+ {alternatives.length > 0 ? (
+
+ 혹시 이 단원인가요?
+ {alternatives.map((topic) => (
+ onPickAlternative(topic.code)}
+ >
+ {topic.name}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {
+ setPhase("input");
+ setError(null);
+ }}
+ >
+ ←
+ 다시 첨부
+
+
+
+
+ 이 문제로 만들기
+ →
+
+
+ {questionText.trim().length === 0
+ ? "문제 본문을 입력하세요."
+ : topicCode === ""
+ ? "단원을 선택하세요."
+ : "학년을 선택하세요."}
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ ←
+ 워크스페이스
+
+
+
+
+
+ 이 문제처럼 만들기
+
+ 가지고 있는 문제를 붙여넣거나 사진으로 올리면, 학년 · 단원을 자동으로 인식하고 같은 유형의 문제를 만듭니다.
+
+
+
+ setInputMode("text")}
+ >
+ 텍스트 붙여넣기
+
+ setInputMode("image")}
+ >
+ 사진 올리기
+
+
+
+ {inputMode === "text" ? (
+
+ 문제 텍스트
+
+ ) : (
+
+ {filePreview !== null ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
문제 사진(PNG · JPG · WEBP, 10MB 이하)을 올려 주세요.
+ )}
+
+ {file === null ? "사진 선택" : "다른 사진 선택"}
+ onPickFile(e.target.files?.[0] ?? null)}
+ />
+
+ {file !== null ?
{file.name}
: null}
+
+ )}
+
+ {error !== null ? (
+
+ ✗
+ {error}
+
+ ) : null}
+
+ {isExtracting ? (
+
+ …
+ 문제를 읽고 있어요. 잠시만 기다려 주세요.
+
+ ) : null}
+
+
+
+
+
+
+ ←
+ 워크스페이스
+
+
+
+ void runExtract()}
+ disabled={!canExtract || isExtracting}
+ >
+ {isExtracting ? "읽는 중…" : "문제 읽기"}
+ →
+
+
+
+
+ >
+ );
+}
diff --git a/packages/web/app/app/new/intent/picker.tsx b/packages/web/app/app/new/intent/picker.tsx
index faee75d..34474a3 100644
--- a/packages/web/app/app/new/intent/picker.tsx
+++ b/packages/web/app/app/new/intent/picker.tsx
@@ -145,8 +145,8 @@ export function IntentPicker({ schoolLevel, grade, topic, candidates }: Props) {
기준 문항을 불러오지 못했어요
- 현재 단원({topic.name})에 해당하는 corpus 문항이 비어 있거나
- agent 서버에 일시적인 문제가 있을 수 있습니다. 잠시 후 다시
+ 현재 단원({topic.name})에 해당하는 예시 문항이 없거나
+ 출제 서버에 일시적인 문제가 있을 수 있습니다. 잠시 후 다시
시도해 주세요.
@@ -346,7 +346,7 @@ export function IntentPicker({ schoolLevel, grade, topic, candidates }: Props) {
어떤 문제를 기준으로 만들까요?
- corpus 에서 가져온 후보 중 하나를 선택해 주세요. 구조 동형은
+ 기출 · 교과 문제 중 하나를 기준으로 선택해 주세요. 구조 동형은
풀이 구조를, 개념 동형은 학습 목표를 이 문항에서 가져옵니다.
diff --git a/packages/web/app/app/new/result/view.tsx b/packages/web/app/app/new/result/view.tsx
index 22bedc1..b00a6d0 100644
--- a/packages/web/app/app/new/result/view.tsx
+++ b/packages/web/app/app/new/result/view.tsx
@@ -365,10 +365,10 @@ export function ResultView({
{fallback ? (
⚙
- 템플릿 폴백
+ 자동 템플릿
) : null}
{unverified ? (
diff --git a/packages/web/app/app/new/topic/picker.tsx b/packages/web/app/app/new/topic/picker.tsx
index 970d022..ee3c475 100644
--- a/packages/web/app/app/new/topic/picker.tsx
+++ b/packages/web/app/app/new/topic/picker.tsx
@@ -211,7 +211,7 @@ export function TopicPicker({ schoolLevel, grade }: Props) {
{topicScopeLabel(selectedTopic)} · {selectedTopic.achievement}
- 예시 {selectedExamples.examples.length} 문항 · corpus 후보
+ 예시 {selectedExamples.examples.length} 문항 · 기출 · 교과 문제
{" "}
{selectedExamples.candidate_pool.toLocaleString()} 건 중
추출
@@ -244,7 +244,7 @@ export function TopicPicker({ schoolLevel, grade }: Props) {
- 예시는 corpus 의 기존 문제이며, 실제 출제 시 LLM 이 동형
+ 예시는 기존 기출 · 교과 문제이며, 실제 출제 시 AI 가 동형
문제를 새로 생성하고 SymPy 가 검증합니다.
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/app/app/page.tsx b/packages/web/app/app/page.tsx
index 8e555d2..05e0fbe 100644
--- a/packages/web/app/app/page.tsx
+++ b/packages/web/app/app/page.tsx
@@ -35,36 +35,23 @@ export default function WorkspacePage() {
-
-
- 이 문제처럼
-
- 준비 중
-
-
+
+ 이 문제처럼
- 기존 문제 이미지를 업로드하면 동형을 추출합니다. 캡스톤
- 데모 단계에서는 비활성화되어 있습니다.
+ 가지고 있는 문제를 붙여넣거나 사진으로 올리면, 학년 · 단원을
+ 자동으로 인식하고 같은 유형의 문제를 만듭니다.
- OCR 이미지 입력
- LaTeX 자동 추출
+ 텍스트 · 사진 입력
+ 학년 · 단원 자동 인식
같은 평가 차원 보존
학년 외 기법 자동 차단
-
- 준비 중
-
-
- OCR 입력은 캡스톤 데모 단계에서 비활성화되어 있습니다.
- v2 에서 도입됩니다.
-
+
+ 시작하기
+ →
+
diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css
index c1011be..75861bb 100644
--- a/packages/web/app/globals.css
+++ b/packages/web/app/globals.css
@@ -2894,4 +2894,110 @@
.example-question { font-size: 12px; line-height: 1.5; }
.example-panel-footer { padding-top: 6px; }
}
+
+ /* ── attach (이 문제처럼) — 첨부 입력 + 확인 화면 ─────────── */
+ .attach-toggle {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+ }
+ .attach-textarea {
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.6;
+ padding: 12px 14px;
+ border: 1px solid var(--color-hairline);
+ border-radius: 8px;
+ background: var(--color-canvas-pure);
+ color: var(--color-ink);
+ width: 100%;
+ min-height: 160px;
+ resize: vertical;
+ }
+ .attach-textarea:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 1px;
+ border-color: var(--color-ink);
+ }
+ .attach-dropzone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 28px 16px;
+ text-align: center;
+ border: 1.5px dashed var(--color-hairline);
+ border-radius: 12px;
+ background: var(--color-canvas-2);
+ color: var(--color-ink-3);
+ }
+ .attach-dropzone .hint {
+ font-size: 13px;
+ color: var(--color-ink-3);
+ }
+ .attach-image-preview {
+ max-width: 100%;
+ max-height: 320px;
+ border-radius: 8px;
+ border: 1px solid var(--color-rule);
+ }
+ .attach-fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 14px;
+ }
+ .attach-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+ .attach-field > .field-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--color-ink-2);
+ }
+ .attach-select {
+ font-family: var(--font-sans);
+ font-size: 15px;
+ padding: 10px 12px;
+ border: 1px solid var(--color-hairline);
+ border-radius: 8px;
+ background: var(--color-canvas-pure);
+ color: var(--color-ink);
+ width: 100%;
+ cursor: pointer;
+ }
+ .attach-select:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 1px;
+ border-color: var(--color-ink);
+ }
+ .attach-preview {
+ padding: 14px 16px;
+ border: 1px solid var(--color-rule);
+ border-radius: 10px;
+ background: var(--color-paper);
+ font-size: 15px;
+ line-height: 1.7;
+ color: var(--color-ink);
+ overflow-x: auto;
+ }
+ .attach-preview .caption {
+ display: block;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--color-ink-4);
+ margin-bottom: 8px;
+ }
+ .attach-alt-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-top: 10px;
+ font-size: 13px;
+ color: var(--color-ink-3);
+ }
}
diff --git a/packages/web/app/samples/page.tsx b/packages/web/app/samples/page.tsx
index 294c4db..76380b4 100644
--- a/packages/web/app/samples/page.tsx
+++ b/packages/web/app/samples/page.tsx
@@ -70,8 +70,8 @@ export default function SamplesPage() {
실제로 만들어지는 문제
- 아래 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/hooks/use-verification-stream.ts b/packages/web/hooks/use-verification-stream.ts
index a200c45..3c6f76e 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;
};
@@ -363,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 리뷰).
@@ -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
new file mode 100644
index 0000000..b0294bd
--- /dev/null
+++ b/packages/web/lib/extract-client.ts
@@ -0,0 +1,83 @@
+/* ─────────────────────────────────────────────────────────────
+ * 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";
+ /** 문제에서 추론한 풀이 종류. 생성 시 토픽 파생값보다 우선(토픽 오분류 보호). */
+ generation_kind?: string;
+ 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 });
+}