diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py index 8028da23..dadb38d5 100644 --- a/backend/app/api/graph.py +++ b/backend/app/api/graph.py @@ -18,6 +18,7 @@ from ..utils.logger import get_logger from ..utils.locale import t, get_locale, set_locale from ..models.task import TaskManager, TaskStatus from ..models.project import ProjectManager, ProjectStatus +from ..services.cached_replays import get_cached_graph, get_cached_project # 获取日志器 logger = get_logger('foresight.api') @@ -31,6 +32,51 @@ def allowed_file(filename: str) -> bool: return ext in Config.ALLOWED_EXTENSIONS +def _prepare_graph_for_visualization(graph_data: dict) -> dict: + """Keep graph visualization dense and readable by showing the top connected nodes.""" + if not isinstance(graph_data, dict): + return graph_data + if graph_data.get("fallback_source") == "cached_demo_graph": + return graph_data + nodes = graph_data.get("nodes") or [] + edges = graph_data.get("edges") or [] + max_nodes = max(1, Config.GRAPH_VISUAL_MAX_NODES) + if len(nodes) <= max_nodes: + return graph_data + + degree = {n.get("uuid"): 0 for n in nodes} + for edge in edges: + src = edge.get("source_node_uuid") + tgt = edge.get("target_node_uuid") + if src in degree: + degree[src] += 1 + if tgt in degree: + degree[tgt] += 1 + + def node_score(node): + labels = node.get("labels") or [] + type_bonus = 1 if any(label not in ("Entity", "Node", "__Entity__") for label in labels) else 0 + return (degree.get(node.get("uuid"), 0), type_bonus, node.get("name") or "") + + selected_nodes = sorted(nodes, key=node_score, reverse=True)[:max_nodes] + selected_ids = {n.get("uuid") for n in selected_nodes} + selected_edges = [ + edge for edge in edges + if edge.get("source_node_uuid") in selected_ids and edge.get("target_node_uuid") in selected_ids + ] + + result = dict(graph_data) + result["raw_node_count"] = graph_data.get("node_count", len(nodes)) + result["raw_edge_count"] = graph_data.get("edge_count", len(edges)) + result["nodes"] = selected_nodes + result["edges"] = selected_edges + result["node_count"] = len(selected_nodes) + result["edge_count"] = len(selected_edges) + result["visualization_limited"] = True + result["visualization_max_nodes"] = max_nodes + return result + + # ============== 项目管理接口 ============== @graph_bp.route('/project/', methods=['GET']) @@ -38,6 +84,13 @@ def get_project(project_id: str): """ 获取项目详情 """ + cached = get_cached_project(project_id) + if cached: + return jsonify({ + "success": True, + "data": cached + }) + project = ProjectManager.get_project(project_id) if not project: @@ -478,6 +531,8 @@ def build_graph(): f"[{task_id}] CustomGraphBuilder 完成: " f"entities={build_stats.get('entities_count')}, " f"edges={build_stats.get('edges_count')}, " + f"snapshot_nodes={build_stats.get('snapshot_node_count')}, " + f"snapshot_edges={build_stats.get('snapshot_edge_count')}, " f"chunks={build_stats.get('chunks_processed')}/{build_stats.get('total_chunks')}, " f"failed={build_stats.get('chunks_failed')}" ) @@ -491,7 +546,17 @@ def build_graph(): message=t('progress.fetchingGraphData'), progress=95 ) - graph_data = builder.get_graph_data(graph_id) + snapshot_graph_data = build_stats.get("graph_data") or {} + ProjectManager.save_graph_snapshot(project_id, snapshot_graph_data) + try: + graph_data = builder.get_graph_data(graph_id) + ProjectManager.save_graph_snapshot(project_id, graph_data) + except Exception as e: + build_logger.warning( + f"[{task_id}] Neo4j 图谱读取失败,使用本地 snapshot: " + f"{type(e).__name__}: {str(e)[:200]}" + ) + graph_data = snapshot_graph_data # 更新项目状态 project.status = ProjectStatus.GRAPH_COMPLETED @@ -596,6 +661,13 @@ def get_graph_data(graph_id: str): 获取图谱数据(节点和边) """ try: + cached = get_cached_graph(graph_id) + if cached: + return jsonify({ + "success": True, + "data": _prepare_graph_for_visualization(cached) + }) + if not Config.ZEP_API_KEY: return jsonify({ "success": False, @@ -603,7 +675,17 @@ def get_graph_data(graph_id: str): }), 500 builder = GraphBuilderService(api_key=Config.ZEP_API_KEY) - graph_data = builder.get_graph_data(graph_id) + try: + graph_data = builder.get_graph_data(graph_id) + except Exception as e: + project = ProjectManager.get_project_by_graph_id(graph_id) + graph_data = ProjectManager.get_graph_snapshot(project.project_id) if project else None + if not graph_data: + raise + graph_data["fallback_source"] = "local_graph_snapshot" + graph_data["fallback_reason"] = f"{type(e).__name__}: {str(e)[:200]}" + + graph_data = _prepare_graph_for_visualization(graph_data) return jsonify({ "success": True, diff --git a/backend/app/api/report.py b/backend/app/api/report.py index 36d02807..dcce1813 100644 --- a/backend/app/api/report.py +++ b/backend/app/api/report.py @@ -14,6 +14,14 @@ from ..services.report_agent import ReportAgent from ..services.report_data import ReportManager, ReportStatus from ..services.simulation_analytics import SimulationAnalyticsService from ..services.simulation_manager import SimulationManager +from ..services.cached_replays import ( + get_cached_console_log, + get_cached_infographic, + get_cached_report, + get_cached_report_by_simulation, + get_cached_report_chat, + get_cached_report_logs, +) from ..models.project import ProjectManager from ..models.task import TaskManager, TaskStatus from ..utils.logger import get_logger @@ -300,6 +308,13 @@ def get_report(report_id: str): } """ try: + cached = get_cached_report(report_id) + if cached: + return jsonify({ + "success": True, + "data": cached + }) + report = ReportManager.get_report(report_id) if not report: @@ -337,6 +352,14 @@ def get_report_by_simulation(simulation_id: str): } """ try: + cached = get_cached_report_by_simulation(simulation_id) + if cached: + return jsonify({ + "success": True, + "data": cached, + "has_report": True + }) + report = ReportManager.get_report_by_simulation(simulation_id) if not report: @@ -520,6 +543,17 @@ def chat_with_report_agent(): "success": False, "error": t('api.requireMessage') }), 400 + + cached_chat = get_cached_report_chat( + simulation_id=simulation_id, + message=message, + chat_history=chat_history, + ) + if cached_chat: + return jsonify({ + "success": True, + "data": cached_chat, + }) # 获取模拟和项目信息 manager = SimulationManager() @@ -730,6 +764,19 @@ def check_report_status(simulation_id: str): } """ try: + cached = get_cached_report_by_simulation(simulation_id) + if cached: + return jsonify({ + "success": True, + "data": { + "simulation_id": simulation_id, + "has_report": True, + "report_status": "completed", + "report_id": cached["report_id"], + "interview_unlocked": True + } + }) + report = ReportManager.get_report_by_simulation(simulation_id) has_report = report is not None @@ -803,6 +850,12 @@ def get_agent_log(report_id: str): """ try: from_line = request.args.get('from_line', 0, type=int) + cached_log = get_cached_report_logs(report_id, from_line=from_line) + if cached_log: + return jsonify({ + "success": True, + "data": cached_log + }) log_data = ReportManager.get_agent_log(report_id, from_line=from_line) @@ -835,6 +888,16 @@ def stream_agent_log(report_id: str): } """ try: + cached_log = get_cached_report_logs(report_id, from_line=0) + if cached_log: + return jsonify({ + "success": True, + "data": { + "logs": cached_log["logs"], + "count": len(cached_log["logs"]) + } + }) + logs = ReportManager.get_agent_log_stream(report_id) return jsonify({ @@ -885,6 +948,12 @@ def get_console_log(report_id: str): """ try: from_line = request.args.get('from_line', 0, type=int) + cached_log = get_cached_console_log(report_id, from_line=from_line) + if cached_log: + return jsonify({ + "success": True, + "data": cached_log + }) log_data = ReportManager.get_console_log(report_id, from_line=from_line) @@ -1037,6 +1106,10 @@ def get_report_infographic(report_id: str): 优先从报告文件夹读取缓存数据,如无则实时计算。 """ try: + cached = get_cached_infographic(report_id) + if cached: + return jsonify({"success": True, "data": cached}) + cached = ReportManager.get_infographic(report_id) if cached: return jsonify({"success": True, "data": cached}) diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index 4de212c8..c57af96f 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -15,6 +15,17 @@ from ..services.zep_entity_reader import ZepEntityReader from ..services.oasis_profile_generator import OasisProfileGenerator from ..services.simulation_manager import SimulationManager, SimulationStatus from ..services.simulation_runner import SimulationRunner, RunnerStatus +from ..services.cached_replays import ( + NB_HNW_AI_CASE_ID, + get_cached_config, + get_cached_config_realtime, + get_cached_history_items, + get_cached_profiles, + get_cached_replay, + get_cached_run_detail, + get_cached_run_status, + get_cached_simulation, +) from ..utils.logger import get_logger from ..utils.locale import t, get_locale, set_locale from ..models.project import ProjectManager @@ -269,7 +280,6 @@ def _check_simulation_prepared(simulation_id: str) -> tuple: "state.json", "simulation_config.json", "reddit_profiles.json", - "twitter_profiles.csv" ] # 检查文件是否存在 @@ -417,6 +427,27 @@ def prepare_simulation(): "success": False, "error": t('api.requireSimulationId') }), 400 + + cached_state = get_cached_simulation(simulation_id) + if cached_state: + return jsonify({ + "success": True, + "data": { + "simulation_id": simulation_id, + "status": "ready", + "message": t('api.alreadyPrepared'), + "already_prepared": True, + "prepare_info": { + "status": "completed", + "entities_count": cached_state.get("entities_count", 0), + "profiles_count": cached_state.get("profiles_count", 0), + "entity_types": cached_state.get("entity_types", []), + "config_generated": True, + "created_at": cached_state.get("created_at"), + "updated_at": cached_state.get("updated_at"), + } + } + }) manager = SimulationManager() state = manager.get_simulation(simulation_id) @@ -500,6 +531,7 @@ def prepare_simulation(): defined_entity_types=entity_types_list, enrich_with_edges=False # 不获取边信息,加快速度 ) + filtered_preview = manager._cap_entities_for_live_demo(filtered_preview) # 保存实体数量到状态(供前端立即获取) state.entities_count = filtered_preview.filtered_count state.entity_types = list(filtered_preview.entity_types) @@ -840,6 +872,13 @@ def get_prepare_status(): def get_simulation(simulation_id: str): """获取模拟状态""" try: + cached_state = get_cached_simulation(simulation_id) + if cached_state: + return jsonify({ + "success": True, + "data": cached_state + }) + manager = SimulationManager() state = manager.get_simulation(simulation_id) @@ -996,6 +1035,7 @@ def get_simulation_history(): limit = request.args.get('limit', 20, type=int) manager = SimulationManager() + cached_history_items = get_cached_history_items() simulations = manager.list_simulations()[:limit] # 增强模拟数据,只从 Simulation 文件读取 @@ -1055,11 +1095,18 @@ def get_simulation_history(): sim_dict["created_date"] = "" enriched_simulations.append(sim_dict) + + existing_ids = {item.get("simulation_id") for item in enriched_simulations} + visible_cached_items = [ + item for item in cached_history_items + if item.get("simulation_id") not in existing_ids + ] + history_items = (visible_cached_items + enriched_simulations)[:limit] return jsonify({ "success": True, - "data": enriched_simulations, - "count": len(enriched_simulations) + "data": history_items, + "count": len(history_items) }) except Exception as e: @@ -1071,6 +1118,47 @@ def get_simulation_history(): }), 500 +@simulation_bp.route('/', methods=['DELETE']) +def delete_simulation(simulation_id: str): + """删除一个历史模拟结果。缓存演示案例不允许删除。""" + try: + if simulation_id == NB_HNW_AI_CASE_ID: + return jsonify({ + "success": False, + "error": "缓存演示案例不能删除" + }), 403 + + manager = SimulationManager() + deleted = manager.delete_simulation(simulation_id) + if not deleted: + return jsonify({ + "success": False, + "error": t('api.simulationNotFound', id=simulation_id) + }), 404 + + return jsonify({ + "success": True, + "data": { + "simulation_id": simulation_id, + "deleted": True + } + }) + + except ValueError as e: + return jsonify({ + "success": False, + "error": str(e) + }), 400 + + except Exception as e: + logger.error(f"删除模拟失败: {simulation_id}, error={str(e)}") + return jsonify({ + "success": False, + "error": str(e), + "traceback": traceback.format_exc() + }), 500 + + @simulation_bp.route('//profiles', methods=['GET']) def get_simulation_profiles(simulation_id: str): """ @@ -1081,6 +1169,16 @@ def get_simulation_profiles(simulation_id: str): """ try: platform = request.args.get('platform', 'reddit') + cached_profiles = get_cached_profiles(simulation_id, platform=platform) + if cached_profiles is not None: + return jsonify({ + "success": True, + "data": { + "platform": platform, + "count": len(cached_profiles), + "profiles": cached_profiles + } + }) manager = SimulationManager() profiles = manager.get_profiles(simulation_id, platform=platform) @@ -1143,6 +1241,21 @@ def get_simulation_profiles_realtime(simulation_id: str): try: platform = request.args.get('platform', 'reddit') + cached_profiles = get_cached_profiles(simulation_id, platform=platform) + if cached_profiles is not None: + return jsonify({ + "success": True, + "data": { + "simulation_id": simulation_id, + "platform": platform, + "count": len(cached_profiles), + "total_expected": len(cached_profiles), + "is_generating": False, + "file_exists": True, + "file_modified_at": "2026-06-04T09:50:00", + "profiles": cached_profiles + } + }) # 获取模拟目录 sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) @@ -1247,6 +1360,13 @@ def get_simulation_config_realtime(simulation_id: str): from datetime import datetime try: + cached_config = get_cached_config_realtime(simulation_id) + if cached_config: + return jsonify({ + "success": True, + "data": cached_config + }) + # 获取模拟目录 sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id) @@ -1352,6 +1472,13 @@ def get_simulation_config(simulation_id: str): - generation_reasoning: LLM的配置推理说明 """ try: + cached_config = get_cached_config(simulation_id) + if cached_config: + return jsonify({ + "success": True, + "data": cached_config + }) + manager = SimulationManager() config = manager.get_simulation_config(simulation_id) @@ -1491,6 +1618,7 @@ def generate_profiles(): defined_entity_types=entity_types, enrich_with_edges=True ) + filtered = SimulationManager._cap_entities_for_live_demo(filtered) if filtered.filtered_count == 0: return jsonify({ @@ -1583,6 +1711,17 @@ def start_simulation(): "error": t('api.requireSimulationId') }), 400 + cached_status = get_cached_run_status(simulation_id) + if cached_status: + response_data = dict(cached_status) + response_data["force_restarted"] = bool(data.get('force', False)) + response_data["graph_memory_update_enabled"] = False + response_data["demo_safe_mode"] = True + return jsonify({ + "success": True, + "data": response_data + }) + platform = data.get('platform', 'parallel') max_rounds = data.get('max_rounds') # 可选:最大模拟轮数 enable_graph_memory_update = data.get('enable_graph_memory_update', False) # 可选:是否启用图谱记忆更新 @@ -1813,6 +1952,13 @@ def get_run_status(simulation_id: str): } """ try: + cached_status = get_cached_run_status(simulation_id) + if cached_status: + return jsonify({ + "success": True, + "data": cached_status + }) + run_state = SimulationRunner.get_run_state(simulation_id) if not run_state: @@ -1882,8 +2028,15 @@ def get_run_status_detail(simulation_id: str): } """ try: - run_state = SimulationRunner.get_run_state(simulation_id) platform_filter = request.args.get('platform') + cached_detail = get_cached_run_detail(simulation_id, platform_filter=platform_filter) + if cached_detail: + return jsonify({ + "success": True, + "data": cached_detail + }) + + run_state = SimulationRunner.get_run_state(simulation_id) if not run_state: return jsonify({ @@ -2102,6 +2255,13 @@ def get_simulation_replay(simulation_id: str): 用途:前端 /simulation/:id/replay 路由,播放整个模拟过程 """ try: + cached = get_cached_replay(simulation_id) + if cached: + return jsonify({ + "success": True, + "data": cached + }) + manager = SimulationManager() state = manager.get_simulation(simulation_id) if not state: diff --git a/backend/app/config.py b/backend/app/config.py index 5160601c..ea0255ed 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,6 +31,8 @@ class Config: LLM_API_KEY = os.environ.get('LLM_API_KEY') LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1') LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini') + LLM_TIMEOUT_SECONDS = float(os.environ.get('LLM_TIMEOUT_SECONDS', '12')) + LLM_MAX_RETRIES = int(os.environ.get('LLM_MAX_RETRIES', '0')) # Neo4j / Graphiti 配置 NEO4J_URI = os.environ.get('NEO4J_URI', 'bolt://localhost:7687') @@ -62,6 +64,8 @@ class Config: # OASIS模拟配置 OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10')) OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations') + SIMULATION_MAX_AGENTS = int(os.environ.get('SIMULATION_MAX_AGENTS', '30')) + GRAPH_VISUAL_MAX_NODES = int(os.environ.get('GRAPH_VISUAL_MAX_NODES', '30')) # OASIS平台可用动作配置 OASIS_TWITTER_ACTIONS = [ @@ -87,4 +91,3 @@ class Config: if not cls.NEO4J_PASSWORD: errors.append("NEO4J_PASSWORD 未配置") return errors - diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 69914053..f5485218 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -128,6 +128,11 @@ class ProjectManager: def _get_project_text_path(cls, project_id: str) -> str: """获取项目提取文本存储路径""" return os.path.join(cls._get_project_dir(project_id), 'extracted_text.txt') + + @classmethod + def _get_graph_snapshot_path(cls, project_id: str) -> str: + """获取本地图谱快照路径""" + return os.path.join(cls._get_project_dir(project_id), 'graph_snapshot.json') @classmethod def create_project(cls, name: str = "Unnamed Project") -> Project: @@ -193,6 +198,32 @@ class ProjectManager: data = json.load(f) return Project.from_dict(data) + + @classmethod + def get_project_by_graph_id(cls, graph_id: str) -> Optional[Project]: + """通过 graph_id 查找项目""" + cls._ensure_projects_dir() + for project_id in os.listdir(cls.PROJECTS_DIR): + project = cls.get_project(project_id) + if project and project.graph_id == graph_id: + return project + return None + + @classmethod + def save_graph_snapshot(cls, project_id: str, graph_data: Dict[str, Any]) -> None: + """保存本地图谱快照,供 Neo4j 不可用时演示回退""" + snapshot_path = cls._get_graph_snapshot_path(project_id) + with open(snapshot_path, 'w', encoding='utf-8') as f: + json.dump(graph_data, f, ensure_ascii=False, indent=2) + + @classmethod + def get_graph_snapshot(cls, project_id: str) -> Optional[Dict[str, Any]]: + """读取本地图谱快照""" + snapshot_path = cls._get_graph_snapshot_path(project_id) + if not os.path.exists(snapshot_path): + return None + with open(snapshot_path, 'r', encoding='utf-8') as f: + return json.load(f) @classmethod def list_projects(cls, limit: int = 50) -> List[Project]: @@ -302,4 +333,3 @@ class ProjectManager: for f in os.listdir(files_dir) if os.path.isfile(os.path.join(files_dir, f)) ] - diff --git a/backend/app/services/cached_replays.py b/backend/app/services/cached_replays.py new file mode 100644 index 00000000..4de14c8f --- /dev/null +++ b/backend/app/services/cached_replays.py @@ -0,0 +1,1647 @@ +""" +Cached replay cases for live demos. + +These cases avoid network, LLM, Neo4j, OASIS, Torch, and GPU dependencies. They +return the same payload shape as /api/simulation//replay so the existing +SimulationReplayView can present a stable Manus-style viewing mode. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional +from uuid import uuid5, NAMESPACE_DNS + + +NB_PROJECT_ID = "proj_nb_hnw_ai_case" +NB_HNW_AI_CASE_ID = "sim_nb_hnw_ai_case" +NB_GRAPH_ID = "cached_nb_hnw_ai_graph" +NB_REPORT_ID = "report_nb_hnw_ai_case" +NB_REQUIREMENT = "以宁波银行大客户经理为核心,为高净值客户推介科技、AI相关理财产品组合,并预测产品组成与销售效果。" +NB_CREATED_AT = "2026-06-04T09:30:00" + + +CASE_AGENTS = [ + (0, "宁波银行大客户经理", "KeyAccountManager", "负责高净值客户资产配置、产品组合推介与合规确认。"), + (1, "高净值客户A", "HighNetWorthClient", "制造业企业主,关注长期稳健收益与科技主题成长性。"), + (2, "私行投资顾问", "PrivateBankingAdvisor", "负责组合建议、风险匹配和产品说明。"), + (3, "科技主题基金经理", "TechFundManager", "关注AI算力、半导体、云基础设施与软件生态。"), + (4, "固收产品经理", "FixedIncomePM", "提供现金管理、短债、同业存单与中高等级信用债建议。"), + (5, "风控合规经理", "RiskCompliance", "检查适当性、集中度、流动性和宣传口径。"), + (6, "AI行业研究员", "AIIndustryAnalyst", "跟踪AI产业链变量、订单和估值波动。"), + (7, "客户家族办公室代表", "FamilyOffice", "关注传承、回撤控制、税务与多账户执行。"), + (8, "配偶共同决策人", "FamilyDecisionMaker", "关注家庭安全垫、教育金安排和回撤体验。"), + (9, "二代继承人", "NextGenDecisionMaker", "关注AI技术机会、长期成长和家族企业数字化。"), + (10, "企业财务负责人", "CorporateCFO", "关注企业现金流、闲置资金和资金使用窗口。"), + (11, "分行财富主管", "BranchWealthLead", "关注AUM新增、客户经营节奏和团队复制。"), + (12, "总行产品准入经理", "ProductAccessManager", "关注产品白名单、风险等级和准入口径。"), + (13, "运营留痕专员", "OpsArchiveSpecialist", "关注客户确认、录音录像和材料归档。"), + (14, "客户服务经理", "ServiceManager", "负责会后跟进、复盘提醒和客户体验维护。"), + (15, "量化对冲产品经理", "QuantPM", "解释低相关资产和回撤控制工具。"), + (16, "黄金多资产策略师", "MultiAssetStrategist", "解释黄金、多资产和汇率波动对冲逻辑。"), + (17, "半导体研究员", "SemiconductorAnalyst", "跟踪国产替代、设备材料和先进封装机会。"), + (18, "云计算研究员", "CloudAnalyst", "跟踪云厂商盈利弹性、AI应用和企业软件复苏。"), + (19, "数据中心研究员", "DataCenterAnalyst", "跟踪算力基础设施、电力约束和REITs机会。"), + (20, "利率策略师", "RateStrategist", "跟踪利率下行、久期风险和信用利差。"), + (21, "汇率策略师", "FXStrategist", "跟踪人民币汇率、海外资产顾虑和黄金配置。"), + (22, "同圈层企业主B", "PeerClient", "观察同类客户是否愿意接受AI主题配置。"), + (23, "保守型客户C", "ConservativeClient", "代表低波动偏好客户,对权益主题保持谨慎。"), + (24, "进取型客户D", "AggressiveClient", "代表高风险偏好客户,关注AI主题弹性。"), + (25, "客户朋友推荐人", "ReferralSource", "观察客户满意度和转介绍机会。"), + (26, "合规质检员", "ComplianceAuditor", "审查话术、适当性和留痕完整度。"), + (27, "家族信托顾问", "TrustAdvisor", "承接税务传承、资产隔离和家庭治理需求。"), + (28, "保险金信托顾问", "InsuranceTrustAdvisor", "承接保障、传承和交叉销售机会。"), + (29, "销售管理看板", "SalesAnalytics", "聚合首轮成交、二次转化、AUM和服务满意度指标。"), +] + + +def _case_time(minutes: int = 0) -> str: + return (datetime.fromisoformat(NB_CREATED_AT) + timedelta(minutes=minutes)).isoformat() + + +def _stable_uuid(name: str) -> str: + return str(uuid5(NAMESPACE_DNS, f"foresight.nb-hnw-ai.{name}")) + + +def _cached_files() -> List[Dict[str, Any]]: + return [ + {"filename": "宁波银行高净值客户AI理财组合缓存案例.md", "size": 4096}, + {"filename": "产品组合与销售效果回放.json", "size": 8192}, + {"filename": "拜访提纲与合规话术.md", "size": 3072}, + ] + + +def get_cached_project(project_id: str) -> Optional[Dict[str, Any]]: + if project_id != NB_PROJECT_ID: + return None + + return { + "project_id": NB_PROJECT_ID, + "name": "宁波银行高净值客户AI理财组合推介", + "status": "graph_completed", + "created_at": NB_CREATED_AT, + "updated_at": _case_time(70), + "files": _cached_files(), + "total_text_length": 4096, + "ontology": { + "entity_types": [ + {"name": "Bank", "description": "银行机构与经营主体"}, + {"name": "BusinessUnit", "description": "财富管理、私行和投顾中台"}, + {"name": "KeyAccountManager", "description": "银行大客户经理"}, + {"name": "HighNetWorthClient", "description": "高净值客户"}, + {"name": "DecisionMaker", "description": "家庭或企业决策参与人"}, + {"name": "ClientNeed", "description": "客户显性与隐性需求"}, + {"name": "PrivateBankingAdvisor", "description": "私行投资顾问"}, + {"name": "FinancialProduct", "description": "理财与资产配置产品"}, + {"name": "Portfolio", "description": "产品组合与配置方案"}, + {"name": "RiskCompliance", "description": "风控合规角色"}, + {"name": "MarketVariable", "description": "影响产品销售与配置的市场变量"}, + {"name": "ComplianceProcess", "description": "适当性、准入、留痕与风险揭示流程"}, + {"name": "SalesStage", "description": "客户经营与销售转化阶段"}, + {"name": "BusinessOutcome", "description": "销售、AUM、粘性与满意度结果"}, + ], + "relationship_types": [ + "SERVES", "ADVISES", "RECOMMENDS", "CONTAINS", "CONSTRAINS", "PREDICTS", "MONITORS", + "INFLUENCES", "REQUIRES", "VALIDATES", "APPROVES", "MITIGATES", "TRIGGERS", + "SUPPORTS", "FOLLOWS_UP", "CONVERTS_TO", "ALLOCATES_TO", "HEDGES_WITH", "DEPENDS_ON" + ], + }, + "analysis_summary": "现场缓存案例:围绕宁波银行大客户经理、高净值客户、私行投顾、科技/AI主题产品、固收底仓和合规约束,构建可回溯的项目过程。", + "graph_id": NB_GRAPH_ID, + "graph_build_task_id": None, + "simulation_requirement": NB_REQUIREMENT, + "chunk_size": 250, + "chunk_overlap": 50, + "error": None, + } + + +def get_cached_graph(graph_id: str) -> Optional[Dict[str, Any]]: + if graph_id != NB_GRAPH_ID: + return None + + entities = [ + ("宁波银行", "Bank", "提供私行、财富管理和企业金融服务的核心机构。"), + ("总行财富管理部", "BusinessUnit", "负责产品策略、财富客群经营和销售支持。"), + ("宁波银行私行中心", "BusinessUnit", "承接高净值客户资产配置、投顾服务和家族场景经营。"), + ("分行经营管理层", "BusinessUnit", "关注大客户经营、AUM增长和合规销售质量。"), + ("投顾中台", "BusinessUnit", "沉淀组合模型、市场观点和标准化材料。"), + ("运营支持团队", "BusinessUnit", "负责材料准备、客户确认、回访与留痕归档。"), + ("宁波银行大客户经理", "KeyAccountManager", "本次推介的主导角色,负责客户沟通、方案组织和服务切入。"), + ("私行投资顾问", "PrivateBankingAdvisor", "负责组合建议、风险匹配和产品说明。"), + ("科技主题基金经理", "PrivateBankingAdvisor", "解释科技权益仓的产业逻辑、估值风险和调仓纪律。"), + ("固收产品经理", "PrivateBankingAdvisor", "解释短债、信用债、同业存单和固收增强底仓。"), + ("AI行业研究员", "MarketVariable", "跟踪AI产业链、算力、半导体和云基础设施变量。"), + ("风控合规经理", "RiskCompliance", "检查适当性、集中度、流动性和宣传口径。"), + ("产品准入委员会", "RiskCompliance", "控制产品白名单、风险等级和销售适配边界。"), + ("高净值客户A", "HighNetWorthClient", "制造业企业主,关注稳健收益、流动性和科技主题成长敞口。"), + ("配偶共同决策人", "DecisionMaker", "关注家庭安全垫、子女安排和回撤体验。"), + ("二代继承人", "DecisionMaker", "关注AI技术、长期成长和家族企业转型机会。"), + ("客户家族办公室代表", "DecisionMaker", "关注传承、回撤控制、税务与多账户执行。"), + ("企业财务负责人", "DecisionMaker", "关注企业现金流、闲置资金和资金使用窗口。"), + ("企业现金流", "ClientNeed", "企业主家庭与经营资产之间的资金调度约束。"), + ("家庭资产安全垫", "ClientNeed", "保证生活、教育、医疗和企业周转的基础资产。"), + ("流动性需求", "ClientNeed", "要求组合保留可赎回、可调仓和应急资金比例。"), + ("风险偏好画像", "ClientNeed", "中等风险偏好,可接受有限波动但反感单一押注。"), + ("税务与传承诉求", "ClientNeed", "关注资产隔离、传承安排和家庭治理。"), + ("教育金计划", "ClientNeed", "为下一代教育保留稳定现金流安排。"), + ("海外资产顾虑", "ClientNeed", "关注汇率、跨境配置和境内替代方案。"), + ("科技AI产品组合", "Portfolio", "推荐组合:现金20%、固收40%、科技权益22%、AI观察仓8%、黄金/多资产10%。"), + ("稳健观察档", "Portfolio", "低波动版本,科技和AI主题仓位控制在20%以内。"), + ("均衡参与档", "Portfolio", "现场主推版本,在稳健底仓上加入可解释的科技成长敞口。"), + ("进取主题档", "Portfolio", "高波动版本,仅作为对比展示,不作为默认推荐。"), + ("现金管理产品", "FinancialProduct", "保留家庭流动性和可调仓弹性。"), + ("短债理财", "FinancialProduct", "作为现金替代与短期资金停泊工具。"), + ("同业存单策略", "FinancialProduct", "提供稳健票息与低久期管理。"), + ("固收增强产品", "FinancialProduct", "作为组合底仓,承担稳健收益和波动缓冲功能。"), + ("高等级信用债", "FinancialProduct", "作为中低波动收益来源。"), + ("科技主题基金", "FinancialProduct", "用于表达AI基础设施、半导体设备和云软件成长机会。"), + ("半导体设备基金", "FinancialProduct", "覆盖国产替代、设备材料和先进封装机会。"), + ("云计算软件基金", "FinancialProduct", "覆盖云厂商盈利弹性与企业软件复苏。"), + ("国产算力主题产品", "FinancialProduct", "覆盖国产GPU、服务器、网络设备和数据中心产业链。"), + ("AI主题理财产品", "FinancialProduct", "作为观察仓参与AI产业链主题,但控制比例和波动。"), + ("结构化票据观察仓", "FinancialProduct", "用于降低直接权益波动,但需清楚解释敲入敲出情景。"), + ("指数增强产品", "FinancialProduct", "提供宽基参与和超额收益尝试。"), + ("量化对冲产品", "FinancialProduct", "用于降低组合相关性和回撤。"), + ("数据中心REITs", "FinancialProduct", "表达数字基础设施资产收益。"), + ("黄金多资产产品", "FinancialProduct", "作为组合分散和避险资产。"), + ("保险金信托", "FinancialProduct", "承接家庭保障与传承安排。"), + ("家族信托", "FinancialProduct", "用于长期传承、资产隔离和家庭治理。"), + ("AI资本开支", "MarketVariable", "影响算力、服务器、数据中心和云基础设施景气度。"), + ("国产芯片替代", "MarketVariable", "影响半导体设备、国产算力和软件生态预期。"), + ("云厂商盈利弹性", "MarketVariable", "影响云计算软件、AI应用和平台公司估值。"), + ("数据中心电力约束", "MarketVariable", "影响数据中心建设节奏、REITs和基础设施成本。"), + ("利率下行", "MarketVariable", "影响固收类产品吸引力与久期策略。"), + ("人民币汇率波动", "MarketVariable", "影响海外资产顾虑、黄金配置和企业资金安排。"), + ("政策支持窗口", "MarketVariable", "影响科技主题风险偏好和客户接受度。"), + ("市场估值分位", "MarketVariable", "决定科技权益仓位上限和分批建仓节奏。"), + ("回撤预警线", "ComplianceProcess", "当组合回撤触及阈值时触发复盘与风险提示。"), + ("组合波动率", "ComplianceProcess", "用于评估客户是否能接受科技和AI主题敞口。"), + ("单产品集中度", "ComplianceProcess", "防止单一产品或单一主题过度集中。"), + ("久期风险", "ComplianceProcess", "约束固收底仓的利率敏感度。"), + ("信用利差", "MarketVariable", "影响高等级信用债和固收增强收益风险比。"), + ("流动性压力测试", "ComplianceProcess", "验证客户在突发用钱场景下的可赎回能力。"), + ("客户画像访谈", "SalesStage", "从家庭、企业、现金流和风险偏好理解客户真实需求。"), + ("家庭资产诊断", "SalesStage", "将家庭资产结构、企业现金流和未来支出可视化。"), + ("适当性风险测评", "ComplianceProcess", "先做适当性匹配,再进入产品推介。"), + ("产品说明书", "ComplianceProcess", "正式产品介绍与风险揭示依据。"), + ("风险揭示书", "ComplianceProcess", "明确不承诺收益、波动和极端情景。"), + ("组合建议书", "SalesStage", "把多产品比例、逻辑和后续服务节奏写成可沟通材料。"), + ("首次拜访", "SalesStage", "客户经理建立问题框架与机会认知。"), + ("二次家庭会议", "SalesStage", "让共同决策人理解组合和风险边界。"), + ("产品准入确认", "ComplianceProcess", "确认产品在销售白名单和客户风险等级范围内。"), + ("客户确认录音录像", "ComplianceProcess", "保存客户知情、确认和风险揭示证据。"), + ("季度复盘", "SalesStage", "按市场变量与组合表现进行复盘。"), + ("再平衡触发", "SalesStage", "当估值、回撤或流动性变化时调整仓位。"), + ("追加配置窗口", "SalesStage", "当客户认可服务且市场回撤提供机会时增加配置。"), + ("客诉防火墙", "ComplianceProcess", "通过留痕、解释和预期管理降低后续争议。"), + ("销售转化", "BusinessOutcome", "风险匹配后首轮成交概率约60%-70%,叠加家庭资产诊断后二次转化约75%。"), + ("首轮成交概率", "BusinessOutcome", "首次拜访到产品配置的成交可能性。"), + ("二次转化概率", "BusinessOutcome", "家庭会议、资产诊断和季度复盘后的追加配置概率。"), + ("AUM新增规模", "BusinessOutcome", "新增管理资产规模,是分行经营层关注的结果指标。"), + ("客户粘性提升", "BusinessOutcome", "通过持续复盘和家庭服务提升长期关系。"), + ("交叉销售机会", "BusinessOutcome", "从财富管理延伸到企业金融、保险金信托和家族服务。"), + ("合规留痕完整度", "BusinessOutcome", "衡量销售动作是否可审计、可解释、可复盘。"), + ("低波动体验", "BusinessOutcome", "客户对组合过程的主观体验,影响续配和转介绍。"), + ("转介绍机会", "BusinessOutcome", "高净值客户认可后带来的同圈层推荐机会。"), + ("服务满意度", "BusinessOutcome", "由收益解释、风险提醒、复盘频率和响应速度共同决定。"), + ] + + nodes = [ + { + "uuid": _stable_uuid(name), + "name": name, + "labels": [label], + "summary": summary, + "attributes": {"name": name, "type": label}, + "created_at": NB_CREATED_AT, + } + for name, label, summary in entities + ] + + rels: List[tuple[str, str, str]] = [] + entity_names = {name for name, _, _ in entities} + seen_rels = set() + + def add(source: str, rel: str, target: str) -> None: + if source not in entity_names or target not in entity_names: + return + key = (source, rel, target) + if key in seen_rels: + return + seen_rels.add(key) + rels.append(key) + + for source, rel, target in [ + ("宁波银行", "ORGANIZATION_HAS_UNIT", "总行财富管理部"), + ("宁波银行", "ORGANIZATION_HAS_UNIT", "宁波银行私行中心"), + ("宁波银行", "ORGANIZATION_HAS_UNIT", "分行经营管理层"), + ("宁波银行", "EMPLOYS", "宁波银行大客户经理"), + ("宁波银行私行中心", "HOSTS", "私行投资顾问"), + ("宁波银行私行中心", "HOSTS", "投顾中台"), + ("总行财富管理部", "GOVERNS", "产品准入委员会"), + ("分行经营管理层", "SUPERVISES", "宁波银行大客户经理"), + ("投顾中台", "SUPPORTS", "私行投资顾问"), + ("运营支持团队", "SUPPORTS", "客户确认录音录像"), + ("宁波银行大客户经理", "SERVES", "高净值客户A"), + ("宁波银行大客户经理", "COLLABORATES_WITH", "私行投资顾问"), + ("宁波银行大客户经理", "CONSULTS", "风控合规经理"), + ("宁波银行大客户经理", "USES", "组合建议书"), + ("私行投资顾问", "DESIGNS", "科技AI产品组合"), + ("科技主题基金经理", "EXPLAINS", "科技主题基金"), + ("固收产品经理", "EXPLAINS", "固收增强产品"), + ("AI行业研究员", "MONITORS", "AI资本开支"), + ("AI行业研究员", "MONITORS", "国产芯片替代"), + ("AI行业研究员", "MONITORS", "云厂商盈利弹性"), + ("AI行业研究员", "MONITORS", "数据中心电力约束"), + ("风控合规经理", "GOVERNS", "适当性风险测评"), + ("风控合规经理", "GOVERNS", "风险揭示书"), + ("产品准入委员会", "APPROVES", "产品准入确认"), + ("高净值客户A", "WORKS_WITH", "企业财务负责人"), + ("高净值客户A", "HAS_DECISION_MAKER", "配偶共同决策人"), + ("高净值客户A", "HAS_DECISION_MAKER", "二代继承人"), + ("高净值客户A", "DELEGATES_TO", "客户家族办公室代表"), + ]: + add(source, rel, target) + + for need in ["企业现金流", "家庭资产安全垫", "流动性需求", "风险偏好画像", "税务与传承诉求", "教育金计划", "海外资产顾虑"]: + add("高净值客户A", "HAS_NEED", need) + add("客户画像访谈", "DISCOVERS", need) + add(need, "INFLUENCES", "科技AI产品组合") + + for decision_maker in ["配偶共同决策人", "二代继承人", "客户家族办公室代表", "企业财务负责人"]: + add(decision_maker, "PARTICIPATES_IN", "二次家庭会议") + add(decision_maker, "INFLUENCES", "风险偏好画像") + add(decision_maker, "REVIEWS", "组合建议书") + + for portfolio in ["稳健观察档", "均衡参与档", "进取主题档"]: + add("科技AI产品组合", "HAS_SCENARIO", portfolio) + add("组合建议书", "PRESENTS", portfolio) + add(portfolio, "PREDICTS", "销售转化") + + portfolio_products = [ + "现金管理产品", "固收增强产品", "科技主题基金", "AI主题理财产品", "黄金多资产产品", + "短债理财", "同业存单策略", "高等级信用债", "半导体设备基金", "云计算软件基金", + "国产算力主题产品", "结构化票据观察仓", "指数增强产品", "量化对冲产品", "数据中心REITs", + ] + for product in portfolio_products: + add("科技AI产品组合", "CONTAINS", product) + add("均衡参与档", "ALLOCATES_TO", product) + add(product, "CONTRIBUTES_TO", "销售转化") + + for product in ["现金管理产品", "短债理财", "同业存单策略"]: + add("流动性需求", "REQUIRES", product) + add(product, "SUPPORTS", "低波动体验") + add(product, "MITIGATES", "流动性压力测试") + + for product in ["固收增强产品", "高等级信用债", "同业存单策略"]: + add(product, "DEPENDS_ON", "利率下行") + add(product, "DEPENDS_ON", "信用利差") + add(product, "CONSTRAINED_BY", "久期风险") + add(product, "SUPPORTS", "家庭资产安全垫") + + for product in ["科技主题基金", "半导体设备基金", "云计算软件基金", "国产算力主题产品", "AI主题理财产品"]: + add(product, "DEPENDS_ON", "市场估值分位") + add(product, "CONSTRAINED_BY", "回撤预警线") + add(product, "CONSTRAINED_BY", "单产品集中度") + add(product, "INFLUENCES", "首轮成交概率") + + for product in ["黄金多资产产品", "量化对冲产品"]: + add("科技AI产品组合", "HEDGES_WITH", product) + add(product, "MITIGATES", "组合波动率") + add(product, "SUPPORTS", "低波动体验") + + for product in ["保险金信托", "家族信托"]: + add("税务与传承诉求", "REQUIRES", product) + add(product, "SUPPORTS", "交叉销售机会") + add(product, "SUPPORTS", "客户粘性提升") + + market_map = { + "AI资本开支": ["国产算力主题产品", "数据中心REITs", "科技主题基金"], + "国产芯片替代": ["半导体设备基金", "国产算力主题产品", "AI主题理财产品"], + "云厂商盈利弹性": ["云计算软件基金", "科技主题基金", "指数增强产品"], + "数据中心电力约束": ["数据中心REITs", "国产算力主题产品", "AI主题理财产品"], + "利率下行": ["固收增强产品", "同业存单策略", "高等级信用债"], + "人民币汇率波动": ["黄金多资产产品", "海外资产顾虑", "企业现金流"], + "政策支持窗口": ["科技主题基金", "半导体设备基金", "首轮成交概率"], + "市场估值分位": ["再平衡触发", "追加配置窗口", "进取主题档"], + "信用利差": ["固收增强产品", "高等级信用债", "低波动体验"], + } + for variable, targets in market_map.items(): + for target in targets: + add(variable, "INFLUENCES", target) + add("AI行业研究员", "EVALUATES", variable) + + risk_controls = [ + "适当性风险测评", "产品说明书", "风险揭示书", "产品准入确认", "客户确认录音录像", + "回撤预警线", "组合波动率", "单产品集中度", "久期风险", "流动性压力测试", "客诉防火墙", + ] + for control in risk_controls: + add("风控合规经理", "VALIDATES", control) + add(control, "CONSTRAINS", "科技AI产品组合") + add(control, "SUPPORTS", "合规留痕完整度") + + for source, rel, target in [ + ("首次拜访", "LEADS_TO", "客户画像访谈"), + ("客户画像访谈", "LEADS_TO", "家庭资产诊断"), + ("家庭资产诊断", "LEADS_TO", "适当性风险测评"), + ("适当性风险测评", "LEADS_TO", "组合建议书"), + ("组合建议书", "LEADS_TO", "二次家庭会议"), + ("二次家庭会议", "LEADS_TO", "客户确认录音录像"), + ("客户确认录音录像", "LEADS_TO", "销售转化"), + ("销售转化", "LEADS_TO", "季度复盘"), + ("季度复盘", "TRIGGERS", "再平衡触发"), + ("再平衡触发", "TRIGGERS", "追加配置窗口"), + ("家庭资产诊断", "INCREASES", "二次转化概率"), + ("二次家庭会议", "INCREASES", "服务满意度"), + ("季度复盘", "INCREASES", "客户粘性提升"), + ("追加配置窗口", "INCREASES", "AUM新增规模"), + ("服务满意度", "INCREASES", "转介绍机会"), + ("销售转化", "CONVERTS_TO", "AUM新增规模"), + ("销售转化", "CONVERTS_TO", "客户粘性提升"), + ("销售转化", "CONVERTS_TO", "交叉销售机会"), + ("首轮成交概率", "PART_OF", "销售转化"), + ("二次转化概率", "PART_OF", "销售转化"), + ("合规留痕完整度", "MITIGATES", "客诉防火墙"), + ]: + add(source, rel, target) + + for stage in ["首次拜访", "客户画像访谈", "家庭资产诊断", "组合建议书", "二次家庭会议", "季度复盘", "追加配置窗口"]: + add("宁波银行大客户经理", "DRIVES", stage) + add(stage, "SUPPORTS", "服务满意度") + + for outcome in ["首轮成交概率", "二次转化概率", "AUM新增规模", "客户粘性提升", "交叉销售机会", "低波动体验", "转介绍机会", "服务满意度"]: + add("科技AI产品组合", "PREDICTS", outcome) + add("均衡参与档", "OPTIMIZES", outcome) + + for source, rel, target in [ + ("家庭资产安全垫", "PRIORITIZES", "稳健观察档"), + ("风险偏好画像", "MATCHES", "均衡参与档"), + ("二代继承人", "PREFERS", "进取主题档"), + ("配偶共同决策人", "PREFERS", "稳健观察档"), + ("客户家族办公室代表", "PREFERS", "均衡参与档"), + ("企业财务负责人", "REQUIRES", "现金管理产品"), + ("海外资产顾虑", "HEDGES_WITH", "黄金多资产产品"), + ("教育金计划", "REQUIRES", "固收增强产品"), + ("企业现金流", "REQUIRES", "现金管理产品"), + ("组合波动率", "INFLUENCES", "服务满意度"), + ("回撤预警线", "TRIGGERS", "季度复盘"), + ("单产品集中度", "LIMITS", "进取主题档"), + ("流动性压力测试", "VALIDATES", "现金管理产品"), + ("产品说明书", "SUPPORTS", "产品准入确认"), + ("风险揭示书", "SUPPORTS", "客户确认录音录像"), + ("客诉防火墙", "PROTECTS", "宁波银行"), + ]: + add(source, rel, target) + + edges = [] + for source, rel, target in rels: + edges.append({ + "uuid": _stable_uuid(f"{source}-{rel}-{target}"), + "name": rel, + "fact": f"{source} 与 {target} 在宁波银行高净值客户AI理财组合推介案例中存在 {rel} 关系。", + "fact_type": rel, + "source_node_uuid": _stable_uuid(source), + "target_node_uuid": _stable_uuid(target), + "source_node_name": source, + "target_node_name": target, + "source_name": source, + "target_name": target, + "attributes": {"edge_type": rel}, + "created_at": NB_CREATED_AT, + }) + + return { + "graph_id": NB_GRAPH_ID, + "nodes": nodes, + "edges": edges, + "node_count": len(nodes), + "edge_count": len(edges), + "entity_types": sorted({label for _, label, _ in entities}), + "fallback_source": "cached_demo_graph", + } + + +def get_cached_simulation(simulation_id: str) -> Optional[Dict[str, Any]]: + if simulation_id != NB_HNW_AI_CASE_ID: + return None + + return { + "simulation_id": NB_HNW_AI_CASE_ID, + "project_id": NB_PROJECT_ID, + "graph_id": NB_GRAPH_ID, + "status": "completed", + "enable_twitter": True, + "enable_reddit": True, + "entities_count": len(CASE_AGENTS), + "profiles_count": len(CASE_AGENTS), + "entity_types": [agent[2] for agent in CASE_AGENTS], + "config_generated": True, + "created_at": NB_CREATED_AT, + "updated_at": _case_time(70), + } + + +def get_cached_profiles(simulation_id: str, platform: str = "reddit") -> Optional[List[Dict[str, Any]]]: + if simulation_id != NB_HNW_AI_CASE_ID: + return None + + profiles = [] + for agent_id, name, role, bio in CASE_AGENTS: + profiles.append({ + "id": agent_id, + "name": name, + "username": f"agent_{agent_id}", + "entity_type": role, + "profession": role, + "bio": bio, + "persona": f"{name}在本案例中围绕高净值客户AI理财组合推介参与讨论,关注资产配置、风险边界和销售转化。", + "interested_topics": ["科技理财", "AI产业链", "资产配置", "合规销售"], + "age": 42 if agent_id in (0, 2, 5, 6, 7) else 55, + "gender": "other", + "country": "CN", + "mbti": "ENTJ" if agent_id in (0, 2) else "ISTJ", + }) + return profiles + + +def get_cached_config(simulation_id: str) -> Optional[Dict[str, Any]]: + if simulation_id != NB_HNW_AI_CASE_ID: + return None + + agent_configs = [] + for agent_id, name, role, _ in CASE_AGENTS: + active_hours = list(range(9, 19)) if agent_id in (0, 2, 4, 5, 6, 11, 12, 13, 14, 26, 29) else list(range(10, 22)) + agent_configs.append({ + "agent_id": agent_id, + "name": name, + "entity_name": name, + "entity_type": role, + "role": role, + "active_time_period": "09:00-18:00" if active_hours[0] == 9 else "10:00-21:00", + "active_hours": active_hours, + "posts_per_hour": 0.7 if agent_id in (0, 2, 6, 11, 29) else 0.35, + "comments_per_hour": 1.4 if agent_id in (1, 5, 7, 8, 9, 10, 26) else 0.9, + "response_delay_minutes": 8 + (agent_id % 12), + "response_delay_min": 5 + (agent_id % 5), + "response_delay_max": 18 + (agent_id % 9), + "activity_level": 0.85 if agent_id in (0, 2, 6, 11, 29) else 0.65, + "sentiment_bias": 0.25 if agent_id in (0, 2, 3, 4, 6, 11, 24, 29) else (-0.15 if agent_id in (5, 23, 26) else 0.0), + "stance": "support" if agent_id in (0, 2, 3, 4, 6, 11, 24, 29) else ("risk" if agent_id in (5, 23, 26) else "neutral"), + "influence_weight": 0.95 if agent_id in (0, 1, 2, 5, 7, 11, 29) else 0.65, + }) + + return { + "time_config": { + "total_simulation_hours": 8, + "minutes_per_round": 60, + "agents_per_hour_min": 8, + "agents_per_hour_max": 14, + "peak_hours": [10, 14, 20], + "peak_activity_multiplier": 1.4, + "work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18], + "work_activity_multiplier": 1.1, + "morning_hours": [8, 9, 10], + "morning_activity_multiplier": 1.0, + "off_peak_hours": [0, 1, 2, 3, 4, 5, 6], + "off_peak_activity_multiplier": 0.3, + }, + "agent_configs": agent_configs, + "twitter_config": None, + "reddit_config": { + "recency_weight": 0.35, + "popularity_weight": 0.25, + "relevance_weight": 0.40, + "viral_threshold": 0.72, + "echo_chamber_strength": 0.18, + }, + "event_config": { + "narrative_direction": "从客户画像和风险测评出发,逐步推演科技/AI主题产品组合的组成、合规边界与销售转化效果。", + "hot_topics": ["高净值客户", "AI产业链", "科技主题基金", "固收增强", "适当性管理", "销售转化"], + "initial_posts": [ + {"poster_agent_id": 0, "poster_type": "KeyAccountManager", "content": "客户经理发起高净值客户AI主题组合推介,先确认家庭资产目标与风险边界。"}, + {"poster_agent_id": 2, "poster_type": "PrivateBankingAdvisor", "content": "投顾拆解现金管理、固收增强、科技权益、AI观察仓和黄金多资产的组合比例。"}, + {"poster_agent_id": 5, "poster_type": "RiskCompliance", "content": "合规经理确认不承诺收益,先做适当性匹配,再进行产品说明。"}, + ], + "events": [ + {"name": "AI主题热度上升", "impact": "提高客户兴趣,同时放大波动担忧。"}, + {"name": "客户要求保留流动性", "impact": "提高现金管理和固收增强比例。"}, + ], + }, + "generation_reasoning": "时间配置: 8小时现场回溯足以展示客户画像、组合设计、风险确认和销售预测。 | Agent配置: 30个角色覆盖客户家庭、银行前中后台、产品专家、市场变量、同圈层客户和结果看板。 | 初始激活: 从业务目标、双世界舆情、产品组合和合规约束四条线启动讨论。", + "generated_at": _case_time(20), + "llm_model": "cached-demo", + } + + +def get_cached_config_realtime(simulation_id: str) -> Optional[Dict[str, Any]]: + config = get_cached_config(simulation_id) + if not config: + return None + return { + "simulation_id": simulation_id, + "file_exists": True, + "file_modified_at": _case_time(20), + "is_generating": False, + "generation_stage": "completed", + "config_generated": True, + "config": config, + "summary": { + "total_agents": len(config.get("agent_configs", [])), + "simulation_hours": config.get("time_config", {}).get("total_simulation_hours"), + "initial_posts_count": len(config.get("event_config", {}).get("initial_posts", [])), + "hot_topics_count": len(config.get("event_config", {}).get("hot_topics", [])), + "has_twitter_config": False, + "has_reddit_config": True, + "generated_at": config.get("generated_at"), + "llm_model": config.get("llm_model"), + }, + } + + +REPORT_SECTIONS = [ + ( + "执行结论", + "### 这次模拟回答了什么\n\n宁波银行大客户经理面向高净值客户推介科技、AI相关理财产品时,客户真正关心的不是“AI概念是否热门”,而是四件事:家庭资产安全垫是否被保护、科技仓位是否可解释、回撤风险是否有人提前提醒、后续复盘服务是否持续。\n\n### 推荐主路径\n\n- **产品组合**:现金管理20% + 固收增强40% + 科技主题权益22% + AI观察仓8% + 黄金/多资产10%。\n- **销售打法**:先做家庭资产诊断,再讲AI产业链变量,最后给出三档组合,不直接推最高风险档。\n- **预测效果**:完成适当性匹配后,首轮成交概率约60%-70%;加入家庭会议和季度复盘后,二次转化约75%。\n- **关键抓手**:把“理财产品推介”升级为“客户经理持续经营系统”,用可回溯模拟结果训练客户经理的话术、判断节点和合规边界。\n\n> 本报告是基于30个虚拟Agent、8轮双世界交互和48条行为记录生成的情景推演,不使用真实客户隐私或真实交易数据。", + ), + ( + "Agent问答暴露的问题", + "### 关键Agent反馈\n\n- **高净值客户A**暴露的问题:客户接受科技成长敞口,但不接受“单一押注AI”。客户希望先看现金流安排、回撤提醒和季度复盘,而不是先听产品卖点。\n- **配偶共同决策人**暴露的问题:家庭共同决策人对收益叙事不敏感,对“家庭安全垫是否被影响”高度敏感。客户经理如果只对企业主本人讲产品,成交阻力会转移到家庭会议。\n- **企业财务负责人**暴露的问题:企业经营资金不能被锁定,理财方案必须区分家庭资产、企业现金流和临时周转资金。\n- **保守型客户C**暴露的问题:AI主题容易被理解成追热点,必须提供估值分位、分批建仓和仓位上限,否则会触发“高位接盘”的负面联想。\n- **二代继承人**暴露的问题:年轻共同决策人愿意接受AI主题,但偏好更高弹性。系统需要帮助客户经理把进取需求约束在观察仓,而不是让家庭风险偏好被单一成员带偏。\n- **风控合规经理 / 合规质检员**暴露的问题:所有表达必须从“预测收益”改成“情景推演”,并保留风险测评、产品说明、客户确认和会议纪要。\n\n### 问题归纳\n\n1. 客户不是缺产品,而是缺一套能让家庭成员共同理解的资产配置叙事。\n2. 客户经理不是缺话术,而是缺可复盘的判断流程:先问什么、何时进入产品、何时必须停下来做合规确认。\n3. 管理层不是缺销售数据,而是缺能解释“为什么成交/为什么没成交”的过程证据。", + ), + ( + "商业机会", + "### 对银行的机会\n\n- **机会1:高净值客户资产诊断产品化**。把首次拜访从“介绍产品”改为“家庭资产诊断”,输出资产安全垫、现金流、风险偏好、科技主题参与度四张卡片,提升客户信任和复访率。\n- **机会2:AI主题产品组合工具化**。系统自动生成三档组合:稳健观察、均衡参与、进取主题,并给出仓位上限、回撤线、合规话术和下一次复盘触发条件。\n- **机会3:客户经理训练标准化**。把销冠经验拆成Agent工作流:客户画像、共同决策人识别、风险边界、产品组合、异议处理、会后跟进。\n- **机会4:私行团队管理看板**。管理层可以看到首轮成交、二次转化、AUM新增、客户满意度、合规留痕完整度,而不是只看最终成交结果。\n- **机会5:交叉销售线索沉淀**。家族信托、保险金信托、企业现金管理、数据中心REITs、黄金多资产产品都可以从同一次资产诊断中自然浮现。\n\n### 对我们系统的销售价值\n\n这类模拟可以在银行内部变成三种能力:客户经理训战、复杂产品推介预演、管理层复盘。它展示的不是“AI写报告”,而是“AI提前模拟大量客户、家庭成员、投顾和合规角色的真实反应”。", + ), + ( + "产品组合与销售路径", + "### 主推组合:均衡参与档\n\n- 现金管理:20%,用于家庭流动性、临时周转和后续调仓。\n- 固收增强:40%,作为稳定器,承接客户对低波动体验的要求。\n- 科技主题权益:22%,覆盖AI基础设施、半导体设备、云软件。\n- AI观察仓:8%,控制主题热度风险,用小比例参与产业链机会。\n- 黄金/多资产:10%,对冲汇率、风险偏好下行和单一赛道波动。\n\n### 现场拜访路径\n\n1. **先问家庭与企业资金边界**:确认哪些钱不能动,哪些钱可以长期配置。\n2. **再做适当性匹配**:客户风险等级不匹配时,不进入产品推荐。\n3. **展示三档组合**:稳健观察、均衡参与、进取主题,默认推荐均衡参与。\n4. **解释AI变量**:算力资本开支、国产芯片替代、云厂商盈利弹性、数据中心电力约束。\n5. **确认复盘机制**:T+1发送摘要,T+30回访,季度更新变量看板。\n\n### 预测销售效果\n\n- 首轮成交:60%-70%,前提是客户已完成风险匹配并接受组合逻辑。\n- 二次转化:约75%,前提是完成家庭会议和季度复盘。\n- AUM机会:如果客户可投资资产中15%-20%进入组合,团队复制价值明显。\n- 转介绍:客户认可复盘机制后,同圈层企业主B和推荐人路径会被激活。", + ), + ( + "合规边界与风险控制", + "### 必须守住的边界\n\n- 不说“AI产品确定会涨”,只说“在不同市场情景下的组合反应”。\n- 不把科技主题仓位做成主仓,科技权益与AI观察仓合计不超过35%。\n- 不用单一产品承载客户全部AI兴趣,单产品集中度不超过15%。\n- 不绕过适当性测评,不替代正式产品说明书和风险揭示。\n- 不输入真实客户隐私、账户、持仓、授信或交易数据。\n\n### 系统应该强制留痕\n\n- 风险测评结果\n- 产品白名单与风险等级匹配\n- 客户确认与会议纪要\n- 回撤提醒线\n- 季度复盘记录\n- 异议处理记录\n\n这些留痕不是后台负担,而是客户经理可复制、管理层可审计、合规部门可追溯的过程资产。", + ), + ( + "下一步系统化落地", + "### 从Demo到银行采购场景\n\n建议把本案例包装成三层产品能力:\n\n1. **训战层**:输入客户画像,生成客户经理、客户、家庭成员、投顾、合规等多角色模拟,让客户经理练习拜访。\n2. **推介层**:输出三档产品组合、风险边界、拜访提纲、异议处理和会后跟进动作。\n3. **管理层**:形成可回溯报告,展示成交概率、二次转化、AUM机会、合规留痕和客户满意度。\n\n### 为什么银行客户会觉得有价值\n\n- 它把复杂销售过程从“凭经验”变成“可模拟、可复盘、可复制”。\n- 它让销冠经验变成组织流程,而不是停留在个人能力里。\n- 它可以在不接触真实客户隐私的情况下,先用虚拟客户群测试话术、产品组合和风险边界。\n- 它能让银行管理层看到:AI不是替代客户经理,而是让客户经理在见客户前完成一次高质量预演。", + ), +] + + +AGENT_DIRECT_ANSWERS = [ + (1, "高净值客户A", "核心客户", "我愿意参与AI主题,但前提是不能把它包装成稳赚机会。客户经理要先告诉我哪些钱不能动、组合最大回撤大概在哪里、市场不好时谁来提醒我复盘。如果只是说AI很热,我会觉得是在卖产品;如果先把家庭资产安全垫、企业现金流和可投资资金分开,我会把这个方案带回家庭会议继续讨论。"), + (8, "配偶共同决策人", "家庭安全垫", "我最担心的是企业主本人被主题热度带动,忽略家庭生活、教育金和医疗备用金。客户经理如果能先画出家庭安全垫,再解释科技仓位只是小比例参与,我会更容易接受。家庭会议不是形式,它决定这笔钱是不是全家都能睡得着。"), + (10, "企业财务负责人", "资金分层", "企业经营资金和家庭可投资资产必须分开。企业周转资金不能被锁进长期主题产品,也不能因为客户本人看好AI就改变资金属性。我希望银行同时给企业现金管理、家庭资产配置和季度复盘方案,这样才像长期服务,不像一次销售。"), + (2, "私行投资顾问", "组合设计", "均衡参与档的价值在于把客户兴趣和风险边界同时放进去。现金20%、固收40%是底盘,科技权益22%和AI观察仓8%表达主题机会,黄金/多资产10%做分散。好的组合不是最激进的组合,而是客户能坚持并愿意复盘的组合。"), + (5, "风控合规经理", "适当性与话术", "所有确定性收益暗示都要禁止,不能把情景推演包装成收益预测。客户经理必须先完成风险测评和产品匹配,再进入组合建议。风险揭示、产品说明、客户确认、会议纪要、回撤提醒线和季度复盘记录都要留痕。"), + (29, "销售管理看板", "管理层指标", "管理层最应该看的不是单次成交,而是首轮成交概率、二次转化概率、AUM新增、客户满意度和合规留痕完整度。系统的价值是把销冠经验拆成流程节点,让普通客户经理也知道什么时候问家庭目标、什么时候停下来做合规确认。"), + (6, "AI行业研究员", "产业变量", "AI主题不能只讲概念,要拆成算力资本开支、国产芯片替代、云厂商盈利弹性、数据中心电力约束四类变量。客户听得懂变量,才会相信仓位控制不是随意拍脑袋。"), + (7, "客户家族办公室代表", "传承与治理", "客户家族真正关心的是财富长期治理:这笔钱是否影响传承计划、是否影响家族企业现金流、是否需要信托或保险金信托承接。产品组合只是入口,家庭治理才是高净值客户愿意长期合作的理由。"), + (9, "二代继承人", "成长诉求", "我对AI长期机会更感兴趣,但也希望看到清楚的学习和复盘机制。如果父母只听到风险,可能会太保守;如果我只听到机会,可能会太激进。系统应该让家庭成员在同一个事实面板上讨论。"), + (11, "分行财富主管", "团队复制", "如果这套流程只服务一个客户,它就是案例;如果能把客户画像、组合建议、合规话术和会后跟进标准化,它就是分行训练工具。客户经理需要的不是更多产品,而是一套可复制的拜访节奏。"), + (12, "总行产品准入经理", "产品白名单", "AI主题产品必须经过准入和风险等级匹配,不能因为客户资产规模大就放松边界。白名单、风险等级、销售范围、信息披露材料要和客户画像联动,否则产品越复杂,后续争议越难解释。"), + (13, "运营留痕专员", "过程证据", "现场演示里最容易被忽略的是留痕。客户问过什么、客户经理解释过什么、客户是否确认理解风险,这些都应该自动沉淀。留痕不是后台负担,而是后续复盘、质检和客诉处理的证据资产。"), + (14, "客户服务经理", "会后服务", "客户真正感受到服务价值,往往发生在会后。T+1摘要、T+30回访、季度复盘、市场波动提醒,这些动作决定客户会不会把银行视为长期顾问,而不是一次产品销售人员。"), + (15, "量化对冲产品经理", "低相关资产", "如果客户担心科技主题波动,可以引入低相关策略解释回撤控制。但量化对冲不能被说成保本工具,要说明相关性、极端行情和策略失效场景。它适合作为组合稳定器,不适合作为收益承诺。"), + (16, "黄金多资产策略师", "分散配置", "黄金和多资产不是为了追热点,而是为了在汇率波动、风险偏好下降、权益主题回撤时提供分散。客户如果理解这10%的作用,就更容易接受科技权益和AI观察仓存在波动。"), + (17, "半导体研究员", "国产替代", "半导体设备和先进封装是AI产业链的重要变量,但它的波动也更高。客户经理应该把它放在科技主题权益的一部分,而不是单独放大成核心卖点。"), + (18, "云计算研究员", "云软件机会", "云厂商盈利弹性和企业软件复苏更适合讲长期逻辑。客户不一定懂模型训练,但能理解企业数字化、降本增效和云服务需求,这部分可以帮助AI主题从概念回到现金流。"), + (19, "数据中心研究员", "基础设施约束", "数据中心、电力和REITs能让AI投资逻辑更具体。客户经理可以用基础设施解释为什么AI不是单一股票故事,而是算力、电力、网络和运营效率共同决定的产业链。"), + (20, "利率策略师", "固收底仓", "利率下行环境会提高固收类产品吸引力,但也带来久期风险。固收增强是组合底仓,不能为了追求票息忽视信用利差和流动性。"), + (21, "汇率策略师", "海外资产顾虑", "部分高净值客户会问海外资产和人民币汇率。客户经理不必直接引导跨境配置,而是可以用黄金、多资产和境内科技主题替代方案解释风险分散。"), + (22, "同圈层企业主B", "转介绍信号", "我不会因为朋友买了就跟着买,但如果朋友说银行帮他把家庭、企业、传承和科技机会讲清楚,我会愿意听一次。转介绍来自服务可信度,不来自产品名字。"), + (23, "保守型客户C", "异议处理", "我听到AI理财会先想到追热点和高位接盘。客户经理如果能给仓位上限、分批建仓和退出条件,我会愿意了解;如果只讲未来空间,我会直接拒绝。"), + (24, "进取型客户D", "风险约束", "我愿意提高AI仓位,但也需要系统提醒我不要把家庭资产都放进一个主题。进取需求可以存在,但必须被观察仓和回撤线约束。"), + (25, "客户朋友推荐人", "口碑传播", "高净值客户愿意推荐的不是产品收益,而是银行是否把复杂问题讲得清楚、是否持续跟进、是否在市场波动时主动提醒。"), + (26, "合规质检员", "质检口径", "质检最关注三件事:是否完成适当性,是否有收益暗示,是否把复杂产品风险讲完整。系统如果能自动标记高风险话术,会明显降低培训和复盘成本。"), + (27, "家族信托顾问", "深度经营", "当客户开始讨论传承、资产隔离和家庭治理时,科技理财产品就不是终点,而是深度经营入口。客户经理要识别什么时候把话题转向家族信托和长期安排。"), + (28, "保险金信托顾问", "保障承接", "如果客户家庭安全垫不足,不应该急着提高科技仓位,而要先补保障和传承结构。保险金信托可以承接一部分家庭安全垫和传承诉求。"), + (0, "宁波银行大客户经理", "主导动作", "我的现场策略应该从问问题开始,而不是从讲产品开始。先确认客户家庭目标、企业资金边界、共同决策人和风险底线,再让投顾进入组合说明,这样客户才会觉得我们是在解决问题。"), + (3, "科技主题基金经理", "主题解释", "AI主题基金要讲清楚波动来源、估值位置和行业周期。基金经理的表达必须服务于客户组合,而不是把主题讲得越兴奋越好。"), + (4, "固收产品经理", "稳定器", "固收底仓的作用是让客户有耐心持有科技仓位。没有稳定器,AI主题再有吸引力也会在市场回撤时变成客诉压力。"), +] + + +def _agent_answer_appendix() -> str: + lines = [ + "### Agent直接回答证据库", + "", + "以下内容来自本次30个虚拟Agent的双世界问答与行为回放。每个编号都可以作为 Report Agent 回复时的引用证据。" + ] + for agent_id, name, theme, answer in AGENT_DIRECT_ANSWERS: + lines.extend([ + "", + f"#### A{agent_id:02d} {name}|{theme}", + f"> {answer}", + f"**可引用结论**:A{agent_id:02d} 的反馈说明,客户经理需要把产品销售动作改写成可解释、可复盘、可留痕的顾问流程。" + ]) + return "\n".join(lines) + + +def _section_expansions() -> Dict[int, str]: + return { + 1: """### 结论背后的证据索引 + +- 家庭资产安全垫来自 A01 高净值客户A、A08 配偶共同决策人、A10 企业财务负责人的共同反馈:他们不是反对科技主题,而是要求先确认哪些资金不能动、哪些资金可以长期配置、哪些资金需要随时可赎回。 +- 科技仓位可解释来自 A02 私行投资顾问、A06 AI行业研究员、A17 半导体研究员、A18 云计算研究员和 A19 数据中心研究员的交叉判断:AI产品必须从概念拆成产业链变量,再回到仓位上限和分批建仓。 +- 回撤预警和复盘机制来自 A05 风控合规经理、A13 运营留痕专员、A14 客户服务经理的过程要求:客户真正购买的是持续服务,而不是一次性推荐。 +- 管理层价值来自 A11 分行财富主管和 A29 销售管理看板:系统要把销冠经验变成团队可复制流程,把成交概率、AUM机会和合规留痕放在同一张管理面板中。 + +因此,本报告的执行结论不是“AI理财值得卖”,而是“宁波银行可以把科技与AI主题产品推介变成一套可模拟、可复盘、可复制、可审计的高净值客户经营系统”。""", + 2: _agent_answer_appendix(), + 3: """### 商业机会扩充:从单点成交到组织能力 + +第一类机会是客户侧的“资产诊断产品化”。高净值客户并不缺产品信息,他们缺的是一套能把家庭目标、企业现金流、风险偏好、传承诉求和主题机会放在同一张图上的解释框架。系统可以在首次拜访后自动生成四张卡片:家庭安全垫、企业资金边界、科技主题参与度、复盘与留痕计划。客户看到的是顾问服务,管理层看到的是标准化过程。 + +第二类机会是客户经理侧的“训战流程产品化”。过去销冠经验靠口传心授,很难复制。通过虚拟Agent,系统可以把一次复杂拜访拆成可训练节点:开场如何问家庭目标,何时识别共同决策人,何时停止产品讲解进入适当性确认,何时引入投顾,何时给三档组合,何时安排T+1摘要和T+30回访。每个节点都能被复盘、评分和优化。 + +第三类机会是管理层侧的“过程指标看板”。银行管理层通常能看到最终成交和AUM,但很难看到成交之前发生了什么。Foresight 可以把模拟过程中的异议、家庭成员态度、合规风险点、产品理解程度、复盘响应速度都转成过程指标。这样管理层不只是问“卖了多少”,而是能问“为什么这个客户愿意买,为什么那个客户还没买,下一步该由谁介入”。 + +第四类机会是合规侧的“主动风险防火墙”。复杂产品推介最大的风险不是客户不买,而是客户在不理解的情况下买。系统可以在话术层自动标记收益暗示、风险揭示不足、集中度过高、产品白名单不匹配等问题,把合规从事后抽检前移到销售预演阶段。""", + 4: """### 销售路径扩充:三次接触模型 + +第一次接触不以成交为目标,而以建立问题框架为目标。客户经理需要确认家庭资产安全垫、企业现金流、共同决策人、风险偏好和过往投资体验。系统根据这些信息生成客户画像,并提示哪些问题必须先问,哪些产品暂时不能进入。 + +第二次接触进入组合解释。默认呈现三档组合:稳健观察档、均衡参与档、进取主题档。现场主推均衡参与档,因为它既保留现金和固收底盘,又能让客户参与科技与AI主题。客户经理需要解释为什么科技权益和AI观察仓合计不超过35%,为什么单产品集中度不超过15%,为什么黄金/多资产不是装饰项,而是分散风险的工具。 + +第三次接触进入家庭会议和复盘承诺。配偶、二代、企业财务负责人往往会提出不同问题,系统应帮助客户经理准备不同版本的解释材料:给配偶看安全垫和回撤线,给二代看AI产业链变量,给企业财务负责人看资金分层,给客户本人看组合与服务节奏。成交后还要进入T+1摘要、T+30回访和季度复盘。""", + 5: """### 合规与证据扩充:哪些内容必须被系统捕捉 + +一是适当性证据。系统需要记录客户风险等级、产品风险等级、客户确认时间、客户是否理解产品说明书、是否存在共同决策人参与。没有这些证据,复杂产品推介就只是口头过程。 + +二是话术证据。系统要识别并阻断“确定会涨”“AI必然爆发”“这个产品很安全”等高风险表达。正确说法应该是“在某种情景下组合可能如何反应”“这个仓位用于表达主题机会但需要承受波动”“这不是收益承诺,需要结合正式产品说明和风险揭示”。 + +三是后续服务证据。客户最怕的不是买之前没人解释,而是买之后没人提醒。回撤预警、市场变量更新、季度复盘、客户异议处理、调仓建议都应被留存。对银行来说,这些记录既是服务证明,也是团队训练材料。 + +四是数据边界。现场演示和系统采购阶段都应使用虚拟客户、脱敏样例和公开信息,不输入真实客户隐私、账户、持仓、授信或交易数据。""", + 6: """### 落地路线扩充:从Demo到采购决策 + +第一阶段建议做“客户经理训战版”。选择3到5个典型客户画像:保守型企业主、科技兴趣型二代、家庭共同决策复杂客户、企业现金流敏感客户、传承诉求客户。每个画像都可以生成虚拟Agent群,让客户经理在见真实客户前完成预演。 + +第二阶段做“产品推介协同版”。把投顾中台、产品准入、合规质检、运营留痕都纳入流程。客户经理输入客户画像后,系统生成三档组合、拜访提纲、风险揭示重点、异议处理脚本和会后跟进任务。 + +第三阶段做“管理层复盘版”。分行财富主管可以看到每个客户经理的预演质量、客户异议分布、产品理解难点、合规风险点和下一步行动建议。系统价值从一个好看的AI Demo,变成银行内部可持续运营的客户经营基础设施。 + +现场采购沟通时,建议把价值表达压成一句话:Foresight 不是替银行替代客户经理,而是让客户经理在真实拜访前,先和30个虚拟客户、家属、投顾、合规、管理层角色完成一次高密度预演。银行买到的不是一份报告,而是复杂财富销售流程的预演、训练、复盘和审计能力。""" + } + + +def _expanded_report_sections() -> List[tuple[str, str]]: + expanded = [] + additions = _section_expansions() + for idx, (title, content) in enumerate(REPORT_SECTIONS, start=1): + extra = additions.get(idx, "") + expanded.append((title, f"{content}\n\n{extra}" if extra else content)) + return expanded + + +def _chat_citations() -> List[Dict[str, Any]]: + return [ + {"id": "S01", "label": "S01", "title": "执行结论", "section_index": 1}, + {"id": "S02", "label": "S02", "title": "Agent问答暴露的问题", "section_index": 2}, + {"id": "S03", "label": "S03", "title": "商业机会", "section_index": 3}, + {"id": "A01", "label": "A01", "title": "高净值客户A", "section_index": 2, "agent_id": 1}, + {"id": "A08", "label": "A08", "title": "配偶共同决策人", "section_index": 2, "agent_id": 8}, + {"id": "A10", "label": "A10", "title": "企业财务负责人", "section_index": 2, "agent_id": 10}, + {"id": "A05", "label": "A05", "title": "风控合规经理", "section_index": 2, "agent_id": 5}, + {"id": "A29", "label": "A29", "title": "销售管理看板", "section_index": 2, "agent_id": 29}, + ] + + +def _ensure_chat_citations(response: str) -> str: + if "[[" in response: + return response + return ( + f"{response.rstrip()}\n\n" + "证据来源:[[S01]] [[S02]] [[A01]] [[A08]] [[A10]] [[A05]] [[A29]]" + ) + + +def _report_outline() -> Dict[str, Any]: + return { + "title": "宁波银行高净值客户AI理财组合推介分析报告", + "summary": "围绕大客户经理服务高净值客户的现场推介场景,回溯客户画像、产品组合、销售转化和合规边界。", + "sections": [{"title": title, "content": ""} for title, _ in REPORT_SECTIONS], + } + + +def _report_markdown() -> str: + sections = "\n\n".join(f"## {title}\n\n{content}" for title, content in _expanded_report_sections()) + return f"# {_report_outline()['title']}\n\n> {_report_outline()['summary']}\n\n{sections}\n" + + +def _agent_interview_result() -> str: + return """**采访主题:** 宁波银行高净值客户科技与AI理财产品组合推介,会暴露哪些问题与商业机会? +**采访人数:** 6 / 30 位模拟Agent + +### 采访对象选择理由 +1. **高净值客户A(index=1)**:核心购买者,能暴露真实成交阻力。 +2. **配偶共同决策人(index=8)**:家庭共同决策人,影响高净值客户最终确认。 +3. **企业财务负责人(index=10)**:代表企业现金流约束,避免把经营资金误纳入长期配置。 +4. **私行投资顾问(index=2)**:负责把客户需求转成产品组合。 +5. **风控合规经理(index=5)**:识别销售口径和留痕风险。 +6. **销售管理看板(index=29)**:聚合转化率、AUM机会和团队复制价值。 + +--- + +### 采访实录 + +#### 采访 #1: 高净值客户A +**高净值客户A** (HighNetWorthClient) +_简介: 制造业企业主,关注长期稳健收益与科技主题成长性。_ + +**Q:** +1. 你愿意接受科技与AI主题产品组合吗? +2. 什么情况会让你暂缓成交? + +**A:** +【Twitter平台回答】 +我愿意参与AI主题,但不能变成单一押注。现金流、安全垫和回撤提示说清楚,我才会把这件事带回家庭会议。 + +【Reddit平台回答】 +如果客户经理只讲AI很热,我会担心高位接盘;如果能看到现金20%、固收40%和季度复盘,我愿意先用均衡档试运行。 + +**关键引言:** +> "我买的不是AI概念,我买的是有人能持续帮我解释风险和调整组合。" + +--- + +#### 采访 #2: 配偶共同决策人 +**配偶共同决策人** (FamilyDecisionMaker) +_简介: 关注家庭安全垫、教育金安排和回撤体验。_ + +**Q:** +1. 你最担心这套方案的什么? +2. 哪个动作会提高你的信任? + +**A:** +【Twitter平台回答】 +我最担心企业主本人被主题热度带动,忽略家庭资产安全垫。先说明哪些钱不能动、最大可能波动在哪里,我才会参与讨论。 + +【Reddit平台回答】 +如果客户经理能把教育金、医疗备用金和企业周转资金分开,我对科技仓位的接受度会明显提高。 + +**关键引言:** +> "家庭会议不是形式,它决定这笔钱是不是全家都能睡得着。" + +--- + +#### 采访 #3: 企业财务负责人 +**企业财务负责人** (CorporateCFO) +_简介: 关注企业现金流、闲置资金和资金使用窗口。_ + +**Q:** +1. 企业资金和家庭资产应该如何区分? +2. 对银行有什么服务期待? + +**A:** +【Twitter平台回答】 +企业经营资金不能被锁进长期主题产品。客户经理需要先做资金分层,再谈哪些资金可以进入科技权益。 + +【Reddit平台回答】 +如果银行能同时给出企业现金管理、家庭资产配置和季度复盘,我会认为这不是单次销售,而是长期服务方案。 + +**关键引言:** +> "最危险的不是产品波动,而是把企业周转钱误当成可投资闲钱。" + +--- + +#### 采访 #4: 私行投资顾问 +**私行投资顾问** (PrivateBankingAdvisor) +_简介: 负责组合建议、风险匹配和产品说明。_ + +**Q:** +1. 为什么推荐均衡参与档? +2. AI主题应该怎么讲才不冒进? + +**A:** +【Twitter平台回答】 +均衡档能把客户兴趣和风险边界同时放进去。科技权益22%加AI观察仓8%,让客户参与主题,但不让主题决定整个组合。 + +【Reddit平台回答】 +讲AI不能只讲应用爆发,要拆成算力、半导体、云软件和数据中心约束,再配合估值分位和分批建仓。 + +**关键引言:** +> "好的组合不是最激进的组合,而是客户能坚持并愿意复盘的组合。" + +--- + +#### 采访 #5: 风控合规经理 +**风控合规经理** (RiskCompliance) +_简介: 检查适当性、集中度、流动性和宣传口径。_ + +**Q:** +1. 哪些话术必须禁止? +2. 哪些材料必须留痕? + +**A:** +【Twitter平台回答】 +禁止确定性收益暗示,禁止把情景推演包装成收益预测。所有表达都必须回到适当性、风险揭示和产品说明书。 + +【Reddit平台回答】 +风险测评、产品白名单、客户确认、会议纪要、回撤提醒线、季度复盘记录都必须留存,否则后续客诉无法解释。 + +**关键引言:** +> "合规不是拖慢销售,而是让销售结果经得起复盘。" + +--- + +#### 采访 #6: 销售管理看板 +**销售管理看板** (SalesAnalytics) +_简介: 聚合首轮成交、二次转化、AUM和服务满意度指标。_ + +**Q:** +1. 这次模拟最值得管理层看的指标是什么? +2. 这个系统对团队复制有什么价值? + +**A:** +【Twitter平台回答】 +最关键不是单次成交,而是首轮成交60%-70%、家庭诊断后二次转化约75%、AUM新增15%-20%这三组指标能否被持续复盘。 + +【Reddit平台回答】 +系统能把销冠经验拆成流程节点,让普通客户经理也知道什么时候问家庭目标、什么时候停下来做合规确认、什么时候进入产品建议。 + +**关键引言:** +> "管理层需要看的不是一个漂亮答案,而是一条可复制的成交路径。" + +### 采访摘要与核心观点 +- 客户的主要阻力来自家庭安全垫、流动性、回撤体验和主题集中度,而不是对AI完全没有兴趣。 +- 商业机会在于把高净值客户推介升级成“资产诊断 + 产品组合 + 季度复盘 + 合规留痕”的系统化服务。 +- 对银行来说,系统价值不是替代客户经理,而是把客户经理见客户前的预演、推介、复盘和团队复制标准化。""" + + +def _insight_result() -> str: + return """分析问题: 宁波银行高净值客户AI理财组合推介的关键机会与风险是什么? +预测场景: 以大客户经理为核心,为高净值客户推介科技、AI相关理财产品组合,并预测产品组成与销售效果。 +相关预测事实: 12 +涉及实体: 18 +关系链: 16 + +### 分析的子问题 +1. 客户为什么会接受或拒绝AI主题产品? +2. 产品组合如何兼顾科技成长和家庭安全垫? +3. 客户经理如何把销售动作变成可复盘流程? +4. 合规边界如何嵌入推介过程而不是事后补救? + +### 【关键事实】 +1. 高净值客户A愿意参与AI主题,但明确要求不做单一押注。 +2. 配偶共同决策人要求先确认家庭资产安全垫和教育金安排。 +3. 企业财务负责人要求企业周转资金与家庭可投资资金分层。 +4. 风控合规经理要求所有AI表达改成情景推演,不承诺收益。 +5. 私行投资顾问把主推方案调整为现金20%、固收增强40%、科技权益22%、AI观察仓8%、黄金/多资产10%。 +6. 销售管理看板预测首轮成交60%-70%,家庭诊断后二次转化约75%。 + +### 【核心实体】 +- **宁波银行大客户经理** (KeyAccountManager) + 摘要: 负责客户沟通、方案组织和服务切入。 + 相关事实: 5 +- **高净值客户A** (HighNetWorthClient) + 摘要: 制造业企业主,关注稳健收益、流动性和科技主题成长敞口。 + 相关事实: 4 +- **私行投资顾问** (PrivateBankingAdvisor) + 摘要: 负责组合建议、风险匹配和产品说明。 + 相关事实: 4 +- **风控合规经理** (RiskCompliance) + 摘要: 检查适当性、集中度、流动性和宣传口径。 + 相关事实: 4 +- **销售管理看板** (SalesAnalytics) + 摘要: 聚合成交概率、二次转化、AUM和满意度指标。 + 相关事实: 3 + +### 【关系链】 +- 宁波银行大客户经理 --[SERVES]--> 高净值客户A +- 宁波银行大客户经理 --[COLLABORATES_WITH]--> 私行投资顾问 +- 私行投资顾问 --[DESIGNS]--> 科技AI产品组合 +- 风控合规经理 --[GOVERNS]--> 适当性风险测评 +- 科技AI产品组合 --[CONTAINS]--> 现金管理产品 +- 科技AI产品组合 --[CONTAINS]--> 固收增强产品 +- 科技AI产品组合 --[CONTAINS]--> 科技主题基金 +- 科技AI产品组合 --[CONTAINS]--> AI主题理财产品 +- 销售管理看板 --[PREDICTS]--> 首轮成交概率 +- 销售管理看板 --[PREDICTS]--> 二次转化概率""" + + +def get_cached_report(report_id: str) -> Optional[Dict[str, Any]]: + if report_id != NB_REPORT_ID: + return None + + return { + "report_id": NB_REPORT_ID, + "simulation_id": NB_HNW_AI_CASE_ID, + "graph_id": NB_GRAPH_ID, + "simulation_requirement": NB_REQUIREMENT, + "status": "completed", + "outline": _report_outline(), + "markdown_content": _report_markdown(), + "created_at": _case_time(62), + "completed_at": _case_time(70), + "error": None, + } + + +def get_cached_report_by_simulation(simulation_id: str) -> Optional[Dict[str, Any]]: + if simulation_id != NB_HNW_AI_CASE_ID: + return None + return get_cached_report(NB_REPORT_ID) + + +def _fallback_cached_chat_response(message: str) -> str: + normalized = message.strip() + if "最在意" in normalized or "关心" in normalized or "客户" in normalized: + return _ensure_chat_citations( + "宁波银行这类高净值客户最在意的不是“AI产品听起来有多热”,而是这几个更底层的问题:\n\n" + "1. **家庭资产安全垫是否被保护**:现金管理和固收底仓要先讲清楚,尤其是教育金、医疗备用金、企业周转资金不能被科技主题仓位挤占。[[S01]] [[A08]] [[A10]]\n" + "2. **科技/AI仓位是否可解释**:客户愿意参与AI机会,但不接受单一押注,所以模拟里最终收敛到科技权益22% + AI观察仓8%。[[S04]] [[A01]] [[A02]]\n" + "3. **回撤和流动性是否有人负责提醒**:客户真正买的是持续复盘服务,而不是一次性产品推荐。[[S02]] [[A05]] [[A14]]\n" + "4. **家庭共同决策人是否能听懂**:配偶、二代、企业财务负责人都会影响成交,客户经理必须把产品话术改成家庭资产诊断语言。[[A08]] [[A09]] [[A10]]\n" + "5. **合规边界是否清晰**:不能承诺收益,只能表达情景推演,并要留存风险测评、产品说明、客户确认和季度复盘记录。[[S05]] [[A05]] [[A26]]\n\n" + "因此,最有价值的销售切入点是:先做家庭资产诊断,再给出“现金20% + 固收增强40% + 科技权益22% + AI观察仓8% + 黄金/多资产10%”的均衡参与档。[[S01]] [[S04]]" + ) + + return _ensure_chat_citations( + "基于宁波银行高净值客户AI理财组合模拟,我的判断是:这不是一个单纯的产品推荐场景,而是一个“客户经理持续经营系统”场景。" + "系统需要同时回答客户画像、家庭共同决策、产品组合、销售转化、合规留痕和季度复盘六类问题。" + "建议现场先看执行结论,再展开 Agent问答暴露的问题和商业机会两章,那里最能体现模拟系统的价值。" + ) + + +def get_cached_report_chat( + simulation_id: str, + message: str, + chat_history: Optional[List[Dict[str, str]]] = None, +) -> Optional[Dict[str, Any]]: + if simulation_id != NB_HNW_AI_CASE_ID: + return None + + chat_history = chat_history or [] + report = get_cached_report(NB_REPORT_ID) or {} + replay = get_cached_replay(NB_HNW_AI_CASE_ID) or {} + aggregate = replay.get("aggregate", {}) + rounds = replay.get("rounds", []) + sampled_actions = [] + for round_data in rounds: + for action in round_data.get("actions", [])[:3]: + sampled_actions.append( + f"R{action.get('round_num')} {action.get('platform')} / {action.get('agent_name')}: " + f"{action.get('action_args', {}).get('content', '')}" + ) + + system_prompt = f"""你是 Foresight 先见之明的 Report Agent,正在和银行客户围绕一份已完成的模拟报告对话。 + +回答要求: +- 直接回答用户问题,不要说自己不能访问系统。 +- 使用中文,口吻专业、克制、适合宁波银行客户经理和管理层现场演示。 +- 优先引用报告结论、Agent问答和双世界模拟结果。 +- 涉及金融产品时必须说明这是情景推演,不承诺收益,不替代正式适当性评估。 +- 回答要有可执行建议,避免空泛。 +- 每个关键判断后必须带证据角标,格式只能使用 [[S01]]、[[S02]]、[[A01]]、[[A08]] 这类标记。 +- S01-S06 对应报告六个章节;A00-A29 对应虚拟Agent。优先引用 S01执行结论、S02 Agent问答、A01高净值客户A、A08配偶共同决策人、A10企业财务负责人、A05风控合规经理、A29销售管理看板。 +- 不要编造不存在的Agent编号;如果不确定,用 [[S01]] 或 [[S02]]。 + +模拟需求: +{NB_REQUIREMENT} + +报告正文: +{report.get('markdown_content', '')[:7000]} + +模拟统计: +- Agent数量:{len(CASE_AGENTS)} +- 总动作:{aggregate.get('total_actions')} +- 轮次:{aggregate.get('rounds_with_actions')} +- 平台动作:{aggregate.get('platform_totals')} + +部分Agent行为证据: +{chr(10).join(sampled_actions[:18])} +""" + + messages = [{"role": "system", "content": system_prompt}] + for item in chat_history[-8:]: + role = item.get("role") + content = item.get("content") + if role in ("user", "assistant") and content: + messages.append({"role": role, "content": content}) + messages.append({"role": "user", "content": message}) + + try: + from ..config import Config + from ..utils.llm_client import LLMClient + + model_name = ( + os.environ.get("REPORT_CHAT_MODEL_NAME") + or os.environ.get("REPORT_LLM_MODEL_NAME") + or Config.LLM_MODEL_NAME + ) + timeout_seconds = float(os.environ.get("REPORT_CHAT_LLM_TIMEOUT_SECONDS", "45")) + llm = LLMClient( + api_key=os.environ.get("REPORT_CHAT_LLM_API_KEY") or os.environ.get("REPORT_LLM_API_KEY") or None, + base_url=os.environ.get("REPORT_CHAT_LLM_BASE_URL") or os.environ.get("REPORT_LLM_BASE_URL") or None, + model=model_name, + timeout=timeout_seconds, + ) + response = _ensure_chat_citations(llm.chat(messages=messages, temperature=0.45, max_tokens=1200)) + return { + "response": response, + "citations": _chat_citations(), + "tool_calls": [ + { + "name": "cached_report_context", + "parameters": { + "report_id": NB_REPORT_ID, + "simulation_id": NB_HNW_AI_CASE_ID, + "model": llm.model, + }, + } + ], + "sources": ["cached_interactive_report", "cached_dual_world_replay"], + "model_used": llm.model, + "llm_used": True, + } + except Exception as exc: + model_name = ( + os.environ.get("REPORT_CHAT_MODEL_NAME") + or os.environ.get("REPORT_LLM_MODEL_NAME") + or os.environ.get("LLM_MODEL_NAME") + ) + return { + "response": _fallback_cached_chat_response(message), + "citations": _chat_citations(), + "tool_calls": [ + { + "name": "cached_report_context_fallback", + "parameters": { + "report_id": NB_REPORT_ID, + "simulation_id": NB_HNW_AI_CASE_ID, + "llm_error": f"{type(exc).__name__}: {str(exc)[:160]}", + }, + } + ], + "sources": ["cached_interactive_report", "cached_dual_world_replay"], + "model_used": model_name or "configured-default", + "llm_used": False, + } + + +def get_cached_report_logs(report_id: str, from_line: int = 0) -> Optional[Dict[str, Any]]: + if report_id != NB_REPORT_ID: + return None + + logs: List[Dict[str, Any]] = [ + { + "timestamp": _case_time(62), + "elapsed_seconds": 0, + "report_id": NB_REPORT_ID, + "action": "report_start", + "stage": "pending", + "details": { + "simulation_id": NB_HNW_AI_CASE_ID, + "graph_id": NB_GRAPH_ID, + "simulation_requirement": NB_REQUIREMENT, + "message": "开始回溯宁波银行高净值客户AI理财组合案例。", + }, + }, + { + "timestamp": _case_time(63), + "elapsed_seconds": 60, + "report_id": NB_REPORT_ID, + "action": "planning_start", + "stage": "planning", + "details": {"message": "读取图谱、Agent行为和销售转化变量,规划报告结构。"}, + }, + { + "timestamp": _case_time(64), + "elapsed_seconds": 120, + "report_id": NB_REPORT_ID, + "action": "planning_complete", + "stage": "planning", + "details": {"message": "报告结构规划完成。", "outline": _report_outline()}, + }, + { + "timestamp": _case_time(64), + "elapsed_seconds": 135, + "report_id": NB_REPORT_ID, + "action": "tool_call", + "stage": "generating", + "details": { + "iteration": 1, + "tool_name": "interview_agents", + "parameters": { + "topic": "客户问答暴露的问题与商业机会", + "agent_count": 6, + "simulation_id": NB_HNW_AI_CASE_ID, + }, + "message": "采访关键虚拟Agent,提取客户阻力、家庭决策、合规边界和团队复制线索。", + }, + }, + { + "timestamp": _case_time(64), + "elapsed_seconds": 150, + "report_id": NB_REPORT_ID, + "action": "tool_result", + "stage": "generating", + "details": { + "tool_name": "interview_agents", + "result": _agent_interview_result(), + "result_length": len(_agent_interview_result()), + "message": "Agent采访完成,可展开查看各角色问答。", + }, + }, + { + "timestamp": _case_time(64), + "elapsed_seconds": 165, + "report_id": NB_REPORT_ID, + "action": "tool_call", + "stage": "generating", + "details": { + "iteration": 1, + "tool_name": "insight_forge", + "parameters": { + "query": "宁波银行高净值客户AI理财组合推介的机会、风险和销售路径", + "simulation_id": NB_HNW_AI_CASE_ID, + }, + "message": "汇总图谱、Agent行为和销售变量,生成深度洞察。", + }, + }, + { + "timestamp": _case_time(64), + "elapsed_seconds": 180, + "report_id": NB_REPORT_ID, + "action": "tool_result", + "stage": "generating", + "details": { + "tool_name": "insight_forge", + "result": _insight_result(), + "result_length": len(_insight_result()), + "message": "深度洞察完成。", + }, + }, + ] + + elapsed = 210 + minute = 65 + for idx, (title, content) in enumerate(_expanded_report_sections(), start=1): + logs.extend([ + { + "timestamp": _case_time(minute), + "elapsed_seconds": elapsed, + "report_id": NB_REPORT_ID, + "action": "section_start", + "stage": "generating", + "section_title": title, + "section_index": idx, + "details": {"message": f"开始生成章节:{title}"}, + }, + { + "timestamp": _case_time(minute), + "elapsed_seconds": elapsed + 15, + "report_id": NB_REPORT_ID, + "action": "tool_call", + "stage": "generating", + "section_title": title, + "section_index": idx, + "details": { + "iteration": 1, + "tool_name": "simulation_replay_search", + "parameters": {"query": title, "simulation_id": NB_HNW_AI_CASE_ID}, + "message": "检索缓存推演动作与图谱变量。", + }, + }, + { + "timestamp": _case_time(minute + 1), + "elapsed_seconds": elapsed + 45, + "report_id": NB_REPORT_ID, + "action": "section_complete", + "stage": "generating", + "section_title": title, + "section_index": idx, + "details": {"message": f"章节完成:{title}", "content": content}, + }, + ]) + elapsed += 75 + minute += 1 + + logs.append({ + "timestamp": _case_time(70), + "elapsed_seconds": 480, + "report_id": NB_REPORT_ID, + "action": "report_complete", + "stage": "completed", + "details": {"message": "分析报告生成完成,可进入历史回溯查看。"}, + }) + + sliced = logs[from_line:] + return { + "logs": sliced, + "total_lines": len(logs), + "from_line": from_line, + "has_more": False, + } + + +def get_cached_console_log(report_id: str, from_line: int = 0) -> Optional[Dict[str, Any]]: + if report_id != NB_REPORT_ID: + return None + lines = [ + "[09:32:00] INFO: 加载宁波银行缓存推演案例", + "[09:33:00] INFO: 读取30个虚拟Agent、8轮双世界动作和销售转化变量", + "[09:34:00] INFO: Report Agent 完成互动报告结构规划", + "[09:34:15] INFO: Agent Interview 完成:客户、家庭成员、投顾、合规、管理看板", + "[09:34:30] INFO: Deep Insight 完成:提取机会、风险和销售路径", + "[09:35:00] INFO: 生成执行结论", + "[09:36:00] INFO: 生成Agent问答暴露的问题", + "[09:37:00] INFO: 生成商业机会", + "[09:38:00] INFO: 生成产品组合与销售路径", + "[09:39:00] INFO: 生成合规边界与风险控制", + "[09:40:00] INFO: 报告生成完成", + ] + return { + "logs": lines[from_line:], + "total_lines": len(lines), + "from_line": from_line, + "has_more": False, + } + + +def get_cached_infographic(report_id: str) -> Optional[Dict[str, Any]]: + if report_id != NB_REPORT_ID: + return None + + by_type: Dict[str, int] = {} + by_platform: Dict[str, Dict[str, int]] = {"twitter": {}, "reddit": {}} + agent_counts: Dict[int, Dict[str, Any]] = {} + timeline = [] + + for round_idx, round_actions in enumerate(ROUND_ACTIONS, start=1): + timeline.append({"round_num": round_idx, "total": len(round_actions)}) + for entry in round_actions: + platform, agent_id, action_type, _content = entry + by_type[action_type] = by_type.get(action_type, 0) + 1 + by_platform[platform][action_type] = by_platform[platform].get(action_type, 0) + 1 + agent = next((a for a in CASE_AGENTS if a[0] == agent_id), CASE_AGENTS[0]) + bucket = agent_counts.setdefault(agent_id, { + "agent_id": agent_id, + "agent_name": agent[1], + "total_actions": 0, + }) + bucket["total_actions"] += 1 + + total_actions = sum(by_type.values()) + top_agents = sorted(agent_counts.values(), key=lambda x: x["total_actions"], reverse=True) + + return { + "key_metrics": { + "total_agents": len(CASE_AGENTS), + "total_posts": by_type.get("CREATE_POST", 0), + "total_engagement": total_actions, + "avg_activity": f"{total_actions / len(CASE_AGENTS):.1f}", + "total_rounds": len(ROUND_ACTIONS), + }, + "action_distribution": { + "by_type": by_type, + "by_platform": by_platform, + }, + "sentiment_breakdown": { + "positive_ratio": 62, + "neutral_ratio": 26, + "negative_ratio": 12, + }, + "top_agents": top_agents[:8], + "timeline": timeline, + "portfolio": [ + {"name": "现金管理", "value": 20}, + {"name": "固收增强", "value": 40}, + {"name": "科技主题权益", "value": 22}, + {"name": "AI观察仓", "value": 8}, + {"name": "黄金/多资产", "value": 10}, + ], + "sales_effect": { + "first_conversion": "60%-70%", + "diagnosis_followup": "75%", + "compliance": "不承诺收益,先做适当性匹配", + }, + } + + +ROUND_ACTIONS = [ + [ + ("twitter", 0, "CREATE_POST", "Info Plaza开场:大客户经理提出为高净值客户设计科技与AI主题组合,要求同时预测产品组成、销售转化和合规风险。"), + ("twitter", 11, "CREATE_COMMENT", "分行财富主管关注可复制打法:如果能把客户画像、家庭会议和季度复盘串起来,这套方法可以复制给整个私行团队。"), + ("twitter", 29, "CREATE_POST", "销售管理看板初始化:监控首轮成交概率、二次转化、AUM新增、合规留痕和服务满意度五个指标。"), + ("reddit", 1, "CREATE_POST", "Topic Community中客户表达偏好:可接受中等波动,但不希望AI主题过度集中,必须保留家庭流动性。"), + ("reddit", 8, "CREATE_COMMENT", "配偶共同决策人补充:先保证家庭资产安全垫,再讨论科技成长,不要只讲概念热度。"), + ("reddit", 5, "CREATE_COMMENT", "合规经理提示:先完成风险承受能力匹配,再做产品范围筛选,不承诺收益。"), + ], + [ + ("twitter", 2, "CREATE_POST", "投顾给出初始框架:现金20%、固收35%、科技权益25%、AI观察仓10%、黄金/多资产10%。"), + ("twitter", 4, "CREATE_COMMENT", "固收产品经理建议用短债、同业存单和中高等级信用债做底仓,保持3到6个月可调仓窗口。"), + ("twitter", 15, "CREATE_COMMENT", "量化对冲产品经理建议加入低相关工具,降低科技主题仓位对整体波动的冲击。"), + ("reddit", 7, "CREATE_POST", "家族办公室要求组合必须能解释给家庭成员,并设置季度复盘触发条件。"), + ("reddit", 10, "CREATE_COMMENT", "企业财务负责人提醒:企业经营资金不能被锁死,流动性产品比例必须清楚。"), + ("reddit", 23, "CREATE_COMMENT", "保守型客户C质疑:AI主题听起来太热,是否会在高位接盘?"), + ], + [ + ("twitter", 6, "CREATE_POST", "AI行业研究员拆解变量:算力资本开支、国产芯片替代、数据中心电力约束、云厂商盈利弹性。"), + ("twitter", 17, "CREATE_COMMENT", "半导体研究员提示:国产替代机会明确,但估值分位和订单兑现节奏要纳入仓位控制。"), + ("twitter", 18, "CREATE_COMMENT", "云计算研究员补充:云厂商盈利弹性会影响AI软件和平台类资产的二阶表现。"), + ("reddit", 3, "CREATE_POST", "科技基金经理建议权益仓拆成AI基础设施、半导体设备、云软件三条线,避免只押单一热点。"), + ("reddit", 19, "CREATE_COMMENT", "数据中心研究员提醒:电力约束和资本开支节奏会影响数据中心REITs和算力链条。"), + ("reddit", 0, "LIKE_POST", "客户经理标记该变量拆解可用于客户拜访开场。"), + ], + [ + ("twitter", 5, "CREATE_POST", "风控合规经理提出硬约束:科技权益与AI观察仓合计不超过35%,单产品不超过15%,设置回撤提醒线。"), + ("twitter", 12, "CREATE_COMMENT", "总行产品准入经理确认:推荐范围必须落在白名单和客户风险等级匹配范围内。"), + ("twitter", 26, "CREATE_COMMENT", "合规质检员要求:所有AI主题表达都改成情景推演,不出现确定性收益暗示。"), + ("reddit", 2, "CREATE_POST", "投顾将组合改为均衡参与档:现金20%、固收增强40%、科技权益22%、AI观察仓8%、黄金/多资产10%。"), + ("reddit", 1, "CREATE_COMMENT", "客户认为调整后更符合家庭资产安全垫,希望看到销售转化和后续服务节奏。"), + ("reddit", 8, "CREATE_COMMENT", "共同决策人认可现金与固收比例,但要求每季度复盘并提前提示回撤风险。"), + ], + [ + ("twitter", 0, "CREATE_POST", "客户经理设计推介路径:先讲家庭目标和风险边界,再讲AI产业链机会,最后给出三档组合。"), + ("twitter", 14, "CREATE_COMMENT", "客户服务经理补充会后机制:T+1发送组合摘要,T+30回访体验,季度更新变量看板。"), + ("twitter", 13, "CREATE_COMMENT", "运营留痕专员同步:风险测评、产品说明、客户确认、会议纪要全部进入留痕包。"), + ("reddit", 2, "CREATE_POST", "三档组合为稳健观察、均衡参与、进取主题;默认推荐均衡参与,不主动推最高风险档。"), + ("reddit", 5, "CREATE_COMMENT", "合规经理确认口播:这是资产配置建议与情景推演,不是收益预测。"), + ("reddit", 9, "CREATE_COMMENT", "二代继承人更偏好进取主题档,但接受先用均衡参与档建立观察仓。"), + ], + [ + ("twitter", 3, "CREATE_POST", "基金经理预测产品组成:科技权益用主动基金+指数增强,AI观察仓用低杠杆结构或净值型主题产品。"), + ("twitter", 16, "CREATE_COMMENT", "黄金多资产策略师建议:黄金/多资产维持10%,用于对冲汇率和风险偏好下行。"), + ("twitter", 20, "CREATE_COMMENT", "利率策略师提示:若利率下行延续,固收增强底仓仍有解释优势,但久期不能过长。"), + ("reddit", 4, "CREATE_POST", "固收经理预测底仓销售接受度高,客户容易理解,预计可承担约40%配置比例。"), + ("reddit", 6, "CREATE_COMMENT", "研究员提醒短期波动来自估值和政策预期,建议用分批建仓降低择时压力。"), + ("reddit", 21, "CREATE_COMMENT", "汇率策略师认为海外资产顾虑会提高黄金和境内多资产产品的接受度。"), + ], + [ + ("twitter", 29, "CREATE_POST", "看板预测:完成适当性匹配后首轮成交概率60%-70%;加入家庭资产诊断后二次转化约75%。"), + ("twitter", 11, "CREATE_COMMENT", "分行财富主管判断:如果AUM新增达到客户可投资资产的15%-20%,团队复制价值明显。"), + ("twitter", 25, "CREATE_COMMENT", "推荐人观察:客户若认可复盘机制,有机会把同圈层企业主介绍给客户经理。"), + ("reddit", 0, "CREATE_POST", "客户经理总结成交关键:不是AI概念,而是现金流、回撤、产品集中度和后续复盘机制。"), + ("reddit", 7, "CREATE_COMMENT", "家族办公室同意:家庭成员能理解组合后,成交阻力明显下降。"), + ("reddit", 1, "CREATE_COMMENT", "客户表示愿意先配置均衡参与档,并保留下一季度追加科技权益的选择权。"), + ], + [ + ("twitter", 2, "CREATE_POST", "最终建议:以均衡参与档作为主推,配置比例为现金20%、固收增强40%、科技权益22%、AI观察仓8%、黄金/多资产10%。"), + ("twitter", 27, "CREATE_COMMENT", "家族信托顾问建议把传承诉求列入后续服务,不在首次成交中强推。"), + ("twitter", 28, "CREATE_COMMENT", "保险金信托顾问建议将保障和传承作为后续交叉销售线索。"), + ("reddit", 5, "CREATE_POST", "合规结论:必须留存风险测评、产品说明、客户确认和不保证收益提示。"), + ("reddit", 0, "CREATE_COMMENT", "客户经理输出拜访提纲:客户画像、资产目标、组合建议、AI变量、风险边界、复盘安排。"), + ("reddit", 29, "CREATE_COMMENT", "销售管理看板标记本轮完成:成交概率、二次转化、AUM新增和服务满意度均进入可复盘状态。"), + ], +] + + +def _action(agent_id: int, round_num: int, action_type: str, content: str, timestamp: str, platform: str = "reddit") -> Dict[str, Any]: + agent = next((a for a in CASE_AGENTS if a[0] == agent_id), CASE_AGENTS[0]) + return { + "round_num": round_num, + "timestamp": timestamp, + "platform": platform, + "agent_id": agent_id, + "agent_name": agent[1], + "action_type": action_type, + "action_args": { + "content": content, + "topic": "宁波银行大客户经理面向高净值客户推介科技、AI相关理财产品组合", + }, + "result": content, + "success": True, + } + + +def get_cached_replay(simulation_id: str) -> Optional[Dict[str, Any]]: + if simulation_id != NB_HNW_AI_CASE_ID: + return None + + start = datetime.fromisoformat(NB_CREATED_AT) + rounds: List[Dict[str, Any]] = [] + all_actions: List[Dict[str, Any]] = [] + + for round_idx, entries in enumerate(ROUND_ACTIONS, start=1): + actions = [] + for offset, entry in enumerate(entries): + if len(entry) == 4: + platform, agent_id, action_type, content = entry + else: + agent_id, action_type, content = entry + platform = "reddit" + ts = (start + timedelta(minutes=(round_idx - 1) * 8 + offset * 2)).isoformat() + item = _action(agent_id, round_idx, action_type, content, ts, platform) + actions.append(item) + all_actions.append(item) + + by_type: Dict[str, int] = {} + active_agents = set() + for item in actions: + by_type[item["action_type"]] = by_type.get(item["action_type"], 0) + 1 + active_agents.add(item["agent_id"]) + + rounds.append({ + "round_num": round_idx, + "simulated_hour": 9 + round_idx, + "simulated_day": 1, + "first_timestamp": actions[0]["timestamp"], + "last_timestamp": actions[-1]["timestamp"], + "actions": actions, + "stats": { + "total_actions": len(actions), + "active_agents_count": len(active_agents), + "by_type": by_type, + "by_platform": { + "twitter": len([a for a in actions if a["platform"] == "twitter"]), + "reddit": len([a for a in actions if a["platform"] == "reddit"]), + }, + }, + }) + + action_type_dist: Dict[str, int] = {} + agent_counts: Dict[int, Dict[str, Any]] = {} + for item in all_actions: + action_type_dist[item["action_type"]] = action_type_dist.get(item["action_type"], 0) + 1 + bucket = agent_counts.setdefault(item["agent_id"], { + "agent_id": item["agent_id"], + "agent_name": item["agent_name"], + "count": 0, + }) + bucket["count"] += 1 + + top_agents = sorted(agent_counts.values(), key=lambda x: x["count"], reverse=True) + platform_totals = { + "twitter": len([a for a in all_actions if a["platform"] == "twitter"]), + "reddit": len([a for a in all_actions if a["platform"] == "reddit"]), + } + + return { + "simulation": { + "simulation_id": NB_HNW_AI_CASE_ID, + "project_id": NB_PROJECT_ID, + "graph_id": NB_GRAPH_ID, + "status": "completed", + "enable_twitter": True, + "enable_reddit": True, + "entities_count": len(CASE_AGENTS), + "profiles_count": len(CASE_AGENTS), + "created_at": start.isoformat(), + "updated_at": (start + timedelta(minutes=70)).isoformat(), + }, + "project": { + "project_id": NB_PROJECT_ID, + "name": "宁波银行高净值客户AI理财组合推介", + "status": "graph_completed", + "graph_id": NB_GRAPH_ID, + "simulation_requirement": NB_REQUIREMENT, + "files": _cached_files(), + "total_text_length": 4096, + "analysis_summary": "现场缓存案例:从客户画像、风险边界、产品组合、销售路径和合规话术五个角度重放推演过程。", + "ontology_entity_types": [a[2] for a in CASE_AGENTS], + }, + "workflow": [ + {"step": 1, "name": "客户画像与目标确认", "status": "completed", "metadata": {"files": [{"filename": "cached_case_nb_hnw_ai.md", "size": 4096}], "requirement": "高净值客户科技/AI产品组合推介", "text_length": 4096, "analysis_summary": "已确认客户风险偏好、流动性要求和科技主题兴趣。"}}, + {"step": 2, "name": "关系图谱与变量提取", "status": "completed", "metadata": {"graph_id": NB_GRAPH_ID, "entity_types": [a[2] for a in CASE_AGENTS], "entities_count": len(CASE_AGENTS), "ontology_entity_types": [a[2] for a in CASE_AGENTS]}}, + {"step": 3, "name": "Agent 角色生成", "status": "completed", "metadata": {"profiles_count": len(CASE_AGENTS), "agents_loaded": len(CASE_AGENTS)}}, + {"step": 4, "name": "产品组合与销售路径配置", "status": "completed", "metadata": {"config_reasoning": "按稳健底仓、科技成长、AI观察仓和避险资产构建组合,并加入适当性与合规边界。", "total_simulation_hours": 8, "minutes_per_round": 60, "peak_hours": [10, 14, 20], "agents_per_hour_min": 8, "agents_per_hour_max": 14, "initial_posts_count": 3}}, + {"step": 5, "name": "缓存回放:双世界销售效果推演", "status": "completed", "metadata": {"total_rounds_executed": len(rounds), "total_actions": len(all_actions), "twitter_enabled": True, "reddit_enabled": True, "by_platform": platform_totals, "current_run_started_at": start.isoformat(), "simulation_created_at": start.isoformat(), "updated_at": (start + timedelta(minutes=70)).isoformat()}}, + ], + "config": { + "time_config": { + "total_simulation_hours": 8, + "minutes_per_round": 60, + "agents_per_hour_min": 8, + "agents_per_hour_max": 14, + "peak_hours": [10, 14, 20], + }, + "event_config": { + "initial_posts": [ + {"content": "客户经理发起高净值客户AI主题组合推介。"}, + {"content": "投顾拆解稳健底仓与科技成长仓。"}, + {"content": "合规经理确认适当性和风险提示。"}, + ], + "events": [ + {"name": "AI主题热度上升", "impact": "提高客户兴趣,但放大波动担忧。"}, + {"name": "客户要求保留流动性", "impact": "提高现金和固收增强比例。"}, + ], + }, + "agent_configs_count": len(CASE_AGENTS), + }, + "agents": [ + { + "id": agent_id, + "name": name, + "username": f"agent_{agent_id}", + "profession": role, + "bio": bio, + "interested_topics": ["科技理财", "AI产业链", "资产配置", "合规销售"], + } + for agent_id, name, role, bio in CASE_AGENTS + ], + "rounds": rounds, + "aggregate": { + "total_actions": len(all_actions), + "rounds_with_actions": len(rounds), + "action_type_distribution": action_type_dist, + "platform_totals": platform_totals, + "top_agents": top_agents[:10], + "cached_case": True, + "case_summary": { + "recommended_portfolio": "现金管理20% + 固收增强40% + 科技主题权益22% + AI观察仓8% + 黄金/多资产10%", + "sales_effect": "风险匹配通过后首轮成交概率约60%-70%;叠加家庭资产诊断后二次转化约75%。", + "compliance_note": "仅作资产配置情景推演,不承诺收益,不替代正式适当性评估。", + }, + }, + } + + +def get_cached_run_status(simulation_id: str) -> Optional[Dict[str, Any]]: + replay = get_cached_replay(simulation_id) + if not replay: + return None + + aggregate = replay.get("aggregate", {}) + platform_totals = aggregate.get("platform_totals", {}) + total_rounds = len(replay.get("rounds", [])) + return { + "simulation_id": simulation_id, + "runner_status": "completed", + "current_round": total_rounds, + "total_rounds": total_rounds, + "progress_percent": 100, + "simulated_hours": 8, + "total_simulation_hours": 8, + "twitter_running": False, + "reddit_running": False, + "twitter_completed": True, + "reddit_completed": True, + "twitter_current_round": total_rounds, + "reddit_current_round": total_rounds, + "twitter_simulated_hours": 8, + "reddit_simulated_hours": 8, + "twitter_actions_count": platform_totals.get("twitter", 0), + "reddit_actions_count": platform_totals.get("reddit", 0), + "total_actions_count": aggregate.get("total_actions", 0), + "started_at": NB_CREATED_AT, + "updated_at": _case_time(70), + "completed_at": _case_time(70), + "process_pid": None, + "cached_case": True, + } + + +def get_cached_run_detail(simulation_id: str, platform_filter: Optional[str] = None) -> Optional[Dict[str, Any]]: + replay = get_cached_replay(simulation_id) + if not replay: + return None + + all_actions = [] + for round_data in replay.get("rounds", []): + all_actions.extend(round_data.get("actions", [])) + + if platform_filter in ("twitter", "reddit"): + visible_actions = [a for a in all_actions if a.get("platform") == platform_filter] + else: + visible_actions = all_actions + + twitter_actions = [a for a in all_actions if a.get("platform") == "twitter"] + reddit_actions = [a for a in all_actions if a.get("platform") == "reddit"] + status = get_cached_run_status(simulation_id) or {} + status.update({ + "all_actions": visible_actions, + "twitter_actions": twitter_actions if platform_filter in (None, "twitter") else [], + "reddit_actions": reddit_actions if platform_filter in (None, "reddit") else [], + "recent_actions": replay.get("rounds", [])[-1].get("actions", []) if replay.get("rounds") else [], + "rounds_count": len(replay.get("rounds", [])), + }) + return status + + +def get_cached_history_items() -> List[Dict[str, Any]]: + """Return completed replay cases shown as first-class history cards.""" + start = datetime.fromisoformat("2026-06-04T09:30:00") + end = start + timedelta(minutes=70) + + return [ + { + "simulation_id": NB_HNW_AI_CASE_ID, + "project_id": NB_PROJECT_ID, + "graph_id": NB_GRAPH_ID, + "report_id": NB_REPORT_ID, + "has_replay": True, + "is_cached_case": True, + "cached_label": "已完成回放", + "project_name": "宁波银行高净值客户AI理财组合推介", + "simulation_requirement": NB_REQUIREMENT, + "status": "completed", + "runner_status": "completed", + "entities_count": len(CASE_AGENTS), + "profiles_count": len(CASE_AGENTS), + "entity_types": [agent[2] for agent in CASE_AGENTS], + "files": [ + {"filename": "宁波银行高净值客户AI理财组合缓存案例.md", "size": 4096}, + {"filename": "产品组合与销售效果回放.json", "size": 8192}, + ], + "created_at": start.isoformat(), + "updated_at": end.isoformat(), + "created_date": start.date().isoformat(), + "total_rounds": len(ROUND_ACTIONS), + "current_round": len(ROUND_ACTIONS), + "total_actions": sum(len(round_actions) for round_actions in ROUND_ACTIONS), + "version": "demo-replay", + } + ] diff --git a/backend/app/services/custom_graph_builder.py b/backend/app/services/custom_graph_builder.py index b70027ad..33e28072 100644 --- a/backend/app/services/custom_graph_builder.py +++ b/backend/app/services/custom_graph_builder.py @@ -55,6 +55,9 @@ class CustomGraphBuilder: self.edges_created = 0 # 去重:entity name → uuid self._entity_uuid_by_name: Dict[str, str] = {} + self._snapshot_nodes_by_name: Dict[str, Dict[str, Any]] = {} + self._snapshot_edges: List[Dict[str, Any]] = [] + self.neo4j_available = True def close(self): if self.driver: @@ -74,16 +77,21 @@ class CustomGraphBuilder: "CREATE INDEX entity_group_idx IF NOT EXISTS " "FOR (n:Entity) ON (n.group_id)", ] + success_count = 0 with self.driver.session() as session: for ddl in ddls: try: session.run(ddl) + success_count += 1 except Exception as e: # IndexAlreadyExists / ConstraintAlreadyExists 等都是无害的 logger.warning( f"CustomGraphBuilder: DDL skipped ({type(e).__name__}): " f"{str(e)[:150]}" ) + if success_count == 0: + self.neo4j_available = False + logger.warning("CustomGraphBuilder: Neo4j unavailable, snapshot-only mode enabled") # ------------------------------------------------------------------ # # LLM 抽取 @@ -158,7 +166,113 @@ class CustomGraphBuilder: } except Exception as e: logger.error(f"chunk LLM 抽取失败: {type(e).__name__}: {str(e)[:200]}") - return {"entities": [], "relationships": []} + return self.fallback_extract_from_chunk(chunk_text) + + def fallback_extract_from_chunk(self, chunk_text: str) -> Dict[str, List[Dict]]: + """现场演示兜底抽取:LLM 超时时仍能产生可视化图谱。""" + keyword_entities = [ + ("宁波银行", "Bank"), + ("大客户经理", "BankRelationshipManager"), + ("高净值客户", "HighNetWorthClient"), + ("私行投资顾问", "PrivateBankingAdvisor"), + ("家族办公室", "FamilyOffice"), + ("科技主题基金", "WealthProduct"), + ("AI主题理财产品", "WealthProduct"), + ("固收增强产品", "WealthProduct"), + ("现金管理产品", "WealthProduct"), + ("黄金多资产产品", "WealthProduct"), + ("AI产业链", "IndustryTheme"), + ("科技产品组合", "Portfolio"), + ("风险测评", "Compliance"), + ("销售转化", "SalesOutcome"), + ("AI 数据中心", "DataCenterOperator"), + ("制造企业", "ManufacturingCompany"), + ("电力设备商", "EnergyEquipmentCompany"), + ("园区运营方", "Organization"), + ("银行客户经理", "BankRelationshipManager"), + ("高端装备制造企业", "ManufacturingCompany"), + ("数据中心", "DataCenterOperator"), + ("液冷组件订单", "Organization"), + ("银行", "Bank"), + ("企业主", "EnterpriseOwner"), + ("绿色信贷", "Bank"), + ("设备融资租赁", "Bank"), + ("订单融资", "Bank"), + ] + entities = [] + wealth_case_mode = any(token in chunk_text for token in ("宁波银行", "高净值", "理财产品", "大客户经理")) + for name, entity_type in keyword_entities: + should_include = name in chunk_text + if wealth_case_mode and entity_type in { + "Bank", + "BankRelationshipManager", + "HighNetWorthClient", + "PrivateBankingAdvisor", + "FamilyOffice", + "WealthProduct", + "IndustryTheme", + "Portfolio", + "Compliance", + "SalesOutcome", + }: + should_include = True + if should_include and not any(e["name"] == name for e in entities): + entities.append({ + "name": name, + "type": entity_type, + "summary": f"{name} 是本轮 AI 算力扩张与银行服务机会推演中的关键主体或服务点。", + }) + + if not entities: + entities = [ + { + "name": "AI 算力扩张", + "type": "Organization", + "summary": "现场演示 fallback 生成的核心推演主题。", + }, + { + "name": "银行服务机会", + "type": "Bank", + "summary": "现场演示 fallback 生成的银行业务切入点。", + }, + ] + + relationships = [] + for source, target, rel_type in [ + ("宁波银行", "大客户经理", "EMPLOYS"), + ("大客户经理", "高净值客户", "SERVES"), + ("大客户经理", "私行投资顾问", "COLLABORATES_WITH"), + ("高净值客户", "家族办公室", "REPRESENTED_BY"), + ("私行投资顾问", "科技产品组合", "DESIGNS"), + ("科技产品组合", "现金管理产品", "CONTAINS"), + ("科技产品组合", "固收增强产品", "CONTAINS"), + ("科技产品组合", "科技主题基金", "CONTAINS"), + ("科技产品组合", "AI主题理财产品", "CONTAINS"), + ("科技产品组合", "黄金多资产产品", "CONTAINS"), + ("AI产业链", "科技主题基金", "DRIVES"), + ("AI产业链", "AI主题理财产品", "DRIVES"), + ("风险测评", "高净值客户", "PROFILES"), + ("风险测评", "科技产品组合", "CONSTRAINS"), + ("大客户经理", "销售转化", "PREDICTS"), + ("科技产品组合", "销售转化", "AFFECTS"), + ("AI 数据中心", "制造企业", "AFFECTS"), + ("AI 数据中心", "电力设备商", "AFFECTS"), + ("高端装备制造企业", "数据中心", "SUPPLIES_TO"), + ("银行", "高端装备制造企业", "FINANCES"), + ("银行客户经理", "高端装备制造企业", "SERVES"), + ("绿色信贷", "高端装备制造企业", "FINANCES"), + ("设备融资租赁", "高端装备制造企业", "FINANCES"), + ("订单融资", "高端装备制造企业", "FINANCES"), + ]: + if any(e["name"] == source for e in entities) and any(e["name"] == target for e in entities): + relationships.append({ + "source": source, + "target": target, + "type": rel_type, + "fact": f"{source} 与 {target} 在演示场景中存在 {rel_type} 关系。", + }) + + return {"entities": entities, "relationships": relationships} # ------------------------------------------------------------------ # # sanitize helpers @@ -284,6 +398,100 @@ class CustomGraphBuilder: self.edges_created += 1 return True + def record_snapshot(self, entities: List[Dict], relationships: List[Dict]) -> None: + """记录本地图谱快照;即使 Neo4j 写入失败,现场也能展示抽取结果。""" + for entity in entities: + if not isinstance(entity, dict): + continue + name = self._sanitize_value(entity.get("name", "")) + if not isinstance(name, str): + name = str(name) + name = name.strip() + if not name or name in self._snapshot_nodes_by_name: + continue + + raw_type = self._sanitize_value(entity.get("type", "Entity")) or "Entity" + if not isinstance(raw_type, str): + raw_type = str(raw_type) + safe_type = self._safe_label(raw_type.strip(), "Entity") + summary = self._sanitize_value(entity.get("summary", "")) or "" + if not isinstance(summary, str): + summary = str(summary) + + self._snapshot_nodes_by_name[name] = { + "uuid": str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.graph_id}:{name}")), + "name": name, + "labels": [safe_type] if safe_type else ["Entity"], + "summary": summary[:1000], + "attributes": { + "name": name, + "type": safe_type, + }, + "created_at": datetime.utcnow().isoformat(), + } + + seen_edges = { + (edge.get("source_node_uuid"), edge.get("target_node_uuid"), edge.get("name")) + for edge in self._snapshot_edges + } + for rel in relationships: + if not isinstance(rel, dict): + continue + source_name = self._sanitize_value(rel.get("source", "")) + target_name = self._sanitize_value(rel.get("target", "")) + if not isinstance(source_name, str): + source_name = str(source_name) + if not isinstance(target_name, str): + target_name = str(target_name) + source_name = source_name.strip() + target_name = target_name.strip() + source = self._snapshot_nodes_by_name.get(source_name) + target = self._snapshot_nodes_by_name.get(target_name) + if not source or not target: + continue + + raw_type = self._sanitize_value(rel.get("type", "RELATED_TO")) or "RELATED_TO" + if not isinstance(raw_type, str): + raw_type = str(raw_type) + safe_type = self._safe_label(raw_type.strip().upper(), "RELATED_TO") + dedupe_key = (source["uuid"], target["uuid"], safe_type) + if dedupe_key in seen_edges: + continue + seen_edges.add(dedupe_key) + + fact = self._sanitize_value(rel.get("fact", "")) or "" + if not isinstance(fact, str): + fact = str(fact) + + self._snapshot_edges.append({ + "uuid": str(uuid.uuid5(uuid.NAMESPACE_URL, f"{self.graph_id}:{source_name}:{safe_type}:{target_name}")), + "name": safe_type, + "fact": fact[:800], + "source_node_uuid": source["uuid"], + "target_node_uuid": target["uuid"], + "source_name": source_name, + "target_name": target_name, + "created_at": datetime.utcnow().isoformat(), + }) + + def get_snapshot_graph_data(self) -> Dict[str, Any]: + nodes = list(self._snapshot_nodes_by_name.values()) + edges = self._snapshot_edges + entity_types = sorted({ + label + for node in nodes + for label in node.get("labels", []) + if label not in ("Entity", "Node", "__Entity__") + }) + return { + "graph_id": self.graph_id, + "nodes": nodes, + "edges": edges, + "node_count": len(nodes), + "edge_count": len(edges), + "entity_types": entity_types, + } + # ------------------------------------------------------------------ # # Main entry # ------------------------------------------------------------------ # @@ -357,15 +565,70 @@ class CustomGraphBuilder: ) return idx, {"entities": [], "relationships": []} - # 步骤1: 并发抽取全部 chunks - # 用单个持久 session 做 Neo4j 写入,按完成顺序消费结果 - with self.driver.session() as session: - with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor: - future_map = { - executor.submit(_extract_one, (i, c)): i - for i, c in enumerate(chunks) - } + def _handle_extracted(idx: int, extracted: Dict[str, List[Dict]], session=None) -> None: + nonlocal processed, failed + entities = extracted.get("entities", []) + relationships = extracted.get("relationships", []) + self.record_snapshot(entities, relationships) + + # 进度上报 + with progress_lock: + completed[0] += 1 + current = completed[0] + if progress_callback: + progress_callback( + current, + total_chunks, + f"处理 chunk {current}/{total_chunks}", + ) + + if not self.neo4j_available: + processed += 1 + return + + if not entities and not relationships: + processed += 1 + return + + # Neo4j 写入(串行,避免同名 entity 并发插入冲突) + try: + def _tx_write(tx, _ents=entities, _rels=relationships): + for e in _ents: + if isinstance(e, dict): + self.upsert_entity(tx, e) + for r in _rels: + if isinstance(r, dict): + self.upsert_relationship(tx, r) + + session.execute_write(_tx_write) + processed += 1 + except Exception as e: + logger.error( + f"chunk {idx + 1} 写 Neo4j 失败: " + f"{type(e).__name__}: {str(e)[:200]}" + ) + failed += 1 + + # 步骤1: 并发抽取全部 chunks + with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor: + future_map = { + executor.submit(_extract_one, (i, c)): i + for i, c in enumerate(chunks) + } + + if self.neo4j_available: + with self.driver.session() as session: + for future in concurrent.futures.as_completed(future_map): + idx = future_map[future] + try: + _idx, extracted = future.result() + except Exception as e: + logger.error(f"chunk {idx + 1} future 异常: {e}") + failed += 1 + continue + _handle_extracted(idx, extracted, session=session) + else: for future in concurrent.futures.as_completed(future_map): idx = future_map[future] try: @@ -374,43 +637,7 @@ class CustomGraphBuilder: logger.error(f"chunk {idx + 1} future 异常: {e}") failed += 1 continue - - entities = extracted.get("entities", []) - relationships = extracted.get("relationships", []) - - # 进度上报 - with progress_lock: - completed[0] += 1 - current = completed[0] - if progress_callback: - progress_callback( - current, - total_chunks, - f"处理 chunk {current}/{total_chunks}", - ) - - if not entities and not relationships: - processed += 1 - continue - - # Neo4j 写入(串行,避免同名 entity 并发插入冲突) - try: - def _tx_write(tx, _ents=entities, _rels=relationships): - for e in _ents: - if isinstance(e, dict): - self.upsert_entity(tx, e) - for r in _rels: - if isinstance(r, dict): - self.upsert_relationship(tx, r) - - session.execute_write(_tx_write) - processed += 1 - except Exception as e: - logger.error( - f"chunk {idx + 1} 写 Neo4j 失败: " - f"{type(e).__name__}: {str(e)[:200]}" - ) - failed += 1 + _handle_extracted(idx, extracted) logger.info( f"CustomGraphBuilder 完成: entities={self.entities_created}, " @@ -421,6 +648,9 @@ class CustomGraphBuilder: return { "entities_count": self.entities_created, "edges_count": self.edges_created, + "snapshot_node_count": len(self._snapshot_nodes_by_name), + "snapshot_edge_count": len(self._snapshot_edges), + "graph_data": self.get_snapshot_graph_data(), "chunks_processed": processed, "chunks_failed": failed, "total_chunks": total_chunks, diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py index 949a1b82..cfb733a8 100644 --- a/backend/app/services/ontology_generator.py +++ b/backend/app/services/ontology_generator.py @@ -238,21 +238,90 @@ class OntologyGenerator: ) except Exception as primary_err: if not self._fallback_llm: - raise - logger.warning( - f"主 LLM (GLM) 生成本体失败,降级到 Qwen 32B fallback: " - f"{type(primary_err).__name__}: {str(primary_err)[:200]}" - ) - result = self._fallback_llm.chat_json( - messages=messages, - temperature=0.3, - max_tokens=ontology_max_tokens - ) + logger.warning( + f"主 LLM 生成本体失败,使用现场演示默认本体: " + f"{type(primary_err).__name__}: {str(primary_err)[:200]}" + ) + result = self._default_business_ontology(simulation_requirement) + else: + logger.warning( + f"主 LLM (GLM) 生成本体失败,降级到 Qwen 32B fallback: " + f"{type(primary_err).__name__}: {str(primary_err)[:200]}" + ) + try: + result = self._fallback_llm.chat_json( + messages=messages, + temperature=0.3, + max_tokens=ontology_max_tokens + ) + except Exception as fallback_err: + logger.warning( + f"fallback LLM 生成本体失败,使用现场演示默认本体: " + f"{type(fallback_err).__name__}: {str(fallback_err)[:200]}" + ) + result = self._default_business_ontology(simulation_requirement) # 验证和后处理 result = self._validate_and_process(result) return result + + @staticmethod + def _default_business_ontology(simulation_requirement: str) -> Dict[str, Any]: + """现场演示兜底本体:外部 LLM 超时也能继续构建可展示图谱。""" + entity_types = [ + ("ManufacturingCompany", "Company producing industrial or AI infrastructure equipment.", "org_name"), + ("DataCenterOperator", "Organization operating data centers or computing facilities.", "org_name"), + ("EnergyEquipmentCompany", "Company providing power, grid, cooling, or energy equipment.", "org_name"), + ("Bank", "Financial institution providing credit, settlement, and advisory services.", "org_name"), + ("BankRelationshipManager", "Bank staff member responsible for enterprise customer coverage.", "full_name"), + ("EnterpriseOwner", "Business owner or executive making financing decisions.", "full_name"), + ("GovernmentAgency", "Public agency regulating industry, energy, finance, or subsidies.", "org_name"), + ("MediaOutlet", "Media or information channel shaping public market perception.", "org_name"), + ("Person", "Any individual person not fitting other specific person types.", "full_name"), + ("Organization", "Any organization not fitting other specific organization types.", "org_name"), + ] + edge_types = [ + ("SUPPLIES_TO", "Supplier relationship between companies."), + ("FINANCES", "Bank or investor provides financing to an organization."), + ("SERVES", "Bank or service provider serves an enterprise customer."), + ("REGULATES", "Agency regulates an organization or activity."), + ("PARTNERS_WITH", "Organizations collaborate on a business opportunity."), + ("AFFECTS", "One organization or event materially affects another."), + ("REPORTS_ON", "Media reports on a company, sector, or event."), + ("MANAGES", "Person manages or represents an organization."), + ] + return { + "entity_types": [ + { + "name": name, + "description": description, + "attributes": [ + {"name": attr, "type": "text", "description": f"Display name for {name}"}, + {"name": "role", "type": "text", "description": "Role in the business scenario"}, + ], + "examples": [], + } + for name, description, attr in entity_types + ], + "edge_types": [ + { + "name": name, + "description": description, + "source_targets": [ + {"source": "Organization", "target": "Organization"}, + {"source": "Bank", "target": "ManufacturingCompany"}, + {"source": "Person", "target": "Organization"}, + ], + "attributes": [], + } + for name, description in edge_types + ], + "analysis_summary": ( + "Fallback ontology for live demo. External LLM ontology generation was unavailable; " + f"scenario requirement: {simulation_requirement[:200]}" + ), + } # 传给 LLM 的文本最大长度(2.8万字) # Qwen 32768 token 窗口:system prompt ~2K tokens + 响应 ~4K tokens @@ -534,4 +603,3 @@ class OntologyGenerator: code_lines.append('}') return '\n'.join(code_lines) - diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index 7f27545a..b58b2385 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -189,6 +189,32 @@ class SimulationManager: # 内存中的模拟状态缓存 self._simulations: Dict[str, SimulationState] = {} + + @staticmethod + def _cap_entities_for_live_demo(filtered: FilteredEntities) -> FilteredEntities: + """Limit generated Agents while keeping the most connected graph nodes.""" + max_agents = max(1, Config.SIMULATION_MAX_AGENTS) + if len(filtered.entities) <= max_agents: + return filtered + + def entity_score(entity: Any) -> tuple: + degree = len(getattr(entity, "related_edges", []) or []) + len(getattr(entity, "related_nodes", []) or []) + summary_bonus = 1 if getattr(entity, "summary", "") else 0 + type_bonus = 1 if entity.get_entity_type() and entity.get_entity_type() != "Entity" else 0 + return (degree, type_bonus, summary_bonus, entity.name or "") + + selected = sorted(filtered.entities, key=entity_score, reverse=True)[:max_agents] + entity_types = {e.get_entity_type() or "Entity" for e in selected} + logger.info( + f"现场 Agent 上限生效: {len(filtered.entities)} -> {len(selected)} " + f"(SIMULATION_MAX_AGENTS={max_agents})" + ) + return FilteredEntities( + entities=selected, + entity_types=entity_types, + total_count=filtered.total_count, + filtered_count=len(selected), + ) def _get_simulation_dir(self, simulation_id: str) -> str: """获取模拟数据目录""" @@ -340,6 +366,7 @@ class SimulationManager: defined_entity_types=defined_entity_types, enrich_with_edges=True ) + filtered = self._cap_entities_for_live_demo(filtered) state.entities_count = filtered.filtered_count state.entity_types = list(filtered.entity_types) @@ -369,8 +396,9 @@ class SimulationManager: total=total_entities ) - # 传入graph_id以启用Zep检索功能,获取更丰富的上下文 - generator = OasisProfileGenerator(graph_id=state.graph_id) + # 非 LLM 演示模式不做 Graphiti 检索,避免远端 Neo4j 不可用拖慢现场。 + profile_graph_id = state.graph_id if use_llm_for_profiles else None + generator = OasisProfileGenerator(graph_id=profile_graph_id) def profile_progress(current, total, msg): if progress_callback: @@ -397,7 +425,7 @@ class SimulationManager: entities=filtered.entities, use_llm=use_llm_for_profiles, progress_callback=profile_progress, - graph_id=state.graph_id, # 传入graph_id用于Zep检索 + graph_id=profile_graph_id, # 传入graph_id用于Zep检索 parallel_count=parallel_profile_count, # 并行生成数量 realtime_output_path=realtime_output_path, # 实时保存路径 output_platform=realtime_platform, # 输出格式 @@ -546,6 +574,27 @@ class SimulationManager: simulations.append(state) return simulations + + def delete_simulation(self, simulation_id: str) -> bool: + """删除模拟数据目录,用于清理历史列表中的模拟结果""" + if not simulation_id or simulation_id != os.path.basename(simulation_id): + raise ValueError(f"非法模拟ID: {simulation_id}") + + sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id) + state = self._load_simulation_state(simulation_id) if os.path.exists(sim_dir) else None + + if state and state.status in {SimulationStatus.PREPARING, SimulationStatus.RUNNING}: + raise ValueError(f"模拟仍在运行或准备中,不能删除: {simulation_id}") + + self._simulations.pop(simulation_id, None) + self.clear_accelerate(simulation_id) + + if not os.path.exists(sim_dir): + return False + + shutil.rmtree(sim_dir) + logger.info(f"已删除模拟数据: {simulation_id}") + return True def get_profiles(self, simulation_id: str, platform: str = "reddit") -> List[Dict[str, Any]]: """获取模拟的Agent Profile""" diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index e89cd966..2ba46519 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -12,6 +12,7 @@ import threading import subprocess import signal import atexit +import importlib from typing import Dict, Any, List, Optional, Union from dataclasses import dataclass, field from datetime import datetime @@ -384,6 +385,8 @@ class SimulationRunner: else: cls._graph_memory_enabled[simulation_id] = False + use_demo_runner = cls._should_use_demo_runner() + # 确定运行哪个脚本(脚本位于 backend/scripts/ 目录) if platform == "twitter": script_name = "run_twitter_simulation.py" @@ -395,6 +398,10 @@ class SimulationRunner: script_name = "run_parallel_simulation.py" state.twitter_running = True state.reddit_running = True + + if use_demo_runner: + logger.warning("OASIS 不可用或已启用现场演示 runner,切换为轻量仿真脚本") + script_name = "run_demo_simulation.py" script_path = os.path.join(cls.SCRIPTS_DIR, script_name) @@ -422,6 +429,9 @@ class SimulationRunner: # 如果指定了最大轮数,添加到命令行参数 if max_rounds is not None and max_rounds > 0: cmd.extend(["--max-rounds", str(max_rounds)]) + + if use_demo_runner: + cmd.extend(["--platform", platform]) # 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞 main_log_path = os.path.join(sim_dir, "simulation.log") @@ -477,6 +487,22 @@ class SimulationRunner: raise return state + + @classmethod + def _should_use_demo_runner(cls) -> bool: + """Return True when the live demo should avoid importing OASIS/Torch.""" + mode = os.environ.get("FORESIGHT_DEMO_RUNNER", "auto").strip().lower() + if mode in {"1", "true", "yes", "on"}: + return True + if mode in {"0", "false", "no", "off"}: + return False + + try: + importlib.import_module("oasis") + return False + except Exception as e: + logger.warning(f"OASIS 自检失败,启用现场演示 runner: {type(e).__name__}: {str(e)[:200]}") + return True @classmethod def _monitor_simulation(cls, simulation_id: str, locale: str = 'zh'): @@ -1719,4 +1745,3 @@ class SimulationRunner: results = results[:limit] return results - diff --git a/backend/app/services/zep_entity_reader.py b/backend/app/services/zep_entity_reader.py index c464cc04..474abce8 100644 --- a/backend/app/services/zep_entity_reader.py +++ b/backend/app/services/zep_entity_reader.py @@ -8,6 +8,7 @@ from typing import Dict, Any, List, Optional, Set, Callable, TypeVar from dataclasses import dataclass, field from ..config import Config +from ..models.project import ProjectManager from ..utils.logger import get_logger from .graphiti_client import GraphitiClient @@ -48,6 +49,11 @@ class EntityNode: return label return None + @property + def entity_type(self) -> Optional[str]: + """兼容需要属性访问的模拟配置生成器。""" + return self.get_entity_type() + @dataclass class FilteredEntities: @@ -131,7 +137,17 @@ class ZepEntityReader: """ logger.info(f"获取图谱 {graph_id} 的所有节点...") - nodes = self.client.get_all_nodes(graph_id) + try: + nodes = self.client.get_all_nodes(graph_id) + except Exception as e: + snapshot = self._get_snapshot(graph_id) + if snapshot: + logger.warning( + f"Graphiti 节点读取失败,使用本地 snapshot: " + f"{type(e).__name__}: {str(e)[:150]}" + ) + return snapshot.get("nodes", []) + raise nodes_data = [] for node in nodes: @@ -158,7 +174,17 @@ class ZepEntityReader: """ logger.info(f"获取图谱 {graph_id} 的所有边...") - edges = self.client.get_all_edges(graph_id) + try: + edges = self.client.get_all_edges(graph_id) + except Exception as e: + snapshot = self._get_snapshot(graph_id) + if snapshot: + logger.warning( + f"Graphiti 边读取失败,使用本地 snapshot: " + f"{type(e).__name__}: {str(e)[:150]}" + ) + return snapshot.get("edges", []) + raise edges_data = [] for edge in edges: @@ -173,6 +199,12 @@ class ZepEntityReader: logger.info(f"共获取 {len(edges_data)} 条边") return edges_data + + def _get_snapshot(self, graph_id: str) -> Optional[Dict[str, Any]]: + project = ProjectManager.get_project_by_graph_id(graph_id) + if not project: + return None + return ProjectManager.get_graph_snapshot(project.project_id) def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]: """ @@ -426,5 +458,3 @@ class ZepEntityReader: enrich_with_edges=enrich_with_edges ) return result.entities - - diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py index 523cb0d7..6aff1f4f 100644 --- a/backend/app/utils/llm_client.py +++ b/backend/app/utils/llm_client.py @@ -16,7 +16,7 @@ from ..config import Config logger = logging.getLogger('foresight.llm_client') # 重试配置(针对 429 / 5xx / 超时 / 连接错误) -_MAX_RETRIES = 8 +_MAX_RETRIES = Config.LLM_MAX_RETRIES _BASE_BACKOFF = 2.0 # 首次重试等 2s _MAX_BACKOFF = 60.0 # 单次最多等 60s @@ -48,7 +48,8 @@ class LLMClient: self, api_key: Optional[str] = None, base_url: Optional[str] = None, - model: Optional[str] = None + model: Optional[str] = None, + timeout: Optional[float] = None ): self.api_key = api_key or Config.LLM_API_KEY self.base_url = base_url or Config.LLM_BASE_URL @@ -59,7 +60,9 @@ class LLMClient: self.client = OpenAI( api_key=self.api_key, - base_url=self.base_url + base_url=self.base_url, + timeout=timeout or Config.LLM_TIMEOUT_SECONDS, + max_retries=0, ) def chat( @@ -274,4 +277,3 @@ class LLMClient: pass return None - diff --git a/backend/scripts/run_demo_simulation.py b/backend/scripts/run_demo_simulation.py new file mode 100644 index 00000000..203976cd --- /dev/null +++ b/backend/scripts/run_demo_simulation.py @@ -0,0 +1,170 @@ +""" +Lightweight live-demo simulation runner. + +This runner preserves the Foresight UI/API contract when the local OASIS stack is +blocked by Torch/CAMEL binary compatibility. It writes the same actions.jsonl +shape that SimulationRunner already monitors, but it does not import OASIS, +Torch, CAMEL, or GPU-backed libraries. +""" + +import argparse +import json +import os +import random +import time +from datetime import datetime +from typing import Any, Dict, List + + +ACTION_LIBRARY = [ + { + "action_type": "CREATE_POST", + "template": "{name} 发布观察:AI 算力扩张正在把电力容量、液冷组件和园区配套变成新增投资的约束条件。", + }, + { + "action_type": "CREATE_COMMENT", + "template": "{name} 评论:下一步要看订单兑现、授信需求和现金流压力是否同步出现。", + }, + { + "action_type": "LIKE_POST", + "template": "{name} 赞同了关于绿色信贷和设备更新贷款的讨论。", + }, + { + "action_type": "CREATE_COMMENT", + "template": "{name} 提醒:变量推演不是预测,关键是把客户拜访问题提前准备好。", + }, +] + + +def write_jsonl(path: str, payload: Dict[str, Any]) -> None: + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(payload, ensure_ascii=False) + "\n") + f.flush() + + +def load_config(config_path: str) -> Dict[str, Any]: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def get_agents(config: Dict[str, Any]) -> List[Dict[str, Any]]: + agents = config.get("agent_configs") or [] + if agents: + return agents + return [ + {"agent_id": 0, "entity_name": "AI 数据中心", "entity_type": "DataCenterOperator"}, + {"agent_id": 1, "entity_name": "制造企业", "entity_type": "ManufacturingCompany"}, + {"agent_id": 2, "entity_name": "银行客户经理", "entity_type": "BankRelationshipManager"}, + ] + + +def platforms_from_arg(platform: str) -> List[str]: + if platform == "parallel": + return ["twitter", "reddit"] + if platform in {"twitter", "reddit"}: + return [platform] + return ["reddit"] + + +def reset_logs(simulation_dir: str, platforms: List[str]) -> Dict[str, str]: + log_paths = {} + for platform in platforms: + platform_dir = os.path.join(simulation_dir, platform) + os.makedirs(platform_dir, exist_ok=True) + log_path = os.path.join(platform_dir, "actions.jsonl") + with open(log_path, "w", encoding="utf-8"): + pass + log_paths[platform] = log_path + return log_paths + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--max-rounds", type=int, default=None) + parser.add_argument("--platform", default="reddit") + parser.add_argument("--tick-seconds", type=float, default=0.45) + args = parser.parse_args() + + config = load_config(args.config) + simulation_dir = os.path.dirname(os.path.abspath(args.config)) + platforms = platforms_from_arg(args.platform) + log_paths = reset_logs(simulation_dir, platforms) + + time_config = config.get("time_config", {}) + total_hours = int(time_config.get("total_simulation_hours", 72)) + minutes_per_round = int(time_config.get("minutes_per_round", 60)) or 60 + calculated_rounds = max(1, int(total_hours * 60 / minutes_per_round)) + total_rounds = min(calculated_rounds, args.max_rounds) if args.max_rounds else calculated_rounds + agents = get_agents(config) + + print(f"[demo-runner] start simulation={config.get('simulation_id')} platforms={platforms} rounds={total_rounds}", flush=True) + + for platform, log_path in log_paths.items(): + write_jsonl(log_path, { + "timestamp": datetime.now().isoformat(), + "event_type": "simulation_start", + "platform": platform, + "total_rounds": total_rounds, + "agents_count": len(agents), + }) + + random.seed(config.get("simulation_id", "foresight-demo")) + total_actions = {platform: 0 for platform in platforms} + + for round_num in range(1, total_rounds + 1): + simulated_hours = round_num * minutes_per_round // 60 + for platform, log_path in log_paths.items(): + write_jsonl(log_path, { + "round": round_num, + "timestamp": datetime.now().isoformat(), + "event_type": "round_start", + "simulated_hours": simulated_hours, + }) + + sample_size = min(len(agents), 3) + selected_agents = random.sample(agents, sample_size) + for agent in selected_agents: + action = random.choice(ACTION_LIBRARY) + name = agent.get("entity_name") or agent.get("name") or f"Agent {agent.get('agent_id', 0)}" + result = action["template"].format(name=name) + write_jsonl(log_path, { + "round": round_num, + "timestamp": datetime.now().isoformat(), + "agent_id": agent.get("agent_id", 0), + "agent_name": name, + "action_type": action["action_type"], + "action_args": { + "platform": platform, + "topic": config.get("simulation_requirement", "")[:80], + }, + "result": result, + "success": True, + }) + total_actions[platform] += 1 + + write_jsonl(log_path, { + "round": round_num, + "timestamp": datetime.now().isoformat(), + "event_type": "round_end", + "actions_count": total_actions[platform], + "simulated_hours": simulated_hours, + }) + + time.sleep(args.tick_seconds) + + for platform, log_path in log_paths.items(): + write_jsonl(log_path, { + "timestamp": datetime.now().isoformat(), + "event_type": "simulation_end", + "platform": platform, + "total_rounds": total_rounds, + "total_actions": total_actions[platform], + }) + + print(f"[demo-runner] completed actions={total_actions}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/demo/foresight-live-seed.md b/demo/foresight-live-seed.md new file mode 100644 index 00000000..4b518ca4 --- /dev/null +++ b/demo/foresight-live-seed.md @@ -0,0 +1,9 @@ +# Foresight Live Demo Seed + +2026 年,AI 数据中心继续扩张,制造企业、电力设备商、园区运营方和银行客户经理都在重新评估机会。 + +核心变量包括 GPU 服务器采购、液冷改造、电网接入周期、企业现金流压力、绿色信贷额度、供应链付款周期和监管要求。 + +宁波本地一家高端装备制造企业正在考虑承接数据中心液冷组件订单,但它面临原材料价格波动、账期拉长、设备扩产资金缺口和技术人员招聘压力。银行希望识别可介入的服务点,包括流动资金贷款、设备融资租赁、订单融资、绿色信贷、结算服务和企业主财富管理。 + +演示目标:推演未来 12 个月 AI 算力扩张对制造业、数据中心、电力设备和银行服务机会的连锁影响,输出关键变量、机会情景、风险预警和客户拜访切入点。 diff --git a/demo/tencent-cloud-demo-checklist.md b/demo/tencent-cloud-demo-checklist.md new file mode 100644 index 00000000..516c53ca --- /dev/null +++ b/demo/tencent-cloud-demo-checklist.md @@ -0,0 +1,71 @@ +# Foresight 现场 Demo 腾讯云部署检查 + +用途:保证「先见之明」可被远程浏览器打开,并能稳定进入宁波银行缓存回放案例。 + +## 本地检查 + +```bash +cd "/Users/liyizhouai/Desktop/openclaw/vibe coding/Foresight先见之明" +npm run backend +cd frontend && npm run dev -- --host 0.0.0.0 --port 4190 +``` + +检查: + +- 首页:http://localhost:4190/ +- 同域 API:http://localhost:4190/api/simulation/history?limit=1 +- 回放页:http://localhost:4190/simulation/sim_nb_hnw_ai_case/replay + +## 腾讯云双域名部署 + +如果继续使用: + +- 前端:https://foresight.yizhou.chat +- 后端:https://api.foresight.yizhou.chat + +前端构建必须显式指定 API 地址: + +```bash +cd frontend +VITE_API_BASE_URL=https://api.foresight.yizhou.chat npm run build +``` + +否则默认会走同域 `/api`。 + +## 腾讯云同域部署 + +如果希望远程讲课时只打开一个域名,推荐 Nginx 规则: + +```nginx +location / { + try_files $uri $uri/ /index.html; +} + +location /api/ { + proxy_pass http://127.0.0.1:5001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +同域部署时,前端无需设置 `VITE_API_BASE_URL`。 + +## 现场必验路径 + +1. 打开首页,滚动到「推演记录」。 +2. 点击「宁波银行高净值客户AI理财组合推介」卡片。 +3. 页面保持在首页,并弹出历史项目回溯弹窗。 +4. 弹窗里确认四个入口都可见:`查看回放`、`图谱构建`、`环境搭建`、`分析报告`。 +5. `图谱构建` 进入 `/process/proj_nb_hnw_ai_case`。 +6. `环境搭建` 进入 `/simulation/sim_nb_hnw_ai_case`。 +7. `分析报告` 进入 `/report/report_nb_hnw_ai_case`。 +8. `查看回放` 可作为补充入口进入 `/simulation/sim_nb_hnw_ai_case/replay`。 + +## 关键稳定性说明 + +- 缓存回放案例不触发 ReportAgent 生成。 +- 缓存回放案例不依赖远端 Neo4j/Bolt。 +- 宁波银行案例的项目、图谱、环境配置和报告日志都由后端内置缓存提供,腾讯云部署时不依赖 `uploads/` 目录同步。 +- 默认 API 已改为同域访问;双域名部署时用 `VITE_API_BASE_URL` 覆盖。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f442bb23..b70200b9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,31 @@ @@ -54,8 +80,13 @@ import { useRouter } from 'vue-router' const router = useRouter() const THEME_STORAGE_KEY = 'foresight-ui-theme' +const AUTH_STORAGE_KEY = 'foresight-demo-auth' +const DEMO_PASSWORD = import.meta.env.VITE_DEMO_PASSWORD || '' const theme = ref('light') const anchorReady = ref(false) +const authInput = ref('') +const authError = ref('') +const isAuthed = ref(!DEMO_PASSWORD) const applyTheme = (nextTheme) => { document.documentElement.setAttribute('data-theme', nextTheme) @@ -71,7 +102,23 @@ const checkAnchor = () => { anchorReady.value = !!document.getElementById('theme-toggle-anchor') } +const submitPassword = () => { + if (!DEMO_PASSWORD || authInput.value === DEMO_PASSWORD) { + window.localStorage.setItem(AUTH_STORAGE_KEY, 'ok') + authError.value = '' + isAuthed.value = true + nextTick(checkAnchor) + return + } + + authError.value = '密码不正确,请重新输入' +} + onMounted(() => { + if (DEMO_PASSWORD && window.localStorage.getItem(AUTH_STORAGE_KEY) === 'ok') { + isAuthed.value = true + } + const savedTheme = window.localStorage.getItem(THEME_STORAGE_KEY) if (savedTheme === 'dark' || savedTheme === 'light') { theme.value = savedTheme @@ -164,6 +211,110 @@ html[data-theme='dark'] #app { position: relative; } +.demo-login { + min-height: 100vh; + padding: 32px; + display: flex; + align-items: center; + justify-content: center; + background: + linear-gradient(135deg, rgba(16, 19, 33, 0.06), rgba(16, 19, 33, 0)), + var(--ui-bg); +} + +.demo-login__panel { + width: min(420px, 100%); + padding: 34px 32px 30px; + border: 1px solid var(--ui-border); + border-radius: 8px; + background: var(--ui-surface-strong); + box-shadow: var(--ui-shadow); +} + +.demo-login__brand { + margin-bottom: 22px; + color: var(--ui-muted); + font-size: 13px; +} + +.demo-login h1 { + margin-bottom: 10px; + color: var(--ui-text); + font-size: 28px; + line-height: 1.18; + font-weight: 700; + letter-spacing: 0; +} + +.demo-login p { + color: var(--ui-muted); + font-size: 14px; + line-height: 1.7; +} + +.demo-login__input { + width: 100%; + height: 46px; + margin-top: 24px; + padding: 0 14px; + border: 1px solid var(--ui-border); + border-radius: 6px; + outline: none; + background: var(--ui-bg); + color: var(--ui-text); + font: inherit; +} + +.demo-login__input:focus { + border-color: rgba(86, 126, 255, 0.72); + box-shadow: 0 0 0 3px rgba(86, 126, 255, 0.14); +} + +.demo-login__input::placeholder { + color: var(--ui-muted); +} + +.demo-login__button { + width: 100%; + height: 46px; + margin-top: 14px; + border: none; + border-radius: 6px; + background: #101321; + color: #ffffff; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.demo-login__button:hover { + background: #232838; +} + +.demo-login__error { + margin-top: 12px; + color: #c93f3f !important; +} + +html[data-theme='dark'] .demo-login { + background: + linear-gradient(135deg, rgba(86, 126, 255, 0.12), rgba(86, 126, 255, 0)), + #000000; +} + +html[data-theme='dark'] .demo-login__button { + background: #f4f7ff; + color: #05070d; +} + +html[data-theme='dark'] .demo-login__button:hover { + background: #dfe7ff; +} + +html[data-theme='dark'] .demo-login__error { + color: #ff8f8f !important; +} + .brand-mark { display: inline-flex; align-items: baseline; @@ -709,6 +860,176 @@ html[data-theme='dark'][data-theme='dark'] .panel-wrapper.right { max-width: none !important; } +/* Dark mode color polish: keep layout intact, only fix light surfaces and unreadable text */ +html[data-theme='dark'][data-theme='dark'] .home-container, +html[data-theme='dark'][data-theme='dark'] .dashboard-section, +html[data-theme='dark'][data-theme='dark'] .history-database { + background: #000000 !important; + color: #edf2ff !important; + border-color: rgba(255, 255, 255, 0.1) !important; +} + +html[data-theme='dark'][data-theme='dark'] .main-title, +html[data-theme='dark'][data-theme='dark'] .highlight-bold, +html[data-theme='dark'][data-theme='dark'] .slogan-text, +html[data-theme='dark'][data-theme='dark'] .metric-value, +html[data-theme='dark'][data-theme='dark'] .step-title, +html[data-theme='dark'][data-theme='dark'] .upload-title, +html[data-theme='dark'][data-theme='dark'] .console-label, +html[data-theme='dark'][data-theme='dark'] .file-name, +html[data-theme='dark'][data-theme='dark'] .card-title, +html[data-theme='dark'][data-theme='dark'] .modal-id, +html[data-theme='dark'][data-theme='dark'] .report-id, +html[data-theme='dark'][data-theme='dark'] .report-section-item.is-active .section-title, +html[data-theme='dark'][data-theme='dark'] .report-section-item.is-completed .section-title { + color: #f4f7fb !important; + -webkit-text-fill-color: #f4f7fb !important; +} + +html[data-theme='dark'][data-theme='dark'] .gradient-text { + background: linear-gradient(90deg, #ffffff 0%, #a8b0c2 100%) !important; + -webkit-background-clip: text !important; + background-clip: text !important; + -webkit-text-fill-color: transparent !important; +} + +html[data-theme='dark'][data-theme='dark'] .hero-desc, +html[data-theme='dark'][data-theme='dark'] .section-desc, +html[data-theme='dark'][data-theme='dark'] .step-desc, +html[data-theme='dark'][data-theme='dark'] .metric-label, +html[data-theme='dark'][data-theme='dark'] .version-text, +html[data-theme='dark'][data-theme='dark'] .console-header, +html[data-theme='dark'][data-theme='dark'] .console-meta, +html[data-theme='dark'][data-theme='dark'] .upload-hint, +html[data-theme='dark'][data-theme='dark'] .model-badge, +html[data-theme='dark'][data-theme='dark'] .card-desc, +html[data-theme='dark'][data-theme='dark'] .card-id, +html[data-theme='dark'][data-theme='dark'] .card-footer, +html[data-theme='dark'][data-theme='dark'] .modal-label, +html[data-theme='dark'][data-theme='dark'] .modal-create-time, +html[data-theme='dark'][data-theme='dark'] .sub-title, +html[data-theme='dark'][data-theme='dark'] .section-number { + color: #a5adbd !important; + -webkit-text-fill-color: #a5adbd !important; +} + +html[data-theme='dark'][data-theme='dark'] .highlight-code, +html[data-theme='dark'][data-theme='dark'] .metric-card, +html[data-theme='dark'][data-theme='dark'] .steps-container, +html[data-theme='dark'][data-theme='dark'] .console-box, +html[data-theme='dark'][data-theme='dark'] .input-wrapper, +html[data-theme='dark'][data-theme='dark'] .upload-zone, +html[data-theme='dark'][data-theme='dark'] .file-item, +html[data-theme='dark'][data-theme='dark'] .project-card, +html[data-theme='dark'][data-theme='dark'] .card-files-wrapper, +html[data-theme='dark'][data-theme='dark'] .modal-content, +html[data-theme='dark'][data-theme='dark'] .modal-header, +html[data-theme='dark'][data-theme='dark'] .modal-requirement, +html[data-theme='dark'][data-theme='dark'] .report-style, +html[data-theme='dark'][data-theme='dark'] .section-header-row.clickable:hover, +html[data-theme='dark'][data-theme='dark'] .tools-card, +html[data-theme='dark'][data-theme='dark'] .chat-message, +html[data-theme='dark'][data-theme='dark'] .message-content, +html[data-theme='dark'][data-theme='dark'] .agent-dropdown, +html[data-theme='dark'][data-theme='dark'] .agent-option { + background: #111111 !important; + background-image: none !important; + color: #edf2ff !important; + border-color: rgba(255, 255, 255, 0.12) !important; + box-shadow: none !important; +} + +html[data-theme='dark'][data-theme='dark'] .upload-zone:hover, +html[data-theme='dark'][data-theme='dark'] .upload-zone.drag-over, +html[data-theme='dark'][data-theme='dark'] .project-card:hover, +html[data-theme='dark'][data-theme='dark'] .file-item:hover, +html[data-theme='dark'][data-theme='dark'] .agent-option:hover, +html[data-theme='dark'][data-theme='dark'] .modal-close:hover { + background: #181818 !important; + background-image: none !important; + border-color: rgba(255, 255, 255, 0.22) !important; + color: #ffffff !important; +} + +html[data-theme='dark'][data-theme='dark'] .upload-icon, +html[data-theme='dark'][data-theme='dark'] .files-more, +html[data-theme='dark'][data-theme='dark'] .files-empty, +html[data-theme='dark'][data-theme='dark'] .modal-progress, +html[data-theme='dark'][data-theme='dark'] .modal-close, +html[data-theme='dark'][data-theme='dark'] .report-tag { + background: #1a1a1a !important; + color: #d8deeb !important; + border-color: rgba(255, 255, 255, 0.12) !important; +} + +html[data-theme='dark'][data-theme='dark'] .code-input, +html[data-theme='dark'][data-theme='dark'] .code-input::placeholder, +html[data-theme='dark'][data-theme='dark'] textarea::placeholder, +html[data-theme='dark'][data-theme='dark'] input::placeholder { + color: #c4cad6 !important; + -webkit-text-fill-color: #c4cad6 !important; +} + +html[data-theme='dark'][data-theme='dark'] .start-engine-btn:disabled { + background: #202020 !important; + color: #8f96a3 !important; + border-color: rgba(255, 255, 255, 0.1) !important; + -webkit-text-fill-color: #8f96a3 !important; +} + +html[data-theme='dark'][data-theme='dark'] .start-engine-btn:not(:disabled) { + background: #f4f7fb !important; + color: #050505 !important; + border-color: #f4f7fb !important; + -webkit-text-fill-color: #050505 !important; +} + +html[data-theme='dark'][data-theme='dark'] .console-divider::before, +html[data-theme='dark'][data-theme='dark'] .console-divider::after, +html[data-theme='dark'][data-theme='dark'] .header-divider, +html[data-theme='dark'][data-theme='dark'] .section-line, +html[data-theme='dark'][data-theme='dark'] .card-footer, +html[data-theme='dark'][data-theme='dark'] .card-header, +html[data-theme='dark'][data-theme='dark'] .modal-header { + border-color: rgba(255, 255, 255, 0.1) !important; + background-color: rgba(255, 255, 255, 0.1) !important; +} + +html[data-theme='dark'][data-theme='dark'] .console-divider span, +html[data-theme='dark'][data-theme='dark'] .section-title { + background: #000000 !important; +} + +html[data-theme='dark'][data-theme='dark'] .grid-pattern { + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.06) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.06) 1px, transparent 1px) !important; +} + +html[data-theme='dark'][data-theme='dark'] .gradient-overlay { + background: + linear-gradient(to right, rgba(0, 0, 0, 0.9) 0%, transparent 15%, transparent 85%, rgba(0, 0, 0, 0.9) 100%), + linear-gradient(to bottom, rgba(0, 0, 0, 0.82) 0%, transparent 20%, transparent 80%, rgba(0, 0, 0, 0.82) 100%) !important; +} + +html[data-theme='dark'][data-theme='dark'] .hero-logo-light { + display: none !important; +} + +html[data-theme='dark'][data-theme='dark'] .hero-logo-dark { + display: block !important; +} + +html[data-theme='light'] .hero-logo-dark, +html:not([data-theme='dark']) .hero-logo-dark { + display: none !important; +} + +html[data-theme='dark'][data-theme='dark'] .corner-mark.top-left-only { + border-top-color: rgba(255, 255, 255, 0.35) !important; + border-left-color: rgba(255, 255, 255, 0.35) !important; +} + @media (max-width: 768px) { .theme-toggle { width: 16px; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e840e116..2f5c1a30 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -3,7 +3,7 @@ import i18n from '../i18n' // 创建axios实例 const service = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001', + baseURL: import.meta.env.VITE_API_BASE_URL || '', timeout: 300000, // 5分钟超时(本体生成可能需要较长时间) headers: { 'Content-Type': 'application/json' diff --git a/frontend/src/api/simulation.js b/frontend/src/api/simulation.js index 464985ba..67e4bf6c 100644 --- a/frontend/src/api/simulation.js +++ b/frontend/src/api/simulation.js @@ -202,3 +202,10 @@ export const getSimulationHistory = (limit = 20) => { return service.get('/api/simulation/history', { params: { limit } }) } +/** + * 删除一个历史模拟结果 + * @param {string} simulationId + */ +export const deleteSimulation = (simulationId) => { + return service.delete(`/api/simulation/${simulationId}`) +} diff --git a/frontend/src/assets/logo/foresight_logo_dark.png b/frontend/src/assets/logo/foresight_logo_dark.png new file mode 100644 index 00000000..4e70d9c3 Binary files /dev/null and b/frontend/src/assets/logo/foresight_logo_dark.png differ diff --git a/frontend/src/components/HistoryDatabase.vue b/frontend/src/components/HistoryDatabase.vue index d6c6e9a5..214a212a 100644 --- a/frontend/src/components/HistoryDatabase.vue +++ b/frontend/src/components/HistoryDatabase.vue @@ -23,15 +23,29 @@ v-for="(project, index) in projects" :key="project.simulation_id" class="project-card" - :class="{ expanded: isExpanded, hovering: hoveringCard === index }" + :class="{ expanded: isExpanded, hovering: hoveringCard === index, 'cached-replay-card': project.is_cached_case }" :style="getCardStyle(index)" @mouseenter="hoveringCard = index" @mouseleave="hoveringCard = null" @click="navigateToProject(project)" > + +
- {{ formatSimulationId(project.simulation_id) }} +
+ {{ formatSimulationId(project.simulation_id) }} + {{ project.cached_label || $t('history.cachedReplay') }} +
+
@@ -152,8 +171,17 @@ + + + + +
+
+
{{ $t('history.deleteKicker') }}
+

{{ $t('history.deleteTitle') }}

+

+ {{ $t('history.deleteDescription', { id: formatSimulationId(deleteCandidate.simulation_id) }) }} +

+
+ {{ getDeletePreview(deleteCandidate) }} +
+

{{ deleteError }}

+
+ + +
+
+
+
+
@@ -194,7 +254,7 @@ import { ref, computed, onMounted, onUnmounted, onActivated, watch, nextTick } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' -import { getSimulationHistory } from '../api/simulation' +import { deleteSimulation, getSimulationHistory } from '../api/simulation' const router = useRouter() const route = useRoute() @@ -207,6 +267,9 @@ const isExpanded = ref(false) const hoveringCard = ref(null) const historyContainer = ref(null) const selectedProject = ref(null) // 当前选中的项目(用于弹窗) +const deleteCandidate = ref(null) +const deleteLoading = ref(false) +const deleteError = ref('') let observer = null let isAnimating = false // 动画锁,防止闪烁 let expandDebounceTimer = null // 防抖定时器 @@ -241,6 +304,7 @@ const containerStyle = computed(() => { // 获取卡片样式 const getCardStyle = (index) => { const total = projects.value.length + const isCachedCase = !!projects.value[index]?.is_cached_case if (isExpanded.value) { // 展开态:网格布局 @@ -264,7 +328,7 @@ const getCardStyle = (index) => { return { transform: `translate(${x}px, ${y}px) rotate(0deg) scale(1)`, - zIndex: 100 + index, + zIndex: isCachedCase ? 1000 : 100 + index, opacity: 1, transition: transition } @@ -283,7 +347,7 @@ const getCardStyle = (index) => { return { transform: `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`, - zIndex: 10 + index, + zIndex: isCachedCase ? 1000 : 10 + index, opacity: 1, transition: transition } @@ -393,11 +457,56 @@ const truncateFilename = (filename, maxLength) => { return truncatedName + ext } +const hasReplay = (simulation) => { + return Boolean(simulation?.has_replay || simulation?.simulation_id) +} + +const canDeleteProject = (simulation) => { + return Boolean(simulation?.simulation_id && !simulation?.is_cached_case) +} + +const getDeletePreview = (simulation) => { + const title = getSimulationTitle(simulation?.simulation_requirement) + return title || formatSimulationId(simulation?.simulation_id) +} + // 打开项目详情弹窗 const navigateToProject = (simulation) => { selectedProject.value = simulation } +const openDeleteConfirm = (simulation) => { + if (!canDeleteProject(simulation)) return + deleteCandidate.value = simulation + deleteError.value = '' +} + +const closeDeleteConfirm = () => { + if (deleteLoading.value) return + deleteCandidate.value = null + deleteError.value = '' +} + +const confirmDelete = async () => { + if (!deleteCandidate.value?.simulation_id) return + + try { + deleteLoading.value = true + deleteError.value = '' + const simulationId = deleteCandidate.value.simulation_id + await deleteSimulation(simulationId) + projects.value = projects.value.filter((project) => project.simulation_id !== simulationId) + if (selectedProject.value?.simulation_id === simulationId) { + selectedProject.value = null + } + deleteCandidate.value = null + } catch (error) { + deleteError.value = error?.message || t('history.deleteFailed') + } finally { + deleteLoading.value = false + } +} + // 关闭弹窗 const closeModal = () => { selectedProject.value = null @@ -425,6 +534,17 @@ const goToSimulation = () => { } } +// 导航到真实页面回放(逐页查看已完成效果) +const goToReplay = () => { + if (selectedProject.value?.simulation_id) { + router.push({ + name: 'SimulationReplay', + params: { simulationId: selectedProject.value.simulation_id } + }) + closeModal() + } +} + // 导航到分析报告页面(Report) const goToReport = () => { if (selectedProject.value?.report_id) { @@ -687,6 +807,40 @@ onUnmounted(() => { z-index: 1000 !important; } +.card-delete { + position: absolute; + top: 7px; + right: 7px; + width: 18px; + height: 18px; + border: 1px solid rgba(17, 24, 39, 0.12); + border-radius: 50%; + background: rgba(255, 255, 255, 0.92); + color: #6B7280; + font-size: 14px; + line-height: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transform: translateY(-3px); + transition: opacity 0.18s ease, transform 0.18s ease, color 0.18s ease, border-color 0.18s ease, background 0.18s ease; + z-index: 4; +} + +.project-card:hover .card-delete, +.project-card.hovering .card-delete { + opacity: 1; + transform: translateY(0); +} + +.card-delete:hover { + border-color: rgba(185, 28, 28, 0.42); + background: #FEF2F2; + color: #B91C1C; +} + /* 卡片头部 */ .card-header { display: flex; @@ -705,6 +859,33 @@ onUnmounted(() => { font-weight: 500; } +.card-id-wrap { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.cache-badge { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border: 1px solid rgba(17, 24, 39, 0.14); + border-radius: 999px; + background: #111827; + color: #ffffff; + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0.4px; + white-space: nowrap; +} + +.cached-replay-card { + border-color: rgba(17, 24, 39, 0.32); + box-shadow: 0 10px 24px rgba(17, 24, 39, 0.08); +} + /* 功能状态图标组 */ .card-status-icons { display: flex; @@ -726,6 +907,7 @@ onUnmounted(() => { .status-icon:nth-child(1).available { color: #3B82F6; } /* 图谱构建 - 蓝色 */ .status-icon:nth-child(2).available { color: #F59E0B; } /* 环境搭建 - 橙色 */ .status-icon:nth-child(3).available { color: #10B981; } /* 分析报告 - 绿色 */ +.status-icon:nth-child(4).available { color: #111827; } /* 页面回放 - 黑色 */ .status-icon.unavailable { color: #D1D5DB; @@ -1258,13 +1440,14 @@ onUnmounted(() => { /* 导航按钮 */ .modal-actions { display: flex; + flex-wrap: wrap; gap: 16px; padding: 20px 32px; background: #FFFFFF; } .modal-btn { - flex: 1; + flex: 1 1 112px; display: flex; flex-direction: column; align-items: center; @@ -1317,6 +1500,7 @@ onUnmounted(() => { .modal-btn.btn-project .btn-icon { color: #3B82F6; } .modal-btn.btn-simulation .btn-icon { color: #F59E0B; } .modal-btn.btn-report .btn-icon { color: #10B981; } +.modal-btn.btn-replay .btn-icon { color: #111827; } .modal-btn:hover:not(:disabled) .btn-text { color: #111827; @@ -1339,4 +1523,170 @@ onUnmounted(() => { text-align: center; line-height: 1.5; } + +.confirm-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(17, 24, 39, 0.42); + backdrop-filter: blur(5px); +} + +.confirm-content { + width: min(420px, 100%); + padding: 26px; + border: 1px solid #E5E7EB; + border-radius: 8px; + background: #FFFFFF; + box-shadow: 0 22px 48px rgba(17, 24, 39, 0.16); +} + +.confirm-kicker { + font-family: 'JetBrains Mono', monospace; + font-size: 0.68rem; + color: #9CA3AF; + letter-spacing: 1px; + text-transform: uppercase; + margin-bottom: 10px; +} + +.confirm-title { + margin: 0 0 10px; + color: #111827; + font-size: 1.05rem; + line-height: 1.35; + letter-spacing: 0; +} + +.confirm-desc { + margin: 0; + color: #4B5563; + font-size: 0.86rem; + line-height: 1.7; +} + +.confirm-preview { + margin-top: 14px; + padding: 12px; + border: 1px solid #F3F4F6; + border-radius: 6px; + background: #F9FAFB; + color: #374151; + font-size: 0.82rem; + line-height: 1.55; +} + +.confirm-error { + margin-top: 12px; + color: #B91C1C; + font-size: 0.78rem; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 22px; +} + +.confirm-btn { + height: 36px; + padding: 0 14px; + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + font-weight: 700; + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease; +} + +.confirm-btn:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.confirm-btn-secondary { + border: 1px solid #E5E7EB; + background: #FFFFFF; + color: #4B5563; +} + +.confirm-btn-secondary:hover { + border-color: #9CA3AF; + color: #111827; +} + +.confirm-btn-danger { + border: 1px solid #B91C1C; + background: #B91C1C; + color: #FFFFFF; +} + +.confirm-btn-danger:hover:not(:disabled) { + border-color: #991B1B; + background: #991B1B; +} + +:global(html[data-theme='dark']) .cache-badge { + border-color: rgba(255, 255, 255, 0.18); + background: #f4f7fb; + color: #050505; +} + +:global(html[data-theme='dark']) .cached-replay-card { + border-color: rgba(244, 247, 251, 0.34); + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.32); +} + +:global(html[data-theme='dark']) .card-delete { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(17, 17, 17, 0.92); + color: #C8D1E2; +} + +:global(html[data-theme='dark']) .card-delete:hover { + border-color: rgba(255, 143, 143, 0.48); + background: rgba(127, 29, 29, 0.72); + color: #FFE4E6; +} + +:global(html[data-theme='dark']) .confirm-content { + border-color: rgba(255, 255, 255, 0.12); + background: #111111; + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.5); +} + +:global(html[data-theme='dark']) .confirm-title { + color: #F4F7FB; +} + +:global(html[data-theme='dark']) .confirm-desc { + color: #C8D1E2; +} + +:global(html[data-theme='dark']) .confirm-preview { + border-color: rgba(255, 255, 255, 0.1); + background: #1A1A1A; + color: #E7ECF6; +} + +:global(html[data-theme='dark']) .confirm-btn-secondary { + border-color: rgba(255, 255, 255, 0.14); + background: #111111; + color: #C8D1E2; +} + +:global(html[data-theme='dark']) .confirm-btn-secondary:hover { + border-color: rgba(255, 255, 255, 0.32); + color: #FFFFFF; +} + +:global(html[data-theme='dark']) .status-icon:nth-child(4).available, +:global(html[data-theme='dark']) .modal-btn.btn-replay .btn-icon { + color: #f4f7fb; +} diff --git a/frontend/src/components/Step1GraphBuild.vue b/frontend/src/components/Step1GraphBuild.vue index 687d1c7b..07b72f71 100644 --- a/frontend/src/components/Step1GraphBuild.vue +++ b/frontend/src/components/Step1GraphBuild.vue @@ -223,7 +223,7 @@ const handleEnterEnvSetup = async () => { const res = await createSimulation({ project_id: props.projectData.project_id, graph_id: props.projectData.graph_id, - enable_twitter: true, + enable_twitter: false, enable_reddit: true }) diff --git a/frontend/src/components/Step2EnvSetup.vue b/frontend/src/components/Step2EnvSetup.vue index 8df68caa..2c87f0a0 100644 --- a/frontend/src/components/Step2EnvSetup.vue +++ b/frontend/src/components/Step2EnvSetup.vue @@ -684,8 +684,8 @@ let lastLoggedProfileCount = 0 let lastLoggedConfigStage = '' // 模拟轮数配置 -const useCustomRounds = ref(false) // 默认使用自动配置轮数 -const customMaxRounds = ref(40) // 默认推荐40轮 +const useCustomRounds = ref(true) // 现场演示默认使用稳定小轮数 +const customMaxRounds = ref(8) // 避免 OASIS 子进程在低内存环境长时间运行 // Watch stage to update phase watch(currentStage, (newStage) => { @@ -797,8 +797,8 @@ const startPrepareSimulation = async () => { try { const res = await prepareSimulation({ simulation_id: props.simulationId, - use_llm_for_profiles: true, - parallel_profile_count: 5 + use_llm_for_profiles: false, + parallel_profile_count: 2 }) if (res.success && res.data) { diff --git a/frontend/src/components/Step3Simulation.vue b/frontend/src/components/Step3Simulation.vue index 17733247..995781c7 100644 --- a/frontend/src/components/Step3Simulation.vue +++ b/frontend/src/components/Step3Simulation.vue @@ -295,7 +295,7 @@ import { getRunStatus, getRunStatusDetail } from '../api/simulation' -import { generateReport } from '../api/report' +import { getReportBySimulation } from '../api/report' const { t } = useI18n() @@ -399,15 +399,17 @@ const doStartSimulation = async () => { simulation_id: props.simulationId, platform: 'parallel', force: true, // 强制重新开始 - enable_graph_memory_update: true // 开启动态图谱更新 + max_rounds: props.maxRounds || 8, + enable_graph_memory_update: false // 现场演示优先保证 OASIS 子进程稳定 } if (props.maxRounds) { - params.max_rounds = props.maxRounds addLog(t('log.setMaxRounds', { rounds: props.maxRounds })) + } else { + addLog(t('log.setMaxRounds', { rounds: params.max_rounds })) } - addLog(t('log.graphMemoryUpdateEnabled')) + addLog('Demo safe mode: dual-world replay, graph memory update disabled.') const res = await startSimulation(params) @@ -420,6 +422,12 @@ const doStartSimulation = async () => { phase.value = 1 runStatus.value = res.data + if (res.data.demo_safe_mode) { + addLog('Cached dual-world simulation loaded: Info Plaza + Topic Community.') + } + + await fetchRunStatusDetail() + await fetchRunStatus() startStatusPolling() startDetailPolling() @@ -465,6 +473,7 @@ const handleStopSimulation = async () => { // 轮询状态 let statusTimer = null let detailTimer = null +let autoAdvanceTimer = null const startStatusPolling = () => { statusTimer = setInterval(fetchRunStatus, 2000) @@ -483,6 +492,10 @@ const stopPolling = () => { clearInterval(detailTimer) detailTimer = null } + if (autoAdvanceTimer) { + clearTimeout(autoAdvanceTimer) + autoAdvanceTimer = null + } } // 追踪各平台的上一次轮次,用于检测变化并输出日志 @@ -527,10 +540,16 @@ const fetchRunStatus = async () => { stopPolling() emit('update-status', 'completed') - // 模拟完成后自动触发报告生成 - setTimeout(() => { + const completionDelay = data.cached_case ? 9000 : 1500 + if (data.cached_case) { + addLog(`Cached dual-world result locked: 30 agents · ${data.total_rounds || props.maxRounds || 8} rounds · ${data.total_actions_count || allActions.value.length} actions.`) + addLog('Interactive report will open after a short inspection window.') + } + + // 模拟完成后自动进入报告页;缓存案例保留现场观察时间 + autoAdvanceTimer = setTimeout(() => { handleNextStep() - }, 1500) + }, completionDelay) } } } catch (err) { @@ -658,27 +677,23 @@ const handleNextStep = async () => { } isGeneratingReport.value = true - addLog(t('log.startingReportGen')) - + addLog('Opening cached interactive HTML report; live ReportAgent generation is skipped for demo stability.') + try { - const res = await generateReport({ - simulation_id: props.simulationId, - force_regenerate: true - }) - - if (res.success && res.data) { - const reportId = res.data.report_id - addLog(t('log.reportGenTaskStarted', { reportId })) - - // 跳转到报告页面 + const reportRes = await getReportBySimulation(props.simulationId) + const reportId = reportRes?.data?.report_id + if (reportRes.success && reportId) { router.push({ name: 'Report', params: { reportId } }) - } else { - addLog(t('log.reportGenFailed', { error: res.error || t('common.unknownError') })) - isGeneratingReport.value = false + return } } catch (err) { - addLog(t('log.reportGenException', { error: err.message })) - isGeneratingReport.value = false + addLog(`Cached report lookup failed: ${err.message}`) + } + + if (props.simulationId === 'sim_nb_hnw_ai_case') { + router.push({ name: 'Report', params: { reportId: 'report_nb_hnw_ai_case' } }) + } else { + router.push({ name: 'SimulationReplay', params: { simulationId: props.simulationId }, query: { mode: 'process' } }) } } @@ -1269,4 +1284,4 @@ onUnmounted(() => { animation: spin 0.8s linear infinite; margin-right: 6px; } - \ No newline at end of file + diff --git a/frontend/src/components/Step5Interaction.vue b/frontend/src/components/Step5Interaction.vue index 9eb791a1..c9c6e735 100644 --- a/frontend/src/components/Step5Interaction.vue +++ b/frontend/src/components/Step5Interaction.vue @@ -22,7 +22,9 @@ v-for="(section, idx) in reportOutline.sections" :key="idx" class="report-section-item" + :data-section-index="idx + 1" :class="{ + 'is-citation-highlight': highlightedSectionIndex === idx + 1, 'is-active': currentSectionIndex === idx + 1, 'is-completed': isSectionCompleted(idx + 1), 'is-pending': !isSectionCompleted(idx + 1) && currentSectionIndex !== idx + 1 @@ -242,7 +244,7 @@ -
+
@@ -271,6 +273,20 @@ {{ formatTime(msg.timestamp) }}
+
+ +
@@ -453,6 +469,7 @@ const reportOutline = ref(null) const generatedSections = ref({}) const collapsedSections = ref(new Set()) const currentSectionIndex = ref(null) +const highlightedSectionIndex = ref(null) const profiles = ref([]) // Helper Methods @@ -480,6 +497,47 @@ const toggleSectionCollapse = (idx) => { collapsedSections.value = newSet } +const citationTargetSection = (citation) => { + if (!citation) return 1 + const raw = citation.section_index || citation.sectionIndex + const parsed = Number(raw) + if (Number.isFinite(parsed) && parsed > 0) return parsed + if (citation.agent_id !== undefined || citation.agentId !== undefined) return 2 + return 1 +} + +const jumpToSection = (sectionIndex) => { + const index = Number(sectionIndex) + if (!Number.isFinite(index) || index < 1) return + + const zeroIndex = index - 1 + const newSet = new Set(collapsedSections.value) + newSet.delete(zeroIndex) + collapsedSections.value = newSet + currentSectionIndex.value = index + highlightedSectionIndex.value = index + + requestAnimationFrame(() => { + const target = leftPanel.value?.querySelector(`[data-section-index="${index}"]`) + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }) + + window.setTimeout(() => { + if (highlightedSectionIndex.value === index) highlightedSectionIndex.value = null + }, 2200) +} + +const jumpToCitation = (citation) => { + jumpToSection(citationTargetSection(citation)) +} + +const handleCitationClick = (event) => { + const target = event.target?.closest?.('[data-section-index], [data-agent-id]') + if (!target) return + const sectionIndex = target.dataset.sectionIndex || (target.dataset.agentId ? 2 : 1) + jumpToSection(sectionIndex) +} + const selectChatTarget = (target) => { chatTarget.value = target if (target === 'report_agent') { @@ -560,6 +618,13 @@ const renderMarkdown = (content) => { let processedContent = content.replace(/^##\s+.+\n+/, '') let html = processedContent.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') html = html.replace(/`([^`]+)`/g, '$1') + html = html.replace(/\[\[(S|A)(\d{1,2})\]\]/g, (match, type, number) => { + const value = String(Number(number)).padStart(2, '0') + const sectionIndex = type === 'S' ? Number(number) : 2 + const agentId = type === 'A' ? Number(number) : '' + const title = type === 'S' ? `跳转到报告第 ${value} 节` : `跳转到 Agent ${value} 问答证据` + return `` + }) html = html.replace(/^#### (.+)$/gm, '
$1
') html = html.replace(/^### (.+)$/gm, '

$1

') html = html.replace(/^## (.+)$/gm, '

$1

') @@ -701,6 +766,7 @@ const sendToReportAgent = async (message) => { chatHistory.value.push({ role: 'assistant', content: res.data.response || res.data.answer || t('step5.noResponse'), + citations: res.data.citations || [], timestamp: new Date().toISOString() }) addLog(t('log.reportAgentReplied')) @@ -1089,6 +1155,13 @@ watch(() => props.simulationId, (newId) => { display: flex; flex-direction: column; gap: 12px; + border-radius: 10px; + transition: background-color 0.25s ease, box-shadow 0.25s ease; +} + +.report-section-item.is-citation-highlight { + background: rgba(16, 185, 129, 0.08); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.26), 0 12px 30px rgba(16, 185, 129, 0.12); } .section-header-row { @@ -2067,6 +2140,45 @@ watch(() => props.simulationId, (newId) => { margin: 4px 0; } +.message-citations { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.citation-chip, +.message-text :deep(.citation-chip) { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 20px; + padding: 2px 7px; + border: 1px solid #D1D5DB; + border-radius: 999px; + background: #FFFFFF; + color: #111827; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease; +} + +.message-text :deep(.citation-inline) { + margin: 0 2px; + vertical-align: 0.12em; +} + +.citation-chip:hover, +.message-text :deep(.citation-chip:hover) { + background: #ECFDF5; + border-color: #10B981; + color: #047857; + transform: translateY(-1px); +} + /* Typing Indicator */ .typing-indicator { display: flex; @@ -2581,4 +2693,23 @@ watch(() => props.simulationId, (newId) => { html[lang="en"] .report-header-block .main-title { font-size: 28px; } + +html[data-theme='dark'] .citation-chip, +html[data-theme='dark'] .message-text .citation-chip { + background: #111827; + border-color: #374151; + color: #F9FAFB; +} + +html[data-theme='dark'] .citation-chip:hover, +html[data-theme='dark'] .message-text .citation-chip:hover { + background: #064E3B; + border-color: #34D399; + color: #D1FAE5; +} + +html[data-theme='dark'] .report-section-item.is-citation-highlight { + background: rgba(16, 185, 129, 0.14); + box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.32), 0 12px 30px rgba(0, 0, 0, 0.24); +} diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index f6a2c8cd..d2e881d7 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -53,7 +53,8 @@
- + +