diff --git a/.agent/skills/deployment-philosophy-guard/SKILL.md b/.agent/skills/deployment-philosophy-guard/SKILL.md
new file mode 100644
index 0000000..6dfa4fb
--- /dev/null
+++ b/.agent/skills/deployment-philosophy-guard/SKILL.md
@@ -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 ... ]` 存在即跳过的防覆盖保护吗?
diff --git a/backend/gateway/platforms/deepview_sse.py b/backend/gateway/platforms/deepview_sse.py
index cd20561..7cb1d02 100644
--- a/backend/gateway/platforms/deepview_sse.py
+++ b/backend/gateway/platforms/deepview_sse.py
@@ -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:
# 新格式(Inbox):recording: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文件第一行必须为来源标注:"
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,
),
)
-
- mdReport = result.get("final_response", "").strip()
- logger.info(f"[DeepviewSSE] Stage 1 (Hermes md2md) done, length={len(mdReport)} chars")
+
+ # 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 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"\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
-
-## 输出必须包含的章节(缺一不可)
-1. 客户画像(审美底线、痛感耐受度、决策风格)——每条必须引用原始录音文件名和大致时间戳作为证据
-2. 信任轨迹:已信任项目列表(含证据)+ 被拒项目列表(含拒绝次数、原声引用、AI拒因分析)
-3. 面诊统计汇总:基于所有录音的平均信任指数趋势、核心诉求、最近一次接纳度
-4. 下次面诊准备要点:客户遗留的未解决问题、曾主动询问但未成交的项目
-
-## 🚨 绝对禁止
-- 禁止编造任何"话术"(破冰话术、价值转化话术等)——这不是你的工作
-- 禁止编造 CRM 数据(会员等级、LTV、NPS 评分等)——你没有这些数据源
-- 所有洞察必须附带录音来源引用,找不到证据的字段留空
-"""
+ # 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
+
+ stage1Prompt = stage1Prompt.replace("{profileMdPath}", profileMdPath)
+ stage1Prompt = stage1Prompt.replace("{historyDir}", historyDir)
+ stage1Prompt = stage1Prompt.replace("{userDir}", userDir)
+
+ 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"文件第一行必须为:"
+ ),
system_message=systemPrompt,
),
)
- mdReport = result.get("final_response", "").strip()
- logger.info(f"[DeepviewSSE] Profile Stage 1 done for {clientId}, length={len(mdReport)} chars")
+ # 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 (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),
})
diff --git a/backend/skills/deepview_assistant/SKILL.md b/backend/skills/deepview_assistant/SKILL.md
index 087e94f..b46f915 100644
--- a/backend/skills/deepview_assistant/SKILL.md
+++ b/backend/skills/deepview_assistant/SKILL.md
@@ -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)了解上一轮的合成结论(仅供增量更新参考)
+
diff --git a/backend/skills/deepview_profile/PROMPT_stage1.md b/backend/skills/deepview_profile/PROMPT_stage1.md
new file mode 100644
index 0000000..580f8bd
--- /dev/null
+++ b/backend/skills/deepview_profile/PROMPT_stage1.md
@@ -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 是你自己的上一轮产出
diff --git a/backend/skills/deepview_profile/PROMPT_stage2.md b/backend/skills/deepview_profile/PROMPT_stage2.md
new file mode 100644
index 0000000..4e2e079
--- /dev/null
+++ b/backend/skills/deepview_profile/PROMPT_stage2.md
@@ -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(字符串数组)
diff --git a/backend/skills/deepview_profile/SCHEMA_profile.json b/backend/skills/deepview_profile/SCHEMA_profile.json
new file mode 100644
index 0000000..7884749
--- /dev/null
+++ b/backend/skills/deepview_profile/SCHEMA_profile.json
@@ -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"]
+}
diff --git a/docs/SPEC_md_first_persistence_v1.md b/docs/SPEC_md_first_persistence_v1.md
new file mode 100644
index 0000000..3f2cd33
--- /dev/null
+++ b/docs/SPEC_md_first_persistence_v1.md
@@ -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
+
+# 面诊沟通X光片深度分析报告
+...
+```
+
+```markdown
+
+# 👤 客户全景档案:测女士
+...
+```
+
+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 中要求写入 `` 标注 |
+
+### 5.2 客户档案管线 (`_generateClientProfile`)
+
+| 步骤 | 现状 | 改造 |
+|------|------|------|
+| Agent user_message | 含 `profileMdPath` 路径 | 保持(Agent 已自然写入此路径) |
+| Stage 1 取值 | ~~`result.get("final_response")`~~ **已修复** → 读取 `profile.md` | ✅ 已完成 |
+| 文件头标注 | 无 | 需追加 `` |
+
+### 5.3 SKILL.md 追加
+
+| 文件 | 变更 |
+|------|------|
+| `skills/deepview_assistant/SKILL.md` | 追加"来源分级阅读规则"章节 |
+| `skills/deepview_profile/PROMPT_stage1.md` | 要求输出文件头带 `` 标注 |
+
+---
+
+## 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
先有价值,后有治理"] --> B["SPEC_metadata_scoping_v3
Prosumer-First 沙箱隔离"]
+ B --> C["SPEC_tri_domain_knowledge
三元知识域"]
+ B --> D["SPEC_md_first_persistence
📄 本规范"]
+ 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 产出必须标注来源级别,原始素材不可变。*
+> *审阅确认后,将指导两条管线的统一改造。*
diff --git a/src/app/core/api.service.ts b/src/app/core/api.service.ts
index 969632d..0029d59 100644
--- a/src/app/core/api.service.ts
+++ b/src/app/core/api.service.ts
@@ -51,7 +51,8 @@ export class ApiService {
}
async getReportsList(): Promise {
- 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();
}
diff --git a/src/app/pages/workspace-dashboard/workspace-dashboard.ts b/src/app/pages/workspace-dashboard/workspace-dashboard.ts
index 66386b7..d0c6d33 100644
--- a/src/app/pages/workspace-dashboard/workspace-dashboard.ts
+++ b/src/app/pages/workspace-dashboard/workspace-dashboard.ts
@@ -24,25 +24,36 @@ export class WorkspaceDashboard implements OnInit {
async loadTasks() {
let completedReports: any[] = [];
try {
- const res = await this.api.getReportsList();
- 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 !== '未知客户';
- return {
- id: r.id,
- coreDemand: r.data?.module1?.insight?.substring(0, 20) + '...' || '面诊分析报告(AI)',
- createdAt: new Date(r.createdAt * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }),
- patientName: isArchived ? rawName : '访客体验号',
- isArchived: isArchived,
- status: r.status // 'processing', 'completed', 'failed'
- };
- });
- }
+ const res = await this.api.getReportsList();
+ console.log('--- REPORTS LIST LOADED ---', res);
+ if (res && res.reports) {
+ completedReports = res.reports.map((r: any) => {
+ 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: coreDemandStr,
+ createdAt: new Date(r.createdAt * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }),
+ patientName: patientName,
+ isArchived: isArchived,
+ status: r.status // 'processing', 'completed', 'failed'
+ };
+ });
+ }
} catch (e) {
- console.error('Failed to load reports from API', e);
+ console.error('Failed to load reports from API', e);
}
-
+
// Sort recently generated to top (API actually already sorts by created_at DESC)
this.reports = [...completedReports, ...MOCK_REPORTS];
}