# -*- 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/', 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'(? 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"]