diff --git a/packages/web/app/app/new/intent/picker.tsx b/packages/web/app/app/new/intent/picker.tsx index a275b77..2c625ff 100644 --- a/packages/web/app/app/new/intent/picker.tsx +++ b/packages/web/app/app/new/intent/picker.tsx @@ -8,6 +8,8 @@ import { type Topic, gradeLabel, } from "../topic/data"; +import { LatexMixed } from "@/components/math/latex-renderer"; +import examplesData from "../topic/examples.json"; type IsomorphismMode = "structural" | "conceptual"; @@ -25,8 +27,25 @@ type ReferenceProblem = { title: string; body: string; sourceProblemText: string; + difficulty?: "easy" | "medium" | "hard"; }; +type ExampleEntry = { + id: string; + achievement_code_2015: string; + topic_name_2015: string; + difficulty: "easy" | "medium" | "hard"; + problem_type: "objective" | "essay" | "short_answer" | "subjective"; + question_text: string; + answer_text: string; +}; + +const bakedExamples = ( + examplesData as { + units: Record; + } +).units; + const modes: ModeOption[] = [ { value: "structural", @@ -47,50 +66,31 @@ const modes: ModeOption[] = [ ]; function referenceProblems(topic: Topic): ReferenceProblem[] { - if (topic.code === "9수01-05") { - return [ - { - id: "sqrt-basic", - title: "제곱근 기본형", - body: "x² = 5 를 만족하는 실수 x를 모두 구한다.", - sourceProblemText: "x**2 - 5 = 0", - }, - { - id: "sqrt-expression", - title: "근호 표현형", - body: "제곱해서 7이 되는 두 수를 근호로 나타낸다.", - sourceProblemText: "x**2 - 7 = 0", - }, - { - id: "real-number", - title: "실수 분류형", - body: "√9, -√5, 0.3 중 유리수와 무리수를 구분한다.", - sourceProblemText: "x**2 - 9 = 0", - }, - ]; + const baked = bakedExamples[topic.code]?.examples; + if (baked && baked.length > 0) { + return baked.map((ex, idx) => ({ + id: ex.id, + title: `${topic.name} · 예시 ${idx + 1}`, + body: ex.question_text, + sourceProblemText: ex.question_text, + difficulty: ex.difficulty, + })); } return [ { - id: `${topic.code}-basic`, - title: `${topic.name} 기본형`, + id: `${topic.code}-standard`, + title: `${topic.name} · 성취기준 기반`, body: topic.achievement, sourceProblemText: topic.achievement, }, - { - id: `${topic.code}-structural`, - title: "구조 보존형", - body: "풀이 단계는 유지하고 수치와 표현만 바꾼다.", - sourceProblemText: topic.achievement, - }, - { - id: `${topic.code}-conceptual`, - title: "개념 보존형", - body: "학습 목표는 유지하되 문항 맥락을 바꾼다.", - sourceProblemText: topic.achievement, - }, ]; } +function difficultyLabel(d: ReferenceProblem["difficulty"]): string | null { + if (d === undefined) return null; + return d === "easy" ? "쉬움" : d === "medium" ? "보통" : "어려움"; +} + type Props = { grade: Grade | null; topic: Topic | null; @@ -98,7 +98,7 @@ type Props = { }; export function IntentPicker({ grade, topic, candidates }: Props) { - const [mode, setMode] = useState(null); + const [mode, setMode] = useState("structural"); const refs = topic === null ? [] : referenceProblems(topic); const [selectedRef, setSelectedRef] = useState(refs[0]?.id ?? ""); const [checked, setChecked] = useState>( @@ -173,8 +173,8 @@ export function IntentPicker({ grade, topic, candidates }: Props) { 어떻게 출제할까요?

- 동형 방식과 보존할 평가 차원을 골라 주세요. 검증 6 단계가 이 - 기준을 따라 진행됩니다. + 동형 방식 · 기준 문항 · 보존할 평가 차원을 골라 주세요. 검증 6 단계가 + 이 기준을 따라 진행됩니다.

{/* S3-A — 동형 모드 */} @@ -228,27 +228,39 @@ export function IntentPicker({ grade, topic, candidates }: Props) { 구조동형은 풀이 구조를, 개념동형은 학습 목표를 이 기준 문항에서 가져옵니다.

- {refs.map((ref) => ( - - ))} + {refs.map((ref) => { + const diff = difficultyLabel(ref.difficulty); + return ( + + ); + })}
diff --git a/packages/web/app/app/new/topic/examples.json b/packages/web/app/app/new/topic/examples.json new file mode 100644 index 0000000..209bcb0 --- /dev/null +++ b/packages/web/app/app/new/topic/examples.json @@ -0,0 +1,1027 @@ +{ + "generated_at": "2026-06-06T15:47:54.774Z", + "corpus_source": "openmath_rag_records.jsonl", + "note": "임시 mini-매핑 기반. D-13 도입 후 mapping-2022-to-2015.yaml 로 이관.", + "units": { + "9수01-01": { + "ui_name": "소인수분해", + "grade": 1, + "candidate_pool": 1317, + "examples": [ + { + "id": "110:train:21709_51439", + "achievement_code_2015": "9수01-01", + "topic_name_2015": "소수와 합성수", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "자연수 $a$보다 작은 소수가 $4$ 개일 때, $a$가 될 수 있는 수의 개수를 구하여라.", + "answer_text": "$4$ 개" + }, + { + "id": "110:train:21726_51553", + "achievement_code_2015": "9수04-05", + "topic_name_2015": "소인수 구하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$N(x)$는 자연수 $x$를 소인수분해하였을 때, 소인수들의 합을 나타낸다. 예를 들어 $N(2)=2$, $N(10)=7$이다. $N(84)=a$일 때, $N(a)$의 값을 구하여라.", + "answer_text": "$N(a)=5$" + }, + { + "id": "110:train:21722_51626", + "achievement_code_2015": "9수01-01", + "topic_name_2015": "소인수분해한 결과에서 밑과 지수구하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$225$를 $a^x\\times b^y$으로 소인수분해하였을 때, 자연수 $a$, $b$, $x$, $y$에 대하여 $a+b-x+y$의 값을 구하여라. (단, $a$, $b$는 서로 다른 소수, $a 중 선분의 수직이등분선 작도를 이용하여 할 수 있는 작도를 모두 골라라. <보기>㉠ 선분 $AB$를 지름으로 하는 원의 작도㉡ $∠AOB=45˚$인 각의 작도㉢ 주어진 선분을 $4$등분하는 작도 ㉣평행선의 작도", + "answer_text": "㉠, ㉡, ㉢" + }, + { + "id": "111:train:00041_44058", + "achievement_code_2015": "9수03-03", + "topic_name_2015": "작도 가능한 각", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "다음 보기 중 작도할 수 있는 각은 모두 몇 개인지 구하여라. $ 7.5^{\\circ} , 10^{\\circ} , 30^{\\circ} , 50^{\\circ} $ $ 75^{\\circ} , 120^{\\circ} , 135^{\\circ} , 150^{\\circ} $", + "answer_text": "$6$개" + } + ] + }, + "9수05-01": { + "ui_name": "자료의 정리와 해석", + "grade": 1, + "candidate_pool": 425, + "examples": [ + { + "id": "110:train:34931_140059", + "achievement_code_2015": "9수04-02", + "topic_name_2015": "줄기와 잎 그림의 이해(2)-두 자료", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 정재네 반 남학생과 여학생의 지난 체육 시간의 줄넘기 횟수를 조사하여 나타낸 줄기와 잎 그림이다. 남학생과 여학생 중에서 줄넘기 횟수가 각각 $5$ 번째로 많은 학생의 횟수의 차를 구하여라.", + "answer_text": "$7$ 회" + }, + { + "id": "110:train:39532_155189", + "achievement_code_2015": "9수04-02", + "topic_name_2015": "줄기와 잎 그림", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 소희네 반 학생들의 키를 조사하여 나타낸 줄기와 잎 그림인데, 일부가 가려져 보이지 않는다. 키가 $140 cm $이상 $160 cm $미만인 학생이 전체의 $40 \\%$일 때, 전체 학생 수를 구하여라.", + "answer_text": "$25$ 명" + }, + { + "id": "110:train:9099_73871", + "achievement_code_2015": "9수04-02", + "topic_name_2015": "도수분포표의 이해(3)-비율", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 학생 $30$ 명의 수학 경시대회 점수를 조사하여 나타낸 도수분포표이다. 수학 경시대회 점수가 $40$ 점 미만인 학생은 전체의 몇 %인지 구하여라.", + "answer_text": "$20\\%$" + } + ] + }, + "9수01-04": { + "ui_name": "유리수와 순환소수", + "grade": 2, + "candidate_pool": 386, + "examples": [ + { + "id": "110:train:25058_77282", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "유한소수와 무한소수 구분하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 분수 $\\frac{3}{8}$을 유한소수로 나타내는 과정이다.$\\square$에 들어갈 수로 옳지 $\\underline{않은}$ 것은? $\\cfrac{3}{8}=\\cfrac{3}{2^{\\boxed{①}}}=\\cfrac{3\\times \\boxed{②}}{2^3 \\times \\boxed{③}}=\\cfrac{375}{\\boxed{④}}=\\boxed{⑤}$ ①$3$ ②$5$ ③$5^3$ ④$1000$ ⑤$0.375$", + "answer_text": "$②$" + }, + { + "id": "110:train:25067_77257", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "유한소수로 나타낼수 있는 분수 찾기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "두 자연수 $a$, $b$에 대하여 $\\frac{a}{b}$를 소수로 나타낼 때, 유한소수이면 $a △ b$$=-1$이고 순환소수이면 $a △ b$$=1$이다. 이때 $(6 △ 12)+(9 △ 20)$의 값을 구하여라.", + "answer_text": "$-2$" + }, + { + "id": "110:train:9505_85262", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "유한소수가 되도록 하는 미지수의 값 구하기(1)", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$\\frac{9}{168}\\times n$을 소수로 나타내면 유한소수가 된다. 이때 $n$의 값이 될 수 있는 가장 작은 두 자리 자연수를 구하여라.", + "answer_text": "$14$" + } + ] + }, + "9수02-05": { + "ui_name": "식의 계산", + "grade": 2, + "candidate_pool": 686, + "examples": [ + { + "id": "110:train:9685_85505", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "지수법칙(1)- a^m×a^n=a^m+n", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$4\\times5\\times9\\times14=2^a\\times3^b\\times5^c\\times7^d$일 때, $ab+cd$의 값을 구하여라. (단, $a$,$b$,$c $,$d$는 자연수)", + "answer_text": "$7$" + }, + { + "id": "110:train:12599_85516", + "achievement_code_2015": "9수04-05", + "topic_name_2015": "지수법칙(2)- (am)^n =a^mn", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음 $\\square$안에 알맞은 수를 써넣어라. $a^5\\times b^3\\times(b^4)^3\\times(a^2)^2\\times b$ = $a^{\\square}b^{\\square}$", + "answer_text": "$9$, $16$" + }, + { + "id": "110:train:25085_85531", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "지수법칙(3)- a^m÷a^n=a^m-n", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음 중 옳지 않은 것은? $\\\\㉠2^4\\div2^7>0$$\\\\㉡2^5\\div2^3>0$$\\\\㉢2^4\\div2^4=0$", + "answer_text": "㉢" + } + ] + }, + "9수02-06": { + "ui_name": "일차부등식", + "grade": 2, + "candidate_pool": 1069, + "examples": [ + { + "id": "110:train:10670_12625", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "문장을 부등식으로 나타내기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음 문장을 부등식으로 나타내어라. $x$의 $2$ 배에서 $1$을 뺀 수는 $x$에 $6$을 더한 수의 $3$ 배보다 작다.", + "answer_text": "$2x-1<3(x+6)$" + }, + { + "id": "110:train:10679_81342", + "achievement_code_2015": "9수02-11", + "topic_name_2015": "부등식의 해", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음 부등식 중 $x=3$이 해가 되는 것은? ① $x+1>4$$ $$ $ ② $-x<0$ $ $$ $ ③ $2x+3<9$ ④ $\\frac{3x+3}{4}<2$$ $$ $ ⑤ $11-3x<0$", + "answer_text": "$②$" + }, + { + "id": "110:train:41450_161453", + "achievement_code_2015": "9수02-11", + "topic_name_2015": "부등식의 성질(2)-a, b에 대한 식의 대소 관계가 주어진 경우", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$-2a+3<6b+11$일 때, $\\square$안에 알맞은 기호를 끌어 놓아라. $3a\\square-9b-12$ $<$ $>$", + "answer_text": "$>$" + } + ] + }, + "9수02-07": { + "ui_name": "연립일차방정식", + "grade": 2, + "candidate_pool": 675, + "examples": [ + { + "id": "110:train:34547_137981", + "achievement_code_2015": "9수02-13", + "topic_name_2015": "연립일차방정식", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$x$, $y$가 자연수일 때, 일차방정식 $3x+4y=13$의 해는 $a$ 개, 일차방정식 $2x+5y=23$의 해는 $b$ 개이다. 다음 물음에 답하여라. (1) 일차방정식 $3x+4y=13$의 해의 개수를 구하여라. (2) 일차방정식 $2x+5y=23$의 해의 개수를 구하여라. (3) $a+b$의 값을 구하여라.", + "answer_text": "$1$개" + }, + { + "id": "110:train:10229_11599", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "연립방정식의 해 또는 계수가 문자로 주어질 때", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "연립방정식 의 해가 $ \\begin{cases} 5(x-2)-y=x+16+ay \\\\ b(x-2)=3(y+5)-11 \\\\ \\end{cases}$의 해가 $(3, -2)$일 때, 수 $a$, $b$에 대하여 $2a+5b$의 값을 구하여라.", + "answer_text": "$2$" + }, + { + "id": "110:train:10342_80010", + "achievement_code_2015": "9수04-05", + "topic_name_2015": "연립방정식의 풀이-대입법", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 연립방정식 $\\begin{cases} y=5-x \\dots ㉠ \\\\ x+3y=17 \\dots ㉡ \\end{cases}$을 푸는 과정이다. $\\square$ 안에 알맞은 수를 써넣어라. $㉠$을 $㉡$에 대입하면 $\\\\$ $x+3(5-x)=17$ $\\\\$ $-2x = \\square \\\\ \\therefore x=\\square$ 을 $㉠$에 대입하면 $y=\\square$", + "answer_text": "$2$, $-1$, $-1$, $6$" + } + ] + }, + "9수03-02": { + "ui_name": "일차함수와 그래프", + "grade": 2, + "candidate_pool": 941, + "examples": [ + { + "id": "110:train:11233_12404", + "achievement_code_2015": "9수02-15", + "topic_name_2015": "일차함수의 뜻", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음 문장에서 $y$를 $x$에 대한 식으로 나타내어라. 문구점에서 $800$ 원짜리 연필 $x$ 자루를 사고 $5000$ 원을 냈을 때의 거스름돈은 $y$ 원이다.", + "answer_text": "$y=5000-800x$" + }, + { + "id": "110:train:34631_138507", + "achievement_code_2015": "9수02-15", + "topic_name_2015": "일차함수의 그래프", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "일차함수 $f(x)=ax+b$에 대하여 $f(-2)=-3$, $f(2)=5$일 때, 다음 물음에 답하여라. (단, $a$, $b$는 수) (1) $a$와 $b$의 값을 각각 구하여라. (2) $f(c)=f(5)-2f(1)$일 때, $c$의 값을 구하여라.", + "answer_text": "$a=2$, $b=1$" + }, + { + "id": "110:train:11261_12542", + "achievement_code_2015": "9수02-15", + "topic_name_2015": "일차함수 y=ax+b의 그래프 위의 점(1)", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "일차함수 $y=\\frac{3}{4}x+3$의 그래프가 점 $(p, -6)$을 지날 때, $p$의 값을 구하여라.", + "answer_text": "$p=-12$" + } + ] + }, + "9수03-03": { + "ui_name": "일차함수의 활용", + "grade": 2, + "candidate_pool": 652, + "examples": [ + { + "id": "110:train:7203_2474", + "achievement_code_2015": "9수03-11", + "topic_name_2015": "좌표의 활용", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "네 점 $A(0, 6)$, $B(-4, 1)$, $C(5, 1)$, $D(9, 6)$을 꼭짓점으로 하는 사각형 $ABCD$의 넓이를 구하여라.", + "answer_text": "$45$" + }, + { + "id": "110:train:9852_85693", + "achievement_code_2015": "9수02-09", + "topic_name_2015": "도형에서 단항식의 나눗셈의 활용", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "한 대각선의 길이가 $3a^3b^7$인 마름모의 넓이가 $(3a^2b^5)^2$일 때, 이 마름모의 다른 대각선의 길이를 구하여라.", + "answer_text": "$6ab^3$" + }, + { + "id": "110:train:10561_12284", + "achievement_code_2015": "9수03-14", + "topic_name_2015": "도형의 활용 문제", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "가로의 길이와 세로의 길이의 합이 $25cm$인 직사각형이 있다. 가로의 길이가 세로의 길이보다 $3cm$ 더 짧을 때, 이 직사각형의 세로의 길이를 구하여라.", + "answer_text": "$14 cm$" + } + ] + }, + "9수04-02": { + "ui_name": "삼각형의 성질", + "grade": 2, + "candidate_pool": 143, + "examples": [ + { + "id": "110:train:18141_38983", + "achievement_code_2015": "9수03-10", + "topic_name_2015": "삼각형의 외심의 뜻과 위치", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 삼각형의 세 변의 수직이등분선은 한 점에서 만남을 설명하는 과정이다. 안에 알맞은 것을 써넣어라.", + "answer_text": "풀이 참조" + }, + { + "id": "111:train:00043_43182", + "achievement_code_2015": "9수03-09", + "topic_name_2015": "이등변삼각형의 성질(1)-두 밑각의 크기", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "다음 삼각형 $ ABC $ 에서 $ \\overline{AB}=\\overline{AC} $ 일 때, $ \\angle x $ 의 크기를 구하여라.", + "answer_text": "(1) $ 30^{\\circ} $ (2) $ 81^{\\circ} $" + }, + { + "id": "111:train:00043_43276", + "achievement_code_2015": "9수03-09", + "topic_name_2015": "이등변삼각형의 성질(2)-수직이등분선", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "$ \\overline{AB}=\\overline{AC} $ 인 $ \\triangle ABC $ 에서 $ \\overline{BC} $를 점 B에서부터 $ n: 2 m, n: m, m: n, m^{2}: n^{2} (m>0, n>0) $으로 나누는 점을 각각 $P, Q, R, S$라 한다. $ \\angle \\mathrm{P A R}=\\angle \\mathrm{Q A S}=90^{\\degree} $일 때, $ \\frac{m}{n} $의 값을 구하여라.", + "answer_text": "$ 2 $" + } + ] + }, + "9수04-03": { + "ui_name": "사각형의 성질", + "grade": 2, + "candidate_pool": 201, + "examples": [ + { + "id": "110:train:18734_115455", + "achievement_code_2015": "9수01-06", + "topic_name_2015": "평행사변형이 되는 조건의 응용(2)-각의 크기, 변의 길이", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 평행사변형 $ABCD$에서 $\\angle B$와 $\\angle D$의 이등분선이 $\\overline{AD}$, $\\overline{BC}$와 만나는 점을 각각 $E$, $F$라 할 때, $\\square BFDE$가 어떤 사각형인지 구하는 과정이다. 빈칸에 알맞은 것을 채워라.", + "answer_text": "풀이 참조" + }, + { + "id": "111:train:00043_43841", + "achievement_code_2015": "9수03-11", + "topic_name_2015": "평행사변형이 되는 조건", + "difficulty": "easy", + "problem_type": "objective", + "question_text": "다음 사각형 중에서 평행사변형이라고 할 수 없는 것을 모두 고르면? (정답 $2$개)", + "answer_text": "③, ⑤" + }, + { + "id": "111:train:00043_43880", + "achievement_code_2015": "9수03-13", + "topic_name_2015": "평행사변형이 되는 조건의 응용(1)-설명하기", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "다음 $\\square ABCD$가 평행사변형일 때, 색칠한 부분의 사각형이 평행사변형이 되는 조건을 말하여라.", + "answer_text": "(1) 한 쌍의 대변이 평행하고, 그 길이가 같다." + } + ] + }, + "9수04-04": { + "ui_name": "도형의 닮음", + "grade": 2, + "candidate_pool": 254, + "examples": [ + { + "id": "111:train:00041_36222", + "achievement_code_2015": "9수03-13", + "topic_name_2015": "SAS닮음을 이용하여 변의 길이 구하기", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "두 선분 $AB$와 $CD$가 점 $O$에서 만나고,$ 3 \\overline{AO}=\\overline{DO}, \\overline{CO}=\\frac{1}{3} \\overline{BO} $일 때, $ \\frac{\\overline{AC}}{\\overline{BD}} $의 값을 구하여라.", + "answer_text": "$ \\frac{1}{3} $" + }, + { + "id": "111:train:00041_36233", + "achievement_code_2015": "9수03-13", + "topic_name_2015": "AA닮음을 이용하여 변의 길이 구하기(2)", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "다음 평행사변형 $ABCD$에서 $x$의 값을 구하여라.", + "answer_text": "$3$" + }, + { + "id": "111:train:00043_47737", + "achievement_code_2015": "9수03-13", + "topic_name_2015": "직각삼각형의 닮음", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "$ \\overline{AB}=8 \\mathrm{~cm}, \\overline{BC}=6 \\mathrm{~cm} $ 이고 $ \\angle B=90^{\\circ} $ 인 직각삼각형 $ ABC $ 가 있다. $ \\triangle ABC $ 를 $ \\overline{AB} $를 축으로 하여 $1$회전 시켜 생기는 원뿔 안에 밑면의 반지름의 길이가 $2cm$인 원기둥을 다음 그림과 같이 그릴 때, 이 원기둥의 높이를 구하여라.", + "answer_text": "$ \\frac{16}{3} \\mathrm{~cm} $" + } + ] + }, + "9수05-02": { + "ui_name": "경우의 수와 확률", + "grade": 2, + "candidate_pool": 643, + "examples": [ + { + "id": "110:train:7907_4113", + "achievement_code_2015": "9수04-05", + "topic_name_2015": "수를 뽑는 경우의 수", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$4$부터 $8$까지의 자연수가 각각 적힌 카드 $5$ 장 중에서 한 장의 카드를 뽑아서 나온 수를 $x$라 한다. $\\frac{1}{x}$을 소수로 나타내었을 때, 순환소수가 되는 경우의 수를 구하여라.", + "answer_text": "$2$" + }, + { + "id": "110:train:7911_4097", + "achievement_code_2015": "9수04-05", + "topic_name_2015": "주사위, 동전을 던지는 경우의 수", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "서로 다른 두 개의 주사위를 동시에 던질 때, 나오는 눈의 수의 합이 $10$이 되는 경우의 수를 구하여라.", + "answer_text": "$3$" + }, + { + "id": "110:train:34903_140204", + "achievement_code_2015": "9수04-05", + "topic_name_2015": "여러 가지 경우의 수", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음과 같이 $ㄱ$~$ㅂ$ 중에서 하나를 선택하여 사다리를 타면 상품을 받는 사다리 타기 그림을 그리는 중이다. 상품은 시계, 가방, 게임기가 있고, 시계는 가방보다 $2$ 개 더 많이 있을 때, 나머지 자리에 상품을 배치하여 그림을 완성하는 경우의 수를 구하여라. (단, 어느 것을 선택해도 상품은 $1$ 개 받는다.)", + "answer_text": "$3$" + } + ] + }, + "9수01-05": { + "ui_name": "제곱근과 실수", + "grade": 3, + "candidate_pool": 1444, + "examples": [ + { + "id": "110:train:34441_137463", + "achievement_code_2015": "9수01-07", + "topic_name_2015": "제곱근의 뜻과 성질", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$25$의 두 제곱근을 $a$, $b$라 할 때, 제곱근 $\\sqrt{2a+b+4}$의 값을 구하려고 한다. (단, $a>b$) (1) $a$, $b$의 값을 각각 구하여라. (2) 제곱근 $\\sqrt{2a+b+4}$의 값을 구하여라.", + "answer_text": "$a=5$$,$ $b=-5$" + }, + { + "id": "110:train:11954_116148", + "achievement_code_2015": "9수01-07", + "topic_name_2015": "제곱근의 성질을 이용하여 제곱근 구하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$\\sqrt{16}$의 음의 제곱근을 $A$, $(-3)^2$의 양의 제곱근을 $B$라 할 때, $A+B$의 값을 구하여라.", + "answer_text": "$1$" + }, + { + "id": "110:train:11979_116172", + "achievement_code_2015": "9수01-07", + "topic_name_2015": "제곱근의 성질을 이용하여 계산하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$A$, $B$가 다음과 같을 때, $A+B$의 값을 구하여라. $A=\\sqrt{16}+\\sqrt{(-2)^2}-(\\sqrt{5})^2$ $B=\\sqrt{100}\\times\\sqrt{(-\\frac{1}{5})^2}$", + "answer_text": "$3$" + } + ] + }, + "9수01-06": { + "ui_name": "근호를 포함한 식의 계산", + "grade": 3, + "candidate_pool": 57, + "examples": [ + { + "id": "110:train:12231_116781", + "achievement_code_2015": "9수03-16", + "topic_name_2015": "분배법칙을 이용한 분모의 유리화", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$\\frac{\\sqrt{12}+4}{\\sqrt{2}}$의 분모를 유리화하였더니 $a\\sqrt{2}+b\\sqrt{6}$이 되었다. 유리수 $a$, $b$에 대하여 $a+b$의 값을 구하여라.", + "answer_text": "$3$" + }, + { + "id": "111:train:00043_66779", + "achievement_code_2015": "9수01-07", + "topic_name_2015": "복잡한 형태의 분모의 유리화", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "$ \\frac{2-\\sqrt{3}}{2+\\sqrt{3}}+\\frac{2+\\sqrt{3}}{2-\\sqrt{3}} $을 간단히 하여라.", + "answer_text": "$14$" + }, + { + "id": "111:train:00042_82895", + "achievement_code_2015": "9수03-16", + "topic_name_2015": "곱셈 공식을 이용한 분모의 유리화", + "difficulty": "easy", + "problem_type": "short_answer", + "question_text": "$\\frac{2+\\sqrt{3}}{2-\\sqrt{3}}=a+b \\sqrt{3}$ 일 때, $a+b$의 값을 구하여라. (단, $a,b$는 유리수)", + "answer_text": "$11 \\\\ $" + } + ] + }, + "9수02-08": { + "ui_name": "다항식의 곱셈과 인수분해", + "grade": 3, + "candidate_pool": 493, + "examples": [ + { + "id": "110:train:42115_164473", + "achievement_code_2015": "9수02-19", + "topic_name_2015": "다항식과 다항식의 곱셈", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음 조건을 모두 만족시키는 자연수 $A$, $B$에 대하여 $AB$를 $7$로 나누었을 때의 나머지를 구하여라. (가) $A$를 $7$로 나누었을 때의 나머지는 $5$이다. (나) $B$를 $7$로 나누었을 때의 나머지는 $4$이다.", + "answer_text": "$6$" + }, + { + "id": "110:train:9993_11168", + "achievement_code_2015": "9수03-16", + "topic_name_2015": "곱셈 공식1-(a+b)2=a2+2ab+b2, (a-b)2=a2-2ab+b2", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$(x-7y)^2=Ax^2-Bxy+Cy^2$일 때, 수 $A$, $B$, $C$에 대하여 $A+B+C$의 값을 구하여라.", + "answer_text": "$64$" + }, + { + "id": "110:train:10013_11218", + "achievement_code_2015": "9수03-16", + "topic_name_2015": "곱셈 공식4-(ax+b)(cx+d)=acx2+(ad+bc)x+bd", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "$(3x-a)(bx+1)=21x^2+cx-2$일 때, 수 $a$, $b$, $c$에 대하여 $a+b-c$의 값을 구하여라.", + "answer_text": "$20$" + } + ] + }, + "9수02-09": { + "ui_name": "이차방정식", + "grade": 3, + "candidate_pool": 778, + "examples": [ + { + "id": "110:train:32124_118523", + "achievement_code_2015": "9수02-04", + "topic_name_2015": "이차방정식의 뜻", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "이차방정식 $(2x-3)(4x+1)=5x(x+2)$를 $3x^2+bx+c=0$의 꼴로 나타낼 때, 수 $b$, $c$에 대하여 $bc$의 값을 구하여라.", + "answer_text": "$60$" + }, + { + "id": "110:train:12514_17331", + "achievement_code_2015": "9수02-04", + "topic_name_2015": "AB=0의 성질을 이용하여 이차방정식의 해 구하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "이차방정식 $(2x+1)(x-4)=0$을 풀어라.", + "answer_text": "$x=-\\frac{1}{2}$ 또는 $x=4$" + }, + { + "id": "110:train:12519_152726", + "achievement_code_2015": "9수02-04", + "topic_name_2015": "인수분해를 이용한 이차방정식의 풀이", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "이차방정식 $(x+3)(x-2)=3(x-1)$을 풀어라.", + "answer_text": "$x=-1$ 또는 $x=3$" + } + ] + }, + "9수02-10": { + "ui_name": "이차방정식의 활용", + "grade": 3, + "candidate_pool": 610, + "examples": [ + { + "id": "110:train:12610_120947", + "achievement_code_2015": "9수02-04", + "topic_name_2015": "근의 공식을 이용한 이차방정식의 풀이의 활용", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "이차방정식 $px^2-2x-5=0$의 근이 $x=\\frac{1\\pm\\sqrt{q}}{4}$일 때, $p+q$의 값을 구하여라. (단, $p$, $q$는 유리수)", + "answer_text": "$25$" + }, + { + "id": "110:train:12753_122071", + "achievement_code_2015": "9수01-07", + "topic_name_2015": "실생활 활용", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "준혁이는 예지보다 $2$ 살이 적다고 한다. 예지의 나이를 제곱하면 준혁이의 나이의 제곱의 $2$ 배보다 $28$ 살이 더 적어진다고 할 때, 예지의 나이를 구하여라.", + "answer_text": "$10$ 살" + }, + { + "id": "110:train:12721_122783", + "achievement_code_2015": "9수01-07", + "topic_name_2015": "도형에의 활용", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "둘레의 길이가 $30$ $cm$인 직사각형의 넓이가 $56$ $cm^2$이다. 가로의 길이가 세로의 길이보다 더 길 때, 가로의 길이를 구하여라.", + "answer_text": "$8 cm$" + } + ] + }, + "9수03-04": { + "ui_name": "이차함수와 그래프", + "grade": 3, + "candidate_pool": 825, + "examples": [ + { + "id": "110:train:12835_121262", + "achievement_code_2015": "9수02-22", + "topic_name_2015": "이차함수의 그래프가 지나는 점", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "이차함수 $y=\\frac{2}{3}x^2$의 그래프가 점 $(a, 24)$를 지날 때, $a$의 값을 구하여라. (단, $a>0$)", + "answer_text": "$a=6$" + }, + { + "id": "110:train:12837_121265", + "achievement_code_2015": "9수02-21", + "topic_name_2015": "이차함수 y=ax²의 그래프의 활용", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "이차함수 $y=-4x^2$의 그래프가 두 점 $(a, -4)$, $(2, b)$를 지날 때, $ab$의 값을 구하여라. (단, $a<0$)", + "answer_text": "$16$" + }, + { + "id": "110:train:34879_165828", + "achievement_code_2015": "9수02-22", + "topic_name_2015": "이차함수 y=ax²의 그래프", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음 조건을 만족시키는 음의 정수 $a$는 모두 몇 개인지 구하여라. 이차함수 $y=ax^2$의 그래프는 $y=-\\frac{4}{3}x^2$의 그래프보다 폭이 더 좁고, $y=-5x^2$의 그래프보다 폭이 더 넓다.", + "answer_text": "$3$ 개" + } + ] + }, + "9수04-05": { + "ui_name": "삼각비", + "grade": 3, + "candidate_pool": 375, + "examples": [ + { + "id": "110:train:33053_127747", + "achievement_code_2015": "9수03-16", + "topic_name_2015": "직선의 기울기와 삼각비", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "점 $(2\\sqrt{3}, 5)$를 지나고 $x$축의 양의 방향과 이루는 각의 크기가 $60\\degree$인 직선의 방정식을 구하여라.", + "answer_text": "$y=\\sqrt{3}x-1$" + }, + { + "id": "110:train:34260_135247", + "achievement_code_2015": "9수03-16", + "topic_name_2015": "사분원을 이용하여 삼각비의 값 구하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "이차방정식 $4x^2-4x+1=0$의 한 근을 $\\sin A$라 할 때, $\\cos3A-2\\sin2A+3\\tan A$의 값을 구하여라. (단, $0\\degree0$)", + "answer_text": "$a=6$" + }, + { + "id": "110:train:13562_134811", + "achievement_code_2015": "9수03-16", + "topic_name_2015": "평균과 분산 또는 표준편차를 알 때, 변량 구하기", + "difficulty": "easy", + "problem_type": "essay", + "question_text": "다음은 평균이 $47$인 변량 $a$, $b$, $c$, $d$에 대한 편차를 나타낸 표이다. $2c+x$의 값을 구하여라.
변량 $a$$b$$c$$d$
편차 $5$ $-2$$x$$-9$
", + "answer_text": "$112$" + } + ] + } + } +} diff --git a/packages/web/app/app/new/topic/picker.tsx b/packages/web/app/app/new/topic/picker.tsx index 1fccfdd..e278028 100644 --- a/packages/web/app/app/new/topic/picker.tsx +++ b/packages/web/app/app/new/topic/picker.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useMemo, useState } from "react"; +import { LatexMixed } from "@/components/math/latex-renderer"; import { type Category, type Grade, @@ -10,6 +11,7 @@ import { gradeLabel, topics, } from "./data"; +import examplesData from "./examples.json"; type Filter = "전체" | Category; @@ -17,6 +19,43 @@ type Props = { grade: Grade | null; }; +type ExampleEntry = { + id: string; + achievement_code_2015: string; + topic_name_2015: string; + difficulty: "easy" | "medium" | "hard"; + problem_type: "objective" | "essay" | "short_answer" | "subjective"; + question_text: string; + answer_text: string; +}; + +type ExamplesUnit = { + ui_name: string; + grade: number; + candidate_pool: number; + examples: ExampleEntry[]; +}; + +const examples = (examplesData as { units: Record }) + .units; + +function difficultyLabel(d: ExampleEntry["difficulty"]): string { + return d === "easy" ? "쉬움" : d === "medium" ? "보통" : "어려움"; +} + +function problemTypeLabel(t: ExampleEntry["problem_type"]): string { + switch (t) { + case "objective": + return "객관식"; + case "essay": + return "서술형"; + case "short_answer": + return "단답형"; + case "subjective": + return "주관식"; + } +} + export function TopicPicker({ grade }: Props) { const [filter, setFilter] = useState("전체"); const [selectedCode, setSelectedCode] = useState(null); @@ -24,7 +63,9 @@ export function TopicPicker({ grade }: Props) { const visible = useMemo(() => { if (grade === null) return []; return topics.filter( - (t) => t.grade === grade && (filter === "전체" || t.category === filter), + (t) => + t.grade === grade && + (filter === "전체" || t.category === filter), ); }, [grade, filter]); @@ -57,6 +98,8 @@ export function TopicPicker({ grade }: Props) { selectedCode !== null ? `/app/new/intent?grade=${grade}&topic=${encodeURIComponent(selectedCode)}` : null; + const selectedTopic = visible.find((t) => t.code === selectedCode) ?? null; + const selectedExamples = selectedCode ? examples[selectedCode] : undefined; return ( <> @@ -74,7 +117,7 @@ export function TopicPicker({ grade }: Props) { -
+

어느 단원인가요?

@@ -106,40 +149,119 @@ export function TopicPicker({ grade }: Props) { })} - {visible.length === 0 ? ( -

- 해당 학년 · 영역에 등록된 단원이 없습니다. -

- ) : ( -
+
- {visible.map((t) => ( - + ))} +
+ )} + + + +
diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css index 7fbaf28..0392476 100644 --- a/packages/web/app/globals.css +++ b/packages/web/app/globals.css @@ -779,6 +779,9 @@ background: var(--color-canvas-pure); min-height: 100vh; } + .app-canvas .container-app { + max-width: 1600px; + } .container-app { max-width: 1280px; margin: 0 auto; @@ -1041,9 +1044,10 @@ flex-shrink: 0; } - /* page-body — S1~S6 공통 본문 영역 */ + /* page-body — S1~S6 공통 본문 영역. padding shorthand 사용 금지: container-app 의 좌우 padding(40px) 을 덮어쓰면 좁은 뷰포트에서 콘텐츠가 가장자리에 붙음. */ .page-body { - padding: 48px 0 56px; + padding-top: 48px; + padding-bottom: 56px; } .page-title { font-family: var(--font-serif); @@ -1293,6 +1297,20 @@ } } + /* reference-list (S3-B) — 기준 문항 라디오 1-column. corpus 문제 길이가 가변이라 카드 높이 들쭉날쭉 + KaTeX inline 폭 보존 위해 stack 레이아웃. */ + .reference-list { + display: flex; + flex-direction: column; + gap: 12px; + } + .reference-list .intent-radio-card .label-desc { + overflow-wrap: anywhere; + } + .reference-list .intent-radio-card .label-desc .katex { + max-width: 100%; + overflow-x: auto; + } + /* dimension-list (S3-B vertical stack) */ .dimension-list { display: flex; @@ -2512,4 +2530,228 @@ transform: rotateY(0) !important; } } + + /* topic-picker (S2) — 2-column: 좌 단원 카드 / 우 예시 패널. 좁은 폭에서도 2-col 유지하여 옆에 같이 보이도록. min-width:0 으로 컬럼 자유 축소. */ + .topic-picker .topic-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: clamp(16px, 2vw, 32px); + align-items: start; + } + .topic-picker .topic-grid-1col { + grid-template-columns: 1fr; + } + @media (max-width: 768px) { + .topic-picker .topic-layout { + grid-template-columns: 1fr; + gap: 20px; + } + } + + /* example-panel — sticky 우측 패널. max-height = 페이지헤더(breadcrumb+title+subtitle+filter ≈ 300px) + action-bar(80px) 차감하여 짧은 뷰포트(vh~650)에서도 잘리지 않음. 내부 .example-list 만 스크롤. min-height 는 빈 상태(empty)에만 적용. */ + .example-panel { + position: sticky; + top: 24px; + max-height: calc(100dvh - 400px); + background: var(--color-canvas-pure); + border: 1px solid var(--color-rule); + border-radius: 12px; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; + } + @media (max-width: 768px) { + .example-panel { + position: static; + max-height: none; + overflow: visible; + } + } + .example-panel:has(.example-panel-empty) { + min-height: 240px; + } + + .example-panel-empty { + display: flex; + flex-direction: column; + gap: 8px; + padding: 32px 16px; + text-align: center; + color: var(--color-ink-3); + } + .example-panel-empty-title { + font-family: var(--font-serif); + font-size: 18px; + font-weight: 500; + color: var(--color-ink-2); + margin: 0; + } + .example-panel-empty-desc { + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.55; + margin: 0; + } + + .example-panel-header { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-rule-soft); + } + .example-panel-title-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + .example-panel-title { + font-family: var(--font-serif); + font-size: 22px; + font-weight: 500; + color: var(--color-ink); + margin: 0; + line-height: 1.2; + } + .example-panel-desc { + font-family: var(--font-sans); + font-size: 13px; + color: var(--color-ink-3); + line-height: 1.55; + margin: 0; + } + .example-panel-meta { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-ink-4); + letter-spacing: 0.02em; + margin: 0; + } + + .example-list { + list-style: none; + padding: 0 4px 0 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; + } + .example-card { + background: var(--color-canvas); + border: 1px solid var(--color-rule-soft); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 8px; + } + .example-card-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .example-index { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-ink-3); + letter-spacing: 0.04em; + } + .example-badge { + font-family: var(--font-sans); + font-size: 10.5px; + font-weight: 500; + padding: 2px 7px; + border-radius: 4px; + line-height: 1.4; + letter-spacing: 0.02em; + } + .example-badge-diff { + background: var(--color-pass-50); + color: var(--color-pass-deep); + } + .example-badge-type { + background: var(--color-canvas-2); + color: var(--color-ink-2); + } + .example-question { + font-family: var(--font-sans); + font-size: 13.5px; + line-height: 1.65; + color: var(--color-ink); + } + .example-answer { + display: flex; + gap: 10px; + align-items: baseline; + padding-top: 8px; + border-top: 1px dashed var(--color-rule-soft); + } + .example-answer-label { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--color-ink-3); + letter-spacing: 0.04em; + flex-shrink: 0; + } + .example-answer-value { + font-family: var(--font-sans); + font-size: 13px; + color: var(--color-ink-2); + line-height: 1.5; + } + .example-panel-footer { + font-family: var(--font-sans); + font-size: 11.5px; + color: var(--color-ink-4); + line-height: 1.55; + padding-top: 12px; + border-top: 1px solid var(--color-rule-soft); + } + + @media (max-height: 800px) { + .topic-picker.page-body { padding-top: 24px; padding-bottom: 32px; } + .topic-picker .page-title { font-size: 26px; margin-bottom: 4px; } + .topic-picker .page-subtitle { font-size: 13.5px; margin-bottom: 16px; max-width: none; } + .topic-picker .filter-row { margin-bottom: 16px; } + .topic-picker .filter-chip { height: 30px; padding: 6px 14px; font-size: 12px; } + .topic-picker .topic-layout { gap: 20px; } + .topic-picker .intent-radio-card { padding: 12px 14px; font-size: 14.5px; } + .topic-picker .intent-radio-card .label-main { font-size: 14.5px; } + .topic-picker .intent-radio-card .label-desc { font-size: 12.5px; margin-top: 2px; } + + .example-panel { max-height: calc(100dvh - 300px); padding: 14px 16px; gap: 8px; } + .example-panel-header { padding-bottom: 10px; gap: 6px; } + .example-panel-title { font-size: 18px; } + .example-panel-desc { font-size: 12px; } + .example-panel-meta { font-size: 10.5px; } + .example-card { padding: 10px 12px; gap: 6px; } + .example-card-meta { gap: 6px; } + .example-question { font-size: 12.5px; line-height: 1.55; } + .example-answer { padding-top: 6px; gap: 8px; } + .example-answer-value { font-size: 12px; } + .example-panel-footer { font-size: 10.5px; padding-top: 8px; } + } + + @media (max-height: 680px) { + .topic-picker.page-body { padding-top: 16px; padding-bottom: 24px; } + .topic-picker .page-title { font-size: 22px; margin-bottom: 2px; } + .topic-picker .page-subtitle { display: none; } + .topic-picker .filter-row { margin-bottom: 12px; } + .topic-picker .filter-chip { height: 28px; padding: 5px 12px; } + .topic-picker .topic-layout { gap: 16px; } + .topic-picker .intent-radio-card { padding: 10px 12px; } + + .example-panel { max-height: calc(100dvh - 220px); padding: 12px 14px; gap: 6px; } + .example-panel-title { font-size: 16px; } + .example-card { padding: 8px 10px; gap: 5px; } + .example-question { font-size: 12px; line-height: 1.5; } + .example-panel-footer { padding-top: 6px; } + } } diff --git a/scripts/bake-topic-examples.mjs b/scripts/bake-topic-examples.mjs new file mode 100644 index 0000000..15ccd63 --- /dev/null +++ b/scripts/bake-topic-examples.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * bake-topic-examples.mjs + * + * Demo path 단원(3개)에 대해 corpus 에서 양질의 예시 문제를 추출하여 + * `packages/web/app/app/new/topic/examples.json` 으로 굽는다. + * + * 정공법(D-13 mapping YAML) 전 임시 인라인 mini-매핑. + * 향후 `packages/agent/data/curriculum/mapping-2022-to-2015.yaml` 로 이관. + * + * 사용법: + * node scripts/bake-topic-examples.mjs + * CORPUS_JSONL=... node scripts/bake-topic-examples.mjs # 경로 override + */ + +import { createReadStream } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline"; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const OUT_PATH = resolve( + ROOT, + "packages/web/app/app/new/topic/examples.json", +); +const CORPUS = process.env.CORPUS_JSONL ?? resolve( + "/Users/tw81512/dev/AI_HUB_data/rag_problem_generation_dataset/openmath_rag_records.jsonl", +); +const PER_UNIT = 3; + +/** UI 2022 코드 → corpus 매칭 룰 (topic_name keyword + grade). + * D-13 매핑 도입 전 임시. 매칭 못 찾는 단원은 examples.json 에 미포함. + */ +const DEMO_UNITS = [ + { ui_code: "9수01-01", ui_name: "소인수분해", grade: 1, + topic_includes: ["소인수", "소수와 합성수", "약수", "배수", "최대공약수", "최소공배수"], + topic_excludes: ["방정식", "함수", "유리수"] }, + { ui_code: "9수01-02", ui_name: "정수와 유리수", grade: 1, + topic_includes: ["정수와 유리수", "수직선", "절댓값", "대소 관계"], + topic_excludes: ["방정식", "함수", "사칙"] }, + { ui_code: "9수01-03", ui_name: "유리수의 사칙연산", grade: 1, + topic_includes: ["혼합 계산", "유리수의 덧셈", "유리수의 뺄셈", "유리수의 곱셈", "유리수의 나눗셈", "사칙계산"], + topic_excludes: ["방정식", "일차식", "함수"] }, + { ui_code: "9수02-01", ui_name: "문자의 사용과 식의 값", grade: 1, + topic_includes: ["문자의 사용", "식의 값", "대입"], + topic_excludes: ["방정식", "함수", "일차식"] }, + { ui_code: "9수02-02", ui_name: "일차식의 계산", grade: 1, + topic_includes: ["일차식", "동류항"], + topic_excludes: ["방정식", "함수"] }, + { ui_code: "9수02-03", ui_name: "일차방정식", grade: 1, + topic_includes: ["일차방정식", "방정식의 해", "방정식의 뜻"], + topic_excludes: ["이차", "연립", "부등식", "활용"] }, + { ui_code: "9수02-04", ui_name: "일차방정식의 활용", grade: 1, + topic_includes: ["일차방정식", "활용"], + topic_excludes: ["이차", "함수", "부등식"] }, + { ui_code: "9수03-01", ui_name: "함수의 개념", grade: 1, + topic_includes: ["함수", "함숫값"], + topic_excludes: ["일차함수", "이차함수", "방정식"] }, + { ui_code: "9수04-01", ui_name: "기본 도형과 작도", grade: 1, + topic_includes: ["작도", "맞꼭지각", "동위각", "엇각", "기본 도형"], + topic_excludes: ["방정식", "함수"] }, + { ui_code: "9수05-01", ui_name: "자료의 정리와 해석", grade: 1, + topic_includes: ["줄기와 잎", "도수분포", "히스토그램", "상대도수"], + topic_excludes: ["함수", "방정식"] }, + + { ui_code: "9수01-04", ui_name: "유리수와 순환소수", grade: 2, + topic_includes: ["순환소수", "유한소수", "기약분수", "무한소수"], + topic_excludes: ["방정식", "함수"] }, + { ui_code: "9수02-05", ui_name: "식의 계산", grade: 2, + topic_includes: ["지수법칙", "다항식의 곱셈", "다항식의 나눗셈", "다항식의 덧셈"], + topic_excludes: ["이차", "이차함수", "방정식의 해"] }, + { ui_code: "9수02-06", ui_name: "일차부등식", grade: 2, + topic_includes: ["부등식", "일차부등식"], + topic_excludes: ["함수", "이차"] }, + { ui_code: "9수02-07", ui_name: "연립일차방정식", grade: 2, + topic_includes: ["연립", "연립방정식", "가감법", "대입법"], + topic_excludes: ["이차", "함수"] }, + { ui_code: "9수03-02", ui_name: "일차함수와 그래프", grade: 2, + topic_includes: ["일차함수", "기울기", "y절편"], + topic_excludes: ["이차", "활용", "방정식의 활용"] }, + { ui_code: "9수03-03", ui_name: "일차함수의 활용", grade: 2, + topic_includes: ["일차함수", "활용"], + topic_excludes: ["이차", "방정식"] }, + { ui_code: "9수04-02", ui_name: "삼각형의 성질", grade: 2, + topic_includes: ["이등변삼각형", "삼각형의 성질", "외심", "내심"], + topic_excludes: ["함수", "방정식", "닮음"] }, + { ui_code: "9수04-03", ui_name: "사각형의 성질", grade: 2, + topic_includes: ["평행사변형", "직사각형", "마름모", "정사각형", "사각형의 성질"], + topic_excludes: ["함수", "방정식", "닮음"] }, + { ui_code: "9수04-04", ui_name: "도형의 닮음", grade: 2, + topic_includes: ["닮음", "닮음비", "평행선 사이의 선분"], + topic_excludes: ["함수", "방정식"] }, + { ui_code: "9수05-02", ui_name: "경우의 수와 확률", grade: 2, + topic_includes: ["경우의 수", "확률", "사건"], + topic_excludes: ["함수", "방정식", "표준편차", "대푯값"] }, + + { ui_code: "9수01-05", ui_name: "제곱근과 실수", grade: 3, + topic_includes: ["제곱근", "근호", "무리수", "실수의 분류", "실수의 대소"], + topic_excludes: ["함수", "방정식", "도형", "삼각비"] }, + { ui_code: "9수01-06", ui_name: "근호를 포함한 식의 계산", grade: 3, + topic_includes: ["근호를 포함한", "근호", "분모의 유리화"], + topic_excludes: ["함수", "방정식", "도형", "삼각비"] }, + { ui_code: "9수02-08", ui_name: "다항식의 곱셈과 인수분해", grade: 3, + topic_includes: ["곱셈 공식", "곱셈공식", "인수분해", "다항식의 곱셈"], + topic_excludes: ["이차방정식", "이차함수", "삼각비"] }, + { ui_code: "9수02-09", ui_name: "이차방정식", grade: 3, + topic_includes: ["이차방정식"], + topic_excludes: ["함수", "그래프", "활용"] }, + { ui_code: "9수02-10", ui_name: "이차방정식의 활용", grade: 3, + topic_includes: ["이차방정식", "활용"], + topic_excludes: ["함수", "그래프"] }, + { ui_code: "9수03-04", ui_name: "이차함수와 그래프", grade: 3, + topic_includes: ["이차함수"], + topic_excludes: ["방정식의 해", "삼각비"] }, + { ui_code: "9수04-05", ui_name: "삼각비", grade: 3, + topic_includes: ["삼각비"], + topic_excludes: ["함수", "방정식"] }, + { ui_code: "9수04-06", ui_name: "원과 직선의 위치 관계", grade: 3, + topic_includes: ["원의 접선", "원의 현", "원과 직선"], + topic_excludes: ["원주각", "함수", "방정식"] }, + { ui_code: "9수04-07", ui_name: "원주각", grade: 3, + topic_includes: ["원주각"], + topic_excludes: ["함수", "방정식"] }, + { ui_code: "9수05-03", ui_name: "대푯값과 산포도", grade: 3, + topic_includes: ["평균", "중앙값", "최빈값", "분산", "표준편차", "산포도"], + topic_excludes: ["함수", "방정식", "도형"] }, +]; + +function topicNameMatches(topicName, includes, excludes) { + if (!topicName) return false; + const tn = topicName; + if (!includes.some((kw) => tn.includes(kw))) return false; + if (excludes.some((kw) => tn.includes(kw))) return false; + return true; +} + +/** preview 로 보여줄 수 있는 문제인지. */ +function isUsablePreview(record) { + const p = record.problem ?? {}; + const q = (p.question_text ?? "").trim(); + const a = (p.answer_text ?? "").trim(); + if (q.length < 25 || q.length > 280) return false; + if (!a) return false; + if ((record.quality ?? {}).is_usable === false) return false; + // 객관식 답이 ①~⑤ 만 있는 경우 미리보기 가치 낮음 → 제외 + if (/^[①②③④⑤]$/.test(a)) return false; + // 이미지 의존 문제는 텍스트가 "다음 그림 / 도형 / <보기>" 등으로 시작하면 제외 (preview 부적합). + // 이미지 metadata 만 있고 텍스트가 자족적인 경우는 통과. + if ((record.media ?? {}).question_image_relpath) { + if (/^(다음\s*그림|아래\s*그림|그림과\s*같이|<\s*보기\s*>|다음\s*도형|다음\s*표)/.test(q)) { + return false; + } + } + return true; +} + +/** 단일 단원에 대한 후보를 점수 정렬 후 PER_UNIT 개 선택. */ +function pickForUnit(candidates) { + // 점수: easy=2, medium=1, hard=0 + 답 길이 적당함 보너스 + const scored = candidates.map((c) => { + const d = c.problem.difficulty; + let score = d === "easy" ? 2 : d === "medium" ? 1 : 0; + const aLen = (c.problem.answer_text ?? "").length; + if (aLen >= 1 && aLen <= 40) score += 1; + const eLen = (c.problem.explanation_text ?? "").length; + if (eLen > 0) score += 0.5; + return { c, score }; + }); + + // difficulty 분산: easy / medium 골고루 + scored.sort((a, b) => b.score - a.score); + const picked = []; + const seen = new Set(); + // 1차 패스: 쉬운 것부터 다양한 topic_name 으로 + for (const { c } of scored) { + if (picked.length >= PER_UNIT) break; + const tn = c.curriculum.topic_name ?? ""; + if (seen.has(tn)) continue; + picked.push(c); + seen.add(tn); + } + // 2차 패스: 부족하면 점수순으로 채움 + for (const { c } of scored) { + if (picked.length >= PER_UNIT) break; + if (!picked.includes(c)) picked.push(c); + } + return picked; +} + +async function main() { + console.log(`[bake] corpus: ${CORPUS}`); + console.log(`[bake] out: ${OUT_PATH}`); + + /** ui_code -> candidates[] */ + const buckets = new Map(DEMO_UNITS.map((u) => [u.ui_code, []])); + + const rl = createInterface({ + input: createReadStream(CORPUS, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + let total = 0; + for await (const line of rl) { + if (!line.trim()) continue; + total += 1; + let r; + try { + r = JSON.parse(line); + } catch { + continue; + } + const c = r.curriculum ?? {}; + if (c.school_level !== "middle") continue; + if (!isUsablePreview(r)) continue; + + for (const unit of DEMO_UNITS) { + if (c.grade !== unit.grade) continue; + if ( + !topicNameMatches( + c.topic_name, + unit.topic_includes, + unit.topic_excludes, + ) + ) { + continue; + } + buckets.get(unit.ui_code).push(r); + break; // 한 record 는 한 단원에만 + } + } + + console.log(`[bake] scanned ${total.toLocaleString()} records`); + + /** out 구조: { "": [{ id, ... }, ...] } */ + const out = { + generated_at: new Date().toISOString(), + corpus_source: CORPUS.split("/").pop(), + note: "임시 mini-매핑 기반. D-13 도입 후 mapping-2022-to-2015.yaml 로 이관.", + units: {}, + }; + + for (const unit of DEMO_UNITS) { + const candidates = buckets.get(unit.ui_code); + const picked = pickForUnit(candidates); + out.units[unit.ui_code] = { + ui_name: unit.ui_name, + grade: unit.grade, + candidate_pool: candidates.length, + examples: picked.map((r) => ({ + id: r.id.problem_id, + achievement_code_2015: r.curriculum.achievement_code, + topic_name_2015: r.curriculum.topic_name, + difficulty: r.problem.difficulty, + problem_type: r.problem.problem_type, + question_text: (r.problem.question_text ?? "").trim(), + answer_text: (r.problem.answer_text ?? "").trim(), + })), + }; + console.log( + `[bake] ${unit.ui_code} (${unit.ui_name}): pool=${candidates.length}, picked=${picked.length}`, + ); + } + + await mkdir(dirname(OUT_PATH), { recursive: true }); + await writeFile(OUT_PATH, JSON.stringify(out, null, 2) + "\n"); + console.log(`[bake] wrote ${OUT_PATH}`); +} + +main().catch((err) => { + console.error("[bake] FAILED:", err); + process.exit(1); +});