feat(backend): implement prosumer storage isolation pattern

This commit is contained in:
lidf 2026-04-12 14:05:23 +08:00
parent e6cf28cb55
commit a71f4051d2
6 changed files with 55 additions and 38 deletions

View File

@ -8,9 +8,9 @@
1. **服从企业知识库 (Wiki Obedience)** 1. **服从企业知识库 (Wiki Obedience)**
- 在任何战略推荐中,必须严格贯彻 `storage/wiki/` 目录下的方法论(如 RFM 客群召回策略。如果客户的画像符合“A4重要挽留”必须使用对应的专属激活打法。 - 在任何战略推荐中,必须严格贯彻 `storage/wiki/` 目录下的方法论(如 RFM 客群召回策略。如果客户的画像符合“A4重要挽留”必须使用对应的专属激活打法。
2. **千人千面适配 (Persona Adoption)** 2. **千人千面适配 (Persona Adoption)**
- 每个医生/顾问的沟通口吻截然不同。你必须在每次输出前读取对应 `storage/doctors/{doc_id}.md` 的偏好设定,将你给出的“话术范例”转换成该医生的真实风格(禁止使用他反感的术语)。 - 每个医生/顾问的沟通口吻截然不同。你必须在每次输出前读取对应 `storage/users/{userId}/doctor_profile.md` 的偏好设定,将你给出的“话术范例”转换成该医生的真实风格(禁止使用他反感的术语)。
3. **基于真实档案 (Profile Grounding)** 3. **基于真实档案 (Profile Grounding)**
- 所有诊断、切入点必须100%基于 `storage/clients/{client_id}/profile.md` 中已经记录的痛点、引荐关系网、真实效果回馈。绝不允许凭空捏造痛点。 - 所有诊断、切入点必须100%基于 `storage/users/{userId}/clients/{client_id}/profile.md` 中已经记录的痛点、引荐关系网、真实效果回馈。绝不允许凭空捏造痛点。
## 🔍 Execution Cycle (运行生命周期) ## 🔍 Execution Cycle (运行生命周期)
- 当你收到 `Chat` 或者 `Strategy Pull` 唤醒时: - 当你收到 `Chat` 或者 `Strategy Pull` 唤醒时:

View File

@ -52,6 +52,8 @@ async def process_uploaded_material_oss(
# 3. 解析 Context 路由(深维专属双上下文 FS-as-Database 管线) # 3. 解析 Context 路由(深维专属双上下文 FS-as-Database 管线)
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage"))
storage_root = Path(storageDir) storage_root = Path(storageDir)
user_id_safe = user_id if user_id else "unknown"
user_dir = storage_root / "users" / user_id_safe
ext = os.path.splitext(original_filename)[1].lower() ext = os.path.splitext(original_filename)[1].lower()
@ -68,18 +70,18 @@ async def process_uploaded_material_oss(
# 旧格式已归档recording:clientId/asrId # 旧格式已归档recording:clientId/asrId
clientId = parts[0] clientId = parts[0]
asrId = parts[-1] asrId = parts[-1]
base_dir = storage_root / "clients" / clientId / "history" base_dir = user_dir / "clients" / clientId / "history"
raw_path = base_dir / f"{asrId}_raw{ext}" raw_path = base_dir / f"{asrId}_raw{ext}"
md_path = base_dir / f"{asrId}.md" md_path = base_dir / f"{asrId}.md"
else: else:
# 新格式Inboxrecording:asrId # 新格式Inboxrecording:asrId
asrId = parts[0] asrId = parts[0]
base_dir = storage_root / "inbox" / asrId base_dir = user_dir / "inbox" / asrId
raw_path = base_dir / f"asr_raw{ext}" raw_path = base_dir / f"asr_raw{ext}"
md_path = base_dir / "asr.md" md_path = base_dir / "asr.md"
elif context_id.startswith("client:"): elif context_id.startswith("client:"):
clientId = context_id.split(":", 1)[1] clientId = context_id.split(":", 1)[1]
base_dir = storage_root / "clients" / clientId base_dir = user_dir / "clients" / clientId
# 客户全景上下文:碎片化资料存档(可能是合同、病历单据等) # 客户全景上下文:碎片化资料存档(可能是合同、病历单据等)
raw_path = base_dir / f"{file_hash}_raw{ext}" raw_path = base_dir / f"{file_hash}_raw{ext}"
md_path = base_dir / f"{file_hash}.md" md_path = base_dir / f"{file_hash}.md"
@ -88,13 +90,12 @@ async def process_uploaded_material_oss(
raw_path = base_dir / f"{file_hash}_raw{ext}" raw_path = base_dir / f"{file_hash}_raw{ext}"
md_path = base_dir / f"{file_hash}.md" md_path = base_dir / f"{file_hash}.md"
elif context_id.startswith("doctor:"): elif context_id.startswith("doctor:"):
doctorId = context_id.split(":", 1)[1] base_dir = user_dir
base_dir = storage_root / "doctors" raw_path = base_dir / f"doctor_profile_raw{ext}"
raw_path = base_dir / f"{doctorId}_raw{ext}" md_path = base_dir / "doctor_profile.md"
md_path = base_dir / f"{doctorId}.md"
else: else:
# Fallback 容错隔离区 # Fallback 容错隔离区
base_dir = storage_root / "misc" / context_id.replace(":", "_") base_dir = user_dir / "misc" / context_id.replace(":", "_")
raw_path = base_dir / f"{file_hash}_raw{ext}" raw_path = base_dir / f"{file_hash}_raw{ext}"
md_path = base_dir / f"{file_hash}.md" md_path = base_dir / f"{file_hash}.md"

View File

@ -170,7 +170,17 @@ class DeepviewSSEServer:
self._initProfilesDb() self._initProfilesDb()
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# SSE 推送基础设施 # 存储辅助方法
# ──────────────────────────────────────────────
def _getUserStorageDir(self, userId: str) -> str:
"""返回当前用户的专属存储沙箱根路径,不存在时自动创建。"""
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage"))
userDir = os.path.join(storageDir, "users", userId)
os.makedirs(userDir, exist_ok=True)
return userDir
# ──────────────────────────────────────────────
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
def _pushEvent(self, userId: str, eventName: str, data: dict) -> None: def _pushEvent(self, userId: str, eventName: str, data: dict) -> None:
@ -511,8 +521,13 @@ class DeepviewSSEServer:
db = SessionDB() db = SessionDB()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# 从环境变量获取存储根目录 # 此处获取 user 的 userId
userObj = await self._extractUser(request)
userId = userObj.get("sub", "unknown") if userObj else "unknown"
# 从环境变量获取存储根目录,并获取用户专属沙箱
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage"))
userDir = self._getUserStorageDir(userId)
# 1. 加载唯一的 SKILL.md (上下文无关版) # 1. 加载唯一的 SKILL.md (上下文无关版)
skillPath = os.path.join( skillPath = os.path.join(
@ -526,7 +541,6 @@ class DeepviewSSEServer:
systemPrompt = f.read() systemPrompt = f.read()
systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir) systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir)
systemPrompt = systemPrompt.replace("{{DOCTOR_ID}}", doctorId)
# 2. 核心架构逻辑:根据 contextId 前缀路由上下文包 # 2. 核心架构逻辑:根据 contextId 前缀路由上下文包
if contextId.startswith("recording:"): if contextId.startswith("recording:"):
@ -536,13 +550,13 @@ class DeepviewSSEServer:
parts = recordingId.split("/") parts = recordingId.split("/")
clientId = parts[0] if len(parts) > 1 else "unknown_client" clientId = parts[0] if len(parts) > 1 else "unknown_client"
asrId = parts[-1] asrId = parts[-1]
asrPath = f"{storageDir}/clients/{clientId}/history/{asrId}.md" asrPath = f"{userDir}/clients/{clientId}/history/{asrId}.md"
systemPrompt += f""" systemPrompt += f"""
## 🎙️ 当前上下文模式:单次面诊录音复盘 ## 🎙️ 当前上下文模式:单次面诊录音复盘
- 录音 ASR 文件{asrPath} - 录音 ASR 文件{asrPath}
- 医生风格档案{storageDir}/doctors/{doctorId}.md - 医生风格档案{userDir}/doctor_profile.md
- 企业知识库目录{storageDir}/wiki/ - 企业知识库目录{storageDir}/wiki/
### 你的工作重心 ### 你的工作重心
@ -554,10 +568,10 @@ class DeepviewSSEServer:
systemPrompt += f""" systemPrompt += f"""
## 👤 当前上下文模式:客户全景档案 ## 👤 当前上下文模式:客户全景档案
- 客户档案目录{storageDir}/clients/{clientId}/ - 客户档案目录{userDir}/clients/{clientId}/
- 核心档案{storageDir}/clients/{clientId}/profile.md - 核心档案{userDir}/clients/{clientId}/profile.md
- 历史录音目录{storageDir}/clients/{clientId}/history/ - 历史录音目录{userDir}/clients/{clientId}/history/
- 医生风格档案{storageDir}/doctors/{doctorId}.md - 医生风格档案{userDir}/doctor_profile.md
- 企业知识库目录{storageDir}/wiki/ - 企业知识库目录{storageDir}/wiki/
### 你的工作重心 ### 你的工作重心
@ -632,6 +646,9 @@ class DeepviewSSEServer:
if not contextId: if not contextId:
return web.json_response({"error": "Missing contextId"}, status=400, headers=_CORS_HEADERS) 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"
userDir = self._getUserStorageDir(userId)
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage"))
# 构建基础 Prompt # 构建基础 Prompt
@ -645,7 +662,6 @@ class DeepviewSSEServer:
systemPrompt = f.read() systemPrompt = f.read()
systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir) systemPrompt = systemPrompt.replace("{{STORAGE_DIR}}", storageDir)
systemPrompt = systemPrompt.replace("{{DOCTOR_ID}}", doctorId)
if contextId.startswith("recording:"): if contextId.startswith("recording:"):
recordingId = contextId.split(":", 1)[1] recordingId = contextId.split(":", 1)[1]
@ -656,14 +672,14 @@ class DeepviewSSEServer:
# 旧格式已归档recording:clientId/asrId # 旧格式已归档recording:clientId/asrId
clientId = parts[0] clientId = parts[0]
asrId = parts[-1] asrId = parts[-1]
asrPath = f"{storageDir}/clients/{clientId}/history/{asrId}.md" asrPath = f"{userDir}/clients/{clientId}/history/{asrId}.md"
clientProfilePath = f"{storageDir}/clients/{clientId}/profile.md" clientProfilePath = f"{userDir}/clients/{clientId}/profile.md"
else: else:
# 新格式Inboxrecording:asrId # 新格式Inboxrecording:asrId
asrId = parts[0] asrId = parts[0]
asrPath = f"{storageDir}/inbox/{asrId}/asr.md" asrPath = f"{userDir}/inbox/{asrId}/asr.md"
clientProfilePath = None clientProfilePath = None
systemPrompt += f"\n## 🎙️ 第1模式单条面诊录音复盘\n你的唯一任务是提取基于这通录音的战术复盘。\n录音路径:{asrPath}\n医生档案参考:{storageDir}/doctors/{doctorId}.md" systemPrompt += f"\n## 🎙️ 第1模式单条面诊录音复盘\n你的唯一任务是提取基于这通录音的战术复盘。\n录音路径:{asrPath}\n医生档案参考:{userDir}/doctor_profile.md"
if clientProfilePath and os.path.exists(clientProfilePath): if clientProfilePath and os.path.exists(clientProfilePath):
systemPrompt += f"\n客户档案参考:{clientProfilePath}" systemPrompt += f"\n客户档案参考:{clientProfilePath}"
systemPrompt += f"\n\n🚨 【系统硬约束Speaker 天然推断指令】" systemPrompt += f"\n\n🚨 【系统硬约束Speaker 天然推断指令】"
@ -671,7 +687,7 @@ class DeepviewSSEServer:
systemPrompt += f"\n在输出所有报告正文时,请直接使用真实姓名,彻底抹除 Speaker_X 痕迹。" systemPrompt += f"\n在输出所有报告正文时,请直接使用真实姓名,彻底抹除 Speaker_X 痕迹。"
else: else:
clientId = contextId.split(":", 1)[1] if ":" in contextId else contextId clientId = contextId.split(":", 1)[1] if ":" in contextId else contextId
systemPrompt += f"\n## 👤 第2模式客户全景档案战略\n你的唯一任务是生成该客户的全景战略洞察报告。\n档案路径:{storageDir}/clients/{clientId}/profile.md\n历史录音目录:{storageDir}/clients/{clientId}/history/\n医生风格:{storageDir}/doctors/{doctorId}.md" 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) ── # ── Stage 1 内容引擎: Hermes AIAgent (md2md) ──
# 只负责生成富文本分析报告,不管格式 # 只负责生成富文本分析报告,不管格式
contentChecklist = """ contentChecklist = """
@ -1477,8 +1493,8 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
if not user: if not user:
return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS) return web.json_response({"error": "Unauthorized"}, status=401, headers=_CORS_HEADERS)
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) userDir = self._getUserStorageDir(user.get("sub", "unknown"))
clientsDir = os.path.join(storageDir, "clients") clientsDir = os.path.join(userDir, "clients")
clients = [] clients = []
if os.path.exists(clientsDir): if os.path.exists(clientsDir):
@ -1518,8 +1534,8 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
import time import time
clientId = f"p_{str(uuid.uuid4())[:8]}" clientId = f"p_{str(uuid.uuid4())[:8]}"
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) userDir = self._getUserStorageDir(user.get("sub", "unknown"))
clientDir = os.path.join(storageDir, "clients", clientId) clientDir = os.path.join(userDir, "clients", clientId)
os.makedirs(clientDir, exist_ok=True) os.makedirs(clientDir, exist_ok=True)
profileData = f"# 客户基本档案\n姓名:{name}\n手机号/尾号:{phone}\n建档时间:{time.strftime('%Y-%m-%d')}\n" profileData = f"# 客户基本档案\n姓名:{name}\n手机号/尾号:{phone}\n建档时间:{time.strftime('%Y-%m-%d')}\n"
@ -1545,8 +1561,8 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
if not clientId: if not clientId:
return web.json_response({"error": "Missing client ID"}, status=400, headers=_CORS_HEADERS) return web.json_response({"error": "Missing client ID"}, status=400, headers=_CORS_HEADERS)
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) userDir = self._getUserStorageDir(user.get("sub", "unknown"))
profilePath = os.path.join(storageDir, "clients", clientId, "profile.md") profilePath = os.path.join(userDir, "clients", clientId, "profile.md")
content = "" content = ""
if os.path.exists(profilePath): if os.path.exists(profilePath):
@ -1584,12 +1600,12 @@ xray.module5: track1(数组,每项含node/action/strategy/purpose), track2(数
return web.json_response({"error": "Missing reportId or clientId"}, status=400, headers=_CORS_HEADERS) return web.json_response({"error": "Missing reportId or clientId"}, status=400, headers=_CORS_HEADERS)
userId = user["userId"] userId = user["userId"]
storageDir = os.getenv("DEEPVIEW_STORAGE_DIR", os.path.expanduser("~/Downloads/Coding/医生助理智能体/backend/storage")) userDir = self._getUserStorageDir(user.get("sub", "unknown"))
# 1. 物理文件迁移:inbox/{reportId}/ → clients/{clientId}/history/{reportId}/ # 1. 物理文件迁移:users/{userId}/inbox/{reportId}/ → users/{userId}/clients/{clientId}/history/{reportId}/
import shutil import shutil
inboxDir = os.path.join(storageDir, "inbox", reportId) inboxDir = os.path.join(userDir, "inbox", reportId)
targetDir = os.path.join(storageDir, "clients", clientId, "history", reportId) targetDir = os.path.join(userDir, "clients", clientId, "history", reportId)
if os.path.exists(inboxDir): if os.path.exists(inboxDir):
os.makedirs(os.path.dirname(targetDir), exist_ok=True) os.makedirs(os.path.dirname(targetDir), exist_ok=True)

View File

@ -4,9 +4,9 @@
给定一段全新长文本(通常为一段面诊录音 ASR 转写Agent 的任务是从中提取出增量的面诊体征数据(信任断点、真实拒因、个人喜好偏好),并将其**无缝追加**到客户的既有 `profile.md` 中。这是碎片化数据生长的引擎。 给定一段全新长文本(通常为一段面诊录音 ASR 转写Agent 的任务是从中提取出增量的面诊体征数据(信任断点、真实拒因、个人喜好偏好),并将其**无缝追加**到客户的既有 `profile.md` 中。这是碎片化数据生长的引擎。
## ⚙️ 先决上下文 (Required Context) ## ⚙️ 先决上下文 (Required Context)
- `<CLIENT_PROFILE>`: `storage/clients/{client_id}/profile.md` - `<CLIENT_PROFILE>`: `storage/users/{userId}/clients/{client_id}/profile.md`
- `<RFM_WIKI>`: `storage/wiki/gemini_rfm_rules.md` - `<WIKI>`: `storage/wiki/`
- `<NEW_ASR_INPUT>`: `storage/clients/{client_id}/history/{date}.md` - `<NEW_ASR_INPUT>`: `storage/users/{userId}/clients/{client_id}/history/{date}.md`或者 `inbox` 裸推演
## 🧠 运行指令 (Execution Instructions) ## 🧠 运行指令 (Execution Instructions)
1. **录音精读**:通读 `<NEW_ASR_INPUT>`。寻找客户提到的“核心痛点”、“预算卡点”、“对于过往医疗服务的不满”、“主动提起的熟人/朋友名字”等。 1. **录音精读**:通读 `<NEW_ASR_INPUT>`。寻找客户提到的“核心痛点”、“预算卡点”、“对于过往医疗服务的不满”、“主动提起的熟人/朋友名字”等。