feat(profile-pipeline): implement two-stage client profile report generation with anti-hallucination schema

This commit is contained in:
lidf 2026-04-12 17:20:41 +08:00
parent cd985e38ea
commit 24fe3c0f7a
4 changed files with 438 additions and 125 deletions

View File

@ -1711,8 +1711,292 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
logger.error(f"[DeepviewSSE] Archive DB update failed: {e}") logger.error(f"[DeepviewSSE] Archive DB update failed: {e}")
return web.json_response({"error": str(e)}, status=500, headers=_CORS_HEADERS) 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) 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 数据会员等级LTVNPS 评分等你没有这些数据源
- 所有洞察必须附带录音来源引用找不到证据的字段留空
"""
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 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() db._conn.commit()
logger.info("[DeepviewSSE] DB schema for clients initialized") logger.info("[DeepviewSSE] DB schema for clients + profiles initialized")
except Exception as e: except Exception as e:
logger.error(f"[DeepviewSSE] DB schema init failed: {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_get("/deepview/clients/list", self._handleClientsList)
self._app.router.add_post("/deepview/clients/create", self._handleClientCreate) 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", self._handleClientProfile)
self._app.router.add_get("/deepview/clients/{id}/profile-report", self._handleClientProfileReport)
# 报告归档 API (Inbox → Client) # 报告归档 API (Inbox → Client)
self._app.router.add_post("/deepview/reports/archive", self._handleReportArchive) self._app.router.add_post("/deepview/reports/archive", self._handleReportArchive)

View File

@ -68,6 +68,12 @@ export class ApiService {
}).toPromise(); }).toPromise();
} }
async getClientProfileReport(clientId: string): Promise<any> {
return this.http.get(`${this.apiUrl}/clients/${clientId}/profile-report`, {
headers: this.getHeaders()
}).toPromise();
}
async createClient(name: string, phone: string = ''): Promise<any> { async createClient(name: string, phone: string = ''): Promise<any> {
return this.http.post(`${this.apiUrl}/clients/create`, { return this.http.post(`${this.apiUrl}/clients/create`, {
name, phone name, phone

View File

@ -1,143 +1,136 @@
<div class="page report-detail-page" *ngIf="patient?.entrustment as profile"> <div class="page report-detail-page">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="breadcrumb"> <nav class="breadcrumb">
<a routerLink="/clients">← 返回客户视角</a> <a routerLink="/clients">← 返回客户视角</a>
</nav> </nav>
<!-- Loading -->
<div *ngIf="loading" class="xray-header animate-fade-in-up" style="text-align:center; padding: 40px 20px;">
<div style="font-size: 24px; margin-bottom: 12px;"></div>
<div style="color: var(--color-text-secondary);">正在加载客户全景档案...</div>
</div>
<!-- Error / Empty State -->
<div *ngIf="!loading && errorMsg" class="xray-header animate-fade-in-up" style="text-align:center; padding: 40px 20px;">
<div style="font-size: 24px; margin-bottom: 12px;">📋</div>
<div style="color: var(--color-text-secondary);">{{ errorMsg }}</div>
</div>
<!-- Profile Report -->
<ng-container *ngIf="!loading && profile">
<!-- Title Section --> <!-- Title Section -->
<div class="xray-header animate-fade-in-up"> <div class="xray-header animate-fade-in-up">
<div class="xray-super-title">深维👩‍⚕️诊前托付档案</div> <div class="xray-super-title">深维👩‍⚕️诊前托付档案</div>
<div class="xray-sub-title">—— 诊前30分钟读懂全生命周期与社交版图</div> <div class="xray-sub-title">—— 基于 {{ profile._meta?.sourceRecordings?.length || 0 }} 条历史录音的 AI 聚合分析</div>
<div class="xray-meta-grid"> <div class="xray-meta-grid">
<div class="meta-item"><span class="meta-label">客户姓名</span> {{ patient?.name }} ({{ patient?.age }}岁)</div> <div class="meta-item"><span class="meta-label">客户姓名</span> {{ profile.clientName }}</div>
<div class="meta-item"><span class="meta-label">当前等级</span> {{ profile.memberTier }} (建档第{{ profile.yearsEnrolled }}年)</div> <div class="meta-item" *ngIf="profile.lifecycle?.totalRecordings"><span class="meta-label">累计面诊</span> {{ profile.lifecycle.totalRecordings }} 次</div>
<div class="meta-item"><span class="meta-label">NPS满意度</span> @for(s of [1,2,3,4,5]; track $index){⭐}</div> <div class="meta-item" *ngIf="profile.lifecycle?.followUpSpanDays"><span class="meta-label">跟踪周期</span> {{ profile.lifecycle.followUpSpanDays }} 天</div>
</div> </div>
<div class="xray-core-results"> <div class="xray-core-results" *ngIf="profile.consultationStats">
<div class="result-row"> <div class="result-row">
<span class="result-label">个人LTV</span> <span class="result-label">核心诉求</span>
<span class="result-val highlight-danger">累计 {{ profile.personalLtv }}</span> <span class="result-val">{{ profile.consultationStats.coreDemand || '待分析' }}</span>
</div> </div>
<div class="result-row"> <div class="result-row">
<span class="result-label">社交杠杆(网络LTV)</span> <span class="result-label">最近接纳度</span>
<span class="result-val text-success">可撬动 {{ profile.networkLtv }}</span> <span class="result-val highlight-danger">{{ profile.consultationStats.lastAcceptance || '待分析' }}</span>
</div>
<div class="result-row" *ngIf="profile.consultationStats.avgTrustIndex">
<span class="result-label">平均信任指数</span>
<span class="result-val" [class.text-success]="profile.consultationStats.avgTrustIndex >= 60" [class.text-danger]="profile.consultationStats.avgTrustIndex < 40">
{{ profile.consultationStats.avgTrustIndex }}%
<span style="font-size:12px; color: var(--color-text-tertiary);">(趋势: {{ profile.consultationStats.trustTrend === 'improving' ? '↗ 上升' : profile.consultationStats.trustTrend === 'declining' ? '↘ 下降' : '→ 平稳' }})</span>
</span>
</div> </div>
</div> </div>
</div> </div>
<div class="xray-body"> <div class="xray-body">
<!-- Module 1: Lifecycle --> <!-- Module 1: Client Portrait -->
<section class="xray-module animate-fade-in-up"> <section class="xray-module animate-fade-in-up" *ngIf="profile.portrait">
<h2 class="module-title">🟢 客户全生命周期洞察</h2> <h2 class="module-title">🟢 客户画像</h2>
<div class="section-divider">
<span class="divider-num">1</span>
<span class="divider-text">医疗美学偏好 & 物理体征</span>
</div>
<div class="module-card"> <div class="module-card">
<ul class="vital-list"> <ul class="vital-list">
<li><strong>💄 审美底线:</strong> {{ profile.aestheticBaseline }}</li> <li *ngIf="profile.portrait.aestheticBaseline">
<li><strong>😣 痛感耐受度:</strong> <span class="text-danger">{{ profile.painTolerance }}</span></li> <strong>💄 审美底线:</strong> {{ profile.portrait.aestheticBaseline }}
<li><strong>🤔 决策风格:</strong> {{ profile.decisionStyle }}</li> </li>
<li *ngIf="profile.portrait.painTolerance">
<strong>😣 痛感耐受度:</strong> <span class="text-danger">{{ profile.portrait.painTolerance }}</span>
</li>
<li *ngIf="profile.portrait.decisionStyle">
<strong>🤔 决策风格:</strong> {{ profile.portrait.decisionStyle }}
</li>
</ul>
</div>
</section>
<!-- Module 2: Trust Trajectory -->
<section class="xray-module animate-fade-in-up" *ngIf="profile.trustTrajectory">
<h2 class="module-title">📊 信任轨迹</h2>
<!-- Trusted Projects -->
<div class="module-card" *ngIf="profile.trustTrajectory.trustedProjects?.length">
<div class="diagnosis-box" style="border-left-color: #2ecc71; background: #eafaf1;">
<div class="diagnosis-label" style="color: #27ae60;">✅ 已建立信任的项目</div>
<div *ngFor="let tp of profile.trustTrajectory.trustedProjects" style="margin-bottom: 8px;">
<div class="dialogue-line" style="color: #2c3e50;"><strong>{{ tp.project }}</strong></div>
<div *ngIf="tp.evidence" style="font-size: 12px; color: #666; padding-left: 16px;">📎 {{ tp.evidence }}</div>
<div *ngIf="tp.sourceRecording" style="font-size: 11px; color: #999; padding-left: 16px;">📁 来源: {{ tp.sourceRecording }}</div>
</div>
</div>
</div>
<!-- Rejected Projects -->
<div class="module-card" *ngIf="profile.trustTrajectory.rejectedProjects?.length" style="margin-top: 12px;">
<div class="diagnosis-box">
<div class="diagnosis-label">❌ 历史拒因破译</div>
<div *ngFor="let rp of profile.trustTrajectory.rejectedProjects" style="margin-bottom: 12px;">
<div class="dialogue-line"><strong>{{ rp.project }}</strong> <span *ngIf="rp.rejectionCount" style="color: #e74c3c; font-size: 12px;">(被拒{{ rp.rejectionCount }}次)</span></div>
<div *ngIf="rp.evidence" style="font-size: 12px; color: #666; padding-left: 16px;">🗣️ 原声: {{ rp.evidence }}</div>
<div *ngIf="rp.aiRejectionInsight" class="insight-box" style="margin-top: 6px;">
<div class="insight-header">🧠 AI 拒因分析</div>
<div class="insight-body">{{ rp.aiRejectionInsight }}</div>
</div>
<div *ngIf="rp.sourceRecordings?.length" style="font-size: 11px; color: #999; padding-left: 16px; margin-top: 4px;">📁 来源: {{ rp.sourceRecordings.join(', ') }}</div>
</div>
</div>
</div>
</section>
<!-- Module 3: Next Visit Brief -->
<section class="xray-module animate-fade-in-up" *ngIf="profile.nextVisitBrief">
<h2 class="module-title">🧭 下次面诊准备要点</h2>
<div class="module-card" *ngIf="profile.nextVisitBrief.keyRisks?.length">
<div class="breakpoint-header" style="color: #c0392b;">⚠️ 风险提示</div>
<ul class="vital-list">
<li *ngFor="let risk of profile.nextVisitBrief.keyRisks">{{ risk }}</li>
</ul> </ul>
</div> </div>
<div class="section-divider mt-xl"> <div class="module-card" *ngIf="profile.nextVisitBrief.topicsToPrepare?.length" style="margin-top: 12px;">
<span class="divider-num">2</span> <div class="breakpoint-header" style="color: #27ae60;">💡 可探索的话题(客户曾主动询问)</div>
<span class="divider-text">托付轨迹与真实拒因池 (AI 分析)</span> <ul class="vital-list">
</div> <li *ngFor="let topic of profile.nextVisitBrief.topicsToPrepare">{{ topic }}</li>
</ul>
<div class="module-card breakpoint-card">
<div class="diagnosis-box" style="margin-bottom: 12px; border-left-color: #2ecc71; background: #eafaf1;">
<div class="diagnosis-label" style="color: #27ae60;">✅ 已建立的信任 (复购项目)</div>
@for (tp of profile.trustedProjects; track $index) {
<div class="dialogue-line" style="color: #2c3e50;">• {{ tp }}</div>
}
</div>
<div class="diagnosis-box" style="margin-bottom: 12px;">
<div class="diagnosis-label">❌ 历史拒因破译:</div>
@for (rp of profile.rejectedProjects; track $index) {
<div class="dialogue-line">• 拒绝项目:{{ rp }}</div>
}
</div>
<div class="insight-box">
<div class="insight-header">🧠 AI 源数据追踪还原</div>
<div class="insight-body">{{ profile.aiRejectionInsight }}</div>
</div>
</div>
<div class="section-divider mt-xl">
<span class="divider-num">3</span>
<span class="divider-text">今日面诊军师策略 🎯</span>
</div>
<div class="module-card">
<div class="action-track">
<h3 class="track-title">🚀 战略目标:{{ profile.strategyGoal }}</h3>
<div class="track-step">
<div class="step-node">💬 破冰切入</div>
<div class="step-detail strategy-text">{{ profile.icebreaker }}</div>
</div>
<div class="track-step">
<div class="step-node">💡 价值转化</div>
<div class="step-detail strategy-text">{{ profile.valuePitch }}</div>
</div>
</div>
<div class="result-callout" style="background:#fff3f3; color:#c0392b; border: 1px dashed #e74c3c; margin-top: 15px;">
<span class="callout-badge" style="background:#e74c3c; color:white;">🚨 护航预警</span> {{ profile.warningAlert }}
</div>
</div> </div>
</section> </section>
<!-- Module 2: Referral Network --> <!-- Meta footer -->
<section class="xray-module animate-fade-in-up"> <div class="report-footer animate-fade-in-up" *ngIf="profile._meta">
<h2 class="module-title" style="color: #3498db;">🔵 信任引荐网络 (Social Capital)</h2> <div style="font-size: 11px; color: var(--color-text-tertiary);">
<div class="section-divider" style="background: #e1f0fa; border-left-color: #3498db; color: #2980b9;"> 生成时间: {{ profile._meta.generatedAt }} ·
<span class="divider-text">揭示客户的“社交杠杆”,利用同侪圈层压力促单</span> 数据来源: {{ profile._meta.sourceRecordings?.length || 0 }} 条录音 ·
模型: {{ profile._meta.modelUsed }}
</div> </div>
<div class="module-card">
<div class="trust-index" style="background: linear-gradient(135deg, #ebf5fb 0%, #d6eaf8 100%);">
<span class="index-label" style="color:#2980b9;">🌐 节点属性:</span>
<span class="index-value" style="color:#2980b9;">{{ profile.socialNodeValue }}</span>
</div>
<div class="insight-box" style="border-left-color: #3498db; background: #f4f6f9;">
<div class="insight-body">📍 AI 系统提示:{{ profile.aiSocialTip }}</div>
</div>
<h3 class="track-title mt-lg" style="color: #2c3e50;">🔗 引荐关系图谱 (CRM溯源)</h3>
<div class="radar-grid" style="grid-template-columns: 1fr;">
@for (node of profile.referralGraph; track $index) {
<div class="radar-item" style="display:flex; justify-content:space-between; align-items:center; padding: 12px; background: white; border: 1px solid #eee;">
<div>
<span style="font-weight: bold; color: #2c3e50;">{{ node.name }}</span>
<span style="font-size: 12px; background: #f1c40f; color:#fff; padding: 2px 6px; border-radius: 4px; margin-left: 8px;">{{ node.tier }}</span>
</div>
<div style="font-size: 13px; color: #7f8c8d; max-width: 60%; text-align:right;">
{{ node.relation }} <br>喜好: {{ node.preference }}
</div>
</div>
}
</div>
<h3 class="track-title mt-lg" style="color: #2c3e50;">📢 圈层杠杆话术建议</h3>
<div class="expert-box" style="background: #e8f8f5; border: 1px solid #1abc9c;">
<div class="expert-header" style="color: #16a085;">👑 军师代笔(反向同侪种草)</div>
<div class="expert-text" style="color: #2c3e50;">{{ profile.socialLeveragePitch }}</div>
</div>
</div>
</section>
<div class="report-footer animate-fade-in-up">
DeepWise AI 结语:通过社交网络建立的信任势能,远大于单点突破。请合理利用她的闺蜜资源。
</div> </div>
</div> </div>
</ng-container>
</div> </div>

View File

@ -1,7 +1,7 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink, ActivatedRoute } from '@angular/router'; 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'; import { ChatContextService } from '../../core/chat-context.service';
@Component({ @Component({
@ -12,24 +12,44 @@ import { ChatContextService } from '../../core/chat-context.service';
styleUrl: './client-profile.css', styleUrl: './client-profile.css',
}) })
export class ClientProfile implements OnInit { export class ClientProfile implements OnInit {
patient: Patient | undefined; profile: any = null;
clientId = '';
constructor( loading = true;
private route: ActivatedRoute, errorMsg = '';
private chatCtx: ChatContextService
) {} private api = inject(ApiService);
private chatCtx = inject(ChatContextService);
constructor(private route: ActivatedRoute) {}
ngOnInit() { ngOnInit() {
this.route.paramMap.subscribe(params => { this.route.paramMap.subscribe(params => {
const id = params.get('id'); 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 // Setup Chat Context
if (this.patient) { this.chatCtx.contextId.set(`client:${id}`);
this.chatCtx.contextId.set(`client:${this.patient.id}`); this.chatCtx.contextType.set('client');
this.chatCtx.contextTitle.set(this.patient.name);
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;
}
}
} }