feat(AI服务):lpt-ai 独立服务初始版本

- Fastify + TypeScript,OpenAI 兼容接口对接 SiliconFlow
- POST /ai/aggregate-report:残片聚合为学习报告(支持预期对比)
- POST /ai/generate-mind-map:生成思维导图缩进大纲(与后端解析格式一致)
- Key 从环境变量读取且日志脱敏,未配置时返回 503 由调用方降级
- prompt 模板集中管理,vitest 单元测试
This commit is contained in:
2026-07-03 23:48:51 +08:00
commit 44c07d28bd
11 changed files with 4187 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
/**
* LLM 客户端 — SiliconFlow OpenAI 兼容接口
*
* 设计要点:
* - Key 从环境变量读取,永不写入代码或日志
* - 未配置 Key 时 isAvailable() 为 false,调用方返回 503,由 Java 端降级
* - 超时保护,避免长时间挂起
*/
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface LlmConfig {
apiUrl: string;
apiKey: string;
model: string;
timeoutMs: number;
}
export function loadConfig(): LlmConfig {
return {
apiUrl: process.env.LLM_API_URL ?? "https://api.siliconflow.cn/v1/chat/completions",
apiKey: process.env.LLM_API_KEY ?? "",
model: process.env.LLM_MODEL ?? "Qwen/Qwen2.5-32B-Instruct",
timeoutMs: Number(process.env.LLM_TIMEOUT_MS ?? 60000),
};
}
export function isAvailable(config: LlmConfig): boolean {
return config.apiKey.length > 0;
}
export class LlmError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
}
}
/**
* 非流式调用,返回完整文本
*/
export async function chat(
config: LlmConfig,
messages: ChatMessage[],
options?: { temperature?: number; maxTokens?: number },
): Promise<string> {
if (!isAvailable(config)) {
throw new LlmError("LLM API Key 未配置", 503);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetch(config.apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages,
temperature: options?.temperature ?? 0.3,
max_tokens: options?.maxTokens ?? 2048,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new LlmError(`LLM 请求失败 (${response.status}): ${body.slice(0, 200)}`, 502);
}
const data = (await response.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new LlmError("LLM 返回内容为空", 502);
}
return content;
} catch (err) {
if (err instanceof LlmError) throw err;
if (err instanceof Error && err.name === "AbortError") {
throw new LlmError("LLM 请求超时", 504);
}
throw new LlmError(`LLM 请求异常: ${err instanceof Error ? err.message : String(err)}`, 502);
} finally {
clearTimeout(timer);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { aggregateReportPrompt, generateMindMapPrompt } from "./prompts.js";
describe("aggregateReportPrompt", () => {
it("包含全部残片和任务名", () => {
const messages = aggregateReportPrompt("Java 并发", [
{ content: "corePoolSize 是核心线程数" },
{ content: "workQueue 满后创建新线程", createdTime: "2026-07-01" },
]);
expect(messages).toHaveLength(2);
expect(messages[0].role).toBe("system");
expect(messages[1].content).toContain("Java 并发");
expect(messages[1].content).toContain("corePoolSize");
expect(messages[1].content).toContain("workQueue");
expect(messages[1].content).toContain("2026-07-01");
});
it("有预期时包含预期内容和对比要求", () => {
const messages = aggregateReportPrompt(
"Java 并发",
[{ content: "x" }],
"理解线程池核心参数",
);
expect(messages[1].content).toContain("理解线程池核心参数");
expect(messages[0].content).toContain("预期");
});
it("无预期时不包含预期块", () => {
const messages = aggregateReportPrompt("Java 并发", [{ content: "x" }]);
expect(messages[1].content).not.toContain("预期目标");
});
});
describe("generateMindMapPrompt", () => {
it("要求输出缩进大纲且包含全部输入", () => {
const messages = generateMindMapPrompt(
"Java 并发",
"并发编程学习",
["报告一"],
["残片一", "残片二"],
);
expect(messages[0].content).toContain("缩进");
expect(messages[1].content).toContain("报告一");
expect(messages[1].content).toContain("残片二");
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Prompt 模板集中管理。
* 修改提示词只动这个文件,方便版本对照与回滚。
*/
import type { ChatMessage } from "./client.js";
export interface FragmentInput {
content: string;
createdTime?: string;
}
/**
* 残片聚合为学习报告。
* 原则:报告是写给"未来复习的自己"的,保留具体知识点,剔除口语碎屑;
* 不虚构残片中不存在的内容。
*/
export function aggregateReportPrompt(
taskName: string,
fragments: FragmentInput[],
expectation?: string,
): ChatMessage[] {
const fragmentLines = fragments
.map((f, i) => `${i + 1}. ${f.content}${f.createdTime ? `${f.createdTime}` : ""}`)
.join("\n");
const expectationBlock = expectation
? `\n本次学习开始前的预期目标:\n${expectation}\n`
: "";
return [
{
role: "system",
content: [
"你是一名学习记录整理助手。用户在一次学习会话中随手记录了若干学习残片,",
"请将它们整理为一份简洁的学习报告。要求:",
"1. 只基于残片内容,不虚构、不扩写残片中没有的知识;",
"2. 保留具体的知识点、概念、结论,去掉口语化的碎屑;",
"3. 按逻辑而非时间组织内容,相关的点合并;",
"4. 报告以要点形式呈现,控制在 300 字以内;",
"5. 如果提供了学习预期,在报告末尾用一句话说明本次学习与预期的差距(完成/部分完成/未涉及);",
"6. 直接输出报告正文,不要输出「报告如下」之类的引导语。",
].join(""),
},
{
role: "user",
content: `学习任务:${taskName}\n${expectationBlock}\n本次学习的残片记录:\n${fragmentLines}`,
},
];
}
/**
* 从学习报告和残片生成标准思维导图(预留,用于替代 Java 端 BuiltinMindMapGenerator)。
* 输出为缩进大纲文本,与后端 MindMapTreeTool.parseOutline 格式一致。
*/
export function generateMindMapPrompt(
taskName: string,
taskDescription: string,
reports: string[],
fragments: string[],
): ChatMessage[] {
return [
{
role: "system",
content: [
"你是一名知识结构整理助手。请阅读某个学习任务的全部学习报告和残片,",
"将其中的知识点整理成一份思维导图大纲。要求:",
"1. 第一行是导图根标题(任务主题);",
"2. 之后每行一个节点,用两个空格的缩进表示层级;",
"3. 按知识的内在结构组织(概念→细分→要点),不按时间顺序;",
"4. 合并重复的知识点;",
"5. 只基于给定内容,不补充外部知识;",
"6. 直接输出大纲,不要任何解释。",
].join(""),
},
{
role: "user",
content: [
`学习任务:${taskName}`,
taskDescription ? `任务描述:${taskDescription}` : "",
"",
"学习报告:",
...reports.map((r, i) => `${i + 1}. ${r}`),
"",
"学习残片:",
...fragments.map((f, i) => `${i + 1}. ${f}`),
]
.filter(Boolean)
.join("\n"),
},
];
}