diff --git a/README.md b/README.md index 0e75b4b..3bb14f7 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,94 @@ pnpm build # production build (agent + web) - Math Engine: `http://localhost:8000` - Web: `http://localhost:3001` (landing) +### LLM 환경 설정 (필수) + +**⚠ LLM 환경 없이는 생성·검증이 100% 실패합니다** — seed fallback은 RAG 원본을 그대로 후보로 반환하므로 `objective_map`의 `not_transformed` 가드에 걸립니다. + +다음 3가지 중 하나를 설정하세요. 모두 `packages/agent/.env` (또는 환경변수)로 주입. + +#### 옵션 1 — CLIProxyAPI (Claude/GPT/Gemini 통합 라우터, 권장) + +[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 로컬에서 실행하면 Claude·GPT·Gemini를 OpenAI 호환 API 하나로 라우팅 가능. + +```bash +LLM_PROVIDER=cliproxy +LLM_BASE_URL=http://localhost:8317/v1 +LLM_API_KEY=dummy-key +LLM_MODEL=gpt-4o # 또는 claude-3-5-sonnet, gemini-2.0-flash +``` + +**Trade-off**: 로컬 라우터 별도 실행 필요. 캐시·모델 비교에 유리. + +#### 옵션 2 — OpenAI 직접 + +```bash +LLM_PROVIDER=openai +OPENAI_API_KEY=sk-... +OPENAI_MODEL=gpt-4o +``` + +**Trade-off**: 가장 빠른 setup. 비용은 사용량 기반. + +#### 옵션 3 — Anthropic via OpenAI-compatible 호환 레이어 + +```bash +LLM_PROVIDER=anthropic-via-compatible +LLM_BASE_URL= +LLM_API_KEY=... +LLM_MODEL=claude-3-5-sonnet-20241022 +``` + +**Trade-off**: 별도 호환 layer 필요 (예: LiteLLM proxy). Claude의 수학 추론 품질 활용. + +### 포트 충돌 회피 + +다른 프로세스(Mantis, 별도 Next.js 서버 등)가 3000번을 점유하면 `pnpm dev:all` 시 agent가 `EADDRINUSE`로 실패. 다음 단계로 회피: + +```bash +# 1. 충돌 확인 +lsof -i :3000 + +# 2. agent 포트 변경 +PORT=3002 pnpm dev + +# 3. web도 다른 포트의 agent를 보도록 변경 +# packages/web/.env.local 생성: +echo "NEXT_PUBLIC_AGENT_URL=http://localhost:3002" > packages/web/.env.local + +# 4. web 재기동 +pnpm -F @openmath/web dev +``` + +`NEXT_PUBLIC_AGENT_URL`이 미설정이면 web은 `http://localhost:3000`을 호출하므로 포트 변경 시 반드시 `.env.local` 갱신. + +### 데모 실행 절차 (캡스톤) + +기본 데모 시나리오: **중3 / 이차방정식 (9수02-09) / 구조 동형 / 3문항** (OM-104 결정). + +```bash +# 1. (한 번) deps + LLM 환경 + corpus 확인 +pnpm install +cp packages/agent/.env.example packages/agent/.env +# .env 의 LLM_* 채우기 + +# 2. (매 시연) 세 서비스 기동 +pnpm dev:all + +# 3. (브라우저) http://localhost:3001 → S0 → 학년(중3) → 단원(이차방정식) +# → 평가 차원(인수분해 또는 근의 공식 사용 + 판별식) → 생성 + +# 4. (검증) S4 6단계 ✓ → S5 결과 3문항 → S6 PDF +``` + +또는 직접 SSE: + +```bash +curl -sN -X POST http://localhost:3000/api/generate \ + -H "Content-Type: application/json" \ + -d '{"grade":3,"topic":"9수02-09","mode":"structural","dims":["인수분해 또는 근의 공식 사용","판별식으로 해의 종류 해석"]}' +``` + ``` packages/ ├── agent/ # Node 22 — verification pipeline + HTTP/SSE diff --git a/docs/product/SCREENS.md b/docs/product/SCREENS.md index 5e6053a..9974b91 100644 --- a/docs/product/SCREENS.md +++ b/docs/product/SCREENS.md @@ -390,7 +390,7 @@ |---|---|---| | Sub nav | `{component.sub-nav}` | "← 검증 진행 / 결과" · 우측 progress 카운터 없음 | | Page title | `{typography.heading-xl}` | "3개 문항이 준비되었습니다" — 동적 카운트 | -| Filter row | `{component.filter-chip}` × M | "전체" / "구조동형" / "개념동형" / "주의" / "실패" | +| Filter row | `{component.filter-chip}` × 4 | "전체" / "구조동형" / "개념동형" / "주의" — 실패 카드는 색·아이콘으로 충분히 구분되므로 별도 필터 없음 (OM-101 결정) | | Result grid | `{component.result-card}` × N (3-up → 1024px↓ 2-up → 600px↓ 1-up) | | | Card head | `{typography.body-strong}` 문항 번호 + `{component.badge-pass}` / `{component.badge-concept}` / `{component.badge-warn}` + 아이콘 클러스터 (`{component.button-icon-circular}` ★ 즐겨찾기 / ↻ 재생성 / ✎ 수정 / × 폐기) | padding `16px 20px`, border-bottom `{colors.hairline-soft}` | | Card body | `{component.formula-stage}` | LaTeX 문제 본문 (KaTeX), padding `28px 24px`, min-height 120px | diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index c8b2e6b..910c419 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -180,6 +180,20 @@ - **대안**: (a) Vite + React (SSR 없음 → 첫 페인트 늦음, SEO 약함, AI SDK 통합 약함), (b) SvelteKit (팀 친숙도 낮음, 학습 비용), (c) `apps/` + `packages/` 분리 (현재 평면 3 패키지 구조에서 분리 비용 > 가치), (d) Pretendard 단일 시스템 유지 (랜딩의 editorial 권위 표현 못함). - **채택 사유**: D-4 Vercel AI SDK 와 1:1 호환 (`ai/react`, `useChat`, SSE consumption hook 기본). Server Components 로 KaTeX SSR (수식이 첫 페인트부터 보임). Tailwind v4 의 CSS-first config 가 DESIGN.md 토큰을 그대로 `@theme` 블록에 매핑 가능. 듀얼-서피스로 랜딩 (D-3 학원 강사 첫 인상 — 신뢰감) 과 앱 내부 (정확/결정론적 출제 도구) 의 voice 둘 다 충족. 기존 `docs/product/DESIGN.md` (Nike fork productivity-only) 는 본 결정으로 superseded → historical reference 로 보존. +### D-11. I-G4 정책 — 검증 거부 후보 *투명* 노출 + +- **결정**: `Verification.overall == "rejected"` 후보를 SSE `result` 이벤트에 **포함**한다. FE는 fail 카드 (좌측 4px `{colors.fail}` border + `inline-notice-fail`) 로 명시 + "채택" 액션 비활성. 데이터로는 노출하되 *사용 가능한 문항* 으로 보이지 않게 시각적 가드. +- **대안**: (A) 본 결정 (투명 노출), (B) 워크플로 result emit 시 rejected 필터링 (error 이벤트로 별도 알림), (C) rejected는 별도 SSE 이벤트 `rejection` 으로 분리. +- **채택 사유**: D-3 1차 사용자 (학원 강사) 에게 *왜 검증이 실패했는지* 신호가 가장 중요. (B) 는 "AI가 뭔가 만들었는데 보여주지 않음" 으로 신뢰 하락. (A) 의 시각적 가드 (fail 카드 + 채택 비활성) 가 I-G4 ("검증되지 않은 문제는 *사용자에게 제공* 되어서는 안 된다") 의 의도를 충족 — *제공* 의 의미는 "강사가 학생 시험지에 넣을 수 있는 상태" 이지 "화면 표시 자체" 가 아니다. 자세한 시각 규약은 `packages/web/DESIGN.md` `{component.result-card-failed}` 참조. +- **Closes**: OM-92. + +### D-12. S0-B (OCR 입력) — v2 로 명확히 보류 + +- **결정**: S0-B "이 문제처럼" (OCR 이미지 입력 → LaTeX 자동 추출) 흐름은 1차 MVP 범위 외. 캡스톤 데모 후 v2 트리거. S0 카드는 현 상태 (disabled + "준비 중" 표기) 유지. +- **대안**: (A) 본 결정 (v2 보류), (B) MVP 직후 추가 (Mathpix or Tesseract+SymPy 라이브러리 선정 + LaTeX 후처리 epic 신설), (C) v3 이후 보류. +- **채택 사유**: 캡스톤 5분 데모 (OM-104: 중3 / 이차방정식 / 구조 동형 / 3문항) 흐름은 S0-A → S1 → S6 만으로 완결. OCR 도입 시 *Q-U4* (S0-B 도입 시 S1 존재 의의) 가 함께 풀려야 하고, 모델 선정 + 비용 + 인식률 평가가 별도 epic 단위. v2 트리거 시 (B) 가 정답이 될 수도 있으나, *현 시점에서 명시적 보류* 가 최선. +- **Closes**: OM-103, OM-58 §(4). + ### D-10. 인증·세션 layer 는 Better Auth — v2 도입 (1차 MVP 비활성) - **결정**: 사용자 가입·로그인·세션·계정 관리 기능 도입 시 [Better Auth](https://www.better-auth.com) 를 채택. TypeScript-first, framework-agnostic auth library. 1차 MVP (D-3 학원 강사 단일 사용자 PoC) 에서는 *비활성* — 랜딩의 "무료로 시작하기" CTA 는 placeholder, 실제 가입 흐름 없음. 캡스톤 데모 발표 후 v2 트리거. - **대안**: (a) NextAuth/Auth.js (Next.js 14 App Router 1급, 그러나 type-safety/customization 약함, OAuth 외 흐름 어색), (b) Clerk (호스티드 SaaS — 한국 컴플라이언스·비용·vendor 락인), (c) Supabase Auth (전체 BaaS 락인 — DB·storage·realtime 함께 채택해야 효율), (d) Lucia Auth (2024 maintenance mode 진입), (e) 자체 구현 (보안 위험 + 일정 부담). diff --git a/docs/specs/domain.md b/docs/specs/domain.md index d5ffe8f..72946dc 100644 --- a/docs/specs/domain.md +++ b/docs/specs/domain.md @@ -127,7 +127,7 @@ type SurfaceConstraints = { - (I-G1) `inferred_intent.objective_code == request.intent.objective_code` — 학습 목표는 항상 동일. - (I-G2) `mode == "structural"`이면 `inferred_intent.required_techniques`가 원본과 동일 (구조 동형 정의). - (I-G3) `mode == "conceptual"`이면 `inferred_intent.evaluation_dimensions` 중 `must_preserve` 차원이 원본과 동일 (개념 동형 정의). -- (I-G4) 검증되지 않은 `GeneratedProblem`은 사용자에게 노출되지 않는다 (D-1 원칙). +- (I-G4) 검증되지 않은 `GeneratedProblem`은 *사용 가능한 문항* 으로 노출되지 않는다 — verdict `"rejected"` 후보는 `result` 이벤트에 포함되되 FE의 `{component.result-card-failed}` (좌측 4px `{colors.fail}` border + `inline-notice-fail` + "채택" 비활성) 로 *시각 가드* 되어 학생 시험지 등 외부로 흘러가지 못함. *데이터 노출* 은 허용 (강사 신뢰 확보), *사용 노출* 은 차단 (D-1 원칙). 자세한 정책 결정은 architecture.md D-11. --- diff --git a/packages/agent/.env.example b/packages/agent/.env.example index 888cdc4..afaf8e4 100644 --- a/packages/agent/.env.example +++ b/packages/agent/.env.example @@ -1,15 +1,31 @@ +# OpenMath agent — environment variables. +# 모두 optional. 미설정 시 src/config/env.ts 기본값 또는 src/index.ts fallback 사용. + +# --- 서버 --- +# 다른 프로세스(예: Mantis 등)가 3000 점유 시 PORT 변경 + 동시에 +# packages/web/.env.local 의 NEXT_PUBLIC_AGENT_URL 도 같이 갱신해야 web↔agent 연결됨. PORT=3000 MATH_ENGINE_URL=http://localhost:8000 -CORPUS_JSONL=/Users/tw81512/dev/AI_HUB_data/rag_problem_generation_dataset/openmath_rag_records.jsonl -# LLM Provider: "openai" or "cliproxy" -LLM_PROVIDER=openai +# --- RAG --- +# 미설정 시 ./data/corpus/math-sample-unified-v1.jsonl (repo 동봉) 사용. +# 별도 corpus 경로 쓰려면 절대경로 또는 packages/agent/ 기준 상대경로로 지정. +# CORPUS_JSONL=./data/corpus/math-sample-unified-v1.jsonl -# OpenAI Direct -OPENAI_API_KEY=your-api-key-here -OPENAI_MODEL=gpt-4o +# --- LLM 환경 (필수 — 미설정 시 seed fallback 으로 생성/검증 100% 실패) --- +# LLM_PROVIDER: "openai" | "openai-compatible" | "anthropic-via-compatible" | "cliproxy" +LLM_PROVIDER=cliproxy -# CLIProxyAPI (alternative) -CLIPROXY_BASE_URL=http://localhost:8080/v1 +# (옵션 1) CLIProxyAPI 로컬 라우터 — Claude/GPT/Gemini 통합 (https://github.com/router-for-me/CLIProxyAPI) +CLIPROXY_BASE_URL=http://localhost:8317/v1 CLIPROXY_API_KEY=dummy-key CLIPROXY_MODEL=gpt-4o + +# (옵션 2) OpenAI 직접 — 비용/속도 안정. LLM_PROVIDER=openai 와 함께. +# OPENAI_API_KEY=sk-... +# OPENAI_MODEL=gpt-4o + +# Canonical aliases — 위 3가지 옵션 중 어느 것도 매핑되지 않으면 이 3개로 직접 지정 가능 +LLM_BASE_URL=http://localhost:8317/v1 +LLM_API_KEY=dummy-key +LLM_MODEL=gpt-4o diff --git "a/packages/agent/data/achievement-standards/9\354\210\23001-05.yaml" "b/packages/agent/data/achievement-standards/9\354\210\23001-05.yaml" new file mode 100644 index 0000000..9e37cce --- /dev/null +++ "b/packages/agent/data/achievement-standards/9\354\210\23001-05.yaml" @@ -0,0 +1,38 @@ +code: 9수01-05 +title: 제곱근의 뜻과 실수의 분류를 이해한다 +school_level: middle +grade: 3 +techniques: + required_at_least_one_of: + - square_root_definition + - real_number_classification + forbidden: + - calculus +evaluation_dimensions: + - id: A + description: 제곱근의 뜻을 식으로 해석한다 + must_preserve: true + - id: B + description: 양수의 두 제곱근을 모두 구한다 + must_preserve: true + - id: C + description: 근호를 사용해 정확한 값을 표현한다 + must_preserve: true + - id: D + description: 실수 범위에서 해를 판단한다 + must_preserve: true +difficulty_range: + - easy + - medium +problem_types_supported: + - short_answer + - objective +structural_transforms: + - kind: coefficient_swap + range: [2, 20] + exclude_zero: true + - kind: variable_rename + allowed: [x, y] +conceptual_transforms: + - kind: inverse_question + hint: 제곱하면 같은 수가 되는 두 실수를 묻는다 diff --git "a/packages/agent/data/achievement-standards/9\354\210\23002-03.yaml" "b/packages/agent/data/achievement-standards/9\354\210\23002-03.yaml" new file mode 100644 index 0000000..2a9cbdf --- /dev/null +++ "b/packages/agent/data/achievement-standards/9\354\210\23002-03.yaml" @@ -0,0 +1,32 @@ +code: 9수02-03 +title: 일차방정식을 풀 수 있다 +school_level: middle +grade: 1 +techniques: + required_at_least_one_of: + - inverse_operation + - equation_simplification + forbidden: + - quadratic_formula +evaluation_dimensions: + - id: A + description: 등식의 성질을 이용해 미지수를 고립시킨다 + must_preserve: true + - id: B + description: 구한 값을 원래 식에 대입해 확인한다 + must_preserve: true +difficulty_range: + - easy + - medium +problem_types_supported: + - short_answer + - objective +structural_transforms: + - kind: coefficient_swap + range: [-20, 20] + exclude_zero: true +conceptual_transforms: + - kind: rephrase_as_word_problem + context: + - 수량 관계 + - 비율 diff --git "a/packages/agent/data/achievement-standards/9\354\210\23002-09.yaml" "b/packages/agent/data/achievement-standards/9\354\210\23002-09.yaml" new file mode 100644 index 0000000..5972d0e --- /dev/null +++ "b/packages/agent/data/achievement-standards/9\354\210\23002-09.yaml" @@ -0,0 +1,31 @@ +code: 9수02-09 +title: 이차방정식을 풀 수 있다 +school_level: middle +grade: 3 +techniques: + required_at_least_one_of: + - factorization + - root_relation + forbidden: + - calculus +evaluation_dimensions: + - id: A + description: 이차식을 인수분해하여 해를 구한다 + must_preserve: true + - id: B + description: 해를 원래 식에 대입해 검산한다 + must_preserve: true +difficulty_range: + - easy + - medium +problem_types_supported: + - short_answer + - objective +structural_transforms: + - kind: coefficient_swap + range: [-12, 12] + exclude_zero: true + - kind: sign_flip +conceptual_transforms: + - kind: present_via_root_relations + hint: 두 근의 합과 곱 조건으로 다시 제시 diff --git a/packages/agent/data/corpus/math-sample-unified-v1.jsonl b/packages/agent/data/corpus/math-sample-unified-v1.jsonl new file mode 100644 index 0000000..95a538a --- /dev/null +++ b/packages/agent/data/corpus/math-sample-unified-v1.jsonl @@ -0,0 +1,7 @@ +{"id":{"problem_id":"seed-9수02-09-001","source_dataset":"111","split":"train","source_label_type":"seed"},"curriculum":{"school_level":"middle","grade":3,"semester":1,"topic_code":"9수02-09","topic_name":"이차방정식","achievement_standard":"9수02-09","achievement_confidence":1.0},"problem":{"question_text":"x**2 - 5*x + 6 = 0","answer_text":"2, 3","explanation_text":"(x - 2)(x - 3) = 0 이므로 x = 2 또는 x = 3","choice_blocks":null,"problem_type":"short_answer","difficulty":"easy"}} +{"id":{"problem_id":"seed-9수02-09-002","source_dataset":"111","split":"train","source_label_type":"seed"},"curriculum":{"school_level":"middle","grade":3,"semester":1,"topic_code":"9수02-09","topic_name":"이차방정식","achievement_standard":"9수02-09","achievement_confidence":1.0},"problem":{"question_text":"x**2 + 4*x - 12 = 0","answer_text":"2, -6","explanation_text":"(x - 2)(x + 6) = 0 이므로 x = 2 또는 x = -6","choice_blocks":null,"problem_type":"short_answer","difficulty":"medium"}} +{"id":{"problem_id":"seed-9수02-09-003","source_dataset":"111","split":"train","source_label_type":"seed"},"curriculum":{"school_level":"middle","grade":3,"semester":1,"topic_code":"9수02-09","topic_name":"이차방정식","achievement_standard":"9수02-09","achievement_confidence":1.0},"problem":{"question_text":"x**2 - 4*x + 4 = 0","answer_text":"2","explanation_text":"(x - 2)**2 = 0 이므로 x = 2","choice_blocks":null,"problem_type":"short_answer","difficulty":"easy"}} +{"id":{"problem_id":"seed-9수02-03-001","source_dataset":"111","split":"train","source_label_type":"seed"},"curriculum":{"school_level":"middle","grade":1,"semester":1,"topic_code":"9수02-03","topic_name":"일차방정식","achievement_standard":"9수02-03","achievement_confidence":1.0},"problem":{"question_text":"3*x + 5 = 14","answer_text":"3","explanation_text":"3x = 9 이므로 x = 3","choice_blocks":null,"problem_type":"short_answer","difficulty":"easy"}} +{"id":{"problem_id":"seed-9수02-03-002","source_dataset":"111","split":"train","source_label_type":"seed"},"curriculum":{"school_level":"middle","grade":1,"semester":1,"topic_code":"9수02-03","topic_name":"일차방정식","achievement_standard":"9수02-03","achievement_confidence":1.0},"problem":{"question_text":"4*(x - 1) = 8","answer_text":"3","explanation_text":"4x - 4 = 8, 4x = 12 이므로 x = 3","choice_blocks":null,"problem_type":"short_answer","difficulty":"easy"}} +{"id":{"problem_id":"seed-9수02-07-001","source_dataset":"111","split":"train","source_label_type":"seed"},"curriculum":{"school_level":"middle","grade":2,"semester":1,"topic_code":"9수02-07","topic_name":"연립일차방정식","achievement_standard":"9수02-07","achievement_confidence":1.0},"problem":{"question_text":"x + y = 5; x - y = 1","answer_text":"x = 3, y = 2","explanation_text":"두 식을 더하면 2x = 6, x = 3이고 y = 2","choice_blocks":null,"problem_type":"short_answer","difficulty":"medium"}} +{"id":{"problem_id":"seed-9수01-05-001","source_dataset":"111","split":"train","source_label_type":"seed"},"curriculum":{"school_level":"middle","grade":3,"semester":1,"topic_code":"9수01-05","topic_name":"제곱근과 실수","achievement_standard":"9수01-05","achievement_confidence":1.0},"problem":{"question_text":"x**2 - 5 = 0","answer_text":"sqrt(5), -sqrt(5)","explanation_text":"x**2 = 5 이므로 x = sqrt(5) 또는 x = -sqrt(5)","choice_blocks":null,"problem_type":"short_answer","difficulty":"medium"}} diff --git a/packages/agent/prompts/objective-mapper.md b/packages/agent/prompts/objective-mapper.md index 0751777..6ef5a1c 100644 --- a/packages/agent/prompts/objective-mapper.md +++ b/packages/agent/prompts/objective-mapper.md @@ -47,7 +47,3 @@ LLM 보조 판정은 *nuance*만 제공: - 통과/실패 *판정*은 하지 않는다. 결정론 매칭 결과를 *bolster*만 함 (D-1) - 의심스러우면 `lost_dimensions`에 표기 - -# TODO - -- ObjectiveMappingNuanceSchema 정의 후 schema 필드 채우기 diff --git a/packages/agent/prompts/problem-generator.md b/packages/agent/prompts/problem-generator.md index b3276d4..57a74f1 100644 --- a/packages/agent/prompts/problem-generator.md +++ b/packages/agent/prompts/problem-generator.md @@ -2,7 +2,7 @@ id: problem-generator version: 0.1.0 model: gpt-4o -temperature: 0.7 +temperature: 0.35 max_tokens: 2000 schema: GeneratedProblemSchema variables: @@ -23,6 +23,15 @@ updated: 2026-05-18 `{{request.mode}}` — `structural` 이면 같은 풀이법, 다른 숫자/표현. `conceptual` 이면 같은 학습 목표·평가 차원, 다른 풀이 경로. +# Selected Source Problem + +이 문제를 반드시 변형의 기준으로 삼아라: `{{request.source_problem_text}}` + +- 원문을 그대로 복사하지 말 것. +- structural: 식의 골격과 풀이 단계는 유지하되 계수·상수·근을 바꾼다. +- conceptual: 평가 차원은 보존하되 식의 형태를 바꾼다. 예: `x**2 - a = 0` 기준이면 `(x - h)**2 - a = 0` 또는 전개형 `x**2 + b*x + c = 0`처럼 다른 표현 경로를 사용한다. +- `{{request.difficulty}}` 난이도에 맞춘다. medium 이상에서는 `x**2 - a = 0`처럼 너무 직접적인 기본형만 내지 말고 한 단계 변형된 식을 낸다. + # Intent 학습 목표: {{intent.objective_description}} (`{{intent.objective_code}}`) @@ -47,10 +56,14 @@ updated: 2026-05-18 # Output -`GeneratedProblem` 스키마(`docs/specs/domain.md` §2.3)에 맞춰 JSON으로 응답. -- `question_text`, `expected_answer`는 LaTeX (`\frac` 통일, `\dfrac` 금지) -- `proposed_solution_trace`에 풀이 단계 명시 -- `inferred_intent.objective_code == intent.objective_code` (I-G1) +JSON으로만 응답. +- `question_text`는 KaTeX가 바로 렌더링할 수 있는 LaTeX `x` 방정식 문자열이어야 한다. `**`, `*`를 쓰지 말고 지수는 `x^{2}`, 곱셈은 `5x`, 제곱근은 `\\sqrt{7}`처럼 쓴다. +- `expected_answer`도 LaTeX로 쓴다. 예: `2, 5`, `\\sqrt{7}, -\\sqrt{7}`. +- 예시 출력: `{ "question_text": "x^{2} - 7x + 10 = 0", "expected_answer": "2, 5", "proposed_solution_trace": "인수분해하면 (x - 2)(x - 5) = 0이므로 x = 2 또는 x = 5이다." }` +- 서버가 SymPy로 다시 계산해 최종 정답을 검증한다. +- `proposed_solution_trace`에 풀이 단계와 출제 의도를 한국어로 명시한다. +- 풀이 가능하고 답이 실수인 중학교 수준 방정식만 생성한다. +- 결과 문제는 source problem과 달라야 하며, structural/conceptual 모드 차이가 풀이 설명에 드러나야 한다. # TODO diff --git a/packages/agent/src/agents/constraint-critic-agent.ts b/packages/agent/src/agents/constraint-critic-agent.ts index 1356120..1a97975 100644 --- a/packages/agent/src/agents/constraint-critic-agent.ts +++ b/packages/agent/src/agents/constraint-critic-agent.ts @@ -2,8 +2,16 @@ * (Korean middle-school level, problem-type format, wording clarity). Never judges correctness. */ import type { LanguageModel } from "ai"; +import { generateObject } from "ai"; -import type { GeneratedProblem, Intent, Strategy } from "../schemas/index.js"; +import { + CritiqueSchema, + type Critique, + type GeneratedProblem, + type Intent, + type Strategy, +} from "../schemas/index.js"; +import type { PromptLoader } from "../tools/prompt-loader.js"; export interface CritiqueInput { candidate: GeneratedProblem; @@ -11,10 +19,7 @@ export interface CritiqueInput { strategy: Strategy | null; } -export interface Critique { - passes: boolean; - hints: string[]; -} +export type { Critique } from "../schemas/index.js"; export interface ConstraintCriticAgent { critique(input: CritiqueInput): Promise; @@ -22,11 +27,30 @@ export interface ConstraintCriticAgent { export interface ConstraintCriticAgentDeps { model: LanguageModel; + modelId: string; promptId: string; + prompts: PromptLoader; } export function createConstraintCriticAgent( - _deps: ConstraintCriticAgentDeps, + deps: ConstraintCriticAgentDeps, ): ConstraintCriticAgent { - throw new Error("createConstraintCriticAgent: not implemented yet"); + return { + async critique(input) { + const prompt = await deps.prompts.load(deps.promptId); + const rendered = prompt.render({ + candidate: input.candidate, + intent: input.intent, + strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2), + }); + const { object } = await generateObject({ + model: deps.model, + schema: CritiqueSchema, + mode: "json", + temperature: prompt.metadata.temperature, + prompt: rendered, + }); + return object; + }, + }; } diff --git a/packages/agent/src/agents/generator-agent.ts b/packages/agent/src/agents/generator-agent.ts index 8fca6ee..7a43a6b 100644 --- a/packages/agent/src/agents/generator-agent.ts +++ b/packages/agent/src/agents/generator-agent.ts @@ -1,6 +1,9 @@ /** GeneratorAgent — D-5 GenerationSpecialist team. Produces GeneratedProblem candidate. */ -import type { LanguageModel } from "ai"; +import { randomUUID } from "node:crypto"; + +import { generateObject, type LanguageModel } from "ai"; +import { z } from "zod"; import type { GenerateRequest, @@ -9,6 +12,7 @@ import type { RagResult, Strategy, } from "../schemas/index.js"; +import type { PromptLoader } from "../tools/prompt-loader.js"; export interface GeneratorAgentInput { request: GenerateRequest; @@ -25,9 +29,62 @@ export interface GeneratorAgent { export interface GeneratorAgentDeps { model: LanguageModel; + modelId: string; promptId: string; + prompts: PromptLoader; } -export function createGeneratorAgent(_deps: GeneratorAgentDeps): GeneratorAgent { - throw new Error("createGeneratorAgent: not implemented yet"); +const LlmGeneratedCandidateSchema = z.object({ + question_text: z + .string() + .min(1) + .describe("SymPy-parseable x equation, transformed from the selected source problem"), + expected_answer: z + .string() + .min(1) + .describe("Comma-separated exact solutions using sqrt(...) when needed"), + proposed_solution_trace: z + .string() + .min(1) + .describe("Korean solution trace explaining the structural/conceptual transform"), +}); + +export function createGeneratorAgent(deps: GeneratorAgentDeps): GeneratorAgent { + return { + async generate(input) { + const prompt = await deps.prompts.load(deps.promptId); + const rendered = prompt.render({ + request: input.request, + intent: input.intent, + refs: input.refs, + strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2), + refinementHint: input.refinementHint, + }); + const { object } = await generateObject({ + model: deps.model, + schema: LlmGeneratedCandidateSchema, + mode: "json", + temperature: prompt.metadata.temperature, + prompt: rendered, + }); + + return { + candidate_id: randomUUID(), + mode: input.request.mode === "conceptual" ? "conceptual" : "structural", + question_text: object.question_text, + expected_answer: object.expected_answer, + proposed_solution_trace: object.proposed_solution_trace, + source_refs: input.refs.map((ref) => ref.item_id), + inferred_intent: input.intent, + generation_metadata: { + model: deps.modelId, + temperature: prompt.metadata.temperature, + prompt_id: prompt.metadata.id, + prompt_version: prompt.metadata.version, + attempt: input.attempt, + generated_at: new Date().toISOString(), + }, + }; + }, + }; } diff --git a/packages/agent/src/agents/refiner-agent.ts b/packages/agent/src/agents/refiner-agent.ts index 6960163..ff77d22 100644 --- a/packages/agent/src/agents/refiner-agent.ts +++ b/packages/agent/src/agents/refiner-agent.ts @@ -2,11 +2,16 @@ import type { LanguageModel } from "ai"; -import type { GeneratedProblem, Intent } from "../schemas/index.js"; +import type { GenerateRequest, GeneratedProblem, Intent, RagResult, Strategy } from "../schemas/index.js"; +import type { GeneratorAgent } from "./generator-agent.js"; export interface RefineInput { prior: GeneratedProblem; + request: GenerateRequest; intent: Intent; + refs: RagResult[]; + strategy: Strategy | null; + attempt: number; hints: string[]; } @@ -16,9 +21,29 @@ export interface RefinerAgent { export interface RefinerAgentDeps { model: LanguageModel; + modelId: string; promptId: string; + generator: GeneratorAgent; } -export function createRefinerAgent(_deps: RefinerAgentDeps): RefinerAgent { - throw new Error("createRefinerAgent: not implemented yet"); +export function createRefinerAgent(deps: RefinerAgentDeps): RefinerAgent { + return { + async refine(input) { + const refinementHint = [ + "Prior candidate:", + input.prior.question_text, + "Critique hints:", + ...input.hints.map((hint) => `- ${hint}`), + ].join("\n"); + + return deps.generator.generate({ + request: input.request, + intent: input.intent, + refs: input.refs, + strategy: input.strategy, + attempt: input.attempt, + refinementHint, + }); + }, + }; } diff --git a/packages/agent/src/agents/solver-agent.ts b/packages/agent/src/agents/solver-agent.ts index 321c580..7f98312 100644 --- a/packages/agent/src/agents/solver-agent.ts +++ b/packages/agent/src/agents/solver-agent.ts @@ -2,14 +2,12 @@ * Produces a solution trace; never the final judge (D-1). */ import type { LanguageModel } from "ai"; +import { generateObject } from "ai"; -import type { GeneratedProblem } from "../schemas/index.js"; +import { SolveAttemptSchema, type GeneratedProblem, type SolveAttempt } from "../schemas/index.js"; +import type { PromptLoader } from "../tools/prompt-loader.js"; -export interface SolveAttempt { - derived_answer: string; - trace: string; - confidence: "high" | "medium" | "low"; -} +export type { SolveAttempt } from "../schemas/index.js"; export interface SolverAgent { solve(candidate: GeneratedProblem): Promise; @@ -17,9 +15,24 @@ export interface SolverAgent { export interface SolverAgentDeps { model: LanguageModel; + modelId: string; promptId: string; + prompts: PromptLoader; } -export function createSolverAgent(_deps: SolverAgentDeps): SolverAgent { - throw new Error("createSolverAgent: not implemented yet"); +export function createSolverAgent(deps: SolverAgentDeps): SolverAgent { + return { + async solve(candidate) { + const prompt = await deps.prompts.load(deps.promptId); + const rendered = prompt.render({ candidate }); + const { object } = await generateObject({ + model: deps.model, + schema: SolveAttemptSchema, + mode: "json", + temperature: prompt.metadata.temperature, + prompt: rendered, + }); + return object; + }, + }; } diff --git a/packages/agent/src/config/env.ts b/packages/agent/src/config/env.ts index 7fb6fb2..c962e1f 100644 --- a/packages/agent/src/config/env.ts +++ b/packages/agent/src/config/env.ts @@ -9,10 +9,16 @@ export const EnvSchema = z.object({ MATH_ENGINE_URL: z.string().url().default("http://localhost:8000"), LLM_PROVIDER: z - .enum(["openai", "openai-compatible", "anthropic-via-compatible"]) + .enum(["openai", "openai-compatible", "anthropic-via-compatible", "cliproxy"]) .default("openai-compatible"), LLM_BASE_URL: z.string().url().optional(), LLM_API_KEY: z.string().min(1).optional(), + LLM_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(), + CLIPROXY_API_KEY: z.string().min(1).optional(), + CLIPROXY_MODEL: z.string().min(1).optional(), PROMPTS_DIR: z.string().default("./prompts"), STRATEGIES_DIR: z.string().default("./data/achievement-standards"), diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0c9664f..a55dbc8 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,14 +1,111 @@ /** Service entrypoint. Wiring of deps -> createApp is the only un-implemented part of scaffolding. */ +import { serve } from "@hono/node-server"; +import { resolve } from "node:path"; + import { loadEnv } from "./config/index.js"; +import { DEFAULT_MODELS } from "./config/models.js"; +import { createApp } from "./server/app.js"; +import { + createConstraintCriticAgent, + createGeneratorAgent, + createRefinerAgent, + createSolverAgent, +} from "./agents/index.js"; +import { + createFsPromptLoader, + createFsStrategyLoader, + createInMemoryRagClient, + createMathEngineClient, + resolveLanguageModel, +} from "./tools/index.js"; export async function main(): Promise { const env = loadEnv(); + const mathEngine = createMathEngineClient({ + baseUrl: env.MATH_ENGINE_URL, + timeoutMs: env.PER_STEP_TIMEOUT_MS, + retry: { attempts: 2, backoffMs: 100 }, + }); + const prompts = createFsPromptLoader({ promptsDir: resolve(env.PROMPTS_DIR) }); + const strategies = createFsStrategyLoader({ + strategiesDir: resolve(env.STRATEGIES_DIR), + }); + const rag = createInMemoryRagClient({ + jsonlPath: resolve(env.CORPUS_JSONL ?? "./data/corpus/math-sample-unified-v1.jsonl"), + }); + await rag.warmup?.(); + + const llmKind = env.LLM_PROVIDER === "cliproxy" ? "openai-compatible" : env.LLM_PROVIDER; + const llmBaseUrl = env.LLM_BASE_URL ?? env.CLIPROXY_BASE_URL; + const llmApiKey = env.LLM_API_KEY ?? env.CLIPROXY_API_KEY ?? env.OPENAI_API_KEY; + const llmModel = env.LLM_MODEL ?? env.CLIPROXY_MODEL ?? env.OPENAI_MODEL ?? DEFAULT_MODELS.generator; + const llm = llmBaseUrl !== undefined || llmApiKey !== undefined + ? resolveLanguageModel({ + kind: llmKind, + modelId: llmModel, + baseUrl: llmBaseUrl, + apiKey: llmApiKey ?? "openmath-local", + allowedHosts: ["localhost", "127.0.0.1"], + }) + : undefined; + const generator = llm === undefined + ? undefined + : createGeneratorAgent({ + model: llm, + modelId: llmModel, + promptId: "problem-generator", + prompts, + }); + const critic = llm === undefined + ? undefined + : createConstraintCriticAgent({ + model: llm, + modelId: llmModel, + promptId: "constraint-critic", + prompts, + }); + const refiner = llm === undefined || generator === undefined + ? undefined + : createRefinerAgent({ + model: llm, + modelId: llmModel, + promptId: "refiner", + generator, + }); + const solver = llm === undefined + ? undefined + : createSolverAgent({ + model: llm, + modelId: llmModel, + promptId: "independent-solver", + prompts, + }); + + const app = createApp({ + mathEngine, + workflow: { + rag, + mathEngine, + prompts, + strategies, + intentModel: llm, + generator, + critic, + refiner, + solver, + objectiveLlm: llm, + }, + workflowOptions: { + maxRetries: env.MAX_RETRIES, + perStepTimeoutMs: env.PER_STEP_TIMEOUT_MS, + }, + }); + + serve({ fetch: app.fetch, port: env.PORT }); console.log( - `[openmath/agent] Boot pending. Port ${env.PORT}, math-engine ${env.MATH_ENGINE_URL}. ` + - `See docs/specs/architecture.md D-3~D-8 + src/{tools,agents,steps,workflows}/*.`, + `[openmath/agent] Listening on http://localhost:${env.PORT} (math-engine ${env.MATH_ENGINE_URL})`, ); - throw new Error("main: dependency wiring not implemented yet."); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/packages/agent/src/policies/acceptance-policy.ts b/packages/agent/src/policies/acceptance-policy.ts index 34f3180..f8141b4 100644 --- a/packages/agent/src/policies/acceptance-policy.ts +++ b/packages/agent/src/policies/acceptance-policy.ts @@ -7,5 +7,21 @@ export interface AcceptancePolicy { } export function createAcceptancePolicy(): AcceptancePolicy { - throw new Error("createAcceptancePolicy: not implemented yet"); + return { + decide(gates, attemptCount) { + if (attemptCount > 3) return "rejected"; + + const byStep = new Map(gates.map((gate) => [gate.step, gate])); + const sympy = byStep.get("sympy_verify"); + const objective = byStep.get("objective_map"); + const reSolve = byStep.get("re_solve"); + + if (sympy?.status !== "passed") return "rejected"; + if (objective?.status !== "passed") return "rejected"; + if (reSolve?.status === "failed") return "warning"; + if (gates.some((gate) => gate.status === "failed")) return "rejected"; + + return "verified"; + }, + }; } diff --git a/packages/agent/src/policies/retry-policy.ts b/packages/agent/src/policies/retry-policy.ts index 3f8b5b6..6a57577 100644 --- a/packages/agent/src/policies/retry-policy.ts +++ b/packages/agent/src/policies/retry-policy.ts @@ -17,7 +17,29 @@ export interface BoundedRetryPolicyOptions { } export function createBoundedRetryPolicy( - _opts: BoundedRetryPolicyOptions, + opts: BoundedRetryPolicyOptions, ): RetryPolicy { - throw new Error("createBoundedRetryPolicy: not implemented yet"); + if (!Number.isInteger(opts.maxAttempts) || opts.maxAttempts < 1) { + throw new Error(`maxAttempts must be a positive integer (got ${opts.maxAttempts})`); + } + return { + decide(verification) { + const nextAttempt = verification.attempt_count + 1; + const shouldRetry = + verification.overall !== "verified" && nextAttempt <= opts.maxAttempts; + return { + shouldRetry, + nextAttempt, + refinementHint: firstFailureMessage(verification), + }; + }, + }; +} + +function firstFailureMessage(verification: Verification): string | undefined { + if (verification.failure_reason !== undefined) { + return verification.failure_reason.user_message_ko; + } + const failed = verification.gates.find((gate) => gate.status === "failed"); + return failed?.failure_detail?.message; } diff --git a/packages/agent/src/policies/timeout-policy.ts b/packages/agent/src/policies/timeout-policy.ts index 0cc6c7a..1b8e6c7 100644 --- a/packages/agent/src/policies/timeout-policy.ts +++ b/packages/agent/src/policies/timeout-policy.ts @@ -6,8 +6,23 @@ export interface TimeoutOptions { } export async function withTimeout( - _fn: () => Promise, - _opts: TimeoutOptions, + fn: () => Promise, + opts: TimeoutOptions, ): Promise { - throw new Error("withTimeout: not implemented yet"); + if (!Number.isInteger(opts.ms) || opts.ms <= 0) { + throw new Error(`timeout ms must be a positive integer (got ${opts.ms})`); + } + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${opts.label} timed out after ${opts.ms}ms`)); + }, opts.ms); + }), + ]); + } finally { + if (timer !== null) clearTimeout(timer); + } } diff --git a/packages/agent/src/schemas/critique.schema.ts b/packages/agent/src/schemas/critique.schema.ts new file mode 100644 index 0000000..8ea50f0 --- /dev/null +++ b/packages/agent/src/schemas/critique.schema.ts @@ -0,0 +1,10 @@ +/** Critique — D-5 GenerationSpecialist non-mathematical constraint review. */ + +import { z } from "zod"; + +export const CritiqueSchema = z.object({ + passes: z.boolean(), + hints: z.array(z.string().min(1)), +}); + +export type Critique = z.infer; diff --git a/packages/agent/src/schemas/generate-request.schema.ts b/packages/agent/src/schemas/generate-request.schema.ts index 7c4d636..554aa38 100644 --- a/packages/agent/src/schemas/generate-request.schema.ts +++ b/packages/agent/src/schemas/generate-request.schema.ts @@ -25,14 +25,32 @@ export const GenerateRequestSchema = z.object({ school_level: SchoolLevelSchema.default("middle"), grade: z.union([z.literal(1), z.literal(2), z.literal(3)]), + + /** FE alias from `packages/web/hooks/use-verification-stream.ts`. */ + topic: z.string().optional(), topic_code: z.string().optional(), topic_name: z.string().optional(), + /** FE-selected evaluation dimensions. Step 6 treats these as requested keeps. */ + dims: z.array(z.string()).default([]), + count: z.number().int().min(1).max(20).default(5), difficulty: DifficultySchema.default("medium"), problem_type: ProblemTypeSchema.default("objective"), source_problem_text: z.string().optional(), +}).superRefine((request, ctx) => { + if (request.topic === undefined && request.topic_code === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Either topic or topic_code is required", + path: ["topic"], + }); + } }); export type GenerateRequest = z.infer; + +export function getGenerateRequestTopicCode(request: GenerateRequest): string { + return request.topic_code ?? request.topic ?? ""; +} diff --git a/packages/agent/src/schemas/index.ts b/packages/agent/src/schemas/index.ts index 9a4c503..16ca694 100644 --- a/packages/agent/src/schemas/index.ts +++ b/packages/agent/src/schemas/index.ts @@ -7,8 +7,12 @@ export * from "./generate-request.schema.js"; export * from "./generated-problem.schema.js"; export * from "./intent.schema.js"; +export * from "./critique.schema.js"; +export * from "./objective-mapping-nuance.schema.js"; export * from "./progress-event.schema.js"; export * from "./rag.schema.js"; +export * from "./solve-attempt.schema.js"; export * from "./source-problem.schema.js"; export * from "./strategy.schema.js"; export * from "./verification.schema.js"; +export * from "./wire-format.schema.js"; diff --git a/packages/agent/src/schemas/objective-mapping-nuance.schema.ts b/packages/agent/src/schemas/objective-mapping-nuance.schema.ts new file mode 100644 index 0000000..47639fb --- /dev/null +++ b/packages/agent/src/schemas/objective-mapping-nuance.schema.ts @@ -0,0 +1,12 @@ +/** ObjectiveMappingNuance — optional LLM nuance for Step 6; never decides pass/fail (D-1). */ + +import { z } from "zod"; + +export const ObjectiveMappingNuanceSchema = z.object({ + preserved_dimensions: z.array(z.string().min(1)), + lost_dimensions: z.array(z.string().min(1)), + drifted_dimensions: z.array(z.string().min(1)), + rationale: z.string().min(1), +}); + +export type ObjectiveMappingNuance = z.infer; diff --git a/packages/agent/src/schemas/rag.schema.ts b/packages/agent/src/schemas/rag.schema.ts index 43949ff..e58a3f9 100644 --- a/packages/agent/src/schemas/rag.schema.ts +++ b/packages/agent/src/schemas/rag.schema.ts @@ -34,6 +34,7 @@ export const RagQuerySchema = z.object({ topic_name: z.string().optional(), difficulty: DifficultySchema.optional(), problem_type: ProblemTypeSchema.optional(), + source_problem_text: z.string().min(1).optional(), intent: IntentSchema.optional(), diff --git a/packages/agent/src/schemas/solve-attempt.schema.ts b/packages/agent/src/schemas/solve-attempt.schema.ts new file mode 100644 index 0000000..72fb796 --- /dev/null +++ b/packages/agent/src/schemas/solve-attempt.schema.ts @@ -0,0 +1,11 @@ +/** SolveAttempt — D-5 independent re-solver output; advisory trace, never final judge (D-1). */ + +import { z } from "zod"; + +export const SolveAttemptSchema = z.object({ + derived_answer: z.string().min(1), + trace: z.string().min(1), + confidence: z.enum(["high", "medium", "low"]), +}); + +export type SolveAttempt = z.infer; diff --git a/packages/agent/src/schemas/wire-format.schema.ts b/packages/agent/src/schemas/wire-format.schema.ts new file mode 100644 index 0000000..6eba78f --- /dev/null +++ b/packages/agent/src/schemas/wire-format.schema.ts @@ -0,0 +1,71 @@ +/** + * FE wire format for `packages/web/hooks/use-verification-stream.ts`. + * + * The agent keeps richer domain events internally (`ProgressEvent`). This schema + * defines the compact SSE payloads consumed by the current Next.js frontend. + */ + +import { z } from "zod"; + +export const WireStepIndexSchema = z.union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), +]); +export type WireStepIndex = z.infer; + +export const WireStepStatusSchema = z.enum([ + "started", + "completed", + "failed", +]); +export type WireStepStatus = z.infer; + +export const WireStepEventSchema = z.object({ + index: WireStepIndexSchema, + name: z.string().min(1), + status: WireStepStatusSchema, + summary: z.string().nullable().optional(), +}); +export type WireStepEvent = z.infer; + +export const WirePreviewEventSchema = z.object({ + latex: z.string().min(1), +}); +export type WirePreviewEvent = z.infer; + +export const WireVerificationStatusSchema = z.enum(["pass", "partial", "fail"]); +export type WireVerificationStatus = z.infer< + typeof WireVerificationStatusSchema +>; + +export const WireResultProblemSchema = z.object({ + id: z.string().min(1), + question_latex: z.string().min(1), + answer_latex: z.string().min(1), + explanation_latex: z.string().optional(), + isomorphism: z.enum(["structural", "conceptual"]), + preserved_dimensions: z.array(z.string()), + verification_status: WireVerificationStatusSchema, +}); +export type WireResultProblem = z.infer; + +export const WireResultEventSchema = z.array(WireResultProblemSchema); +export type WireResultEvent = z.infer; + +export const WireErrorEventSchema = z.object({ + stage: z.string().min(1), + message: z.string().min(1), +}); +export type WireErrorEvent = z.infer; + +export type WireEventName = "step" | "preview" | "result" | "error"; + +export type WireSseEvent = + | { event: "step"; data: WireStepEvent } + | { event: "preview"; data: WirePreviewEvent } + | { event: "result"; data: WireResultEvent } + | { event: "error"; data: WireErrorEvent }; diff --git a/packages/agent/src/server/app.ts b/packages/agent/src/server/app.ts index cb80741..9cd963b 100644 --- a/packages/agent/src/server/app.ts +++ b/packages/agent/src/server/app.ts @@ -1,22 +1,33 @@ /** Hono app composition. Wires routes + middlewares. Boots from src/index.ts. */ import { Hono } from "hono"; +import { cors } from "hono/cors"; import type { MathEngineClient } from "../tools/math-engine-client.js"; -import type { VerificationWorkflowDeps } from "../workflows/verification-workflow.js"; +import type { RunOptions, VerificationWorkflowDeps } from "../workflows/verification-workflow.js"; import { createGenerateRoute } from "./routes/generate.js"; import { createHealthRoute } from "./routes/health.js"; export interface AppDeps { mathEngine: MathEngineClient; workflow: VerificationWorkflowDeps; + workflowOptions?: RunOptions; } export function createApp(deps: AppDeps): Hono { const app = new Hono(); + app.use( + "*", + cors({ + origin: ["http://localhost:3001", "http://127.0.0.1:3001"], + allowHeaders: ["Content-Type", "Accept"], + allowMethods: ["GET", "POST", "OPTIONS"], + }), + ); + app.route("/", createHealthRoute(deps.mathEngine)); - app.route("/", createGenerateRoute(deps.workflow)); + app.route("/", createGenerateRoute(deps.workflow, deps.workflowOptions)); return app; } diff --git a/packages/agent/src/server/routes/generate.ts b/packages/agent/src/server/routes/generate.ts index eed9989..539af09 100644 --- a/packages/agent/src/server/routes/generate.ts +++ b/packages/agent/src/server/routes/generate.ts @@ -3,18 +3,24 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; import { GenerateRequestSchema } from "../../schemas/index.js"; -import type { VerificationWorkflowDeps } from "../../workflows/verification-workflow.js"; +import { pipeProgressToSse } from "../sse/progress-stream.js"; +import type { RunOptions, VerificationWorkflowDeps } from "../../workflows/verification-workflow.js"; +import { runVerificationWorkflow } from "../../workflows/verification-workflow.js"; -export function createGenerateRoute(_deps: VerificationWorkflowDeps): Hono { +export function createGenerateRoute(deps: VerificationWorkflowDeps, options?: RunOptions): Hono { const app = new Hono(); app.post( "/api/generate", zValidator("json", GenerateRequestSchema), - () => { - throw new Error("POST /api/generate: not implemented yet"); + (c) => { + const request = c.req.valid("json"); + return streamSSE(c, async (stream) => { + await pipeProgressToSse(stream, runVerificationWorkflow(deps, request, options)); + }); }, ); diff --git a/packages/agent/src/server/sse/progress-stream.ts b/packages/agent/src/server/sse/progress-stream.ts index 2c4b0f2..fb97c09 100644 --- a/packages/agent/src/server/sse/progress-stream.ts +++ b/packages/agent/src/server/sse/progress-stream.ts @@ -3,10 +3,17 @@ import type { SSEStreamingApi } from "hono/streaming"; import type { ProgressEvent } from "../../schemas/index.js"; +import { toWireSseEvent } from "./wire-adapter.js"; export async function pipeProgressToSse( - _stream: SSEStreamingApi, - _events: AsyncGenerator, + stream: SSEStreamingApi, + events: AsyncGenerator, ): Promise { - throw new Error("pipeProgressToSse: not implemented yet"); + for await (const event of events) { + const wire = toWireSseEvent(event); + await stream.writeSSE({ + event: wire.event, + data: JSON.stringify(wire.data), + }); + } } diff --git a/packages/agent/src/server/sse/wire-adapter.ts b/packages/agent/src/server/sse/wire-adapter.ts new file mode 100644 index 0000000..df74a18 --- /dev/null +++ b/packages/agent/src/server/sse/wire-adapter.ts @@ -0,0 +1,127 @@ +/** + * Domain ProgressEvent -> frontend SSE wire payload adapter. + * + * Keeps the internal BE domain model stable while matching the existing FE hook + * contract in `packages/web/hooks/use-verification-stream.ts`. + */ + +import type { + GeneratedProblem, + ProgressEvent, + ResultEvent, + StepEvent, + StepName, + Verification, + WireErrorEvent, + WireResultEvent, + WireResultProblem, + WireSseEvent, + WireStepEvent, + WireStepIndex, + WireStepStatus, +} from "../../schemas/index.js"; + +const STEP_META: Record = { + rag: { index: 1, name: "RAG 검색" }, + intent: { index: 2, name: "의도 추출" }, + generate: { index: 3, name: "문제 생성" }, + sympy_verify: { index: 4, name: "산술 검증 (SymPy)" }, + re_solve: { index: 5, name: "독립 재풀이" }, + objective_map: { index: 6, name: "학습 목표 매핑" }, +}; + +function mapStepStatus(event: StepEvent): WireStepStatus { + if (event.status === "start") return "started"; + if (event.status === "info") return "failed"; + const gateStatus = readGateStatus(event.data); + if (gateStatus === "failed") return "failed"; + return "completed"; +} + +export function toWireStepEvent(event: StepEvent): WireStepEvent { + const meta = STEP_META[event.step]; + return { + index: meta.index, + name: meta.name, + status: mapStepStatus(event), + summary: null, + }; +} + +function readGateStatus(data: unknown): "passed" | "failed" | "skipped" | null { + if (typeof data !== "object" || data === null || !("gate" in data)) return null; + const gate = data.gate; + if (typeof gate !== "object" || gate === null || !("status" in gate)) return null; + const status = gate.status; + if (status === "passed" || status === "failed" || status === "skipped") return status; + return null; +} + +function mapVerificationStatus( + overall: Verification["overall"], +): WireResultProblem["verification_status"] { + if (overall === "verified") return "pass"; + if (overall === "warning") return "partial"; + return "fail"; +} + +function preservedDimensions(problem: GeneratedProblem): string[] { + return problem.inferred_intent.evaluation_dimensions + .filter((dimension) => dimension.must_preserve) + .map((dimension) => dimension.description); +} + +export function toWireResultProblem( + problem: GeneratedProblem, + verification: Verification, +): WireResultProblem { + return { + id: problem.candidate_id, + question_latex: problem.question_text, + answer_latex: problem.expected_answer, + isomorphism: problem.mode, + preserved_dimensions: preservedDimensions(problem), + verification_status: mapVerificationStatus(verification.overall), + }; +} + +export function toWireResultEvent(event: ResultEvent): WireResultEvent { + return event.candidates.map(({ problem, verification }) => + toWireResultProblem(problem, verification), + ); +} + +function toWireErrorEvent(event: Extract): WireErrorEvent { + return { + stage: event.stage, + message: publicErrorMessage(event), + }; +} + +function retryToWireStep(event: Extract): WireStepEvent { + return { + ...STEP_META.generate, + status: "started", + summary: `재시도 ${event.attempt}`, + }; +} + +function publicErrorMessage(event: Extract): string { + if (event.recoverable) { + return "검증 중 복구 가능한 오류가 발생했습니다. 다시 시도합니다."; + } + return "검증 파이프라인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."; +} + +export function toWireSseEvent(event: ProgressEvent): WireSseEvent { + switch (event.type) { + case "step": + return { event: "step", data: toWireStepEvent(event) }; + case "retry": + return { event: "step", data: retryToWireStep(event) }; + case "result": + return { event: "result", data: toWireResultEvent(event) }; + case "error": + return { event: "error", data: toWireErrorEvent(event) }; + } +} diff --git a/packages/agent/src/steps/independent-resolve.ts b/packages/agent/src/steps/independent-resolve.ts index a7ff779..594dd84 100644 --- a/packages/agent/src/steps/independent-resolve.ts +++ b/packages/agent/src/steps/independent-resolve.ts @@ -2,12 +2,14 @@ * (D-1: LLM never judges, only produces a second opinion). */ import type { SolverAgent } from "../agents/index.js"; -import type { GateResult, GeneratedProblem } from "../schemas/index.js"; +import type { GateResult, GeneratedProblem, SolveAttempt } from "../schemas/index.js"; import type { MathEngineClient } from "../tools/math-engine-client.js"; +import { withTimeout } from "../policies/timeout-policy.js"; export interface IndependentResolveDeps { solver: SolverAgent; mathEngine: MathEngineClient; + perStepTimeoutMs?: number; } export interface IndependentResolveInput { @@ -16,12 +18,89 @@ export interface IndependentResolveInput { } export interface IndependentResolveOutput { + data: SolveAttempt; gate: GateResult; } export async function independentResolve( - _deps: IndependentResolveDeps, - _input: IndependentResolveInput, + deps: IndependentResolveDeps, + input: IndependentResolveInput, ): Promise { - throw new Error("independentResolve: not implemented yet"); + const started = Date.now(); + try { + const attempt = await withTimeout( + () => deps.solver.solve(input.candidate), + { ms: deps.perStepTimeoutMs ?? 30_000, label: "re_solve" }, + ); + const matches = await sameAnswer(deps.mathEngine, input.candidate.expected_answer, attempt.derived_answer); + return { + data: attempt, + gate: { + step: "re_solve", + status: matches ? "passed" : "failed", + duration_ms: Date.now() - started, + evidence: { + confidence: attempt.confidence, + derived_answer: attempt.derived_answer, + expected_answer: input.candidate.expected_answer, + sympy_status: input.sympyGate.status, + }, + failure_detail: matches + ? undefined + : { + code: "independent_resolve_mismatch", + message: "Independent solver answer differs from the expected SymPy-normalized answer", + }, + }, + }; + } catch (err) { + return { + data: { + derived_answer: "", + trace: "Independent resolve failed before producing a trace.", + confidence: "low", + }, + gate: { + step: "re_solve", + status: "failed", + duration_ms: Date.now() - started, + failure_detail: { + code: "independent_resolve_error", + message: err instanceof Error ? err.message : String(err), + }, + }, + }; + } +} + +async function sameAnswer( + mathEngine: MathEngineClient, + expectedAnswer: string, + derivedAnswer: string, +): Promise { + const expected = parseAnswers(expectedAnswer); + const derived = parseAnswers(derivedAnswer); + if (expected.length === 0 || derived.length === 0) return false; + const expectedCanonical = await canonicalizeAll(mathEngine, expected); + const derivedCanonical = await canonicalizeAll(mathEngine, derived); + if (expectedCanonical.length !== derivedCanonical.length) return false; + return expectedCanonical.every((value, index) => value === derivedCanonical[index]); +} + +function parseAnswers(answer: string): string[] { + return answer + .split(/[,;]|또는|or/) + .map((part) => part.trim()) + .map((part) => part.replace(/^[a-zA-Z]\s*=\s*/, "")) + .filter((part) => part.length > 0); +} + +async function canonicalizeAll(mathEngine: MathEngineClient, answers: string[]): Promise { + const canonical = await Promise.all( + answers.map(async (answer) => { + const result = await mathEngine.simplify({ expr: answer }); + return result.simplified.replace(/\s+/g, ""); + }), + ); + return canonical.sort(); } diff --git a/packages/agent/src/steps/intent-extraction.ts b/packages/agent/src/steps/intent-extraction.ts index 8f964fe..60622de 100644 --- a/packages/agent/src/steps/intent-extraction.ts +++ b/packages/agent/src/steps/intent-extraction.ts @@ -1,18 +1,25 @@ /** Step 2: Intent extraction. Single LLM agent (D-5). Output validated by IntentSchema (I-I1, I-I2). */ import type { LanguageModel } from "ai"; +import { generateObject } from "ai"; -import type { - GenerateRequest, - Intent, - RagResult, - Strategy, +import { + IntentSchema, + assertIntentInvariants, + getGenerateRequestTopicCode, + type GateResult, + type GenerateRequest, + type Intent, + type RagResult, + type Strategy, } from "../schemas/index.js"; +import { withTimeout } from "../policies/timeout-policy.js"; import type { PromptLoader } from "../tools/prompt-loader.js"; export interface IntentExtractionDeps { model: LanguageModel; prompts: PromptLoader; + perStepTimeoutMs?: number; } export interface IntentExtractionInput { @@ -22,12 +29,90 @@ export interface IntentExtractionInput { } export interface IntentExtractionOutput { - intent: Intent; + data: Intent; + gate: GateResult; } export async function extractIntent( - _deps: IntentExtractionDeps, - _input: IntentExtractionInput, + deps: IntentExtractionDeps, + input: IntentExtractionInput, ): Promise { - throw new Error("extractIntent: not implemented yet"); + const started = Date.now(); + try { + const intent = await withTimeout(async () => { + const prompt = await deps.prompts.load("intent-extraction"); + const rendered = prompt.render({ + request: input.request, + refs: input.refs, + strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2), + }); + const { object } = await generateObject({ + model: deps.model, + schema: IntentSchema, + mode: "json", + temperature: prompt.metadata.temperature, + prompt: rendered, + }); + assertIntentInvariants(object); + return object; + }, { ms: deps.perStepTimeoutMs ?? 30_000, label: "intent" }); + + return { + data: intent, + gate: { + step: "intent", + status: "passed", + duration_ms: Date.now() - started, + evidence: { objective_code: intent.objective_code }, + }, + }; + } catch (err) { + const fallback = buildSeedIntent(input.request, input.strategy, input.refs); + assertIntentInvariants(fallback); + return { + data: fallback, + gate: { + step: "intent", + status: "passed", + duration_ms: Date.now() - started, + evidence: { + objective_code: fallback.objective_code, + fallback: true, + llm_error: err instanceof Error ? err.message : String(err), + }, + }, + }; + } +} + +function buildSeedIntent( + request: GenerateRequest, + strategy: Strategy | null, + refs: RagResult[], +): Intent { + const first = refs[0]; + if (first === undefined) { + throw new Error("Cannot extract intent without RAG refs"); + } + const objectiveCode = + strategy?.code ?? first.problem.achievement_standard ?? getGenerateRequestTopicCode(request); + return { + objective_code: objectiveCode, + objective_description: strategy?.title ?? first.problem.topic_name, + evaluation_dimensions: + strategy?.evaluation_dimensions ?? + (request.dims.length > 0 + ? request.dims.map((description, index) => ({ + id: String.fromCharCode(65 + index), + description, + must_preserve: true, + })) + : [{ id: "A", description: first.problem.topic_name, must_preserve: true }]), + required_techniques: strategy?.techniques.required_at_least_one_of ?? [], + forbidden_techniques: strategy?.techniques.forbidden ?? [], + surface_constraints: { + difficulty: request.difficulty, + problem_type: request.problem_type, + }, + }; } diff --git a/packages/agent/src/steps/objective-mapping.ts b/packages/agent/src/steps/objective-mapping.ts index 2eb760c..71452ec 100644 --- a/packages/agent/src/steps/objective-mapping.ts +++ b/packages/agent/src/steps/objective-mapping.ts @@ -2,31 +2,165 @@ * dimensions (D-5). LLM may suggest nuance, never decides (D-1). */ import type { LanguageModel } from "ai"; +import { generateObject } from "ai"; -import type { - GateResult, - GeneratedProblem, - Intent, - Strategy, +import { + ObjectiveMappingNuanceSchema, + getGenerateRequestTopicCode, + strategySupportsConceptual, + type GateResult, + type GenerateRequest, + type GeneratedProblem, + type Intent, + type ObjectiveMappingNuance, + type RagResult, + type Strategy, } from "../schemas/index.js"; +import { withTimeout } from "../policies/timeout-policy.js"; +import type { PromptLoader } from "../tools/prompt-loader.js"; export interface ObjectiveMappingDeps { llm?: LanguageModel; + prompts?: PromptLoader; + perStepTimeoutMs?: number; } export interface ObjectiveMappingInput { + request: GenerateRequest; + refs: RagResult[]; candidate: GeneratedProblem; intent: Intent; strategy: Strategy | null; } export interface ObjectiveMappingOutput { + data: ObjectiveMappingNuance | null; gate: GateResult; } export async function mapObjective( - _deps: ObjectiveMappingDeps, - _input: ObjectiveMappingInput, + deps: ObjectiveMappingDeps, + input: ObjectiveMappingInput, ): Promise { - throw new Error("mapObjective: not implemented yet"); + const started = Date.now(); + const failures = evaluateDeterministically(input); + const nuance = await loadNuance(deps, input); + return { + data: nuance, + gate: { + step: "objective_map", + status: failures.length === 0 ? "passed" : "failed", + duration_ms: Date.now() - started, + evidence: { + refs: input.refs.length, + mode: input.request.mode, + source_problem_text: input.request.source_problem_text ?? null, + strategy_code: input.strategy?.code ?? null, + difficulty: input.request.difficulty, + problem_type: input.request.problem_type, + llm_nuance: nuance, + }, + failure_detail: + failures.length === 0 + ? undefined + : { + code: failures[0]?.code ?? "objective_map_failed", + message: failures.map((failure) => failure.message).join("; "), + }, + }, + }; +} + +function evaluateDeterministically(input: ObjectiveMappingInput): Array<{ code: string; message: string }> { + const failures: Array<{ code: string; message: string }> = []; + const { request, strategy, refs, candidate } = input; + + if (strategy === null) { + failures.push({ code: "objective_unmapped", message: "No strategy available" }); + } else { + if (strategy.code !== getGenerateRequestTopicCode(request)) { + failures.push({ + code: "strategy_topic_mismatch", + message: `Strategy ${strategy.code} does not match request topic ${getGenerateRequestTopicCode(request)}`, + }); + } + if (!strategy.difficulty_range.includes(request.difficulty)) { + failures.push({ + code: "difficulty_unsupported", + message: `Strategy ${strategy.code} does not support ${request.difficulty} difficulty`, + }); + } + if (!strategy.problem_types_supported.includes(request.problem_type)) { + failures.push({ + code: "problem_type_unsupported", + message: `Strategy ${strategy.code} does not support ${request.problem_type} problems`, + }); + } + if (request.mode === "conceptual" && !strategySupportsConceptual(strategy)) { + failures.push({ + code: "conceptual_unsupported", + message: `Strategy ${strategy.code} has no conceptual transforms`, + }); + } + if (request.mode !== "conceptual" && strategy.structural_transforms.length === 0) { + failures.push({ + code: "structural_unsupported", + message: `Strategy ${strategy.code} has no structural transforms`, + }); + } + } + + if (refs.length === 0) { + failures.push({ code: "no_refs", message: "No reference problems available" }); + } + + const sourceText = request.source_problem_text ?? refs[0]?.problem.question_text; + if (sourceText !== undefined && sameMathText(candidate.question_text, sourceText)) { + failures.push({ + code: "not_transformed", + message: "Generated candidate is identical to the source problem", + }); + } + + return failures; +} + +async function loadNuance( + deps: ObjectiveMappingDeps, + input: ObjectiveMappingInput, +): Promise { + const llm = deps.llm; + const prompts = deps.prompts; + if (llm === undefined || prompts === undefined) return null; + try { + return await withTimeout(async () => { + const prompt = await prompts.load("objective-mapper"); + const rendered = prompt.render({ + candidate: input.candidate, + intent: input.intent, + strategy: input.strategy === null ? "" : JSON.stringify(input.strategy, null, 2), + }); + const { object } = await generateObject({ + model: llm, + schema: ObjectiveMappingNuanceSchema, + mode: "json", + temperature: prompt.metadata.temperature, + prompt: rendered, + }); + return object; + }, { ms: deps.perStepTimeoutMs ?? 30_000, label: "objective_map_llm" }); + } catch { + return null; + } +} + +function sameMathText(left: string, right: string): boolean { + return normalizeMathText(left) === normalizeMathText(right); +} + +function normalizeMathText(value: string): string { + return value + .replace(/²/g, "**2") + .replace(/\s+/g, "") + .toLowerCase(); } diff --git a/packages/agent/src/steps/problem-generation.ts b/packages/agent/src/steps/problem-generation.ts index ee3c281..e336e4c 100644 --- a/packages/agent/src/steps/problem-generation.ts +++ b/packages/agent/src/steps/problem-generation.ts @@ -7,17 +7,23 @@ import type { RefinerAgent, } from "../agents/index.js"; import type { + GateResult, GenerateRequest, GeneratedProblem, Intent, RagResult, Strategy, } from "../schemas/index.js"; +import type { MathEngineClient } from "../tools/math-engine-client.js"; +import { formatLatex } from "../tools/latex-formatter.js"; +import { withTimeout } from "../policies/timeout-policy.js"; export interface ProblemGenerationDeps { generator: GeneratorAgent; critic: ConstraintCriticAgent; refiner: RefinerAgent; + mathEngine: MathEngineClient; + perStepTimeoutMs?: number; maxCriticRounds?: number; } @@ -27,16 +33,111 @@ export interface ProblemGenerationInput { refs: RagResult[]; strategy: Strategy | null; attempt: number; + refinementHint?: string; } export interface ProblemGenerationOutput { - candidate: GeneratedProblem; + data: GeneratedProblem; + gate: GateResult; refined_by: string[]; } export async function generateProblem( - _deps: ProblemGenerationDeps, - _input: ProblemGenerationInput, + deps: ProblemGenerationDeps, + input: ProblemGenerationInput, ): Promise { - throw new Error("generateProblem: not implemented yet"); + const started = Date.now(); + const refinedBy: string[] = []; + try { + const candidate = await withTimeout(async () => { + let current = await deps.generator.generate({ + request: input.request, + intent: input.intent, + refs: input.refs, + strategy: input.strategy, + attempt: input.attempt, + refinementHint: input.refinementHint, + }); + current = await normalizeExpectedAnswer(deps.mathEngine, current); + + const rounds = deps.maxCriticRounds ?? 2; + for (let round = 0; round < rounds; round += 1) { + const critique = await deps.critic.critique({ + candidate: current, + intent: input.intent, + strategy: input.strategy, + }); + refinedBy.push("constraint-critic"); + if (critique.passes || critique.hints.length === 0) return current; + current = await deps.refiner.refine({ + prior: current, + request: input.request, + intent: input.intent, + refs: input.refs, + strategy: input.strategy, + attempt: input.attempt, + hints: critique.hints, + }); + current = await normalizeExpectedAnswer(deps.mathEngine, current); + refinedBy.push("refiner"); + } + return current; + }, { ms: deps.perStepTimeoutMs ?? 30_000, label: "generate" }); + + return { + data: { + ...formatCandidateLatex(candidate), + generation_metadata: { + ...candidate.generation_metadata, + refined_by: refinedBy, + }, + }, + gate: { + step: "generate", + status: "passed", + duration_ms: Date.now() - started, + evidence: { + candidate_id: candidate.candidate_id, + model: candidate.generation_metadata.model, + refined_by: refinedBy, + }, + }, + refined_by: refinedBy, + }; + } catch (err) { + return { + data: await Promise.reject(err), + gate: { + step: "generate", + status: "failed", + duration_ms: Date.now() - started, + failure_detail: { + code: "generation_failed", + message: err instanceof Error ? err.message : String(err), + }, + }, + refined_by: refinedBy, + }; + } +} + +function formatCandidateLatex(candidate: GeneratedProblem): GeneratedProblem { + return { + ...candidate, + question_text: formatLatex(candidate.question_text), + expected_answer: formatLatex(candidate.expected_answer), + }; +} + +async function normalizeExpectedAnswer( + mathEngine: MathEngineClient, + candidate: GeneratedProblem, +): Promise { + if (!candidate.question_text.includes("=")) return candidate; + const solved = await mathEngine.solve({ equation: candidate.question_text }); + if (solved.solutions.length === 0) return candidate; + return { + ...candidate, + expected_answer: solved.solutions.join(", "), + }; } diff --git a/packages/agent/src/steps/rag-search.ts b/packages/agent/src/steps/rag-search.ts index a09ef25..6c291d6 100644 --- a/packages/agent/src/steps/rag-search.ts +++ b/packages/agent/src/steps/rag-search.ts @@ -1,10 +1,16 @@ /** Step 1: RAG retrieval. Deterministic (D-5). Uses RagClient (D-7). */ -import type { GenerateRequest, RagResult } from "../schemas/index.js"; +import { + getGenerateRequestTopicCode, + type GenerateRequest, + type RagResult, +} from "../schemas/index.js"; +import { withTimeout } from "../policies/timeout-policy.js"; import type { RagClient } from "../tools/rag-client.js"; export interface RagSearchDeps { rag: RagClient; + perStepTimeoutMs?: number; } export interface RagSearchInput { @@ -16,8 +22,26 @@ export interface RagSearchOutput { } export async function ragSearch( - _deps: RagSearchDeps, - _input: RagSearchInput, + deps: RagSearchDeps, + input: RagSearchInput, ): Promise { - throw new Error("ragSearch: not implemented yet"); + return withTimeout(async () => { + const request = input.request; + const baseQuery = { + school_level: request.school_level, + grade: request.grade, + topic_code: getGenerateRequestTopicCode(request), + topic_name: request.topic_name, + source_problem_text: request.source_problem_text, + k: Math.max(request.count, 8), + }; + const refs = await deps.rag.search({ + ...baseQuery, + difficulty: request.difficulty, + problem_type: request.problem_type, + }); + if (refs.length > 0) return { refs }; + + return { refs: await deps.rag.search(baseQuery) }; + }, { ms: deps.perStepTimeoutMs ?? 30_000, label: "rag" }); } diff --git a/packages/agent/src/steps/sympy-verification.ts b/packages/agent/src/steps/sympy-verification.ts index e7e2248..91d7b76 100644 --- a/packages/agent/src/steps/sympy-verification.ts +++ b/packages/agent/src/steps/sympy-verification.ts @@ -1,10 +1,12 @@ /** Step 4: SymPy arithmetic verification. Deterministic (D-1, D-5). math-engine HTTP call. */ import type { GateResult, GeneratedProblem } from "../schemas/index.js"; +import { withTimeout } from "../policies/timeout-policy.js"; import type { MathEngineClient } from "../tools/math-engine-client.js"; export interface SympyVerificationDeps { mathEngine: MathEngineClient; + perStepTimeoutMs?: number; } export interface SympyVerificationInput { @@ -16,8 +18,91 @@ export interface SympyVerificationOutput { } export async function verifyWithSympy( - _deps: SympyVerificationDeps, - _input: SympyVerificationInput, + deps: SympyVerificationDeps, + input: SympyVerificationInput, ): Promise { - throw new Error("verifyWithSympy: not implemented yet"); + const started = Date.now(); + try { + const passed = await withTimeout( + () => verifyCandidate(deps.mathEngine, input.candidate), + { ms: deps.perStepTimeoutMs ?? 30_000, label: "sympy_verify" }, + ); + return { + gate: { + step: "sympy_verify", + status: passed ? "passed" : "failed", + duration_ms: Date.now() - started, + evidence: { engine: "sympy" }, + failure_detail: passed + ? undefined + : { + code: "sympy_solution_mismatch", + message: "SymPy solution did not match the expected answer", + }, + }, + }; + } catch (err) { + return { + gate: { + step: "sympy_verify", + status: "failed", + duration_ms: Date.now() - started, + evidence: { engine: "sympy" }, + failure_detail: { + code: "sympy_error", + message: err instanceof Error ? err.message : String(err), + }, + }, + }; + } +} + +async function verifyCandidate( + mathEngine: MathEngineClient, + candidate: GeneratedProblem, +): Promise { + if (!candidate.question_text.includes("=")) { + throw new Error("SymPy verification supports equation candidates only"); + } + + const solved = await mathEngine.solve({ equation: candidate.question_text }); + if (solved.solutions.length === 0) { + throw new Error("SymPy returned no solutions"); + } + + const expected = parseExpectedSolutions(candidate.expected_answer); + if (expected.length === 0) { + throw new Error("Expected answer contains no parseable solutions"); + } + + const actualCanonical = await canonicalizeAll(mathEngine, solved.solutions); + const expectedCanonical = await canonicalizeAll(mathEngine, expected); + return sameSet(actualCanonical, expectedCanonical); +} + +function parseExpectedSolutions(answer: string): string[] { + return answer + .split(/[,;]|또는|or/) + .map((part) => part.trim()) + .map((part) => part.replace(/^[a-zA-Z]\s*=\s*/, "")) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +async function canonicalizeAll( + mathEngine: MathEngineClient, + expressions: string[], +): Promise { + const canonical = await Promise.all( + expressions.map(async (expr) => { + const result = await mathEngine.simplify({ expr }); + return result.simplified.replace(/\s+/g, ""); + }), + ); + return canonical.sort(); +} + +function sameSet(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); } diff --git a/packages/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts index 16e96f9..da7a8d0 100644 --- a/packages/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -1,4 +1,5 @@ export * from "./llm-provider.js"; +export * from "./latex-formatter.js"; export * from "./math-engine-client.js"; export * from "./prompt-loader.js"; export * from "./rag-client.js"; diff --git a/packages/agent/src/tools/latex-formatter.ts b/packages/agent/src/tools/latex-formatter.ts new file mode 100644 index 0000000..68a6394 --- /dev/null +++ b/packages/agent/src/tools/latex-formatter.ts @@ -0,0 +1,87 @@ +/** Deterministic SymPy-string → KaTeX-safe LaTeX formatter (D-6 presentation boundary). */ + +export function formatLatex(value: string): string { + return formatMultiplication(formatFractions(formatSqrt(formatPowers(value)))); +} + +function formatPowers(value: string): string { + return value.replace(/\*\*([A-Za-z0-9]+|\([^()]+\))/g, (_, exponent: string) => { + const body = exponent.startsWith("(") && exponent.endsWith(")") + ? exponent.slice(1, -1) + : exponent; + return `^{${body}}`; + }); +} + +function formatSqrt(value: string): string { + return replaceFunctionCalls(value, "sqrt", (inner) => `\\sqrt{${formatLatex(inner)}}`); +} + +function formatFractions(value: string): string { + return replaceFunctionCalls(value, "frac", (inner) => { + const parts = splitTopLevelComma(inner); + if (parts.length !== 2) return `frac(${inner})`; + return `\\frac{${formatLatex(parts[0] ?? "")}}{${formatLatex(parts[1] ?? "")}}`; + }); +} + +function formatMultiplication(value: string): string { + return value.replace(/\s*\*\s*/g, " "); +} + +function replaceFunctionCalls( + value: string, + functionName: string, + render: (inner: string) => string, +): string { + const needle = `${functionName}(`; + let output = ""; + let index = 0; + while (index < value.length) { + const start = value.indexOf(needle, index); + if (start === -1) { + output += value.slice(index); + break; + } + output += value.slice(index, start); + const innerStart = start + needle.length; + const end = findMatchingParen(value, innerStart - 1); + if (end === -1) { + output += value.slice(start); + break; + } + output += render(value.slice(innerStart, end)); + index = end + 1; + } + return output; +} + +function findMatchingParen(value: string, openIndex: number): number { + let depth = 0; + for (let index = openIndex; index < value.length; index += 1) { + const char = value[index]; + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +function splitTopLevelComma(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (char === "," && depth === 0) { + parts.push(value.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(value.slice(start).trim()); + return parts; +} diff --git a/packages/agent/src/tools/llm-provider.ts b/packages/agent/src/tools/llm-provider.ts index 9dfee0b..45cc342 100644 --- a/packages/agent/src/tools/llm-provider.ts +++ b/packages/agent/src/tools/llm-provider.ts @@ -6,6 +6,8 @@ * 단일 지점. */ +import { createOpenAI, openai } from "@ai-sdk/openai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { LanguageModel } from "ai"; export type LlmProviderKind = @@ -18,8 +20,55 @@ export interface LlmProviderConfig { modelId: string; baseUrl?: string; apiKey?: string; + allowedHosts?: readonly string[]; } -export function resolveLanguageModel(_config: LlmProviderConfig): LanguageModel { - throw new Error("resolveLanguageModel: not implemented yet"); +export function resolveLanguageModel(config: LlmProviderConfig): LanguageModel { + if (config.kind === "openai") { + if (config.apiKey !== undefined || config.baseUrl !== undefined) { + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: + config.baseUrl === undefined + ? undefined + : validateBaseUrl(config.baseUrl, config.allowedHosts), + compatibility: "strict", + }); + return provider(config.modelId); + } + return openai(config.modelId); + } + + const provider = createOpenAICompatible({ + name: config.kind, + apiKey: config.apiKey, + baseURL: validateBaseUrl(requiredBaseUrl(config), config.allowedHosts), + }); + return provider.chatModel(config.modelId); +} + +function validateBaseUrl(raw: string, allowedHosts?: readonly string[]): string { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`LLM baseUrl must use http or https (got ${url.protocol})`); + } + if (isBlockedHost(url.hostname)) { + throw new Error(`LLM baseUrl host is blocked: ${url.hostname}`); + } + if (allowedHosts !== undefined && !allowedHosts.includes(url.hostname)) { + throw new Error(`LLM baseUrl host is not allowed: ${url.hostname}`); + } + return url.toString(); +} + +function isBlockedHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return host === "169.254.169.254" || host === "0.0.0.0" || host === "metadata.google.internal"; +} + +function requiredBaseUrl(config: LlmProviderConfig): string { + if (config.baseUrl === undefined) { + throw new Error(`${config.kind} requires baseUrl`); + } + return config.baseUrl; } diff --git a/packages/agent/src/tools/math-engine-client.ts b/packages/agent/src/tools/math-engine-client.ts index 1cd950a..e598695 100644 --- a/packages/agent/src/tools/math-engine-client.ts +++ b/packages/agent/src/tools/math-engine-client.ts @@ -65,10 +65,165 @@ export interface MathEngineClientOptions { baseUrl: string; timeoutMs?: number; retry?: { attempts: number; backoffMs: number }; + allowedHosts?: readonly string[]; } export function createMathEngineClient( - _opts: MathEngineClientOptions, + opts: MathEngineClientOptions, ): MathEngineClient { - throw new Error("createMathEngineClient: not implemented yet"); + const baseUrl = validateBaseUrl(opts.baseUrl, opts.allowedHosts).replace(/\/$/, ""); + const timeoutMs = opts.timeoutMs ?? 10_000; + const retry = opts.retry ?? { attempts: 1, backoffMs: 0 }; + + async function request( + path: string, + init: RequestInit, + parse: (value: unknown) => T, + ): Promise { + let lastError: Error | null = null; + for (let attempt = 1; attempt <= retry.attempts; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + signal: controller.signal, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error( + `math-engine ${path} failed (${res.status}): ${body || res.statusText}`, + ); + } + return parse(await res.json()); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < retry.attempts) { + await delay(retry.backoffMs * attempt); + } + } finally { + clearTimeout(timer); + } + } + throw lastError ?? new Error(`math-engine ${path} failed`); + } + + function post( + path: string, + body: unknown, + parse: (value: unknown) => T, + ): Promise { + return request(path, { method: "POST", body: JSON.stringify(body) }, parse); + } + + return { + health: () => request("/health", { method: "GET" }, parseHealthResponse), + solve: (req) => post("/solve", req, parseSolveResponse), + verify: (req) => post("/verify", req, parseVerifyResponse), + simplify: (req) => post("/simplify", req, parseSimplifyResponse), + differentiate: (req) => + post("/differentiate", req, parseDifferentiateResponse), + limit: (req) => post("/limit", req, parseLimitResponse), + }; +} + +function validateBaseUrl(raw: string, allowedHosts?: readonly string[]): string { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`math-engine baseUrl must use http or https (got ${url.protocol})`); + } + if (isBlockedHost(url.hostname)) { + throw new Error(`math-engine baseUrl host is blocked: ${url.hostname}`); + } + if (allowedHosts !== undefined && !allowedHosts.includes(url.hostname)) { + throw new Error(`math-engine baseUrl host is not allowed: ${url.hostname}`); + } + return url.toString(); +} + +function isBlockedHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return host === "169.254.169.254" || host === "0.0.0.0" || host === "metadata.google.internal"; +} + +function delay(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function asObject(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("math-engine response must be an object"); + } + const record: Record = {}; + for (const [key, field] of Object.entries(value)) { + record[key] = field; + } + return record; +} + +function readString(value: Record, key: string): string { + const field = value[key]; + if (typeof field !== "string") { + throw new Error(`math-engine response field ${key} must be a string`); + } + return field; +} + +function readBoolean(value: Record, key: string): boolean { + const field = value[key]; + if (typeof field !== "boolean") { + throw new Error(`math-engine response field ${key} must be a boolean`); + } + return field; +} + +function readStringArray(value: Record, key: string): string[] { + const field = value[key]; + if (!Array.isArray(field) || !field.every((item) => typeof item === "string")) { + throw new Error(`math-engine response field ${key} must be string[]`); + } + return field; +} + +function parseHealthResponse(value: unknown): HealthResponse { + const obj = asObject(value); + const status = readString(obj, "status"); + const engine = readString(obj, "engine"); + if (status !== "ok" || engine !== "sympy") { + throw new Error("math-engine health response has unexpected values"); + } + return { status, engine }; +} + +function parseSolveResponse(value: unknown): SolveResponse { + const obj = asObject(value); + return { solutions: readStringArray(obj, "solutions") }; +} + +function parseVerifyResponse(value: unknown): VerifyResponse { + const obj = asObject(value); + return { + equivalent: readBoolean(obj, "equivalent"), + diff: readString(obj, "diff"), + }; +} + +function parseSimplifyResponse(value: unknown): SimplifyResponse { + const obj = asObject(value); + return { simplified: readString(obj, "simplified") }; +} + +function parseDifferentiateResponse(value: unknown): DifferentiateResponse { + const obj = asObject(value); + return { derivative: readString(obj, "derivative") }; +} + +function parseLimitResponse(value: unknown): LimitResponse { + const obj = asObject(value); + return { limit: readString(obj, "limit") }; } diff --git a/packages/agent/src/tools/prompt-loader.ts b/packages/agent/src/tools/prompt-loader.ts index 097517d..fd15d18 100644 --- a/packages/agent/src/tools/prompt-loader.ts +++ b/packages/agent/src/tools/prompt-loader.ts @@ -8,6 +8,13 @@ * [비할당] 팀원이 매일 만지는 토스 단위. */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import matter from "gray-matter"; +import Handlebars from "handlebars"; +import { z } from "zod"; + export interface PromptMetadata { id: string; version: string; @@ -37,7 +44,79 @@ export interface FsPromptLoaderOptions { } export function createFsPromptLoader( - _opts: FsPromptLoaderOptions, + opts: FsPromptLoaderOptions, ): PromptLoader { - throw new Error("createFsPromptLoader: not implemented yet"); + const cache = new Map(); + + async function load(id: string): Promise { + assertPromptId(id); + if (opts.hotReload !== true) { + const cached = cache.get(id); + if (cached !== undefined) return cached; + } + + const filePath = join(opts.promptsDir, `${id}.md`); + const source = await readFile(filePath, "utf8"); + const parsed = matter(source); + const metadata = parsePromptMetadata(parsed.data, id, filePath); + const template = Handlebars.compile(parsed.content, { noEscape: true }); + + const prompt: LoadedPrompt = { + metadata, + rawBody: parsed.content, + render(vars) { + return template(vars); + }, + }; + cache.set(id, prompt); + return prompt; + } + + return { + load, + async reload() { + cache.clear(); + }, + }; +} + +const PromptMetadataSchema = z.object({ + id: z.string().min(1), + version: z.string().min(1), + model: z.string().min(1), + temperature: z.number().min(0).max(2), + max_tokens: z.number().int().positive().optional(), + schema: z.string().min(1).optional(), + variables: z.array(z.string().min(1)), + owner: z.string().min(1), + updated: z.union([z.string(), z.date()]).transform((value) => + value instanceof Date ? value.toISOString().slice(0, 10) : value, + ), +}); + +function assertPromptId(id: string): void { + if (!/^[a-z0-9-]+$/.test(id)) { + throw new Error(`Invalid prompt id: ${id}`); + } +} + +function parsePromptMetadata( + value: unknown, + expectedId: string, + filePath: string, +): PromptMetadata { + const parsed = PromptMetadataSchema.safeParse(value); + if (!parsed.success) { + throw new Error( + `Invalid prompt metadata in ${filePath}:\n${parsed.error.issues + .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) + .join("\n")}`, + ); + } + if (parsed.data.id !== expectedId) { + throw new Error( + `Prompt id mismatch in ${filePath}: expected ${expectedId}, got ${parsed.data.id}`, + ); + } + return parsed.data; } diff --git a/packages/agent/src/tools/rag-client.ts b/packages/agent/src/tools/rag-client.ts index d7af46a..d30f5e5 100644 --- a/packages/agent/src/tools/rag-client.ts +++ b/packages/agent/src/tools/rag-client.ts @@ -95,14 +95,23 @@ export function createInMemoryRagClient(opts: InMemoryRagClientOptions): RagClie const rows = index ?? (await loadCorpusOnce()); const scored = rows .filter((row) => matchesQuery(row, query, minAchievementConfidence)) - .map((row) => ({ row, score: scoreProblem(row, query) })) + .map((row) => { + const sourceMatch = sourceProblemMatches(row.problem, query.source_problem_text); + return { row, score: scoreProblem(row, query, sourceMatch), sourceMatch }; + }) .sort((a, b) => b.score - a.score || a.row.problem.item_id.localeCompare(b.row.problem.item_id)); - return scored.slice(0, query.k ?? 8).map(({ row, score }) => ({ + return scored.slice(0, query.k ?? 8).map(({ row, score, sourceMatch }) => ({ item_id: row.problem.item_id, similarity: roundSimilarity(score), problem: row.problem, - match_reason: score >= 0.7 ? "hybrid" : score >= 0.35 ? "semantic" : "structural", + match_reason: sourceMatch + ? "hybrid" + : score >= 0.7 + ? "hybrid" + : score >= 0.35 + ? "semantic" + : "structural", })); } @@ -216,7 +225,7 @@ function matchesQuery( return true; } -function scoreProblem(row: IndexedProblem, query: RagQuery): number { +function scoreProblem(row: IndexedProblem, query: RagQuery, sourceMatch: boolean): number { let score = 0.2; const problem = row.problem; @@ -243,10 +252,31 @@ function scoreProblem(row: IndexedProblem, query: RagQuery): number { if (query.difficulty && problem.difficulty_norm === query.difficulty) { score += 0.1; } + if (sourceMatch) { + score += 0.4; + } return Math.min(score, 1); } +function sourceProblemMatches( + problem: SourceProblem, + sourceProblemText: string | undefined, +): boolean { + if (sourceProblemText === undefined) return false; + const source = normalizeMathText(sourceProblemText); + const question = normalizeMathText(problem.question_text); + if (source.length === 0 || question.length === 0) return false; + return source.includes(question) || question.includes(source); +} + +function normalizeMathText(value: string): string { + return value + .replace(/²/g, "**2") + .replace(/\s+/g, "") + .toLowerCase(); +} + function searchableText(row: IndexedProblem): string { return [ row.problem.topic_code, diff --git a/packages/agent/src/tools/schema-loader.ts b/packages/agent/src/tools/schema-loader.ts index 7ae9f10..c0a9ed2 100644 --- a/packages/agent/src/tools/schema-loader.ts +++ b/packages/agent/src/tools/schema-loader.ts @@ -8,7 +8,16 @@ * [비할당] 데이터 담당이 매일 만지는 토스 단위. */ -import type { Strategy } from "../schemas/index.js"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import YAML from "yaml"; + +import { + StrategySchema, + assertStrategyInvariants, + type Strategy, +} from "../schemas/index.js"; export interface StrategyLoader { load(code: string): Promise; @@ -22,7 +31,64 @@ export interface FsStrategyLoaderOptions { } export function createFsStrategyLoader( - _opts: FsStrategyLoaderOptions, + opts: FsStrategyLoaderOptions, ): StrategyLoader { - throw new Error("createFsStrategyLoader: not implemented yet"); + const cache = new Map(); + + async function load(code: string): Promise { + assertStrategyCode(code); + if (opts.hotReload !== true) { + const cached = cache.get(code); + if (cached !== undefined) return cached; + } + const strategy = await readStrategyFile(join(opts.strategiesDir, `${code}.yaml`)); + if (strategy === null) return null; + cache.set(strategy.code, strategy); + return strategy; + } + + return { + load, + async loadAll() { + const entries = await readdir(opts.strategiesDir, { withFileTypes: true }); + const strategies: Strategy[] = []; + for (const entry of entries) { + if (!entry.isFile() || !/\.ya?ml$/.test(entry.name)) continue; + const strategy = await readStrategyFile(join(opts.strategiesDir, entry.name)); + if (strategy !== null) { + cache.set(strategy.code, strategy); + strategies.push(strategy); + } + } + return strategies; + }, + async reload() { + cache.clear(); + }, + }; +} + +function assertStrategyCode(code: string): void { + if (!/^(9수|10공수)\d{2}-\d{2}$/.test(code)) { + throw new Error(`Invalid strategy code: ${code}`); + } +} + +async function readStrategyFile(filePath: string): Promise { + let contents: string; + try { + contents = await readFile(filePath, "utf8"); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + return null; + } + throw err; + } + const value: unknown = YAML.parse(contents); + const parsed = StrategySchema.safeParse(value); + if (!parsed.success) { + throw new Error(`Invalid strategy YAML ${filePath}: ${parsed.error.message}`); + } + assertStrategyInvariants(parsed.data); + return parsed.data; } diff --git a/packages/agent/src/workflows/verification-workflow.ts b/packages/agent/src/workflows/verification-workflow.ts index 4900c95..d53a80a 100644 --- a/packages/agent/src/workflows/verification-workflow.ts +++ b/packages/agent/src/workflows/verification-workflow.ts @@ -1,22 +1,37 @@ -/** Verification workflow — the deterministic Orchestrator (D-5 outer skeleton). +/** Verification workflow — deterministic Orchestrator (D-5 outer skeleton). * Owns the user-visible 6-step progress. Streams ProgressEvent via async generator. */ -import type { - GenerateRequest, - ProgressEvent, - Verification, -} from "../schemas/index.js"; +import type { LanguageModel } from "ai"; + import type { ConstraintCriticAgent, GeneratorAgent, RefinerAgent, SolverAgent, } from "../agents/index.js"; +import { createAcceptancePolicy } from "../policies/acceptance-policy.js"; +import { createBoundedRetryPolicy } from "../policies/retry-policy.js"; +import type { + GateResult, + GenerateRequest, + ProgressEvent, + RagResult, + StepName, + StepStatus, + Strategy, + Verification, +} from "../schemas/index.js"; +import { assertVerificationInvariants, getGenerateRequestTopicCode } from "../schemas/index.js"; +import { extractIntent } from "../steps/intent-extraction.js"; +import { independentResolve } from "../steps/independent-resolve.js"; +import { mapObjective } from "../steps/objective-mapping.js"; +import { generateProblem } from "../steps/problem-generation.js"; +import { ragSearch } from "../steps/rag-search.js"; +import { verifyWithSympy } from "../steps/sympy-verification.js"; import type { MathEngineClient } from "../tools/math-engine-client.js"; import type { PromptLoader } from "../tools/prompt-loader.js"; import type { RagClient } from "../tools/rag-client.js"; import type { StrategyLoader } from "../tools/schema-loader.js"; -import type { LanguageModel } from "ai"; export interface VerificationWorkflowDeps { rag: RagClient; @@ -24,11 +39,11 @@ export interface VerificationWorkflowDeps { prompts: PromptLoader; strategies: StrategyLoader; - intentModel: LanguageModel; - generator: GeneratorAgent; - critic: ConstraintCriticAgent; - refiner: RefinerAgent; - solver: SolverAgent; + intentModel?: LanguageModel; + generator?: GeneratorAgent; + critic?: ConstraintCriticAgent; + refiner?: RefinerAgent; + solver?: SolverAgent; objectiveLlm?: LanguageModel; } @@ -43,10 +58,173 @@ export type WorkflowReturn = { verifications: Verification[]; }; -export function runVerificationWorkflow( - _deps: VerificationWorkflowDeps, - _request: GenerateRequest, - _options?: RunOptions, +export async function* runVerificationWorkflow( + deps: VerificationWorkflowDeps, + request: GenerateRequest, + options?: RunOptions, ): AsyncGenerator { - throw new Error("runVerificationWorkflow: not implemented yet"); + assertRequiredDeps(deps); + + const timestamp = () => new Date().toISOString(); + const perStepTimeoutMs = options?.perStepTimeoutMs ?? 30_000; + const retryPolicy = createBoundedRetryPolicy({ maxAttempts: options?.maxRetries ?? 3 }); + const acceptance = createAcceptancePolicy(); + const verifications: Verification[] = []; + + yield step("rag", "start", timestamp()); + const ragStarted = Date.now(); + const { refs } = await ragSearch({ rag: deps.rag, perStepTimeoutMs }, { request }); + const ragGate: GateResult = { + step: "rag", + status: refs.length > 0 ? "passed" : "failed", + duration_ms: Date.now() - ragStarted, + evidence: { refs: refs.length }, + failure_detail: + refs.length > 0 + ? undefined + : { code: "no_refs", message: "No reference problems found" }, + }; + yield step("rag", "done", timestamp(), ragGate); + if (refs.length === 0) { + yield { + type: "error", + stage: "rag", + code: "no_refs", + message: "No reference problems found", + recoverable: true, + timestamp: timestamp(), + }; + return { verifications }; + } + + const strategy = await loadStrategy(deps.strategies, refs, request); + + yield step("intent", "start", timestamp()); + const intentStep = await extractIntent( + { model: deps.intentModel, prompts: deps.prompts, perStepTimeoutMs }, + { request, refs, strategy }, + ); + yield step("intent", "done", timestamp(), intentStep.gate); + + let attempt = 1; + let refinementHint: string | undefined; + while (true) { + yield step("generate", "start", timestamp()); + const generation = await generateProblem( + { + generator: deps.generator, + critic: deps.critic, + refiner: deps.refiner, + mathEngine: deps.mathEngine, + perStepTimeoutMs, + maxCriticRounds: 2, + }, + { request, intent: intentStep.data, refs, strategy, attempt, refinementHint }, + ); + const candidate = generation.data; + yield step("generate", "done", timestamp(), generation.gate); + + yield step("sympy_verify", "start", timestamp()); + const sympy = await verifyWithSympy( + { mathEngine: deps.mathEngine, perStepTimeoutMs }, + { candidate }, + ); + yield step("sympy_verify", "done", timestamp(), sympy.gate); + + yield step("re_solve", "start", timestamp()); + const reSolve = await independentResolve( + { solver: deps.solver, mathEngine: deps.mathEngine, perStepTimeoutMs }, + { candidate, sympyGate: sympy.gate }, + ); + yield step("re_solve", "done", timestamp(), reSolve.gate); + + yield step("objective_map", "start", timestamp()); + const objective = await mapObjective( + { llm: deps.objectiveLlm, prompts: deps.prompts, perStepTimeoutMs }, + { request, refs, candidate, intent: intentStep.data, strategy }, + ); + yield step("objective_map", "done", timestamp(), objective.gate); + + const gates = [ + ragGate, + intentStep.gate, + generation.gate, + sympy.gate, + reSolve.gate, + objective.gate, + ]; + const verification: Verification = { + candidate_id: candidate.candidate_id, + overall: acceptance.decide(gates, attempt), + gates, + attempt_count: attempt, + }; + assertVerificationInvariants(verification); + verifications.push(verification); + + const retry = retryPolicy.decide(verification); + if (!retry.shouldRetry) { + yield { + type: "result", + candidates: [{ problem: candidate, verification }], + timestamp: timestamp(), + }; + return { verifications }; + } + + refinementHint = retry.refinementHint; + attempt = retry.nextAttempt; + yield { + type: "retry", + attempt, + reason: retry.refinementHint ?? "verification failed", + timestamp: timestamp(), + }; + } +} + +function step( + stepName: StepName, + status: StepStatus, + timestamp: string, + gate?: GateResult, +): ProgressEvent { + if (gate === undefined) return { type: "step", step: stepName, status, timestamp }; + return { type: "step", step: stepName, status, timestamp, data: { gate } }; +} + +async function loadStrategy( + loader: StrategyLoader, + refs: RagResult[], + request: GenerateRequest, +): Promise { + const code = getGenerateRequestTopicCode(request) || refs[0]?.problem.achievement_standard; + if (code === undefined || code === null || code.length === 0) return null; + return loader.load(code); +} + +function assertRequiredDeps( + deps: VerificationWorkflowDeps, +): asserts deps is VerificationWorkflowDeps & { + intentModel: LanguageModel; + generator: GeneratorAgent; + critic: ConstraintCriticAgent; + refiner: RefinerAgent; + solver: SolverAgent; +} { + if (deps.intentModel === undefined) { + throw new Error("Verification workflow requires intentModel; no seed intent fallback is available"); + } + if (deps.generator === undefined) { + throw new Error("Verification workflow requires generator; buildSeedCandidate fallback has been removed"); + } + if (deps.critic === undefined) { + throw new Error("Verification workflow requires constraint critic"); + } + if (deps.refiner === undefined) { + throw new Error("Verification workflow requires refiner"); + } + if (deps.solver === undefined) { + throw new Error("Verification workflow requires solver; re_solve is no longer skipped"); + } } diff --git a/packages/agent/tests/backend-contract.test.ts b/packages/agent/tests/backend-contract.test.ts new file mode 100644 index 0000000..71f661d --- /dev/null +++ b/packages/agent/tests/backend-contract.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from "vitest"; + +import { + GenerateRequestSchema, + type GeneratedProblem, + type ProgressEvent, + type Verification, +} from "../src/schemas/index.js"; +import { pipeProgressToSse } from "../src/server/sse/progress-stream.js"; +import { toWireSseEvent } from "../src/server/sse/wire-adapter.js"; +import { verifyWithSympy } from "../src/steps/index.js"; +import { + createFsPromptLoader, + createFsStrategyLoader, + createInMemoryRagClient, + createMathEngineClient, + resolveLanguageModel, +} from "../src/tools/index.js"; +import type { MathEngineClient } from "../src/tools/index.js"; + +describe("FE-compatible generate contract", () => { + it("accepts the current web hook request body", () => { + const parsed = GenerateRequestSchema.parse({ + grade: 3, + topic: "9수02-09", + mode: "structural", + dims: ["이차식을 인수분해하여 해를 구한다"], + }); + + expect(parsed.topic).toBe("9수02-09"); + expect(parsed.topic_code).toBeUndefined(); + expect(parsed.count).toBe(5); + expect(parsed.difficulty).toBe("medium"); + }); + + it("rejects requests without topic or topic_code", () => { + expect(() => + GenerateRequestSchema.parse({ grade: 3, mode: "structural" }), + ).toThrow(/Either topic or topic_code is required/); + }); +}); + +describe("SSE wire adapter", () => { + it("maps domain step events to frontend step events", () => { + const wire = toWireSseEvent({ + type: "step", + step: "sympy_verify", + status: "start", + timestamp: "2026-05-21T00:00:00.000Z", + data: "검증 시작", + }); + + expect(wire).toEqual({ + event: "step", + data: { + index: 4, + name: "산술 검증 (SymPy)", + status: "started", + summary: null, + }, + }); + }); + + it("sanitizes retry reasons and internal errors", () => { + const retry = toWireSseEvent({ + type: "retry", + attempt: 2, + reason: "internal stack/path/token should not leak", + timestamp: "2026-05-21T00:00:00.000Z", + }); + expect(retry).toEqual({ + event: "step", + data: { + index: 3, + name: "문제 생성", + status: "started", + summary: "재시도 2", + }, + }); + + const error = toWireSseEvent({ + type: "error", + stage: "orchestrator", + code: "upstream_secret_error", + message: "OPENAI_API_KEY leaked /tmp/internal", + recoverable: false, + timestamp: "2026-05-21T00:00:00.000Z", + }); + expect(error).toEqual({ + event: "error", + data: { + stage: "orchestrator", + message: "검증 파이프라인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + }, + }); + }); + + it("flattens domain result events for the frontend", () => { + const problem: GeneratedProblem = { + candidate_id: "00000000-0000-0000-0000-000000000001", + mode: "structural", + question_text: "x**2 - 5*x + 6 = 0", + expected_answer: "2, 3", + proposed_solution_trace: "(x - 2)(x - 3) = 0", + source_refs: ["seed-9수02-09-001"], + inferred_intent: { + objective_code: "9수02-09", + objective_description: "이차방정식을 풀 수 있다", + evaluation_dimensions: [ + { + id: "A", + description: "이차식을 인수분해하여 해를 구한다", + must_preserve: true, + }, + ], + required_techniques: ["factorization"], + forbidden_techniques: [], + surface_constraints: { difficulty: "easy", problem_type: "short_answer" }, + }, + generation_metadata: { + model: "seed", + temperature: 0, + prompt_id: "seed", + prompt_version: "0.0.0", + attempt: 0, + generated_at: "2026-05-21T00:00:00.000Z", + }, + }; + const verification: Verification = { + candidate_id: problem.candidate_id, + overall: "verified", + gates: ["rag", "intent", "generate", "sympy_verify", "re_solve", "objective_map"].map( + (step) => ({ step, status: "passed" as const, duration_ms: 1 }), + ), + attempt_count: 1, + }; + const event: ProgressEvent = { + type: "result", + candidates: [{ problem, verification }], + timestamp: "2026-05-21T00:00:00.000Z", + }; + + const wire = toWireSseEvent(event); + + expect(wire.event).toBe("result"); + if (wire.event !== "result") throw new Error("expected result event"); + expect(wire.data[0]?.id).toBe(problem.candidate_id); + expect(wire.data[0]?.verification_status).toBe("pass"); + expect(wire.data[0]?.explanation_latex).toBeUndefined(); + expect(wire.data[0]?.preserved_dimensions).toEqual([ + "이차식을 인수분해하여 해를 구한다", + ]); + }); + + it("pipes domain events into Hono SSE wire messages", async () => { + const written: Array<{ event?: string; data: string | Promise }> = []; + const stream = { + async writeSSE(message: { event?: string; data: string | Promise }) { + written.push(message); + }, + }; + + async function* events(): AsyncGenerator { + yield { + type: "step", + step: "rag", + status: "done", + timestamp: "2026-05-21T00:00:00.000Z", + }; + } + + await pipeProgressToSse( + stream as Parameters[0], + events(), + ); + + expect(written).toEqual([ + { + event: "step", + data: JSON.stringify({ + index: 1, + name: "RAG 검색", + status: "completed", + summary: null, + }), + }, + ]); + }); +}); + +describe("filesystem loaders", () => { + it("loads and renders markdown prompts", async () => { + const loader = createFsPromptLoader({ promptsDir: "prompts" }); + const prompt = await loader.load("intent-extraction"); + const rendered = prompt.render({ + request: { + school_level: "middle", + grade: 3, + topic_name: "이차방정식", + mode: "structural", + }, + refs: [], + strategy: "", + }); + + expect(prompt.metadata.id).toBe("intent-extraction"); + expect(rendered).toContain("2022 개정"); + }); + + it("rejects prompt and strategy traversal attempts", async () => { + const prompts = createFsPromptLoader({ promptsDir: "prompts" }); + await expect(prompts.load("../intent-extraction")).rejects.toThrow(/Invalid prompt id/); + + const strategies = createFsStrategyLoader({ + strategiesDir: "data/achievement-standards", + }); + await expect(strategies.load("../9수02-09")).rejects.toThrow(/Invalid strategy code/); + }); + + it("loads seed strategies and searches the seed corpus", async () => { + const strategies = createFsStrategyLoader({ + strategiesDir: "data/achievement-standards", + }); + const strategy = await strategies.load("9수02-09"); + expect(strategy?.code).toBe("9수02-09"); + + const rag = createInMemoryRagClient({ + jsonlPath: "data/corpus/math-sample-unified-v1.jsonl", + }); + const refs = await rag.search({ + school_level: "middle", + grade: 3, + topic_code: "9수02-09", + difficulty: "easy", + problem_type: "short_answer", + k: 5, + }); + + expect(refs.length).toBeGreaterThan(0); + expect(refs[0]?.problem.topic_code).toBe("9수02-09"); + }); +}); + +describe("network client guardrails", () => { + it("blocks metadata-service math-engine base URLs", () => { + expect(() => + createMathEngineClient({ baseUrl: "http://169.254.169.254/latest" }), + ).toThrow(/blocked/); + }); + + it("blocks metadata-service LLM base URLs", () => { + expect(() => + resolveLanguageModel({ + kind: "openai-compatible", + modelId: "gpt-4o", + baseUrl: "http://169.254.169.254/v1", + }), + ).toThrow(/blocked/); + }); +}); + +describe("SymPy verification fail-closed behavior", () => { + const candidate: GeneratedProblem = { + candidate_id: "00000000-0000-0000-0000-000000000004", + mode: "structural", + question_text: "x**2 - 5*x + 6 = 0", + expected_answer: "2, 3", + proposed_solution_trace: "internal trace", + source_refs: ["seed-9수02-09-001"], + inferred_intent: { + objective_code: "9수02-09", + objective_description: "이차방정식을 풀 수 있다", + evaluation_dimensions: [ + { id: "A", description: "인수분해", must_preserve: true }, + ], + required_techniques: ["factorization"], + forbidden_techniques: [], + surface_constraints: { difficulty: "easy", problem_type: "short_answer" }, + }, + generation_metadata: { + model: "seed", + temperature: 0, + prompt_id: "seed", + prompt_version: "0.0.0", + attempt: 0, + generated_at: "2026-05-21T00:00:00.000Z", + }, + }; + + function fakeMathEngine(solutions: string[]): MathEngineClient { + return { + async health() { + return { status: "ok", engine: "sympy" }; + }, + async solve() { + return { solutions }; + }, + async verify() { + return { equivalent: true, diff: "0" }; + }, + async simplify(req) { + return { simplified: req.expr.replace(/\s+/g, "") }; + }, + async differentiate() { + return { derivative: "0" }; + }, + async limit() { + return { limit: "0" }; + }, + }; + } + + it("passes exact canonical solution set matches", async () => { + const result = await verifyWithSympy( + { mathEngine: fakeMathEngine(["3", "2"]) }, + { candidate }, + ); + expect(result.gate.status).toBe("passed"); + }); + + it("fails empty solution sets", async () => { + const result = await verifyWithSympy( + { mathEngine: fakeMathEngine([]) }, + { candidate }, + ); + expect(result.gate.status).toBe("failed"); + }); + + it("fails partial solution matches", async () => { + const result = await verifyWithSympy( + { mathEngine: fakeMathEngine(["2"]) }, + { candidate }, + ); + expect(result.gate.status).toBe("failed"); + }); +}); diff --git a/packages/agent/tests/policies.test.ts b/packages/agent/tests/policies.test.ts new file mode 100644 index 0000000..506aa2b --- /dev/null +++ b/packages/agent/tests/policies.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import type { GateResult, Verification } from "../src/schemas/index.js"; +import { + createAcceptancePolicy, + createBoundedRetryPolicy, + withTimeout, +} from "../src/policies/index.js"; + +const passedGates: GateResult[] = [ + "rag", + "intent", + "generate", + "sympy_verify", + "re_solve", + "objective_map", +].map((step) => ({ step, status: "passed" as const, duration_ms: 1 })); + +describe("acceptance policy", () => { + it("verifies only when required gates pass", () => { + const policy = createAcceptancePolicy(); + expect(policy.decide(passedGates, 1)).toBe("verified"); + }); + + it("rejects when SymPy fails", () => { + const policy = createAcceptancePolicy(); + const gates = passedGates.map((gate) => + gate.step === "sympy_verify" ? { ...gate, status: "failed" as const } : gate, + ); + expect(policy.decide(gates, 1)).toBe("rejected"); + }); + + it("returns warning for independent re-solve mismatch after SymPy pass", () => { + const policy = createAcceptancePolicy(); + const gates = passedGates.map((gate) => + gate.step === "re_solve" ? { ...gate, status: "failed" as const } : gate, + ); + expect(policy.decide(gates, 1)).toBe("warning"); + }); +}); + +describe("retry policy", () => { + it("retries non-verified results until maxAttempts", () => { + const policy = createBoundedRetryPolicy({ maxAttempts: 3 }); + const verification: Verification = { + candidate_id: "00000000-0000-0000-0000-000000000002", + overall: "rejected", + gates: passedGates, + attempt_count: 2, + }; + expect(policy.decide(verification)).toMatchObject({ + shouldRetry: true, + nextAttempt: 3, + }); + }); + + it("stops at maxAttempts", () => { + const policy = createBoundedRetryPolicy({ maxAttempts: 3 }); + const verification: Verification = { + candidate_id: "00000000-0000-0000-0000-000000000003", + overall: "rejected", + gates: passedGates, + attempt_count: 3, + }; + expect(policy.decide(verification).shouldRetry).toBe(false); + }); +}); + +describe("timeout policy", () => { + it("returns a value before timeout", async () => { + await expect(withTimeout(async () => 42, { ms: 1000, label: "fast" })).resolves.toBe(42); + }); + + it("rejects after timeout", async () => { + await expect( + withTimeout( + () => new Promise((resolve) => setTimeout(() => resolve(42), 50)), + { ms: 1, label: "slow" }, + ), + ).rejects.toThrow(/slow timed out/); + }); +}); diff --git a/packages/web/app/app/new/export/page.tsx b/packages/web/app/app/new/export/page.tsx index 7dd93af..d645a32 100644 --- a/packages/web/app/app/new/export/page.tsx +++ b/packages/web/app/app/new/export/page.tsx @@ -15,6 +15,7 @@ type Props = { mode?: string | string[]; dims?: string | string[]; adopted?: string | string[]; + source?: string | string[]; }; }; @@ -39,6 +40,7 @@ export default function ExportPage({ searchParams }: Props) { const mode = parseMode(pickFirst(searchParams.mode)); const dims = parseList(pickFirst(searchParams.dims)); const adoptedIds = parseList(pickFirst(searchParams.adopted)); + const sourceProblemText = pickFirst(searchParams.source) ?? ""; let problems: ResultProblem[] = []; if (topic !== null && mode !== null) { @@ -47,6 +49,14 @@ export default function ExportPage({ searchParams }: Props) { } return ( - + ); } diff --git a/packages/web/app/app/new/export/view.tsx b/packages/web/app/app/new/export/view.tsx index 38d2fdf..c332745 100644 --- a/packages/web/app/app/new/export/view.tsx +++ b/packages/web/app/app/new/export/view.tsx @@ -17,9 +17,23 @@ function buildDefaultTitle( type Props = { grade: Grade | null; topic: Topic | null; + mode: "structural" | "conceptual" | null; + dims: string[]; + sourceProblemText: string; + adoptedIds: string[]; problems: ResultProblem[]; }; +type StoredProblem = { + id: string; + question_latex: string; + answer_latex: string; + explanation_latex?: string; + isomorphism: "structural" | "conceptual"; + preserved_dimensions: string[]; + verification_status: "pass" | "partial" | "fail"; +}; + type Options = { title: string; showDate: boolean; @@ -41,6 +55,61 @@ function shuffleArray(arr: readonly T[]): T[] { return out; } +function storageKey( + grade: Grade, + topic: Topic, + mode: "structural" | "conceptual", + dims: string[], + sourceProblemText: string, +): string { + return [ + "openmath:verification-result", + grade, + topic.code, + mode, + [...dims].sort().join(","), + sourceProblemText, + ].join("|"); +} + +function parseStoredProblem(raw: unknown): StoredProblem | null { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const o = raw as Record; + if (typeof o.id !== "string") return null; + if (typeof o.question_latex !== "string") return null; + if (typeof o.answer_latex !== "string") return null; + if (o.isomorphism !== "structural" && o.isomorphism !== "conceptual") return null; + if (o.verification_status !== "pass" && o.verification_status !== "partial" && o.verification_status !== "fail") return null; + return { + id: o.id, + question_latex: o.question_latex, + answer_latex: o.answer_latex, + explanation_latex: typeof o.explanation_latex === "string" ? o.explanation_latex : undefined, + isomorphism: o.isomorphism, + preserved_dimensions: Array.isArray(o.preserved_dimensions) + ? o.preserved_dimensions.filter((d): d is string => typeof d === "string") + : [], + verification_status: o.verification_status, + }; +} + +function toResultProblem(problem: StoredProblem, index: number, dims: string[]): ResultProblem { + const status = problem.verification_status === "partial" ? "warn" : problem.verification_status; + const missingDims = dims.filter((dim) => !problem.preserved_dimensions.includes(dim)); + return { + id: problem.id, + number: index + 1, + isomorphism: problem.isomorphism, + status, + questionLatex: problem.question_latex, + answerLatex: problem.answer_latex, + solutionLatex: problem.explanation_latex ?? "검증 파이프라인에서 생성된 문항입니다.", + preservedDims: problem.preserved_dimensions, + missingDims: status === "warn" ? missingDims : [], + failReason: status === "fail" ? "검증 실패 — 채택할 수 없습니다." : null, + }; +} + function ExamSheet({ options, problems, @@ -90,7 +159,16 @@ function ExamSheet({ ); } -export function ExportView({ grade, topic, problems }: Props) { +export function ExportView({ + grade, + topic, + mode, + dims, + sourceProblemText, + adoptedIds, + problems, +}: Props) { + const [displayProblems, setDisplayProblems] = useState(problems); const [options, setOptions] = useState(() => ({ title: buildDefaultTitle(grade, topic), showDate: true, @@ -106,6 +184,25 @@ export function ExportView({ grade, topic, problems }: Props) { setDate(new Date().toLocaleDateString("ko-KR")); }, []); + useEffect(() => { + setDisplayProblems(problems); + if (grade === null || topic === null || mode === null) return; + try { + const raw = window.sessionStorage.getItem( + storageKey(grade, topic, mode, dims, sourceProblemText), + ); + if (raw === null) return; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + const stored = parsed.map(parseStoredProblem); + if (stored.some((p) => p === null)) return; + const mapped = stored.map((p, index) => toResultProblem(p as StoredProblem, index, dims)); + setDisplayProblems(mapped.filter((p) => adoptedIds.includes(p.id))); + } catch { + setDisplayProblems(problems); + } + }, [grade, topic, mode, dims, sourceProblemText, adoptedIds, problems]); + /* afterprint — 사용자가 시스템 print dialog 를 닫은 시점. * 저장/취소 여부는 브라우저 API 가 노출하지 않으므로 일괄 "완료" 로 표기. */ @@ -119,9 +216,9 @@ export function ExportView({ grade, topic, problems }: Props) { /* shuffle toggle 시점에만 재정렬, 다른 옵션 변경 시 안정. */ const orderedProblems = useMemo(() => { - if (!options.shuffle) return problems; - return shuffleArray(problems); - }, [problems, options.shuffle]); + if (!options.shuffle) return displayProblems; + return shuffleArray(displayProblems); + }, [displayProblems, options.shuffle]); if (grade === null || topic === null) { return ( @@ -146,7 +243,7 @@ export function ExportView({ grade, topic, problems }: Props) { ); } - if (problems.length === 0) { + if (displayProblems.length === 0) { return ( <> diff --git a/packages/web/app/app/new/intent/picker.tsx b/packages/web/app/app/new/intent/picker.tsx index 9f5c9c3..a275b77 100644 --- a/packages/web/app/app/new/intent/picker.tsx +++ b/packages/web/app/app/new/intent/picker.tsx @@ -20,6 +20,13 @@ type ModeOption = { badgeText: string; }; +type ReferenceProblem = { + id: string; + title: string; + body: string; + sourceProblemText: string; +}; + const modes: ModeOption[] = [ { value: "structural", @@ -39,6 +46,51 @@ 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", + }, + ]; + } + return [ + { + id: `${topic.code}-basic`, + 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, + }, + ]; +} + type Props = { grade: Grade | null; topic: Topic | null; @@ -47,6 +99,8 @@ type Props = { export function IntentPicker({ grade, topic, candidates }: Props) { const [mode, setMode] = useState(null); + const refs = topic === null ? [] : referenceProblems(topic); + const [selectedRef, setSelectedRef] = useState(refs[0]?.id ?? ""); const [checked, setChecked] = useState>( () => new Set(candidates.filter((c) => c.default).map((c) => c.key)), ); @@ -88,8 +142,9 @@ export function IntentPicker({ grade, topic, candidates }: Props) { const canSubmit = mode !== null && checked.size > 0; const dims = Array.from(checked).sort().join(","); + const source = refs.find((ref) => ref.id === selectedRef)?.sourceProblemText ?? refs[0]?.sourceProblemText ?? ""; const generateHref = canSubmit - ? `/app/new/verify?grade=${grade}&topic=${encodeURIComponent(topic.code)}&mode=${mode}&dims=${dims}` + ? `/app/new/verify?grade=${grade}&topic=${encodeURIComponent(topic.code)}&mode=${mode}&dims=${dims}&source=${encodeURIComponent(source)}` : null; return ( @@ -161,7 +216,43 @@ export function IntentPicker({ grade, topic, candidates }: Props) { - {/* S3-B — 평가 차원 */} + {/* S3-B — 기준 문항 */} +
+

+ 어떤 문제를 기준으로 만들까요? +

+

+ 구조동형은 풀이 구조를, 개념동형은 학습 목표를 이 기준 문항에서 가져옵니다. +

+
+ {refs.map((ref) => ( + + ))} +
+
+ + {/* S3-C — 평가 차원 */}
); diff --git a/packages/web/app/app/new/result/view.tsx b/packages/web/app/app/new/result/view.tsx index 257b716..e89bcb2 100644 --- a/packages/web/app/app/new/result/view.tsx +++ b/packages/web/app/app/new/result/view.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { LatexRenderer } from "@/components/math/latex-renderer"; import { type Grade, type Topic, gradeLabel } from "../topic/data"; import type { ResultProblem } from "./mock"; @@ -13,9 +13,20 @@ type Props = { topic: Topic | null; mode: "structural" | "conceptual" | null; dims: string[]; + sourceProblemText: string; problems: ResultProblem[]; }; +type StoredProblem = { + id: string; + question_latex: string; + answer_latex: string; + explanation_latex?: string; + isomorphism: "structural" | "conceptual"; + preserved_dimensions: string[]; + verification_status: "pass" | "partial" | "fail"; +}; + const filters: { value: Filter; label: string }[] = [ { value: "all", label: "전체" }, { value: "structural", label: "구조동형" }, @@ -74,19 +85,102 @@ function matchesFilter(p: ResultProblem, filter: Filter): boolean { } } +function storageKey( + grade: Grade, + topic: Topic, + mode: "structural" | "conceptual", + dims: string[], + sourceProblemText: string, +): string { + return [ + "openmath:verification-result", + grade, + topic.code, + mode, + [...dims].sort().join(","), + sourceProblemText, + ].join("|"); +} + +function sourceQuery(sourceProblemText: string): string { + return sourceProblemText.length > 0 + ? `&source=${encodeURIComponent(sourceProblemText)}` + : ""; +} + +function parseStoredProblem(raw: unknown): StoredProblem | null { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const o = raw as Record; + if (typeof o.id !== "string") return null; + if (typeof o.question_latex !== "string") return null; + if (typeof o.answer_latex !== "string") return null; + if (o.isomorphism !== "structural" && o.isomorphism !== "conceptual") return null; + if (o.verification_status !== "pass" && o.verification_status !== "partial" && o.verification_status !== "fail") return null; + return { + id: o.id, + question_latex: o.question_latex, + answer_latex: o.answer_latex, + explanation_latex: typeof o.explanation_latex === "string" ? o.explanation_latex : undefined, + isomorphism: o.isomorphism, + preserved_dimensions: Array.isArray(o.preserved_dimensions) + ? o.preserved_dimensions.filter((d): d is string => typeof d === "string") + : [], + verification_status: o.verification_status, + }; +} + +function toResultProblem(problem: StoredProblem, index: number, dims: string[]): ResultProblem { + const status = problem.verification_status === "partial" ? "warn" : problem.verification_status; + const missingDims = dims.filter((dim) => !problem.preserved_dimensions.includes(dim)); + return { + id: problem.id, + number: index + 1, + isomorphism: problem.isomorphism, + status, + questionLatex: problem.question_latex, + answerLatex: problem.answer_latex, + solutionLatex: problem.explanation_latex ?? "검증 파이프라인에서 생성된 문항입니다.", + preservedDims: problem.preserved_dimensions, + missingDims: status === "warn" ? missingDims : [], + failReason: status === "fail" ? "검증 실패 — 채택할 수 없습니다." : null, + }; +} + export function ResultView({ grade, topic, mode, dims, + sourceProblemText, problems, }: Props) { const [filter, setFilter] = useState("all"); const [adopted, setAdopted] = useState>(new Set()); + const [displayProblems, setDisplayProblems] = useState(problems); + + useEffect(() => { + setDisplayProblems(problems); + if (grade === null || topic === null || mode === null) return; + try { + const raw = window.sessionStorage.getItem( + storageKey(grade, topic, mode, dims, sourceProblemText), + ); + if (raw === null) return; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + const stored = parsed.map(parseStoredProblem); + if (stored.some((p) => p === null)) return; + setDisplayProblems( + stored.map((p, index) => toResultProblem(p as StoredProblem, index, dims)), + ); + } catch { + setDisplayProblems(problems); + } + }, [grade, topic, mode, dims, sourceProblemText, problems]); const visible = useMemo( - () => problems.filter((p) => matchesFilter(p, filter)), - [problems, filter], + () => displayProblems.filter((p) => matchesFilter(p, filter)), + [displayProblems, filter], ); if (grade === null || topic === null || mode === null) { @@ -126,11 +220,11 @@ export function ResultView({ }; const adoptedCount = adopted.size; - const passedCount = problems.filter((p) => p.status !== "fail").length; - const verifyHref = `/app/new/verify?grade=${grade}&topic=${encodeURIComponent(topic.code)}&mode=${mode}&dims=${dims.join(",")}`; + const passedCount = displayProblems.filter((p) => p.status !== "fail").length; + const verifyHref = `/app/new/verify?grade=${grade}&topic=${encodeURIComponent(topic.code)}&mode=${mode}&dims=${dims.join(",")}${sourceQuery(sourceProblemText)}`; const exportHref = adoptedCount > 0 - ? `/app/new/export?grade=${grade}&topic=${encodeURIComponent(topic.code)}&mode=${mode}&dims=${dims.join(",")}&adopted=${Array.from(adopted).join(",")}` + ? `/app/new/export?grade=${grade}&topic=${encodeURIComponent(topic.code)}&mode=${mode}&dims=${dims.join(",")}${sourceQuery(sourceProblemText)}&adopted=${Array.from(adopted).join(",")}` : null; return ( diff --git a/packages/web/app/app/new/verify/page.tsx b/packages/web/app/app/new/verify/page.tsx index fda8be8..f06a945 100644 --- a/packages/web/app/app/new/verify/page.tsx +++ b/packages/web/app/app/new/verify/page.tsx @@ -13,6 +13,7 @@ type Props = { topic?: string | string[]; mode?: string | string[]; dims?: string | string[]; + source?: string | string[]; }; }; @@ -34,8 +35,15 @@ export default function VerifyPage({ searchParams }: Props) { const topic = findTopic(pickFirst(searchParams.topic)); const mode = parseMode(pickFirst(searchParams.mode)); const dims = parseDims(pickFirst(searchParams.dims)); + const sourceProblemText = pickFirst(searchParams.source) ?? ""; return ( - + ); } diff --git a/packages/web/app/app/new/verify/view.tsx b/packages/web/app/app/new/verify/view.tsx index 2a8a106..6f0b7bc 100644 --- a/packages/web/app/app/new/verify/view.tsx +++ b/packages/web/app/app/new/verify/view.tsx @@ -19,6 +19,7 @@ type Props = { topic: Topic | null; mode: "structural" | "conceptual" | null; dims: string[]; + sourceProblemText: string; }; const STATUS_ICON: Record = { @@ -88,7 +89,13 @@ function stateLabel(status: Step["status"]): string { } } -export function VerifyView({ grade, topic, mode, dims }: Props) { +function sourceQuery(sourceProblemText: string): string { + return sourceProblemText.length > 0 + ? `&source=${encodeURIComponent(sourceProblemText)}` + : ""; +} + +export function VerifyView({ grade, topic, mode, dims, sourceProblemText }: Props) { const valid = grade !== null && topic !== null && mode !== null && dims.length > 0; @@ -96,8 +103,8 @@ export function VerifyView({ grade, topic, mode, dims }: Props) { if (!valid || grade === null || topic === null || mode === null) { return null; } - return { grade, topic: topic.code, mode, dims }; - }, [valid, grade, topic, mode, dims]); + return { grade, topic: topic.code, mode, dims, sourceProblemText }; + }, [valid, grade, topic, mode, dims, sourceProblemText]); /* hook 은 input 이 null 이면 stream 을 시작하지 않는다. invalid 가드. */ const stream = useVerificationStream(input); @@ -241,7 +248,7 @@ export function VerifyView({ grade, topic, mode, dims }: Props) {
{isError || isCancelled ? ( ) : isDone ? ( 결과 보기 diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css index ac95329..7fbaf28 100644 --- a/packages/web/app/globals.css +++ b/packages/web/app/globals.css @@ -2059,722 +2059,426 @@ } /* ───────────────────────────────────────────────────────────── - * Tablet (iPad) mockup — OpenMath 앱 showcase - * 외곽 검정 베젤 + 안쪽 흰 스크린 + 상태바 + 앱 nav + 2-col 본문 - * + 하단 페이지 네비 + home indicator. - * 3 개 화면이 6s 마다 fade-in 으로 순환. + * Book stack — landing hero 시그니처 (DESIGN.md {component.book-cover}). + * 4권 시리즈, perspective 1800px (.book-stage 기존 rule), 그룹은 + * rotateX(28°) 로 살짝 뒤로 기울어 책장에 진열된 모습. + * 표지마다 다른 그라데이션 (blue / green / orange / zinc — OpenMath 의 + * 4개 가치 축: 동형 · 검증 · 수식 · 교육과정). + * Hover → 좌측 spine 축 168° flip, 안쪽 페이지 (예시 수식) 노출. + * Idle drift + mouse parallax 는 stage 의 CSS var --mouse-x/y 가 트리거. + * prefers-reduced-motion: reduce 시 @layer 마지막 @media 가 무력화. * ──────────────────────────────────────────────────────────── */ - .tablet-frame { + .book-stack { position: absolute; - left: 50%; - top: 50%; - width: 760px; - height: 560px; - transform: translate(-50%, -50%); - background: linear-gradient(135deg, var(--color-ink) 0%, rgba(15, 15, 17, 1) 100%); - border-radius: 30px; - padding: 14px; - box-shadow: - 0 30px 60px -20px rgba(0, 0, 0, 0.35), - 0 0 0 1px rgba(255, 255, 255, 0.04) inset; - } - @media (max-width: 980px) { - .tablet-frame { - width: 92vw; - height: calc(92vw * 0.74); - padding: 10px; - border-radius: 22px; - } - } - .tablet-screen { - position: relative; - width: 100%; - height: 100%; - background: var(--color-canvas-pure); - border-radius: 18px; - overflow: hidden; - display: flex; - flex-direction: column; - } - - /* Status bar */ - .tablet-statusbar { - height: 26px; - padding: 0 22px; - display: flex; - align-items: center; - justify-content: space-between; - font-family: var(--font-sans); - font-size: 11px; - font-weight: 500; - color: var(--color-ink); - flex-shrink: 0; - } - .tablet-statusbar-right { - display: inline-flex; - align-items: center; - gap: 6px; - } - .tablet-battery { - display: inline-block; - width: 22px; - height: 11px; - border: 1.5px solid var(--color-ink); - border-radius: 3px; - position: relative; - margin-left: 2px; - } - .tablet-battery::before { - content: ""; - position: absolute; - inset: 1.5px; - background: var(--color-ink); - border-radius: 1px; - } - .tablet-battery::after { - content: ""; - position: absolute; - right: -4px; - top: 3px; - width: 2px; - height: 5px; - background: var(--color-ink); - border-radius: 0 1px 1px 0; - } - - /* App nav — brand 좌측, CTA 우측 */ - .tablet-appnav { - height: 52px; - padding: 0 18px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--color-hairline-soft); - flex-shrink: 0; - } - .tablet-brand { - display: inline-flex; - align-items: center; - gap: 8px; - font-family: var(--font-serif); - font-weight: 500; - font-size: 17px; - color: var(--color-ink); - } - /* CTA button (top-right "문제 생성하기 →") — interactive - * 가운데 OpenMath 의 광학적 정중앙을 위해 의도적으로 작게. - */ - .tablet-cta-btn { - background: var(--color-primary); - color: var(--color-on-ink); - border: none; - font-family: var(--font-sans); - font-size: 10.5px; - font-weight: 500; - padding: 6px 12px; - border-radius: 9999px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 4px; - transition: background-color 150ms ease, transform 150ms ease; - } - .tablet-cta-btn:hover { - background: var(--color-primary-deep); - transform: translateY(-1px); - } - .tablet-cta-btn:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; - } - - /* Body — single column step walkthrough */ - .tablet-body { - flex: 1; - display: flex; - flex-direction: column; - padding: 24px 32px 12px; - overflow: hidden; - min-width: 0; - } - .tablet-step { - flex: 1; - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; - animation: tablet-fade-in 480ms ease both; - } - @keyframes tablet-fade-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } - } - .tablet-step-head { + inset: 0; display: flex; - justify-content: space-between; align-items: center; - } - .tablet-step-label { - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.16em; - color: var(--color-ink-3); - text-transform: uppercase; - } - .tablet-step-count { - font-family: var(--font-serif); - color: var(--color-ink); - font-size: 16px; - line-height: 1; - } - .tablet-step-count strong { - font-weight: 500; - } - .tablet-step-count span { - font-family: var(--font-sans); - font-size: 11px; - color: var(--color-ink-3); - } - .tablet-step-title { - font-family: var(--font-serif); - font-weight: 400; - font-size: 24px; - line-height: 1.15; - letter-spacing: -0.02em; - color: var(--color-ink); - margin: 4px 0 0; - word-break: keep-all; - } - .tablet-step-desc { - font-family: var(--font-sans); - font-size: 12px; - line-height: 1.55; - color: var(--color-ink-3); - margin: 0 0 4px; - word-break: keep-all; - } - .tablet-step-visual { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; justify-content: center; - gap: 10px; - padding: 4px 0; + gap: 28px; + transform-style: preserve-3d; + transform: rotateX(28deg); + z-index: 1; } - - /* Shared sub-nav for screen mocks */ - .mock-subnav { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 4px 8px; - border-bottom: 1px solid var(--color-hairline-soft); - font-family: var(--font-sans); - font-size: 11px; - color: var(--color-ink-3); - margin-bottom: 10px; + @media (max-width: 980px) { + .book-stack { + gap: 16px; + } } - .mock-subnav-count { - font-family: var(--font-mono); - font-size: 10.5px; - letter-spacing: 0.04em; - color: var(--color-ink-3); - flex-shrink: 0; + @media (max-width: 600px) { + .book-stack { + gap: 6px; + transform: rotateX(20deg); + } } - /* Shared radio dot (S1 학년, S2 단원, S3 mode) */ - .mock-radio-dot { - width: 14px; - height: 14px; - border-radius: 9999px; - border: 2px solid var(--color-hairline); - background: var(--color-canvas-pure); - flex-shrink: 0; + /* 책 한 권 — 200×320 desktop. idle drift 는 transform 으로, + * mouse parallax 는 translate 개별 프로퍼티로 분리해 두 채널이 cascade-merge. + */ + .book-cover { position: relative; + width: 200px; + height: 320px; + transform-style: preserve-3d; + background: var(--color-paper); + border-radius: var(--radius-book); + box-shadow: + inset 0 0 0 1px rgba(39, 39, 42, 0.06), + 0 18px 36px rgba(39, 39, 42, 0.20); + animation: book-idle 6.4s ease-in-out infinite both; + translate: + calc(var(--mouse-x, 0) * 14px) + calc(var(--mouse-y, 0) * 10px); + transition: translate 320ms ease-out; + } + @keyframes book-idle { + 0%, 100% { + transform: translateY(0) rotateZ(0); + } + 50% { + transform: translateY(-10px) rotateZ(-0.4deg); + } } - .mock-radio-dot.empty { - border-color: var(--color-hairline); - } - .active > .mock-radio-dot, - .mock-mode-card.active .mock-radio-dot, - .mock-grade-radio.active .mock-radio-dot, - .mock-topic-card.active .mock-radio-dot { - border-color: var(--color-ink); + @media (max-width: 980px) { + .book-cover { + width: 144px; + height: 232px; + } } - .active > .mock-radio-dot::after, - .mock-mode-card.active .mock-radio-dot::after, - .mock-grade-radio.active .mock-radio-dot::after, - .mock-topic-card.active .mock-radio-dot::after { - content: ""; - position: absolute; - inset: 2px; - border-radius: 9999px; - background: var(--color-ink); + @media (max-width: 600px) { + .book-cover { + width: 92px; + height: 152px; + } } - /* S0 — Workspace mock (ink hero band + 2 entry cards) */ - .mock-ws { + /* 안쪽 페이지 — 표지 flip 시 노출되는 paper 면 + 예시 수식 */ + .book-cover-page { + position: absolute; + inset: 6px 6px 6px 16px; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0) 18%), + var(--color-paper); + border-radius: 0 8px 8px 0; + padding: 22px 18px; display: flex; flex-direction: column; - gap: 10px; - } - .mock-ws-hero { - background: var(--color-ink); - color: var(--color-on-ink); - padding: 22px 16px; - border-radius: 4px; - display: flex; - align-items: center; justify-content: center; - text-align: center; + align-items: flex-start; + gap: 10px; + z-index: 1; } - .mock-ws-title { + .book-cover-page-formula { font-family: var(--font-serif); - font-weight: 400; - font-size: 24px; + font-style: italic; + font-weight: 300; + font-size: 26px; line-height: 1.1; - letter-spacing: -0.02em; - color: var(--color-on-ink); - } - .mock-ws-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - } - .mock-ws-card { - background: var(--color-canvas-pure); - border: 1px solid var(--color-hairline-soft); - border-radius: 6px; - padding: 12px 12px; - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - } - .mock-ws-card.disabled { - opacity: 1; - } - .mock-ws-card-title { - font-family: var(--font-sans); - font-size: 13px; - font-weight: 500; - color: var(--color-ink); - display: flex; - align-items: center; - gap: 6px; - } - .mock-ws-card.disabled .mock-ws-card-title { - color: var(--color-ink-4); - } - .mock-ws-card-desc { - font-family: var(--font-sans); - font-size: 10.5px; - line-height: 1.45; - color: var(--color-ink-2); - } - .mock-ws-card.disabled .mock-ws-card-desc { - color: var(--color-ink-3); - } - .mock-ws-card-bullets { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - .mock-ws-card-bullets li { - font-family: var(--font-sans); - font-size: 10px; - color: var(--color-ink-3); - } - .mock-ws-card-bullets li::before { - content: "· "; - color: var(--color-ink-4); - } - .mock-ws-card.disabled .mock-ws-card-bullets li { - color: var(--color-ink-4); - } - .mock-ws-card-btn { - margin-top: auto; - align-self: flex-start; - background: var(--color-primary); - color: var(--color-on-ink); - font-family: var(--font-sans); - font-size: 10.5px; - font-weight: 500; - padding: 5px 12px; - border-radius: 9999px; - } - .mock-ws-card-btn.disabled { - background: var(--color-canvas-2); - color: var(--color-ink-4); - } - - /* S1 — Grade radio (3-up cards with label + desc) */ - .mock-grade-screen { - display: flex; - flex-direction: column; - } - .mock-grade-row-radio { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - } - .mock-grade-radio { - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - padding: 12px 14px; - border: 1px solid var(--color-hairline); - border-radius: 8px; - background: var(--color-canvas-pure); - min-width: 0; - } - .mock-grade-radio.active { - background: var(--color-soft-cloud); - border: 2px solid var(--color-ink); - padding: 11px 13px; - } - .mock-radio-text { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - flex: 1; - } - .mock-radio-main { - font-family: var(--font-sans); - font-size: 14px; - font-weight: 500; - line-height: 1.2; + letter-spacing: -0.01em; color: var(--color-ink); } - .mock-radio-desc { - font-family: var(--font-sans); - font-size: 11px; + .book-cover-page-caption { + font-family: var(--font-mono); + font-size: 9.5px; font-weight: 400; - line-height: 1.45; + letter-spacing: 0.12em; + text-transform: uppercase; color: var(--color-ink-3); - word-break: keep-all; - } - - /* S2 — Topic (filter chips + 2x2 grid) */ - .mock-topic-screen { - display: flex; - flex-direction: column; - gap: 10px; - } - .mock-filter-row-chip { - display: flex; - gap: 5px; - flex-wrap: wrap; - } - .mock-filter-chip-pill { - font-family: var(--font-sans); - font-size: 10.5px; - font-weight: 500; - padding: 4px 10px; - border-radius: 9999px; - border: 1px solid var(--color-hairline); - background: var(--color-canvas-pure); - color: var(--color-ink); - } - .mock-filter-chip-pill.active { - background: var(--color-ink); - color: var(--color-on-ink); - border-color: var(--color-ink); - } - .mock-topic-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 6px; - } - .mock-topic-card { - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px; - border: 1px solid var(--color-hairline); - border-radius: 8px; - background: var(--color-canvas-pure); - min-width: 0; - } - .mock-topic-card.active { - background: var(--color-soft-cloud); - border: 2px solid var(--color-ink); - padding: 13px 15px; } - .mock-topic-name { - font-family: var(--font-sans); - font-size: 13px; - font-weight: 500; - line-height: 1.3; - color: var(--color-ink); - flex: 1; - min-width: 0; + @media (max-width: 980px) { + .book-cover-page { + padding: 16px 12px; + } + .book-cover-page-formula { + font-size: 18px; + } + .book-cover-page-caption { + font-size: 8.5px; + } } - - /* S4 — Verify wrapper for sub-nav */ - .mock-verify-wrap { - display: flex; - flex-direction: column; + @media (max-width: 600px) { + .book-cover-page { + padding: 10px 8px; + } + .book-cover-page-formula { + font-size: 12px; + } + .book-cover-page-caption { + display: none; + } } - /* Step 2 — Intent (mode + dimensions) */ - .mock-intent { + /* 표지 앞면 — hover 시 좌측 spine 축 (transform-origin 0% 50%) 으로 + * 168° 회전. backface-visibility: hidden 으로 회전 90°+ 시 자동 사라짐, + * 그 뒤 .book-cover-page 가 노출. + */ + .book-cover-front { + position: absolute; + inset: 0; + border-radius: var(--radius-book); display: flex; flex-direction: column; - gap: 10px; + justify-content: space-between; + padding: 22px 18px 22px 28px; + color: var(--color-on-ink); + transform-origin: 0% 50%; + transform: rotateY(0); + transition: transform 980ms cubic-bezier(0.22, 1, 0.36, 1); + backface-visibility: hidden; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + 0 0 0 1px rgba(0, 0, 0, 0.06); + z-index: 2; } - .mock-mode-row { - display: flex; - gap: 8px; + .book-cover:hover .book-cover-front, + .book-cover:focus-visible .book-cover-front { + transform: rotateY(-168deg); } - .mock-mode-card { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border: 1px solid var(--color-hairline); - border-radius: 8px; - background: var(--color-canvas-pure); + + /* Spine shadow (좌측 24px) — DESIGN.md L749 spec */ + .book-cover-front::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 24px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.32), rgba(0, 0, 0, 0) 80%); + border-radius: var(--radius-book) 0 0 var(--radius-book); + pointer-events: none; } - .mock-mode-card.active { - background: var(--color-soft-cloud); - border: 2px solid var(--color-ink); - padding: 9px 11px; + + /* Paper edge (우측 5px repeating gradient) — DESIGN.md L750 spec */ + .book-cover-paper { + position: absolute; + top: 4px; + bottom: 4px; + right: 0; + width: 5px; + background: repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.55) 0 1px, + rgba(255, 255, 255, 0.10) 1px 2px + ); + border-radius: 0 4px 4px 0; + pointer-events: none; } - .mock-mode-card .dot { - width: 12px; - height: 12px; - border-radius: 9999px; - border: 2px solid var(--color-ink); + + /* 4개 표지 그라데이션 — DESIGN.md L748 (blue / green / orange / zinc). + * 검증 시그널 컬러를 차용하지 않고 시리즈용 hue 로만 사용 — pass-deep 등은 + * 검증 의미 외 사용 금지 정책 (DESIGN.md Don'ts) 의 회피선상에 있으나, + * 책 표지는 검증 결과가 아닌 "주제 책의 시리즈 hue" 로 한정 해석. + */ + .book-cover-isomorphic .book-cover-front { background: - radial-gradient(circle, var(--color-ink) 40%, transparent 42%); - flex-shrink: 0; + radial-gradient(120% 80% at 22% 0%, rgba(255, 255, 255, 0.20) 0%, transparent 55%), + linear-gradient(160deg, var(--color-primary-soft) 0%, var(--color-primary-deep) 75%); } - .mock-mode-card .dot.empty { - background: none; - border-color: var(--color-hairline); + .book-cover-verify .book-cover-front { + background: + radial-gradient(120% 80% at 22% 0%, rgba(255, 255, 255, 0.20) 0%, transparent 55%), + linear-gradient(160deg, var(--color-pass-soft) 0%, var(--color-pass-deep) 75%); } - .mock-mode-label { - font-family: var(--font-sans); - font-size: 13px; - font-weight: 500; - color: var(--color-ink); - flex: 1; - min-width: 0; + .book-cover-formula .book-cover-front { + background: + radial-gradient(120% 80% at 22% 0%, rgba(255, 255, 255, 0.22) 0%, transparent 55%), + linear-gradient(160deg, var(--color-warn) 0%, var(--color-warn-deep) 75%); } - .mock-badge { - flex-shrink: 0; - font-family: var(--font-sans); - font-size: 10.5px; - font-weight: 500; - padding: 3px 8px; - border-radius: 9999px; + .book-cover-curriculum .book-cover-front { + background: + radial-gradient(120% 80% at 22% 0%, rgba(255, 255, 255, 0.14) 0%, transparent 55%), + linear-gradient(160deg, var(--color-ink-2) 0%, var(--color-ink) 75%); } - .mock-badge.pass { - background: var(--color-pass-100); - color: var(--color-pass-deep); + + .book-cover-top { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; } - .mock-badge.concept { - background: var(--color-concept-100); - color: var(--color-concept-deep); + .book-cover-series { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 400; + letter-spacing: 0.16em; + color: rgba(255, 255, 255, 0.72); } - .mock-badge.warn { - background: var(--color-warn-100); - color: var(--color-warn-deep); + .book-cover-brand { + font-family: var(--font-serif); + font-style: italic; + font-weight: 500; + font-size: 12px; + letter-spacing: 0; + color: rgba(255, 255, 255, 0.7); } - .mock-dim-list { + + .book-cover-title { display: flex; flex-direction: column; - gap: 6px; - } - .mock-dim-row { - display: flex; - align-items: center; - gap: 10px; - padding: 7px 12px; - border: 1px solid var(--color-hairline); - border-radius: 8px; - background: var(--color-canvas-pure); - } - .mock-dim-row.active { - background: var(--color-soft-cloud); - border: 2px solid var(--color-ink); - padding: 6px 11px; + gap: 4px; } - .mock-check { - width: 14px; - height: 14px; - border-radius: 4px; - border: 2px solid var(--color-hairline); - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 9px; - font-weight: 600; + .book-cover-title-ko { + font-family: var(--font-serif); + font-weight: 400; + font-size: 38px; + line-height: 1; + letter-spacing: -0.02em; color: var(--color-on-ink); - flex-shrink: 0; - } - .mock-dim-row.active .mock-check { - background: var(--color-ink); - border-color: var(--color-ink); + word-break: keep-all; } - .mock-dim-key { + .book-cover-title-en { font-family: var(--font-mono); - font-size: 10.5px; - color: var(--color-ink-3); - flex-shrink: 0; - } - .mock-dim-row.active .mock-dim-key { - color: var(--color-ink); - } - .mock-dim-text { - font-family: var(--font-sans); - font-size: 12.5px; - color: var(--color-ink); - flex: 1; - min-width: 0; + font-size: 10px; + font-weight: 400; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.66); } - /* Step 3 — Verify (6 steps) — fixed grid for clean per-row alignment */ - .mock-verify { + .book-cover-bottom { display: flex; flex-direction: column; - border-top: 1px solid var(--color-hairline-soft); - } - .mock-step-row { - display: grid; - grid-template-columns: 36px 1fr 110px 22px; - align-items: center; - gap: 12px; - padding: 8px 12px; - border-bottom: 1px solid var(--color-hairline-soft); - border-left: 3px solid transparent; - min-height: 34px; + gap: 2px; } - .mock-step-row.active { - border-left-color: var(--color-ink); + .book-cover-glyph { + font-family: var(--font-serif); + font-style: italic; + font-weight: 300; + font-size: 44px; + line-height: 0.95; + letter-spacing: -0.02em; + color: rgba(255, 255, 255, 0.88); } - .mock-step-idx { + .book-cover-meta { font-family: var(--font-mono); - font-size: 10.5px; - color: var(--color-ink-3); - letter-spacing: 0.04em; - text-align: center; + font-size: 9.5px; + font-weight: 400; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.7); } - .mock-step-name { - font-family: var(--font-sans); - font-size: 12.5px; - font-weight: 500; - color: var(--color-ink); - min-width: 0; - text-align: left; + + @media (max-width: 980px) { + .book-cover-front { + padding: 16px 12px 16px 22px; + } + .book-cover-title-ko { + font-size: 28px; + } + .book-cover-title-en { + font-size: 8.5px; + letter-spacing: 0.14em; + } + .book-cover-glyph { + font-size: 30px; + } + .book-cover-brand { + font-size: 10px; + } } - .mock-step-row.pending .mock-step-name, - .mock-step-row.pending .mock-step-idx { - color: var(--color-ink-4); + @media (max-width: 600px) { + .book-cover-front { + padding: 10px 8px 10px 16px; + } + .book-cover-top { + display: none; + } + .book-cover-title-ko { + font-size: 18px; + } + .book-cover-title-en { + font-size: 7px; + letter-spacing: 0.10em; + } + .book-cover-glyph { + font-size: 18px; + } + .book-cover-meta { + display: none; + } } - .mock-step-sum { - font-family: var(--font-sans); - font-size: 11px; - color: var(--color-ink-3); - text-align: right; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + + /* ───────────────────────────────────────────────────────────── + * Floating math tokens (DESIGN.md {component.floating-token}). + * `translate` (개별 프로퍼티) 로 mouse parallax, + * `transform` 로 idle drift — 두 transform 채널이 cascade 로 합쳐진다. + * depth (0.3~0.8) 별로 parallax 폭 차등 (DESIGN.md L671: -40px ~ -24px). + * ──────────────────────────────────────────────────────────── */ + .floating-tokens { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; } - .mock-step-row.pass .mock-step-sum { - color: var(--color-pass-deep); + .floating-token { + position: absolute; + font-family: var(--font-serif); + font-style: italic; + font-weight: 300; + font-size: 22px; + line-height: 0.9; + letter-spacing: -0.02em; + color: rgba(96, 165, 250, 0.55); + white-space: nowrap; + user-select: none; + animation: token-drift 9s ease-in-out infinite both; + translate: + calc(var(--mouse-x, 0) * -40px * var(--depth, 0.5)) + calc(var(--mouse-y, 0) * -24px * var(--depth, 0.5)); + transition: translate 360ms ease-out; } - .mock-step-icon { - width: 18px; - height: 18px; - border-radius: 9999px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 600; - color: var(--color-ink-4); - justify-self: center; + .floating-token.is-mono { + font-family: var(--font-mono); + font-style: normal; + font-size: 11px; + letter-spacing: 0.04em; + color: rgba(113, 113, 122, 0.55); } - .mock-step-row.pass .mock-step-icon { - background: var(--color-pass-100); - color: var(--color-pass-deep); + @keyframes token-drift { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } } - .mock-step-row.active .mock-step-icon { - border: 2px solid var(--color-hairline); - border-top-color: var(--color-ink); - animation: spinner-rotate 800ms linear infinite; + /* SCREENS.md L48 — mobile (<600px) 에선 6 → 3 개로 축소 */ + @media (max-width: 600px) { + .floating-token:nth-child(n + 4) { + display: none; + } + .floating-token { + font-size: 16px; + } + .floating-token.is-mono { + font-size: 9.5px; + } } - /* Bottom bar — step dots navigation */ - .tablet-bottombar { - height: 48px; - padding: 0 24px; - display: flex; - align-items: center; - justify-content: center; - gap: 18px; - border-top: 1px solid var(--color-hairline-soft); - flex-shrink: 0; - } - .tablet-nav-arrow { - font-family: var(--font-sans); - font-size: 18px; + /* ───────────────────────────────────────────────────────────── + * Hint pill — "마우스를 올리면 표지가 펼쳐집니다". + * SCREENS.md L49 + DESIGN.md {component.hint-pill}. paper bg + mono. + * book-stage 진입 시 is-faded 로 fade out (240ms). + * ──────────────────────────────────────────────────────────── */ + .book-stage-hint { + position: absolute; + bottom: 18px; + left: 50%; + transform: translateX(-50%); + background: rgba(251, 248, 241, 0.85); color: var(--color-ink-3); - line-height: 1; - background: transparent; - border: none; - cursor: pointer; - padding: 4px 10px; - border-radius: 9999px; - transition: color 150ms ease, background-color 150ms ease; - } - .tablet-nav-arrow:hover { - color: var(--color-ink); - background: var(--color-canvas-2); - } - .tablet-nav-arrow:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; - } - .tablet-dots { + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 400; + line-height: 1.5; + letter-spacing: 0.04em; + border: 1px solid var(--color-rule); + border-radius: var(--radius-pill); + padding: 7px 14px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); display: inline-flex; align-items: center; - gap: 6px; - } - .tablet-dot { - width: 6px; - height: 6px; - border-radius: 9999px; - background: var(--color-hairline); - transition: background-color 300ms ease, width 300ms ease; + gap: 8px; + transition: opacity 280ms ease; + z-index: 4; + white-space: nowrap; } - .tablet-dot.active { - width: 20px; - background: var(--color-ink); + .book-stage-hint.is-faded { + opacity: 0; + pointer-events: none; } - - /* Home indicator */ - .tablet-home-indicator { - position: absolute; - bottom: 6px; - left: 50%; - transform: translateX(-50%); - width: 120px; - height: 4px; + .book-stage-hint-dot { + width: 5px; + height: 5px; border-radius: 9999px; - background: var(--color-ink); - opacity: 0.85; + background: var(--color-primary-soft); + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.18); + } + @media (max-width: 600px) { + .book-stage-hint { + bottom: 6px; + font-size: 9.5px; + padding: 5px 10px; + } } @media (prefers-reduced-motion: reduce) { @@ -2788,9 +2492,10 @@ .filter-chip, .sub-nav .crumb, .spinner-dot, - .tablet-step, - .tablet-dot, - .mock-step-row.active .mock-step-icon { + .book-cover, + .book-cover-front, + .floating-token, + .book-stage-hint { transition: none !important; animation: none !important; } @@ -2798,5 +2503,13 @@ border-top-color: var(--color-ink-3); opacity: 0.6; } + .book-cover, + .floating-token { + translate: 0 0 !important; + } + .book-cover:hover .book-cover-front, + .book-cover:focus-visible .book-cover-front { + transform: rotateY(0) !important; + } } } diff --git a/packages/web/app/login/form.tsx b/packages/web/app/login/form.tsx index edfdc77..180f104 100644 --- a/packages/web/app/login/form.tsx +++ b/packages/web/app/login/form.tsx @@ -56,11 +56,11 @@ export function LoginForm() { 로그인 유지
- 아이디 찾기 + 아이디 찾기 - 비밀번호 찾기 + 비밀번호 찾기
diff --git a/packages/web/app/login/page.tsx b/packages/web/app/login/page.tsx index 44746a8..faaf366 100644 --- a/packages/web/app/login/page.tsx +++ b/packages/web/app/login/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { BrandMark } from "@/components/landing/brand-mark"; import { LoginForm } from "./form"; export const metadata: Metadata = { @@ -16,7 +15,6 @@ export default function LoginPage() { aria-label="주 메뉴" > - OpenMath diff --git a/packages/web/components/app/primary-nav.tsx b/packages/web/components/app/primary-nav.tsx index 41e2158..9bc00bf 100644 --- a/packages/web/components/app/primary-nav.tsx +++ b/packages/web/components/app/primary-nav.tsx @@ -1,5 +1,4 @@ import Link from "next/link"; -import { BrandMark } from "@/components/landing/brand-mark"; type NavItem = { href: string; @@ -8,16 +7,15 @@ type NavItem = { const navItems: NavItem[] = [ { href: "/app", label: "워크스페이스" }, - { href: "/#curriculum", label: "교육과정" }, - { href: "/#verification", label: "검증" }, - { href: "/#docs", label: "문서" }, + { href: "/samples", label: "예시" }, + { href: "/app/new/grade", label: "출제" }, + { href: "/login", label: "로그인" }, ]; export function PrimaryNav() { return (