feat(lpt-ai):新增 /ai/compare-recall — AI 语义化回忆对比
- prompts.ts:新增 compareRecallPrompt(),输出结构化 JSON (matchedTree/MISSED|标注 + extraNodes + recallRatio + evaluation) - routes/ai.ts:新增 POST /ai/compare-recall,temperature 0.2 自动清理 LLM 可能包裹的 ```json ``` 标记 - 日志记录完整请求/响应,管理员面板可回溯
This commit is contained in:
@@ -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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+54
-1
@@ -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<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -120,4 +126,51 @@ export async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user