Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435beaffbb | ||
|
|
3f504976ea | ||
|
|
81dfb4fc3e | ||
|
|
a71feea4fd | ||
|
|
9f620e84f8 | ||
|
|
a1cfb045ba | ||
|
|
e106a8bfdc | ||
|
|
e6bcf0db15 | ||
|
|
943faf0ba9 | ||
|
|
977a031393 | ||
|
|
5497dfc290 |
+95
-25
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
environment:
|
environment:
|
||||||
description: '部署环境'
|
description: '部署环境(本仓库仅 main 分支;dev/prod 均从 main 部署)'
|
||||||
required: true
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
@@ -19,67 +19,137 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
KUBECONFIG: /tmp/kubeconfig
|
||||||
steps:
|
steps:
|
||||||
|
- name: Validate branch ↔ environment
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REF="${{ gitea.ref }}"
|
||||||
|
BRANCH="${REF#refs/heads/}"
|
||||||
|
if [[ "$REF" == refs/tags/* ]]; then
|
||||||
|
echo "ERROR: 请从分支触发部署,不要用 tag。当前 ref=$REF"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ENV="${{ inputs.environment }}"
|
||||||
|
echo "branch=$BRANCH environment=$ENV sha=${{ gitea.sha }}"
|
||||||
|
# lpt-ai 仅维护 main:dev/prod 都从 main 出包,靠 environment 区分 namespace/镜像 tag
|
||||||
|
case "$BRANCH" in
|
||||||
|
main|master)
|
||||||
|
echo "OK: lpt-ai 从 $BRANCH 部署到 $ENV"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: lpt-ai 只能从 main/master 部署,当前分支是 '$BRANCH'"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$ENV" in
|
||||||
|
dev|prod) ;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: 未知 environment=$ENV"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
run: |
|
run: |
|
||||||
git clone $REPO_URL .
|
set -euo pipefail
|
||||||
git checkout ${{ gitea.sha }}
|
git clone "$REPO_URL" .
|
||||||
|
git checkout "${{ gitea.sha }}"
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login $REGISTRY -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
set -euo pipefail
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
- name: Build & Push Docker image
|
- name: Build & Push Docker image
|
||||||
run: |
|
run: |
|
||||||
TAG=${{ gitea.sha }}-$(date +%s)
|
set -euo pipefail
|
||||||
docker build -t $REGISTRY/$APP:$TAG -t $REGISTRY/$APP:${{ inputs.environment }} .
|
ENV="${{ inputs.environment }}"
|
||||||
docker push $REGISTRY/$APP:$TAG
|
TAG="${{ gitea.sha }}-$(date +%s)"
|
||||||
docker push $REGISTRY/$APP:${{ inputs.environment }}
|
echo "Building $REGISTRY/$APP:$TAG (env tag=$ENV)"
|
||||||
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
|
docker build -t "$REGISTRY/$APP:$TAG" -t "$REGISTRY/$APP:$ENV" .
|
||||||
|
docker push "$REGISTRY/$APP:$TAG"
|
||||||
|
docker push "$REGISTRY/$APP:$ENV"
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||||
|
echo "IMAGE_TAG=$TAG" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then
|
||||||
|
echo "IMAGE_TAG=$TAG" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
echo "$TAG" > image_tag.txt
|
||||||
|
if [ -n "${{ runner.temp }}" ]; then
|
||||||
|
echo "$TAG" > "${{ runner.temp }}/image_tag.txt" || true
|
||||||
|
fi
|
||||||
|
echo "IMAGE_TAG=$TAG"
|
||||||
|
|
||||||
- name: Setup kubectl
|
- name: Setup kubectl
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
curl -sLO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl"
|
curl -sLO "https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl"
|
||||||
chmod +x kubectl
|
chmod +x kubectl
|
||||||
mv kubectl /usr/local/bin/
|
mv kubectl /usr/local/bin/
|
||||||
mkdir -p ~/.kube
|
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > /tmp/kubeconfig
|
||||||
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config
|
chmod 600 /tmp/kubeconfig
|
||||||
|
|
||||||
- name: Create/Update imagePullSecret
|
- name: Create/Update imagePullSecret
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
kubectl create secret docker-registry regcred \
|
kubectl create secret docker-registry regcred \
|
||||||
--docker-server=$REGISTRY \
|
--docker-server="$REGISTRY" \
|
||||||
--docker-username="${{ secrets.REGISTRY_USERNAME }}" \
|
--docker-username="${{ secrets.REGISTRY_USERNAME }}" \
|
||||||
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
|
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
-n lpt-${{ inputs.environment }} \
|
-n "lpt-${{ inputs.environment }}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
- name: Deploy to K8s
|
- name: Deploy to K8s
|
||||||
run: |
|
run: |
|
||||||
kubectl set image deployment/$APP $APP=$REGISTRY/$APP:$IMAGE_TAG -n lpt-${{ inputs.environment }} --record
|
set -euo pipefail
|
||||||
kubectl rollout status deployment/$APP -n lpt-${{ inputs.environment }} --timeout=5m
|
ENV="${{ inputs.environment }}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||||
|
if [ -z "$IMAGE_TAG" ] && [ -f image_tag.txt ]; then
|
||||||
|
IMAGE_TAG="$(cat image_tag.txt)"
|
||||||
|
fi
|
||||||
|
if [ -z "$IMAGE_TAG" ] && [ -f "${{ runner.temp }}/image_tag.txt" ]; then
|
||||||
|
IMAGE_TAG="$(cat "${{ runner.temp }}/image_tag.txt")"
|
||||||
|
fi
|
||||||
|
if [ -z "$IMAGE_TAG" ]; then
|
||||||
|
echo "ERROR: IMAGE_TAG 为空,无法部署"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Deploying $REGISTRY/$APP:$IMAGE_TAG -> namespace lpt-$ENV"
|
||||||
|
kubectl set image "deployment/$APP" \
|
||||||
|
"$APP=$REGISTRY/$APP:$IMAGE_TAG" \
|
||||||
|
-n "lpt-$ENV" --record
|
||||||
|
kubectl rollout status "deployment/$APP" \
|
||||||
|
-n "lpt-$ENV" --timeout=5m
|
||||||
|
|
||||||
- name: Debug on failure
|
- name: Debug on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
|
ENV="${{ inputs.environment }}"
|
||||||
echo "=== Deployment Status ==="
|
echo "=== Deployment Status ==="
|
||||||
kubectl get deployment $APP -n lpt-${{ inputs.environment }}
|
kubectl get deployment "$APP" -n "lpt-$ENV" || true
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Pod Status ==="
|
echo "=== Pod Status ==="
|
||||||
kubectl get pods -n lpt-${{ inputs.environment }} -l app=$APP
|
kubectl get pods -n "lpt-$ENV" -l "app=$APP" || true
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Recent Events ==="
|
echo "=== Recent Events ==="
|
||||||
kubectl get events -n lpt-${{ inputs.environment }} --sort-by='.lastTimestamp' | tail -20
|
kubectl get events -n "lpt-$ENV" --sort-by='.lastTimestamp' | tail -20 || true
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Pod Describe (latest) ==="
|
echo "=== Pod Describe (latest) ==="
|
||||||
POD=$(kubectl get pods -n lpt-${{ inputs.environment }} -l app=$APP --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}')
|
POD=$(kubectl get pods -n "lpt-$ENV" -l "app=$APP" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}' 2>/dev/null || true)
|
||||||
kubectl describe pod $POD -n lpt-${{ inputs.environment }} || true
|
if [ -n "${POD:-}" ]; then
|
||||||
echo ""
|
kubectl describe pod "$POD" -n "lpt-$ENV" || true
|
||||||
echo "=== Pod Logs (latest) ==="
|
echo ""
|
||||||
kubectl logs $POD -n lpt-${{ inputs.environment }} --tail=50 || true
|
echo "=== Pod Logs (latest) ==="
|
||||||
|
kubectl logs "$POD" -n "lpt-$ENV" --tail=50 || true
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Rollback on failure
|
- name: Rollback on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
kubectl rollout undo deployment/$APP -n lpt-${{ inputs.environment }}
|
set -euo pipefail
|
||||||
kubectl rollout status deployment/$APP -n lpt-${{ inputs.environment }}
|
ENV="${{ inputs.environment }}"
|
||||||
|
kubectl rollout undo "deployment/$APP" -n "lpt-$ENV" || true
|
||||||
|
kubectl rollout status "deployment/$APP" -n "lpt-$ENV" || true
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# LPT AI 服务(lpt-ai)
|
||||||
|
|
||||||
|
> TypeScript + Fastify 独立 AI 服务,集成 SiliconFlow LLM,为后端提供异步 AI 任务。
|
||||||
|
|
||||||
|
## Git 提交规范
|
||||||
|
|
||||||
|
- 提交信息必须简短且使用中文,不要使用英文长句。
|
||||||
|
- 格式:`类型: 简述`,例如 `feat: 新增思维导图生成任务`、`fix: 修复任务队列TTL清理`、`docs: 补充环境变量说明`。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 运行时:Node.js 20+
|
||||||
|
- 框架:Fastify 5
|
||||||
|
- LLM:SiliconFlow OpenAI 兼容接口(Qwen/Qwen2.5-32B-Instruct)
|
||||||
|
- 运行时依赖:除 fastify 外无其他外部依赖
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
lpt-ai/src/
|
||||||
|
├── index.ts → 服务入口(加载 .env,启动 Fastify)
|
||||||
|
├── routes/ai.ts → AI 路由(health, tasks, fetch-title)
|
||||||
|
├── llm/
|
||||||
|
│ ├── client.ts → LLM 客户端(SiliconFlow 接口)
|
||||||
|
│ ├── prompts.ts → Prompt 模板(3 种任务类型)
|
||||||
|
│ └── prompts.test.ts
|
||||||
|
├── task-queue.ts → 异步任务队列
|
||||||
|
└── admin/
|
||||||
|
├── index.ts → 管理面板路由
|
||||||
|
├── routes.ts
|
||||||
|
└── store.ts → 日志存储
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 端点(5 个)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/health` | 健康检查(返回 LLM 可用状态和模型名) |
|
||||||
|
| POST | `/ai/tasks` | 提交异步任务(返回 taskId) |
|
||||||
|
| GET | `/ai/tasks/:taskId` | 轮询任务结果 |
|
||||||
|
| POST | `/fetch-title` | 抓取网页标题 |
|
||||||
|
| GET | `/admin` | 管理面板(查看最近 200 条日志) |
|
||||||
|
|
||||||
|
## 支持的任务类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 温度 | Max Tokens |
|
||||||
|
|------|------|------|------------|
|
||||||
|
| `aggregate-report` | 残片聚合为学习报告 | 0.3 | 2048 |
|
||||||
|
| `generate-mind-map` | 从报告/残片生成思维导图大纲 | 0.3 | 4096 |
|
||||||
|
| `compare-recall` | 用户回忆 vs 标准导图语义对比 | 0.2 | 4096 |
|
||||||
|
|
||||||
|
## 任务队列设计
|
||||||
|
|
||||||
|
- 异步任务模式:submit + poll,避免 LLM 长耗时(10-60s)超时。
|
||||||
|
- 单线程 Worker:`setImmediate` 链,同一时刻只处理一个 LLM 调用,防止 API 限流。
|
||||||
|
- TTL 清理:5 分钟一次,移除完成超过 1 小时的记录。
|
||||||
|
- 异常隔离:单个任务失败不影响后续任务。
|
||||||
|
- 管理面板日志:复用 `logStore`,记录最近 200 条请求/响应。
|
||||||
|
|
||||||
|
## LLM 配置
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_API_URL=https://api.siliconflow.cn/v1/chat/completions
|
||||||
|
LLM_API_KEY= # 必填,未配置时 /health 返回 llmAvailable: false
|
||||||
|
LLM_MODEL=Qwen/Qwen2.5-32B-Instruct
|
||||||
|
LLM_TIMEOUT_MS=60000 # 单次 LLM 调用超时
|
||||||
|
PORT=5199
|
||||||
|
```
|
||||||
|
|
||||||
|
## 降级策略
|
||||||
|
|
||||||
|
- LLM API Key 未配置 → `/health` 返回 `llmAvailable: false`。
|
||||||
|
- 后端(lpt-be)检测到 AI 不可用 → 自动切换到内置规则引擎(`BuiltinMindMapGenerator`)。
|
||||||
|
- Java 后端是唯一调用方;本服务不直接暴露给前端,不做鉴权(部署在内网)。
|
||||||
|
|
||||||
|
## 启动命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # 开发模式(tsx watch,热重载)
|
||||||
|
npm run build # 编译 TypeScript
|
||||||
|
npm run start # 生产模式
|
||||||
|
npm run test # 测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
- 多阶段构建:`node:20-alpine` 编译 → `node:20-alpine` 运行。
|
||||||
|
- 暴露端口:5199。
|
||||||
|
- 健康检查:`curl http://localhost:5199/health`。
|
||||||
|
|
||||||
|
## 关联项目
|
||||||
|
|
||||||
|
| 项目 | 路径 | 端口 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| lpt-be | `../lpt-be/` | 5157 | Spring Boot 后端,通过 `lpt.ai-service.url` 调用本服务 |
|
||||||
|
| lpt-fe | `../lpt-fe/` | 5158 | Vue 3 前端,不直接调用本服务 |
|
||||||
|
|
||||||
|
## 调用关系
|
||||||
|
|
||||||
|
```text
|
||||||
|
lpt-be (5157) ──HTTP POST /ai/tasks──→ lpt-ai (5199)
|
||||||
|
lpt-be (5157) ──HTTP GET /ai/tasks/:id──→ lpt-ai (5199)
|
||||||
|
```
|
||||||
@@ -85,7 +85,7 @@ curl http://localhost:5199/ai/tasks/550e8400-e29b-41d4-a716-446655440000
|
|||||||
| type | 说明 | params 字段 |
|
| type | 说明 | params 字段 |
|
||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| `aggregate-report` | 聚合学习残片为报告 | `taskName`, `expectation`, `fragments[]` |
|
| `aggregate-report` | 聚合学习残片为报告 | `taskName`, `expectation`, `fragments[]` |
|
||||||
| `generate-mind-map` | 生成思维导图大纲 | `taskName`, `reports[]`, `fragments[]`, `applications[]` |
|
| `generate-mind-map` | 生成思维导图大纲 | `taskName`, `reports[]`, `applications[]` |
|
||||||
| `compare-recall` | 对比用户回忆与标准导图 | `taskName`, `standardOutline`, `recallOutline` |
|
| `compare-recall` | 对比用户回忆与标准导图 | `taskName`, `standardOutline`, `recallOutline` |
|
||||||
|
|
||||||
## 架构位置
|
## 架构位置
|
||||||
|
|||||||
@@ -32,15 +32,14 @@ describe("aggregateReportPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("generateMindMapPrompt", () => {
|
describe("generateMindMapPrompt", () => {
|
||||||
it("要求输出缩进大纲且包含全部输入", () => {
|
it("要求输出缩进大纲且只包含学习报告", () => {
|
||||||
const messages = generateMindMapPrompt(
|
const messages = generateMindMapPrompt(
|
||||||
"Java 并发",
|
"Java 并发",
|
||||||
"并发编程学习",
|
"并发编程学习",
|
||||||
["报告一"],
|
["报告一"],
|
||||||
["残片一", "残片二"],
|
|
||||||
);
|
);
|
||||||
expect(messages[0].content).toContain("缩进");
|
expect(messages[0].content).toContain("缩进");
|
||||||
expect(messages[1].content).toContain("报告一");
|
expect(messages[1].content).toContain("报告一");
|
||||||
expect(messages[1].content).toContain("残片二");
|
expect(messages[1].content).not.toContain("学习残片");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-6
@@ -50,20 +50,19 @@ export function aggregateReportPrompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从学习报告和残片生成标准思维导图(预留,用于替代 Java 端 BuiltinMindMapGenerator)。
|
* 从学习报告生成标准思维导图(预留,用于替代 Java 端 BuiltinMindMapGenerator)。
|
||||||
* 输出为缩进大纲文本,与后端 MindMapTreeTool.parseOutline 格式一致。
|
* 输出为缩进大纲文本,与后端 MindMapTreeTool.parseOutline 格式一致。
|
||||||
*/
|
*/
|
||||||
export function generateMindMapPrompt(
|
export function generateMindMapPrompt(
|
||||||
taskName: string,
|
taskName: string,
|
||||||
taskDescription: string,
|
taskDescription: string,
|
||||||
reports: string[],
|
reports: string[],
|
||||||
fragments: string[],
|
|
||||||
): ChatMessage[] {
|
): ChatMessage[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: [
|
content: [
|
||||||
"你是一名知识结构整理助手。请阅读某个学习任务的全部学习报告和残片,",
|
"你是一名知识结构整理助手。请阅读某个学习任务的全部学习报告,",
|
||||||
"将其中的知识点整理成一份思维导图大纲。要求:",
|
"将其中的知识点整理成一份思维导图大纲。要求:",
|
||||||
"1. 第一行是导图根标题(任务主题);",
|
"1. 第一行是导图根标题(任务主题);",
|
||||||
"2. 之后每行一个节点,用两个空格的缩进表示层级;",
|
"2. 之后每行一个节点,用两个空格的缩进表示层级;",
|
||||||
@@ -81,9 +80,6 @@ export function generateMindMapPrompt(
|
|||||||
"",
|
"",
|
||||||
"学习报告:",
|
"学习报告:",
|
||||||
...reports.map((r, i) => `${i + 1}. ${r}`),
|
...reports.map((r, i) => `${i + 1}. ${r}`),
|
||||||
"",
|
|
||||||
"学习残片:",
|
|
||||||
...fragments.map((f, i) => `${i + 1}. ${f}`),
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
|
|||||||
@@ -160,12 +160,10 @@ function buildMessages(task: TaskRecord) {
|
|||||||
}
|
}
|
||||||
case "generate-mind-map": {
|
case "generate-mind-map": {
|
||||||
const reports = p.reports as string[] | undefined;
|
const reports = p.reports as string[] | undefined;
|
||||||
const fragments = p.fragments as string[] | undefined;
|
|
||||||
return generateMindMapPrompt(
|
return generateMindMapPrompt(
|
||||||
p.taskName as string,
|
p.taskName as string,
|
||||||
(p.taskDescription as string) ?? "",
|
(p.taskDescription as string) ?? "",
|
||||||
reports ?? [],
|
reports ?? [],
|
||||||
fragments ?? [],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "compare-recall": {
|
case "compare-recall": {
|
||||||
|
|||||||
Reference in New Issue
Block a user