2026-10-29 10:36:31 +08:00

934 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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: 按产品编码/名称筛选(模糊匹配);operation_history 也支持 product_name 按产品名称检索旧日志文本
- 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)
- operation_history 专用参数:module=板块名称,field=修改字段,operator=操作人,start_date/end_date=YYYY-MM-DD。
- 查询修改记录时必须提供 product_code 或明确的板块/人员/日期条件;不做无条件全库扫描。
"""
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 _filter_operation_history_permissions(uid, role, rows):
"""日志查询逐条遵守原业务页面查看权限及字段查看权限。"""
if is_admin_role(role):
return rows
from routes.auth import _has_effective_page_permission
from services.field_perms import field_perms_for_page
page_fields = {
'planning': 'planning', '产品信息管理': 'products', 'products': 'products',
'计划预测': 'planning', '国内订单管理': 'order_domestic', 'order_domestic': 'order_domestic',
'海外订单管理': 'order_overseas', 'order_overseas': 'order_overseas',
'包材订单管理': 'order_packaging', 'order_packaging': 'order_packaging',
'待下单管理': 'purchase_pending', '下单备份管理': 'purchase_backup',
'物料清单管理': 'bom', 'bom': 'bom', '供应商管理': 'suppliers', 'suppliers': 'suppliers',
'现货采样申请': 'spot_application', '现货采样确认': 'spot_confirmation',
'现货销量管理': 'spot_sales',
}
page_ids = set(page_fields.values())
fields_cache = {}
filtered = []
for row in rows:
page = str(row.get('板块') or '')
page_id = page_fields.get(page, page if page in page_ids else '')
if not page_id:
row = dict(row, **{'修改内容': '该历史日志所属菜单无法确认,修改内容已隐藏'})
filtered.append(row)
continue
if page_id not in fields_cache:
fields_cache[page_id] = field_perms_for_page(uid, role, page_id)
field_perms = fields_cache[page_id]
content = row.get('修改内容') or ''
# 修改内容由“字段: 旧值 → 新值”组成;无结构化字段时隐藏原文,避免泄露未授权字段。
if ' → ' in content and field_perms:
visible_changes = []
for change in content.split(';'):
key = re.split(r'[::]\s*', change, maxsplit=1)[0]
if field_perms.get(key, {}).get('view', False):
visible_changes.append(change)
content = ';'.join(visible_changes) or '修改字段内容因字段权限不可见'
else:
content = '修改内容未提供字段级权限定义,已隐藏'
row = dict(row, **{'修改内容': content})
filtered.append(row)
return filtered
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:
params = dict(task.get('params') or {})
if key == 'operation_history' and not is_admin_role(role):
from routes.auth import _has_effective_page_permission
page_ids = ['planning', 'products', 'order_domestic', 'order_overseas', 'order_packaging',
'purchase_pending', 'purchase_backup', 'bom', 'suppliers', 'spot_application',
'spot_confirmation', 'spot_sales']
allowed_ids = {page for page in page_ids if _has_effective_page_permission(uid, role, page)}
params['_allowed_pages'] = [page for page, page_id in {
'planning': 'planning', '计划预测': 'planning', 'products': 'products', '产品信息管理': 'products',
'order_domestic': 'order_domestic', '国内订单管理': 'order_domestic',
'order_overseas': 'order_overseas', '海外订单管理': 'order_overseas',
'order_packaging': 'order_packaging', '包材订单管理': 'order_packaging',
'purchase_pending': 'purchase_pending', '待下单管理': 'purchase_pending',
'purchase_backup': 'purchase_backup', '下单备份管理': 'purchase_backup',
'bom': 'bom', '物料清单管理': 'bom', 'suppliers': 'suppliers', '供应商管理': 'suppliers',
'spot_application': 'spot_application', '现货采样申请': 'spot_application',
'spot_confirmation': 'spot_confirmation', '现货采样确认': 'spot_confirmation',
'spot_sales': 'spot_sales', '现货销量管理': 'spot_sales',
}.items() if page_id in allowed_ids]
result = ds['handler'](params) or {}
if key == 'operation_history':
result['rows'] = _filter_operation_history_permissions(uid, role, result.get('rows') 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})
# 操作日志结果直接确定性总结,避免模型改写或混淆精确匹配与历史文本匹配。
if all(d['key'] == 'operation_history' for d in datasets):
ds = datasets[0]
total = int((ds.get('summary') or {}).get('row_count') or 0)
returned = len(ds.get('rows') or [])
exact = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '精确对象匹配')
fallback = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '历史文本匹配')
subject = ((ds.get('params') or {}).get('product_code')
or (ds.get('params') or {}).get('product_name') or '指定条件')
summary = '找到%s条与%s相关的操作记录。' % (total, subject) if total else '没有找到符合条件的操作记录。'
limitations = []
if total > returned:
limitations.append('当前明细最多展示%s条,匹配总数为%s条。' % (returned, total))
if fallback:
limitations.append('%s条为历史文本匹配,旧日志可能未保存完整的修改前值。' % fallback)
if exact:
limitations.append('%s条为精确业务对象匹配。' % exact)
analysis = {
'title': '操作记录查询', 'summary': summary,
'findings': [{'title': '匹配结果', 'detail': '符合条件%s条,返回%s条;其中精确对象匹配%s条,历史文本匹配%s条。' % (total, returned, exact, fallback), 'level': '信息'}],
'risks': [], 'actions': [], 'evidence': [], 'follow_ups': [], 'confidence': '高' if not fallback else '中',
'limitations': limitations, 'chart': {'type': 'none'},
}
else:
analysis = None
# 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。
if analysis is None:
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', 'module', 'field', 'start_date', 'end_date', 'product_name'}
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
# 查询操作日志时仅允许读取该数据源,避免同一问题额外触发业务数据查询。
history_tasks = [task for task in tasks if task['datasource'] == 'operation_history']
if history_tasks:
return history_tasks[:1]
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'
'8. 问某产品或板块的手动编辑、谁修改过、修改前后、操作历史时,使用operation_history;'
'优先传入product_code;用户只提供产品名称时传product_name,指定板块/人员/日期时传module/operator/start_date/end_date;'
'不要为查询操作记录调用其他业务数据源。\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"]