From 3f333a32bafce5322951d183d877e782f9eef5eb Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Thu, 11 Jun 2026 20:34:53 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=B2=A8=EB=B6=80=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EB=8F=99=ED=98=95=20=EC=B6=9C=EC=A0=9C(=EC=9D=B4?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=EC=B2=98=EB=9F=BC)=20+=20UI=20=EC=9A=A9?= =?UTF-8?q?=EC=96=B4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB 예시 선택 대신 사용자가 가진 문제를 첨부해 동형 출제하는 플로우 추가. 백엔드 (POST /api/extract): - extractor-agent: 이미지(비전)·텍스트 → question_text/choices/answer/figure_dependent - classifier-agent: 추출 문제 → 교육과정 카탈로그(43단원) 자동 인식·스냅 - source_problem_text 는 이미 검색/생성/목표매핑을 관통 → 기존 6단계 파이프라인 무변경 프론트 (/app/new/attach): - 입력(텍스트/이미지) → 문제 읽기 → 학년·단원 자동 인식 → 확인·수정 → verify 합류 - S0 '이 문제처럼' 카드 활성화 UI 내부 용어 평문화: - corpus → 예시·기출/교과, LLM → AI, RAG 검색 → 비슷한 문제 찾기, 의도 추출 → 출제 의도 분석 (SymPy·동형 은 브랜드 용어로 유지) 검증: 백엔드 extract 12 + 전체 275 테스트 통과, 웹 typecheck·build 통과. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/prompts/problem-classifier.md | 42 ++ packages/agent/prompts/problem-extractor.md | 33 ++ packages/agent/src/agents/classifier-agent.ts | 111 ++++ packages/agent/src/agents/extractor-agent.ts | 76 +++ packages/agent/src/agents/index.ts | 2 + packages/agent/src/config/env.ts | 4 + packages/agent/src/index.ts | 35 ++ .../agent/src/schemas/curriculum-topics.ts | 117 +++++ packages/agent/src/schemas/extract.schema.ts | 74 +++ packages/agent/src/schemas/index.ts | 2 + packages/agent/src/server/app.ts | 3 + packages/agent/src/server/routes/extract.ts | 132 +++++ packages/agent/tests/extract.test.ts | 152 ++++++ .../agent/tests/integration/generate.test.ts | 1 + packages/web/app/app/new/attach/page.tsx | 13 + packages/web/app/app/new/attach/view.tsx | 495 ++++++++++++++++++ packages/web/app/app/new/intent/picker.tsx | 6 +- packages/web/app/app/new/result/view.tsx | 4 +- packages/web/app/app/new/topic/picker.tsx | 4 +- packages/web/app/app/page.tsx | 33 +- packages/web/app/globals.css | 106 ++++ packages/web/app/samples/page.tsx | 4 +- packages/web/components/landing/faq.tsx | 4 +- .../web/components/landing/feature-strip.tsx | 2 +- packages/web/lib/extract-client.ts | 81 +++ 25 files changed, 1501 insertions(+), 35 deletions(-) create mode 100644 packages/agent/prompts/problem-classifier.md create mode 100644 packages/agent/prompts/problem-extractor.md create mode 100644 packages/agent/src/agents/classifier-agent.ts create mode 100644 packages/agent/src/agents/extractor-agent.ts create mode 100644 packages/agent/src/schemas/curriculum-topics.ts create mode 100644 packages/agent/src/schemas/extract.schema.ts create mode 100644 packages/agent/src/server/routes/extract.ts create mode 100644 packages/agent/tests/extract.test.ts create mode 100644 packages/web/app/app/new/attach/page.tsx create mode 100644 packages/web/app/app/new/attach/view.tsx create mode 100644 packages/web/lib/extract-client.ts diff --git a/packages/agent/prompts/problem-classifier.md b/packages/agent/prompts/problem-classifier.md new file mode 100644 index 0000000..ffedbbe --- /dev/null +++ b/packages/agent/prompts/problem-classifier.md @@ -0,0 +1,42 @@ +--- +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`: 그 코드의 단원 이름(카탈로그 표기 그대로). +- `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/src/agents/classifier-agent.ts b/packages/agent/src/agents/classifier-agent.ts new file mode 100644 index 0000000..f04b0fe --- /dev/null +++ b/packages/agent/src/agents/classifier-agent.ts @@ -0,0 +1,111 @@ +/** ClassifierAgent — 추출 문제를 교육과정 카탈로그 단원으로 분류한 뒤 코드로 스냅. + * + * LLM 은 카탈로그 안의 코드를 고르고, resolveClassification 이 그 결과를 + * 카탈로그(curriculum-topics)에 맞춰 검증·보정한다. 코드를 못 맞추면 이름으로, + * 그것도 안 되면 확신을 크게 낮춰 사용자가 확인 화면에서 직접 고르게 한다. + */ + +import { generateObject, type LanguageModel } from "ai"; +import { z } from "zod"; + +import { + ClassificationAlternativeSchema, + DifficultySchema, + 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), + 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 = 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) { + // 카탈로그 밖 — 사용자가 확인 화면에서 직접 고르도록 확신을 크게 낮춘다. + return { + school_level: raw.topic_code.startsWith("10") ? "high" : "middle", + grade: null, + topic_code: "", + topic_name: "", + 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, + problem_type: raw.problem_type, + difficulty: raw.difficulty, + confidence, + alternatives: alternatives.filter((alt) => alt.topic_code !== matched.code), + }; +} 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/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..62e8441 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, @@ -111,10 +113,43 @@ export async function main(): Promise { promptId: "independent-solver", prompts, }); + const extractModel = env.EXTRACT_MODEL ?? llmModel; + const extractLlm = llm === undefined + ? undefined + : extractModel === llmModel + ? llm + : withLlmLogging( + resolveLanguageModel({ + kind: llmKind, + modelId: extractModel, + baseUrl: llmBaseUrl, + apiKey: llmApiKey ?? "openmath-local", + allowedHosts: ["localhost", "127.0.0.1"], + }), + `${extractModel} (extract)`, + logLlmCall, + ); + const 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..3473fde --- /dev/null +++ b/packages/agent/src/schemas/extract.schema.ts @@ -0,0 +1,74 @@ +/** + * 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 { + 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, + 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/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..caf2f18 --- /dev/null +++ b/packages/agent/src/server/routes/extract.ts @@ -0,0 +1,132 @@ +/** + * 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 이미지만 올릴 수 있어요." }, 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) { + console.error("[extract] classification failed:", err instanceof Error ? err.message : err); + return c.json( + { error: "classification_failed", message: "단원 자동 인식에 실패했어요. 확인 화면에서 직접 골라 주세요." }, + 502, + ); + } + + 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; +} diff --git a/packages/agent/tests/extract.test.ts b/packages/agent/tests/extract.test.ts new file mode 100644 index 0000000..8729cce --- /dev/null +++ b/packages/agent/tests/extract.test.ts @@ -0,0 +1,152 @@ +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: "이차방정식", + 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); + }); + + 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 and drops the matched topic", () => { + const r = resolveClassification({ + ...base, + alternatives: [ + { topic_code: "9수02-09", topic_name: "이차방정식" }, + { topic_code: "9수02-10", topic_name: "이차방정식의 활용" }, + { topic_code: "zzz", topic_name: "없음" }, + ], + }); + expect(r.alternatives).toEqual([{ topic_code: "9수02-10", topic_name: "이차방정식의 활용" }]); + }); +}); + +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("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/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/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..59fbc39 --- /dev/null +++ b/packages/web/app/app/new/attach/view.tsx @@ -0,0 +1,495 @@ +"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); + setTopicCode(scope.some((t) => t.code === c.topic_code) ? 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, + }), + ); + } 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} + +
+

읽은 문제

+