diff --git a/src/llm/prompts.ts b/src/llm/prompts.ts index 4ad8919..a91074e 100644 --- a/src/llm/prompts.ts +++ b/src/llm/prompts.ts @@ -90,3 +90,66 @@ export function generateMindMapPrompt( }, ]; } + +/** + * 回忆对比:将用户凭记忆写的导图与标准导图语义对比。 + * + * 输出要求:严格 JSON(不要包含 markdown 代码块标记),结构如下: + * { + * "matchedTree": { "title": "标准根标题", "children": [...], "notes": "MATCHED|备注" }, + * "extraNodes": [{ "title": "额外节点标题", "path": "路径" }], + * "recallRatio": 0.75, + * "matchedCount": 6, + * "missedCount": 2, + * "extraCount": 3, + * "evaluation": "评价文字" + * } + * + * 要点: + * - 标准导图的每个节点在 matchedTree 中标注 MATCHED| 或 MISSED| 前缀, + * 匹配时保留原始标题,不要改变标准导图的标题文字 + * - 匹配应基于语义而非字面,同义表达(如"锁升级"≈"锁膨胀")算匹配 + * - 考虑层级上下文:节点逻辑上归属正确即可匹配,不要求层级路径完全一致 + * - 用户额外回忆到的节点放入 extraNodes + * - 评价用中文写,2-5 句话,指出回忆完整度、遗漏的关键概念、 + * 额外回忆的知识点是否有价值 + */ +export function compareRecallPrompt( + taskName: string, + standardOutline: string, + recallOutline: string, +): ChatMessage[] { + return [ + { + role: "system", + content: [ + "你是一名学习评估专家。请对比两份思维导图大纲:", + "1. 标准导图(系统从学习报告中提炼的知识结构)", + "2. 用户导图(用户凭记忆回忆的知识结构)", + "", + "你的任务:", + "- 对标准导图的每个非根节点,判断用户在回忆中是否覆盖了它;", + "- 匹配应基于语义理解:即使表达的用词、角度不同,概念相同就算匹配;", + "- 考虑层级上下文:一个节点在用户导图中归属到正确的逻辑分组下才算匹配;", + "- 找出用户额外回忆到的、标准导图中没有的知识点(extraNodes);", + "- 统计覆盖率、命中/遗漏/额外数量;", + "- 用中文写一段评价(2-5 句话),包括:回忆完整度评价、遗漏了哪些关键概念、额外回忆的内容是否有价值。", + "", + "输出必须是有效 JSON,不要包含 ```json 之类的标记,直接输出 JSON 对象。", + "标准导图中需要标注的节点,在 notes 字段中用 MATCHED| 或 MISSED| 标记。", + ].join("\n"), + }, + { + role: "user", + content: [ + `学习任务:${taskName}`, + "", + "===== 标准导图(知识结构)=====", + standardOutline, + "", + "===== 用户回忆导图 =====", + recallOutline, + ].join("\n"), + }, + ]; +} diff --git a/src/routes/ai.ts b/src/routes/ai.ts index 7d8f0af..fb191b7 100644 --- a/src/routes/ai.ts +++ b/src/routes/ai.ts @@ -4,7 +4,7 @@ import type { FastifyInstance } from "fastify"; import { chat, isAvailable, loadConfig, LlmError } from "../llm/client.js"; -import { aggregateReportPrompt, generateMindMapPrompt, type FragmentInput } from "../llm/prompts.js"; +import { aggregateReportPrompt, compareRecallPrompt, generateMindMapPrompt, type FragmentInput } from "../llm/prompts.js"; import { logStore } from "../admin/store.js"; interface AggregateReportBody { @@ -20,6 +20,12 @@ interface GenerateMindMapBody { fragments: string[]; } +interface CompareRecallBody { + taskName: string; + standardOutline: string; + recallOutline: string; +} + export async function aiRoutes(app: FastifyInstance): Promise { const config = loadConfig(); @@ -120,4 +126,51 @@ export async function aiRoutes(app: FastifyInstance): Promise { throw err; } }); + + /** + * 回忆对比:将用户凭记忆写的导图大纲与标准导图语义对比。 + */ + app.post<{ Body: CompareRecallBody }>("/ai/compare-recall", async (request, reply) => { + const { taskName, standardOutline, recallOutline } = request.body ?? ({} as CompareRecallBody); + const t0 = Date.now(); + const params = { temperature: 0.2, max_tokens: 4096 }; + + if (!taskName || !standardOutline?.trim() || !recallOutline?.trim()) { + logStore.add({ + timestamp: new Date().toISOString(), endpoint: "/ai/compare-recall", + model: config.model, durationMs: Date.now() - t0, status: 400, + requestBody: request.body ?? {}, params, + responseBody: { error: "taskName、standardOutline、recallOutline 均不可为空" }, + }); + return reply.status(400).send({ error: "taskName、standardOutline、recallOutline 均不可为空" }); + } + + try { + const messages = compareRecallPrompt(taskName, standardOutline, recallOutline); + const raw = await chat(config, messages, { temperature: 0.2, maxTokens: 4096 }); + // LLM 可能把 JSON 包在 ```json ... ``` 里,做一次清理 + const clean = raw.trim().replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, ""); + const result = JSON.parse(clean); + logStore.add({ + timestamp: new Date().toISOString(), endpoint: "/ai/compare-recall", + model: config.model, durationMs: Date.now() - t0, status: 200, + requestBody: request.body ?? {}, messages, params, + responseBody: result, + }); + return result; + } catch (err) { + const status = err instanceof LlmError ? err.statusCode + : err instanceof SyntaxError ? 502 : 500; + const errMsg = err instanceof Error ? err.message : String(err); + logStore.add({ + timestamp: new Date().toISOString(), endpoint: "/ai/compare-recall", + model: config.model, durationMs: Date.now() - t0, status, + requestBody: request.body ?? {}, params, responseBody: {}, error: errMsg, + }); + if (err instanceof LlmError) { + return reply.status(err.statusCode).send({ error: err.message }); + } + throw err; + } + }); }