feat:客服agent接入nl2sql

This commit is contained in:
2026-09-13 20:48:42 +08:00
parent b133dd58c4
commit dd281b3361
11 changed files with 778 additions and 18 deletions
+139
View File
@@ -0,0 +1,139 @@
"""按 fin_product 为指定客户生成 fin_holdings 持仓数据(本地开发/演示用)。
用法:
python scripts/seed_holdings_for_customer.py --customer-id 18
python scripts/seed_holdings_for_customer.py --customer-id 18 --count 5 --force
规则:
- 只挑选 fin_product 中 status=在售 的产品,按 id 升序取前 count 只;
- 每笔持仓的买入净值 = 当前净值 × (1 - 浮动),浮动由固定 seed 生成,保证可复现;
- shares = cost_amount / 买入净值(4 位小数),current_value = shares × 当前净值;
- profit_loss / profit_ratio 由上述字段推导;
- 客户已有持仓时默认拒绝,需 --force 才会追加。
"""
from __future__ import annotations
import argparse
import asyncio
import random
import sys
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from config.database.mysql import get_session_factory
from model.fin_holdings import FinHoldings
from model.fin_product import FinProduct
from model.sys_user import SysUser
_MONEY = Decimal("0.01")
_SHARES = Decimal("0.0001")
_RATIO = Decimal("0.0001")
def _money(value: Decimal) -> Decimal:
return value.quantize(_MONEY, rounding=ROUND_HALF_UP)
async def seed(customer_id: int, *, count: int, force: bool) -> list[dict]:
session_factory = get_session_factory()
async with session_factory() as db:
user = await db.get(SysUser, customer_id)
if user is None or user.user_type != "CUSTOMER":
raise SystemExit(f"customer_id={customer_id} 不是客户用户或不存在")
existing = (
await db.execute(
select(FinHoldings).where(FinHoldings.customer_id == customer_id)
)
).scalars().all()
if existing and not force:
raise SystemExit(
f"客户 {customer_id} 已有 {len(existing)} 条持仓,如需追加请加 --force"
)
products = (
await db.execute(
select(FinProduct)
.where(FinProduct.status == "在售")
.order_by(FinProduct.id)
.limit(count)
)
).scalars().all()
if not products:
raise SystemExit("fin_product 中没有在售产品")
rng = random.Random(f"holdings-{customer_id}")
created: list[dict] = []
for product in products:
nav = product.nav or Decimal("1.000000")
cost_amount = _money(Decimal(rng.randint(5_000, 50_000)))
# 买入净值在当前净值的 88%~97% 之间浮动,形成有涨有跌的持仓
buy_nav = _money(nav * Decimal(str(round(rng.uniform(0.88, 0.97), 6))))
if buy_nav <= 0:
buy_nav = Decimal("1.0000")
shares = (cost_amount / buy_nav).quantize(_SHARES, rounding=ROUND_HALF_UP)
current_value = _money(shares * nav)
profit_loss = _money(current_value - cost_amount)
profit_ratio = (profit_loss / cost_amount).quantize(_RATIO, rounding=ROUND_HALF_UP)
db.add(
FinHoldings(
customer_id=customer_id,
product_id=product.id,
shares=shares,
cost_amount=cost_amount,
current_value=current_value,
profit_loss=profit_loss,
profit_ratio=profit_ratio,
status="持有中",
)
)
created.append(
{
"product_id": product.id,
"product_name": product.product_name,
"buy_nav": str(buy_nav),
"nav": str(nav),
"shares": str(shares),
"cost_amount": str(cost_amount),
"current_value": str(current_value),
"profit_loss": str(profit_loss),
"profit_ratio": str(profit_ratio),
}
)
await db.commit()
return created
def main() -> None:
parser = argparse.ArgumentParser(description="按 fin_product 生成客户持仓数据")
parser.add_argument("--customer-id", type=int, required=True)
parser.add_argument("--count", type=int, default=8, help="生成持仓条数(默认 8)")
parser.add_argument(
"--force", action="store_true", help="客户已有持仓时仍允许追加"
)
args = parser.parse_args()
created = asyncio.run(seed(args.customer_id, count=args.count, force=args.force))
total_cost = sum(Decimal(item["cost_amount"]) for item in created)
total_value = sum(Decimal(item["current_value"]) for item in created)
print(f"客户 {args.customer_id} 新增持仓 {len(created)} 条:")
for item in created:
print(
f" [{item['product_id']}] {item['product_name'][:24]}… "
f"买入净值={item['buy_nav']} 当前净值={item['nav']} "
f"份额={item['shares']} 成本={item['cost_amount']} "
f"市值={item['current_value']} 盈亏={item['profit_loss']} "
f"({item['profit_ratio']})"
)
print(f"合计:成本 {total_cost} 元,市值 {total_value} 元,"
f"盈亏 {total_value - total_cost} 元")
if __name__ == "__main__":
main()
+55 -5
View File
@@ -1,7 +1,18 @@
"""读取当前 MySQL 元数据并同步到 Milvus。"""
"""同步 NL2SQL 元数据到 Milvus(表名单以 model/ 目录的 ORM 为主)。
主数据源策略:
- 表名单 = model/ 下 SQLAlchemy ORM 定义的全部表(Base.metadata),
不再与数据库求交集——数据库尚未建表的 ORM 表也纳入元数据,
其表/字段信息从 ORM 定义合成,并在表说明标注"尚未建表";
- 数据库里真实存在的 ORM 表,表/列中文注释仍取自 information_schema
(列注释只存在于库中,ORM 代码里没有字段级注释);
- 名单之外(库里有、model/ 没定义)的表元数据 chunk 会被从 Milvus 删除。
"""
from __future__ import annotations
import asyncio
import importlib
import pkgutil
import sys
from pathlib import Path
@@ -14,7 +25,8 @@ from config.database import mysql
from config.database.milvus import client as milvus_client
from config.database.mysql import get_session_factory
from config.settings import settings
from nl2sql.metadata_sync import sync_metadata
from model.base import Base
from nl2sql.metadata_sync import merge_orm_metadata_rows, sync_metadata
TABLES_SQL = text(
@@ -36,6 +48,15 @@ COLUMNS_SQL = text(
)
def collect_orm_tables() -> set[str]:
"""导入 model/ 全部模块,从 Base.metadata 收集 ORM 表名。"""
import model
for module_info in pkgutil.iter_modules(model.__path__):
importlib.import_module(f"model.{module_info.name}")
return set(Base.metadata.tables.keys())
async def load_information_schema() -> tuple[list[dict], list[dict]]:
async with get_session_factory()() as session:
tables = [
@@ -49,14 +70,43 @@ async def load_information_schema() -> tuple[list[dict], list[dict]]:
return tables, columns
async def synchronize() -> int:
def _table_name(row: dict) -> str:
return str(row.get("TABLE_NAME") or row.get("table_name") or "").strip()
async def synchronize() -> tuple[int, set[str], set[str], set[str]]:
"""返回 (upserted, allowed_tables, 名单外表, 未建表的 ORM 表)。"""
orm_tables = collect_orm_tables()
try:
tables, columns = await load_information_schema()
return await sync_metadata(milvus_client(), tables, columns)
db_tables = {_table_name(row) for row in tables}
missing_tables = sorted(orm_tables - db_tables)
table_rows, column_rows = merge_orm_metadata_rows(
tables,
columns,
allowed_tables=orm_tables,
missing_table_objects=(
Base.metadata.tables[name] for name in missing_tables
),
)
upserted = await sync_metadata(
milvus_client(),
table_rows,
column_rows,
allowed_tables=orm_tables,
)
dropped = db_tables - orm_tables
return upserted, orm_tables, dropped, set(missing_tables)
finally:
await mysql.dispose()
await milvus_db.dispose()
if __name__ == "__main__":
print(f"upserted {asyncio.run(synchronize())} NL2SQL metadata chunks")
upserted, allowed, dropped, missing = asyncio.run(synchronize())
print(f"ORM 表名单(model/ 全量): {len(allowed)} 张")
print(f"其中数据库尚未建表(用 ORM 定义合成元数据): {len(missing)} 张")
if missing:
print(f" {sorted(missing)}")
print(f"已排除的非 ORM 表: {sorted(dropped) if dropped else '无'}")
print(f"upserted {upserted} NL2SQL metadata chunks")