-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 첨부 문제 동형 출제(이 문제처럼) + UI 용어 정리 #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3f333a3
1d8c946
8f7ca3d
c6d8b4e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)으로 판단한다. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` 를 낮춰라. | ||
| - 문제를 풀거나 정답을 새로 계산하지 말 것. 원본에 적힌 것만 옮긴다. | ||
| - 여러 문제가 보이면 가장 완전한 **한 개**만 고른다. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<typeof LlmClassificationSchema>; | ||||||
|
|
||||||
| export interface ClassifierAgentInput { | ||||||
| extraction: Pick<Extraction, "question_text" | "choices">; | ||||||
| signal?: AbortSignal; | ||||||
| } | ||||||
|
|
||||||
| export interface ClassifierAgent { | ||||||
| classify(input: ClassifierAgentInput): Promise<Classification>; | ||||||
| } | ||||||
|
|
||||||
| 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<typeof topic> => 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", | ||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||
| 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; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Name-fallback classifications are capped at exactly 0.5, but the extract route warns only when Prompt for AI agents
Suggested change
|
||||||
| 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<string>(); | ||||||
| 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; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Extraction>; | ||
| } | ||
|
|
||
| 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; | ||
| }, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The prompt instructs the LLM to return at most 2 alternatives, but the
LlmClassificationSchemadoes not enforce this limit on thealternativesarray. If the model returns more than 2 entries, the frontend could render an unbounded number of chip buttons, breaking the UI contract. Consider adding.max(2)to the Zod array definition to make the schema match the prompt instruction and protect the UI boundary.Prompt for AI agents