feat(profile-pipeline): implement two-stage client profile report generation with anti-hallucination schema
This commit is contained in:
parent
cd985e38ea
commit
24fe3c0f7a
@ -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)
|
||||
|
||||
@ -68,6 +68,12 @@ export class ApiService {
|
||||
}).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> {
|
||||
return this.http.post(`${this.apiUrl}/clients/create`, {
|
||||
name, phone
|
||||
|
||||
@ -1,143 +1,136 @@
|
||||
<div class="page report-detail-page" *ngIf="patient?.entrustment as profile">
|
||||
<div class="page report-detail-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb">
|
||||
<a routerLink="/clients">← 返回客户视角</a>
|
||||
</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 -->
|
||||
<div class="xray-header animate-fade-in-up">
|
||||
<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="meta-item"><span class="meta-label">客户姓名</span> {{ patient?.name }} ({{ patient?.age }}岁)</div>
|
||||
<div class="meta-item"><span class="meta-label">当前等级</span> {{ profile.memberTier }} (建档第{{ profile.yearsEnrolled }}年)</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"><span class="meta-label">客户姓名</span> {{ profile.clientName }}</div>
|
||||
<div class="meta-item" *ngIf="profile.lifecycle?.totalRecordings"><span class="meta-label">累计面诊</span> {{ profile.lifecycle.totalRecordings }} 次</div>
|
||||
<div class="meta-item" *ngIf="profile.lifecycle?.followUpSpanDays"><span class="meta-label">跟踪周期</span> {{ profile.lifecycle.followUpSpanDays }} 天</div>
|
||||
</div>
|
||||
|
||||
<div class="xray-core-results">
|
||||
<div class="xray-core-results" *ngIf="profile.consultationStats">
|
||||
<div class="result-row">
|
||||
<span class="result-label">个人LTV</span>
|
||||
<span class="result-val highlight-danger">累计 {{ profile.personalLtv }}</span>
|
||||
<span class="result-label">核心诉求</span>
|
||||
<span class="result-val">{{ profile.consultationStats.coreDemand || '待分析' }}</span>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="result-label">社交杠杆(网络LTV)</span>
|
||||
<span class="result-val text-success">可撬动 {{ profile.networkLtv }}</span>
|
||||
<span class="result-label">最近接纳度</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 class="xray-body">
|
||||
|
||||
<!-- Module 1: Lifecycle -->
|
||||
<section class="xray-module animate-fade-in-up">
|
||||
<h2 class="module-title">🟢 客户全生命周期洞察</h2>
|
||||
|
||||
<div class="section-divider">
|
||||
<span class="divider-num">1</span>
|
||||
<span class="divider-text">医疗美学偏好 & 物理体征</span>
|
||||
</div>
|
||||
<!-- Module 1: Client Portrait -->
|
||||
<section class="xray-module animate-fade-in-up" *ngIf="profile.portrait">
|
||||
<h2 class="module-title">🟢 客户画像</h2>
|
||||
|
||||
<div class="module-card">
|
||||
<ul class="vital-list">
|
||||
<li><strong>💄 审美底线:</strong> {{ profile.aestheticBaseline }}</li>
|
||||
<li><strong>😣 痛感耐受度:</strong> <span class="text-danger">{{ profile.painTolerance }}</span></li>
|
||||
<li><strong>🤔 决策风格:</strong> {{ profile.decisionStyle }}</li>
|
||||
<li *ngIf="profile.portrait.aestheticBaseline">
|
||||
<strong>💄 审美底线:</strong> {{ profile.portrait.aestheticBaseline }}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="section-divider mt-xl">
|
||||
<span class="divider-num">2</span>
|
||||
<span class="divider-text">托付轨迹与真实拒因池 (AI 分析)</span>
|
||||
</div>
|
||||
|
||||
<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 class="module-card" *ngIf="profile.nextVisitBrief.topicsToPrepare?.length" style="margin-top: 12px;">
|
||||
<div class="breakpoint-header" style="color: #27ae60;">💡 可探索的话题(客户曾主动询问)</div>
|
||||
<ul class="vital-list">
|
||||
<li *ngFor="let topic of profile.nextVisitBrief.topicsToPrepare">{{ topic }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Module 2: Referral Network -->
|
||||
<section class="xray-module animate-fade-in-up">
|
||||
<h2 class="module-title" style="color: #3498db;">🔵 信任引荐网络 (Social Capital)</h2>
|
||||
<div class="section-divider" style="background: #e1f0fa; border-left-color: #3498db; color: #2980b9;">
|
||||
<span class="divider-text">揭示客户的“社交杠杆”,利用同侪圈层压力促单</span>
|
||||
<!-- Meta footer -->
|
||||
<div class="report-footer animate-fade-in-up" *ngIf="profile._meta">
|
||||
<div style="font-size: 11px; color: var(--color-text-tertiary);">
|
||||
生成时间: {{ profile._meta.generatedAt }} ·
|
||||
数据来源: {{ profile._meta.sourceRecordings?.length || 0 }} 条录音 ·
|
||||
模型: {{ profile._meta.modelUsed }}
|
||||
</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>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
profile: any = null;
|
||||
clientId = '';
|
||||
loading = true;
|
||||
errorMsg = '';
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private chatCtx: ChatContextService
|
||||
) {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user