feat: implementation of markdown-first persistence architecture
This commit is contained in:
parent
24fe3c0f7a
commit
9857498632
48
.agent/skills/deployment-philosophy-guard/SKILL.md
Normal file
48
.agent/skills/deployment-philosophy-guard/SKILL.md
Normal 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 ... ]` 存在即跳过的防覆盖保护吗?
|
||||||
@ -661,7 +661,7 @@ class DeepviewSSEServer:
|
|||||||
return web.json_response({"error": "Missing contextId"}, status=400, headers=_CORS_HEADERS)
|
return web.json_response({"error": "Missing contextId"}, status=400, headers=_CORS_HEADERS)
|
||||||
|
|
||||||
userObj = await self._extractUser(request)
|
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)
|
userDir = self._getUserStorageDir(userId)
|
||||||
|
|
||||||
# 解析 orgId
|
# 解析 orgId
|
||||||
@ -698,12 +698,16 @@ class DeepviewSSEServer:
|
|||||||
asrId = parts[-1]
|
asrId = parts[-1]
|
||||||
asrPath = f"{userDir}/clients/{clientId}/history/{asrId}.md"
|
asrPath = f"{userDir}/clients/{clientId}/history/{asrId}.md"
|
||||||
clientProfilePath = f"{userDir}/clients/{clientId}/profile.md"
|
clientProfilePath = f"{userDir}/clients/{clientId}/profile.md"
|
||||||
|
reportDraftPath = f"{userDir}/clients/{clientId}/history/{asrId}/report_draft.md"
|
||||||
else:
|
else:
|
||||||
# 新格式(Inbox):recording:asrId
|
# 新格式(Inbox):recording:asrId
|
||||||
asrId = parts[0]
|
asrId = parts[0]
|
||||||
asrPath = f"{userDir}/inbox/{asrId}/asr.md"
|
asrPath = f"{userDir}/inbox/{asrId}/asr.md"
|
||||||
clientProfilePath = None
|
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## 🎙️ 第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):
|
if clientProfilePath and os.path.exists(clientProfilePath):
|
||||||
systemPrompt += f"\n客户档案参考:{clientProfilePath}"
|
systemPrompt += f"\n客户档案参考:{clientProfilePath}"
|
||||||
systemPrompt += f"\n\n🚨 【系统硬约束:Speaker 天然推断指令】"
|
systemPrompt += f"\n\n🚨 【系统硬约束:Speaker 天然推断指令】"
|
||||||
@ -711,6 +715,7 @@ class DeepviewSSEServer:
|
|||||||
systemPrompt += f"\n在输出所有报告正文时,请直接使用真实姓名,彻底抹除 Speaker_X 痕迹。"
|
systemPrompt += f"\n在输出所有报告正文时,请直接使用真实姓名,彻底抹除 Speaker_X 痕迹。"
|
||||||
else:
|
else:
|
||||||
clientId = contextId.split(":", 1)[1] if ":" in contextId else contextId
|
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"
|
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) ──
|
# ── Stage 1 内容引擎: Hermes AIAgent (md2md) ──
|
||||||
# 只负责生成富文本分析报告,不管格式
|
# 只负责生成富文本分析报告,不管格式
|
||||||
@ -742,16 +747,33 @@ class DeepviewSSEServer:
|
|||||||
user_id=userId,
|
user_id=userId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
userMsg = f"请根据给定的上下文文件,生成完整的面诊沟通X光片分析报告。按照章节 checklist 确保不遗漏任何模块。"
|
||||||
|
if reportDraftPath:
|
||||||
|
userMsg += f" 将报告写入 {reportDraftPath}"
|
||||||
|
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: agent.run_conversation(
|
lambda: agent.run_conversation(
|
||||||
user_message="请根据给定的上下文文件,生成完整的面诊沟通X光片分析报告。按照章节 checklist 确保不遗漏任何模块。",
|
user_message=userMsg,
|
||||||
system_message=systemPrompt,
|
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()
|
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:
|
if not mdReport or len(mdReport) < 100:
|
||||||
return web.json_response({"error": "Stage 1 报告内容为空"}, status=500, headers=_CORS_HEADERS)
|
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
|
import time
|
||||||
clientId = f"p_{str(uuid.uuid4())[:8]}"
|
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)
|
clientDir = os.path.join(userDir, "clients", clientId)
|
||||||
os.makedirs(clientDir, exist_ok=True)
|
os.makedirs(clientDir, exist_ok=True)
|
||||||
|
|
||||||
@ -1622,7 +1644,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
|
|||||||
if not clientId:
|
if not clientId:
|
||||||
return web.json_response({"error": "Missing client ID"}, status=400, headers=_CORS_HEADERS)
|
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")
|
profilePath = os.path.join(userDir, "clients", clientId, "profile.md")
|
||||||
|
|
||||||
content = ""
|
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)
|
return web.json_response({"error": "Missing reportId or clientId"}, status=400, headers=_CORS_HEADERS)
|
||||||
|
|
||||||
userId = user["userId"]
|
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
|
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)
|
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)
|
os.makedirs(os.path.dirname(targetDir), exist_ok=True)
|
||||||
shutil.move(inboxDir, targetDir)
|
shutil.move(inboxDir, targetDir)
|
||||||
logger.info(f"[DeepviewSSE] Archived {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
|
# 2. DB 更新:标记 client_id + 更新 context_id
|
||||||
try:
|
try:
|
||||||
@ -1714,7 +1754,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
|
|||||||
# 异步触发客户全景报告生成/刷新
|
# 异步触发客户全景报告生成/刷新
|
||||||
try:
|
try:
|
||||||
userObj = await self._extractUser(request)
|
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)
|
userStorageDir = self._getUserStorageDir(userSub)
|
||||||
asyncio.create_task(self._generateClientProfile(userId, clientId, userStorageDir, userSub))
|
asyncio.create_task(self._generateClientProfile(userId, clientId, userStorageDir, userSub))
|
||||||
logger.info(f"[DeepviewSSE] Async client profile generation triggered for {clientId}")
|
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 = f.read()
|
||||||
systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir)
|
systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir)
|
||||||
systemPrompt = systemPrompt.replace(f"{storageDir}/wiki/", f"{orgDir}/wiki/")
|
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你的唯一任务是基于该客户的所有历史面诊录音,生成一份聚合式的客户全景档案。
|
# Read phase 1 prompt from external file
|
||||||
档案路径:{profileMdPath}
|
stage1PromptPath = os.path.join(
|
||||||
历史录音目录:{historyDir}/
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
医生风格:{userDir}/doctor_profile.md
|
"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
|
||||||
|
|
||||||
## 输出必须包含的章节(缺一不可)
|
stage1Prompt = stage1Prompt.replace("{profileMdPath}", profileMdPath)
|
||||||
1. 客户画像(审美底线、痛感耐受度、决策风格)——每条必须引用原始录音文件名和大致时间戳作为证据
|
stage1Prompt = stage1Prompt.replace("{historyDir}", historyDir)
|
||||||
2. 信任轨迹:已信任项目列表(含证据)+ 被拒项目列表(含拒绝次数、原声引用、AI拒因分析)
|
stage1Prompt = stage1Prompt.replace("{userDir}", userDir)
|
||||||
3. 面诊统计汇总:基于所有录音的平均信任指数趋势、核心诉求、最近一次接纳度
|
|
||||||
4. 下次面诊准备要点:客户遗留的未解决问题、曾主动询问但未成交的项目
|
|
||||||
|
|
||||||
## 🚨 绝对禁止
|
systemPrompt += stage1Prompt
|
||||||
- 禁止编造任何"话术"(破冰话术、价值转化话术等)——这不是你的工作
|
|
||||||
- 禁止编造 CRM 数据(会员等级、LTV、NPS 评分等)——你没有这些数据源
|
|
||||||
- 所有洞察必须附带录音来源引用,找不到证据的字段留空
|
|
||||||
"""
|
|
||||||
|
|
||||||
mdReport = ""
|
mdReport = ""
|
||||||
try:
|
try:
|
||||||
@ -1794,6 +1834,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
|
|||||||
from hermes_state import SessionDB
|
from hermes_state import SessionDB
|
||||||
db = SessionDB()
|
db = SessionDB()
|
||||||
|
|
||||||
|
import uuid
|
||||||
agent = AIAgent(
|
agent = AIAgent(
|
||||||
model=os.getenv("DEEPVIEW_MODEL", "gemini-pro-vertex"),
|
model=os.getenv("DEEPVIEW_MODEL", "gemini-pro-vertex"),
|
||||||
enabled_toolsets=["file"],
|
enabled_toolsets=["file"],
|
||||||
@ -1804,16 +1845,28 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
|
|||||||
user_id=userId,
|
user_id=userId,
|
||||||
)
|
)
|
||||||
|
|
||||||
import uuid
|
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: agent.run_conversation(
|
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,
|
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()
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[DeepviewSSE] Profile Stage 1 failed for {clientId}: {e}")
|
logger.error(f"[DeepviewSSE] Profile Stage 1 failed for {clientId}: {e}")
|
||||||
return
|
return
|
||||||
@ -1823,78 +1876,30 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ── Stage 2: JSON 格式引擎 ──
|
# ── Stage 2: JSON 格式引擎 ──
|
||||||
profileSchema = {
|
schemaPath = os.path.join(
|
||||||
"type": "object",
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
"properties": {
|
"skills", "deepview_profile", "SCHEMA_profile.json",
|
||||||
"clientName": {"type": "string"},
|
)
|
||||||
"lifecycle": {
|
try:
|
||||||
"type": "object",
|
with open(schemaPath, "r", encoding="utf-8") as f:
|
||||||
"properties": {
|
profileSchema = json.load(f)
|
||||||
"totalRecordings": {"type": "number"},
|
except Exception as e:
|
||||||
"firstRecordingDate": {"type": "string"},
|
logger.error(f"[DeepviewSSE] Failed to load SCHEMA_profile.json: {e}")
|
||||||
"lastRecordingDate": {"type": "string"},
|
profileSchema = {}
|
||||||
"followUpSpanDays": {"type": "number"}
|
|
||||||
},
|
stage2PromptPath = os.path.join(
|
||||||
"required": ["totalRecordings"]
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
},
|
"skills", "deepview_profile", "PROMPT_stage2.md",
|
||||||
"portrait": {
|
)
|
||||||
"type": "object",
|
if os.path.exists(stage2PromptPath):
|
||||||
"properties": {
|
with open(stage2PromptPath, "r", encoding="utf-8") as f:
|
||||||
"aestheticBaseline": {"type": "string"},
|
stage2Prompt = f.read()
|
||||||
"painTolerance": {"type": "string"},
|
else:
|
||||||
"decisionStyle": {"type": "string"}
|
stage2Prompt = "你是一个 JSON 格式转换器。" # Fallback
|
||||||
}
|
|
||||||
},
|
# 将外置 Schema 注入 Stage 2 的 system prompt 中,作为硬约束参考
|
||||||
"trustTrajectory": {
|
if profileSchema:
|
||||||
"type": "object",
|
stage2Prompt += f"\n\n## 目标 JSON Schema(严格遵循)\n```json\n{json.dumps(profileSchema, indent=2, ensure_ascii=False)}\n```"
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
@ -1908,16 +1913,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
|
|||||||
lambda: litellmClient.chat.completions.create(
|
lambda: litellmClient.chat.completions.create(
|
||||||
model=os.getenv("DEEPVIEW_STAGE2_MODEL", "qwen-plus"),
|
model=os.getenv("DEEPVIEW_STAGE2_MODEL", "qwen-plus"),
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": """你是一个 JSON 格式转换器。将用户提供的客户全景档案报告精确转换为以下 JSON 结构。
|
{"role": "system", "content": stage2Prompt},
|
||||||
忠实保留原文内容和来源标注,不要增删改。不要编造任何话术类内容。
|
|
||||||
如果某个字段在原文中未提及,设为 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": "user", "content": mdReport}
|
{"role": "user", "content": mdReport}
|
||||||
],
|
],
|
||||||
response_format={"type": "json_object"},
|
response_format={"type": "json_object"},
|
||||||
@ -1951,7 +1947,7 @@ nextVisitBrief: keyRisks(字符串数组), topicsToPrepare(字符串数组)"""},
|
|||||||
logger.info(f"[DeepviewSSE] Profile persisted to DB for {clientId}")
|
logger.info(f"[DeepviewSSE] Profile persisted to DB for {clientId}")
|
||||||
|
|
||||||
# Notify via SSE
|
# Notify via SSE
|
||||||
await self._broadcastToUser(userId, "profile:done", {
|
self._pushEvent(userId, "profile:done", {
|
||||||
"clientId": clientId,
|
"clientId": clientId,
|
||||||
"clientName": parsedProfile.get("clientName", ""),
|
"clientName": parsedProfile.get("clientName", ""),
|
||||||
})
|
})
|
||||||
@ -1959,7 +1955,7 @@ nextVisitBrief: keyRisks(字符串数组), topicsToPrepare(字符串数组)"""},
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[DeepviewSSE] Profile Stage 2 failed for {clientId}: {e}")
|
logger.error(f"[DeepviewSSE] Profile Stage 2 failed for {clientId}: {e}")
|
||||||
# Notify error
|
# Notify error
|
||||||
await self._broadcastToUser(userId, "profile:error", {
|
self._pushEvent(userId, "profile:error", {
|
||||||
"clientId": clientId,
|
"clientId": clientId,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 仅供参考**。如果 L2(profile.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)了解上一轮的合成结论(仅供增量更新参考)
|
||||||
|
|
||||||
|
|||||||
22
backend/skills/deepview_profile/PROMPT_stage1.md
Normal file
22
backend/skills/deepview_profile/PROMPT_stage1.md
Normal 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 是你自己的上一轮产出
|
||||||
10
backend/skills/deepview_profile/PROMPT_stage2.md
Normal file
10
backend/skills/deepview_profile/PROMPT_stage2.md
Normal 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(字符串数组)
|
||||||
72
backend/skills/deepview_profile/SCHEMA_profile.json
Normal file
72
backend/skills/deepview_profile/SCHEMA_profile.json
Normal 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"]
|
||||||
|
}
|
||||||
299
docs/SPEC_md_first_persistence_v1.md
Normal file
299
docs/SPEC_md_first_persistence_v1.md
Normal 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_response(Ghost 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 产出必须标注来源级别,原始素材不可变。*
|
||||||
|
> *审阅确认后,将指导两条管线的统一改造。*
|
||||||
@ -51,7 +51,8 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getReportsList(): Promise<any> {
|
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()
|
headers: this.getHeaders()
|
||||||
}).toPromise();
|
}).toPromise();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,15 +25,26 @@ export class WorkspaceDashboard implements OnInit {
|
|||||||
let completedReports: any[] = [];
|
let completedReports: any[] = [];
|
||||||
try {
|
try {
|
||||||
const res = await this.api.getReportsList();
|
const res = await this.api.getReportsList();
|
||||||
|
console.log('--- REPORTS LIST LOADED ---', res);
|
||||||
if (res && res.reports) {
|
if (res && res.reports) {
|
||||||
completedReports = res.reports.map((r: any) => {
|
completedReports = res.reports.map((r: any) => {
|
||||||
const rawName = r.data?._meta?.patientName || r.contextId.split('/')[0].replace('recording:', '') || '未知客户';
|
const isArchived = r.contextId.includes('/');
|
||||||
const isArchived = !rawName.includes('unknown_client') && rawName !== '未知客户';
|
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 {
|
return {
|
||||||
id: r.id,
|
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' }),
|
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,
|
isArchived: isArchived,
|
||||||
status: r.status // 'processing', 'completed', 'failed'
|
status: r.status // 'processing', 'completed', 'failed'
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user