Compare commits

5 Commits
5 changed files with 18 additions and 153 deletions
+4
View File
@@ -0,0 +1,4 @@
# 行尾规范:文本文件统一 LF,Windows 批处理保留 CRLF
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
-136
View File
@@ -1,136 +0,0 @@
# lpt-ai
LPT 学习进度跟踪系统的独立 AI 服务。将 LLM 相关能力从 Java 主服务中剥离,便于独立迭代、增加安全层与沙箱。
## 能力
| 端点 | 方法 | 说明 |
|------|------|------|
| `/health` | GET | 健康检查,返回 LLM 可用状态 |
| `/ai/tasks` | POST | 提交异步 AI 任务(聚合报告/生成导图/回忆对比) |
| `/ai/tasks/:taskId` | GET | 轮询任务状态和结果 |
| `/fetch-title` | GET | 抓取 URL 页面标题 |
| `/admin` | GET | 管理面板(任务日志、Prompt 调试) |
## 快速开始
```bash
cp .env.example .env
# 编辑 .env,填入 LLM_API_KEY
npm install
npm run dev
```
未配置 `LLM_API_KEY` 时服务可启动,但 AI 端点返回 503,由调用方(Java 后端)降级到内置规则。
## 异步任务架构
由于 LLM 调用耗时长(可达数十秒),所有 AI 任务采用异步模式:
### 1. 提交任务
```bash
curl -X POST http://localhost:5199/ai/tasks \
-H "Content-Type: application/json" \
-d '{
"type": "aggregate-report",
"params": {
"taskName": "Java 并发编程",
"expectation": "理解线程池核心参数",
"fragments": [
{"content": "corePoolSize 是核心线程数,即使空闲也不回收"},
{"content": "workQueue 满了之后才会创建超过 core 的线程"}
]
}
}'
```
响应:
```json
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending"
}
```
### 2. 轮询结果
```bash
curl http://localhost:5199/ai/tasks/550e8400-e29b-41d4-a716-446655440000
```
响应(进行中):
```json
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "running",
"progress": 50
}
```
响应(完成):
```json
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"result": {
"summary": "本次学习主要掌握了线程池的核心参数配置...",
"keyPoints": ["corePoolSize 核心线程数", "workQueue 工作队列"]
}
}
```
### 3. 支持的任务类型
| type | 说明 | params 字段 |
|------|------|------------|
| `aggregate-report` | 聚合学习残片为报告 | `taskName`, `expectation`, `fragments[]` |
| `generate-mind-map` | 生成思维导图大纲 | `taskName`, `reports[]`, `fragments[]`, `applications[]` |
| `compare-recall` | 对比用户回忆与标准导图 | `taskName`, `standardOutline`, `recallOutline` |
## 架构位置
```
LPT 前端 (Vue) ──► LPT 后端 (Java/Spring Boot) ──► lpt-ai (TS/Fastify) ──► SiliconFlow API
```
Java 后端是唯一调用方;本服务不直接暴露给前端,不做鉴权(部署在内网)。
## 任务队列机制
- 任务提交后进入内存队列,返回 taskId
- 后台异步执行,Java 端通过轮询获取结果
- 任务超时时间:5 分钟
- 任务结果保留时间:30 分钟(自动清理)
- 失败重试:无(由 Java 端决定是否重试或降级)
## 降级策略
```
AI 服务不可用
Java 后端捕获 503/超时
自动切换到内置生成器
- 聚合报告:简单拼接残片
- 生成导图:按 session 分组 + 规则去重
- 回忆对比:字符串相似度匹配
```
确保系统在 AI 服务异常时仍可正常工作。
## 管理面板
访问 `http://localhost:5199/admin` 查看:
- 任务执行日志(状态、耗时、token 消耗)
- Prompt 模板预览与调试
- 队列状态监控
## 后续规划
- 输入内容安全过滤(Prompt 注入防护)
- 输出审核
- 多模型路由(按任务复杂度选择模型)
- 用量与成本监控
- SSE 流式输出
+2 -3
View File
@@ -32,15 +32,14 @@ describe("aggregateReportPrompt", () => {
});
describe("generateMindMapPrompt", () => {
it("要求输出缩进大纲且包含全部输入", () => {
it("要求输出缩进大纲且包含学习报告", () => {
const messages = generateMindMapPrompt(
"Java 并发",
"并发编程学习",
["报告一"],
["残片一", "残片二"],
);
expect(messages[0].content).toContain("缩进");
expect(messages[1].content).toContain("报告一");
expect(messages[1].content).toContain("残片");
expect(messages[1].content).not.toContain("学习残片");
});
});
+12 -12
View File
@@ -13,7 +13,7 @@ export interface FragmentInput {
/**
* 残片聚合为学习报告。
* 原则:报告是写给"未来复习的自己"的,保留具体知识点,剔除口语碎屑;
* 不虚构残片中不存在的内容。
* 不虚构残片中不存在的内容;对抽象概念补充正反例子以明确边界
*/
export function aggregateReportPrompt(
taskName: string,
@@ -34,12 +34,16 @@ export function aggregateReportPrompt(
content: [
"你是一名学习记录整理助手。用户在一次学习会话中随手记录了若干学习残片,",
"请将它们整理为一份简洁的学习报告。要求:",
"1. 只基于残片内容,不虚构、不扩写残片中没有的知识;",
"1. 只基于残片内容,不虚构残片中不存在的知识;",
"2. 保留具体的知识点、概念、结论,去掉口语化的碎屑;",
"3. 按逻辑而非时间组织内容,相关的点合并;",
"4. 报告以要点形式呈现,控制在 300 字以内",
"5. 如果提供了学习预期,在报告末尾用一句话说明本次学习与预期的差距(完成/部分完成/未涉及)",
"6. 直接输出报告正文,不要输出「报告如下」之类的引导语。",
"3. 对残片中的抽象概念或知识,除定义/要点外,再补充一组正反例子,",
" 用「是X:…;不是X:…」的形式列出具体具象,帮助明确概念边界",
" 例如:奇数(也叫单数)是不能被 2 整除的整数;是奇数:1、3、5、7;不是奇数:2、4、6、8",
" 例子只用于解释残片已有的抽象内容,不引入新的知识点;",
"4. 按逻辑而非时间组织内容,相关的点合并;",
"5. 报告以要点形式呈现,因包含示例,目标 600 字左右,不超过 1000 字;",
"6. 如果提供了学习预期,在报告末尾用一句话说明本次学习与预期的差距(完成/部分完成/未涉及);",
"7. 直接输出报告正文,不要输出「报告如下」之类的引导语。",
].join(""),
},
{
@@ -50,20 +54,19 @@ export function aggregateReportPrompt(
}
/**
* 从学习报告和残片生成标准思维导图(预留,用于替代 Java 端 BuiltinMindMapGenerator)。
* 从学习报告生成标准思维导图(预留,用于替代 Java 端 BuiltinMindMapGenerator)。
* 输出为缩进大纲文本,与后端 MindMapTreeTool.parseOutline 格式一致。
*/
export function generateMindMapPrompt(
taskName: string,
taskDescription: string,
reports: string[],
fragments: string[],
): ChatMessage[] {
return [
{
role: "system",
content: [
"你是一名知识结构整理助手。请阅读某个学习任务的全部学习报告和残片",
"你是一名知识结构整理助手。请阅读某个学习任务的全部学习报告,",
"将其中的知识点整理成一份思维导图大纲。要求:",
"1. 第一行是导图根标题(任务主题);",
"2. 之后每行一个节点,用两个空格的缩进表示层级;",
@@ -81,9 +84,6 @@ export function generateMindMapPrompt(
"",
"学习报告:",
...reports.map((r, i) => `${i + 1}. ${r}`),
"",
"学习残片:",
...fragments.map((f, i) => `${i + 1}. ${f}`),
]
.filter(Boolean)
.join("\n"),
-2
View File
@@ -160,12 +160,10 @@ function buildMessages(task: TaskRecord) {
}
case "generate-mind-map": {
const reports = p.reports as string[] | undefined;
const fragments = p.fragments as string[] | undefined;
return generateMindMapPrompt(
p.taskName as string,
(p.taskDescription as string) ?? "",
reports ?? [],
fragments ?? [],
);
}
case "compare-recall": {