832 lines
41 KiB
Python
832 lines
41 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""AI 分析模块:自然语言问答 → 选择系统数据源(已按规则计算好的数据)→ 返回结果
|
||
|
||
AI 不直接查询原始数据库表,而是从 services.ai_datasources 注册的数据源中选择,
|
||
保证回答与系统页面展示的数据口径完全一致。
|
||
"""
|
||
import re
|
||
import json
|
||
import requests
|
||
from flask import Blueprint, request, jsonify
|
||
from routes.auth import login_required, operation_required
|
||
from services.field_perms import field_perms_for_page, is_admin_role
|
||
from services.db import add_operation_log, query
|
||
from services.ai_datasources import (AI_DATASOURCES, AI_DATASOURCES_DESC,
|
||
datasource_meta, suggest_datasources)
|
||
from ai_config import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
|
||
|
||
ai_bp = Blueprint('ai', __name__)
|
||
|
||
# 历史记录最多保留条数
|
||
AI_HISTORY_LIMIT = 100
|
||
|
||
|
||
def _save_history(user_id, question, summary, datasource, result_type, content=None):
|
||
"""保存一条 AI 分析历史记录(按账号独立绑定,content 存完整结果 JSON 供弹窗回看)"""
|
||
try:
|
||
# 超出上限时删除最旧的
|
||
query(
|
||
"DELETE FROM ai_analysis_history WHERE user_id=%s AND id NOT IN "
|
||
"(SELECT id FROM (SELECT id FROM ai_analysis_history WHERE user_id=%s ORDER BY id DESC LIMIT %s) t)",
|
||
(user_id, user_id, AI_HISTORY_LIMIT - 1)
|
||
)
|
||
query(
|
||
"INSERT INTO ai_analysis_history (user_id, question, summary, datasource, result_type, content) VALUES (%s, %s, %s, %s, %s, %s)",
|
||
(user_id, (question or '')[:1000], (summary or '')[:2000], (datasource or '')[:64], (result_type or '')[:16], content)
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 数据源使用说明(供 AI 参考参数格式)
|
||
AI_PARAMS_GUIDE = """
|
||
数据源参数说明(params 为 JSON 对象,可选字段):
|
||
- product_code: 按产品编码/名称筛选(模糊匹配)
|
||
- brand: 按品牌筛选(product_inventory / product_sales / planning_metrics / orders_in_transit /
|
||
material_inventory / inventory_value / brand_overview 均支持)
|
||
- sort_by: 排序字段(必须是该数据源返回的字段名)
|
||
- sort_order: 升序 asc / 降序 desc(默认 desc)
|
||
- limit: 返回行数上限(默认50,最大200)
|
||
- kind: 仅用于出库统计排行数据源,brand=按品牌 / shop=按店铺 / product=按单品
|
||
- days: 出库统计排行和按日出库统计均支持,最近 N 天(如问"最近30天"传 30)
|
||
"""
|
||
|
||
|
||
def _ai_operation_perms(uid, role):
|
||
"""当前用户在 AI 分析页的有效操作权限(角色级 + 用户级覆盖);None 表示全权限。"""
|
||
if is_admin_role(role):
|
||
return None
|
||
merged = {}
|
||
for r in query("SELECT operation, permission FROM role_operation_permissions WHERE role=%s AND page='ai_analysis'",
|
||
('viewer' if role == '查看者' else role,)):
|
||
merged[r['operation']] = int(r['permission'])
|
||
for r in query("SELECT operation, permission FROM user_permissions WHERE user_id=%s AND page='ai_analysis'", (uid,)):
|
||
merged[r['operation']] = int(r['permission'])
|
||
return merged
|
||
|
||
|
||
def _can_view_datasource(uid, role, key):
|
||
"""同时检查AI页字段权限和原业务菜单查看权限,防止通过AI绕过权限。"""
|
||
ds = AI_DATASOURCES.get(key) or {}
|
||
if not ds:
|
||
return False
|
||
if not is_admin_role(role):
|
||
from routes.auth import _has_effective_page_permission
|
||
for page in ds.get('permission_pages') or []:
|
||
if not _has_effective_page_permission(uid, role, page):
|
||
return False
|
||
perm_key = ds.get('perm_key')
|
||
if not perm_key or is_admin_role(role):
|
||
return True
|
||
from services.field_perms import _load_field_config
|
||
role_perm, user_perm, _ = _load_field_config(uid, role, 'ai_analysis')
|
||
for src in (user_perm, role_perm):
|
||
entry = src.get(perm_key) or {}
|
||
if 'view' in entry:
|
||
return int(entry['view'] or 0) == 1
|
||
return False
|
||
|
||
|
||
def _can_export_ai(uid, role):
|
||
"""导出权限:显式配置优先,未配置时跟随页面查看权限"""
|
||
if is_admin_role(role):
|
||
return True
|
||
perms = _ai_operation_perms(uid, role) or {}
|
||
if 'export' in perms:
|
||
return perms['export'] == 1
|
||
return perms.get('view', 0) == 1
|
||
|
||
|
||
@ai_bp.route('/api/ai/schema')
|
||
@login_required
|
||
def ai_schema():
|
||
"""返回可用数据源清单(前端展示可问什么)"""
|
||
return jsonify({"code": 0, "data": AI_DATASOURCES_DESC})
|
||
|
||
|
||
@ai_bp.route('/api/ai/insights')
|
||
@login_required
|
||
def ai_insights():
|
||
"""P1主动洞察:扫描库存、销量、订单、供应商和BOM异常,并按岗位聚焦。"""
|
||
if not operation_required('ai_analysis', 'view'):
|
||
return jsonify({"code": 403, "msg": "权限不足,当前账号没有AI分析查看权限"})
|
||
from flask import session
|
||
from services.ai_insights import get_operational_insights, filter_insights, position_profile
|
||
|
||
uid = session.get('user_id')
|
||
role = session.get('role')
|
||
user_rows = query("SELECT position FROM dashboard_users WHERE id=%s LIMIT 1", (uid,))
|
||
position = (user_rows[0].get('position') if user_rows else '') or ''
|
||
profile = position_profile(position, role)
|
||
allowed_sources = [key for key in AI_DATASOURCES if _can_view_datasource(uid, role, key)]
|
||
force = str(request.args.get('refresh') or '').lower() in ('1', 'true', 'yes')
|
||
raw = get_operational_insights(force=force)
|
||
result = filter_insights(raw, allowed_sources, profile, limit=request.args.get('limit', 80))
|
||
|
||
severity = (request.args.get('severity') or '').strip()
|
||
kind = (request.args.get('type') or '').strip()
|
||
category = (request.args.get('category') or '').strip()
|
||
keyword = (request.args.get('keyword') or '').strip().lower()
|
||
items = result.get('items') or []
|
||
if severity:
|
||
items = [x for x in items if x.get('severity') == severity]
|
||
if kind:
|
||
items = [x for x in items if x.get('type') == kind]
|
||
if category:
|
||
items = [x for x in items if x.get('category') == category]
|
||
if keyword:
|
||
items = [x for x in items if keyword in json.dumps(x, ensure_ascii=False, default=str).lower()]
|
||
result['items'] = items
|
||
result['filtered_total'] = len(items)
|
||
return jsonify({"code": 0, "data": result})
|
||
|
||
|
||
@ai_bp.route('/api/ai/report', methods=['POST'])
|
||
@login_required
|
||
def ai_generate_report():
|
||
"""P1经营报告:基于确定性异常清单生成今日/采购/库存/供应商等报告。"""
|
||
if not operation_required('ai_analysis', 'view'):
|
||
return jsonify({"code": 403, "msg": "权限不足,当前账号没有AI分析查看权限"})
|
||
from flask import session
|
||
from services.ai_insights import get_operational_insights, filter_insights, position_profile
|
||
|
||
uid = session.get('user_id')
|
||
role = session.get('role')
|
||
body = request.get_json(silent=True) or {}
|
||
report_type = str(body.get('report_type') or 'daily').strip()
|
||
report_names = {
|
||
'daily': '今日经营风险报告',
|
||
'procurement': '采购补货建议报告',
|
||
'inventory': '库存健康报告',
|
||
'supplier': '供应商交付报告',
|
||
'sales': '销量异常报告',
|
||
}
|
||
report_name = report_names.get(report_type, report_names['daily'])
|
||
user_rows = query("SELECT position FROM dashboard_users WHERE id=%s LIMIT 1", (uid,))
|
||
position = (user_rows[0].get('position') if user_rows else '') or ''
|
||
profile = position_profile(position, role)
|
||
allowed_sources = [key for key in AI_DATASOURCES if _can_view_datasource(uid, role, key)]
|
||
result = filter_insights(get_operational_insights(), allowed_sources, profile, limit=100)
|
||
type_filters = {
|
||
'procurement': {'stockout', 'bom_shortage', 'overdue_order'},
|
||
'inventory': {'overstock', 'stockout', 'sales_decline', 'sales_growth'},
|
||
'supplier': {'supplier_risk', 'overdue_order'},
|
||
'sales': {'sales_decline', 'sales_growth', 'overstock', 'stockout'},
|
||
}
|
||
selected_types = type_filters.get(report_type)
|
||
items = [x for x in result.get('items') or [] if not selected_types or x.get('type') in selected_types]
|
||
# 控制事实数量,避免报告提示过长导致结构化输出被截断。
|
||
items = items[:30]
|
||
if not items:
|
||
return jsonify({"code": 200, "data": {
|
||
"type": "report", "title": report_name, "summary": "当前权限范围内暂无可生成报告的异常数据。",
|
||
"sections": [], "priorities": [], "follow_ups": profile.get('questions') or [],
|
||
"generated_at": result.get('generated_at'), "counts": result.get('counts') or {},
|
||
}})
|
||
|
||
facts = [{
|
||
'severity': x.get('severity'), 'category': x.get('category'), 'title': x.get('title'),
|
||
'detail': x.get('detail'), 'metrics': x.get('metrics'), 'question': x.get('question')
|
||
} for x in items]
|
||
system = (
|
||
'你是蝶雅供应链经营分析报告助手。只能依据输入的异常事实生成报告,禁止虚构数字和原因。'
|
||
'报告要简洁、可执行,并把高风险事项排在前面。只输出JSON:'
|
||
'{"title":"报告标题","summary":"管理摘要",'
|
||
'"sections":[{"title":"章节","content":"归纳说明","items":["事实"]}],'
|
||
'"priorities":[{"priority":"P0|P1|P2","action":"动作","owner":"建议责任岗位","reason":"依据"}],'
|
||
'"follow_ups":["推荐追问"]}'
|
||
)
|
||
try:
|
||
report = _call_deepseek_json([
|
||
{'role': 'system', 'content': system},
|
||
{'role': 'user', 'content': json.dumps({
|
||
'report_name': report_name, 'position': profile.get('name'),
|
||
'generated_at': result.get('generated_at'), 'facts': facts
|
||
}, ensure_ascii=False, default=str)}
|
||
], max_tokens=3200, temperature=0.1)
|
||
except Exception:
|
||
grouped = {}
|
||
for item in items:
|
||
grouped.setdefault(item.get('category') or '其他', []).append(item)
|
||
report = {
|
||
'title': report_name,
|
||
'summary': f'本次共识别{len(items)}项异常,其中高风险{sum(1 for x in items if x.get("severity")=="高")}项。',
|
||
'sections': [{'title': k, 'content': f'共{len(v)}项需要关注。',
|
||
'items': [x.get('title') for x in v[:8]]} for k, v in grouped.items()],
|
||
'priorities': [{'priority': 'P0' if x.get('severity') == '高' else 'P1',
|
||
'action': x.get('question'), 'owner': profile.get('name'),
|
||
'reason': x.get('detail')} for x in items[:10]],
|
||
'follow_ups': profile.get('questions') or [],
|
||
}
|
||
payload = {
|
||
'type': 'report', 'title': report.get('title') or report_name,
|
||
'summary': report.get('summary') or '', 'sections': report.get('sections') or [],
|
||
'priorities': report.get('priorities') or [], 'follow_ups': report.get('follow_ups') or [],
|
||
'generated_at': result.get('generated_at'), 'counts': result.get('counts') or {},
|
||
'source_items': items[:30], 'profile': profile,
|
||
}
|
||
try:
|
||
_save_history(uid, report_name, payload['summary'], 'multi', 'report',
|
||
json.dumps(payload, ensure_ascii=False, default=str)[:60000])
|
||
add_operation_log('AI经营报告', f'{report_name} | 异常数:{len(items)} | {payload["summary"][:160]}',
|
||
page='ai_analysis', event_type='ai_report', object_type='ai_report',
|
||
new_values={'report_type': report_type, 'item_count': len(items)})
|
||
except Exception:
|
||
pass
|
||
return jsonify({"code": 200, "data": payload})
|
||
|
||
|
||
@ai_bp.route('/api/ai/simulate', methods=['POST'])
|
||
@login_required
|
||
def ai_simulate():
|
||
"""P2情景模拟:销量变化、供应商延期和计划下单,不写业务数据。"""
|
||
if not operation_required('ai_analysis', 'view'):
|
||
return jsonify({"code": 403, "msg": "权限不足,当前账号没有AI分析查看权限"})
|
||
from flask import session
|
||
from services.ai_decision import simulate_product
|
||
uid = session.get('user_id')
|
||
role = session.get('role')
|
||
allowed = [key for key in AI_DATASOURCES if _can_view_datasource(uid, role, key)]
|
||
body = request.get_json(silent=True) or {}
|
||
try:
|
||
result = simulate_product(
|
||
body.get('product_code'), body.get('sales_change_pct', 0),
|
||
body.get('supplier_delay_days', 0), body.get('planned_order_qty', 0),
|
||
body.get('horizon_days', 30), allowed_sources=allowed)
|
||
except PermissionError as exc:
|
||
return jsonify({"code": 403, "msg": str(exc)})
|
||
except ValueError as exc:
|
||
return jsonify({"code": 400, "msg": str(exc)})
|
||
except Exception as exc:
|
||
return jsonify({"code": 500, "msg": f"情景模拟失败:{str(exc)[:200]}"})
|
||
try:
|
||
add_operation_log('AI情景模拟',
|
||
f"产品:{result['product_code']} | 参数:{json.dumps(result['inputs'], ensure_ascii=False)} | 结论:{result['conclusion'][:160]}",
|
||
page='ai_analysis', event_type='ai_simulation', object_type='product',
|
||
object_id=result['product_code'], new_values=result['inputs'])
|
||
except Exception:
|
||
pass
|
||
return jsonify({"code": 200, "data": result})
|
||
|
||
|
||
@ai_bp.route('/api/ai/forecast')
|
||
@login_required
|
||
def ai_forecast():
|
||
"""P2未来缺货风险预测。"""
|
||
if not operation_required('ai_analysis', 'view'):
|
||
return jsonify({"code": 403, "msg": "权限不足,当前账号没有AI分析查看权限"})
|
||
from flask import session
|
||
from services.ai_decision import forecast_supply_risks
|
||
uid = session.get('user_id')
|
||
role = session.get('role')
|
||
allowed = [key for key in AI_DATASOURCES if _can_view_datasource(uid, role, key)]
|
||
try:
|
||
result = forecast_supply_risks(request.args.get('days', 30), request.args.get('limit', 100),
|
||
allowed_sources=allowed)
|
||
except PermissionError as exc:
|
||
return jsonify({"code": 403, "msg": str(exc)})
|
||
except Exception as exc:
|
||
return jsonify({"code": 500, "msg": f"风险预测失败:{str(exc)[:200]}"})
|
||
return jsonify({"code": 0, "data": result})
|
||
|
||
|
||
@ai_bp.route('/api/ai/action-drafts')
|
||
@login_required
|
||
def ai_action_drafts():
|
||
"""P2行动草稿:补货复核、订单催办和BOM补料,全部需人工确认。"""
|
||
if not operation_required('ai_analysis', 'view'):
|
||
return jsonify({"code": 403, "msg": "权限不足,当前账号没有AI分析查看权限"})
|
||
from flask import session
|
||
from services.ai_decision import build_action_drafts
|
||
from services.ai_insights import position_profile
|
||
uid = session.get('user_id')
|
||
role = session.get('role')
|
||
user_rows = query("SELECT position FROM dashboard_users WHERE id=%s LIMIT 1", (uid,))
|
||
position = (user_rows[0].get('position') if user_rows else '') or ''
|
||
profile = position_profile(position, role)
|
||
allowed = [key for key in AI_DATASOURCES if _can_view_datasource(uid, role, key)]
|
||
try:
|
||
result = build_action_drafts(allowed, profile, request.args.get('days', 30),
|
||
request.args.get('limit', 50))
|
||
except Exception as exc:
|
||
return jsonify({"code": 500, "msg": f"行动草稿生成失败:{str(exc)[:200]}"})
|
||
return jsonify({"code": 0, "data": result})
|
||
|
||
|
||
@ai_bp.route('/api/ai/history', methods=['GET'])
|
||
@login_required
|
||
def ai_history():
|
||
"""查询当前账号的 AI 分析历史记录(按时间倒序)"""
|
||
from flask import session
|
||
uid = session.get('user_id')
|
||
limit = request.args.get('limit', 50)
|
||
try:
|
||
limit = int(limit)
|
||
except (TypeError, ValueError):
|
||
limit = 50
|
||
limit = max(1, min(limit, 100))
|
||
keyword = (request.args.get('keyword') or '').strip()
|
||
ds_filter = (request.args.get('datasource') or '').strip()
|
||
where = ['user_id=%s']
|
||
args = [uid]
|
||
if keyword:
|
||
where.append('(question LIKE %s OR summary LIKE %s)')
|
||
args += ['%' + keyword + '%', '%' + keyword + '%']
|
||
if ds_filter:
|
||
where.append('datasource=%s')
|
||
args.append(ds_filter)
|
||
rows = query(
|
||
"SELECT id, question, summary, datasource, result_type, content, "
|
||
"DATE_FORMAT(created_at, '%%Y-%%m-%%d %%H:%%i:%%s') AS created_at "
|
||
f"FROM ai_analysis_history WHERE {' AND '.join(where)} ORDER BY id DESC LIMIT %s",
|
||
tuple(args + [limit])
|
||
)
|
||
for r in rows:
|
||
ds_key = r.get('datasource') or ''
|
||
_ds = AI_DATASOURCES.get(ds_key) or {}
|
||
r['datasource_name'] = ('跨模块联合分析' if ds_key == 'multi' else (_ds.get('name') or ds_key))
|
||
ds_rows = query(
|
||
"SELECT DISTINCT datasource FROM ai_analysis_history WHERE user_id=%s AND datasource IS NOT NULL AND datasource<>'' ORDER BY datasource",
|
||
(uid,)
|
||
)
|
||
datasources = [
|
||
{'key': r['datasource'], 'name': ('跨模块联合分析' if r['datasource'] == 'multi'
|
||
else ((AI_DATASOURCES.get(r['datasource']) or {}).get('name') or r['datasource']))}
|
||
for r in ds_rows
|
||
]
|
||
return jsonify({"code": 0, "data": rows, "datasources": datasources})
|
||
|
||
|
||
@ai_bp.route('/api/ai/history/<int:history_id>', methods=['DELETE'])
|
||
@login_required
|
||
def ai_history_delete(history_id):
|
||
"""删除当前账号的单条历史记录"""
|
||
from flask import session
|
||
uid = session.get('user_id')
|
||
rows = query("SELECT id FROM ai_analysis_history WHERE id=%s AND user_id=%s", (history_id, uid))
|
||
if not rows:
|
||
return jsonify({"code": 404, "msg": "记录不存在或不属于当前账号"})
|
||
query("DELETE FROM ai_analysis_history WHERE id=%s AND user_id=%s", (history_id, uid))
|
||
return jsonify({"code": 0, "msg": "已删除"})
|
||
|
||
|
||
@ai_bp.route('/api/ai/history', methods=['DELETE'])
|
||
@login_required
|
||
def ai_history_clear():
|
||
"""清空当前账号的 AI 分析历史记录"""
|
||
from flask import session
|
||
uid = session.get('user_id')
|
||
query("DELETE FROM ai_analysis_history WHERE user_id=%s", (uid,))
|
||
return jsonify({"code": 0, "msg": "已清空历史记录"})
|
||
|
||
|
||
@ai_bp.route('/api/ai/ask', methods=['POST'])
|
||
@login_required
|
||
def ai_ask():
|
||
"""P0 AI经营分析:规划多个受控数据源 → 查询 → 基于真实结果二次分析。"""
|
||
if not operation_required('ai_analysis', 'view'):
|
||
return jsonify({"code": 403, "msg": "权限不足,当前账号没有该操作权限"})
|
||
from flask import session
|
||
uid = session.get('user_id')
|
||
role = session.get('role')
|
||
data = request.get_json(silent=True) or {}
|
||
question = (data.get('question') or '').strip()
|
||
history = data.get('history') or []
|
||
if not question:
|
||
return jsonify({"code": 400, "msg": "请输入问题"})
|
||
if len(question) > 500:
|
||
return jsonify({"code": 400, "msg": "问题过长(最多500字)"})
|
||
|
||
try:
|
||
plan = _plan_brand_inventory_lookup(question) or _plan_analysis(question, history, data.get('last_result') or {})
|
||
except Exception as exc:
|
||
# 规划模型偶发格式异常时,按关键词选择最接近的数据源兜底,避免整页不可用。
|
||
suggestions = suggest_datasources(question, limit=1)
|
||
if not suggestions:
|
||
return jsonify({"code": 500, "msg": f"AI分析规划失败:{str(exc)[:240]}"})
|
||
plan = {
|
||
'intent': 'analysis', 'title': '业务数据分析',
|
||
'tasks': [{'datasource': suggestions[0]['key'], 'params': {}, 'purpose': question}],
|
||
'direct_answer': '', 'planning_warning': '已使用关键词匹配数据源进行兜底查询',
|
||
}
|
||
|
||
tasks = _normalize_tasks(plan.get('tasks'))
|
||
direct_answer = str(plan.get('direct_answer') or '').strip()
|
||
if not tasks:
|
||
summary = direct_answer or '当前受控数据源暂时无法回答这个问题。'
|
||
suggestions = suggest_datasources(question)
|
||
payload = {
|
||
'type': 'text', 'summary': summary, 'rows': [], 'columns': [],
|
||
'suggestions': suggestions, 'follow_ups': [f"查询{s['name']}" for s in suggestions],
|
||
}
|
||
_save_history(uid, question, summary, '', 'text',
|
||
json.dumps(payload, ensure_ascii=False, default=str))
|
||
return jsonify({"code": 200, "data": payload})
|
||
|
||
datasets = []
|
||
denied = []
|
||
failed = []
|
||
for task in tasks:
|
||
key = task['datasource']
|
||
ds = AI_DATASOURCES.get(key)
|
||
if not ds:
|
||
failed.append({'datasource': key, 'error': '数据源不存在'})
|
||
continue
|
||
if not _can_view_datasource(uid, role, key):
|
||
denied.append(ds.get('name') or key)
|
||
continue
|
||
try:
|
||
result = ds['handler'](task.get('params') or {}) or {}
|
||
meta = datasource_meta(key)
|
||
datasets.append({
|
||
'key': key,
|
||
'name': meta.get('name') or key,
|
||
'purpose': task.get('purpose') or '',
|
||
'params': task.get('params') or {},
|
||
'columns': result.get('columns') or [],
|
||
'rows': (result.get('rows') or [])[:200],
|
||
'total_rows': (result.get('summary') or {}).get('row_count', len(result.get('rows') or [])),
|
||
# Aggregates are computed by the data source over the full
|
||
# result, before the bounded detail rows are sent to the AI.
|
||
'summary': result.get('summary') or {},
|
||
'data_date': (result.get('summary') or {}).get('date') or meta.get('data_date') or '',
|
||
'source_page': _source_page(key),
|
||
})
|
||
except Exception as exc:
|
||
failed.append({'datasource': key, 'error': str(exc)[:180]})
|
||
|
||
if not datasets:
|
||
if denied:
|
||
msg = '你没有查看以下数据的权限:' + '、'.join(denied) + '。请联系管理员开通。'
|
||
else:
|
||
msg = '本次分析需要的数据查询失败,请稍后重试。'
|
||
if failed:
|
||
msg += ' 失败数据源:' + '、'.join(x['datasource'] for x in failed)
|
||
payload = {'type': 'text', 'summary': msg, 'rows': [], 'columns': [],
|
||
'denied_sources': denied, 'failed_sources': failed,
|
||
'can_export': _can_export_ai(uid, role)}
|
||
_save_history(uid, question, msg, ','.join(x['datasource'] for x in tasks)[:64], 'text',
|
||
json.dumps(payload, ensure_ascii=False, default=str))
|
||
return jsonify({"code": 200, "data": payload})
|
||
|
||
# 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。
|
||
analysis = _apply_authoritative_inventory_summary(None, datasets, question)
|
||
if analysis is None:
|
||
try:
|
||
analysis = _synthesize_analysis(question, plan, datasets, denied, failed)
|
||
except Exception as exc:
|
||
analysis = _fallback_analysis(question, plan, datasets)
|
||
analysis.setdefault('limitations', []).append('AI深度归纳暂时不可用,当前展示确定性查询结果。')
|
||
analysis['analysis_warning'] = str(exc)[:160]
|
||
if denied:
|
||
analysis.setdefault('limitations', []).append('无权查看以下数据:' + '、'.join(denied))
|
||
if failed:
|
||
analysis.setdefault('limitations', []).append('以下数据查询失败:' + '、'.join(x['datasource'] for x in failed))
|
||
|
||
primary = datasets[0]
|
||
chart = _validate_chart(analysis.get('chart'), datasets)
|
||
analysis['chart'] = chart
|
||
summary = str(analysis.get('summary') or plan.get('title') or '分析完成').strip()
|
||
payload = {
|
||
'type': 'analysis',
|
||
'summary': summary,
|
||
'title': analysis.get('title') or plan.get('title') or 'AI经营分析',
|
||
'findings': analysis.get('findings') or [],
|
||
'risks': analysis.get('risks') or [],
|
||
'actions': analysis.get('actions') or [],
|
||
'evidence': analysis.get('evidence') or [],
|
||
'limitations': analysis.get('limitations') or [],
|
||
'follow_ups': analysis.get('follow_ups') or [],
|
||
'confidence': analysis.get('confidence') or '中',
|
||
'chart': chart,
|
||
'datasets': datasets,
|
||
'denied_sources': denied,
|
||
'failed_sources': failed,
|
||
'planning_warning': plan.get('planning_warning') or '',
|
||
# 兼容旧版前端/追问上下文:主数据集继续平铺返回。
|
||
'rows': primary['rows'], 'columns': primary['columns'],
|
||
'datasource': ','.join(d['key'] for d in datasets),
|
||
'datasource_name': '、'.join(d['name'] for d in datasets),
|
||
'data_date': primary.get('data_date') or '',
|
||
'total_rows': primary.get('total_rows') or len(primary['rows']),
|
||
'can_export': _can_export_ai(uid, role),
|
||
}
|
||
|
||
try:
|
||
add_operation_log(
|
||
'AI经营分析',
|
||
'问题:%s | 数据源:%s | 任务数:%s | 结论:%s' % (
|
||
question, payload['datasource'], len(datasets), summary[:160]
|
||
),
|
||
page='ai_analysis', event_type='ai_analysis',
|
||
object_type='ai_analysis', new_values={
|
||
'question': question,
|
||
'tasks': [{'datasource': d['key'], 'params': d['params']} for d in datasets],
|
||
'denied_sources': denied,
|
||
'failed_sources': failed,
|
||
}
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
history_payload = dict(payload)
|
||
history_payload['datasets'] = [dict(d, rows=d['rows'][:60]) for d in datasets]
|
||
content_json = json.dumps(history_payload, ensure_ascii=False, default=str)[:60000]
|
||
except Exception:
|
||
content_json = None
|
||
history_source = datasets[0]['key'] if len(datasets) == 1 else 'multi'
|
||
_save_history(uid, question, summary, history_source, 'analysis', content_json)
|
||
return jsonify({"code": 200, "data": payload})
|
||
|
||
|
||
def _normalize_tasks(raw_tasks):
|
||
"""只接受注册数据源和对象参数;去重并限制最多6个工具,防止模型无限调用。"""
|
||
if not isinstance(raw_tasks, list):
|
||
return []
|
||
tasks = []
|
||
seen = set()
|
||
for raw in raw_tasks[:8]:
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
key = str(raw.get('datasource') or '').strip()
|
||
if not key or key not in AI_DATASOURCES or key in seen:
|
||
continue
|
||
params = raw.get('params') if isinstance(raw.get('params'), dict) else {}
|
||
# 仅保留受控参数,杜绝模型传SQL、表名或其他自由执行内容。
|
||
allowed = {'product_code', 'brand', 'sort_by', 'sort_order', 'limit', 'kind', 'days',
|
||
'supplier', 'status', 'product_type', 'planner', 'follower', 'operator',
|
||
'material_code'}
|
||
params = {k: v for k, v in params.items() if k in allowed}
|
||
try:
|
||
params['limit'] = max(1, min(int(params.get('limit') or 50), 200))
|
||
except (TypeError, ValueError):
|
||
params['limit'] = 50
|
||
tasks.append({'datasource': key, 'params': params,
|
||
'purpose': str(raw.get('purpose') or '')[:200]})
|
||
seen.add(key)
|
||
if len(tasks) >= 6:
|
||
break
|
||
return tasks
|
||
|
||
|
||
def _history_messages(history):
|
||
messages = []
|
||
if not isinstance(history, list):
|
||
return messages
|
||
for item in history[-8:]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
role = 'user' if str(item.get('role')) == 'user' else 'assistant'
|
||
content = str(item.get('content') or '').strip()[:1500]
|
||
if content:
|
||
messages.append({'role': role, 'content': content})
|
||
return messages
|
||
|
||
|
||
def _is_inventory_lookup(question):
|
||
"""仅接管库存查数;跨业务判断、历史比较仍交给原有分析流程。"""
|
||
return ('库存' in question and not re.search(
|
||
r'包材|内料|在途|订单|下单|销量|销售|动销|支撑|风险|原因|为什么|建议|对比|比较|变化|'
|
||
r'昨天|昨日|历史|上月|上周|最近|近\d+天|\d{1,4}[-/.年]\d{1,2}|'
|
||
r'最低|最少|大于|小于|高于|低于|超过|不足|零库存|无库存|有库存|库存(?:为|是|等于)\s*(?:0|零)', question))
|
||
|
||
|
||
def _plan_brand_inventory_lookup(question):
|
||
"""明确品牌的当前库存查询无需模型规划,测试环境也能准确查数。"""
|
||
if not _is_inventory_lookup(question):
|
||
return None
|
||
if re.search(r'(?:[A-Za-z0-9]+-){2,}[A-Za-z0-9]+|型号|编号|名称为|这款|单品', question):
|
||
return None
|
||
try:
|
||
brands = query("SELECT DISTINCT TRIM(brand) AS brand FROM products "
|
||
"WHERE deleted_at IS NULL AND COALESCE(TRIM(brand), '') != ''")
|
||
except Exception:
|
||
return None
|
||
matched = []
|
||
for row in brands:
|
||
brand = str(row.get('brand') or '').strip()
|
||
if brand and re.search(r'(?<![A-Za-z0-9_-])' + re.escape(brand) + r'(?![A-Za-z0-9_-])', question, re.I):
|
||
matched.append(brand)
|
||
# 重叠的品牌名优先最长完整名称;多个不同品牌的比较不擅自合并。
|
||
matched = [b for b in matched if not any(b.casefold() in other.casefold() and len(other) > len(b) for other in matched)]
|
||
if len(matched) != 1:
|
||
return None
|
||
params = {'brand': matched[0], 'limit': 200}
|
||
if re.search(r'排行|排名|最高|最多|TOP|前\s*\d+', question, re.I):
|
||
params.update(sort_by='库存金额' if re.search(r'金额|成本|价|钱', question) else '库存', sort_order='desc')
|
||
count = re.search(r'(?:TOP\s*|前\s*)(\d+)', question, re.I)
|
||
if count:
|
||
params['limit'] = max(1, min(int(count.group(1)), 200))
|
||
return {'intent': 'lookup', 'title': matched[0] + '品牌库存汇总',
|
||
'tasks': [{'datasource': 'inventory_value', 'params': params, 'purpose': question}]}
|
||
|
||
|
||
def _plan_analysis(question, history, last_result):
|
||
system = (
|
||
'你是蝶雅供应链AI经营分析助手的任务规划器。你的任务不是回答数字,而是为后端选择1到6个受控数据源。'
|
||
'禁止生成SQL,禁止编造不存在的数据源。复杂问题必须拆成多个数据任务;简单排行可只用一个。\n\n'
|
||
f'可用数据源:\n{AI_DATASOURCES_DESC}\n\n{AI_PARAMS_GUIDE}\n'
|
||
'补充参数:supplier=供应商模糊筛选,status=状态,product_type=产品类型,planner=采购计划,'
|
||
'follower=采购跟单,operator=运营,material_code=物料编码。\n'
|
||
'规划原则:\n'
|
||
'1. 问“是否需要下单/为什么建议下单/缺货风险”时,优先组合 product_profile、planning_metrics、'
|
||
'product_sales、product_inventory、orders_in_transit;涉及包材再加 bom_supply_chain。\n'
|
||
'2. 问订单延期、到货、不良品时用 order_detail;问供应商整体表现用 supplier_delivery。\n'
|
||
'3. 问产品货期、供应商、负责人或基础信息时用 product_profile。\n'
|
||
'4. 问BOM、某成品包材是否够用时用 bom_supply_chain,并传产品编码。\n'
|
||
'5. 纯口径解释可不调用数据源,在direct_answer解释;超出系统范围时tasks为空。\n'
|
||
'6. 所有具体产品问题都给每个相关任务传同一个product_code。\n'
|
||
'7. 问某品牌库存数量、库存乘单价的总成本、库存金额时,使用inventory_value或brand_overview,'
|
||
'所有相关任务传同一个brand;不得用inventory_cost_summary代替品牌筛选。'
|
||
'全量总数与总金额由后端summary计算,limit和sort_by只控制明细。\n'
|
||
'只输出JSON对象,格式:'
|
||
'{"intent":"analysis|lookup|explain|unsupported","title":"分析标题",'
|
||
'"tasks":[{"datasource":"key","params":{},"purpose":"为什么需要该数据"}],'
|
||
'"direct_answer":"无需查数时的回答"}'
|
||
)
|
||
messages = [{'role': 'system', 'content': system}]
|
||
messages.extend(_history_messages(history))
|
||
if isinstance(last_result, dict) and last_result.get('datasource'):
|
||
messages.append({'role': 'system', 'content': '上一轮数据源为%s,用户可能在追问。上一轮列:%s' % (
|
||
last_result.get('datasource'), ','.join(str(x) for x in (last_result.get('columns') or [])[:20]))})
|
||
messages.append({'role': 'user', 'content': question})
|
||
return _call_deepseek_json(messages, max_tokens=1800, temperature=0.0)
|
||
|
||
|
||
def _synthesize_analysis(question, plan, datasets, denied, failed):
|
||
packed = []
|
||
for d in datasets:
|
||
packed.append({
|
||
'key': d['key'], 'name': d['name'], 'purpose': d['purpose'],
|
||
'data_date': d['data_date'], 'total_rows': d['total_rows'],
|
||
'columns': d['columns'], 'rows': d['rows'][:30],
|
||
'summary': d.get('summary') or {},
|
||
})
|
||
system = (
|
||
'你是蝶雅供应链资深供应链经营分析师。你只能依据输入的数据集形成结论,绝对不能补造数字、日期或原因。'
|
||
'数据集中的summary.authoritative=true是后端对全量数据的确定性计算,涉及总数、总库存、总金额时必须原样引用,'
|
||
'不得用rows中的截断明细重新求和,也不得用示例数、整额占位数替代summary。rows仅用于展示明细和举例。'
|
||
'数字计算尽量直接引用数据;若数据不足以判断原因,要明确写入limitations,不得猜测。'
|
||
'建议必须能由证据支撑。风险等级只能是高/中/低。\n'
|
||
'只输出JSON对象,字段:\n'
|
||
'{"title":"标题","summary":"2到4句核心结论,必须包含实际数据中的关键对象和数字",'
|
||
'"findings":[{"title":"发现","detail":"含数字的说明","level":"高|中|低|信息"}],'
|
||
'"risks":[{"title":"风险","detail":"依据","level":"高|中|低"}],'
|
||
'"actions":[{"priority":"P0|P1|P2","action":"建议动作","reason":"依据"}],'
|
||
'"evidence":[{"label":"指标","value":"值","source":"数据源名称","date":"日期","formula":"可选公式"}],'
|
||
'"chart":{"type":"bar|line|pie|none","title":"图表标题","datasource":"数据源key",'
|
||
'"label_field":"字段","value_fields":["字段"]},'
|
||
'"confidence":"高|中|低","limitations":["限制"],"follow_ups":["推荐追问"]}'
|
||
)
|
||
user = {
|
||
'question': question,
|
||
'plan_title': plan.get('title'),
|
||
'datasets': packed,
|
||
'permission_denied_sources': denied,
|
||
'failed_sources': failed,
|
||
}
|
||
return _call_deepseek_json([
|
||
{'role': 'system', 'content': system},
|
||
{'role': 'user', 'content': json.dumps(user, ensure_ascii=False, default=str)},
|
||
], max_tokens=3000, temperature=0.1)
|
||
|
||
|
||
def _apply_authoritative_inventory_summary(analysis, datasets, question=''):
|
||
"""库存查数用确定性回复,覆盖摘要/发现/证据,避免旧模型数字残留。"""
|
||
if question and not _is_inventory_lookup(question):
|
||
return analysis
|
||
if any(d['key'] not in ('inventory_value', 'brand_overview', 'product_inventory') for d in datasets):
|
||
return analysis
|
||
authoritative = [d for d in datasets if (d.get('summary') or {}).get('authoritative')
|
||
and (d.get('summary') or {}).get('kind') in ('inventory_value', 'brand_overview')]
|
||
if not authoritative:
|
||
return analysis
|
||
statements, findings, evidence, limitations = [], [], [], []
|
||
has_unpriced = False
|
||
seen = set()
|
||
for dataset in authoritative:
|
||
totals = dataset['summary']
|
||
filt = totals.get('filter') or {}
|
||
identity = (totals.get('date'), tuple(sorted(filt.items())))
|
||
if identity in seen:
|
||
continue
|
||
seen.add(identity)
|
||
label = ' / '.join(filt.values()) or '当前筛选结果'
|
||
sku_count = int(totals.get('sku_count', totals.get('row_count', 0)))
|
||
stock_qty = int(totals.get('stock_qty') or 0)
|
||
value = float(totals.get('inventory_value') or 0)
|
||
date = totals.get('date') or ''
|
||
statement = f'基于{date}库存数据,{label}共{sku_count}个SKU,库存数量{stock_qty:,},库存金额{value:,.2f}元。'
|
||
statements.append(statement)
|
||
findings.append({'title': label + '库存汇总', 'detail': statement, 'level': '信息'})
|
||
findings.append({'title': '零库存商品', 'detail': f"上述SKU包含{int(totals.get('zero_stock_sku_count') or 0)}个零库存商品。", 'level': '信息'})
|
||
for top in (totals.get('top_items') or [])[:1]:
|
||
findings.append({'title': label + '库存金额最高商品', 'level': '信息',
|
||
'detail': f"{top.get('product_code')}({top.get('product_name') or ''}),库存数量{int(top.get('stock_qty') or 0):,},"
|
||
f"成本价{float(top.get('cost_price') or 0):g}元,库存金额{float(top.get('inventory_value') or 0):,.2f}元。"})
|
||
for name, result in [('SKU数', str(sku_count)), ('库存数量', f'{stock_qty:,}'), ('库存金额', f'{value:,.2f}元')]:
|
||
evidence.append({'label': label + name, 'value': result, 'source': dataset['name'], 'date': date,
|
||
'formula': '全部筛选后的SKU逐项计算库存×成本价并求和' if name == '库存金额' else '全量筛选结果汇总'})
|
||
unpriced = int(totals.get('unpriced_stock_sku_count') or 0)
|
||
if unpriced:
|
||
has_unpriced = True
|
||
limitations.append(f'{label}有{unpriced}个有库存的商品成本价为0或未维护,暂按0计入金额;补全成本价后金额会变化。')
|
||
limitations.append('统计最新库存日期中有记录且未删除的产品,排除包材、内料及无产品档案的编码;库存数量为各仓可用库存之和,不含在途。')
|
||
limitations.append('总数包含零库存商品,按全量结果计算;排序与明细条数限制仅影响表格展示。')
|
||
if any(d['key'] == 'product_inventory' for d in datasets):
|
||
limitations.append('产品库存视图的可用库存另有加工厂仓及指定商品仓库排除规则,不可与库存金额口径混加。')
|
||
return {'title': '库存数量与金额核算', 'summary': ' '.join(statements),
|
||
'findings': findings, 'evidence': evidence, 'risks': [], 'actions': [],
|
||
'limitations': limitations, 'confidence': '中' if has_unpriced else '高',
|
||
'follow_ups': [], 'chart': {'type': 'none'}}
|
||
|
||
|
||
def _fallback_analysis(question, plan, datasets):
|
||
primary = datasets[0]
|
||
rows = primary.get('rows') or []
|
||
summary = '%s已完成,共查询%s个数据源。' % (plan.get('title') or '业务分析', len(datasets))
|
||
findings = []
|
||
if rows:
|
||
first = rows[0]
|
||
bits = []
|
||
for key in primary.get('columns') or []:
|
||
value = first.get(key)
|
||
if value not in (None, ''):
|
||
bits.append('%s=%s' % (key, value))
|
||
if len(bits) >= 4:
|
||
break
|
||
if bits:
|
||
summary += ' 首条结果:' + ','.join(bits) + '。'
|
||
findings.append({'title': primary['name'], 'detail': '返回%s行数据。' % primary['total_rows'], 'level': '信息'})
|
||
return {
|
||
'title': plan.get('title') or 'AI经营分析', 'summary': summary,
|
||
'findings': findings, 'risks': [], 'actions': [], 'evidence': [],
|
||
'confidence': '中', 'limitations': [], 'follow_ups': [], 'chart': {'type': 'none'},
|
||
}
|
||
|
||
|
||
def _validate_chart(chart, datasets):
|
||
if not isinstance(chart, dict) or chart.get('type') not in ('bar', 'line', 'pie'):
|
||
return {'type': 'none'}
|
||
key = str(chart.get('datasource') or '')
|
||
ds = next((d for d in datasets if d['key'] == key), None)
|
||
if not ds:
|
||
return {'type': 'none'}
|
||
label = str(chart.get('label_field') or '')
|
||
values = [str(x) for x in (chart.get('value_fields') or [])[:3]]
|
||
if label not in ds['columns'] or not values or any(v not in ds['columns'] for v in values):
|
||
return {'type': 'none'}
|
||
return {'type': chart['type'], 'title': str(chart.get('title') or '')[:120],
|
||
'datasource': key, 'label_field': label, 'value_fields': values}
|
||
|
||
|
||
def _source_page(key):
|
||
pages = {
|
||
'inventory_cost_summary': '/', 'product_inventory': '/inventory/finished',
|
||
'material_inventory': '/inventory/material', 'product_sales': '/outbound',
|
||
'outbound_stats': '/outbound', 'outbound_daily': '/outbound',
|
||
'planning_metrics': '/planning', 'orders_in_transit': '/planning',
|
||
'inventory_value': '/dashboard', 'brand_overview': '/dashboard',
|
||
'product_profile': '/products', 'bom_supply_chain': '/bom',
|
||
'order_detail': '/orders/domestic', 'supplier_delivery': '/suppliers',
|
||
}
|
||
return pages.get(key, '')
|
||
|
||
|
||
def _call_deepseek_json(messages, max_tokens=2000, temperature=0.1):
|
||
"""使用JSON模式调用DeepSeek;兼容模型偶发markdown包裹。"""
|
||
content = _call_deepseek(messages, max_tokens=max_tokens, temperature=temperature, json_mode=True)
|
||
try:
|
||
parsed = json.loads(content)
|
||
except (TypeError, ValueError):
|
||
match = re.search(r'\{.*\}', content or '', re.DOTALL)
|
||
if not match:
|
||
raise RuntimeError('AI返回的不是有效JSON')
|
||
parsed = json.loads(match.group(0))
|
||
if not isinstance(parsed, dict):
|
||
raise RuntimeError('AI返回JSON顶层必须是对象')
|
||
return parsed
|
||
|
||
|
||
def _call_deepseek(messages, max_tokens=2000, temperature=0.1, json_mode=False):
|
||
"""调用DeepSeek Chat API。P0使用JSON模式约束规划与分析输出。"""
|
||
url = f"{DEEPSEEK_BASE_URL}/chat/completions"
|
||
headers = {
|
||
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
payload = {
|
||
"model": DEEPSEEK_MODEL,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
"temperature": temperature,
|
||
"stream": False
|
||
}
|
||
if json_mode:
|
||
payload['response_format'] = {'type': 'json_object'}
|
||
resp = requests.post(url, headers=headers, json=payload, timeout=120)
|
||
if resp.status_code != 200:
|
||
raise RuntimeError(f"DeepSeek API错误 {resp.status_code}: {resp.text[:300]}")
|
||
data = resp.json()
|
||
return data["choices"][0]["message"]["content"]
|