|
| 1 | +/** |
| 2 | + * P1 今日学习计划 Handler |
| 3 | + * |
| 4 | + * 处理 ai_learning_plan IPC 请求,调用 AI 网关根据客户端聚合的学习状态 |
| 5 | + * 生成今日任务计划;本地 Ollama 可用时本地生成(离线降级路径之一), |
| 6 | + * 全部不可用时由渲染层本地规则规划兜底(degraded 语义)。 |
| 7 | + * |
| 8 | + * @ai-context: P1 learning-plan IPC handler——AIFeatureDef 注册表模式, |
| 9 | + * authToken 由渲染进程从 supabase session 显式注入透传(主进程不自动注入)。 |
| 10 | + * 请求响应契约与网关 learning_plan 路由的 Pydantic model 对齐。 |
| 11 | + */ |
| 12 | + |
| 13 | +import { safeHandle } from '../../ipcUtils.js'; |
| 14 | +import { logger } from '../../logger.js'; |
| 15 | +import { callWithLocalFallback, gatewayUrl, parseModelJson, type AIFeatureDef } from '../utils.js'; |
| 16 | +import { generateText } from '../ollama/OllamaProvider.js'; |
| 17 | + |
| 18 | +// ================================================================ |
| 19 | +// IPC Handler |
| 20 | +// ================================================================ |
| 21 | + |
| 22 | +/** |
| 23 | + * ai_learning_plan — POST /api/v1/ai/learning-plan |
| 24 | + */ |
| 25 | +function register(): void { |
| 26 | + safeHandle( |
| 27 | + 'ai_learning_plan', |
| 28 | + async ( |
| 29 | + _event, |
| 30 | + args: { |
| 31 | + masterySummary?: string; |
| 32 | + dueCounts?: Record<string, number>; |
| 33 | + peakHours?: number[]; |
| 34 | + weeklyGoalMinutes?: number; |
| 35 | + todayMinutes?: number; |
| 36 | + authToken?: string; |
| 37 | + }, |
| 38 | + ) => { |
| 39 | + // 所有字段均可选(服务端 Pydantic 模型均为 Optional): |
| 40 | + // masterySummary 允许空串(新用户无掌握度数据时 AI 仍可生成入门计划), |
| 41 | + // 其他字段为 undefined 时后端跳过拼接。仅校验类型,避免 requireText |
| 42 | + // 拒绝空串导致新用户永远回退本地规划(AI 路径被静默跳过)。 |
| 43 | + if (args?.masterySummary !== undefined && typeof args.masterySummary !== 'string') { |
| 44 | + throw new Error('IPC 入参错误: masterySummary 必须为字符串'); |
| 45 | + } |
| 46 | + if (args?.dueCounts !== undefined && (typeof args.dueCounts !== 'object' || args.dueCounts === null || Array.isArray(args.dueCounts))) { |
| 47 | + throw new Error('IPC 入参错误: dueCounts 必须为对象'); |
| 48 | + } |
| 49 | + const startMs = Date.now(); |
| 50 | + logger.info( |
| 51 | + `[AI] [learning-plan] IPC received: mastery_len=${args.masterySummary?.length ?? 0}, due_decks=${Object.keys(args.dueCounts ?? {}).length}, hasAuth=${!!args.authToken}`, |
| 52 | + ); |
| 53 | + |
| 54 | + // 前端 camelCase → 后端 snake_case |
| 55 | + const reqBody = { |
| 56 | + mastery_summary: args.masterySummary ?? '', |
| 57 | + due_counts: args.dueCounts ?? undefined, |
| 58 | + peak_hours: args.peakHours ?? undefined, |
| 59 | + weekly_goal_minutes: args.weeklyGoalMinutes ?? undefined, |
| 60 | + today_minutes: args.todayMinutes ?? undefined, |
| 61 | + }; |
| 62 | + |
| 63 | + logger.info(`[AI] [learning-plan] Target: ${gatewayUrl()}/api/v1/ai/learning-plan`); |
| 64 | + |
| 65 | + interface PlanItemResp { |
| 66 | + module: string; |
| 67 | + title: string; |
| 68 | + minutes: number; |
| 69 | + task: string; |
| 70 | + reason: string; |
| 71 | + order: number; |
| 72 | + } |
| 73 | + |
| 74 | + interface LearningPlanResp { |
| 75 | + date: string; |
| 76 | + items: PlanItemResp[]; |
| 77 | + note: string; |
| 78 | + status: string; |
| 79 | + model: string; |
| 80 | + tokens_used: number; |
| 81 | + latency_ms: number; |
| 82 | + } |
| 83 | + |
| 84 | + try { |
| 85 | + // 本地优先:Ollama 可用时本地生成(离线降级路径之一) |
| 86 | + const localHandler = async (): Promise<LearningPlanResp> => { |
| 87 | + const contextLines = [ |
| 88 | + args.masterySummary ? `掌握度摘要:${args.masterySummary}` : '', |
| 89 | + args.dueCounts && Object.keys(args.dueCounts).length > 0 |
| 90 | + ? `今日到期卡片:${Object.entries(args.dueCounts).map(([k, v]) => `${k} ${v} 张`).join('、')}` |
| 91 | + : '', |
| 92 | + args.peakHours?.length ? `个人高峰时段:${args.peakHours.join(',')} 点` : '', |
| 93 | + args.weeklyGoalMinutes != null ? `周目标:${args.weeklyGoalMinutes} 分钟` : '', |
| 94 | + args.todayMinutes != null ? `今日已学习:${args.todayMinutes} 分钟` : '', |
| 95 | + ].filter(Boolean).join('\n') || '(无历史数据,生成一份轻量入门计划)'; |
| 96 | + |
| 97 | + const prompt = `用户今日学习状态:\n${contextLines}\n\n请生成今日学习计划。任务模块仅限:pomodoro(深潜番茄钟)/notes(结礁笔记)/flashcards(闪卡复习)/feynman(费曼讲解)/inspiration(灵感沉淀)。到期卡片优先复习;单任务10-60分钟,共2-4项,总时长不超过90分钟;高峰时段安排深度学习。返回JSON: {"date":"YYYY-MM-DD","items":[{"module":"...","title":"...","minutes":30,"task":"具体做什么","reason":"为什么安排这个","order":1}],"note":"一句鼓励语"}`; |
| 98 | + const result = await generateText(prompt, '你是一位学习规划教练,只输出 JSON。', { temperature: 0.6, maxTokens: 1024 }); |
| 99 | + const parsed = parseModelJson<Partial<LearningPlanResp>>(result.content, {}); |
| 100 | + const items = Array.isArray(parsed.items) ? parsed.items : []; |
| 101 | + return { |
| 102 | + date: parsed.date ?? new Date().toISOString().slice(0, 10), |
| 103 | + items, |
| 104 | + note: parsed.note ?? '', |
| 105 | + status: items.length > 0 ? 'success' : 'degraded', |
| 106 | + model: result.model, |
| 107 | + tokens_used: result.tokens_used, |
| 108 | + latency_ms: result.latency_ms, |
| 109 | + }; |
| 110 | + }; |
| 111 | + |
| 112 | + const { data: resp, source, requestId } = await callWithLocalFallback<typeof reqBody, LearningPlanResp>( |
| 113 | + '/api/v1/ai/learning-plan', |
| 114 | + reqBody, |
| 115 | + localHandler, |
| 116 | + args.authToken, |
| 117 | + 30000, |
| 118 | + ); |
| 119 | + |
| 120 | + const elapsed = Date.now() - startMs; |
| 121 | + logger.info( |
| 122 | + `[AI] [learning-plan] ✔ Success (${source}): items=${resp.items?.length ?? 0}, status=${resp.status}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`, |
| 123 | + ); |
| 124 | + return { |
| 125 | + date: resp.date, |
| 126 | + items: resp.items ?? [], |
| 127 | + note: resp.note ?? '', |
| 128 | + status: resp.status, |
| 129 | + model: resp.model, |
| 130 | + tokensUsed: resp.tokens_used, |
| 131 | + latencyMs: resp.latency_ms, |
| 132 | + requestId, |
| 133 | + source, |
| 134 | + }; |
| 135 | + } catch (err) { |
| 136 | + const elapsed = Date.now() - startMs; |
| 137 | + const error = err instanceof Error ? err : new Error(String(err)); |
| 138 | + logger.error(`[AI] [learning-plan] ✖ Failed after ${elapsed}ms: ${error.message}`); |
| 139 | + throw error; |
| 140 | + } |
| 141 | + }, |
| 142 | + ); |
| 143 | +} |
| 144 | + |
| 145 | +// ================================================================ |
| 146 | +// 功能定义导出 |
| 147 | +// ================================================================ |
| 148 | + |
| 149 | +export const feature: AIFeatureDef = { |
| 150 | + id: 'ai_learning_plan', |
| 151 | + name: 'P1 今日学习计划', |
| 152 | + version: '1.0.0', |
| 153 | + register, |
| 154 | +}; |
0 commit comments