diff --git a/backend/gateway/platforms/deepview_sse.py b/backend/gateway/platforms/deepview_sse.py index 99d3784..cd20561 100644 --- a/backend/gateway/platforms/deepview_sse.py +++ b/backend/gateway/platforms/deepview_sse.py @@ -1711,8 +1711,292 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数 logger.error(f"[DeepviewSSE] Archive DB update failed: {e}") return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + # 异步触发客户全景报告生成/刷新 + try: + userObj = await self._extractUser(request) + userSub = userObj.get("sub", "unknown") 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}") + except Exception as triggerErr: + logger.error(f"[DeepviewSSE] Profile gen trigger failed: {triggerErr}") + return web.json_response({"success": True, "reportId": reportId, "clientId": clientId}, headers=_CORS_HEADERS) + # ────────────────────────────────────────────── + # 客户全景报告生成管道 (Profile Pipeline) + # ────────────────────────────────────────────── + + async def _generateClientProfile(self, userId: str, clientId: str, userDir: str, userSub: str) -> None: + """ + 两段式异步管道: + Stage 1 (Hermes Agent): 读取 profile.md + history/*.md → 生成 Markdown 分析 + Stage 2 (Qwen JSON): Markdown → 结构化 JSON (Schema 硬约束) + """ + import time as _time + loop = asyncio.get_event_loop() + clientDir = os.path.join(userDir, "clients", clientId) + profileMdPath = os.path.join(clientDir, "profile.md") + historyDir = os.path.join(clientDir, "history") + + # 收集所有源录音 ID + sourceRecordings = [] + if os.path.exists(historyDir): + for item in os.listdir(historyDir): + itemPath = os.path.join(historyDir, item) + if os.path.isdir(itemPath): + sourceRecordings.append(item) + elif item.endswith(".md"): + sourceRecordings.append(item.replace(".md", "")) + + if not sourceRecordings: + logger.info(f"[DeepviewSSE] No recordings for client {clientId}, skipping profile gen") + return + + # ── Stage 1: Hermes Agent (md2md) ── + storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) + orgId = "org_001" + orgDir = self._getOrgStorageDir(orgId) + platformDir = os.path.join(storageDir, "platform") + + systemPrompt = "你是深维面诊智能军师。\n" + skillPath = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "skills", "deepview_assistant", "SKILL.md", + ) + if os.path.exists(skillPath): + with open(skillPath, "r", encoding="utf-8") as f: + 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你的唯一任务是基于该客户的所有历史面诊录音,生成一份聚合式的客户全景档案。 +档案路径:{profileMdPath} +历史录音目录:{historyDir}/ +医生风格:{userDir}/doctor_profile.md + +## 输出必须包含的章节(缺一不可) +1. 客户画像(审美底线、痛感耐受度、决策风格)——每条必须引用原始录音文件名和大致时间戳作为证据 +2. 信任轨迹:已信任项目列表(含证据)+ 被拒项目列表(含拒绝次数、原声引用、AI拒因分析) +3. 面诊统计汇总:基于所有录音的平均信任指数趋势、核心诉求、最近一次接纳度 +4. 下次面诊准备要点:客户遗留的未解决问题、曾主动询问但未成交的项目 + +## 🚨 绝对禁止 +- 禁止编造任何"话术"(破冰话术、价值转化话术等)——这不是你的工作 +- 禁止编造 CRM 数据(会员等级、LTV、NPS 评分等)——你没有这些数据源 +- 所有洞察必须附带录音来源引用,找不到证据的字段留空 +""" + + mdReport = "" + try: + from run_agent import AIAgent + from hermes_state import SessionDB + db = SessionDB() + + agent = AIAgent( + model=os.getenv("DEEPVIEW_MODEL", "gemini-pro-vertex"), + enabled_toolsets=["file"], + quiet_mode=True, + platform="deepview", + session_id=str(uuid.uuid4()), + session_db=db, + user_id=userId, + ) + + import uuid + result = await loop.run_in_executor( + None, + lambda: agent.run_conversation( + user_message=f"请读取 {profileMdPath} 和 {historyDir}/ 下所有历史录音文件,生成该客户的全景档案报告。每条洞察必须标注来源录音文件名。", + system_message=systemPrompt, + ), + ) + mdReport = result.get("final_response", "").strip() + logger.info(f"[DeepviewSSE] Profile Stage 1 done for {clientId}, length={len(mdReport)} chars") + except Exception as e: + logger.error(f"[DeepviewSSE] Profile Stage 1 failed for {clientId}: {e}") + return + + if not mdReport or len(mdReport) < 50: + logger.warning(f"[DeepviewSSE] Profile Stage 1 output too short for {clientId}") + 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"] + } + + try: + from openai import OpenAI + litellmClient = OpenAI( + base_url=os.getenv("GEMINI_BASE_URL", "http://127.0.0.1:4000/v1"), + api_key=os.getenv("GEMINI_API_KEY", "sk-placeholder"), + ) + + stage2Response = await loop.run_in_executor( + None, + 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": "user", "content": mdReport} + ], + response_format={"type": "json_object"}, + max_tokens=8192, + ) + ) + + jsonOutput = stage2Response.choices[0].message.content.strip() + parsedProfile = json.loads(jsonOutput) + logger.info(f"[DeepviewSSE] Profile Stage 2 done for {clientId}, keys={list(parsedProfile.keys())}") + + # Inject metadata + parsedProfile["_meta"] = { + "generatedAt": _time.strftime("%Y-%m-%dT%H:%M:%S+08:00"), + "generatedBy": "deepview_profile_pipeline_v1", + "sourceRecordings": sourceRecordings, + "modelUsed": f"{os.getenv('DEEPVIEW_MODEL', 'gemini-pro-vertex')} + {os.getenv('DEEPVIEW_STAGE2_MODEL', 'qwen-plus')}" + } + parsedProfile["clientId"] = clientId + + # Persist to DB + from hermes_state import SessionDB + profileDb = SessionDB() + profileJson = json.dumps(parsedProfile, ensure_ascii=False) + with profileDb._lock: + profileDb._conn.execute( + "INSERT OR REPLACE INTO deepview_client_profiles (client_id, user_id, profile_json, generated_at, source_recordings) VALUES (?, ?, ?, ?, ?)", + (clientId, userId, profileJson, _time.time(), json.dumps(sourceRecordings)) + ) + profileDb._conn.commit() + logger.info(f"[DeepviewSSE] Profile persisted to DB for {clientId}") + + # Notify via SSE + await self._broadcastToUser(userId, "profile:done", { + "clientId": clientId, + "clientName": parsedProfile.get("clientName", ""), + }) + + except Exception as e: + logger.error(f"[DeepviewSSE] Profile Stage 2 failed for {clientId}: {e}") + # Notify error + await self._broadcastToUser(userId, "profile:error", { + "clientId": clientId, + "error": str(e), + }) + + async def _handleClientProfileReport(self, request: "web.Request") -> "web.Response": + """ + GET /deepview/clients/{id}/profile-report + 返回客户全景档案的 JSON 报告。 + """ + user = await self._extractUser(request) + if not user: + return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) + + clientId = request.match_info.get("id", "") + if not clientId: + return web.json_response({"error": "Missing client ID"}, status=400, headers=_CORS_HEADERS) + + try: + from hermes_state import SessionDB + db = SessionDB() + with db._lock: + cursor = db._conn.execute( + "SELECT profile_json, generated_at FROM deepview_client_profiles WHERE client_id=?", + (clientId,) + ) + row = cursor.fetchone() + + if not row: + return web.json_response({"error": "No profile report yet", "clientId": clientId}, status=404, headers=_CORS_HEADERS) + + profileData = json.loads(row[0]) + safeBody = json.dumps({"success": True, "data": profileData, "generatedAt": row[1]}, ensure_ascii=False) + return web.Response(text=safeBody, content_type="application/json", headers=_CORS_HEADERS) + except Exception as e: + logger.error(f"[DeepviewSSE] Profile report get error: {e}") + return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) + # ────────────────────────────────────────────── # 服务器生命周期 # ────────────────────────────────────────────── @@ -1740,8 +2024,17 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数 ) """) db._conn.execute("CREATE INDEX IF NOT EXISTS idx_clients_user ON deepview_clients(user_id)") + db._conn.execute(""" + CREATE TABLE IF NOT EXISTS deepview_client_profiles ( + client_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + profile_json TEXT NOT NULL DEFAULT '{}', + generated_at REAL, + source_recordings TEXT DEFAULT '[]' + ) + """) db._conn.commit() - logger.info("[DeepviewSSE] DB schema for clients initialized") + logger.info("[DeepviewSSE] DB schema for clients + profiles initialized") except Exception as e: logger.error(f"[DeepviewSSE] DB schema init failed: {e}") @@ -1767,6 +2060,7 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数 self._app.router.add_get("/deepview/clients/list", self._handleClientsList) self._app.router.add_post("/deepview/clients/create", self._handleClientCreate) self._app.router.add_get("/deepview/clients/{id}/profile", self._handleClientProfile) + self._app.router.add_get("/deepview/clients/{id}/profile-report", self._handleClientProfileReport) # 报告归档 API (Inbox → Client) self._app.router.add_post("/deepview/reports/archive", self._handleReportArchive) diff --git a/src/app/core/api.service.ts b/src/app/core/api.service.ts index 790072b..969632d 100644 --- a/src/app/core/api.service.ts +++ b/src/app/core/api.service.ts @@ -68,6 +68,12 @@ export class ApiService { }).toPromise(); } + async getClientProfileReport(clientId: string): Promise { + return this.http.get(`${this.apiUrl}/clients/${clientId}/profile-report`, { + headers: this.getHeaders() + }).toPromise(); + } + async createClient(name: string, phone: string = ''): Promise { return this.http.post(`${this.apiUrl}/clients/create`, { name, phone diff --git a/src/app/pages/client-profile/client-profile.html b/src/app/pages/client-profile/client-profile.html index d6cee7d..b9a3a8e 100644 --- a/src/app/pages/client-profile/client-profile.html +++ b/src/app/pages/client-profile/client-profile.html @@ -1,143 +1,136 @@ -
+
+ +
+
+
正在加载客户全景档案...
+
+ + +
+
📋
+
{{ errorMsg }}
+
+ + + +
深维👩‍⚕️诊前托付档案
-
—— 诊前30分钟,读懂全生命周期与社交版图
+
—— 基于 {{ profile._meta?.sourceRecordings?.length || 0 }} 条历史录音的 AI 聚合分析
-
客户姓名 {{ patient?.name }} ({{ patient?.age }}岁)
-
当前等级 {{ profile.memberTier }} (建档第{{ profile.yearsEnrolled }}年)
-
NPS满意度 @for(s of [1,2,3,4,5]; track $index){⭐}
+
客户姓名 {{ profile.clientName }}
+
累计面诊 {{ profile.lifecycle.totalRecordings }} 次
+
跟踪周期 {{ profile.lifecycle.followUpSpanDays }} 天
-
+
- 个人LTV - 累计 {{ profile.personalLtv }} + 核心诉求 + {{ profile.consultationStats.coreDemand || '待分析' }}
- 社交杠杆(网络LTV) - 可撬动 {{ profile.networkLtv }} + 最近接纳度 + {{ profile.consultationStats.lastAcceptance || '待分析' }} +
+
+ 平均信任指数 + + {{ profile.consultationStats.avgTrustIndex }}% + (趋势: {{ profile.consultationStats.trustTrend === 'improving' ? '↗ 上升' : profile.consultationStats.trustTrend === 'declining' ? '↘ 下降' : '→ 平稳' }}) +
- -
-

🟢 客户全生命周期洞察

- -
- 1 - 医疗美学偏好 & 物理体征 -
+ +
+

🟢 客户画像

    -
  • 💄 审美底线: {{ profile.aestheticBaseline }}
  • -
  • 😣 痛感耐受度: {{ profile.painTolerance }}
  • -
  • 🤔 决策风格: {{ profile.decisionStyle }}
  • +
  • + 💄 审美底线: {{ profile.portrait.aestheticBaseline }} +
  • +
  • + 😣 痛感耐受度: {{ profile.portrait.painTolerance }} +
  • +
  • + 🤔 决策风格: {{ profile.portrait.decisionStyle }} +
  • +
+
+
+ + +
+

📊 信任轨迹

+ + +
+
+
✅ 已建立信任的项目
+
+
{{ tp.project }}
+
📎 {{ tp.evidence }}
+
📁 来源: {{ tp.sourceRecording }}
+
+
+
+ + +
+
+
❌ 历史拒因破译
+
+
{{ rp.project }} (被拒{{ rp.rejectionCount }}次)
+
🗣️ 原声: {{ rp.evidence }}
+
+
🧠 AI 拒因分析
+
{{ rp.aiRejectionInsight }}
+
+
📁 来源: {{ rp.sourceRecordings.join(', ') }}
+
+
+
+
+ + +
+

🧭 下次面诊准备要点

+ +
+
⚠️ 风险提示
+
    +
  • {{ risk }}
-
- 2 - 托付轨迹与真实拒因池 (AI 分析) -
- -
-
-
✅ 已建立的信任 (复购项目):
- @for (tp of profile.trustedProjects; track $index) { -
• {{ tp }}
- } -
- -
-
❌ 历史拒因破译:
- @for (rp of profile.rejectedProjects; track $index) { -
• 拒绝项目:{{ rp }}
- } -
- -
-
🧠 AI 源数据追踪还原
-
{{ profile.aiRejectionInsight }}
-
-
- -
- 3 - 今日面诊军师策略 🎯 -
- -
-
-

🚀 战略目标:{{ profile.strategyGoal }}

-
-
💬 破冰切入
-
{{ profile.icebreaker }}
-
-
-
💡 价值转化
-
{{ profile.valuePitch }}
-
-
-
- 🚨 护航预警 {{ profile.warningAlert }} -
+
+
💡 可探索的话题(客户曾主动询问)
+
    +
  • {{ topic }}
  • +
- -
-

🔵 信任引荐网络 (Social Capital)

-
- 揭示客户的“社交杠杆”,利用同侪圈层压力促单 + +
- -
+ +
diff --git a/src/app/pages/client-profile/client-profile.ts b/src/app/pages/client-profile/client-profile.ts index a7043f4..21ab237 100644 --- a/src/app/pages/client-profile/client-profile.ts +++ b/src/app/pages/client-profile/client-profile.ts @@ -1,7 +1,7 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink, ActivatedRoute } from '@angular/router'; -import { MOCK_PATIENTS, Patient } from '../../data/mock-data'; +import { ApiService } from '../../core/api.service'; import { ChatContextService } from '../../core/chat-context.service'; @Component({ @@ -12,24 +12,44 @@ import { ChatContextService } from '../../core/chat-context.service'; styleUrl: './client-profile.css', }) export class ClientProfile implements OnInit { - patient: Patient | undefined; - - constructor( - private route: ActivatedRoute, - private chatCtx: ChatContextService - ) {} + profile: any = null; + clientId = ''; + loading = true; + errorMsg = ''; + + private api = inject(ApiService); + private chatCtx = inject(ChatContextService); + + constructor(private route: ActivatedRoute) {} ngOnInit() { this.route.paramMap.subscribe(params => { const id = params.get('id'); - this.patient = MOCK_PATIENTS.find(p => p.id === id) || MOCK_PATIENTS[0]; - + if (!id) return; + this.clientId = id; + this.loadProfile(id); + // Setup Chat Context - if (this.patient) { - this.chatCtx.contextId.set(`client:${this.patient.id}`); - this.chatCtx.contextTitle.set(this.patient.name); - this.chatCtx.contextType.set('client'); - } + this.chatCtx.contextId.set(`client:${id}`); + this.chatCtx.contextType.set('client'); }); } + + async loadProfile(clientId: string) { + this.loading = true; + this.errorMsg = ''; + try { + const res = await this.api.getClientProfileReport(clientId); + this.profile = res.data; + this.chatCtx.contextTitle.set(this.profile?.clientName || clientId); + } catch (e: any) { + if (e.status === 404) { + this.errorMsg = '该客户的全景报告尚未生成。请先归档至少一条面诊录音。'; + } else { + this.errorMsg = '加载失败: ' + (e.message || e.statusText); + } + } finally { + this.loading = false; + } + } }