baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""AI 经营问答(批次 B3):白名单工具取数 → LLM 组织语言。
|
||||
|
||||
安全设计(计划要求"只读、不给 LLM 写权限"):
|
||||
- 取数完全由本模块的**白名单工具**完成,LLM 只拿到聚合后的数字,不能生成查询;
|
||||
- 所有工具都是只读 ORM,且强制 `tenant=tenant`(越权查他租户不可能);
|
||||
- 无 LLM KEY 时返回结构化数据 + `llm_enhanced=False`(前端可隐藏入口)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from . import llm
|
||||
|
||||
# 意图 → 工具名(关键词命中即取该工具数据)
|
||||
INTENT_KEYWORDS = [
|
||||
("sales", ["销售", "卖了", "营收", "营业额", "出货"]),
|
||||
("receivable", ["应收", "欠款", "回款", "催收", "未收", "账款"]),
|
||||
("inventory", ["库存", "存货", "积压", "周转", "缺货"]),
|
||||
("purchase", ["采购", "进货", "买入"]),
|
||||
("profit", ["利润", "毛利", "赚", "成本", "盈利"]),
|
||||
("ranking", ["排行", "最好卖", "畅销", "大客户", "top", "TOP"]),
|
||||
]
|
||||
|
||||
|
||||
def detect_intents(question: str) -> list:
|
||||
"""按关键词命中意图;都没命中则给一个"全局概览"。"""
|
||||
q = question or ""
|
||||
hits = [name for name, kws in INTENT_KEYWORDS if any(k in q for k in kws)]
|
||||
return hits or ["overview"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 白名单工具(全部只读 + 租户隔离)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def tool_sales(tenant) -> dict:
|
||||
from apps.report.services import get_dashboard_summary
|
||||
|
||||
s = get_dashboard_summary(tenant)
|
||||
return {"本月销售额": float(s["month_sales"]["amount"]),
|
||||
"本月销售单数": s["month_sales"]["count"]}
|
||||
|
||||
|
||||
def tool_purchase(tenant) -> dict:
|
||||
from apps.report.services import get_dashboard_summary
|
||||
|
||||
s = get_dashboard_summary(tenant)
|
||||
return {"本月采购额": float(s["month_purchase"]["amount"]),
|
||||
"本月进货单数": s["month_purchase"]["count"]}
|
||||
|
||||
|
||||
def tool_inventory(tenant) -> dict:
|
||||
from apps.report.services import get_dashboard_summary
|
||||
|
||||
s = get_dashboard_summary(tenant)
|
||||
return {"库存估值": float(s["inventory"]["valuation"]),
|
||||
"SKU 数": s["inventory"]["sku_count"],
|
||||
"库存件数": float(s["inventory"]["total_quantity"])}
|
||||
|
||||
|
||||
def tool_receivable(tenant) -> dict:
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.finance.models import Receivable
|
||||
from apps.finance.services import receivable_aging
|
||||
|
||||
aging = receivable_aging(tenant)
|
||||
overdue_amount = Decimal("0")
|
||||
for b in aging["buckets"]:
|
||||
if b["bucket"] not in ("0-30",):
|
||||
overdue_amount += Decimal(str(b["amount"]))
|
||||
return {
|
||||
"应收未结合计": float(aging["total"]),
|
||||
"其中 30 天以上": float(overdue_amount),
|
||||
"账龄分桶": {b["bucket"]: float(b["amount"]) for b in aging["buckets"]},
|
||||
}
|
||||
|
||||
|
||||
def tool_profit(tenant) -> dict:
|
||||
from apps.finance import services as fin_services
|
||||
|
||||
period = fin_services.current_period(tenant)
|
||||
if period is None:
|
||||
return {"本月营业收入": 0.0, "本月营业成本": 0.0,
|
||||
"本月毛利": 0.0, "本月净利润": 0.0}
|
||||
data = fin_services.income_statement(tenant, period)
|
||||
return {
|
||||
"本月营业收入": float(data.get("total_revenue") or 0),
|
||||
"本月营业成本": float(data.get("cogs") or 0),
|
||||
"本月毛利": float(data.get("gross_profit") or 0),
|
||||
"本月净利润": float(data.get("net_profit") or 0),
|
||||
}
|
||||
|
||||
|
||||
def tool_ranking(tenant) -> dict:
|
||||
from apps.report.services import get_sales_rank
|
||||
|
||||
today = date.today()
|
||||
start = today - timedelta(days=30)
|
||||
prods = get_sales_rank(tenant, start_date=start, rank_by="product", top_n=5)
|
||||
custs = get_sales_rank(tenant, start_date=start, rank_by="customer", top_n=5)
|
||||
return {
|
||||
"近30天商品排行": [
|
||||
{"商品": p["product_name"], "销售额": float(p["total_amount"])} for p in prods
|
||||
],
|
||||
"近30天客户排行": [
|
||||
{"客户": c["customer_name"], "销售额": float(c["total_amount"])} for c in custs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tool_risk(tenant) -> dict:
|
||||
from . import risk as ai_risk
|
||||
|
||||
top = ai_risk.risk_ranking(tenant, top_n=5)
|
||||
return {
|
||||
"风险客户TOP5": [
|
||||
{"客户": r["customer_name"], "风险分": r["score"],
|
||||
"档位": r["level"], "未结": r["outstanding"]}
|
||||
for r in top
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
"sales": tool_sales,
|
||||
"purchase": tool_purchase,
|
||||
"inventory": tool_inventory,
|
||||
"receivable": tool_receivable,
|
||||
"profit": tool_profit,
|
||||
"ranking": tool_ranking,
|
||||
"risk": tool_risk,
|
||||
}
|
||||
|
||||
|
||||
def gather(tenant, question: str) -> dict:
|
||||
"""按意图取数:返回 {intents, data}。'overview' 时给全景四项。"""
|
||||
intents = detect_intents(question)
|
||||
if intents == ["overview"]:
|
||||
intents = ["sales", "receivable", "inventory", "profit"]
|
||||
data = {}
|
||||
for name in intents:
|
||||
fn = TOOLS.get(name)
|
||||
if fn is None:
|
||||
continue
|
||||
try:
|
||||
data[name] = fn(tenant)
|
||||
except Exception as exc: # 单个工具失败不影响整体问答
|
||||
data[name] = {"error": str(exc)}
|
||||
return {"intents": intents, "data": data}
|
||||
|
||||
|
||||
ASK_PROMPT = """你是经销商进销存系统的"老板参谋"。下面是系统实时取到的经营数据(JSON),
|
||||
请用中文回答老板的问题:先给结论,再给 1-2 条可执行建议。总长不超过 120 字。
|
||||
|
||||
问题:{question}
|
||||
数据:{data}
|
||||
"""
|
||||
|
||||
|
||||
def answer(tenant, question: str) -> dict:
|
||||
"""问答主入口:取数 →(有 LLM 则)组织语言。"""
|
||||
gathered = gather(tenant, question)
|
||||
payload = {
|
||||
"question": question,
|
||||
"intents": gathered["intents"],
|
||||
"data": gathered["data"],
|
||||
"llm_enhanced": False,
|
||||
"answer": None,
|
||||
}
|
||||
|
||||
if llm.available():
|
||||
import json
|
||||
|
||||
text = llm.collect_advice(
|
||||
ASK_PROMPT.format(question=question,
|
||||
data=json.dumps(gathered["data"], ensure_ascii=False))
|
||||
)
|
||||
if text:
|
||||
payload["answer"] = text
|
||||
payload["llm_enhanced"] = True
|
||||
|
||||
if not payload["answer"]:
|
||||
payload["answer"] = _stat_answer(gathered)
|
||||
return payload
|
||||
|
||||
|
||||
def _stat_answer(gathered: dict) -> str:
|
||||
"""无 LLM 时的统计口径回答(结构化文本,保证功能不空转)。"""
|
||||
parts = []
|
||||
d = gathered["data"]
|
||||
if "sales" in d and "error" not in d["sales"]:
|
||||
parts.append(f"本月销售额 ¥{d['sales'].get('本月销售额', 0):,.2f}"
|
||||
f"({d['sales'].get('本月销售单数', 0)} 单)")
|
||||
if "purchase" in d and "error" not in d["purchase"]:
|
||||
parts.append(f"本月采购额 ¥{d['purchase'].get('本月采购额', 0):,.2f}")
|
||||
if "receivable" in d and "error" not in d["receivable"]:
|
||||
parts.append(f"应收未结 ¥{d['receivable'].get('应收未结合计', 0):,.2f}"
|
||||
f",其中 30 天以上 ¥{d['receivable'].get('其中 30 天以上', 0):,.2f}")
|
||||
if "inventory" in d and "error" not in d["inventory"]:
|
||||
parts.append(f"库存估值 ¥{d['inventory'].get('库存估值', 0):,.2f}"
|
||||
f"({d['inventory'].get('SKU 数', 0)} 个 SKU)")
|
||||
if "profit" in d and "error" not in d["profit"]:
|
||||
parts.append(f"本月净利润 ¥{d['profit'].get('本月净利润', 0):,.2f}")
|
||||
if "ranking" in d and "error" not in d["ranking"]:
|
||||
top = (d["ranking"].get("近30天商品排行") or [{}])[0]
|
||||
if top.get("商品"):
|
||||
parts.append(f"近 30 天最畅销:{top['商品']}(¥{top.get('销售额', 0):,.2f})")
|
||||
if "risk" in d and "error" not in d["risk"]:
|
||||
risk_top = (d["risk"].get("风险客户TOP5") or [{}])[0]
|
||||
if risk_top.get("客户"):
|
||||
parts.append(f"应收风险最高:{risk_top['客户']}"
|
||||
f"({risk_top.get('风险分', 0):.0f} 分 / {risk_top.get('档位')})")
|
||||
return ";".join(parts) + "。" if parts else "暂无相关经营数据。"
|
||||
Reference in New Issue
Block a user