800 lines
31 KiB
Markdown
800 lines
31 KiB
Markdown
# 深维面诊智能体 (Deepview) 后端工程规范 SPEC v1.2
|
||
|
||
> **Status**: APPROVED
|
||
> **Source**: 馨总智能体 (`xinzong-agent`) 完整开发周期复盘 + 医生助理业务需求
|
||
> **Scope**: 后端 SSE 桥接、Hermes AIAgent 集成、素材摄入管线、部署运维
|
||
> **Audience**: 任何参与本项目后端开发的工程师或 AI Agent
|
||
> **v1.2 变更**: MindPass 认证迁移至路径 B(验证委托),业务服务不再持有 JWT 签发密钥
|
||
|
||
---
|
||
|
||
## 一、架构总览:三层 + 双库
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ Angular 前端(SPA, Standalone Components) │
|
||
│ state.ts (Signal) ← GlobalSSEService │
|
||
│ 部署:/deepview/ 子路径 │
|
||
└─────────────────┬───────────────────────────────────┘
|
||
│ SSE + REST API
|
||
┌─────────────────▼───────────────────────────────────┐
|
||
│ deepview_sse.py(aiohttp 桥接层) │
|
||
│ 职责:JWT 认证、SSE 推送、异步任务编排 │
|
||
│ 端口:8645(仅监听 127.0.0.1) │
|
||
└─────────────────┬───────────────────────────────────┘
|
||
│ 同步调用
|
||
┌─────────────────▼───────────────────────────────────┐
|
||
│ Hermes AIAgent(run_agent.py) │
|
||
│ 职责:LLM 对话 + 工具调用(view_file / edit_file) │
|
||
│ 状态:SessionDB (SQLite) — 对话历史 │
|
||
│ 知识:storage/ 目录树(FS-as-Database) │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.1 铁律:不引入 Node.js 中间层
|
||
|
||
Python aiohttp 直接服务前端。这是馨总项目最重要的架构决策,消除了 MindOS 架构中 Node → Python 二次流转的复杂度。
|
||
|
||
### 1.2 双库模型
|
||
|
||
本项目同时存在两种存储介质,各有分工,**严禁混用**:
|
||
|
||
| 存储 | 介质 | 存什么 | 谁读谁写 |
|
||
|------|------|--------|----------|
|
||
| **SessionDB (SQLite)** | Hermes 内置 `state.db` | 对话历史、会话列表、钉选板、素材文件索引 | SSE 桥接层读写 |
|
||
| **storage/ 目录树 (FS)** | 本地文件系统 `.md` 文件 | 企业知识库、客户档案、医生风格画像 | Agent 读写(view_file / edit_file) |
|
||
|
||
> [!IMPORTANT]
|
||
> SessionDB 管"交互态"(用户和 AI 说了什么),storage/ 管"业务态"(客户是什么人、该怎么应对)。
|
||
> 两者唯一的交汇点在 `_runChat()` 中:Agent 从 SessionDB 加载对话历史,从 storage/ 加载知识上下文。
|
||
|
||
### 1.3 双上下文包模型(核心架构创新)
|
||
|
||
本项目存在两种截然不同的对话场景,各自对应不同的上下文加载策略。这与馨总智能体中「馨总 context」(全局知识库)vs「项目 context」(具体品牌项目)的设计完全同构。
|
||
|
||
| 入口页面 | contextId 格式 | 上下文包构成 | 业务语义 |
|
||
|---------|---------------|------------|----------|
|
||
| **笔记列表** → 点录音卡片 → X光片页 | `recording:{recordingId}` | wiki/ + doctors/{doc_id}.md + **单条 ASR 转写** | 基于**这一次**面诊的战术复盘 |
|
||
| **客户通** → 点客户卡片 → 托付档案页 | `client:{clientId}` | wiki/ + doctors/{doc_id}.md + **完整 profile.md + 全量 history/** | 基于**全部历史**的战略洞察 |
|
||
|
||
```
|
||
wiki/ (全局共享 - 始终加载)
|
||
gemini_rfm_rules.md etc.
|
||
|
|
||
+----------+----------+
|
||
| |
|
||
+--------v--------+ +--------v--------+
|
||
| Recording Context| | Client Context |
|
||
| (单次录音复盘) | | (全景客户档案) |
|
||
+-----------------+ +-----------------+
|
||
| 单条 ASR.md | | profile.md |
|
||
| + doctor.md | | + history/* |
|
||
| | | + doctor.md |
|
||
+-----------------+ +-----------------+
|
||
输出: X光片五模块 输出: 托付档案策略
|
||
(单次战术复盘) (全景战略洞察)
|
||
```
|
||
|
||
> [!IMPORTANT]
|
||
> **前端通过 `contextId` 字段告知后端当前处于哪种模式。** 后端根据 contextId 前缀(`recording:` 或 `client:`)决定加载哪个上下文包。前端不编排 Agent 行为,只传递入口标识。
|
||
|
||
---
|
||
|
||
## 二、storage/ 目录结构规范(FS-as-Database)
|
||
|
||
```text
|
||
backend/storage/
|
||
├── wiki/ # 【企业知识库 — 全局共享】
|
||
│ ├── gemini_rfm_rules.md # RFM 客群分级与召回策略
|
||
│ ├── focus_methodology.md # 焦点法则内训手册
|
||
│ └── product_knowledge.md # 品项知识库(乔雅登、保妥适等)
|
||
│
|
||
├── doctors/ # 【医生风格画像 — 按人隔离】
|
||
│ ├── doc_001.md # A主任:直球学术风、反感比喻
|
||
│ └── doc_002.md # B顾问:闺蜜温情风、喜欢寒暄
|
||
│
|
||
└── clients/ # 【客户档案 — 按客户隔离】
|
||
├── p_li001/ # 李女士
|
||
│ ├── profile.md # ★ 唯一真相源(由 Agent 增量更新)
|
||
│ └── history/ # 面诊录音 ASR 碎片
|
||
│ ├── 2024-03-01_asr.md
|
||
│ └── 2025-04-11_asr.md
|
||
└── p_zhang001/ # 张女士
|
||
├── profile.md
|
||
└── history/
|
||
```
|
||
|
||
### 2.1 profile.md 规范
|
||
|
||
每个客户的 `profile.md` 必须遵循固定的 Markdown 层级结构(参照 `doss/关键参考/客户档案示例.md`),Agent 在执行增量合并时**禁止修改章节骨架**,只能在已有章节下追加或修改条目。
|
||
|
||
标准章节列表:
|
||
1. `📚【档案摘要】` — 姓名、等级、LTV、NPS
|
||
2. `🟢 第一部分:客户全生命周期洞察`
|
||
- `1. 医疗美学偏好 & 物理体征`
|
||
- `2. 托付轨迹与真实拒因池`
|
||
- `3. 今日面诊军师策略`
|
||
3. `🔵 第二部分:信任引荐网络`
|
||
- `1. 社交节点价值估算`
|
||
- `2. 引荐关系图谱`
|
||
- `3. 圈层杠杆话术建议`
|
||
|
||
### 2.2 隔离与安全约束
|
||
|
||
- Agent 执行任务时,`SKILL.md` 中通过 `{{STORAGE_DIR}}` 占位符注入存储根路径,Agent 的 `view_file` / `edit_file` 权限**被约束在该目录树内**
|
||
- 禁止跨客户目录读取(Agent 不应在处理 `p_li001` 时去读 `p_zhang001/profile.md`)
|
||
- wiki/ 对所有 Agent 实例只读(Agent 不应修改企业知识库,管理员通过素材摄入管线更新)
|
||
|
||
---
|
||
|
||
## 三、七个封闭 SSE 事件(核心协议 — 照搬馨总)
|
||
|
||
**不可擅自新增事件。** 这是整个系统最重要的契约。
|
||
|
||
### 对话域(4 个)
|
||
|
||
| 事件名 | Payload | 产生源 | 消费者 |
|
||
|--------|---------|--------|--------|
|
||
| `agent:thinking` | `{chatId, step, message}` | Hermes `tool_progress("tool.started")` | 前端思考药丸 |
|
||
| `agent:chunk` | `{chatId, text}` | Hermes `stream_delta_callback(text)` | Markdown 流式渲染 |
|
||
| `agent:done` | `{chatId, fullAnswer}` | `run_conversation()` 返回 | 覆写终态文本 |
|
||
| `agent:error` | `{chatId, message}` | `run_conversation()` 异常 | 错误提示 |
|
||
|
||
### 业务域(3 个)
|
||
|
||
| 事件名 | Payload | 用途 |
|
||
|--------|---------|------|
|
||
| `material:done` | `{clientId, filename, fileId}` | 素材解析完成(录音 ASR / 文档摄入) |
|
||
| `pin:added` | `{chatId, pinId, summary}` | 钉选完成通知 |
|
||
| `wiki:updated` | `{wikiId}` | 企业知识库更新通知 |
|
||
|
||
### 必须丢弃的 3 个 Hermes 原始信号
|
||
|
||
| 信号 | 丢弃原因 |
|
||
|------|----------|
|
||
| `stream_delta(None)` | CLI 哨兵,无业务语义 |
|
||
| `tool_progress("tool.completed")` | 前端不需要,chunk 到来即隐含答案开始 |
|
||
| `tool_progress("reasoning.available")` | **与 delta 内容 100% 重复**,不丢弃会导致文字重复显示 |
|
||
|
||
> [!CAUTION]
|
||
> `reasoning.available` 是最阴险的坑。流式模式下,文本先通过 `stream_delta` 逐块到达、再通过此事件整块重发。前端如果同时消费两者,用户会看到一段话出现两次。
|
||
|
||
---
|
||
|
||
## 四、全量 API 接口定义
|
||
|
||
### 4.1 路由前缀
|
||
|
||
所有接口统一挂载在 `/deepview/` 子路径下。Nginx 反代到 `127.0.0.1:8645`。
|
||
|
||
### 4.2 认证
|
||
|
||
所有接口(除 `/health`)必须通过 MindPass JWT 认证:
|
||
- POST 请求:`Authorization: Bearer <token>` Header
|
||
- EventSource(SSE):`?token=<token>` Query Param(因为 EventSource 不支持自定义 Header)
|
||
|
||
### 4.3 接口清单
|
||
|
||
```
|
||
GET /deepview/health — 健康检查(无需认证)
|
||
GET /deepview/events?token=&connId= — SSE 长连接
|
||
POST /deepview/chat — 发起对话
|
||
GET /deepview/chat/history?chatId= — 获取单个对话历史
|
||
GET /deepview/sessions/list — 获取当前用户的会话列表
|
||
POST /deepview/materials/upload_token — 获取 OSS 直传凭证
|
||
POST /deepview/materials/confirm — 确认上传完成(触发后台解析)
|
||
GET /deepview/materials/list?contextId= — 获取素材列表
|
||
GET /deepview/pins/list — 获取钉选列表
|
||
POST /deepview/pins/add — 添加钉选
|
||
POST /deepview/pins/remove — 移除钉选
|
||
GET /deepview/clients/list — 获取客户列表(扫描 storage/clients/)
|
||
GET /deepview/clients/{id}/profile — 读取客户档案原始 Markdown
|
||
```
|
||
|
||
### 4.4 各接口详细定义
|
||
|
||
#### `POST /deepview/chat`
|
||
|
||
```json
|
||
// Request
|
||
{
|
||
"chatId": "chat_1712345678_abc",
|
||
"text": "这次面诊客户为什么拒绝了乔雅登?",
|
||
"contextId": "recording:r_001",
|
||
"doctorId": "doc_001"
|
||
}
|
||
|
||
// Response (HTTP only confirms receipt)
|
||
{ "received": true }
|
||
|
||
// Results delivered via SSE: agent:thinking -> agent:chunk -> agent:done
|
||
```
|
||
|
||
**`contextId` 双模式约定(类比馨总的 xinzong / probiotics):**
|
||
|
||
| contextId 格式 | 前端入口 | Agent 加载的上下文包 |
|
||
|---------------|---------|---------------------|
|
||
| `recording:{recordingId}` | 笔记列表 → X光片页底部输入框 | wiki/ + doctors/{doctorId}.md + **单条 ASR 文件** |
|
||
| `client:{clientId}` | 客户通 → 托付档案页底部输入框 | wiki/ + doctors/{doctorId}.md + **profile.md + history/*** |
|
||
|
||
> [!IMPORTANT]
|
||
> **前端只传 `contextId`,不负责告诉后端该读哪些文件。** 后端根据前缀 `recording:` 或 `client:` 自行决定上下文包的加载范围。
|
||
|
||
**后台执行流程(`_runChat`):**
|
||
```python
|
||
async def _runChat(self, userId, chatId, text, contextId, doctorId):
|
||
# 1. Load SKILL.md, inject storage path and doctor ID
|
||
skillPath = "skills/deepview_assistant/SKILL.md"
|
||
systemPrompt = read(skillPath)
|
||
systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir)
|
||
systemPrompt = systemPrompt.replace("{{DOCTOR_ID}}", doctorId or "unknown")
|
||
|
||
# 2. Route context pack based on contextId prefix
|
||
if contextId.startswith("recording:"):
|
||
# -- Recording Context Pack: single ASR + doctor profile --
|
||
recordingId = contextId.split(":", 1)[1]
|
||
clientId, asrPath = self._resolveRecording(recordingId)
|
||
systemPrompt += f"""
|
||
|
||
## Current Context Mode: Single Recording Review
|
||
- ASR file: {asrPath}
|
||
- Doctor profile: {storageDir}/doctors/{doctorId}.md
|
||
- Wiki directory: {storageDir}/wiki/
|
||
|
||
### Your focus
|
||
Analyze this single consultation recording for trust breakpoints,
|
||
communication vital signs, and improvement suggestions.
|
||
Do NOT read this client's full profile.md (that belongs to Client mode).
|
||
"""
|
||
|
||
elif contextId.startswith("client:"):
|
||
# -- Client Context Pack: full profile + all history + doctor --
|
||
clientId = contextId.split(":", 1)[1]
|
||
systemPrompt += f"""
|
||
|
||
## Current Context Mode: Full Client Profile
|
||
- Client directory: {storageDir}/clients/{clientId}/
|
||
- Core profile: {storageDir}/clients/{clientId}/profile.md
|
||
- History directory: {storageDir}/clients/{clientId}/history/
|
||
- Doctor profile: {storageDir}/doctors/{doctorId}.md
|
||
- Wiki directory: {storageDir}/wiki/
|
||
|
||
### Your focus
|
||
Provide pre-consultation strategy, social leverage analysis,
|
||
and cross-category icebreaker plans based on full lifecycle data.
|
||
Read profile.md first for overview, then dive into history/ as needed.
|
||
"""
|
||
|
||
else:
|
||
# Fallback: pure wiki Q&A
|
||
systemPrompt += f"\n\n## General Mode\nWiki directory: {storageDir}/wiki/\n"
|
||
|
||
# 3. Load conversation history (SessionDB)
|
||
history = db.get_messages_as_conversation(chatId)
|
||
|
||
# 4. Create AIAgent and execute
|
||
agent = AIAgent(
|
||
model="gemini-pro-vertex",
|
||
enabled_toolsets=["file"],
|
||
stream_delta_callback=...,
|
||
tool_progress_callback=...,
|
||
session_id=chatId,
|
||
session_db=db,
|
||
user_id=userId,
|
||
)
|
||
result = agent.run_conversation(
|
||
user_message=text,
|
||
system_message=systemPrompt,
|
||
conversation_history=history,
|
||
)
|
||
|
||
#### `POST /deepview/report/generate`
|
||
|
||
**用途:** 生成完整的 X光片报告(五模块)或托付档案(两段式)。
|
||
**机制:** 由于报告渲染需要严苛的属性结构(前端基于 JSON Schema 固定 HTML 模板强渲染),本接口采用 **同步 / 阻塞式 HTTP 响应(非 SSE 流式)**。内部由 LLM 输出纯 JSON,若解析失败自带最多 3 次重试。
|
||
|
||
```json
|
||
// Request
|
||
{
|
||
"contextId": "recording:r_001",
|
||
"doctorId": "doc_001"
|
||
}
|
||
|
||
// Response (成功)
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"module1": {
|
||
"acceptanceStatus": "局部接纳",
|
||
"trustIndex": 35,
|
||
"insight": "..."
|
||
},
|
||
// ... 匹配前端的强类型对象
|
||
}
|
||
}
|
||
|
||
// Response (3次重试全败)
|
||
{ "error": "JSON parse failed after 3 retries" }
|
||
```
|
||
|
||
> [!NOTE]
|
||
> 追问环节(页面底部的 Chat Box)依然使用 `POST /deepview/chat` 走 SSE Streaming 纯文本流,不需要 JSON。
|
||
|
||
#### `POST /deepview/materials/upload_token`
|
||
|
||
```json
|
||
// Request
|
||
{ "filename": "2025-04-11_面诊录音.mp3" }
|
||
|
||
// Response
|
||
{
|
||
"putUrl": "https://meetings-dev.oss-cn-beijing.aliyuncs.com/deepview-raw/...",
|
||
"ossKey": "deepview-raw/{userId}/{hash}.mp3",
|
||
"filename": "2025-04-11_面诊录音.mp3"
|
||
}
|
||
```
|
||
|
||
#### `POST /deepview/materials/confirm`
|
||
|
||
```json
|
||
// Request
|
||
{
|
||
"ossKey": "deepview-raw/xxx/abc123.mp3",
|
||
"filename": "2025-04-11_面诊录音.mp3",
|
||
"contextId": "p_li001", // ★ 关键:投递到哪个客户目录
|
||
"mediaType": "audio" // audio | pdf | docx | image
|
||
}
|
||
|
||
// Response(立即返回)
|
||
{ "received": true }
|
||
|
||
// 后台异步管线:OSS 下载 → ASR/文档解析 → 转 .md → 写入 storage/clients/{contextId}/history/
|
||
// 完成后推送 SSE material:done
|
||
```
|
||
|
||
#### `GET /deepview/clients/list`
|
||
|
||
```json
|
||
// Response
|
||
{
|
||
"clients": [
|
||
{
|
||
"id": "p_li001",
|
||
"name": "李女士",
|
||
"tier": "铂金会员",
|
||
"ltv": "8.5万",
|
||
"lastVisit": "2025-04-11"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
> [!NOTE]
|
||
> 实现方式:扫描 `storage/clients/` 下所有子目录,读取每个 `profile.md` 的前 10 行提取摘要字段。**无 SQL 查询**。
|
||
|
||
#### `GET /deepview/clients/{id}/profile`
|
||
|
||
```json
|
||
// Response
|
||
{
|
||
"id": "p_li001",
|
||
"markdown": "👧深维·美的托付档案\n深维·美的托付档案 DeepWise...",
|
||
"lastModified": "2025-04-11T14:30:00Z"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、素材摄入管线(AnyFile → Markdown)
|
||
|
||
### 5.1 统一流程
|
||
|
||
```
|
||
前端选文件 → POST /materials/upload_token(获取 OSS 凭证)
|
||
→ 前端直传 OSS(不经后端,节省带宽)
|
||
→ POST /materials/confirm { ossKey, filename, contextId, mediaType }
|
||
→ 后端异步:
|
||
┌─ audio → MindOS ASR API / DashScope ASR → 文本 .md
|
||
├─ pdf → Sniffer(横版/低密度→VLM,常规→pymupdf4llm)→ .md
|
||
├─ docx → python-docx → .md
|
||
└─ image → VLM 直接分析 → .md
|
||
→ 写入 storage/clients/{contextId}/history/{date}_{hash}.md
|
||
→ DB 记录(xinzong_materials 表)
|
||
→ SSE material:done
|
||
```
|
||
|
||
### 5.2 投递规则
|
||
|
||
素材解析后的 `.md` 文件投递到哪里,取决于 `contextId` 参数:
|
||
|
||
| contextId | 投递目标 | 含义 |
|
||
|-----------|---------|------|
|
||
| `p_li001` | `storage/clients/p_li001/history/` | 该客户的面诊碎片 |
|
||
| `wiki` | `storage/wiki/` | 企业知识库(需走 LLM-WIKI 清洗) |
|
||
| `doc_001` | `storage/doctors/doc_001.md` 追加 | 医生风格补充(极少使用) |
|
||
|
||
### 5.3 Sniffer 判断规则(照搬馨总验证通过)
|
||
|
||
```python
|
||
def _is_vlm_needed(pdf_path):
|
||
# 横版(PPT)→ VLM
|
||
if width > height and (width / height) > 1.2: return True
|
||
# 极低文字密度(扫描件)→ VLM
|
||
if (text_len / page_count) < 100: return True
|
||
# 正常文档 → pymupdf4llm 文本提取
|
||
return False
|
||
```
|
||
|
||
### 5.4 关键约束
|
||
|
||
- **不在 HTTP 请求链上调用 LLM**。HTTP 只返回 `{received: true}`,所有重活在 `asyncio.create_task()` 中
|
||
- **MD5 去重**:同一文件重复上传不重复处理
|
||
- **LLM 配置统一走 LiteLLM Gateway**,不使用单独的第三方密钥
|
||
|
||
---
|
||
|
||
## 六、SKILL.md — Agent 系统提示设计模板
|
||
|
||
### 6.1 标准结构(上下文无关的通用骨架)
|
||
|
||
SKILL.md 定义 Agent 的**角色人格和通用铁律**。它不包含具体的上下文路径——那些由 `_runChat()` 在运行时根据 `contextId` 前缀动态追加(见第四章 4.4 节)。
|
||
|
||
```markdown
|
||
---
|
||
name: deepview-assistant
|
||
description: 深维面诊智能军师
|
||
---
|
||
|
||
# 角色定义
|
||
你是「深维面诊 AI 军师」。你深谙"降维成交"、"焦点法则"与"客户心理学"。
|
||
|
||
## 核心铁律
|
||
1. **忠实原文**:所有诊断切入点必须 100% 基于实际文件中已记录的事实
|
||
2. **服从 Wiki**:战略推荐必须贯彻 wiki/ 下的方法论(如 RFM 分级)
|
||
3. **口吻适配**:输出前必须读取当前医生 doctors/ 档案,禁止使用其反感的术语
|
||
4. **AI 补充标记**:任何推理获取的信息必须标记 [AI 推断]
|
||
5. **超出范围拒答**:不回答与面诊无关的问题
|
||
6. **上下文边界**:严格遵守下方「当前上下文模式」指定的文件范围,不越界读取
|
||
|
||
## 领域术语表
|
||
| 术语 | 定义 | 注意 |
|
||
|------|------|------|
|
||
| LTV | 客户全生命周期价值 | 含个人消费 + 转介绍网络价值 |
|
||
| RFM | Recency-Frequency-Monetary | 八大客群分类标准 |
|
||
| 降维科普 | 用通俗语言解释医学原理 | 不同医生降维风格不同 |
|
||
| MD Codes | 面部支撑点注射技术 | 常见于乔雅登类品项 |
|
||
```
|
||
|
||
> [!NOTE]
|
||
> SKILL.md 末尾**不写死**上下文路径。`_runChat()` 会在运行时根据 `contextId` 的前缀动态追加 `## Current Context Mode` 章节(详见第四章 4.4 节的伪代码)。这与馨总智能体中 `{{KNOWLEDGE_BASE_DIR}}` / `{{PROJECTS_DIR}}` 占位符注入的机制完全一致。
|
||
|
||
### 6.2 双上下文模式下的 Agent 行为差异
|
||
|
||
同一个 SKILL.md,但 Agent 的工作重心和输出格式因上下文模式而异:
|
||
|
||
| 维度 | Recording 模式(单次录音复盘) | Client 模式(全景客户档案) |
|
||
|------|------------------------------|---------------------------|
|
||
| **Agent 首先读什么** | 单条 `history/{recordingId}.md` | `profile.md` 全文 |
|
||
| **次要参考** | wiki/ + doctors/{id}.md | history/*(按需深入)+ wiki/ + doctors/{id}.md |
|
||
| **输出体裁** | 五模块 X光片(接纳度、体征、断点、雷达、行动处方) | 两段式托付档案(全生命周期洞察 + 社交网络杠杆) |
|
||
| **典型用户提问** | "这次面诊客户为什么拒绝了乔雅登?" | "李女士下午来面诊,帮我准备破冰策略" |
|
||
| **禁止行为** | 不应读取 profile.md(避免与全景模式重叠) | 不应只看单条录音(应综合全量历史) |
|
||
|
||
### 6.3 占位符注入(运行时替换)
|
||
|
||
```python
|
||
# SKILL.md 中的通用占位符(由 _runChat 替换)
|
||
systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir)
|
||
systemPrompt = systemPrompt.replace("{{DOCTOR_ID}}", doctorId)
|
||
|
||
# 上下文模式章节由 contextId 分支逻辑动态拼接(不写在 SKILL.md 里)
|
||
# 见第四章 4.4 节的 _runChat 伪代码
|
||
```
|
||
|
||
### 6.4 关键设计:免 RAG 的 Agentic 翻阅
|
||
|
||
**不使用向量检索(RAG)**。让 Agent 用 `list_dir` + `view_file` 工具自主翻阅 storage/ 目录。
|
||
|
||
理由:
|
||
- 本项目对 Token 不敏感,在意内容准确性
|
||
- 向量分块必然造成上下文丢失(如 RFM 规则跨段被切断)
|
||
- Agent 自主翻阅 = 100% 完整上下文 + 零召回损失
|
||
- 双上下文模式下,Agent 被明确告知了要读的文件路径,不需要"检索"
|
||
|
||
---
|
||
|
||
## 七、MindPass SSO 集成(路径 B:验证委托)
|
||
|
||
### 7.1 设计原则
|
||
|
||
**本服务不持有 MindPass 的 JWT 签发密钥。** 所有 Token 验证通过 HTTP 调用 MindPass 中央认证服务(端口 3020)的 `/api/auth/me` 端点完成。
|
||
|
||
| 路径 | 做法 | 本项目选择 |
|
||
|------|------|-----------|
|
||
| A. 各自持有密钥 | 各项目 `.env` 各放一份 `MINDPASS_JWT_SECRET`,本地 `pyjwt.decode` | ❌ **不推荐** — 密钥分散管理、轮换困难、违反最小权限 |
|
||
| B. 验证委托 | 业务服务不持有密钥,调用 MindPass `/api/auth/me` 验证 Token | ✅ **当前方案** — 密钥集中管控在 MindPass 中 |
|
||
|
||
> [!IMPORTANT]
|
||
> **严禁共享 .env 符号链接。** 业务服务不应以任何形式持有 JWT 签发密钥。正确的做法是让 MindPass 作为唯一的密钥持有者,业务服务只做消费者。
|
||
|
||
### 7.2 后端验证逻辑
|
||
|
||
```python
|
||
MINDPASS_BASE_URL = os.environ.get("MINDPASS_BASE_URL", "http://127.0.0.1:3020")
|
||
|
||
# 内存缓存(TTL 60s),避免同一 token 高频重复请求 MindPass
|
||
_tokenCache: Dict[str, tuple] = {} # token -> (userDict, expireTimestamp)
|
||
|
||
async def _verifyTokenAsync(token: str) -> Optional[dict]:
|
||
"""
|
||
★ 路径 B:委托 MindPass 验证 Token。
|
||
请求 MindPass /api/auth/me,返回 {userId, phone, name, avatarUrl} 或 None。
|
||
"""
|
||
# 1. 检查缓存
|
||
cached = _tokenCache.get(token)
|
||
if cached and cached[1] > time.time():
|
||
return cached[0]
|
||
|
||
# 2. 调用 MindPass
|
||
async with ClientSession(timeout=ClientTimeout(total=5)) as session:
|
||
async with session.get(
|
||
f"{MINDPASS_BASE_URL}/api/auth/me",
|
||
headers={"Authorization": f"Bearer {token}"},
|
||
) as resp:
|
||
if resp.status != 200:
|
||
return None
|
||
data = await resp.json()
|
||
user = {
|
||
"userId": data["id"], # MindPass 返回的 id = JWT sub
|
||
"phone": data.get("phone", ""),
|
||
"name": data.get("name", ""),
|
||
"avatarUrl": data.get("avatarUrl", ""),
|
||
}
|
||
_tokenCache[token] = (user, time.time() + 60)
|
||
return user
|
||
```
|
||
|
||
**MindPass `/api/auth/me` 返回格式:**
|
||
```json
|
||
{
|
||
"id": "11c1cece-2422-41e7-86f0-1f54b6162b95",
|
||
"phone": "18926156985",
|
||
"name": "李东方",
|
||
"role": "authenticated",
|
||
"avatarUrl": "https://meetings-dev.oss-cn-guangzhou.aliyuncs.com/mindos/avatars/xxx.png"
|
||
}
|
||
```
|
||
|
||
### 7.3 前端 Token Key 隔离
|
||
|
||
```typescript
|
||
const TOKEN_KEY = 'deepview_token'; // ★ 独立于 xinzong_token
|
||
const USER_KEY = 'deepview_user';
|
||
```
|
||
|
||
### 7.4 SSE 连接
|
||
|
||
```typescript
|
||
connect(token: string) {
|
||
this.source = new EventSource(`/deepview/events?token=${token}&connId=${connId}`);
|
||
}
|
||
```
|
||
|
||
### 7.5 教训:AIAgent 必须传 user_id
|
||
|
||
```python
|
||
# ❌ 忘传 user_id(sessions 表无法按用户过滤)
|
||
agent = AIAgent(session_id=chatId, session_db=db, platform="deepview")
|
||
|
||
# ✅ user_id 从 MindPass 验证结果获取,写入 sessions 表
|
||
agent = AIAgent(session_id=chatId, session_db=db, platform="deepview", user_id=userId)
|
||
```
|
||
|
||
### 7.6 教训:自动迁移孤儿记录
|
||
|
||
首次真实用户登录时,把 `user_id=NULL` 或 `dev_user_001` 的开发期记录归属到当前用户:
|
||
|
||
```python
|
||
if userId != "dev_user_001":
|
||
conn.execute(
|
||
"UPDATE sessions SET user_id = ? WHERE source = 'deepview' AND "
|
||
"(user_id IS NULL OR user_id = 'dev_user_001')", (userId,))
|
||
```
|
||
|
||
---
|
||
|
||
## 八、部署规范(ECS + Nginx 子路径)
|
||
|
||
本后端遵循 **"技能包外挂模型 (Skill-As-A-Package)"**,通过 `pip install -e` 将 Hermes 引擎挂载为底层库。**不直接复制代码** 到本仓库中,彻底避免分叉引发的引擎漂移。
|
||
|
||
### 8.1 极致精简的服务目录
|
||
|
||
```text
|
||
/opt/apps/deepview-agent/backend/
|
||
├── .env # 深维专属环境变量(8645 端口、特殊 OSS Key)
|
||
├── gateway/
|
||
│ └── platforms/
|
||
│ ├── deepview_sse.py # 唯一网关入口
|
||
│ └── deepview_materials.py # 素材管理管线
|
||
├── skills/
|
||
│ └── deepview_assistant/
|
||
│ └── SKILL.md # 纯 Markdown 指令注入 (不硬编码路径)
|
||
└── storage/ # 物理隔离的业务数据(clients/, doctors/, wiki/)
|
||
```
|
||
|
||
### 8.2 环境装配与引擎挂载
|
||
|
||
由于底层 Hermes 引擎采用 `uv` 与 Python 3.11+ 严格管理依赖,我们不需要在深维内部重复建立 `venv`。深维的代码直接复用引擎的运行时环境,实现完美的“外挂接入”:
|
||
|
||
```bash
|
||
# 假设底层 Hermes 引擎位于 /opt/apps/hermess
|
||
# 直接使用 Hermes 的 Python 解释器执行深维网关,并将 Hermes 源码路径加入 PYTHONPATH
|
||
export PYTHONPATH=/opt/apps/hermess
|
||
/opt/apps/hermess/.venv/bin/python gateway/platforms/deepview_sse.py
|
||
```
|
||
|
||
需要在底层 Hermes 的虚拟环境中补充我们特有的额外依赖:
|
||
```bash
|
||
/opt/apps/hermess/.venv/bin/pip install aiohttp pyjwt oss2
|
||
```
|
||
|
||
### 8.3 systemd 服务 (外挂守护进程)
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=Deepview Agent SSE Server
|
||
After=network.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=root
|
||
WorkingDirectory=/opt/apps/deepview-agent/backend
|
||
EnvironmentFile=/opt/apps/deepview-agent/backend/.env
|
||
Environment=PYTHONPATH=/opt/apps/hermess
|
||
# 核心:复用底层引擎的 Python 环境
|
||
ExecStart=/opt/apps/hermess/.venv/bin/python gateway/platforms/deepview_sse.py
|
||
Restart=always
|
||
RestartSec=5
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
### 8.4 Nginx Snippet
|
||
|
||
```nginx
|
||
# /etc/nginx/snippets/deepview.conf
|
||
|
||
# API 路由(必须在 SPA try_files 之前,使用 ^~)
|
||
location ^~ /deepview/events {
|
||
proxy_pass http://127.0.0.1:8645/deepview/events;
|
||
proxy_http_version 1.1;
|
||
proxy_buffering off; # ★ SSE 必须关闭缓冲
|
||
proxy_read_timeout 86400s; # ★ 24h 超时
|
||
proxy_set_header Connection '';
|
||
proxy_set_header Host $host;
|
||
}
|
||
|
||
location ^~ /deepview/chat { proxy_pass http://127.0.0.1:8645/deepview/chat; proxy_http_version 1.1; ... }
|
||
location ^~ /deepview/sessions { proxy_pass http://127.0.0.1:8645/deepview/sessions; proxy_http_version 1.1; ... }
|
||
location ^~ /deepview/pins { proxy_pass http://127.0.0.1:8645/deepview/pins; proxy_http_version 1.1; ... }
|
||
location ^~ /deepview/materials { proxy_pass http://127.0.0.1:8645/deepview/materials; proxy_http_version 1.1; ... }
|
||
location ^~ /deepview/clients { proxy_pass http://127.0.0.1:8645/deepview/clients; proxy_http_version 1.1; ... }
|
||
location ^~ /deepview/health { proxy_pass http://127.0.0.1:8645/deepview/health; }
|
||
|
||
# SPA 兜底(在所有 API 路由之后)
|
||
location = /deepview { return 301 /deepview/; }
|
||
location ^~ /deepview/ {
|
||
root /opt/apps/deepview-agent/frontend;
|
||
index index.html;
|
||
try_files $uri $uri/ /deepview/index.html;
|
||
}
|
||
```
|
||
|
||
### 8.4 环境变量 (.env)
|
||
|
||
```bash
|
||
# LLM
|
||
GEMINI_API_KEY=sk-xxx
|
||
GEMINI_BASE_URL=http://127.0.0.1:4000/v1
|
||
DEEPVIEW_MODEL=gemini-pro-vertex
|
||
|
||
# Auth(路径 B:验证委托,不持有密钥)
|
||
MINDPASS_BASE_URL=http://127.0.0.1:3020
|
||
# ★ 不需要 MINDPASS_JWT_SECRET!密钥由 MindPass 中央管控。
|
||
# 本地开发免登录:设置 DEEPVIEW_AUTH_DEV_MODE=1
|
||
|
||
# OSS(素材直传)
|
||
ALIYUN_ACCESS_KEY_ID=xxx
|
||
ALIYUN_ACCESS_KEY_SECRET=xxx
|
||
OSS_BUCKET=meetings-dev
|
||
OSS_ENDPOINT=oss-cn-beijing.aliyuncs.com
|
||
|
||
# Paths
|
||
DEEPVIEW_STORAGE_DIR=/opt/apps/deepview-agent/backend/storage
|
||
```
|
||
|
||
---
|
||
|
||
## 九、前端对接契约
|
||
|
||
### 9.1 前端状态管理(Signal-Based)
|
||
|
||
```typescript
|
||
// state.ts — 核心 Signal 定义
|
||
export const currentUser = signal<User | null>(null);
|
||
export const chatMessages = signal<Map<string, Message[]>>(new Map());
|
||
export const isThinking = signal(false);
|
||
export const pinnedItems = signal<Pin[]>([]);
|
||
export const materialFiles = signal<MaterialFile[]>([]);
|
||
```
|
||
|
||
### 9.2 前端绝对禁止的行为
|
||
|
||
1. **前端不编排后端**。Angular 只做 `POST /chat` + `SSE.on('agent:done', handler)`
|
||
2. **不在 HTTP 返回值中读取业务状态**(无 `shouldProbe` / `nextQuestion` 等字段)
|
||
3. **不自行决定是否显示档案**。客户列表和档案内容皆由后端 API 统一提供
|
||
|
||
### 9.3 乐观更新模式
|
||
|
||
```typescript
|
||
// 添加钉选:先更新 UI,再 fire-and-forget 写后端
|
||
pinnedItems.update(old => [newPin, ...old]);
|
||
fetch('/deepview/pins/add', { method: 'POST', body: ... })
|
||
.catch(err => console.warn('Pin persist failed:', err));
|
||
```
|
||
|
||
---
|
||
|
||
## 十、血泪教训合集(17 条)
|
||
|
||
### 架构类
|
||
1. **不引入 Node 中间层**。aiohttp 直连前端,链路越短越稳
|
||
2. **SSE 事件封闭集**。不经审批不加事件,防止"事件爆炸"
|
||
3. **HTTP 只返回 `{received: true}`**。业务状态全走 SSE
|
||
4. **前端不编排后端**。Angular 只做 `POST` + `SSE.on()`
|
||
|
||
### 状态类
|
||
5. **SessionDB 管交互、storage/ 管业务**。严禁混用
|
||
6. **AIAgent 必须传 `user_id`**。忘传 = 数据孤立无法过滤
|
||
7. **自动迁移 > 手动 SQL**。用户首次登录自动归属孤儿记录
|
||
8. **前端乐观更新 + fire-and-forget API**。不阻塞 UI
|
||
|
||
### SSE 类
|
||
9. **丢弃 `reasoning.available`**。与 delta 重复,不丢 = 文字重复
|
||
10. **丢弃 `stream_delta(None)`**。CLI 哨兵,无业务语义
|
||
11. **三层隔离**:userId × connId × chatId。多 Tab 不串
|
||
|
||
### 部署类
|
||
12. **Nginx location 优先级**。API 路由用 `^~` 且放在 SPA `try_files` 之前
|
||
13. **SSE endpoint 必须关 buffering + 设 24h 超时**
|
||
14. **`proxy_pass` 无尾斜杠保留完整路径**
|
||
|
||
### Prompt 类
|
||
15. **免 RAG 的 Agentic 翻阅**。让 Agent 用 `list_dir` + `view_file` 自主翻阅,比 RAG 更精准
|
||
16. **答案与证据分离**。输出分三层(策略 → 话术 → 数据来源声明)
|
||
17. **profile.md 章节骨架不可变**。Agent 只能追加内容,不能重构文档结构
|
||
|
||
---
|
||
|
||
## 十一、新项目启动 Checklist
|
||
|
||
```
|
||
[ ] 1. 复制 xinzong_sse.py → deepview_sse.py,全局替换 xinzong → deepview
|
||
[ ] 2. 修改端口(8643 → 8645)
|
||
[ ] 3. 复制 SKILL.md,改为面诊军师的领域知识和术语表
|
||
[ ] 4. 复制 xinzong_materials.py → deepview_materials.py,改路径常量
|
||
[ ] 5. 新增 /clients/list 和 /clients/{id}/profile 两个接口(扫描 storage/)
|
||
[ ] 6. 复制前端 state.ts / sse.service.ts,改 key 前缀和 API 路径
|
||
[ ] 7. 复制 auth/ 组件,改 token key 名称
|
||
[ ] 8. 准备 storage/ 目录(wiki/ + doctors/ + clients/)
|
||
[ ] 9. 在 .env 中配置 MINDPASS_BASE_URL + LLM API keys(★ 不需要 JWT Secret)
|
||
[ ] 10. 创建 systemd service + Nginx snippet
|
||
[ ] 11. 部署并验证 /deepview/health
|
||
[ ] 12. 在新浏览器验证跨设备数据同步
|
||
```
|
||
|
||
---
|
||
|
||
*SPEC v1.2 — 2026-04-12 — MindPass 认证从路径 A(本地持有密钥)迁移至路径 B(验证委托),业务服务不再持有 JWT 签发密钥*
|