"""数据分析 Agent 路由(D-01~D-12 / N-03/07/08)。""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query from app.api.analyst_auth_adapter import ( AnalystAuthContext, AnalystAuthError, analyst_auth_from_deps, assert_analyst_query_access, ) from app.api.deps import AuthContext, get_platform_auth_context from app.model.analyst_schemas import ( AnalystResponse, AssetCreateRequest, AnalyzeRequest, ChatRequest, DictAmbiguityCheckRequest, EscalateRequest, InterpretRequest, ) from app.service.analyst_agent import AnalystAgent from app.service.metric_ambiguity import detect_dict_ambiguity from app.utils.trace import current_trace router = APIRouter(prefix="/api/analyst", tags=["analyst"]) _agent: AnalystAgent | None = None def get_agent() -> AnalystAgent: global _agent if _agent is None: _agent = AnalystAgent() return _agent def _analyst_ctx(auth: AuthContext = Depends(get_platform_auth_context)) -> AnalystAuthContext: return analyst_auth_from_deps(auth, trace_id=current_trace() or "") @router.post("/chat", response_model=AnalystResponse) def chat( req: ChatRequest, auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ) -> AnalystResponse: return agent.run( req.question, auth, req.session_id, req.trace_id, interpret=req.interpret, ) @router.post("/interpret", response_model=AnalystResponse) def interpret( req: InterpretRequest, auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ) -> AnalystResponse: """按需解读:仅接受最近一次问数快照 + 原问题(看图说话)。""" return agent.interpret(req, auth) @router.post("/analyze", response_model=AnalystResponse) def analyze( req: AnalyzeRequest, auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ) -> AnalystResponse: """分析助手:快照 + 提示词 → 文字解读与/或 ChartSpec(不重新查库)。""" return agent.analyze(req, auth) @router.get("/query/{trace_id}/sample", response_model=AnalystResponse) def query_sample( trace_id: str, limit: int = Query(default=5, ge=1, le=20), auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ) -> AnalystResponse: """聚合结果抽样明细(N-03)。""" return agent.sample_by_trace(trace_id, auth, limit) @router.post("/escalate") def escalate( req: EscalateRequest, auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """问数失败/超时一键转人工(N-07)。""" return agent.escalate(req.trace_id, req.question, req.reason, auth) @router.get("/template-prompts") def template_prompts( auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """已发布模板收录问句(问数页推荐/标星)。""" try: assert_analyst_query_access(auth) except AnalystAuthError as exc: raise HTTPException(status_code=403, detail=exc.message) from exc prompts = agent.templates.list_published_prompts(auth.roles) return {"prompts": prompts} @router.get("/dashboard") def dashboard( auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """智能看数板(D-12)后端:按角色返回卡片。""" try: domain = assert_analyst_query_access(auth) except AnalystAuthError as exc: raise HTTPException(status_code=403, detail=exc.message) from exc role = next( (r for r in auth.roles if r in ("analyst", "advisor", "risk_officer", "ops", "customer")), "analyst", ) cards = { "analyst": ["客户总数", "总持仓规模", "今日交易笔数与金额", "待处理预警数", "口径字典资产数"], "advisor": ["名下客户数", "名下资产规模", "盈亏分布", "风险等级分布"], "risk_officer": ["待处理预警数", "预警按类型分布", "近7天新增趋势"], "customer": ["我的持仓规模", "近30日交易笔数", "风险等级", "盈亏概览"], "ops": ["近30天申购金额", "近30天赎回金额", "各产品类型规模TOP"], }.get(role, []) metrics: dict = {} try: if role == "analyst": res = agent.repo.execute_readonly( "SELECT (SELECT COUNT(*) FROM core_customer) AS customers, " "(SELECT COALESCE(SUM(market_value),0) FROM core_holding) AS holdings, " "(SELECT COUNT(*) FROM jinrong_agent.risk_alert WHERE status='pending_review') AS pending, " "(SELECT COUNT(*) FROM jinrong_agent.analytics_metric_dict WHERE status='published') AS dict_count" ) if res["rows"]: metrics = dict(zip(res["columns"], res["rows"][0])) elif role == "customer" and auth.customer_id: cid = auth.customer_id res = agent.repo.execute_readonly( f"SELECT (SELECT COALESCE(SUM(market_value),0) FROM core_holding WHERE customer_id='{cid}') AS holdings, " f"(SELECT COUNT(*) FROM core_trade WHERE customer_id='{cid}' AND trade_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)) AS trades_30d" ) if res["rows"]: metrics = dict(zip(res["columns"], res["rows"][0])) elif role == "advisor" and auth.subject_id: scope = agent.repo.resolve_advisor_scope(auth.subject_id) if scope: ids = ",".join(f"'{x}'" for x in scope) res = agent.repo.execute_readonly( f"SELECT COUNT(DISTINCT customer_id) AS clients, " f"COALESCE(SUM(market_value),0) AS aum FROM core_holding WHERE customer_id IN ({ids})" ) if res["rows"]: metrics = dict(zip(res["columns"], res["rows"][0])) else: metrics = {"clients": 0, "aum": 0} elif role == "risk_officer": res = agent.repo.execute_readonly( "SELECT COUNT(*) AS pending FROM jinrong_agent.risk_alert WHERE status='pending_review'" ) if res["rows"]: metrics = dict(zip(res["columns"], res["rows"][0])) except Exception: # noqa: BLE001 pass return {"role": role, "domain": domain, "cards": cards, "metrics": metrics} @router.post("/assets") def create_asset( req: AssetCreateRequest, auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """沉淀资产(D-11):仅分析师可写。""" if "analyst" not in auth.roles: raise HTTPException(status_code=403, detail="仅分析师可沉淀资产") if req.kind not in ("dict", "few_shot", "template"): raise HTTPException(status_code=400, detail="kind 必须是 dict/few_shot/template") payload = dict(req.payload) if req.publish: payload["status"] = "published" asset_id = agent.repo.insert_asset(req.kind, payload, auth.subject_id) if req.publish: try: agent.reload_published_assets() except Exception: pass return {"ok": True, "id": asset_id, "kind": req.kind, "published": req.publish} @router.get("/assets") def list_assets( kind: str | None = Query(default=None, description="dict / few_shot / template"), limit: int = Query(default=50, ge=1, le=100), auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """我的沉淀资产列表(D-11)。""" if "analyst" not in auth.roles: raise HTTPException(status_code=403, detail="仅分析师可查看沉淀资产") if kind and kind not in ("dict", "few_shot", "template"): raise HTTPException(status_code=400, detail="kind 必须是 dict/few_shot/template") items = agent.repo.list_assets(created_by=auth.subject_id, kind=kind, limit=limit) return {"items": items} @router.post("/assets/{kind}/{asset_id}/publish") def publish_asset( kind: str, asset_id: int, auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """draft → published 并热加载(网页发布不重启进程)。""" if "analyst" not in auth.roles: raise HTTPException(status_code=403, detail="仅分析师可发布资产") if kind not in ("dict", "few_shot", "template"): raise HTTPException(status_code=400, detail="kind 必须是 dict/few_shot/template") ok = agent.repo.publish_asset(kind, asset_id, auth.subject_id) if not ok: raise HTTPException(status_code=404, detail="资产不存在或已发布") try: agent.reload_published_assets() except Exception: pass return {"ok": True, "kind": kind, "id": asset_id} @router.post("/dict/ambiguity-check") def dict_ambiguity_check( req: DictAmbiguityCheckRequest, auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """沉淀口径前:扫描歧义 + 可选 LLM 建议(D-11)。""" if "analyst" not in auth.roles: raise HTTPException(status_code=403, detail="仅分析师可检测口径歧义") result = detect_dict_ambiguity( metric_key=req.metric_key, metric_name=req.metric_name, definition=req.definition, aliases=req.aliases, repo=agent.repo, base_registry=agent.registry, llm=agent.llm, ) return result @router.get("/ops/metrics") def ops_metrics( auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """运营指标(N-08,P1 最小版)。""" if "analyst" not in auth.roles: raise HTTPException(status_code=403, detail="仅分析师可查看运营指标") res = agent.repo.execute_readonly( "SELECT COUNT(*) AS total, " "SUM(exec_status='blocked') AS blocked " "FROM jinrong_agent.analytics_query_log" ) return res["rows"][0] if res["rows"] else {"total": 0, "blocked": 0}