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:
@@ -0,0 +1,11 @@
|
|||||||
|
# LLM 提供商配置(SiliconFlow OpenAI 兼容接口)
|
||||||
|
LLM_API_URL=https://api.siliconflow.cn/v1/chat/completions
|
||||||
|
# API Key(必填,未配置时服务返回 503)
|
||||||
|
LLM_API_KEY=
|
||||||
|
# 模型名(32B 级别即可满足残片聚合需求)
|
||||||
|
LLM_MODEL=Qwen/Qwen2.5-32B-Instruct
|
||||||
|
# 请求超时(毫秒)
|
||||||
|
LLM_TIMEOUT_MS=60000
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=5199
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# lpt-ai
|
||||||
|
|
||||||
|
LPT 学习进度跟踪系统的独立 AI 服务。将 LLM 相关能力从 Java 主服务中剥离,便于独立迭代、增加安全层与沙箱。
|
||||||
|
|
||||||
|
## 能力
|
||||||
|
|
||||||
|
| 端点 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/health` | GET | 健康检查,返回 LLM 可用状态 |
|
||||||
|
| `/ai/aggregate-report` | POST | 将学习残片聚合为学习报告 |
|
||||||
|
| `/ai/generate-mind-map` | POST | 从报告/残片生成思维导图大纲 |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env,填入 LLM_API_KEY
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
未配置 `LLM_API_KEY` 时服务可启动,但 AI 端点返回 503,由调用方(Java 后端)降级到内置规则。
|
||||||
|
|
||||||
|
## 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5199/ai/aggregate-report \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"taskName": "Java 并发编程",
|
||||||
|
"expectation": "理解线程池核心参数",
|
||||||
|
"fragments": [
|
||||||
|
{"content": "corePoolSize 是核心线程数,即使空闲也不回收"},
|
||||||
|
{"content": "workQueue 满了之后才会创建超过 core 的线程"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构位置
|
||||||
|
|
||||||
|
```
|
||||||
|
LPT 前端 (Vue) ──► LPT 后端 (Java/Spring Boot) ──► lpt-ai (TS/Fastify) ──► SiliconFlow API
|
||||||
|
```
|
||||||
|
|
||||||
|
Java 后端是唯一调用方;本服务不直接暴露给前端,不做鉴权(部署在内网)。
|
||||||
|
|
||||||
|
## 后续规划
|
||||||
|
|
||||||
|
- 输入内容安全过滤(Prompt 注入防护)
|
||||||
|
- 输出审核
|
||||||
|
- 多模型路由(按任务复杂度选择模型)
|
||||||
|
- 用量与成本监控
|
||||||
|
- SSE 流式输出
|
||||||
Generated
+3737
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "lpt-ai",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "LPT 学习进度跟踪系统的独立 AI 服务:残片聚合报告、思维导图生成",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "^5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import { aiRoutes } from "./routes/ai.js";
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: "info",
|
||||||
|
redact: ["req.headers.authorization"], // 永不记录密钥
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(aiRoutes);
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 5199);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port, host: "0.0.0.0" });
|
||||||
|
app.log.info(`lpt-ai 服务已启动,端口 ${port}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("残片二");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* AI 能力路由
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { chat, isAvailable, loadConfig, LlmError } from "../llm/client.js";
|
||||||
|
import { aggregateReportPrompt, generateMindMapPrompt, type FragmentInput } from "../llm/prompts.js";
|
||||||
|
|
||||||
|
interface AggregateReportBody {
|
||||||
|
taskName: string;
|
||||||
|
fragments: FragmentInput[];
|
||||||
|
expectation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateMindMapBody {
|
||||||
|
taskName: string;
|
||||||
|
taskDescription?: string;
|
||||||
|
reports: string[];
|
||||||
|
fragments: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
app.get("/health", async () => ({
|
||||||
|
status: "ok",
|
||||||
|
llmAvailable: isAvailable(config),
|
||||||
|
model: config.model,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 残片聚合为学习报告
|
||||||
|
*/
|
||||||
|
app.post<{ Body: AggregateReportBody }>("/ai/aggregate-report", async (request, reply) => {
|
||||||
|
const { taskName, fragments, expectation } = request.body ?? ({} as AggregateReportBody);
|
||||||
|
|
||||||
|
if (!taskName || !Array.isArray(fragments) || fragments.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "taskName 与 fragments 不可为空" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = aggregateReportPrompt(taskName, fragments, expectation);
|
||||||
|
const report = await chat(config, messages);
|
||||||
|
return { report: report.trim(), model: config.model };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof LlmError) {
|
||||||
|
return reply.status(err.statusCode).send({ error: err.message });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从学习数据生成思维导图大纲(缩进文本,与后端 MindMapTreeTool 格式一致)
|
||||||
|
*/
|
||||||
|
app.post<{ Body: GenerateMindMapBody }>("/ai/generate-mind-map", async (request, reply) => {
|
||||||
|
const { taskName, taskDescription, reports, fragments } =
|
||||||
|
request.body ?? ({} as GenerateMindMapBody);
|
||||||
|
|
||||||
|
if (!taskName || ((reports?.length ?? 0) === 0 && (fragments?.length ?? 0) === 0)) {
|
||||||
|
return reply.status(400).send({ error: "taskName 不可为空且 reports/fragments 至少一项非空" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = generateMindMapPrompt(
|
||||||
|
taskName,
|
||||||
|
taskDescription ?? "",
|
||||||
|
reports ?? [],
|
||||||
|
fragments ?? [],
|
||||||
|
);
|
||||||
|
const outline = await chat(config, messages, { maxTokens: 4096 });
|
||||||
|
return { outline: outline.trim(), model: config.model };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof LlmError) {
|
||||||
|
return reply.status(err.statusCode).send({ error: err.message });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user