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]; }