feat: implementation of markdown-first persistence architecture

This commit is contained in:
lidf 2026-04-12 20:45:15 +08:00
parent 24fe3c0f7a
commit 9857498632
9 changed files with 618 additions and 135 deletions

View File

@ -0,0 +1,48 @@
# Deployment Philosophy Guard: Immutable Configuration Pattern
## 原则目标 (Objective)
在编写 `deploy.sh` 部署脚本或操作服务器环境配置时,彻底避免 `tar` 打包的上下文导致的配置文件覆盖事故。彻底物理阻断**“代码包发布”**与**“生产环境参数”**之间的偶发性交叉。
---
## 核心漏洞分析 (The "Tar Path Trap")
在历史上发生的灾难级线上配置丢失案中,部署脚本中常见以下形式的打包语句:
```bash
# ❌ 错误示范:致命的 tar 路径陷阱
tar czf backend.tar.gz --exclude='backend/.env' -C backend .
```
**原因解析:**
由于使用了 `-C backend .` 指令,`tar` 的作用域已经全盘切换至 `backend` 目录下。此时 `.env` 文件的相对路径变成了 `./.env`,而 `--exclude='backend/.env'` 变得形同虚设!
结果是:开发者本地带有大量 `dev_user_001` 等测试占位符的 `.env` 会被强制打包进 `.tar.gz` 中,在远端 `tar xzf` 解压时,产生毁灭性的生产环境覆盖。
---
## 运维铁律 (Systemd & Environment Boundary)
**禁止仅仅依靠 `--exclude` 来维护安全,必须从物理架构上进行环境解耦。**
1. **环境变量物理隔离Systemd 级别)**
- 所有的环境变量配置文件 `.env` 必须在**代码解压目录的上层目录**或其他专门用于运行时的配置目录。
- ❌ `EnvironmentFile=/opt/apps/my-agent/backend/.env` (危险)
- ✅ `EnvironmentFile=/opt/apps/my-agent/.env` (安全)
2. **静默失效原则**
- 当 `EnvironmentFile` 被定位到代码目录之外,意味着无论部署脚本在后续执行 `tar xz` 时是否不小心夹带了开发版 `.env` 放进 `my-agent/backend/` 中,`systemd` 都将对那堆错误文件置若罔闻。这使得错误打包本身自动降维成为无影响的“垃圾文件”。
---
## 反面模式 (Anti-Patterns)
| ❌ 禁止模式 (Anti-pattern) | ✅ 推荐模式 (Best Practice) | 理由 |
|---|---|---|
| 修改 Systemd 指向代码解压目录下的 `.env` | Systemd 指向工程根目录或 `/etc/` 等专用配置目录的 `.env` | 防止发版引起生产配置丢失 |
| 服务器脚本中用 `sed -i` 试图修补上传的 `.env` | 部署脚本在判断父目录 `.env` 不存在时才提供空模板,有则不碰 | 防止覆盖,保证配置只进行增量挂载 |
| 在自动化部署脚本中试图把 `keys` 写死 | SSH 下发命令在控制台提醒:`"请手动编辑上一层目录的 .env"` | `BYOK` 原则与安全交接 |
---
## 出发前自检 (Checklist)
当你修改或新建类似 `deploy_*.sh` 或编写 `.service` 注册脚本时,请进行下述三项灵魂拷问:
- [ ] 当前的 `tar --exclude` 是否和 `-C` 标签出现了上下文冲突?
- [ ] 脚本或服务中指向的 `.env` 路径属于**不被代码包直接覆盖的独立目录**吗?
- [ ] 服务器初始化脚本如果写入 `cat > .../.env`,外面有包一层 `if [ ! -f ... ]` 存在即跳过的防覆盖保护吗?

View File

@ -661,7 +661,7 @@ class DeepviewSSEServer:
return web.json_response({"error": "Missing contextId"}, status=400, headers=_CORS_HEADERS)
userObj = await self._extractUser(request)
userId = userObj.get("sub", "unknown") if userObj else "unknown"
userId = userObj["userId"] if userObj else "unknown"
userDir = self._getUserStorageDir(userId)
# 解析 orgId
@ -698,12 +698,16 @@ class DeepviewSSEServer:
asrId = parts[-1]
asrPath = f"{userDir}/clients/{clientId}/history/{asrId}.md"
clientProfilePath = f"{userDir}/clients/{clientId}/profile.md"
reportDraftPath = f"{userDir}/clients/{clientId}/history/{asrId}/report_draft.md"
else:
# 新格式Inboxrecording:asrId
asrId = parts[0]
asrPath = f"{userDir}/inbox/{asrId}/asr.md"
clientProfilePath = None
reportDraftPath = f"{userDir}/inbox/{asrId}/report_draft.md"
systemPrompt += f"\n## 🎙️ 第1模式单条面诊录音复盘\n你的唯一任务是提取基于这通录音的战术复盘。\n录音路径:{asrPath}\n医生档案参考:{userDir}/doctor_profile.md"
systemPrompt += f"\n\n📄 【输出要求】将完整的分析报告写入以下路径:{reportDraftPath}"
systemPrompt += f"\n文件第一行必须为来源标注:<!-- provenance: ai-single | level: L1 | sources: [{os.path.basename(os.path.dirname(asrPath))}/asr.md] -->"
if clientProfilePath and os.path.exists(clientProfilePath):
systemPrompt += f"\n客户档案参考:{clientProfilePath}"
systemPrompt += f"\n\n🚨 【系统硬约束Speaker 天然推断指令】"
@ -711,6 +715,7 @@ class DeepviewSSEServer:
systemPrompt += f"\n在输出所有报告正文时,请直接使用真实姓名,彻底抹除 Speaker_X 痕迹。"
else:
clientId = contextId.split(":", 1)[1] if ":" in contextId else contextId
reportDraftPath = None # 全景档案模式由 _generateClientProfile 处理
systemPrompt += f"\n## 👤 第2模式客户全景档案战略\n你的唯一任务是生成该客户的全景战略洞察报告。\n档案路径:{userDir}/clients/{clientId}/profile.md\n历史录音目录:{userDir}/clients/{clientId}/history/\n医生风格:{userDir}/doctor_profile.md"
# ── Stage 1 内容引擎: Hermes AIAgent (md2md) ──
# 只负责生成富文本分析报告,不管格式
@ -742,16 +747,33 @@ class DeepviewSSEServer:
user_id=userId,
)
userMsg = f"请根据给定的上下文文件生成完整的面诊沟通X光片分析报告。按照章节 checklist 确保不遗漏任何模块。"
if reportDraftPath:
userMsg += f" 将报告写入 {reportDraftPath}"
result = await loop.run_in_executor(
None,
lambda: agent.run_conversation(
user_message="请根据给定的上下文文件生成完整的面诊沟通X光片分析报告。按照章节 checklist 确保不遗漏任何模块。",
user_message=userMsg,
system_message=systemPrompt,
),
)
# md-first: 优先从 Agent 写入的物理文件读取,兜底 final_response
if reportDraftPath and os.path.exists(reportDraftPath):
with open(reportDraftPath, "r", encoding="utf-8") as f:
mdReport = f.read().strip()
logger.info(f"[DeepviewSSE] Stage 1 done (from report_draft.md), length={len(mdReport)} chars")
else:
mdReport = result.get("final_response", "").strip()
logger.info(f"[DeepviewSSE] Stage 1 (Hermes md2md) done, length={len(mdReport)} chars")
logger.info(f"[DeepviewSSE] Stage 1 done (from final_response), length={len(mdReport)} chars")
# 兜底:如果 Agent 返回了内容但未写文件,帮它落盘(审计留痕)
if reportDraftPath and mdReport and len(mdReport) > 100:
os.makedirs(os.path.dirname(reportDraftPath), exist_ok=True)
with open(reportDraftPath, "w", encoding="utf-8") as f:
f.write(f"<!-- provenance: ai-single | level: L1 | sources: [{contextId}] -->\n")
f.write(mdReport)
logger.info(f"[DeepviewSSE] Fallback: wrote report_draft.md from final_response")
if not mdReport or len(mdReport) < 100:
return web.json_response({"error": "Stage 1 报告内容为空"}, status=500, headers=_CORS_HEADERS)
@ -1582,7 +1604,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
import time
clientId = f"p_{str(uuid.uuid4())[:8]}"
userDir = self._getUserStorageDir(user.get("sub", "unknown"))
userDir = self._getUserStorageDir(user["userId"])
clientDir = os.path.join(userDir, "clients", clientId)
os.makedirs(clientDir, exist_ok=True)
@ -1622,7 +1644,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
if not clientId:
return web.json_response({"error": "Missing client ID"}, status=400, headers=_CORS_HEADERS)
userDir = self._getUserStorageDir(user.get("sub", "unknown"))
userDir = self._getUserStorageDir(user["userId"])
profilePath = os.path.join(userDir, "clients", clientId, "profile.md")
content = ""
@ -1661,17 +1683,35 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
return web.json_response({"error": "Missing reportId or clientId"}, status=400, headers=_CORS_HEADERS)
userId = user["userId"]
userDir = self._getUserStorageDir(user.get("sub", "unknown"))
userDir = self._getUserStorageDir(user["userId"])
# 1. 物理文件迁移users/{userId}/inbox/{reportId}/ → users/{userId}/clients/{clientId}/history/{reportId}/
# 1. 从 DB 中查询真实的 context_id 从而获取 ASR ID执行物理文件迁移
import shutil
inboxDir = os.path.join(userDir, "inbox", reportId)
from hermes_state import SessionDB
db = SessionDB()
with db._lock:
ctx_row = db._conn.execute(
"SELECT context_id FROM deepview_reports_v2 WHERE report_id=? AND user_id=?",
(reportId, userId)
).fetchone()
originalCtx = ctx_row[0] if ctx_row else ""
inboxDir = None
if originalCtx.startswith("recording:"):
ctxPayload = originalCtx.split(":", 1)[1]
if "/" not in ctxPayload:
asrId = ctxPayload
inboxDir = os.path.join(userDir, "inbox", asrId)
targetDir = os.path.join(userDir, "clients", clientId, "history", reportId)
if os.path.exists(inboxDir):
if inboxDir and os.path.exists(inboxDir):
os.makedirs(os.path.dirname(targetDir), exist_ok=True)
shutil.move(inboxDir, targetDir)
logger.info(f"[DeepviewSSE] Archived {inboxDir}{targetDir}")
else:
logger.warning(f"[DeepviewSSE] Inbox dir not found or already migrated for {reportId}, skip file migration")
# 2. DB 更新:标记 client_id + 更新 context_id
try:
@ -1714,7 +1754,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
# 异步触发客户全景报告生成/刷新
try:
userObj = await self._extractUser(request)
userSub = userObj.get("sub", "unknown") if userObj else "unknown"
userSub = userObj["userId"] if userObj else "unknown"
userStorageDir = self._getUserStorageDir(userSub)
asyncio.create_task(self._generateClientProfile(userId, clientId, userStorageDir, userSub))
logger.info(f"[DeepviewSSE] Async client profile generation triggered for {clientId}")
@ -1769,24 +1809,24 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
systemPrompt = f.read()
systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir)
systemPrompt = systemPrompt.replace(f"{storageDir}/wiki/", f"{orgDir}/wiki/")
systemPrompt += f"\n\n## 规则遵循\n企业知识库:{orgDir}/wiki/\n平台规则:{platformDir}/wiki/\n"
systemPrompt += f"\n\n## 规则遵循\n企业知识库:{orgDir}/wiki/\n平台规则:{platformDir}/wiki/\n\n"
systemPrompt += f"""\n## 👤 客户全景档案生成模式\n你的唯一任务是基于该客户的所有历史面诊录音,生成一份聚合式的客户全景档案。
档案路径{profileMdPath}
历史录音目录{historyDir}/
医生风格{userDir}/doctor_profile.md
# Read phase 1 prompt from external file
stage1PromptPath = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"skills", "deepview_profile", "PROMPT_stage1.md",
)
if os.path.exists(stage1PromptPath):
with open(stage1PromptPath, "r", encoding="utf-8") as f:
stage1Prompt = f.read()
else:
stage1Prompt = "## 👤 客户全景档案生成模式\n请生成档案。" # Fallback
## 输出必须包含的章节(缺一不可)
1. 客户画像审美底线痛感耐受度决策风格每条必须引用原始录音文件名和大致时间戳作为证据
2. 信任轨迹已信任项目列表含证据+ 被拒项目列表含拒绝次数原声引用AI拒因分析
3. 面诊统计汇总基于所有录音的平均信任指数趋势核心诉求最近一次接纳度
4. 下次面诊准备要点客户遗留的未解决问题曾主动询问但未成交的项目
stage1Prompt = stage1Prompt.replace("{profileMdPath}", profileMdPath)
stage1Prompt = stage1Prompt.replace("{historyDir}", historyDir)
stage1Prompt = stage1Prompt.replace("{userDir}", userDir)
## 🚨 绝对禁止
- 禁止编造任何"话术"破冰话术价值转化话术等这不是你的工作
- 禁止编造 CRM 数据会员等级LTVNPS 评分等你没有这些数据源
- 所有洞察必须附带录音来源引用找不到证据的字段留空
"""
systemPrompt += stage1Prompt
mdReport = ""
try:
@ -1794,6 +1834,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
from hermes_state import SessionDB
db = SessionDB()
import uuid
agent = AIAgent(
model=os.getenv("DEEPVIEW_MODEL", "gemini-pro-vertex"),
enabled_toolsets=["file"],
@ -1804,16 +1845,28 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
user_id=userId,
)
import uuid
result = await loop.run_in_executor(
None,
lambda: agent.run_conversation(
user_message=f"请读取 {profileMdPath}{historyDir}/ 下所有历史录音文件,生成该客户的全景档案报告。每条洞察必须标注来源录音文件名。",
user_message=(
f"请读取 {historyDir}/ 下所有历史录音文件(优先读取 report_draft.md 摘要,必要时回溯 asr.md 原文验证),"
f"生成该客户的全景档案报告并写入 {profileMdPath}"
f"每条洞察必须标注来源录音文件名。"
f"文件第一行必须为:<!-- provenance: ai-aggregate | level: L2 | sources: [{', '.join(sourceRecordings)}] -->"
),
system_message=systemPrompt,
),
)
# Agent 通常通过 write_file 将分析结果写入 profile.md
# final_response 仅是操作完成的告知性消息。
# 因此优先从物理文件获取分析内容final_response 作为兜底。
if os.path.exists(profileMdPath):
with open(profileMdPath, "r", encoding="utf-8") as f:
mdReport = f.read().strip()
logger.info(f"[DeepviewSSE] Profile Stage 1 done (from file), length={len(mdReport)} chars")
else:
mdReport = result.get("final_response", "").strip()
logger.info(f"[DeepviewSSE] Profile Stage 1 done for {clientId}, length={len(mdReport)} chars")
logger.info(f"[DeepviewSSE] Profile Stage 1 done (from response), length={len(mdReport)} chars")
except Exception as e:
logger.error(f"[DeepviewSSE] Profile Stage 1 failed for {clientId}: {e}")
return
@ -1823,78 +1876,30 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
return
# ── Stage 2: JSON 格式引擎 ──
profileSchema = {
"type": "object",
"properties": {
"clientName": {"type": "string"},
"lifecycle": {
"type": "object",
"properties": {
"totalRecordings": {"type": "number"},
"firstRecordingDate": {"type": "string"},
"lastRecordingDate": {"type": "string"},
"followUpSpanDays": {"type": "number"}
},
"required": ["totalRecordings"]
},
"portrait": {
"type": "object",
"properties": {
"aestheticBaseline": {"type": "string"},
"painTolerance": {"type": "string"},
"decisionStyle": {"type": "string"}
}
},
"trustTrajectory": {
"type": "object",
"properties": {
"trustedProjects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"project": {"type": "string"},
"evidence": {"type": "string"},
"sourceRecording": {"type": "string"}
},
"required": ["project"]
}
},
"rejectedProjects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"project": {"type": "string"},
"rejectionCount": {"type": "number"},
"evidence": {"type": "string"},
"aiRejectionInsight": {"type": "string"},
"sourceRecordings": {"type": "array", "items": {"type": "string"}}
},
"required": ["project"]
}
}
}
},
"consultationStats": {
"type": "object",
"properties": {
"avgTrustIndex": {"type": "number"},
"trustTrend": {"type": "string"},
"coreDemand": {"type": "string"},
"lastAcceptance": {"type": "string"}
}
},
"nextVisitBrief": {
"type": "object",
"properties": {
"keyRisks": {"type": "array", "items": {"type": "string"}},
"topicsToPrepare": {"type": "array", "items": {"type": "string"}}
}
}
},
"required": ["clientName", "portrait", "trustTrajectory", "consultationStats", "nextVisitBrief"]
}
schemaPath = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"skills", "deepview_profile", "SCHEMA_profile.json",
)
try:
with open(schemaPath, "r", encoding="utf-8") as f:
profileSchema = json.load(f)
except Exception as e:
logger.error(f"[DeepviewSSE] Failed to load SCHEMA_profile.json: {e}")
profileSchema = {}
stage2PromptPath = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"skills", "deepview_profile", "PROMPT_stage2.md",
)
if os.path.exists(stage2PromptPath):
with open(stage2PromptPath, "r", encoding="utf-8") as f:
stage2Prompt = f.read()
else:
stage2Prompt = "你是一个 JSON 格式转换器。" # Fallback
# 将外置 Schema 注入 Stage 2 的 system prompt 中,作为硬约束参考
if profileSchema:
stage2Prompt += f"\n\n## 目标 JSON Schema严格遵循\n```json\n{json.dumps(profileSchema, indent=2, ensure_ascii=False)}\n```"
try:
from openai import OpenAI
@ -1908,16 +1913,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
lambda: litellmClient.chat.completions.create(
model=os.getenv("DEEPVIEW_STAGE2_MODEL", "qwen-plus"),
messages=[
{"role": "system", "content": """你是一个 JSON 格式转换器。将用户提供的客户全景档案报告精确转换为以下 JSON 结构。
忠实保留原文内容和来源标注不要增删改不要编造任何话术类内容
如果某个字段在原文中未提及设为 null 或空数组
顶层: clientName, lifecycle, portrait, trustTrajectory, consultationStats, nextVisitBrief
portrait: aestheticBaseline, painTolerance, decisionStyle (每条尽量包含来源录音引用)
trustTrajectory.trustedProjects: [{project, evidence, sourceRecording}]
trustTrajectory.rejectedProjects: [{project, rejectionCount, evidence, aiRejectionInsight, sourceRecordings}]
consultationStats: avgTrustIndex(0-100整数), trustTrend(improving/stable/declining), coreDemand, lastAcceptance
nextVisitBrief: keyRisks(字符串数组), topicsToPrepare(字符串数组)"""},
{"role": "system", "content": stage2Prompt},
{"role": "user", "content": mdReport}
],
response_format={"type": "json_object"},
@ -1951,7 +1947,7 @@ nextVisitBrief: keyRisks(字符串数组), topicsToPrepare(字符串数组)"""},
logger.info(f"[DeepviewSSE] Profile persisted to DB for {clientId}")
# Notify via SSE
await self._broadcastToUser(userId, "profile:done", {
self._pushEvent(userId, "profile:done", {
"clientId": clientId,
"clientName": parsedProfile.get("clientName", ""),
})
@ -1959,7 +1955,7 @@ nextVisitBrief: keyRisks(字符串数组), topicsToPrepare(字符串数组)"""},
except Exception as e:
logger.error(f"[DeepviewSSE] Profile Stage 2 failed for {clientId}: {e}")
# Notify error
await self._broadcastToUser(userId, "profile:error", {
self._pushEvent(userId, "profile:error", {
"clientId": clientId,
"error": str(e),
})

View File

@ -28,3 +28,27 @@ description: 深维面诊智能军师
你的回答需要逻辑清晰且具备**极高的可落地性**。如果处于**「单次录音复盘模式」**,必须生成【接纳度评估】【体征数据】【信任断点提取】等结构块。如果处于**「全景档案模式」**,必须重点分析【生命周期洞察】和【社交杠杆引荐网络】。
无论何种模式,最终必须给出**直接对客使用的话术范例**。
## 📋 来源分级阅读规则
系统中的文件分为三个可信度层级,你必须严格区分:
| 级别 | 标识 | 文件类型 | 可信度 |
|------|------|---------|--------|
| **L0** | 无标注 / `source: human` | `asr.md`, `asr_raw.m4a` | 最高——原始录音转写,不可变证据 |
| **L1** | `provenance: ai-single` | `report_draft.md` | 高——单条录音的 AI 摘要,有损 |
| **L2** | `provenance: ai-aggregate` | `profile.md` | 中——跨录音 AI 聚合分析,幻觉风险最大 |
### 阅读准则
1. **L0 是唯一的终极证据源**。所有核心论断必须能追溯到 L0 文件中的原文。
2. **L1 可用于快速掌握历史**。但引用原声时,必须回溯 L0 文件验证后才可引用。
3. **L2 仅供参考**。如果 L2profile.md中的结论与 L0 原文冲突,以 L0 为准。L2 是你上一次的工作产出,不是事实来源。
4. **引用格式**:所有洞察标注格式为 `(来源: {文件名} L{级别}, {行号}行)`
- ✅ `(来源: rep_bc439351/asr.md L0, 125行)`
- ❌ `(来源: profile.md)` ← 禁止引用 L2 作为唯一证据
### 规模化阅读策略(录音数 > 5 条时)
- **先读 L1**report_draft.md快速掌握每条录音的核心结论
- **按需读 L0**asr.md对关键论断进行原文考据
- **最后读 L2**profile.md了解上一轮的合成结论仅供增量更新参考

View File

@ -0,0 +1,22 @@
## 👤 客户全景档案生成模式
你的唯一任务是基于该客户的所有历史面诊录音,生成一份聚合式的客户全景档案。
档案路径:{profileMdPath}
历史录音目录:{historyDir}/
医生风格:{userDir}/doctor_profile.md
## 📋 来源分级阅读策略
1. **优先读取** 每条录音目录下的 `report_draft.md`L1 摘要),快速掌握历史
2. **按需回溯** `asr.md`L0 原始转写)验证关键论断的原声证据
3. **参考但不依赖** 已有的 `profile.md`L2它是你上一轮的产出不是事实来源
## 输出必须包含的章节(缺一不可)
1. 客户画像(审美底线、痛感耐受度、决策风格)——每条必须引用 L0 原始录音文件名和大致行号作为证据
2. 信任轨迹:已信任项目列表(含 L0 证据)+ 被拒项目列表含拒绝次数、原声引用、AI拒因分析
3. 面诊统计汇总:基于所有录音的平均信任指数趋势、核心诉求、最近一次接纳度
4. 下次面诊准备要点:客户遗留的未解决问题、曾主动询问但未成交的项目
## 🚨 绝对禁止
- 禁止编造任何"话术"(破冰话术、价值转化话术等)——这不是你的工作
- 禁止编造 CRM 数据会员等级、LTV、NPS 评分等)——你没有这些数据源
- 所有洞察必须附带录音来源引用(格式:`来源: {文件名} L{级别}, {行号}行`),找不到 L0 证据的字段留空
- 禁止引用 profile.md (L2) 作为唯一证据——L2 是你自己的上一轮产出

View File

@ -0,0 +1,10 @@
你是一个 JSON 格式转换器。将用户提供的客户全景档案报告精确转换为指定的 JSON 结构。
忠实保留原文内容和来源标注,不要增删改。不要编造任何话术类内容。
如果某个字段在原文中未提及,设为 null 或空数组。
顶层: clientName, lifecycle, portrait, trustTrajectory, consultationStats, nextVisitBrief
portrait: aestheticBaseline, painTolerance, decisionStyle (每条尽量包含来源录音引用)
trustTrajectory.trustedProjects: [{project, evidence, sourceRecording}]
trustTrajectory.rejectedProjects: [{project, rejectionCount, evidence, aiRejectionInsight, sourceRecordings}]
consultationStats: avgTrustIndex(0-100整数), trustTrend(improving/stable/declining), coreDemand, lastAcceptance
nextVisitBrief: keyRisks(字符串数组), topicsToPrepare(字符串数组)

View File

@ -0,0 +1,72 @@
{
"type": "object",
"properties": {
"clientName": {"type": "string"},
"lifecycle": {
"type": "object",
"properties": {
"totalRecordings": {"type": "number"},
"firstRecordingDate": {"type": "string"},
"lastRecordingDate": {"type": "string"},
"followUpSpanDays": {"type": "number"}
},
"required": ["totalRecordings"]
},
"portrait": {
"type": "object",
"properties": {
"aestheticBaseline": {"type": "string"},
"painTolerance": {"type": "string"},
"decisionStyle": {"type": "string"}
}
},
"trustTrajectory": {
"type": "object",
"properties": {
"trustedProjects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"project": {"type": "string"},
"evidence": {"type": "string"},
"sourceRecording": {"type": "string"}
},
"required": ["project"]
}
},
"rejectedProjects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"project": {"type": "string"},
"rejectionCount": {"type": "number"},
"evidence": {"type": "string"},
"aiRejectionInsight": {"type": "string"},
"sourceRecordings": {"type": "array", "items": {"type": "string"}}
},
"required": ["project"]
}
}
}
},
"consultationStats": {
"type": "object",
"properties": {
"avgTrustIndex": {"type": "number"},
"trustTrend": {"type": "string"},
"coreDemand": {"type": "string"},
"lastAcceptance": {"type": "string"}
}
},
"nextVisitBrief": {
"type": "object",
"properties": {
"keyRisks": {"type": "array", "items": {"type": "string"}},
"topicsToPrepare": {"type": "array", "items": {"type": "string"}}
}
}
},
"required": ["clientName", "portrait", "trustTrajectory", "consultationStats", "nextVisitBrief"]
}

View File

@ -0,0 +1,299 @@
# SPEC: Markdown-First 物理落盘与来源分级架构
> **版本**: v1.0
> **前置依赖**:
> - [SPEC_deepview_metadata_scoping_v3.md](./SPEC_deepview_metadata_scoping_v3.md) (Prosumer-First 沙箱隔离)
> - [SPEC_tri_domain_knowledge_architecture.md](./SPEC_tri_domain_knowledge_architecture.md) (三元知识域)
> - [DESIGN_PHILOSOPHY_AI_NATIVE.md](./DESIGN_PHILOSOPHY_AI_NATIVE.md) (AI-Native 产品心法)
> **状态**: 草案 (Proposal)
---
## 1. 问题陈述
当前管线中存在三个结构性隐患,它们共享同一根源——**AI 产出物与原始素材在文件系统中无法区分**。
### 1.1 Ghost Response 问题
Hermes Agent 在执行"分析素材并生成报告"类任务时,天然地会通过 `write_file` 工具将分析结果写入物理文件,而 `final_response` 仅返回"操作完成"的告知性消息。如果管线代码从 `final_response` 取值,拿到的是一段无意义的摘要而非真正的分析内容。
这不是 Agent 的 bug——这是 Agent 作为**自主工作者**的正确行为。人类分析师也不会口述 2000 字报告给你,他会"写完放这里,告诉你一声"。
| 管线 | 当前取值方式 | 风险 |
|------|-------------|------|
| 录音笔记 Stage 1 | `result.get("final_response")` | 碰巧可靠Agent 恰好未写文件),但不稳定 |
| 客户档案 Stage 1 | `result.get("final_response")` | **已验证失效** — Agent 将内容写入 `profile.md`response 仅 401 字符 |
### 1.2 幻觉复利效应
当前 `profile.md` 既是 Agent 的**输入素材**(读取既往档案),又是 Agent 的**输出目标**(生成新档案后覆盖写入)。文件系统中没有任何标记区分:
- `asr.md`L0 原始转写,可信度最高)
- `profile.md`L2 AI 聚合产出,可能包含错误推断)
当 Agent 下次执行时,它 `read_file("profile.md")`——无法判断这是人类录入的事实,还是上一轮 AI 的推测。如果上一次的 profile.md 含有错误推断(如"客户属于价格不敏感型"Agent 会将其当作已有事实继续叠加推理。
**错误被放大而非纠正——这是幻觉的复利。**
### 1.3 Token 消耗悖论
一位客户积累 50 次面诊后:
- **暴力拼接所有 asr.md**(每条 1-3 万字)→ 100 万字 → 上下文直接爆炸
- **只读 report.md 摘要**(每条 3000 字)→ 15 万字 → 可控
但前提是:**Agent 必须知道 report.md 是 AI 生成的一阶摘要有损asr.md 才是无损证据。当需要考据时,应回溯 asr.md 原文,而非引用 report.md 中的话当作原声。**
可靠的中间产出物反而减少 Token 消耗;不可靠的中间产出物则制造幻觉。
---
## 2. 核心原则Markdown-First 物理落盘
### 2.1 总则
> **管线各阶段之间的接口契约,必须是物理文件,不是内存态变量。**
| 接口方式 | 韧性 | 可审计性 | Agent 兼容性 |
|----------|------|---------|-------------|
| `final_response`(内存态) | ❌ 进程崩溃即丢 | ❌ 需翻 session 日志 | ❌ 与 Agent 的 `write_file` 行为冲突 |
| 物理 `.md` 文件 | ✅ 落盘即不丢 | ✅ `cat` 即可审 | ✅ 顺应 Agent 自然行为 |
### 2.2 两段式管线的物理接口
```
Stage 0 (ASR/上传) Stage 1 (Hermes Agent) Stage 2 (Qwen JSON)
│ │ │
▼ ▼ ▼
asr.md ──read───► Agent ──write──► report_draft.md ──read──► LLM ──► DB JSON
(L0 原始) (L1 AI 产出)
```
- **Stage 0 → Stage 1**Agent 通过 `read_file` 自主读取 `asr.md`(及其他素材)
- **Stage 1 → Stage 2**:管线代码从 Agent 写入的物理文件读取内容,传入 Stage 2
- **Stage 2 → DB**JSON 写入数据库(已实现,无变更)
### 2.3 Agent 指令约定
Agent 的 user_message 必须明确指定**输出目标路径**,让 Agent 将分析结果写入该路径。管线代码执行完 Agent 后,从该路径读取内容。
```python
# ✅ 正确明确输出路径Agent 写入后管线读取
result = agent.run_conversation(
user_message=f"分析 {historyDir}/ 下的素材,将全景档案写入 {outputPath}",
system_message=systemPrompt,
)
with open(outputPath, "r") as f:
mdReport = f.read()
# ❌ 错误:依赖 final_responseGhost Response 风险)
mdReport = result.get("final_response", "")
```
---
## 3. 来源分级标记 (Provenance Levels)
### 3.1 三级分类
| 级别 | 标识 | 含义 | 可信度 | 文件命名约定 | 示例 |
|------|------|------|--------|-------------|------|
| **L0** | `source: human` | 物理世界直接采集的原始素材 | 最高——可回溯到原声/原件 | `asr.md`, `asr_raw.m4a`, 用户上传照片 | 录音转写文本ASR 原始输出) |
| **L1** | `source: ai-single` | AI 基于**单个** L0 素材生成的一阶分析 | 高——有损摘要,但来源明确 | `report_draft.md` | 单条录音的面诊 X 光片报告 |
| **L2** | `source: ai-aggregate` | AI 基于**多个** L0/L1 素材聚合生成的产物 | 中——叠加推理,幻觉风险最大 | `profile.md` | 客户全景档案(跨录音合成) |
### 3.2 文件头元数据标注
所有 AI 生成的 `.md` 文件,必须在文件头以 HTML 注释形式嵌入来源元数据:
```markdown
<!-- provenance: ai-single | level: L1 | generated_at: 2026-04-12T19:42:30+08:00 | model: gemini-pro-vertex | sources: [rep_bc439351/asr.md] -->
# 面诊沟通X光片深度分析报告
...
```
```markdown
<!-- provenance: ai-aggregate | level: L2 | generated_at: 2026-04-12T19:54:39+08:00 | model: gemini-pro-vertex | sources: [rep_bc439351/asr.md, rep_abc123/asr.md] -->
# 👤 客户全景档案:测女士
...
```
L0 文件asr.md**不需要**标注——没有标注即为 L0。这是"无标记即原始"的默认原则。
### 3.3 Agent 行为约束(注入 SKILL.md
在 Agent 的 SKILL 指令中,追加来源分级感知规则:
```markdown
## 📋 来源分级阅读规则
1. **L0 文件**asr.md, asr_raw.m4a是不可变的原始证据所有核心论断必须以 L0 文件为最终考据依据。
2. **L1 文件**report_draft.md是单次录音的 AI 摘要,可用于快速浏览历史,但引用原声时必须回溯 L0 验证。
3. **L2 文件**profile.md是跨录音的 AI 聚合分析,优先级最低。如果 L2 中的结论与 L0 原文冲突,以 L0 为准。
4. **引用格式**:所有洞察必须标注来源文件名和大致行号,并注明来源级别。
- ✅ `(来源: rep_bc439351/asr.md L0, 125 行)`
- ❌ `(来源: profile.md)` ← 禁止引用 L2 作为唯一证据
```
---
## 4. 文件系统约定
### 4.1 录音笔记管线(单条录音 → 报告)
**当前**:
```
inbox/{asrId}/
├── asr.md ← L0 原始转写
└── asr_raw.m4a ← L0 原始音频
```
**改造后**(归档前):
```
inbox/{asrId}/
├── asr.md ← L0 原始转写
├── asr_raw.m4a ← L0 原始音频
└── report_draft.md ← L1 AI 单次分析Agent 写入,管线读取后传入 Stage 2
```
**改造后**(归档后):
```
clients/{clientId}/history/{reportId}/
├── asr.md ← L0 不可变
├── asr_raw.m4a ← L0 不可变
└── report_draft.md ← L1 随迁(审计留痕)
```
### 4.2 客户档案管线(多条录音 → 全景档案)
```
clients/{clientId}/
├── profile.md ← L2 AI 聚合产出(每次生成时覆盖)
└── history/
├── {reportId_A}/
│ ├── asr.md ← L0
│ ├── asr_raw.m4a ← L0
│ └── report_draft.md ← L1
├── {reportId_B}/
│ ├── asr.md ← L0
│ └── report_draft.md ← L1
└── ...
```
### 4.3 不可变性规则
| 文件 | 可读 | 可写/覆盖 | 可删除 |
|------|------|----------|--------|
| `asr.md` (L0) | ✅ Agent + 管线 | ❌ 上传后不可变 | ❌ |
| `asr_raw.m4a` (L0) | ✅ 审计用 | ❌ | ❌ |
| `report_draft.md` (L1) | ✅ Agent + 管线 | ✅ 重新生成时覆盖 | ❌ |
| `profile.md` (L2) | ✅ Agent + 管线 | ✅ 每次归档后重新生成 | ❌ |
---
## 5. 代码改造清单
### 5.1 录音笔记管线 (`_handleReportGenerate`)
| 步骤 | 现状 | 改造 |
|------|------|------|
| Agent user_message | 未指定输出路径 | 新增 `report_draft.md` 输出路径指令 |
| Stage 1 取值 | `result.get("final_response")` | 优先读取 `inbox/{asrId}/report_draft.md`,兜底 `final_response` |
| report_draft.md 写入 | 无 | Agent 自行写入prompt 引导),或管线兜底写入 |
| 文件头标注 | 无 | Agent prompt 中要求写入 `<!-- provenance -->` 标注 |
### 5.2 客户档案管线 (`_generateClientProfile`)
| 步骤 | 现状 | 改造 |
|------|------|------|
| Agent user_message | 含 `profileMdPath` 路径 | 保持Agent 已自然写入此路径) |
| Stage 1 取值 | ~~`result.get("final_response")`~~ **已修复** → 读取 `profile.md` | ✅ 已完成 |
| 文件头标注 | 无 | 需追加 `<!-- provenance: ai-aggregate ... -->` |
### 5.3 SKILL.md 追加
| 文件 | 变更 |
|------|------|
| `skills/deepview_assistant/SKILL.md` | 追加"来源分级阅读规则"章节 |
| `skills/deepview_profile/PROMPT_stage1.md` | 要求输出文件头带 `<!-- provenance -->` 标注 |
---
## 6. Agent 信息获取策略(规模化场景)
当客户积累大量面诊录音后Agent 应自主规划信息获取策略,而非被管线代码预先读取所有文件:
### 6.1 分层阅读策略
```
Agent 收到任务: "生成 p_b1232417 的全景档案"
├─ Step 1: search_files → 扫描 history/ 目录结构,确认有 N 条录音
├─ Step 2: read_file(profile.md) → 读取 L2 既有档案(如果存在)
│ └── Agent 识别 provenance: ai-aggregate → 知道这是上一轮 AI 自己的产出,仅供参考
├─ Step 3: 逐条读取 report_draft.md (L1) → 快速掌握每条录音的核心结论
│ └── Token 消耗: N × ~3000 字(远小于 N × ~20000 字的 asr.md
├─ Step 4 (按需): 对关键论断回溯 asr.md (L0) 验证
│ └── 仅在需要考据原声时才读取完整 ASR避免全量加载
└─ Step 5: write_file(profile.md) → 生成新的 L2 档案,覆盖旧版
```
### 6.2 Token 经济模型
| 录音数量 | 暴力全读 (asr.md) | 分层阅读 (report_draft.md + 按需 asr.md) | 节省比 |
|----------|-------------------|----------------------------------------|--------|
| 5 条 | ~10 万字 | ~1.5 万字 + 按需 ~2 万字 | ~65% |
| 20 条 | ~40 万字 | ~6 万字 + 按需 ~4 万字 | ~75% |
| 50 条 | ~100 万字 (上下文爆炸) | ~15 万字 + 按需 ~6 万字 | **可行 vs 不可行** |
---
## 7. 与已有 SPEC 的关系
```mermaid
graph TB
A["DESIGN_PHILOSOPHY_AI_NATIVE<br/>先有价值,后有治理"] --> B["SPEC_metadata_scoping_v3<br/>Prosumer-First 沙箱隔离"]
B --> C["SPEC_tri_domain_knowledge<br/>三元知识域"]
B --> D["SPEC_md_first_persistence<br/>📄 本规范"]
D --> E["来源分级标记"]
D --> F["管线物理接口"]
D --> G["Agent 分层阅读策略"]
```
- **V3 SPEC** 定义了"数据在哪"`users/{userId}/` 沙箱)
- **三元域 SPEC** 定义了"知识从哪来"(平台/机构/个人三级注入)
- **本 SPEC** 定义了"文件怎么流"物理落盘、来源标记、Agent 感知证据层级)
三者合力构成了完整的数据治理体系:**沙箱隔离 × 知识分域 × 来源追溯**。
---
## 8. 验证计划
### 8.1 自动化测试
```bash
# 1. 录音笔记管线:验证 report_draft.md 是否被生成
test -f inbox/{asrId}/report_draft.md && echo "✅ L1 文件存在"
head -1 inbox/{asrId}/report_draft.md | grep "provenance" && echo "✅ 来源标记存在"
# 2. 客户档案管线:验证 profile.md 来源标记
head -1 clients/{clientId}/profile.md | grep "ai-aggregate" && echo "✅ L2 标记正确"
# 3. 归档后文件完整性
ls clients/{clientId}/history/{reportId}/ | grep -c ".md" # 应 >= 2 (asr.md + report_draft.md)
```
### 8.2 人工审计
- 对比 `profile.md` 中的引述与 `asr.md` 原文,确认无二次幻觉放大
- 检查 Agent session 日志,确认分层阅读行为(先读 L1 摘要,按需回溯 L0
---
> *本规范定义了深维面诊助理的 Markdown-First 物理落盘与来源分级架构。*
> *核心信念物理文件是管线各阶段之间的唯一接口契约。AI 产出必须标注来源级别,原始素材不可变。*
> *审阅确认后,将指导两条管线的统一改造。*

View File

@ -51,7 +51,8 @@ export class ApiService {
}
async getReportsList(): Promise<any> {
return this.http.get(`${this.apiUrl}/reports/list`, {
const ts = new Date().getTime();
return this.http.get(`${this.apiUrl}/reports/list?_t=${ts}`, {
headers: this.getHeaders()
}).toPromise();
}

View File

@ -25,15 +25,26 @@ export class WorkspaceDashboard implements OnInit {
let completedReports: any[] = [];
try {
const res = await this.api.getReportsList();
console.log('--- REPORTS LIST LOADED ---', res);
if (res && res.reports) {
completedReports = res.reports.map((r: any) => {
const rawName = r.data?._meta?.patientName || r.contextId.split('/')[0].replace('recording:', '') || '未知客户';
const isArchived = !rawName.includes('unknown_client') && rawName !== '未知客户';
const isArchived = r.contextId.includes('/');
let patientName = r.data?.clientName || r.data?.patientName || '未知客户';
console.log(`Report [${r.id}] -> dbClientName: ${r.data?.clientName}, dbPatient: ${r.data?.patientName}, finalName: ${patientName}`);
if (!isArchived) patientName = '访客体验号';
let coreDemandStr = r.data?.coreDemand || r.data?.xray?.module1?.coreInsight || '面诊分析报告AI';
if (coreDemandStr.length > 20) {
coreDemandStr = coreDemandStr.substring(0, 20) + '...';
}
return {
id: r.id,
coreDemand: r.data?.module1?.insight?.substring(0, 20) + '...' || '面诊分析报告AI',
coreDemand: coreDemandStr,
createdAt: new Date(r.createdAt * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }),
patientName: isArchived ? rawName : '访客体验号',
patientName: patientName,
isArchived: isArchived,
status: r.status // 'processing', 'completed', 'failed'
};