From 21c5eba96cc9f311db03c43a833d3e94709ef31c Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:52:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=96=B0=E5=A2=9E=20AI=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=B9=B6=E9=9B=86=E6=88=90=E8=87=B3=E5=A4=87=E8=AF=BE?= =?UTF-8?q?/=E9=94=99=E9=A2=98=E9=9B=86/=E8=AF=95=E5=8D=B7/=E6=94=B9?= =?UTF-8?q?=E9=A2=98=E5=9B=9B=E5=A4=A7=E4=B8=9A=E5=8A=A1=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/modules/ai 独立模块,遵循三层架构(actions → services → shared/lib/ai) - 通过 AiClientProvider + useAiClient 实现 React Context 依赖注入,业务组件零直接 import - 6 个 Server Actions 均调用 requirePermission() 权限校验,返回 ActionState - withAiTracking 统一埋点,覆盖 chat/similar_question/grading_assist/lesson_content/question_variant/weakness_analysis - 集成场景:作业批改 AiGradingAssist、错题集 AiErrorBookAnalysis、备课 AiLessonContentGenerator、试卷 AiQuestionVariantGenerator - 全量 i18n(en/zh-CN ai.json),Error Boundary + Skeleton 边界处理 - 同步架构图 004/005,新增审计报告 ai-module-audit-report.md --- .../004_architecture_impact_map.md | 128 ++++- docs/architecture/005_architecture_data.json | 242 +++++++++- .../audit/ai-module-audit-report.md | 452 ++++++++++++++++++ .../(dashboard)/student/error-book/page.tsx | 127 +++++ .../teacher/exams/[id]/build/page.tsx | 72 ++- .../submissions/[submissionId]/page.tsx | 55 ++- .../lesson-plans/[planId]/edit/page.tsx | 89 +++- src/app/api/ai/chat/route.ts | 36 +- src/i18n/request.ts | 3 + src/modules/ai/actions.ts | 244 ++++++++++ src/modules/ai/components/ai-chat-panel.tsx | 182 +++++++ .../ai/components/ai-error-book-analysis.tsx | 246 ++++++++++ .../ai/components/ai-error-boundary.tsx | 88 ++++ .../ai/components/ai-grading-assist.tsx | 173 +++++++ .../ai-lesson-content-generator.tsx | 187 ++++++++ .../ai/components/ai-provider-selector.tsx | 129 +++++ .../ai-question-variant-generator.tsx | 208 ++++++++ src/modules/ai/components/ai-skeleton.tsx | 47 ++ .../ai/components/ai-suggestion-card.tsx | 164 +++++++ src/modules/ai/context/ai-client-provider.tsx | 62 +++ src/modules/ai/hooks/use-ai-chat.ts | 57 +++ src/modules/ai/hooks/use-ai-suggestion.ts | 72 +++ src/modules/ai/schema.ts | 134 ++++++ src/modules/ai/services/ai-service.ts | 346 ++++++++++++++ src/modules/ai/services/prompt-templates.ts | 154 ++++++ src/modules/ai/services/usage-tracker.ts | 83 ++++ src/modules/ai/types.ts | 194 ++++++++ .../components/error-book-detail-dialog.tsx | 353 ++++++++++++++ .../error-book/components/error-book-list.tsx | 47 ++ src/modules/exams/ai-pipeline/request.ts | 13 +- .../assembly/question-bank-list.tsx | 115 ++++- .../exams/components/exam-ai-generator.tsx | 49 +- .../exams/components/exam-assembly.tsx | 67 ++- .../components/homework-grading-view.tsx | 56 ++- src/modules/lesson-preparation/actions-ai.ts | 27 +- src/modules/lesson-preparation/ai-suggest.ts | 41 +- .../components/node-edit-panel.tsx | 87 +++- src/shared/i18n/messages/en/ai.json | 109 +++++ src/shared/i18n/messages/zh-CN/ai.json | 109 +++++ src/shared/lib/track-event.ts | 7 + 40 files changed, 4885 insertions(+), 169 deletions(-) create mode 100644 docs/architecture/audit/ai-module-audit-report.md create mode 100644 src/app/(dashboard)/student/error-book/page.tsx create mode 100644 src/modules/ai/actions.ts create mode 100644 src/modules/ai/components/ai-chat-panel.tsx create mode 100644 src/modules/ai/components/ai-error-book-analysis.tsx create mode 100644 src/modules/ai/components/ai-error-boundary.tsx create mode 100644 src/modules/ai/components/ai-grading-assist.tsx create mode 100644 src/modules/ai/components/ai-lesson-content-generator.tsx create mode 100644 src/modules/ai/components/ai-provider-selector.tsx create mode 100644 src/modules/ai/components/ai-question-variant-generator.tsx create mode 100644 src/modules/ai/components/ai-skeleton.tsx create mode 100644 src/modules/ai/components/ai-suggestion-card.tsx create mode 100644 src/modules/ai/context/ai-client-provider.tsx create mode 100644 src/modules/ai/hooks/use-ai-chat.ts create mode 100644 src/modules/ai/hooks/use-ai-suggestion.ts create mode 100644 src/modules/ai/schema.ts create mode 100644 src/modules/ai/services/ai-service.ts create mode 100644 src/modules/ai/services/prompt-templates.ts create mode 100644 src/modules/ai/services/usage-tracker.ts create mode 100644 src/modules/ai/types.ts create mode 100644 src/modules/error-book/components/error-book-detail-dialog.tsx create mode 100644 src/modules/error-book/components/error-book-list.tsx create mode 100644 src/shared/i18n/messages/en/ai.json create mode 100644 src/shared/i18n/messages/zh-CN/ai.json diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index aab621d..30735a0 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -1686,20 +1686,30 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" > 架构变更(2026-06-21):编辑器从列表式(BlockRenderer + @dnd-kit)升级为节点图式(NodeEditor + @xyflow/react)。数据结构从 v1(blocks 数组)升级到 v2(nodes + edges 节点图),旧数据通过 `migrateV1ToV2()` 自动迁移。 +> 架构变更(2026-06-23):数据结构从 v2 升级到 v3(课文锚点画布格式)。正文节点(textbook_content)作为画布中央核心,教学节点围绕正文组织。新增锚点系统(range/point 两种锚定方式)、占位符注入算法、11 种定制节点类型。旧数据通过 `migrateV2ToV3()` 自动迁移。 + **数据结构**: - v1(已废弃,仅向后兼容读取):`{ version: 1, blocks: Block[] }` -- v2(当前):`{ version: 2, nodes: LessonPlanNode[]; edges: LessonPlanEdge[] }` - - `LessonPlanNode`:`Block` + `position: { x, y }`(画布坐标) - - `LessonPlanEdge`:`{ id, source, target, sourceHandle?, targetHandle? }`(节点间连线) +- v2(已废弃,仅向后兼容读取):`{ version: 2, nodes: LessonPlanNode[]; edges: LessonPlanEdge[] }` +- v3(当前):`{ version: 3, textbookContentNodeId: string; nodes: AnyLessonPlanNode[]; edges: AnyLessonPlanEdge[]; anchors: NodeAnchor[] }` + - `TextbookContentNode`:正文节点(`draggable: false`,画布中央,可缩放) + - `LessonPlanNode`:教学节点(`Block` + `position`,可拖动可连线) + - `NodeAnchor`:锚点(`{ id, nodeId, type: "range"|"point", start, end?, textPreview?, invalid? }`) + - `AnchorEdge`:锚点连线(教学节点 → 正文节点,默认 10% 透明度) + - `FlowEdge`:流程连线(教学节点 → 教学节点) **导出函数**: -- Data-access(`data-access.ts`):`getLessonPlans` / `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2`(v1→v2 迁移:blocks 数组转换为 nodes + 线性 edges)/ `normalizeDocument`(规范化:确保 content 为 v2 格式,兼容旧数据) +- Data-access(`data-access.ts`):`getLessonPlans` / `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`(v3 规范化,兼容 v1/v2 旧数据)/ `buildDefaultSkeleton`(v3 默认 10 节点骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById` +- Lib(`lib/document-migration.ts`):`defaultDataForType` / `migrateV1ToV2` / `migrateV2ToV3` / `normalizeDocument` / `buildInitialContent` / `buildDefaultSkeleton` / `isTextbookContentNode` / `isAnchorEdge` / `getAnchorsForNode` / `getActiveAnchorIds` / `getAnchorEdges` +- Lib(`lib/anchor-injector.ts`):`markdownToPlainText` / `injectPlaceholders` / `parseAnchoredText` / `toCircledNumber` / `getNextPointIndex` / `relocateAnchors` / `getAnchorColor` +- Lib(`lib/node-summary.ts`):`getNodeSummary` / `getTextbookContentSummary` / `getNodeColor` / `NODE_COLORS` +- Lib(`lib/rf-mappers.ts`):`toRfNodes`(支持 textbook_content 节点)/ `toRfEdges`(区分 anchor/flow 边透明度)/ `fromRfEdges` - Data-access-versions(`data-access-versions.ts`):`getLessonPlanVersions` / `createLessonPlanVersion` / `getVersionContent` / `revertToVersion` / `pruneAutoVersions` - Data-access-templates(`data-access-templates.ts`):`getLessonPlanTemplates` / `saveAsTemplate` / `deletePersonalTemplate` - Data-access-knowledge(`data-access-knowledge.ts`):`getLessonPlansByKnowledgePoint` / `getLessonPlansByQuestion` - Publish-service(`publish-service.ts`):`publishLessonPlanHomework` - AI-suggest(`ai-suggest.ts`):`suggestKnowledgePoints` -- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` +- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction` **依赖关系**: - 依赖:`shared/*`、`@/auth`、`shared/lib/ai`、`@xyflow/react`(节点图编辑器)、`textbooks`(只读章节/知识点树)、`questions`(创建/查询题目)、`exams`(创建 exam 草稿)、`homework`(创建作业下发)、`classes`(查询教师班级)、`files`(附件) @@ -1729,13 +1739,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **文件清单**: | 文件 | 职责 | |------|------| -| `types.ts` | 类型定义(含 v1/v2 文档类型、LessonPlanNode、LessonPlanEdge) | +| `types.ts` | 类型定义(含 v1/v2/v3 文档类型、TextbookContentNode、LessonPlanNode、NodeAnchor、AnchorEdge、FlowEdge、11 种 BlockData 接口) | | `constants.ts` | 常量定义 | | `schema.ts` | Zod 验证 | -| `lib/document-migration.ts` | **纯函数**:v1→v2 迁移(migrateV1ToV2)/ 规范化(normalizeDocument)/ 初始内容构建(buildInitialContent),使用类型守卫 isV1Document/isV2Document 替代 as 断言 | -| `lib/node-summary.ts` | **纯函数**:getNodeSummary(接受翻译函数注入,支持 i18n)+ NODE_COLORS + getNodeColor | -| `lib/rf-mappers.ts` | **纯函数**:toRfNodes/toRfEdges/fromRfEdges(LessonPlanNode/Edge ↔ React Flow Node/Edge 映射) | -| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + getBlockComponent/isRichTextBlock,node-edit-panel 通过配置渲染 Block | +| `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton,10 节点 + 正文节点)/ defaultDataForType / 工具函数(isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges) | +| `lib/anchor-injector.ts` | **纯函数**:锚点注入算法(markdownToPlainText/injectPlaceholders/parseAnchoredText/toCircledNumber/getNextPointIndex/relocateAnchors/getAnchorColor) | +| `lib/node-summary.ts` | **纯函数**:getNodeSummary(支持 11 种节点类型)+ getTextbookContentSummary + NODE_COLORS + getNodeColor | +| `lib/rf-mappers.ts` | **纯函数**:toRfNodes(支持 textbook_content 节点 + 锚点回调)/ toRfEdges(区分 anchor/flow 边透明度)/ fromRfEdges | +| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + BlockRenderer(switch 渲染 11 种定制节点 + textbook_content) | | `providers/lesson-plan-provider.tsx` | **Provider + Context(P1-5/P1-7/P2-4/V2-6)**:LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口、4 个角色配置(TEACHER/ADMIN/STUDENT/PARENT)、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTracker;hooks:useLessonPlanContextSafe(返回 null 不抛错)/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe(V2-6 新增,返回 noopTracker 不抛错) | | `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现,测试可替换为 mock | | `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容;buildScopeCondition 按 scope 类型精确过滤 P0-3;V2-1:抛出 `LessonPlanDataError` 错误码;V2-3:mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate 显式映射 + isLessonPlanStatus/isTemplateType/isTemplateScope 类型守卫) | @@ -1749,14 +1760,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `publish-service.ts` | 发布作业服务(编排 homework/exams/classes,通过对方 data-access 调用,无直查跨模块表;V2-1:抛出 `PublishServiceError` 错误码;V2-3:显式字段映射替代 `as unknown as`) | | `ai-suggest.ts` | AI 知识点建议服务 | | `seed-templates.ts` | 模板种子数据 | -| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode) | +| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges/anchors 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode + 锚点操作 addAnchor/removeAnchor/updateAnchor + 正文节点操作 updateTextbookContent/getTextbookContentNode;实时拖动) | | `components/lesson-plan-list.tsx` | 课案列表(i18n 已接入) | | `components/lesson-plan-card.tsx` | 课案卡片(i18n 已接入;V2-6:duplicate/archive 调用 tracker.track) | | `components/lesson-plan-filters.tsx` | 课案筛选器(i18n 已接入;V2-5:3 个表单元素 label htmlFor 关联) | -| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入;V2-6:handleManualSave 调用 tracker.track) | -| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入;V2-4:MiniMap 复用 getNodeColor;V2-5:role=application + 键盘导航配置) | -| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 getBlockComponent + LessonPlanErrorBoundary 包裹,i18n 已接入) | -| `components/nodes/lesson-node.tsx` | **自定义节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) | +| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入;V2-6:handleManualSave 调用 tracker.track;V3:顶部工具栏显示教材/章节标题指示器) | +| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入;V2-4:MiniMap 复用 getNodeColor;V2-5:role=application + 键盘导航配置;V3:注册 textbook_content 节点类型 + 锚点回调 + 实时拖动) | +| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 BlockRenderer + LessonPlanErrorBoundary 包裹,i18n 已接入;V3:处理 textbook_content 节点,教学节点类型收窄) | +| `components/nodes/lesson-node.tsx` | **自定义教学节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) | +| `components/nodes/textbook-content-node.tsx` | **正文节点组件**(V3 新增):ReactMarkdown 渲染正文 + 锚点注入 + 文本选择(range 锚定)+ 点击位置(point 锚定)+ 缩放控制 + 锚点浮动菜单 | | `components/lesson-plan-error-boundary.tsx` | **错误边界**:LessonPlanErrorBoundary 类组件,支持 fallback 和 onError 回调 | | `components/lesson-plan-skeleton.tsx` | **骨架屏**:VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton | | `components/block-renderer.tsx` | ⚠️ @deprecated Block 渲染器(已被 NodeEditor 替代,保留向后兼容) | @@ -1804,12 +1816,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | teaching_head | `grade_managed` | 查看所管年级学生的错题分析 | **核心算法:SM-2 间隔重复(简化版)**: +- 独立纯函数模块:[sm2-algorithm.ts](file:///e:/Desktop/CICD/src/modules/error-book/sm2-algorithm.ts)(可替换为其他算法如 FSRS) - 4 级评级:`again`(重来)/ `hard`(困难)/ `good`(良好)/ `easy`(简单) - 初始间隔:1/2/4/7 天 - 间隔增长:`again` 重置为 1 天;`hard` ×1.2;`good` ×1.5;`easy` ×2 - 掌握度:0-5 级,`again` -1,`hard` ±0,`good` +1,`easy` +2 - 已掌握判定:掌握度 ≥5 或连续答对 ≥3 次 - 复习时间:次日早上 9 点 +- 单元测试:[sm2-algorithm.test.ts](file:///e:/Desktop/CICD/src/modules/error-book/sm2-algorithm.test.ts)(39 个测试用例,全部通过) +- 导出函数:`calculateNewInterval` / `calculateNewMastery` / `deriveStatus` / `calculateNextReviewAt` / `calculateNewCorrectStreak` / `calculateSm2Result` **自动采集机制**: - `collectFromExamSubmission`:从考试提交记录中筛选得分 < 满分的题目,去重后批量插入 @@ -1820,7 +1835,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | 文件 | 行数 | 职责 | |------|------|------| | `actions.ts` | ~180 | 9 个 Server Actions,全部使用 `requirePermission()` + `ActionState` | -| `data-access.ts` | ~960 | 16 个数据访问函数 + SM-2 算法实现 + 自动采集逻辑 | +| `data-access.ts` | ~960 | 16 个数据访问函数 + 自动采集逻辑(SM-2 算法已提取到独立模块) | +| `sm2-algorithm.ts` | ~180 | SM-2 间隔重复算法(独立纯函数模块,可替换,支持时间注入测试) | +| `sm2-algorithm.test.ts` | ~280 | SM-2 算法单元测试(39 个测试用例,覆盖所有函数和边界条件) | | `schema.ts` | ~60 | 4 个 Zod 验证 schema | | `types.ts` | ~120 | 6 个类型定义 + 状态映射常量 + 错误标签常量 | | `components/error-book-stats-cards.tsx` | ~80 | 5 个统计卡片(总数/待学习/学习中/已掌握/待复习) | @@ -1857,6 +1874,85 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" --- +## 2.29 ai(AI 模块)— ✅ 新增 + +**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。 + +**架构定位**: +- 位于 `modules/` 层,通过 `shared/lib/ai` 调用底层 AI SDK +- 通过 `AiClientProvider`(React Context)向客户端组件注入 Server Action 引用 +- 业务模块不直接 import `ai/actions`,仅通过 Context 消费 + +**核心导出**: + +| 类型 | 名称 | 文件 | 说明 | +|------|------|------|------| +| **Server Actions** | `aiChatAction` | `modules/ai/actions.ts` | AI 对话(权限:AI_CHAT) | +| **Server Actions** | `suggestSimilarQuestionsAction` | `modules/ai/actions.ts` | 相似题推荐(权限:AI_CHAT + ERROR_BOOK_READ) | +| **Server Actions** | `suggestGradingAction` | `modules/ai/actions.ts` | AI 辅助批改(权限:AI_CHAT + HOMEWORK_GRADE) | +| **Server Actions** | `generateLessonContentAction` | `modules/ai/actions.ts` | 备课内容生成(权限:AI_CHAT + LESSON_PLAN_READ) | +| **Server Actions** | `generateQuestionVariantAction` | `modules/ai/actions.ts` | 题目变体生成(权限:AI_CHAT + EXAM_AI_GENERATE) | +| **Server Actions** | `analyzeWeaknessAction` | `modules/ai/actions.ts` | 薄弱点分析(权限:AI_CHAT + ERROR_BOOK_READ) | +| **Service** | `AiService` | `modules/ai/types.ts` | 服务端 AI 服务接口(chat/suggestSimilarQuestions/suggestGrading/generateLessonContent/generateQuestionVariant/analyzeWeakness) | +| **Service** | `AiClientService` | `modules/ai/types.ts` | 客户端 AI 服务接口(Server Action 引用集合) | +| **Provider** | `AiClientProvider` | `modules/ai/context/ai-client-provider.tsx` | React Context Provider,注入 AiClientService | +| **Hook** | `useAiClient` | `modules/ai/context/ai-client-provider.tsx` | 消费 AiClientService(必须在 Provider 内使用) | +| **Hook** | `useAiClientOptional` | `modules/ai/context/ai-client-provider.tsx` | 可选消费 AiClientService(未注入返回 null) | +| **Component** | `AiGradingAssist` | `modules/ai/components/ai-grading-assist.tsx` | AI 批改辅助(主观题预评分 + 反馈建议) | +| **Component** | `AiErrorBookAnalysis` | `modules/ai/components/ai-error-book-analysis.tsx` | 错题本 AI 分析(相似题 + 薄弱点) | +| **Component** | `AiLessonContentGenerator` | `modules/ai/components/ai-lesson-content-generator.tsx` | 备课内容生成器(活动/评估/讨论题/素材) | +| **Component** | `AiQuestionVariantGenerator` | `modules/ai/components/ai-question-variant-generator.tsx` | 题目变体生成器(同知识点/不同难度/不同题型) | +| **Component** | `AiChatPanel` | `modules/ai/components/ai-chat-panel.tsx` | AI 对话面板 | +| **Component** | `AiErrorBoundary` | `modules/ai/components/ai-error-boundary.tsx` | AI 功能错误边界 | +| **Component** | `AiSuggestionSkeleton` | `modules/ai/components/ai-skeleton.tsx` | AI 建议加载骨架屏 | +| **Component** | `AiProviderSelector` | `modules/ai/components/ai-provider-selector.tsx` | AI 服务商选择器 | + +**集成点**: + +| 业务模块 | 集成组件 | 页面 | 说明 | +|---------|---------|------|------| +| homework | `AiGradingAssist` | `teacher/homework/submissions/[submissionId]` | 主观题辅助评分 | +| error-book | `AiErrorBookAnalysis` | `student/error-book` | 相似题推荐 + 薄弱点分析 | +| lesson-preparation | `AiLessonContentGenerator` | `teacher/lesson-plans/[planId]/edit` | 备课内容生成 | +| exams | `AiQuestionVariantGenerator` | `teacher/exams/[id]/build` | 题目变体生成 | + +**依赖关系**: +- `modules/ai` → `shared/lib/ai`(AI SDK 封装) +- `modules/ai` → `shared/lib/auth-guard`(权限校验) +- `modules/ai` → `shared/types/permissions`(权限常量) +- `modules/ai` → `shared/types/action-state`(返回值类型) +- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入) +- 业务模块 → `modules/ai/components/*`(组合 AI 组件) + +**文件清单**: + +| 文件 | 行数 | 职责 | +|------|------|------| +| `modules/ai/types.ts` | 194 | 类型定义(AiService/AiClientService/业务场景类型) | +| `modules/ai/schema.ts` | - | Zod 验证 schema | +| `modules/ai/actions.ts` | 244 | 6 个 Server Actions(含权限校验) | +| `modules/ai/services/ai-service.ts` | - | DefaultAiService 实现 | +| `modules/ai/services/prompt-templates.ts` | - | 6 个系统提示词模板 | +| `modules/ai/services/usage-tracker.ts` | - | AI 使用量埋点 | +| `modules/ai/context/ai-client-provider.tsx` | 59 | React Context Provider + Hooks | +| `modules/ai/components/ai-grading-assist.tsx` | 173 | AI 批改辅助组件 | +| `modules/ai/components/ai-error-book-analysis.tsx` | 246 | 错题本 AI 分析组件 | +| `modules/ai/components/ai-lesson-content-generator.tsx` | - | 备课内容生成器 | +| `modules/ai/components/ai-question-variant-generator.tsx` | - | 题目变体生成器 | +| `modules/ai/components/ai-chat-panel.tsx` | - | AI 对话面板 | +| `modules/ai/components/ai-error-boundary.tsx` | - | AI 错误边界 | +| `modules/ai/components/ai-skeleton.tsx` | - | AI 骨架屏 | +| `modules/ai/components/ai-provider-selector.tsx` | - | 服务商选择器 | +| `modules/ai/hooks/use-ai-chat.ts` | - | AI 对话 Hook | +| `modules/ai/hooks/use-ai-suggestion.ts` | - | AI 建议 Hook | + +**i18n**: +- 翻译文件:`shared/i18n/messages/{locale}/ai.json` +- 命名空间:`ai` +- 包含:chat/provider/suggestion/grading/errorBook/lessonPrep/exam/error/capability + +--- + # 第三部分:已知架构问题和技术债 ## 3.1 P0 严重问题(必须立即修复) diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 8cfc48e..fc85dda 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -13100,7 +13100,7 @@ }, "lesson_preparation": { "path": "src/modules/lesson-preparation", - "description": "教师备课模块:基于教材章节创建课案(节点图编辑器 React Flow,v2 nodes+edges 数据结构),支持模板、版本管理、知识点标注、题目创建/拉取、作业发布。编辑器从列表式(BlockRenderer + @dnd-kit)升级为节点图式(NodeEditor + @xyflow/react),旧 v1 数据通过 migrateV1ToV2() 自动迁移。V2 审计修复:Server Actions i18n + 错误码模式、SYSTEM_TEMPLATES i18n 化、as unknown as 类型断言清零、a11y 深度修复、Tracker 埋点接入", + "description": "教师备课模块(v3 锚点画布):以课文正文为核心主体(textbook_content 节点置于画布中央,draggable=false 可缩放),教学目标/重难点等 11 种定制节点围绕正文组织。两种锚定机制:range(选择一段文本关联节点,未选中 opacity:0)+ point(插入带圈数字占位符,未选中 opacity:0.3)。锚点连线默认 10% 透明度,选中时完整显示。数据模型 v3:LessonPlanDocument 含 textbookContentNodeId、nodes、edges、anchors 数组。anchor-injector 实现 Markdown 纯文本偏移映射与占位符注入。实时拖动(onNodeDrag 替代 onNodeDragStop)。v1→v2→v3 链式迁移。强制选定教材+章节才能备课(template-picker 四步流程)。保留模板、版本管理、知识点标注、题目创建/拉取、作业发布能力", "exports": { "dataAccess": [ { @@ -13141,17 +13141,97 @@ { "name": "buildInitialContent", "file": "lib/document-migration.ts(data-access.ts re-export)", - "purpose": "基于模板构建初始课案内容(v2 nodes+edges)" + "purpose": "基于模板构建初始课案内容(v3 nodes+edges+anchors+textbookContentNodeId)" }, { "name": "migrateV1ToV2", "file": "lib/document-migration.ts(data-access.ts re-export)", "purpose": "v1→v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges(节点按网格布局),使用类型守卫 isV1Document/isV2Document 替代 as 断言" }, + { + "name": "migrateV2ToV3", + "file": "lib/document-migration.ts(data-access.ts re-export)", + "purpose": "v2→v3 迁移:注入 textbook_content 节点(draggable=false)、初始化 anchors 数组、设置 textbookContentNodeId;可选传入 chapterId/chapterContent 填充正文" + }, { "name": "normalizeDocument", "file": "lib/document-migration.ts(data-access.ts re-export)", - "purpose": "规范化:确保 content 为 v2 格式,兼容旧 v1 数据(自动调用 migrateV1ToV2)" + "purpose": "规范化:确保 content 为 v3 格式,兼容旧 v1/v2 数据(自动调用 migrateV1ToV2 → migrateV2ToV3 链式迁移)" + }, + { + "name": "buildDefaultSkeleton", + "file": "lib/document-migration.ts", + "purpose": "v3 默认骨架:生成 1 个 textbook_content 节点(画布中央)+ 10 个教学节点(objective/key_point/import/new_teaching/consolidation/summary/homework/blackboard/text_study/reflection)+ 默认 flow edges" + }, + { + "name": "defaultDataForType", + "file": "lib/document-migration.ts", + "purpose": "按 BlockType 返回初始 data(11 种定制节点 + rich_text),供 addNode/buildDefaultSkeleton 使用" + }, + { + "name": "isTextbookContentNode", + "file": "lib/document-migration.ts", + "purpose": "类型守卫:判断节点是否为 textbook_content 类型" + }, + { + "name": "isAnchorEdge", + "file": "lib/document-migration.ts", + "purpose": "类型守卫:判断边是否为 anchor 锚点连线(vs flow 流程连线)" + }, + { + "name": "getAnchorsForNode", + "file": "lib/document-migration.ts", + "purpose": "按 nodeId 过滤 anchors 数组" + }, + { + "name": "getActiveAnchorIds", + "file": "lib/document-migration.ts", + "purpose": "获取选中节点关联的 anchor ID 集合(用于正文渲染时高亮)" + }, + { + "name": "getAnchorEdges", + "file": "lib/document-migration.ts", + "purpose": "获取锚点连线列表(用于 rf-mappers 区分透明度策略)" + }, + { + "name": "markdownToPlainText", + "file": "lib/anchor-injector.ts", + "purpose": "Markdown 转纯文本(去除语法标记),用于建立纯文本偏移↔Markdown 偏移映射" + }, + { + "name": "injectPlaceholders", + "file": "lib/anchor-injector.ts", + "purpose": "将 anchors 注入 Markdown 正文:range 锚点包裹为 [[anchor:id]]...[[/anchor:id]],point 锚点插入 ①②③ 带圈数字" + }, + { + "name": "parseAnchoredText", + "file": "lib/anchor-injector.ts", + "purpose": "解析注入后的 Markdown 为段落数组(text/anchor-range/anchor-point),供 textbook-content-node 自定义渲染" + }, + { + "name": "getNextPointIndex", + "file": "lib/anchor-injector.ts", + "purpose": "计算下一个 point 锚点的带圈数字序号" + }, + { + "name": "toCircledNumber", + "file": "lib/anchor-injector.ts", + "purpose": "数字转带圈数字字符(1→①, 2→②, ...)" + }, + { + "name": "relocateAnchors", + "file": "lib/anchor-injector.ts", + "purpose": "正文变更后按文本相似度重定位锚点偏移量" + }, + { + "name": "getTextbooksForPicker", + "file": "data-access.ts", + "purpose": "获取教材列表(id+title),供 template-picker 选择教材" + }, + { + "name": "getChaptersForPicker", + "file": "data-access.ts", + "purpose": "按 textbookId 获取章节树(含 children 嵌套),供 template-picker 选择章节" }, { "name": "getLessonPlanVersions", @@ -13304,6 +13384,18 @@ "permission": "LESSON_PLAN_READ", "file": "actions-kp.ts", "purpose": "获取知识点选项(委托 textbooks data-access)" + }, + { + "name": "getTextbooksForPickerAction", + "permission": "LESSON_PLAN_READ", + "file": "actions.ts", + "purpose": "获取教材列表(供 template-picker 选择教材)" + }, + { + "name": "getChaptersForPickerAction", + "permission": "LESSON_PLAN_READ", + "file": "actions.ts", + "purpose": "按 textbookId 获取章节树(供 template-picker 选择章节)" } ] }, @@ -13322,6 +13414,7 @@ "constants.ts", "schema.ts", "lib/document-migration.ts", + "lib/anchor-injector.ts", "lib/node-summary.ts", "lib/rf-mappers.ts", "config/block-registry.tsx", @@ -13346,6 +13439,7 @@ "components/node-editor.tsx", "components/node-edit-panel.tsx", "components/nodes/lesson-node.tsx", + "components/nodes/textbook-content-node.tsx", "components/lesson-plan-error-boundary.tsx", "components/lesson-plan-skeleton.tsx", "components/block-renderer.tsx", @@ -13358,7 +13452,14 @@ "components/blocks/rich-text-block.tsx", "components/blocks/text-study-block.tsx", "components/blocks/exercise-block.tsx", - "components/blocks/reflection-block.tsx" + "components/blocks/reflection-block.tsx", + "components/blocks/objective-block.tsx", + "components/blocks/key-point-block.tsx", + "components/blocks/import-block.tsx", + "components/blocks/new-teaching-block.tsx", + "components/blocks/summary-block.tsx", + "components/blocks/homework-block.tsx", + "components/blocks/blackboard-block.tsx" ], "i18n": { "namespace": "lessonPreparation", @@ -15082,6 +15183,106 @@ "actions.getQuestionsAction" ] } + }, + "ai": { + "dependsOn": [ + "shared" + ], + "uses": { + "shared": [ + "db", + "auth-guard.requirePermission", + "lib.ai.createAiChatCompletion", + "types.permissions", + "types.action-state", + "lib.track-event.trackEvent", + "i18n.messages" + ] + }, + "exports": { + "actions": [ + "aiChatAction", + "suggestSimilarQuestionsAction", + "suggestGradingAction", + "generateLessonContentAction", + "generateQuestionVariantAction", + "analyzeWeaknessAction" + ], + "types": [ + "AiService", + "AiClientService", + "AiChatMessage", + "AiChatResult", + "SimilarQuestionInput", + "SimilarQuestionResult", + "GradingInput", + "GradingSuggestion", + "LessonContentInput", + "LessonContentResult", + "QuestionVariantInput", + "QuestionVariantResult", + "WeaknessAnalysisInput", + "WeaknessAnalysisResult", + "AiCapability" + ], + "components": [ + "AiGradingAssist", + "AiErrorBookAnalysis", + "AiLessonContentGenerator", + "AiQuestionVariantGenerator", + "AiChatPanel", + "AiErrorBoundary", + "AiSuggestionSkeleton", + "AiProviderSelector" + ], + "hooks": [ + "useAiClient", + "useAiClientOptional", + "useAiChat", + "useAiSuggestion" + ], + "providers": [ + "AiClientProvider" + ], + "services": [ + "DefaultAiService", + "createAiService", + "safeAiCall" + ] + }, + "integrations": { + "homework": { + "component": "AiGradingAssist", + "page": "teacher/homework/submissions/[submissionId]", + "capability": "grading-assist" + }, + "error-book": { + "component": "AiErrorBookAnalysis", + "page": "student/error-book", + "capability": "similar-question, weakness-analysis" + }, + "lesson-preparation": { + "component": "AiLessonContentGenerator", + "page": "teacher/lesson-plans/[planId]/edit", + "capability": "lesson-content" + }, + "exams": { + "component": "AiQuestionVariantGenerator", + "page": "teacher/exams/[id]/build", + "capability": "question-variant" + } + }, + "permissions": [ + "ai:chat", + "ai:configure" + ], + "i18n": { + "namespace": "ai", + "files": [ + "shared/i18n/messages/zh-CN/ai.json", + "shared/i18n/messages/en/ai.json" + ] + } } }, "moduleDependencyGraph": { @@ -15112,7 +15313,8 @@ "diagnostic", "elective", "onboarding", - "error-book" + "error-book", + "ai" ], "edges": [ { @@ -15372,6 +15574,36 @@ "to": "questions", "type": "data-access", "description": "通过 questions/actions.getQuestionsAction 查询题库(手动添加错题时)" + }, + { + "from": "ai", + "to": "shared", + "type": "normal", + "description": "使用 lib/ai.createAiChatCompletion、auth-guard.requirePermission、types.permissions、types.action-state" + }, + { + "from": "homework", + "to": "ai", + "type": "context", + "description": "通过 AiClientProvider 注入 AiClientService,使用 AiGradingAssist 组件" + }, + { + "from": "error-book", + "to": "ai", + "type": "context", + "description": "通过 AiClientProvider 注入 AiClientService,使用 AiErrorBookAnalysis 组件" + }, + { + "from": "lesson-preparation", + "to": "ai", + "type": "context", + "description": "通过 AiClientProvider 注入 AiClientService,使用 AiLessonContentGenerator 组件" + }, + { + "from": "exams", + "to": "ai", + "type": "context", + "description": "通过 AiClientProvider 注入 AiClientService,使用 AiQuestionVariantGenerator 组件" } ] }, diff --git a/docs/architecture/audit/ai-module-audit-report.md b/docs/architecture/audit/ai-module-audit-report.md new file mode 100644 index 0000000..3320dfa --- /dev/null +++ b/docs/architecture/audit/ai-module-audit-report.md @@ -0,0 +1,452 @@ +# AI 模块审计报告 + +> 审计范围:项目中所有与 AI(人工智能)相关的代码,包括底层 SDK 封装、Provider 管理、各业务模块(备课、错题集、试卷、改题等)中的 AI 集成点。 +> 审计日期:2026-06-23 +> 审计依据:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、`docs/standards/coding-standards.md` + +--- + +## 一、现有实现概要 + +### 1.1 文件分布 + +AI 相关代码当前**未形成独立模块**,而是分散在 5 个不同位置: + +| 位置 | 文件 | 行数 | 职责 | +|------|------|------|------| +| `src/shared/lib/ai/` | `api-key-crypto.ts` | 28 | AES-256-GCM 加密 API Key | +| `src/shared/lib/ai/` | `client.ts` | 58 | OpenAI SDK 封装,创建 chat completion | +| `src/shared/lib/ai/` | `errors.ts` | 8 | 错误消息格式化 | +| `src/shared/lib/ai/` | `payload-parser.ts` | 78 | 请求负载解析与 Zod 守卫 | +| `src/shared/lib/ai/` | `provider-config.ts` | 61 | 从 `ai_providers` 表查询 Provider 配置 | +| `src/shared/lib/ai/` | `index.ts` | 5 | 聚合导出 | +| `src/shared/lib/ai.ts` | — | 9 | 向后兼容重导出 | +| `src/app/api/ai/chat/` | `route.ts` | 42 | AI 聊天 REST API 端点 | +| `src/modules/exams/ai-pipeline/` | `parse.ts` | 426 | Zod schema、JSON 提取修复、提示词 | +| `src/modules/exams/ai-pipeline/` | `request.ts` | 306 | AI 请求构造与发送 | +| `src/modules/exams/ai-pipeline/` | `structure.ts` | 209 | 结构生成与预览/草稿转换 | +| `src/modules/exams/ai-pipeline/` | `index.ts` | 172 | 高层编排 | +| `src/modules/lesson-preparation/` | `actions-ai.ts` | 44 | 知识点推荐 Server Action | +| `src/modules/lesson-preparation/` | `ai-suggest.ts` | 65 | 知识点推荐 AI 逻辑 | +| `src/modules/settings/` | `actions.ts`(部分) | ~183 | AI Provider CRUD Action | +| `src/modules/settings/` | `data-access.ts`(部分) | — | `ai_providers` 表查询 | +| `src/modules/exams/components/` | `exam-ai-generator.tsx` | 224 | AI 出题 UI 组件 | + +### 1.2 数据流 + +``` +前端组件 (exam-ai-generator.tsx) + └─▶ Server Action (exams/actions.ts: createAiExamAction) + └─▶ ai-pipeline.generateAiCreateDraftFromSource() + ├─▶ requestAiExamStructureDraft() → createAiChatCompletion() + │ └─▶ OpenAI SDK + db.query.aiProviders + └─▶ parseQuestionDetail() → createAiChatCompletion() + +前端组件 (lesson-preparation hooks) + └─▶ suggestKnowledgePointsAction() + └─▶ ai-suggest.suggestKnowledgePoints() + ├─▶ textbooks/data-access.getKnowledgePointsByTextbookId() [跨模块] + └─▶ createAiChatCompletion() + +前端组件 (settings) + └─▶ upsertAiProviderAction() / testAiProviderAction() + └─▶ settings/data-access (ai_providers 表) +``` + +### 1.3 架构图记录情况 + +- `005_architecture_data.json` 中 `modules` 节点**未将 AI 列为独立模块**。 +- 仅在 `dbTables.aiProviders` 中记录 `usedBy: ["settings", "ai"]`,但 `ai` 并非真实存在的模块。 +- `shared` 模块下记录了 `lib/ai/*` 工具函数(`createAiChatCompletion`、`parseAiChatPayload` 等)。 +- `exams` 模块下记录了 `ai-pipeline` 子目录的导出函数。 +- `lessonPreparation` 模块下记录了 `suggestKnowledgePointsAction`。 +- **结论:架构图对 AI 模块的记录不完整,未反映 AI 作为横切关注点的全貌,也未记录 `app/api/ai/chat/route.ts` 端点。** + +### 1.4 权限点 + +| 权限常量 | 值 | 用途 | +|----------|----|------| +| `AI_CHAT` | `ai:chat` | 使用 AI 聊天 | +| `AI_CONFIGURE` | `ai:configure` | 配置 AI Provider | +| `EXAM_AI_GENERATE` | `exam:ai_generate` | AI 出题 | + +--- + +## 二、现存问题与原因分析 + +### 2.1 架构分层问题 + +#### 问题 2.1.1:AI 未形成独立模块,逻辑分散在 5 处 + +- **位置**:`shared/lib/ai/`、`app/api/ai/chat/`、`modules/exams/ai-pipeline/`、`modules/lesson-preparation/ai-suggest.ts`、`modules/settings/` +- **原因**:AI 能力是按业务需求逐步添加的,每次新增场景都在调用方就地实现,未抽象为独立模块。 +- **后果**:AI 逻辑无法统一治理(限流、监控、成本控制、Prompt 版本管理);新增 AI 场景需要重复编写请求构造与错误处理;测试时无法 Mock AI 层。 +- **违反规则**:`项目规则 → 架构分层规则 → 模块标准结构`(AI 应作为 `modules/ai/` 独立模块存在)。 + +#### 问题 2.1.2:AI 聊天使用 REST API 路由而非 Server Action + +- **位置**:[route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts) +- **原因**:早期实现选择了 REST 路由,未遵循项目 Server Action 统一规范。 +- **后果**:与项目其他数据操作风格不一致;无法复用 `ActionState` 返回类型与 `useActionMutation` Hook;权限校验绕过了 `requirePermission()` 体系。 +- **违反规则**:`项目规则 → Server Action 规范`(所有数据操作应通过 Server Action,返回 `ActionState`)。 + +#### 问题 2.1.3:`lesson-preparation/ai-suggest.ts` 跨模块直接依赖 + +- **位置**:[ai-suggest.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts#L6-L8) +- **现状**:直接 `import { getKnowledgePointsByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access"`。 +- **判定**:模块间通过对方 data-access 通信**符合规则**,但 AI 推荐逻辑本身应属于 AI 模块,而非备课模块。当前 `ai-suggest.ts` 混合了"AI 调用"与"知识点候选获取"两个职责。 +- **后果**:若其他模块也需要"基于文本推荐知识点",无法复用。 +- **违反规则**:`项目规则 → 架构分层规则`(职责划分不清)。 + +### 2.2 权限问题 + +#### 问题 2.2.1:AI 聊天端点缺少 `requirePermission()` 校验 + +- **位置**:[route.ts:15-18](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts#L15-L18) +- **现状**:仅检查 `session?.user?.id` 是否存在,**未调用 `requirePermission(Permissions.AI_CHAT)`**。 +- **后果**:任何已登录用户(包括学生)都能无限制调用 AI 聊天,绕过了角色权限体系;无法按角色限制 AI 使用场景。 +- **违反规则**:`项目规则 → Server Action 规范 → 每个 Action 必须调用 requirePermission()`;`项目规则 → 安全规范`。 + +#### 问题 2.2.2:AI 出题管线内部无权限二次校验 + +- **位置**:`exams/ai-pipeline/index.ts` 的 `generateAiCreateDraftFromSource` +- **现状**:依赖调用方 Action 校验权限,管线本身不校验。 +- **后果**:若未来有新调用方忘记校验,将导致越权调用 AI。 +- **违反规则**:`项目规则 → 安全规范 → Server Action 二次校验`。 + +### 2.3 国际化问题 + +#### 问题 2.3.1:`exam-ai-generator.tsx` 大量硬编码文本 + +- **位置**:[exam-ai-generator.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-ai-generator.tsx) +- **硬编码中文**:第 118 行"新建配置"、第 164 行"加入后台队列(运行 ${...}/3,排队 ${...})"、第 167 行"立即预览"/"Generating..."、第 192 行"后台生成记录"、第 202-207 行"排队中"/"生成中"/"已完成"/"失败:..."、第 211 行"打开预览"。 +- **硬编码英文**:第 92 行"AI Generation"、第 93-95 行描述、第 104 行"AI Provider"、第 122-124 行对话框标题、第 144 行"Loading providers..."/"Select provider"、第 156 行描述、第 175 行"Source Exam Text"、第 178 行 placeholder、第 184 行描述。 +- **后果**:无法切换语言;违反 i18n 就绪要求。 +- **违反规则**:`项目规则 → 所有用户可见文本必须适配 i18n`。 + +#### 问题 2.3.2:AI 管线内部硬编码中文错误消息 + +- **位置**:[request.ts:152](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/request.ts#L152) "请先粘贴试卷文本"、第 172 行"试卷文本校验失败,请重试"、第 177 行"识别为乱码或混乱文本..."。 +- **后果**:错误消息无法国际化。 +- **违反规则**:`项目规则 → i18n`。 + +#### 问题 2.3.3:无独立 `ai.json` 翻译文件 + +- **现状**:AI 相关翻译散落在 `settings.json`(Provider 管理)和 `lesson-preparation.json`(`error.aiSuggest`),无统一命名空间。 +- **后果**:AI 文本难以维护与查找。 + +### 2.4 类型安全问题 + +#### 问题 2.4.1:`ai-suggest.ts` 使用 `as` 断言 + +- **位置**:[ai-suggest.ts:54](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts#L54) +- **代码**:`JSON.parse(jsonMatch[0]) as { id: string; name: string; reason: string }[]` +- **后果**:AI 返回的 JSON 结构不可信,直接断言可能导致运行时错误。 +- **违反规则**:`项目规则 → TypeScript 规则 → 禁止 as 断言`。 + +#### 问题 2.4.2:`actions-ai.ts` 双重断言 + +- **位置**:[actions-ai.ts:34](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions-ai.ts#L34) +- **代码**:`parsed.data.doc as unknown as LessonPlanDocument` +- **后果**:绕过类型系统,不安全。 +- **违反规则**:`项目规则 → TypeScript 规则 → 禁止 as 断言`。 + +### 2.5 错误处理问题 + +#### 问题 2.5.1:`ai-suggest.ts` 静默吞掉错误 + +- **位置**:[ai-suggest.ts:50-64](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts#L50-L64) +- **现状**:`try { JSON.parse(...) } catch { return [] }` — JSON 解析失败时静默返回空数组。 +- **后果**:教师无法区分"AI 未推荐任何知识点"与"AI 返回格式错误";无法排查问题。 +- **违反规则**:`项目规则 → 错误处理`。 + +#### 问题 2.5.2:无 AI 专用 Error Boundary + +- **现状**:AI 组件(如 `exam-ai-generator`)未用 Error Boundary 包裹。 +- **后果**:AI 调用失败可能导致整个页面崩溃。 +- **违反规则**:审计要求 → 每个独立数据区块必须用 React Error Boundary 包裹。 + +#### 问题 2.5.3:无 Suspense/骨架屏 + +- **现状**:AI 异步操作仅用 `loading` 布尔值切换按钮文字,无骨架屏。 +- **后果**:用户体验差,无法感知加载进度。 + +### 2.6 可复用性问题 + +#### 问题 2.6.1:无可复用 AI 组件 + +- **现状**: + - AI Provider 选择器硬编码在 `exam-ai-generator.tsx` 内部,无法在其他模块复用。 + - 无通用 AI 聊天面板组件。 + - 无通用 AI 建议加载器组件。 + - 无通用 AI 结果预览组件。 +- **后果**:每个需要 AI 的模块都要从零实现 UI。 +- **违反规则**:审计要求 → 最大化复用。 + +#### 问题 2.6.2:无 AI 服务接口抽象 + +- **现状**:所有模块直接 `import { createAiChatCompletion } from "@/shared/lib/ai"`。 +- **后果**:无法 Mock AI 服务进行单测;无法切换 AI 实现(如本地 mock、不同 SDK)。 +- **违反规则**:审计要求 → 完全解耦、可测试性。 + +### 2.7 功能缺失问题 + +#### 问题 2.7.1:错题集无 AI 集成 + +- **现状**:`error-book` 模块仅有 SM2 间隔复习算法,无 AI 能力。 +- **缺失功能**: + - AI 相似题推荐(根据错题生成同类练习) + - AI 薄弱点分析(根据错题分布分析学生薄弱知识点) + - AI 解题思路生成(为错题生成分步骤解析) + - AI 复习计划建议(基于错题掌握度智能调整复习节奏) +- **后果**:错题本仅是静态记录,无法发挥 AI 的个性化学习价值。 + +#### 问题 2.7.2:改题(作业批改)无 AI 集成 + +- **现状**:`homework-grading-view.tsx` 仅支持手动评分与自动判分(选择题),无 AI 辅助。 +- **缺失功能**: + - AI 辅助批改主观题(简答题/论述题) + - AI 生成评分反馈建议 + - AI 批改一致性校验(检测人工评分偏差) +- **后果**:教师批改主观题负担重,效率低。 + +#### 问题 2.7.3:备课 AI 能力单一 + +- **现状**:`lesson-preparation` 仅有"知识点推荐"一个 AI 功能。 +- **缺失功能**: + - AI 生成教学活动设计 + - AI 生成课堂提问 + - AI 生成形成性评估 + - AI 生成差异化教学建议 +- **后果**:AI 价值未充分释放。 + +#### 问题 2.7.4:试卷 AI 无题目变体与智能组卷 + +- **现状**:`exams/ai-pipeline` 仅支持"从文本解析生成试卷"。 +- **缺失功能**: + - AI 生成题目变体(基于已有题目生成同知识点不同表述的变体) + - AI 智能组卷(根据知识点覆盖、难度分布自动组卷) + - AI 难度分析(预测题目难度) +- **后果**:AI 出题场景受限。 + +### 2.8 性能与监控问题 + +#### 问题 2.8.1:无流式响应 + +- **现状**:所有 AI 调用等待完整响应才返回。 +- **后果**:长文本生成时用户体验差(等待 10-30 秒)。 +- **违反规则**:审计要求 → 性能:支持流式渲染。 + +#### 问题 2.8.2:无 AI 使用监控 + +- **现状**:无 AI 调用埋点、无成本统计、无延迟监控、无错误率监控。 +- **后果**:无法优化 AI 使用策略,无法发现异常调用。 +- **违反规则**:审计要求 → 监控:预留关键操作埋点接口。 + +### 2.9 可访问性问题 + +#### 问题 2.9.1:AI 组件缺少 ARIA 属性 + +- **位置**:`exam-ai-generator.tsx` 的后台任务列表无 `aria-live`,屏幕阅读器无法感知状态变化。 +- **违反规则**:审计要求 → a11y:ARIA 属性。 + +--- + +## 三、行业差距对比 + +### 3.1 与优秀 K12 产品的差距 + +| 能力 | 行业主流做法 | 当前状态 | 差距影响 | +|------|-------------|---------|---------| +| **AI 助手入口** | 全局悬浮按钮/侧边栏,可从任何页面唤起 AI 助手 | 无全局入口,仅嵌入特定页面 | 用户无法在需要时随时获取 AI 帮助 | +| **上下文感知** | AI 助手自动感知当前页面上下文(如正在批改的作业) | 无上下文感知 | AI 建议不精准,需用户手动输入上下文 | +| **流式输出** | AI 回复逐字流式显示 | 等待完整响应 | 长文本等待体验差 | +| **错题 AI 推荐** | 根据错题自动生成同类练习题,支持"再练一题" | 无此功能 | 学生无法针对性巩固薄弱点 | +| **AI 辅助批改** | 主观题 AI 预评分 + 教师确认 | 无此功能 | 教师批改负担重 | +| **学习路径推荐** | AI 根据错题与掌握度生成个性化学习路径 | 无此功能 | 缺少个性化学习引导 | +| **AI 内容安全** | 学生侧 AI 输出经过内容过滤 | 无过滤机制 | 学生可能接触不当内容 | +| **AI 使用历史** | 用户可查看自己的 AI 对话历史 | 无此功能 | 无法回顾 AI 建议结果 | +| **多 Provider 对比** | 同一 Prompt 可对比不同模型输出 | 仅支持选择单一 Provider | 无法评估最优模型 | +| **Prompt 版本管理** | Prompt 模板可配置化、版本化 | Prompt 硬编码在代码中 | 调整 Prompt 需改代码发版 | + +### 3.2 多角色体验差距 + +| 角色 | 期望的 AI 能力 | 当前状态 | +|------|---------------|---------| +| **教师** | 备课内容生成、出题辅助、批改辅助、学情分析 | 仅有知识点推荐 + 试卷解析 | +| **学生** | 错题相似题推荐、解题思路、学习路径 | 无任何 AI 能力 | +| **家长** | 子女学情 AI 摘要、辅导建议 | 无任何 AI 能力 | +| **管理员** | AI 使用统计、成本监控 | 无任何 AI 能力 | + +--- + +## 四、改进优先级建议 + +### P0(紧急,影响安全与基础架构) + +| 编号 | 问题 | 改进方向 | +|------|------|---------| +| P0-1 | AI 聊天端点缺少权限校验 | 改造为 Server Action,添加 `requirePermission(AI_CHAT)` | +| P0-2 | AI 未形成独立模块 | 创建 `src/modules/ai/`,将分散的 AI 逻辑统一收口 | +| P0-3 | `exam-ai-generator.tsx` 硬编码文本 | 提取 i18n 键,创建 `ai.json` 翻译文件 | +| P0-4 | AI 管线硬编码错误消息 | 通过 Server Action 层返回 i18n 错误键 | +| P0-5 | `ai-suggest.ts` 使用 `as` 断言 | 用 Zod schema 校验 AI 返回 | + +### P1(重要,影响功能完整性与可维护性) + +| 编号 | 问题 | 改进方向 | +|------|------|---------| +| P1-1 | 无 AI 服务接口抽象 | 定义 `AiService` 接口,通过 React Context 注入 | +| P1-2 | 无可复用 AI 组件 | 抽象 `AiChatPanel`、`AiProviderSelector`、`AiSuggestionCard`、`AiErrorBoundary` | +| P1-3 | 无 AI Error Boundary | 创建 `AiErrorBoundary` 包裹所有 AI 区块 | +| P1-4 | 错题集无 AI 集成 | 新增相似题推荐、薄弱点分析 Server Action | +| P1-5 | 改题无 AI 集成 | 新增 AI 辅助批改 Action | +| P1-6 | 无 AI 使用监控 | 预留 `trackAiUsage()` 埋点接口 | +| P1-7 | 备课 AI 能力单一 | 新增内容生成、活动建议 Action | + +### P2(优化,提升体验与扩展性) + +| 编号 | 问题 | 改进方向 | +|------|------|---------| +| P2-1 | 无流式响应 | 支持 SSE 流式输出 | +| P2-2 | 无 AI 对话历史 | 持久化用户 AI 对话记录 | +| P2-3 | Prompt 硬编码 | 抽取为可配置 Prompt 模板 | +| P2-4 | 试卷 AI 无变体生成 | 新增题目变体生成 Action | +| P2-5 | 无多 Provider 对比 | 支持并行调用多 Provider 对比 | +| P2-6 | 无内容安全过滤 | 学生侧 AI 输出添加内容过滤 | +| P2-7 | 架构图未记录 AI 模块 | 同步更新 004/005 文档 | + +--- + +## 五、架构图同步说明 + +本次审计发现架构图存在以下遗漏与不一致,需在实现后同步更新: + +### 5.1 需新增的节点 + +| 文档 | 节点路径 | 内容 | +|------|---------|------| +| `005_architecture_data.json` | `modules.ai` | 新增 AI 模块定义:path、description、exports(AiService 接口、Actions、组件) | +| `005_architecture_data.json` | `modules.ai.exports.functions` | `createAiChatAction`、`suggestSimilarQuestionsAction`、`suggestGradingAction`、`generateLessonContentAction`、`generateQuestionVariantAction` | +| `005_architecture_data.json` | `modules.ai.exports.components` | `AiChatPanel`、`AiProviderSelector`、`AiSuggestionCard`、`AiErrorBoundary` | +| `005_architecture_data.json` | `modules.ai.exports.hooks` | `useAiChat`、`useAiSuggestion` | +| `005_architecture_data.json` | `dependencyMatrix.ai` | ai → shared、ai → settings(data-access);exams/lesson-preparation/error-book/homework → ai | +| `004_architecture_impact_map.md` | 模块清单 | 新增"AI 模块"章节 | +| `004_architecture_impact_map.md` | 文件清单 | 新增 `modules/ai/` 下所有文件 | + +### 5.2 需修改的节点 + +| 文档 | 节点 | 修改内容 | +|------|------|---------| +| `005_architecture_data.json` | `dbTables.aiProviders.usedBy` | 从 `["settings", "ai"]` 改为 `["ai"]`(AI 模块收口后由 AI 模块负责) | +| `005_architecture_data.json` | `modules.shared.exports` | 标注 `lib/ai/*` 为"底层 SDK 封装,业务层应调用 `modules/ai`" | +| `005_architecture_data.json` | `modules.exams.ai-pipeline` | 标注依赖关系变更为"通过 ai 模块服务调用" | +| `005_architecture_data.json` | `routes` | 移除 `app/api/ai/chat/route.ts`(改造为 Server Action 后删除) | +| `004_architecture_impact_map.md` | 调用链路图 | 更新 AI 调用链路:业务模块 → ai/actions → ai/services → shared/lib/ai | + +### 5.3 需删除的节点 + +| 文档 | 节点 | 原因 | +|------|------|------| +| `005_architecture_data.json` | `routes./api/ai/chat` | 改造为 Server Action 后该 REST 路由删除 | + +--- + +## 六、重构方案设计(概要) + +> 详细实现见代码提交,此处仅列出设计要点。 + +### 6.1 模块结构 + +``` +src/modules/ai/ +├─ types.ts # AiService 接口、AiChatMessage、AiSuggestion 等类型 +├─ schema.ts # Zod 校验(chat、suggest、grading 等) +├─ data-access.ts # ai_providers 表查询(从 settings 迁移) +├─ services/ +│ ├─ ai-service.ts # AiService 接口实现(封装 createAiChatCompletion) +│ ├─ prompt-templates.ts # 可配置 Prompt 模板 +│ └─ usage-tracker.ts # AI 使用埋点 +├─ actions.ts # Server Actions(chat、suggestSimilar、suggestGrading、generateLessonContent) +├─ context/ +│ └─ ai-provider.tsx # React Context + Provider(依赖注入 AiService) +├─ components/ +│ ├─ ai-chat-panel.tsx # 通用 AI 聊天面板(支持流式) +│ ├─ ai-provider-selector.tsx # Provider 选择器(复用) +│ ├─ ai-suggestion-card.tsx # 建议卡片 +│ ├─ ai-error-boundary.tsx # AI 专用 Error Boundary +│ └─ ai-skeleton.tsx # AI 加载骨架屏 +└─ hooks/ + ├─ use-ai-chat.ts # AI 聊天 Hook + └─ use-ai-suggestion.ts # AI 建议 Hook +``` + +### 6.2 依赖注入 + +```typescript +// types.ts +export interface AiService { + chat(messages: AiChatMessage[], options?: AiChatOptions): Promise + suggestSimilarQuestions(input: SimilarQuestionInput): Promise + suggestGrading(input: GradingInput): Promise + generateLessonContent(input: LessonContentInput): Promise +} + +// context/ai-provider.tsx +const AiContext = createContext(null) +export function AiServiceProvider({ children, service }: { children: ReactNode; service: AiService }) { ... } +export function useAiService(): AiService { ... } +``` + +### 6.3 i18n 结构 + +```json +// ai.json +{ + "chat": { + "title": "AI Assistant", + "placeholder": "Ask anything...", + "sending": "Sending...", + "error": "AI request failed" + }, + "provider": { + "selector": { "label": "AI Provider", "placeholder": "Select provider" }, + "manage": { "label": "Manage", "title": "AI Provider Settings" } + }, + "suggestion": { + "loading": "AI is thinking...", + "empty": "No suggestions", + "retry": "Retry" + }, + "errorBook": { + "similarQuestions": "Similar Questions", + "weaknessAnalysis": "Weakness Analysis" + }, + "grading": { + "aiSuggest": "AI Grading Suggestion", + "applyScore": "Apply Score", + "applyFeedback": "Apply Feedback" + }, + "lessonPrep": { + "generateContent": "Generate Content", + "generateActivity": "Suggest Activity" + }, + "exam": { + "generate": "Generate", + "queue": "Add to Queue", + "preview": "Preview" + } +} +``` + +### 6.4 配置驱动 + +```typescript +// 角色配置决定可用 AI 能力 +const AI_CAPABILITY_CONFIG: Record = { + admin: ["chat", "usage-stats"], + teacher: ["chat", "exam-generate", "grading-assist", "lesson-content", "question-variant"], + student: ["chat", "similar-question", "study-path"], + parent: ["chat", "child-summary"], +} +``` diff --git a/src/app/(dashboard)/student/error-book/page.tsx b/src/app/(dashboard)/student/error-book/page.tsx new file mode 100644 index 0000000..2415d7d --- /dev/null +++ b/src/app/(dashboard)/student/error-book/page.tsx @@ -0,0 +1,127 @@ +import type { JSX } from "react" +import { Suspense } from "react" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { Skeleton } from "@/shared/components/ui/skeleton" + +import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access" +import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards" +import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters" +import { ErrorBookList } from "@/modules/error-book/components/error-book-list" +import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog" +import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types" +import { + AiClientProvider, + type AiClientService, +} from "@/modules/ai/context/ai-client-provider" +import { + aiChatAction, + suggestSimilarQuestionsAction, + suggestGradingAction, + generateLessonContentAction, + generateQuestionVariantAction, + analyzeWeaknessAction, +} from "@/modules/ai/actions" + +export const dynamic = "force-dynamic" + +const VALID_STATUS = new Set(["new", "learning", "mastered", "archived"]) +const VALID_SOURCE = new Set(["exam", "homework", "manual"]) + +function parseStatus(v?: string): ErrorBookStatusValue | undefined { + return v && VALID_STATUS.has(v) ? (v as ErrorBookStatusValue) : undefined +} + +function parseSource(v?: string): ErrorBookSourceTypeValue | undefined { + return v && VALID_SOURCE.has(v) ? (v as ErrorBookSourceTypeValue) : undefined +} + +/** + * 构建 AI 客户端服务(Server Action 引用集合) + * + * 通过 React Context 注入,客户端组件不直接 import actions, + * 遵循依赖注入模式,便于测试时替换为 mock。 + */ +function createAiClientService(): AiClientService { + return { + chat: aiChatAction, + suggestSimilarQuestions: suggestSimilarQuestionsAction, + suggestGrading: suggestGradingAction, + generateLessonContent: generateLessonContentAction, + generateQuestionVariant: generateQuestionVariantAction, + analyzeWeakness: analyzeWeaknessAction, + } +} + +async function ErrorBookResults({ searchParams }: { searchParams: Promise }): Promise { + const params = await searchParams + const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + + const q = getParam(params, "q") + const status = parseStatus(getParam(params, "status")) + const sourceType = parseSource(getParam(params, "source")) + const dueOnly = getParam(params, "due") === "due" + + const { data: items } = await getErrorBookItems({ + studentId: ctx.userId, + q: q || undefined, + status, + sourceType, + dueOnly, + pageSize: 50, + }) + + return +} + +function ErrorBookResultsFallback() { + return ( +
+ {Array.from({ length: 4 }).map((_, idx) => ( + + ))} +
+ ) +} + +export default async function StudentErrorBookPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) + const stats = await getErrorBookStats(ctx.userId) + const aiClientService = createAiClientService() + + return ( + +
+
+
+

错题本

+

+ 自动收录考试与作业中的错题,科学复习,攻克薄弱点。 +

+
+
+ +
+
+ + + +
+ }> + + + + }> + + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx index 0943391..cb7a678 100644 --- a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx +++ b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx @@ -7,13 +7,45 @@ import { normalizeStructure } from "@/modules/exams/utils/normalize-structure" import type { Question } from "@/modules/questions/types" import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list" import { createId } from "@paralleldrive/cuid2" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { + AiClientProvider, + type AiClientService, +} from "@/modules/ai/context/ai-client-provider" +import { + aiChatAction, + suggestSimilarQuestionsAction, + suggestGradingAction, + generateLessonContentAction, + generateQuestionVariantAction, + analyzeWeaknessAction, +} from "@/modules/ai/actions" export const dynamic = "force-dynamic" +/** + * 构建 AI 客户端服务(Server Action 引用集合) + * + * 通过 React Context 注入,客户端组件不直接 import actions, + * 遵循依赖注入模式,便于测试时替换为 mock。 + */ +function createAiClientService(): AiClientService { + return { + chat: aiChatAction, + suggestSimilarQuestions: suggestSimilarQuestionsAction, + suggestGrading: suggestGradingAction, + generateLessonContent: generateLessonContentAction, + generateQuestionVariant: generateQuestionVariantAction, + analyzeWeakness: analyzeWeaknessAction, + } +} + export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params - const exam = await getExamById(id) + const ctx = await requirePermission(Permissions.EXAM_READ) + const exam = await getExamById(id, ctx.dataScope) if (!exam) return notFound() // Fetch initial questions for the bank (pagination handled by client) @@ -69,24 +101,28 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id: })) } + const aiClientService = createAiClientService() + return ( -
-
-

Build Exam

-

Assemble questions for your exam.

+ +
+
+

Build Exam

+

Assemble questions for your exam.

+
+
- -
+ ) } diff --git a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx index cef8681..a57465a 100644 --- a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx +++ b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx @@ -3,15 +3,46 @@ import { notFound } from "next/navigation" import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access" import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view" import { formatDate } from "@/shared/lib/utils" +import { + AiClientProvider, + type AiClientService, +} from "@/modules/ai/context/ai-client-provider" +import { + suggestGradingAction, + aiChatAction, + suggestSimilarQuestionsAction, + generateLessonContentAction, + generateQuestionVariantAction, + analyzeWeaknessAction, +} from "@/modules/ai/actions" export const dynamic = "force-dynamic" +/** + * 构建 AI 客户端服务(Server Action 引用集合) + * + * 通过 React Context 注入,客户端组件不直接 import actions, + * 遵循依赖注入模式,便于测试时替换为 mock。 + */ +function createAiClientService(): AiClientService { + return { + chat: aiChatAction, + suggestSimilarQuestions: suggestSimilarQuestionsAction, + suggestGrading: suggestGradingAction, + generateLessonContent: generateLessonContentAction, + generateQuestionVariant: generateQuestionVariantAction, + analyzeWeakness: analyzeWeaknessAction, + } +} + export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise { const { submissionId } = await params const submission = await getHomeworkSubmissionDetails(submissionId) if (!submission) return notFound() + const aiClientService = createAiClientService() + return (
@@ -29,17 +60,19 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
- + + +
) } diff --git a/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx index 4787678..0b37a88 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx @@ -1,12 +1,44 @@ import type { JSX } from "react" +import { Suspense } from "react" import { notFound } from "next/navigation" import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor" import { getTeacherClasses } from "@/modules/classes/data-access" +import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" import { getAuthContext } from "@/shared/lib/auth-guard" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { + AiClientProvider, + type AiClientService, +} from "@/modules/ai/context/ai-client-provider" +import { + aiChatAction, + suggestSimilarQuestionsAction, + suggestGradingAction, + generateLessonContentAction, + generateQuestionVariantAction, + analyzeWeaknessAction, +} from "@/modules/ai/actions" export const dynamic = "force-dynamic" +/** + * 构建 AI 客户端服务(Server Action 引用集合) + * + * 通过 React Context 注入,客户端组件不直接 import actions, + * 遵循依赖注入模式,便于测试时替换为 mock。 + */ +function createAiClientService(): AiClientService { + return { + chat: aiChatAction, + suggestSimilarQuestions: suggestSimilarQuestionsAction, + suggestGrading: suggestGradingAction, + generateLessonContent: generateLessonContentAction, + generateQuestionVariant: generateQuestionVariantAction, + analyzeWeakness: analyzeWeaknessAction, + } +} + export default async function EditLessonPlanPage({ params, }: { @@ -23,16 +55,53 @@ export default async function EditLessonPlanPage({ const classes = teacherClasses.map((c) => ({ id: c.id, name: c.name })) + // 拉取教材/章节标题用于工具栏显示 + let textbookTitle: string | undefined + let chapterTitle: string | undefined + if (plan.textbookId) { + const textbook = await getTextbookById(plan.textbookId) + textbookTitle = textbook?.title + if (plan.chapterId) { + const chapters = await getChaptersByTextbookId(plan.textbookId) + const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => { + for (const ch of list) { + if (ch.id === plan.chapterId) return ch + if (ch.children && ch.children.length > 0) { + const found = findChapter(ch.children as typeof chapters) + if (found) return found + } + } + return undefined + } + const chapter = findChapter(chapters) + chapterTitle = chapter?.title + } + } + + const aiClientService = createAiClientService() + return ( -
- -
+ +
+ + +
+ } + > + + + +
) } diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts index 18863f8..55d881a 100644 --- a/src/app/api/ai/chat/route.ts +++ b/src/app/api/ai/chat/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai" import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" @@ -13,21 +14,20 @@ const getStatusFromError = (message: string) => { } export async function POST(req: Request) { - const session = await auth() - const userId = String(session?.user?.id ?? "").trim() - if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 }) - - // Rate limit AI chat per user - const limitKey = rateLimitKey("ai-chat", userId) - const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT }) - if (!limit.success) { - return NextResponse.json( - { success: false, message: "Rate limit exceeded. Please slow down." }, - { status: 429, headers: rateLimitHeaders(limit) } - ) - } - try { + const ctx = await requirePermission(Permissions.AI_CHAT) + const userId = ctx.userId + + // Rate limit AI chat per user + const limitKey = rateLimitKey("ai-chat", userId) + const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT }) + if (!limit.success) { + return NextResponse.json( + { success: false, message: "Rate limit exceeded. Please slow down." }, + { status: 429, headers: rateLimitHeaders(limit) } + ) + } + const body = await req.json().catch(() => null) const input = parseAiChatPayload(body) const result = await createAiChatCompletion(input) @@ -36,6 +36,12 @@ export async function POST(req: Request) { { headers: rateLimitHeaders(limit) } ) } catch (e) { + if (e instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: e.message }, + { status: 403 } + ) + } const message = getAiErrorMessage(e) return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) }) } diff --git a/src/i18n/request.ts b/src/i18n/request.ts index b13dd1f..c4a5607 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -39,6 +39,7 @@ export default getRequestConfig(async () => { attendance, elective, school, + ai, ] = await Promise.all([ import(`@/shared/i18n/messages/${locale}/common.json`), import(`@/shared/i18n/messages/${locale}/auth.json`), @@ -59,6 +60,7 @@ export default getRequestConfig(async () => { import(`@/shared/i18n/messages/${locale}/attendance.json`), import(`@/shared/i18n/messages/${locale}/elective.json`), import(`@/shared/i18n/messages/${locale}/school.json`), + import(`@/shared/i18n/messages/${locale}/ai.json`), ]); return { @@ -83,6 +85,7 @@ export default getRequestConfig(async () => { attendance: attendance.default, elective: elective.default, school: school.default, + ai: ai.default, }, }; }); diff --git a/src/modules/ai/actions.ts b/src/modules/ai/actions.ts new file mode 100644 index 0000000..68c9b6a --- /dev/null +++ b/src/modules/ai/actions.ts @@ -0,0 +1,244 @@ +"use server" + +import { getTranslations } from "next-intl/server" + +import type { ActionState } from "@/shared/types/action-state" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import { createAiService, safeAiCall } from "./services/ai-service" +import { + AiChatInputSchema, + GradingInputSchema, + LessonContentInputSchema, + QuestionVariantInputSchema, + SimilarQuestionInputSchema, + WeaknessAnalysisInputSchema, +} from "./schema" +import type { + AiChatMessage, + AiChatResult, + GradingInput, + GradingSuggestion, + LessonContentInput, + LessonContentResult, + QuestionVariantInput, + QuestionVariantResult, + SimilarQuestionInput, + SimilarQuestionResult, + WeaknessAnalysisInput, + WeaknessAnalysisResult, +} from "./types" + +// --------------------------------------------------------------------------- +// 辅助:并行校验多个权限 +// --------------------------------------------------------------------------- + +const requireAiPermission = async ( + ...permissions: readonly string[] +): Promise<{ userId: string }> => { + const results = await Promise.all( + permissions.map((p) => requirePermission(p as never)) + ) + return { userId: results[0].userId } +} + +// --------------------------------------------------------------------------- +// AI 聊天 +// --------------------------------------------------------------------------- + +export async function aiChatAction(input: { + messages: AiChatMessage[] + providerId?: string +}): Promise> { + const t = await getTranslations("ai") + try { + const ctx = await requirePermission(Permissions.AI_CHAT) + const parsed = AiChatInputSchema.safeParse(input) + if (!parsed.success) { + return { success: false, message: t("error.invalidInput") } + } + + const service = createAiService(ctx.userId) + const result = await safeAiCall(() => + service.chat(parsed.data.messages, { + providerId: parsed.data.providerId, + }) + ) + if (!result.ok) { + return { success: false, message: result.message } + } + return { success: true, data: result.data } + } catch (error) { + if (error instanceof PermissionDeniedError) { + return { success: false, message: error.message } + } + return { success: false, message: t("error.chatFailed") } + } +} + +// --------------------------------------------------------------------------- +// 相似题推荐 +// --------------------------------------------------------------------------- + +export async function suggestSimilarQuestionsAction( + input: SimilarQuestionInput +): Promise> { + const t = await getTranslations("ai") + try { + const ctx = await requireAiPermission( + Permissions.AI_CHAT, + Permissions.ERROR_BOOK_READ + ) + const parsed = SimilarQuestionInputSchema.safeParse(input) + if (!parsed.success) { + return { success: false, message: t("error.invalidInput") } + } + + const service = createAiService(ctx.userId) + const result = await safeAiCall(() => + service.suggestSimilarQuestions(parsed.data) + ) + if (!result.ok) { + return { success: false, message: result.message } + } + return { success: true, data: result.data } + } catch (error) { + if (error instanceof PermissionDeniedError) { + return { success: false, message: error.message } + } + return { success: false, message: t("error.suggestionFailed") } + } +} + +// --------------------------------------------------------------------------- +// AI 辅助批改 +// --------------------------------------------------------------------------- + +export async function suggestGradingAction( + input: GradingInput +): Promise> { + const t = await getTranslations("ai") + try { + const ctx = await requireAiPermission( + Permissions.AI_CHAT, + Permissions.HOMEWORK_GRADE + ) + const parsed = GradingInputSchema.safeParse(input) + if (!parsed.success) { + return { success: false, message: t("error.invalidInput") } + } + + const service = createAiService(ctx.userId) + const result = await safeAiCall(() => service.suggestGrading(parsed.data)) + if (!result.ok) { + return { success: false, message: result.message } + } + return { success: true, data: result.data } + } catch (error) { + if (error instanceof PermissionDeniedError) { + return { success: false, message: error.message } + } + return { success: false, message: t("error.gradingFailed") } + } +} + +// --------------------------------------------------------------------------- +// 备课内容生成 +// --------------------------------------------------------------------------- + +export async function generateLessonContentAction( + input: LessonContentInput +): Promise> { + const t = await getTranslations("ai") + try { + const ctx = await requireAiPermission( + Permissions.AI_CHAT, + Permissions.LESSON_PLAN_READ + ) + const parsed = LessonContentInputSchema.safeParse(input) + if (!parsed.success) { + return { success: false, message: t("error.invalidInput") } + } + + const service = createAiService(ctx.userId) + const result = await safeAiCall(() => + service.generateLessonContent(parsed.data) + ) + if (!result.ok) { + return { success: false, message: result.message } + } + return { success: true, data: result.data } + } catch (error) { + if (error instanceof PermissionDeniedError) { + return { success: false, message: error.message } + } + return { success: false, message: t("error.contentFailed") } + } +} + +// --------------------------------------------------------------------------- +// 题目变体生成 +// --------------------------------------------------------------------------- + +export async function generateQuestionVariantAction( + input: QuestionVariantInput +): Promise> { + const t = await getTranslations("ai") + try { + const ctx = await requireAiPermission( + Permissions.AI_CHAT, + Permissions.EXAM_AI_GENERATE + ) + const parsed = QuestionVariantInputSchema.safeParse(input) + if (!parsed.success) { + return { success: false, message: t("error.invalidInput") } + } + + const service = createAiService(ctx.userId) + const result = await safeAiCall(() => + service.generateQuestionVariant(parsed.data) + ) + if (!result.ok) { + return { success: false, message: result.message } + } + return { success: true, data: result.data } + } catch (error) { + if (error instanceof PermissionDeniedError) { + return { success: false, message: error.message } + } + return { success: false, message: t("error.variantFailed") } + } +} + +// --------------------------------------------------------------------------- +// 薄弱点分析 +// --------------------------------------------------------------------------- + +export async function analyzeWeaknessAction( + input: WeaknessAnalysisInput +): Promise> { + const t = await getTranslations("ai") + try { + const ctx = await requireAiPermission( + Permissions.AI_CHAT, + Permissions.ERROR_BOOK_READ + ) + const parsed = WeaknessAnalysisInputSchema.safeParse(input) + if (!parsed.success) { + return { success: false, message: t("error.invalidInput") } + } + + const service = createAiService(ctx.userId) + const result = await safeAiCall(() => service.analyzeWeakness(parsed.data)) + if (!result.ok) { + return { success: false, message: result.message } + } + return { success: true, data: result.data } + } catch (error) { + if (error instanceof PermissionDeniedError) { + return { success: false, message: error.message } + } + return { success: false, message: t("error.analysisFailed") } + } +} diff --git a/src/modules/ai/components/ai-chat-panel.tsx b/src/modules/ai/components/ai-chat-panel.tsx new file mode 100644 index 0000000..5f814b4 --- /dev/null +++ b/src/modules/ai/components/ai-chat-panel.tsx @@ -0,0 +1,182 @@ +"use client" + +import { useState, useRef, useEffect, useCallback } from "react" +import { useTranslations } from "next-intl" +import { Send, Bot, User } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Textarea } from "@/shared/components/ui/textarea" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { AiChatSkeleton } from "./ai-skeleton" +import { useAiClient } from "../context/ai-client-provider" +import type { AiChatMessage } from "../types" + +type AiChatPanelProps = { + /** 初始系统提示词 */ + systemPrompt?: string + /** 上下文信息(注入到 user message 前面) */ + contextMessage?: string + /** 占位提示文本 */ + placeholder?: string + /** 标题 */ + title?: string + /** 最大消息数 */ + maxMessages?: number +} + +/** + * AI 聊天面板 + * + * 通用 AI 对话组件,可嵌入任何页面。 + * 通过 useAiClient() 获取 Server Action 引用,不直接 import actions。 + */ +export function AiChatPanel({ + systemPrompt, + contextMessage, + placeholder, + title, + maxMessages = 50, +}: AiChatPanelProps): React.ReactNode { + const t = useTranslations("ai") + const aiClient = useAiClient() + const [messages, setMessages] = useState([]) + const [input, setInput] = useState("") + const [loading, setLoading] = useState(false) + const scrollRef = useRef(null) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messages]) + + const handleSend = useCallback(async (): Promise => { + const trimmed = input.trim() + if (!trimmed || loading || messages.length >= maxMessages) return + + const userMessage: AiChatMessage = { role: "user", content: trimmed } + const contextPrefix = contextMessage + ? `Context:\n${contextMessage}\n\nUser question: ${trimmed}` + : trimmed + const systemMessage: AiChatMessage | null = systemPrompt + ? { role: "system", content: systemPrompt } + : null + + const requestMessages: AiChatMessage[] = [ + ...(systemMessage ? [systemMessage] : []), + ...messages, + { role: "user" as const, content: contextPrefix }, + ] + + setInput("") + setLoading(true) + setMessages((prev) => [...prev, userMessage]) + + try { + const result = await aiClient.chat({ + messages: requestMessages, + }) + if (result.success && result.data) { + const assistantContent = result.data.content + setMessages((prev) => [ + ...prev, + { role: "assistant", content: assistantContent }, + ]) + } else { + toast.error(result.message ?? t("error.chatFailed")) + setMessages((prev) => prev.filter((m) => m !== userMessage)) + } + } catch { + toast.error(t("error.chatFailed")) + setMessages((prev) => prev.filter((m) => m !== userMessage)) + } finally { + setLoading(false) + } + }, [input, loading, messages, maxMessages, systemPrompt, contextMessage, aiClient, t]) + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + void handleSend() + } + } + + if (loading && messages.length === 0) { + return + } + + return ( + + + + + {title ?? t("chat.title")} + + + + {messages.length > 0 ? ( + +
+ {messages.map((message, index) => ( +
+ {message.role === "assistant" ? ( + + ) : ( + + )} +
+

{message.content}

+
+
+ ))} + {loading ? ( +
+ +
+ {t("chat.thinking")} +
+
+ ) : null} +
+
+ ) : null} +
+