Develop feature customer #23
@@ -24,12 +24,43 @@ from repositories.fin_holdings import FinHoldingsRepo
|
||||
from repositories.fin_product import FinProductRepo
|
||||
|
||||
|
||||
def enrich_customer_rows(
|
||||
rows: list[dict[str, Any]],
|
||||
columns: list[str],
|
||||
name_by_id: dict[int, str],
|
||||
) -> dict[str, Any]:
|
||||
"""为查询结果补充客户姓名,并生成可直接展示的姓名摘要。"""
|
||||
id_keys = ("customer_id", "客户ID", "客户编号", "客户_id")
|
||||
enriched = [dict(row) for row in rows]
|
||||
names: list[str] = []
|
||||
for row in enriched:
|
||||
customer_id = next((row.get(key) for key in id_keys if row.get(key) is not None), None)
|
||||
try:
|
||||
customer_id = int(customer_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
name = name_by_id.get(customer_id)
|
||||
if name:
|
||||
row["客户姓名"] = name
|
||||
names.append(f"客户{customer_id}({name})")
|
||||
output_columns = list(columns)
|
||||
if any("客户姓名" in row for row in enriched) and "客户姓名" not in output_columns:
|
||||
insert_at = next(
|
||||
(index + 1 for index, column in enumerate(output_columns) if column in id_keys),
|
||||
len(output_columns),
|
||||
)
|
||||
output_columns.insert(insert_at, "客户姓名")
|
||||
return {"rows": enriched, "columns": output_columns, "name_summary": "、".join(dict.fromkeys(names))}
|
||||
|
||||
|
||||
def _is_current_holdings_query(question: str) -> bool:
|
||||
text = "".join((question or "").split())
|
||||
return any(term in text for term in ("当前持仓", "目前持仓", "现有持仓", "持仓明细", "持仓情况"))
|
||||
|
||||
|
||||
async def _query_current_holdings(db, *, customer_id: int, trace_id: str) -> dict[str, Any]:
|
||||
async def _query_current_holdings(
|
||||
db, *, customer_id: int, trace_id: str, customer_name: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
holdings = await FinHoldingsRepo(db).list_by_customer(customer_id, status="持有中")
|
||||
product_repo = FinProductRepo(db)
|
||||
rows: list[dict[str, Any]] = []
|
||||
@@ -60,7 +91,8 @@ async def _query_current_holdings(db, *, customer_id: int, trace_id: str) -> dic
|
||||
"truncated": False,
|
||||
"summary": f"当前持仓共 {len(rows)} 条记录。",
|
||||
"answer": (
|
||||
f"当前持有 {len(rows)} 只基金,总市值约 {total_value:.2f} 元。"
|
||||
(f"客户{customer_id}({customer_name})" if customer_name else f"客户{customer_id}")
|
||||
+ f"当前持有 {len(rows)} 只基金,总市值约 {total_value:.2f} 元。"
|
||||
+ (f"包括:{'、'.join(names[:6])}。" if names else "")
|
||||
),
|
||||
"sql": None,
|
||||
@@ -108,11 +140,25 @@ async def execute_advisor_data_query(
|
||||
await ensure_customer_access(db, advisor_id=advisor_id, customer_id=customer_id)
|
||||
customer_ids = [customer_id]
|
||||
|
||||
name_by_id: dict[int, str] = {}
|
||||
try:
|
||||
relation_rows = await CustomerRelationRepo(db).list_customer_rows(
|
||||
advisor_id=advisor_id, limit=max(len(customer_ids), 100)
|
||||
)
|
||||
name_by_id = {
|
||||
int(account.id): account.real_name
|
||||
for _relation, account, _profile in relation_rows
|
||||
if account.real_name
|
||||
}
|
||||
except Exception: # noqa: BLE001 姓名增强失败不阻断数据查询
|
||||
pass
|
||||
|
||||
if scope == "customer" and _is_current_holdings_query(question):
|
||||
return await _query_current_holdings(
|
||||
db,
|
||||
customer_id=customer_id,
|
||||
trace_id=trace_id,
|
||||
customer_name=name_by_id.get(customer_id),
|
||||
)
|
||||
permission = await load_query_permission(db, advisor_id)
|
||||
if not permission.get("can_query", False):
|
||||
@@ -168,6 +214,12 @@ async def execute_advisor_data_query(
|
||||
except (EmbeddingError, LLMFailError) as exc:
|
||||
raise QueryServiceError("投顾 Agent 依赖服务不可用,请检查 LLM/Embedding 服务连接") from exc
|
||||
payload = asdict(result)
|
||||
enriched = enrich_customer_rows(payload.get("rows", []), payload.get("columns", []), name_by_id)
|
||||
payload["rows"] = enriched["rows"]
|
||||
payload["columns"] = enriched["columns"]
|
||||
if enriched["name_summary"]:
|
||||
existing_answer = payload.get("answer") or payload.get("summary") or ""
|
||||
payload["answer"] = f"{existing_answer.rstrip('。')}。客户姓名:{enriched['name_summary']}。"
|
||||
payload["sql"] = None
|
||||
payload["customer_id"] = customer_id
|
||||
return payload
|
||||
|
||||
@@ -71,6 +71,7 @@ async def generate_recommendation_draft(
|
||||
memories: list[dict] | None = None,
|
||||
llm_client=None,
|
||||
llm_timeout: float = 5.0,
|
||||
customer_context: dict | None = None,
|
||||
):
|
||||
draft_data = build_recommendation_draft(
|
||||
customer_id=customer_id,
|
||||
@@ -87,7 +88,10 @@ async def generate_recommendation_draft(
|
||||
user_prompt=(
|
||||
"请根据以下候选基金和客户记忆生成简洁推荐说明:"
|
||||
+ json.dumps(
|
||||
{"candidates": candidates, "memories": memories or []},
|
||||
{
|
||||
"candidates": candidates,
|
||||
"customer_context": customer_context or {},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
),
|
||||
|
||||
+124
-83
@@ -56,7 +56,9 @@ from service.advisor_agent.context import (
|
||||
load_rebalance_context,
|
||||
load_recommendation_context,
|
||||
)
|
||||
from service.advisor_agent.customer_context import load_customer_context
|
||||
from service.event_publisher import publish_event
|
||||
from nl2sql.session_context import SessionContextStore, build_conversation_context
|
||||
from schemas.advisor_agent import (
|
||||
AdvisorDraftOperateReq,
|
||||
AdvisorDraftSaveReq,
|
||||
@@ -110,6 +112,16 @@ def _advisor_runtime(request: Request):
|
||||
return getattr(getattr(app, "state", None), "advisor_agent_runtime", None)
|
||||
|
||||
|
||||
@router.get("/session/{session_id}/history")
|
||||
async def advisor_session_history(
|
||||
session_id: str,
|
||||
user: SysUser = Depends(audited_advisor),
|
||||
):
|
||||
"""读取当前投顾自己的短期 Agent 会话记录。"""
|
||||
messages = await SessionContextStore(redis_db.client()).load(user.id, session_id)
|
||||
return agent_success(messages, trace_id=new_request_id())
|
||||
|
||||
|
||||
def _infer_chat_intent(query: str) -> str | None:
|
||||
"""从自然语言问题推断投顾意图;无法确定时保留通用问答。"""
|
||||
if any(word in query for word in ("调仓", "再平衡", "组合偏离")):
|
||||
@@ -242,7 +254,8 @@ async def chat_stream(
|
||||
explicit_intent=None,
|
||||
)
|
||||
inferred_intent = classification.intent
|
||||
customer_id = None
|
||||
scope = chat_request.scope
|
||||
customer_id = chat_request.customer_id
|
||||
if not chat_request.query:
|
||||
code = ERR_CODE_FORBIDDEN_CUSTOMER
|
||||
message = "对话请求缺少有效参数"
|
||||
@@ -259,60 +272,30 @@ async def chat_stream(
|
||||
|
||||
payload = None
|
||||
|
||||
# 单客户范围且未传编号时,兼容从问题中解析客户;投顾范围查询不解析客户。
|
||||
if inferred_intent in {
|
||||
AGENT_INTENT_RECOMMEND,
|
||||
AGENT_INTENT_REBALANCE,
|
||||
AGENT_INTENT_FUND_ANALYSIS,
|
||||
AGENT_INTENT_DIALOGUE_SCRIPT,
|
||||
AGENT_INTENT_DATA_QUERY,
|
||||
}:
|
||||
customer_id, resolve_error = await _resolve_customer_from_query(
|
||||
# 客户 ID 是可选上下文;未选择客户时尝试从自然语言识别姓名,失败不阻断 Agent 判断。
|
||||
if customer_id is not None:
|
||||
await ensure_customer_access(db, advisor_id=user.id, customer_id=int(customer_id))
|
||||
else:
|
||||
resolved_customer_id, _resolve_error = await _resolve_customer_from_query(
|
||||
db, advisor_id=user.id, query=chat_request.query
|
||||
)
|
||||
if resolve_error:
|
||||
payload = agent_failure(ERR_CODE_FORBIDDEN_CUSTOMER, resolve_error, trace_id=trace_id)
|
||||
async def resolve_error_events():
|
||||
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_ERROR, **payload}, ensure_ascii=False)}\n\n"
|
||||
return StreamingResponse(resolve_error_events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id})
|
||||
if resolved_customer_id is not None:
|
||||
customer_id = resolved_customer_id
|
||||
scope = "customer"
|
||||
|
||||
# 不带客户编号时只提供通用基金问答,不读取客户画像,也不生成个性化草稿。
|
||||
if False:
|
||||
payload = agent_failure(
|
||||
ERR_CODE_FORBIDDEN_CUSTOMER,
|
||||
"投顾范围查询仅支持客户数据查询",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
elif False:
|
||||
payload = agent_failure(
|
||||
ERR_CODE_FORBIDDEN_CUSTOMER,
|
||||
"投顾范围查询不能指定单个客户",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
elif customer_id is None:
|
||||
if inferred_intent in {
|
||||
AGENT_INTENT_RECOMMEND,
|
||||
AGENT_INTENT_REBALANCE,
|
||||
AGENT_INTENT_FUND_ANALYSIS,
|
||||
AGENT_INTENT_DIALOGUE_SCRIPT,
|
||||
AGENT_INTENT_DATA_QUERY,
|
||||
}:
|
||||
if payload is None:
|
||||
payload = agent_failure(
|
||||
ERR_CODE_FORBIDDEN_CUSTOMER,
|
||||
"个性化投顾分析需要在问题中明确客户编号或姓名",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
if customer_id is None:
|
||||
if inferred_intent == AGENT_INTENT_DATA_QUERY:
|
||||
scope = "advisor"
|
||||
else:
|
||||
llm_client = getattr(runtime, "llm_client", None)
|
||||
if inferred_intent == AGENT_INTENT_CASUAL_CHAT:
|
||||
answer = "您好,我是投顾助手,请选择客户后使用个性化分析。"
|
||||
answer = "您好,我是投顾助手,可以协助您查询名下客户数据、分析基金和生成投顾辅助方案。"
|
||||
elif llm_client is None:
|
||||
answer = "已收到问题。当前未配置通用投顾模型,请选择客户后使用个性化分析,或联系管理员配置 Agent 服务。"
|
||||
answer = "已收到问题。当前未配置通用投顾模型,但您可以直接查询名下客户数据。"
|
||||
else:
|
||||
answer = await generate_text(
|
||||
llm_client,
|
||||
system_prompt="你是基金投顾助手,只回答通用基金知识和产品分析问题,不读取或推断任何客户信息,不承诺收益,不代客交易。",
|
||||
system_prompt="你是基金投顾助手。投顾未指定单个客户时,你可以回答通用问题或说明需要的客户范围;不得越权读取客户数据,不承诺收益,不代客交易。",
|
||||
user_prompt=chat_request.query,
|
||||
fallback=lambda: "当前模型暂时不可用,请稍后重试。",
|
||||
timeout=5.0,
|
||||
@@ -331,7 +314,42 @@ async def chat_stream(
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Trace-Id": trace_id},
|
||||
)
|
||||
else:
|
||||
if inferred_intent == AGENT_INTENT_DATA_QUERY:
|
||||
effective_scope = "customer" if customer_id is not None else "advisor"
|
||||
try:
|
||||
context_store = SessionContextStore(redis_db.client())
|
||||
conversation_context = build_conversation_context(
|
||||
await context_store.load(user.id, chat_request.session_id)
|
||||
)
|
||||
result = await execute_advisor_data_query(
|
||||
db,
|
||||
advisor_id=user.id,
|
||||
customer_id=int(customer_id) if customer_id is not None else None,
|
||||
scope=effective_scope,
|
||||
question=chat_request.query,
|
||||
trace_id=trace_id,
|
||||
session_id=chat_request.session_id,
|
||||
conversation_context=conversation_context,
|
||||
llm_client=getattr(_advisor_runtime(request), "llm_client", None),
|
||||
)
|
||||
except QueryServiceError as exc:
|
||||
payload = agent_failure(ERR_CODE_LLM_ERROR, _data_query_error_message(exc), trace_id=trace_id)
|
||||
else:
|
||||
await context_store.append(
|
||||
user.id, chat_request.session_id, chat_request.query,
|
||||
result.get("answer") or result.get("summary") or "查询完成",
|
||||
)
|
||||
|
||||
async def events():
|
||||
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_META, 'intent': AGENT_INTENT_DATA_QUERY, 'query_id': result.get('query_id'), 'trace_id': trace_id}, ensure_ascii=False)}\n\n"
|
||||
answer = result.get("answer") or result.get("summary")
|
||||
if answer:
|
||||
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_TEXT, 'content': answer}, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_DONE, 'query_id': result.get('query_id')}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(events(), media_type="text/event-stream", headers={"X-Trace-Id": trace_id})
|
||||
|
||||
if customer_id is not None:
|
||||
relation = await ensure_customer_access(
|
||||
db, advisor_id=user.id, customer_id=int(customer_id)
|
||||
)
|
||||
@@ -345,6 +363,9 @@ async def chat_stream(
|
||||
context = await load_recommendation_context(
|
||||
db, customer_id=int(customer_id)
|
||||
)
|
||||
customer_context = await load_customer_context(
|
||||
db, customer_id=int(customer_id), memories=memories
|
||||
)
|
||||
if context is not None:
|
||||
try:
|
||||
draft = await generate_recommendation_draft(
|
||||
@@ -354,6 +375,7 @@ async def chat_stream(
|
||||
trace_id=trace_id,
|
||||
memories=memories,
|
||||
llm_client=getattr(runtime, "llm_client", None),
|
||||
customer_context=customer_context,
|
||||
**context,
|
||||
)
|
||||
except Exception:
|
||||
@@ -377,43 +399,6 @@ async def chat_stream(
|
||||
):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
events(),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Trace-Id": trace_id},
|
||||
)
|
||||
if inferred_intent == AGENT_INTENT_DATA_QUERY:
|
||||
if not chat_request.query or not chat_request.query.strip():
|
||||
payload = agent_failure(
|
||||
ERR_CODE_FORBIDDEN_CUSTOMER,
|
||||
"查询问题不能为空",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
result = await execute_advisor_data_query(
|
||||
db,
|
||||
advisor_id=user.id,
|
||||
customer_id=int(customer_id) if customer_id is not None else None,
|
||||
scope="customer",
|
||||
question=chat_request.query,
|
||||
trace_id=trace_id,
|
||||
llm_client=getattr(_advisor_runtime(request), "llm_client", None),
|
||||
)
|
||||
except QueryServiceError as exc:
|
||||
payload = agent_failure(
|
||||
ERR_CODE_LLM_ERROR,
|
||||
_data_query_error_message(exc),
|
||||
trace_id=trace_id,
|
||||
)
|
||||
else:
|
||||
async def events():
|
||||
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_META, 'intent': AGENT_INTENT_DATA_QUERY, 'query_id': result.get('query_id'), 'trace_id': trace_id}, ensure_ascii=False)}\n\n"
|
||||
answer = result.get("answer") or result.get("summary")
|
||||
if answer:
|
||||
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_TEXT, 'content': answer}, ensure_ascii=False)}\n\n"
|
||||
yield f"data: {json.dumps({'type': SSE_EVENT_TYPE_DONE, 'query_id': result.get('query_id')}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
events(),
|
||||
media_type="text/event-stream",
|
||||
@@ -432,6 +417,35 @@ async def chat_stream(
|
||||
result = build_fund_analysis(
|
||||
contexts[0]["fund"], contexts[0]["performance"]
|
||||
)
|
||||
runtime = _advisor_runtime(request)
|
||||
if getattr(runtime, "llm_client", None) is not None:
|
||||
customer_context = await load_customer_context(
|
||||
db,
|
||||
customer_id=int(customer_id),
|
||||
memories=await _recall_advisor_memories(
|
||||
request,
|
||||
customer_id=int(customer_id),
|
||||
query=chat_request.query,
|
||||
),
|
||||
)
|
||||
result["analysis_text"] = await generate_text(
|
||||
runtime.llm_client,
|
||||
system_prompt=(
|
||||
"你是合规的基金投顾分析助手。只能根据基金数据和客户上下文回答,"
|
||||
"不得承诺收益,不得代客交易;如果信息不足要明确说明。"
|
||||
),
|
||||
user_prompt=json.dumps(
|
||||
{
|
||||
"question": chat_request.query,
|
||||
"fund": contexts[0]["fund"],
|
||||
"performance": contexts[0]["performance"],
|
||||
"customer_context": customer_context,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
fallback=lambda: result["analysis_text"],
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
async def events():
|
||||
for event in (
|
||||
@@ -456,6 +470,33 @@ async def chat_stream(
|
||||
else:
|
||||
scene_type = TALK_SCENE_PORTFOLIO_DIVERGENCE
|
||||
result = build_talk_script(scene_type)
|
||||
runtime = _advisor_runtime(request)
|
||||
if getattr(runtime, "llm_client", None) is not None:
|
||||
memories = await _recall_advisor_memories(
|
||||
request,
|
||||
customer_id=int(customer_id),
|
||||
query=chat_request.query,
|
||||
)
|
||||
customer_context = await load_customer_context(
|
||||
db, customer_id=int(customer_id), memories=memories
|
||||
)
|
||||
result["content"] = await generate_text(
|
||||
runtime.llm_client,
|
||||
system_prompt=(
|
||||
"你是华夏基金合规投顾助手。请生成简洁、克制、尊重客户的沟通参考话术,"
|
||||
"结合客户上下文但不要暴露内部字段,不承诺收益,不代客交易。"
|
||||
),
|
||||
user_prompt=json.dumps(
|
||||
{
|
||||
"scene": scene_type,
|
||||
"request": chat_request.query,
|
||||
"customer_context": customer_context,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
fallback=lambda: result["content"],
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
async def events():
|
||||
for event in (
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=/backend
|
||||
BACKEND_API_ORIGIN=http://127.0.0.1:8000
|
||||
@@ -0,0 +1,95 @@
|
||||
"""NL2SQL 查询问题改写与会话上下文补全。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from tool.llm import llm
|
||||
|
||||
|
||||
logger = logging.getLogger("nl2sql.query_rewriter")
|
||||
|
||||
_SYSTEM_PROMPT = """你是基金平台 NL2SQL 的问题改写器。
|
||||
将当前问题改写为不依赖上下文、可直接交给数据库查询系统理解的完整中文问题。
|
||||
只输出改写后的问题,不要输出解释、JSON、SQL 或 Markdown。
|
||||
只补全指代和省略,不改变用户的查询目标、客户范围、时间范围或排序条件。
|
||||
不能扩大权限范围,不能臆造查询结果;上下文不足时保留原问题。
|
||||
"""
|
||||
|
||||
|
||||
def _rewrite_explicit_customer_identity(question: str) -> str | None:
|
||||
normalized = re.sub(r"\s+", "", question)
|
||||
match = re.fullmatch(
|
||||
r"(?:查询|请问|告诉我)?客户(?:编号|号|#)?(\d+)(是谁|姓名是什么|姓名|基本信息|联系方式|手机号是什么|手机号)",
|
||||
normalized,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return f"查询客户编号{match.group(1)}的姓名"
|
||||
|
||||
|
||||
def _rewrite_customer_name_follow_up(question: str, context: str) -> str | None:
|
||||
"""从最近的客户身份问答中恢复客户编号,保证追问可独立查询。"""
|
||||
normalized_question = re.sub(r"\s+", "", question)
|
||||
if not any(term in normalized_question for term in ("持仓", "资产", "基金", "产品", "收益", "盈亏", "余额", "交易")):
|
||||
return None
|
||||
|
||||
lines = context.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
customer_match = re.search(r"客户(?:编号|号|#)?(\d+)", line)
|
||||
if not customer_match or not any(term in line for term in ("是谁", "姓名")):
|
||||
continue
|
||||
for answer_line in lines[index + 1 : index + 4]:
|
||||
if not answer_line.startswith("assistant:"):
|
||||
continue
|
||||
answer = answer_line.split(":", 1)[1].strip()
|
||||
name_match = re.search(r"(?:姓名|是)\s*[::]?\s*([\u4e00-\u9fff]{2,6})", answer)
|
||||
candidates = [name_match.group(1)] if name_match else re.findall(r"[\u4e00-\u9fff]{2,6}", answer)
|
||||
for name in reversed(candidates):
|
||||
if name in normalized_question:
|
||||
return f"查询客户编号{customer_match.group(1)}的持仓和资产信息"
|
||||
return None
|
||||
|
||||
|
||||
async def rewrite_query(
|
||||
question: str,
|
||||
conversation_context: str = "",
|
||||
*,
|
||||
llm_client=llm,
|
||||
) -> str:
|
||||
"""使用有限会话上下文补全问题;改写失败时安全返回原问题。"""
|
||||
original = (question or "").strip()
|
||||
context = (conversation_context or "").strip()
|
||||
if not original:
|
||||
return original
|
||||
explicit_identity = _rewrite_explicit_customer_identity(original)
|
||||
if explicit_identity:
|
||||
return explicit_identity
|
||||
contextual_identity = _rewrite_customer_name_follow_up(original, context)
|
||||
if contextual_identity:
|
||||
return contextual_identity
|
||||
if not context or llm_client is None:
|
||||
return original
|
||||
|
||||
prompt = (
|
||||
f"会话上下文:\n{context[:4000]}\n\n"
|
||||
f"当前问题:\n{original[:2000]}\n\n"
|
||||
"改写后的独立问题:"
|
||||
)
|
||||
try:
|
||||
rewritten = await llm_client.chat(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=0,
|
||||
max_tokens=512,
|
||||
)
|
||||
except Exception: # noqa: BLE001 改写失败不阻断原查询
|
||||
logger.warning("NL2SQL 查询问题改写失败,继续使用原问题", exc_info=True)
|
||||
return original
|
||||
value = str(rewritten or "").strip()
|
||||
return value or original
|
||||
|
||||
|
||||
__all__ = ["rewrite_query"]
|
||||
@@ -36,3 +36,13 @@ class FinTransactionRepo(BaseRepository):
|
||||
FinTransaction.customer_id == customer_id
|
||||
)
|
||||
)
|
||||
|
||||
async def list_recent(self, customer_id: int, *, limit: int = 20) -> list[FinTransaction]:
|
||||
"""按客户读取最近成交记录,供投顾上下文摘要使用。"""
|
||||
stmt = (
|
||||
select(FinTransaction)
|
||||
.where(FinTransaction.customer_id == customer_id)
|
||||
.order_by(FinTransaction.create_time.desc(), FinTransaction.id.desc())
|
||||
.limit(max(1, min(limit, 100)))
|
||||
)
|
||||
return list((await self.db.scalars(stmt)).all())
|
||||
|
||||
@@ -22,6 +22,9 @@ class AdvisorRebalanceRunReq(BaseModel):
|
||||
|
||||
class AdvisorChatReq(BaseModel):
|
||||
query: str = Field(min_length=1, max_length=4000)
|
||||
session_id: str | None = Field(default=None, max_length=64)
|
||||
scope: Literal["customer", "advisor"] = "advisor"
|
||||
customer_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class AdvisorDataQueryReq(BaseModel):
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""为投顾角色初始化 NL2SQL 最小只读权限。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from config import database
|
||||
from config.database.mysql import get_session_factory
|
||||
|
||||
|
||||
ROLE = {
|
||||
"role_code": "advisor",
|
||||
"role_name": "投顾",
|
||||
"employee_role": "投顾",
|
||||
"can_query": 1,
|
||||
"max_rows": 1000,
|
||||
"daily_quota": 100,
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
TABLE_PERMISSIONS = [
|
||||
("fin_holdings", "customer_ids", "customer_id"),
|
||||
("fin_transaction", "customer_ids", "customer_id"),
|
||||
("fin_customer_profile", "customer_ids", "customer_id"),
|
||||
("customer_relation", "customer_ids", "customer_id"),
|
||||
("sys_user", "customer_ids", "id"),
|
||||
("fin_product", "none", None),
|
||||
("fund_performance", "none", None),
|
||||
]
|
||||
|
||||
COLUMNS = {
|
||||
"fin_holdings": (
|
||||
"customer_id", "product_id", "shares", "cost_amount", "current_value",
|
||||
"profit_loss", "profit_ratio", "status", "update_time",
|
||||
),
|
||||
"fin_transaction": (
|
||||
"customer_id", "product_id", "transaction_type", "amount", "shares",
|
||||
"nav", "fee", "status", "create_time",
|
||||
),
|
||||
"fin_customer_profile": (
|
||||
"customer_id", "risk_level", "risk_score", "investment_experience",
|
||||
"annual_income_range", "total_assets", "customer_level", "update_time",
|
||||
),
|
||||
"customer_relation": (
|
||||
"customer_id", "advisor_id", "assign_time", "signed_time", "status",
|
||||
),
|
||||
"sys_user": (
|
||||
"id", "real_name", "phone", "customer_level", "status",
|
||||
),
|
||||
"fin_product": (
|
||||
"id", "product_code", "product_name", "product_type", "risk_level",
|
||||
"expected_return", "nav", "nav_date", "fee_rate", "term_days",
|
||||
"fund_manager", "status",
|
||||
),
|
||||
"fund_performance": (
|
||||
"product_id", "period", "return_rate", "annual_volatility",
|
||||
"max_drawdown", "sharpe", "calc_date",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with get_session_factory()() as db:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO nl2sql_query_role
|
||||
(role_code, role_name, employee_role, can_query, max_rows, daily_quota, status)
|
||||
VALUES
|
||||
(:role_code, :role_name, :employee_role, :can_query, :max_rows, :daily_quota, :status)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
role_name = VALUES(role_name),
|
||||
can_query = VALUES(can_query),
|
||||
max_rows = VALUES(max_rows),
|
||||
daily_quota = VALUES(daily_quota),
|
||||
status = VALUES(status)
|
||||
"""
|
||||
),
|
||||
ROLE,
|
||||
)
|
||||
role_id = int(
|
||||
await db.scalar(
|
||||
text("SELECT id FROM nl2sql_query_role WHERE employee_role = :employee_role"),
|
||||
{"employee_role": ROLE["employee_role"]},
|
||||
)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
text("DELETE FROM nl2sql_role_table_permission WHERE role_id = :role_id"),
|
||||
{"role_id": role_id},
|
||||
)
|
||||
await db.execute(
|
||||
text("DELETE FROM nl2sql_role_column_permission WHERE role_id = :role_id"),
|
||||
{"role_id": role_id},
|
||||
)
|
||||
|
||||
for table_name, scope_type, scope_column in TABLE_PERMISSIONS:
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO nl2sql_role_table_permission
|
||||
(role_id, table_name, permission, row_scope_type, row_scope_column, status)
|
||||
VALUES
|
||||
(:role_id, :table_name, 'SELECT', :row_scope_type, :row_scope_column, 'active')
|
||||
"""
|
||||
),
|
||||
{
|
||||
"role_id": role_id,
|
||||
"table_name": table_name,
|
||||
"row_scope_type": scope_type,
|
||||
"row_scope_column": scope_column,
|
||||
},
|
||||
)
|
||||
|
||||
for table_name, columns in COLUMNS.items():
|
||||
for column_name in columns:
|
||||
access_mode = "mask" if table_name == "sys_user" and column_name == "phone" else "allow"
|
||||
await db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO nl2sql_role_column_permission
|
||||
(role_id, table_name, column_name, access_mode, mask_type, status)
|
||||
VALUES
|
||||
(:role_id, :table_name, :column_name, :access_mode, :mask_type, 'active')
|
||||
"""
|
||||
),
|
||||
{
|
||||
"role_id": role_id,
|
||||
"table_name": table_name,
|
||||
"column_name": column_name,
|
||||
"access_mode": access_mode,
|
||||
"mask_type": "partial" if access_mode == "mask" else None,
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
print({"role_id": role_id, "tables": len(TABLE_PERMISSIONS), "columns": sum(map(len, COLUMNS.values()))})
|
||||
await database.mysql.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -25,7 +25,7 @@ from repositories.risk_assessment import CustomerProfileRepo
|
||||
from repositories.sys_user import SysUserRepo
|
||||
from schemas.advisor import RelationReq
|
||||
from service.advisor.audit_writer import write_audit
|
||||
from service.advisor.masking import mask_name, mask_phone
|
||||
from service.advisor.masking import mask_phone
|
||||
from service.advisor.permissions import ensure_customer_owned, require_owned_relation
|
||||
from utils.exceptions import ForbiddenError, NotFoundError
|
||||
from utils.pagination import normalize_pagination, pagination_result
|
||||
@@ -72,7 +72,7 @@ async def list_customers(
|
||||
items.append(
|
||||
{
|
||||
"customer_id": rel.customer_id,
|
||||
"real_name": mask_name(account.real_name),
|
||||
"real_name": account.real_name or "",
|
||||
"phone": mask_phone(account.phone),
|
||||
"risk_level": profile.risk_level if profile else None,
|
||||
"customer_level": account.customer_level,
|
||||
@@ -107,7 +107,7 @@ async def get_customer(
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"real_name": mask_name(account.real_name),
|
||||
"real_name": account.real_name or "",
|
||||
"phone": phone,
|
||||
"risk_level": profile.risk_level if profile else None,
|
||||
"risk_score": profile.risk_score if profile else None,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""为投顾 Agent 组装经过裁剪的客户综合上下文。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from repositories.fin_customer_profile import FinCustomerProfileRepo
|
||||
from repositories.fin_holdings import FinHoldingsRepo
|
||||
from repositories.fin_transaction import FinTransactionRepo
|
||||
|
||||
MAX_HOLDINGS = 50
|
||||
MAX_TRANSACTIONS = 20
|
||||
MAX_MEMORIES = 10
|
||||
logger = logging.getLogger("advisor.customer_context")
|
||||
|
||||
|
||||
def _number(value):
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
|
||||
def _profile_payload(profile) -> dict:
|
||||
if profile is None:
|
||||
return {}
|
||||
return {
|
||||
"risk_level": getattr(profile, "risk_level", None),
|
||||
"risk_score": getattr(profile, "risk_score", None),
|
||||
"investment_experience": getattr(profile, "investment_experience", None),
|
||||
"annual_income_range": getattr(profile, "annual_income_range", None),
|
||||
"total_assets": _number(getattr(profile, "total_assets", None)),
|
||||
"asset_allocation": getattr(profile, "asset_allocation", None),
|
||||
"product_preference": getattr(profile, "product_preference", None),
|
||||
"customer_level": getattr(profile, "customer_level", None),
|
||||
}
|
||||
|
||||
|
||||
def _holding_payload(holding, product) -> dict:
|
||||
return {
|
||||
"product_id": holding.product_id,
|
||||
"product_code": getattr(product, "product_code", None),
|
||||
"product_name": getattr(product, "product_name", None),
|
||||
"shares": _number(holding.shares),
|
||||
"cost_amount": _number(holding.cost_amount),
|
||||
"current_value": _number(holding.current_value),
|
||||
"profit_loss": _number(holding.profit_loss),
|
||||
"profit_ratio": _number(holding.profit_ratio),
|
||||
"status": holding.status,
|
||||
}
|
||||
|
||||
|
||||
def _transaction_payload(transaction) -> dict:
|
||||
return {
|
||||
"product_id": transaction.product_id,
|
||||
"transaction_type": transaction.transaction_type,
|
||||
"amount": _number(transaction.amount),
|
||||
"shares": _number(transaction.shares),
|
||||
"nav": _number(transaction.nav),
|
||||
"status": transaction.status,
|
||||
"create_time": transaction.create_time.isoformat()
|
||||
if hasattr(transaction.create_time, "isoformat")
|
||||
else str(transaction.create_time),
|
||||
}
|
||||
|
||||
|
||||
def _memory_payload(memory) -> dict:
|
||||
return {
|
||||
"tag": memory.get("tag"),
|
||||
"content": str(memory.get("content") or "")[:500],
|
||||
"info_type": memory.get("info_type"),
|
||||
"memory_type": memory.get("memory_type"),
|
||||
}
|
||||
|
||||
|
||||
async def load_customer_context(
|
||||
db,
|
||||
*,
|
||||
customer_id: int,
|
||||
memories: list[dict] | None = None,
|
||||
profile_repo_cls=FinCustomerProfileRepo,
|
||||
holdings_repo_cls=FinHoldingsRepo,
|
||||
transaction_repo_cls=FinTransactionRepo,
|
||||
) -> dict:
|
||||
"""读取并裁剪客户画像、持仓、交易和长期记忆。"""
|
||||
try:
|
||||
profile = await profile_repo_cls(db).get_by_customer_id(customer_id)
|
||||
except Exception: # noqa: BLE001 个性化上下文故障不阻断主流程
|
||||
logger.warning("customer profile context unavailable", exc_info=True)
|
||||
profile = None
|
||||
try:
|
||||
holding_rows = await holdings_repo_cls(db).list_with_products(
|
||||
customer_id, include_closed=False
|
||||
)
|
||||
except Exception: # noqa: BLE001 个性化上下文故障不阻断主流程
|
||||
logger.warning("customer holdings context unavailable", exc_info=True)
|
||||
holding_rows = []
|
||||
try:
|
||||
transactions = await transaction_repo_cls(db).list_recent(
|
||||
customer_id, limit=MAX_TRANSACTIONS
|
||||
)
|
||||
except Exception: # noqa: BLE001 个性化上下文故障不阻断主流程
|
||||
logger.warning("customer transaction context unavailable", exc_info=True)
|
||||
transactions = []
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"profile": _profile_payload(profile),
|
||||
"holdings": [
|
||||
_holding_payload(holding, product)
|
||||
for holding, product in holding_rows[:MAX_HOLDINGS]
|
||||
],
|
||||
"transactions": [
|
||||
_transaction_payload(item) for item in transactions[:MAX_TRANSACTIONS]
|
||||
],
|
||||
"memories": [_memory_payload(item) for item in (memories or [])[:MAX_MEMORIES]],
|
||||
}
|
||||
Reference in New Issue
Block a user