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} + +
+

읽은 문제

+