Commit 58a795a043c03d60e37df20a1eab5853b29158fe
1 parent
b14e72d3
feat: 添加分析报告生成功能,包括新的模型、代理节点、SQL迁移、提示词和测试。
Showing
12 changed files
with
685 additions
and
15 deletions
README.md
| ... | ... | @@ -22,7 +22,8 @@ graph LR |
| 22 | 22 | B --> C[FetchPartRatio] |
| 23 | 23 | C --> D[SQLAgent<br/>LLM分析] |
| 24 | 24 | D --> E[AllocateBudget] |
| 25 | - E --> G[SaveResult] | |
| 25 | + E --> F[AnalysisReport] | |
| 26 | + F --> G[SaveResult] | |
| 26 | 27 | ``` |
| 27 | 28 | |
| 28 | 29 | 详细架构图见 [docs/architecture.md](docs/architecture.md) |
| ... | ... | @@ -95,7 +96,8 @@ fw-pms-ai/ |
| 95 | 96 | 1. FetchPartRatio - 从 part_ratio 表获取库销比数据 |
| 96 | 97 | 2. SQLAgent - LLM 分析数据,生成补货建议 |
| 97 | 98 | 3. AllocateBudget - 转换建议为补货明细 |
| 98 | -4. SaveResult - 写入数据库 | |
| 99 | +4. AnalysisReport - 生成分析报告(风险评估、行动方案) | |
| 100 | +5. SaveResult - 写入数据库 | |
| 99 | 101 | ``` |
| 100 | 102 | |
| 101 | 103 | ### 业务术语 | ... | ... |
docs/architecture.md
| ... | ... | @@ -27,7 +27,8 @@ flowchart TB |
| 27 | 27 | C --> D{需要重试?} |
| 28 | 28 | D -->|是| C |
| 29 | 29 | D -->|否| E[allocate_budget] |
| 30 | - E --> F[END] | |
| 30 | + E --> E2[generate_analysis_report] | |
| 31 | + E2 --> F[END] | |
| 31 | 32 | end |
| 32 | 33 | |
| 33 | 34 | subgraph Services ["业务服务层"] |
| ... | ... | @@ -62,6 +63,7 @@ flowchart TB |
| 62 | 63 | | `fetch_part_ratio` | 获取商家组合的配件库销比数据 | dealer_grouping_id | part_ratios[] | |
| 63 | 64 | | `sql_agent` | LLM 分析配件数据,生成补货建议 | part_ratios[] | llm_suggestions[], part_results[] | |
| 64 | 65 | | `allocate_budget` | 转换 LLM 建议为补货明细 | llm_suggestions[] | details[] | |
| 66 | +| `generate_analysis_report` | 生成分析报告 | part_ratios[], details[] | analysis_report | | |
| 65 | 67 | |
| 66 | 68 | --- |
| 67 | 69 | |
| ... | ... | @@ -121,3 +123,4 @@ src/fw_pms_ai/ |
| 121 | 123 | | `ai_replenishment_detail` | 补货明细 | |
| 122 | 124 | | `ai_replenishment_part_summary` | 配件级汇总 | |
| 123 | 125 | | `ai_task_execution_log` | 执行日志 | |
| 126 | +| `ai_analysis_report` | 分析报告(JSON结构化) | | ... | ... |
prompts/analysis_report.md
0 → 100644
| 1 | +# 智能补货建议分析报告 | |
| 2 | + | |
| 3 | +你是一位资深汽车配件采购顾问。AI系统已经生成了详细的补货建议明细(包含每个配件的补货数量、理由等),现在需要你站在更宏观的视角,为采购决策者提供**整体性分析**。 | |
| 4 | + | |
| 5 | +> **核心定位**: 补货明细已回答了"补什么、补多少"的问题,本报告聚焦于"整体策略、风险预警、资金规划"等**决策层面**的洞察。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 商家组合信息 | |
| 10 | + | |
| 11 | +| 项目 | 数值 | | |
| 12 | +|------|------| | |
| 13 | +| 商家组合ID | {dealer_grouping_id} | | |
| 14 | +| 商家组合名称 | {dealer_grouping_name} | | |
| 15 | +| 报告生成日期 | {statistics_date} | | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## 本期补货建议概览 | |
| 20 | + | |
| 21 | +{suggestion_summary} | |
| 22 | + | |
| 23 | +--- | |
| 24 | + | |
| 25 | +## 库存健康度参考 | |
| 26 | + | |
| 27 | +| 状态分类 | 配件数量 | 涉及金额 | | |
| 28 | +|----------|----------|----------| | |
| 29 | +| 缺货件 | {shortage_cnt} | {shortage_amount} | | |
| 30 | +| 呆滞件 | {stagnant_cnt} | {stagnant_amount} | | |
| 31 | +| 低频件 | {low_freq_cnt} | {low_freq_amount} | | |
| 32 | + | |
| 33 | +--- | |
| 34 | + | |
| 35 | +## 分析任务 | |
| 36 | + | |
| 37 | +请严格按以下4个模块输出 **JSON格式** 的整体分析报告。 | |
| 38 | + | |
| 39 | +**注意**: 不要重复补货明细中已有的配件级别分析,聚焦于以下**宏观维度**。 | |
| 40 | + | |
| 41 | +### 模块1: 整体态势研判 (overall_assessment) | |
| 42 | + | |
| 43 | +从全局视角评估本次补货建议: | |
| 44 | + | |
| 45 | +1. **补货规模评估**: 本期补货总金额与历史同期相比是偏高、正常还是偏低?可能的原因是什么? | |
| 46 | +2. **结构特征分析**: 补货建议在品类、价格区间、周转频次上呈现什么分布特征?是否存在明显的集中或失衡? | |
| 47 | +3. **时机判断**: 当前是否处于补货的有利时机?需要考虑哪些时间因素(如节假日、促销季、供应商备货周期)? | |
| 48 | + | |
| 49 | +### 模块2: 风险预警与应对 (risk_alerts) | |
| 50 | + | |
| 51 | +识别本次补货可能面临的风险并给出应对建议: | |
| 52 | + | |
| 53 | +1. **供应风险**: 是否有配件可能面临缺货、涨价、交期延长等供应端问题? | |
| 54 | +2. **资金风险**: 本期补货是否会造成资金压力?是否存在呆滞风险较高的配件需要谨慎采购? | |
| 55 | +3. **市场风险**: 是否有配件需求可能下滑(如车型停产、季节性波动)? | |
| 56 | +4. **执行风险**: 补货建议中是否有需要人工复核的异常项?(如建议量远超历史、首次采购配件等) | |
| 57 | + | |
| 58 | +### 模块3: 采购策略建议 (procurement_strategy) | |
| 59 | + | |
| 60 | +提供整体性的采购执行策略: | |
| 61 | + | |
| 62 | +1. **优先级排序原则**: 如果预算或精力有限,应按什么顺序安排采购?给出清晰的分级标准 | |
| 63 | +2. **批量采购机会**: 是否有可以合并下单以降低成本的机会?涉及哪些品类或供应商? | |
| 64 | +3. **分批采购建议**: 哪些配件可以分批次补货?建议的节奏是什么? | |
| 65 | +4. **供应商协调要点**: 是否需要提前与供应商确认交期、价格或备货?关键沟通事项有哪些? | |
| 66 | + | |
| 67 | +### 模块4: 效果预期与建议 (expected_impact) | |
| 68 | + | |
| 69 | +预估按建议执行后的整体效果: | |
| 70 | + | |
| 71 | +1. **库存健康度改善**: 补货后整体库存结构预计如何变化?缺货率预计下降多少? | |
| 72 | +2. **资金效率预估**: 本期补货的预计投入产出如何?资金周转是否会改善? | |
| 73 | +3. **后续关注点**: 补货完成后需要持续关注哪些指标或配件?下一步建议行动是什么? | |
| 74 | + | |
| 75 | +--- | |
| 76 | + | |
| 77 | +## 输出格式 | |
| 78 | + | |
| 79 | +直接输出JSON对象,**不要**包含 ```json 标记: | |
| 80 | + | |
| 81 | +{{ | |
| 82 | + "overall_assessment": {{ | |
| 83 | + "scale_evaluation": {{ | |
| 84 | + "current_vs_historical": "与历史同期对比结论", | |
| 85 | + "possible_reasons": "规模变化的可能原因" | |
| 86 | + }}, | |
| 87 | + "structure_analysis": {{ | |
| 88 | + "category_distribution": "品类分布特征", | |
| 89 | + "price_range_distribution": "价格区间分布特征", | |
| 90 | + "turnover_distribution": "周转频次分布特征", | |
| 91 | + "imbalance_warning": "是否存在失衡及说明" | |
| 92 | + }}, | |
| 93 | + "timing_judgment": {{ | |
| 94 | + "is_favorable": true或false, | |
| 95 | + "timing_factors": "需要考虑的时间因素", | |
| 96 | + "recommendation": "时机相关建议" | |
| 97 | + }} | |
| 98 | + }}, | |
| 99 | + "risk_alerts": {{ | |
| 100 | + "supply_risks": [ | |
| 101 | + {{ | |
| 102 | + "risk_type": "风险类型(缺货/涨价/交期延长等)", | |
| 103 | + "affected_scope": "影响范围描述", | |
| 104 | + "likelihood": "可能性评估(高/中/低)", | |
| 105 | + "mitigation": "应对建议" | |
| 106 | + }} | |
| 107 | + ], | |
| 108 | + "capital_risks": {{ | |
| 109 | + "cash_flow_pressure": "资金压力评估", | |
| 110 | + "stagnation_warning": "呆滞风险提示", | |
| 111 | + "recommendation": "资金风险应对建议" | |
| 112 | + }}, | |
| 113 | + "market_risks": [ | |
| 114 | + {{ | |
| 115 | + "risk_description": "市场风险描述", | |
| 116 | + "affected_parts": "影响配件范围", | |
| 117 | + "recommendation": "应对建议" | |
| 118 | + }} | |
| 119 | + ], | |
| 120 | + "execution_anomalies": [ | |
| 121 | + {{ | |
| 122 | + "anomaly_type": "异常类型", | |
| 123 | + "description": "异常描述", | |
| 124 | + "review_suggestion": "复核建议" | |
| 125 | + }} | |
| 126 | + ] | |
| 127 | + }}, | |
| 128 | + "procurement_strategy": {{ | |
| 129 | + "priority_principle": {{ | |
| 130 | + "tier1_criteria": "第一优先级标准及说明", | |
| 131 | + "tier2_criteria": "第二优先级标准及说明", | |
| 132 | + "tier3_criteria": "可延后采购的标准及说明" | |
| 133 | + }}, | |
| 134 | + "batch_opportunities": {{ | |
| 135 | + "potential_savings": "潜在节省金额或比例", | |
| 136 | + "applicable_categories": "适用品类或供应商", | |
| 137 | + "execution_suggestion": "具体操作建议" | |
| 138 | + }}, | |
| 139 | + "phased_procurement": {{ | |
| 140 | + "recommended_parts": "建议分批采购的配件范围", | |
| 141 | + "suggested_rhythm": "建议的采购节奏" | |
| 142 | + }}, | |
| 143 | + "supplier_coordination": {{ | |
| 144 | + "key_communications": "关键沟通事项", | |
| 145 | + "timing_suggestions": "沟通时机建议" | |
| 146 | + }} | |
| 147 | + }}, | |
| 148 | + "expected_impact": {{ | |
| 149 | + "inventory_health": {{ | |
| 150 | + "structure_improvement": "库存结构改善预期", | |
| 151 | + "shortage_reduction": "缺货率预计下降幅度" | |
| 152 | + }}, | |
| 153 | + "capital_efficiency": {{ | |
| 154 | + "investment_amount": 本期补货投入金额, | |
| 155 | + "expected_return": "预期收益描述", | |
| 156 | + "turnover_improvement": "周转改善预期" | |
| 157 | + }}, | |
| 158 | + "follow_up_actions": {{ | |
| 159 | + "key_metrics_to_watch": "需持续关注的指标", | |
| 160 | + "next_steps": "下一步建议行动" | |
| 161 | + }} | |
| 162 | + }} | |
| 163 | +}} | |
| 164 | + | |
| 165 | +--- | |
| 166 | + | |
| 167 | +## 重要约束 | |
| 168 | + | |
| 169 | +1. **输出必须是合法的JSON对象** | |
| 170 | +2. **所有金额单位为元,保留2位小数** | |
| 171 | +3. **聚焦宏观分析,不要重复明细中已有的配件级别信息** | |
| 172 | +4. **风险和效果预估尽量量化** | |
| 173 | +5. **策略建议要具体可执行,避免空泛描述** | |
| 174 | +6. **分析基于提供的汇总数据,保持客观理性** | ... | ... |
sql/migrate_analysis_report.sql
0 → 100644
| 1 | +-- ============================================================================ | |
| 2 | +-- AI 补货建议分析报告表 | |
| 3 | +-- ============================================================================ | |
| 4 | +-- 版本: 2.0.0 | |
| 5 | +-- 更新日期: 2026-02-05 | |
| 6 | +-- 变更说明: 重构报告模块,聚焦补货决策支持(区别于传统库销分析) | |
| 7 | +-- ============================================================================ | |
| 8 | + | |
| 9 | +DROP TABLE IF EXISTS ai_analysis_report; | |
| 10 | +CREATE TABLE ai_analysis_report ( | |
| 11 | + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', | |
| 12 | + task_no VARCHAR(32) NOT NULL COMMENT '任务编号', | |
| 13 | + group_id BIGINT NOT NULL COMMENT '集团ID', | |
| 14 | + dealer_grouping_id BIGINT NOT NULL COMMENT '商家组合ID', | |
| 15 | + dealer_grouping_name VARCHAR(128) COMMENT '商家组合名称', | |
| 16 | + brand_grouping_id BIGINT COMMENT '品牌组合ID', | |
| 17 | + report_type VARCHAR(32) DEFAULT 'replenishment' COMMENT '报告类型', | |
| 18 | + | |
| 19 | + -- 报告各模块 (JSON 结构化存储) - 宏观决策分析 | |
| 20 | + -- 注:字段名保持兼容,实际存储内容已更新为新模块 | |
| 21 | + replenishment_insights JSON COMMENT '整体态势研判(规模评估/结构分析/时机判断) - 原overall_assessment', | |
| 22 | + urgency_assessment JSON COMMENT '风险预警与应对(供应/资金/市场/执行风险) - 原risk_alerts', | |
| 23 | + strategy_recommendations JSON COMMENT '采购策略建议(优先级/批量机会/分批/供应商协调) - 原procurement_strategy', | |
| 24 | + execution_guide JSON COMMENT '已废弃,置为NULL', | |
| 25 | + expected_outcomes JSON COMMENT '效果预期与建议(库存健康/资金效率/后续行动) - 原expected_impact', | |
| 26 | + | |
| 27 | + -- 统计信息 | |
| 28 | + total_suggest_cnt INT DEFAULT 0 COMMENT '总建议数量', | |
| 29 | + total_suggest_amount DECIMAL(14,2) DEFAULT 0 COMMENT '总建议金额', | |
| 30 | + shortage_risk_cnt INT DEFAULT 0 COMMENT '缺货风险配件数', | |
| 31 | + excess_risk_cnt INT DEFAULT 0 COMMENT '过剩风险配件数', | |
| 32 | + stagnant_cnt INT DEFAULT 0 COMMENT '呆滞件数量', | |
| 33 | + low_freq_cnt INT DEFAULT 0 COMMENT '低频件数量', | |
| 34 | + | |
| 35 | + -- LLM 元数据 | |
| 36 | + llm_provider VARCHAR(32) COMMENT 'LLM提供商', | |
| 37 | + llm_model VARCHAR(64) COMMENT 'LLM模型名称', | |
| 38 | + llm_tokens INT DEFAULT 0 COMMENT 'LLM Token消耗', | |
| 39 | + execution_time_ms INT DEFAULT 0 COMMENT '执行耗时(毫秒)', | |
| 40 | + | |
| 41 | + statistics_date VARCHAR(16) COMMENT '统计日期', | |
| 42 | + create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', | |
| 43 | + | |
| 44 | + INDEX idx_task_no (task_no), | |
| 45 | + INDEX idx_group_date (group_id, statistics_date), | |
| 46 | + INDEX idx_dealer_grouping (dealer_grouping_id) | |
| 47 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI补货建议分析报告表-结构化补货决策支持报告'; | ... | ... |
src/fw_pms_ai/agent/analysis_report_node.py
0 → 100644
| 1 | +""" | |
| 2 | +分析报告生成节点 | |
| 3 | + | |
| 4 | +在补货建议工作流的最后一个节点执行,生成结构化分析报告 | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import logging | |
| 8 | +import time | |
| 9 | +import json | |
| 10 | +import os | |
| 11 | +from typing import Dict, Any | |
| 12 | +from decimal import Decimal | |
| 13 | +from datetime import datetime | |
| 14 | + | |
| 15 | +from langchain_core.messages import HumanMessage | |
| 16 | + | |
| 17 | +from ..llm import get_llm_client | |
| 18 | +from ..models import AnalysisReport | |
| 19 | +from ..services.result_writer import ResultWriter | |
| 20 | + | |
| 21 | +logger = logging.getLogger(__name__) | |
| 22 | + | |
| 23 | + | |
| 24 | +def _load_prompt(filename: str) -> str: | |
| 25 | + """从prompts目录加载提示词文件""" | |
| 26 | + prompts_dir = os.path.join( | |
| 27 | + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), | |
| 28 | + "prompts" | |
| 29 | + ) | |
| 30 | + filepath = os.path.join(prompts_dir, filename) | |
| 31 | + | |
| 32 | + if not os.path.exists(filepath): | |
| 33 | + raise FileNotFoundError(f"Prompt文件未找到: {filepath}") | |
| 34 | + | |
| 35 | + with open(filepath, "r", encoding="utf-8") as f: | |
| 36 | + return f.read() | |
| 37 | + | |
| 38 | + | |
| 39 | +def _calculate_risk_stats(part_ratios: list) -> dict: | |
| 40 | + """计算风险统计数据""" | |
| 41 | + stats = { | |
| 42 | + "shortage_cnt": 0, | |
| 43 | + "shortage_amount": Decimal("0"), | |
| 44 | + "stagnant_cnt": 0, | |
| 45 | + "stagnant_amount": Decimal("0"), | |
| 46 | + "low_freq_cnt": 0, | |
| 47 | + "low_freq_amount": Decimal("0"), | |
| 48 | + } | |
| 49 | + | |
| 50 | + for pr in part_ratios: | |
| 51 | + valid_storage = Decimal(str(pr.get("valid_storage_cnt", 0) or 0)) | |
| 52 | + avg_sales = Decimal(str(pr.get("avg_sales_cnt", 0) or 0)) | |
| 53 | + out_stock = Decimal(str(pr.get("out_stock_cnt", 0) or 0)) | |
| 54 | + cost_price = Decimal(str(pr.get("cost_price", 0) or 0)) | |
| 55 | + | |
| 56 | + # 呆滞件: 有库存但90天无出库 | |
| 57 | + if valid_storage > 0 and out_stock == 0: | |
| 58 | + stats["stagnant_cnt"] += 1 | |
| 59 | + stats["stagnant_amount"] += valid_storage * cost_price | |
| 60 | + | |
| 61 | + # 低频件: 无库存且月均销量<1 | |
| 62 | + elif valid_storage == 0 and avg_sales < 1: | |
| 63 | + stats["low_freq_cnt"] += 1 | |
| 64 | + | |
| 65 | + # 缺货件: 无库存且月均销量>=1 | |
| 66 | + elif valid_storage == 0 and avg_sales >= 1: | |
| 67 | + stats["shortage_cnt"] += 1 | |
| 68 | + # 缺货损失估算:月均销量 * 成本价 | |
| 69 | + stats["shortage_amount"] += avg_sales * cost_price | |
| 70 | + | |
| 71 | + return stats | |
| 72 | + | |
| 73 | + | |
| 74 | +def _build_suggestion_summary(part_results: list, allocated_details: list) -> str: | |
| 75 | + """构建补货建议汇总文本""" | |
| 76 | + if not part_results and not allocated_details: | |
| 77 | + return "暂无补货建议" | |
| 78 | + | |
| 79 | + lines = [] | |
| 80 | + total_cnt = 0 | |
| 81 | + total_amount = Decimal("0") | |
| 82 | + | |
| 83 | + # 优先使用 part_results (配件级汇总) | |
| 84 | + if part_results: | |
| 85 | + for pr in part_results[:10]: # 只取前10个 | |
| 86 | + if hasattr(pr, "part_code"): | |
| 87 | + lines.append( | |
| 88 | + f"- {pr.part_code} {pr.part_name}: " | |
| 89 | + f"建议{pr.total_suggest_cnt}件, " | |
| 90 | + f"金额{pr.total_suggest_amount:.2f}元, " | |
| 91 | + f"优先级{pr.priority}" | |
| 92 | + ) | |
| 93 | + total_cnt += pr.total_suggest_cnt | |
| 94 | + total_amount += pr.total_suggest_amount | |
| 95 | + elif isinstance(pr, dict): | |
| 96 | + lines.append( | |
| 97 | + f"- {pr.get('part_code', '')} {pr.get('part_name', '')}: " | |
| 98 | + f"建议{pr.get('total_suggest_cnt', 0)}件, " | |
| 99 | + f"金额{pr.get('total_suggest_amount', 0):.2f}元" | |
| 100 | + ) | |
| 101 | + | |
| 102 | + lines.insert(0, f"**总计**: {total_cnt}件配件, 金额{total_amount:.2f}元\n") | |
| 103 | + return "\n".join(lines) | |
| 104 | + | |
| 105 | + | |
| 106 | +def generate_analysis_report_node(state: dict) -> dict: | |
| 107 | + """ | |
| 108 | + 生成分析报告节点 | |
| 109 | + | |
| 110 | + 输入: part_ratios, llm_suggestions, allocated_details, part_results | |
| 111 | + 输出: analysis_report | |
| 112 | + """ | |
| 113 | + start_time = time.time() | |
| 114 | + | |
| 115 | + task_no = state.get("task_no", "") | |
| 116 | + group_id = state.get("group_id", 0) | |
| 117 | + dealer_grouping_id = state.get("dealer_grouping_id", 0) | |
| 118 | + dealer_grouping_name = state.get("dealer_grouping_name", "") | |
| 119 | + brand_grouping_id = state.get("brand_grouping_id") | |
| 120 | + statistics_date = state.get("statistics_date", "") | |
| 121 | + | |
| 122 | + part_ratios = state.get("part_ratios", []) | |
| 123 | + part_results = state.get("part_results", []) | |
| 124 | + allocated_details = state.get("allocated_details", []) | |
| 125 | + | |
| 126 | + logger.info(f"[{task_no}] 开始生成分析报告: dealer={dealer_grouping_name}") | |
| 127 | + | |
| 128 | + try: | |
| 129 | + # 计算风险统计 | |
| 130 | + risk_stats = _calculate_risk_stats(part_ratios) | |
| 131 | + | |
| 132 | + # 构建建议汇总 | |
| 133 | + suggestion_summary = _build_suggestion_summary(part_results, allocated_details) | |
| 134 | + | |
| 135 | + # 加载 Prompt | |
| 136 | + prompt_template = _load_prompt("analysis_report.md") | |
| 137 | + | |
| 138 | + # 填充 Prompt 变量 | |
| 139 | + prompt = prompt_template.format( | |
| 140 | + dealer_grouping_id=dealer_grouping_id, | |
| 141 | + dealer_grouping_name=dealer_grouping_name, | |
| 142 | + statistics_date=statistics_date, | |
| 143 | + suggestion_summary=suggestion_summary, | |
| 144 | + shortage_cnt=risk_stats["shortage_cnt"], | |
| 145 | + shortage_amount=f"{risk_stats['shortage_amount']:.2f}", | |
| 146 | + stagnant_cnt=risk_stats["stagnant_cnt"], | |
| 147 | + stagnant_amount=f"{risk_stats['stagnant_amount']:.2f}", | |
| 148 | + low_freq_cnt=risk_stats["low_freq_cnt"], | |
| 149 | + low_freq_amount="0.00", # 低频件无库存 | |
| 150 | + ) | |
| 151 | + | |
| 152 | + # 调用 LLM | |
| 153 | + llm_client = get_llm_client() | |
| 154 | + response = llm_client.invoke( | |
| 155 | + messages=[HumanMessage(content=prompt)], | |
| 156 | + ) | |
| 157 | + | |
| 158 | + # 解析 JSON 响应 | |
| 159 | + response_text = response.content.strip() | |
| 160 | + # 移除可能的 markdown 代码块 | |
| 161 | + if response_text.startswith("```"): | |
| 162 | + lines = response_text.split("\n") | |
| 163 | + response_text = "\n".join(lines[1:-1]) | |
| 164 | + | |
| 165 | + report_data = json.loads(response_text) | |
| 166 | + | |
| 167 | + # 计算统计信息 | |
| 168 | + total_suggest_cnt = sum( | |
| 169 | + d.suggest_cnt if hasattr(d, "suggest_cnt") else d.get("suggest_cnt", 0) | |
| 170 | + for d in allocated_details | |
| 171 | + ) | |
| 172 | + total_suggest_amount = sum( | |
| 173 | + d.suggest_amount if hasattr(d, "suggest_amount") else Decimal(str(d.get("suggest_amount", 0))) | |
| 174 | + for d in allocated_details | |
| 175 | + ) | |
| 176 | + | |
| 177 | + execution_time_ms = int((time.time() - start_time) * 1000) | |
| 178 | + | |
| 179 | + # 创建报告对象 | |
| 180 | + # 新 prompt 字段名映射到现有数据库字段: | |
| 181 | + # overall_assessment -> replenishment_insights | |
| 182 | + # risk_alerts -> urgency_assessment | |
| 183 | + # procurement_strategy -> strategy_recommendations | |
| 184 | + # expected_impact -> expected_outcomes | |
| 185 | + # execution_guide 已移除,置为 None | |
| 186 | + report = AnalysisReport( | |
| 187 | + task_no=task_no, | |
| 188 | + group_id=group_id, | |
| 189 | + dealer_grouping_id=dealer_grouping_id, | |
| 190 | + dealer_grouping_name=dealer_grouping_name, | |
| 191 | + brand_grouping_id=brand_grouping_id, | |
| 192 | + report_type="replenishment", | |
| 193 | + replenishment_insights=report_data.get("overall_assessment"), | |
| 194 | + urgency_assessment=report_data.get("risk_alerts"), | |
| 195 | + strategy_recommendations=report_data.get("procurement_strategy"), | |
| 196 | + execution_guide=None, | |
| 197 | + expected_outcomes=report_data.get("expected_impact"), | |
| 198 | + total_suggest_cnt=total_suggest_cnt, | |
| 199 | + total_suggest_amount=total_suggest_amount, | |
| 200 | + shortage_risk_cnt=risk_stats["shortage_cnt"], | |
| 201 | + excess_risk_cnt=risk_stats["stagnant_cnt"], | |
| 202 | + stagnant_cnt=risk_stats["stagnant_cnt"], | |
| 203 | + low_freq_cnt=risk_stats["low_freq_cnt"], | |
| 204 | + llm_provider=getattr(llm_client, "provider", ""), | |
| 205 | + llm_model=getattr(llm_client, "model", ""), | |
| 206 | + llm_tokens=response.usage.total_tokens, | |
| 207 | + execution_time_ms=execution_time_ms, | |
| 208 | + statistics_date=statistics_date, | |
| 209 | + ) | |
| 210 | + | |
| 211 | + # 保存到数据库 | |
| 212 | + result_writer = ResultWriter() | |
| 213 | + try: | |
| 214 | + result_writer.save_analysis_report(report) | |
| 215 | + finally: | |
| 216 | + result_writer.close() | |
| 217 | + | |
| 218 | + logger.info( | |
| 219 | + f"[{task_no}] 分析报告生成完成: " | |
| 220 | + f"shortage={risk_stats['shortage_cnt']}, " | |
| 221 | + f"stagnant={risk_stats['stagnant_cnt']}, " | |
| 222 | + f"time={execution_time_ms}ms" | |
| 223 | + ) | |
| 224 | + | |
| 225 | + return { | |
| 226 | + "analysis_report": report.to_dict(), | |
| 227 | + "end_time": time.time(), | |
| 228 | + } | |
| 229 | + | |
| 230 | + except Exception as e: | |
| 231 | + logger.error(f"[{task_no}] 分析报告生成失败: {e}", exc_info=True) | |
| 232 | + | |
| 233 | + # 返回空报告,不中断整个流程 | |
| 234 | + return { | |
| 235 | + "analysis_report": { | |
| 236 | + "error": str(e), | |
| 237 | + "task_no": task_no, | |
| 238 | + }, | |
| 239 | + "end_time": time.time(), | |
| 240 | + } | ... | ... |
src/fw_pms_ai/agent/replenishment.py
| ... | ... | @@ -20,6 +20,7 @@ from .nodes import ( |
| 20 | 20 | allocate_budget_node, |
| 21 | 21 | should_retry_sql, |
| 22 | 22 | ) |
| 23 | +from .analysis_report_node import generate_analysis_report_node | |
| 23 | 24 | from ..models import ReplenishmentTask, TaskStatus, TaskExecutionLog, LogStatus, ReplenishmentPartSummary |
| 24 | 25 | from ..services import ResultWriter |
| 25 | 26 | |
| ... | ... | @@ -45,7 +46,7 @@ class ReplenishmentAgent: |
| 45 | 46 | 构建 LangGraph 工作流 |
| 46 | 47 | |
| 47 | 48 | 工作流结构: |
| 48 | - fetch_part_ratio → sql_agent → allocate_budget → END | |
| 49 | + fetch_part_ratio → sql_agent → allocate_budget → generate_analysis_report → END | |
| 49 | 50 | """ |
| 50 | 51 | workflow = StateGraph(AgentState) |
| 51 | 52 | |
| ... | ... | @@ -53,6 +54,7 @@ class ReplenishmentAgent: |
| 53 | 54 | workflow.add_node("fetch_part_ratio", fetch_part_ratio_node) |
| 54 | 55 | workflow.add_node("sql_agent", sql_agent_node) |
| 55 | 56 | workflow.add_node("allocate_budget", allocate_budget_node) |
| 57 | + workflow.add_node("generate_analysis_report", generate_analysis_report_node) | |
| 56 | 58 | |
| 57 | 59 | # 设置入口 |
| 58 | 60 | workflow.set_entry_point("fetch_part_ratio") |
| ... | ... | @@ -70,8 +72,9 @@ class ReplenishmentAgent: |
| 70 | 72 | } |
| 71 | 73 | ) |
| 72 | 74 | |
| 73 | - # allocate_budget → END | |
| 74 | - workflow.add_edge("allocate_budget", END) | |
| 75 | + # allocate_budget → generate_analysis_report → END | |
| 76 | + workflow.add_edge("allocate_budget", "generate_analysis_report") | |
| 77 | + workflow.add_edge("generate_analysis_report", END) | |
| 75 | 78 | |
| 76 | 79 | return workflow.compile() |
| 77 | 80 | ... | ... |
src/fw_pms_ai/agent/sql_agent/analyzer.py
| ... | ... | @@ -406,13 +406,13 @@ class PartAnalyzer: |
| 406 | 406 | confidence = float(result.get("confidence", 0.8)) |
| 407 | 407 | part_decision_reason = result.get("part_decision_reason", "") |
| 408 | 408 | need_replenishment = result.get("need_replenishment", False) |
| 409 | - priority = int(result.get("priority", 2)) | |
| 409 | + priority = int(result.get("priority") or 2) | |
| 410 | 410 | |
| 411 | 411 | # 更新配件结果 |
| 412 | 412 | part_result.need_replenishment = need_replenishment |
| 413 | - part_result.total_suggest_cnt = int(result.get("total_suggest_cnt", 0)) | |
| 413 | + part_result.total_suggest_cnt = int(result.get("total_suggest_cnt") or 0) | |
| 414 | 414 | part_result.total_suggest_amount = Decimal(str(result.get("total_suggest_amount", 0))) |
| 415 | - part_result.shop_count = int(result.get("shop_count", len(shop_data_list))) | |
| 415 | + part_result.shop_count = int(result.get("shop_count") or len(shop_data_list)) | |
| 416 | 416 | part_result.part_decision_reason = part_decision_reason |
| 417 | 417 | part_result.priority = priority |
| 418 | 418 | part_result.confidence = confidence |
| ... | ... | @@ -430,11 +430,11 @@ class PartAnalyzer: |
| 430 | 430 | shop_suggestions_data = result.get("shop_suggestions", []) |
| 431 | 431 | if shop_suggestions_data: |
| 432 | 432 | for shop in shop_suggestions_data: |
| 433 | - s_id = int(shop.get("shop_id", 0)) | |
| 433 | + s_id = int(shop.get("shop_id") or 0) | |
| 434 | 434 | shop_suggestion_map[s_id] = shop |
| 435 | 435 | |
| 436 | 436 | # 统计需要补货的门店数 |
| 437 | - need_replenishment_shop_count = len([s for s in shop_suggestions_data if int(s.get("suggest_cnt", 0)) > 0]) | |
| 437 | + need_replenishment_shop_count = len([s for s in shop_suggestions_data if int(s.get("suggest_cnt") or 0) > 0]) | |
| 438 | 438 | part_result.need_replenishment_shop_count = need_replenishment_shop_count |
| 439 | 439 | |
| 440 | 440 | # 递归所有输入门店,确保每个门店都有记录 |
| ... | ... | @@ -445,10 +445,10 @@ class PartAnalyzer: |
| 445 | 445 | # 检查LLM是否有针对该门店的建议 |
| 446 | 446 | if shop_id in shop_suggestion_map: |
| 447 | 447 | s_item = shop_suggestion_map[shop_id] |
| 448 | - suggest_cnt = int(s_item.get("suggest_cnt", 0)) | |
| 449 | - suggest_amount = Decimal(str(s_item.get("suggest_amount", 0))) | |
| 450 | - reason = s_item.get("reason", part_decision_reason) | |
| 451 | - shop_priority = int(s_item.get("priority", priority)) | |
| 448 | + suggest_cnt = int(s_item.get("suggest_cnt") or 0) | |
| 449 | + suggest_amount = Decimal(str(s_item.get("suggest_amount") or 0)) | |
| 450 | + reason = s_item.get("reason") or part_decision_reason | |
| 451 | + shop_priority = int(s_item.get("priority") or priority) | |
| 452 | 452 | else: |
| 453 | 453 | # LLM未提及该门店,根据门店数据生成个性化默认理由 |
| 454 | 454 | suggest_cnt = 0 | ... | ... |
src/fw_pms_ai/agent/state.py
| ... | ... | @@ -73,6 +73,9 @@ class AgentState(TypedDict, total=False): |
| 73 | 73 | # 配件汇总结果 |
| 74 | 74 | part_results: Annotated[List[Any], merge_lists] |
| 75 | 75 | |
| 76 | + # 分析报告 | |
| 77 | + analysis_report: Annotated[Optional[dict], keep_last] | |
| 78 | + | |
| 76 | 79 | # LLM 统计(使用累加,合并多个并行节点的 token 使用量) |
| 77 | 80 | llm_provider: Annotated[str, keep_last] |
| 78 | 81 | llm_model: Annotated[str, keep_last] | ... | ... |
src/fw_pms_ai/models/__init__.py
| ... | ... | @@ -6,6 +6,7 @@ from .execution_log import TaskExecutionLog, LogStatus |
| 6 | 6 | from .part_summary import ReplenishmentPartSummary |
| 7 | 7 | from .sql_result import SQLExecutionResult |
| 8 | 8 | from .suggestion import ReplenishmentSuggestion, PartAnalysisResult |
| 9 | +from .analysis_report import AnalysisReport | |
| 9 | 10 | |
| 10 | 11 | __all__ = [ |
| 11 | 12 | "PartRatio", |
| ... | ... | @@ -18,6 +19,7 @@ __all__ = [ |
| 18 | 19 | "SQLExecutionResult", |
| 19 | 20 | "ReplenishmentSuggestion", |
| 20 | 21 | "PartAnalysisResult", |
| 22 | + "AnalysisReport", | |
| 21 | 23 | ] |
| 22 | 24 | |
| 23 | 25 | ... | ... |
src/fw_pms_ai/models/analysis_report.py
0 → 100644
| 1 | +""" | |
| 2 | +数据模型 - 分析报告 | |
| 3 | +""" | |
| 4 | + | |
| 5 | +from dataclasses import dataclass, field | |
| 6 | +from decimal import Decimal | |
| 7 | +from datetime import datetime | |
| 8 | +from typing import Optional, Dict, Any | |
| 9 | + | |
| 10 | + | |
| 11 | +@dataclass | |
| 12 | +class AnalysisReport: | |
| 13 | + """AI补货建议分析报告""" | |
| 14 | + | |
| 15 | + task_no: str | |
| 16 | + group_id: int | |
| 17 | + dealer_grouping_id: int | |
| 18 | + | |
| 19 | + id: Optional[int] = None | |
| 20 | + dealer_grouping_name: Optional[str] = None | |
| 21 | + brand_grouping_id: Optional[int] = None | |
| 22 | + report_type: str = "replenishment" | |
| 23 | + | |
| 24 | + # 报告各模块 (字典结构) | |
| 25 | + replenishment_insights: Optional[Dict[str, Any]] = None | |
| 26 | + urgency_assessment: Optional[Dict[str, Any]] = None | |
| 27 | + strategy_recommendations: Optional[Dict[str, Any]] = None | |
| 28 | + execution_guide: Optional[Dict[str, Any]] = None | |
| 29 | + expected_outcomes: Optional[Dict[str, Any]] = None | |
| 30 | + | |
| 31 | + # 统计信息 | |
| 32 | + total_suggest_cnt: int = 0 | |
| 33 | + total_suggest_amount: Decimal = Decimal("0") | |
| 34 | + shortage_risk_cnt: int = 0 | |
| 35 | + excess_risk_cnt: int = 0 | |
| 36 | + stagnant_cnt: int = 0 | |
| 37 | + low_freq_cnt: int = 0 | |
| 38 | + | |
| 39 | + # LLM 元数据 | |
| 40 | + llm_provider: str = "" | |
| 41 | + llm_model: str = "" | |
| 42 | + llm_tokens: int = 0 | |
| 43 | + execution_time_ms: int = 0 | |
| 44 | + | |
| 45 | + statistics_date: str = "" | |
| 46 | + create_time: Optional[datetime] = None | |
| 47 | + | |
| 48 | + def to_dict(self) -> dict: | |
| 49 | + """转换为字典""" | |
| 50 | + return { | |
| 51 | + "task_no": self.task_no, | |
| 52 | + "group_id": self.group_id, | |
| 53 | + "dealer_grouping_id": self.dealer_grouping_id, | |
| 54 | + "dealer_grouping_name": self.dealer_grouping_name, | |
| 55 | + "brand_grouping_id": self.brand_grouping_id, | |
| 56 | + "report_type": self.report_type, | |
| 57 | + "replenishment_insights": self.replenishment_insights, | |
| 58 | + "urgency_assessment": self.urgency_assessment, | |
| 59 | + "strategy_recommendations": self.strategy_recommendations, | |
| 60 | + "execution_guide": self.execution_guide, | |
| 61 | + "expected_outcomes": self.expected_outcomes, | |
| 62 | + "total_suggest_cnt": self.total_suggest_cnt, | |
| 63 | + "total_suggest_amount": float(self.total_suggest_amount), | |
| 64 | + "shortage_risk_cnt": self.shortage_risk_cnt, | |
| 65 | + "excess_risk_cnt": self.excess_risk_cnt, | |
| 66 | + "stagnant_cnt": self.stagnant_cnt, | |
| 67 | + "low_freq_cnt": self.low_freq_cnt, | |
| 68 | + "llm_provider": self.llm_provider, | |
| 69 | + "llm_model": self.llm_model, | |
| 70 | + "llm_tokens": self.llm_tokens, | |
| 71 | + "execution_time_ms": self.execution_time_ms, | |
| 72 | + "statistics_date": self.statistics_date, | |
| 73 | + } | ... | ... |
src/fw_pms_ai/services/result_writer.py
| ... | ... | @@ -14,6 +14,7 @@ from ..models import ( |
| 14 | 14 | ReplenishmentDetail, |
| 15 | 15 | TaskExecutionLog, |
| 16 | 16 | ReplenishmentPartSummary, |
| 17 | + AnalysisReport, | |
| 17 | 18 | ) |
| 18 | 19 | |
| 19 | 20 | logger = logging.getLogger(__name__) |
| ... | ... | @@ -322,3 +323,67 @@ class ResultWriter: |
| 322 | 323 | finally: |
| 323 | 324 | cursor.close() |
| 324 | 325 | |
| 326 | + def save_analysis_report(self, report: AnalysisReport) -> int: | |
| 327 | + """ | |
| 328 | + 保存分析报告 | |
| 329 | + | |
| 330 | + Returns: | |
| 331 | + 插入的报告ID | |
| 332 | + """ | |
| 333 | + conn = self._get_connection() | |
| 334 | + cursor = conn.cursor() | |
| 335 | + | |
| 336 | + try: | |
| 337 | + sql = """ | |
| 338 | + INSERT INTO ai_analysis_report ( | |
| 339 | + task_no, group_id, dealer_grouping_id, dealer_grouping_name, | |
| 340 | + brand_grouping_id, report_type, | |
| 341 | + replenishment_insights, urgency_assessment, strategy_recommendations, | |
| 342 | + execution_guide, expected_outcomes, | |
| 343 | + total_suggest_cnt, total_suggest_amount, shortage_risk_cnt, | |
| 344 | + excess_risk_cnt, stagnant_cnt, low_freq_cnt, | |
| 345 | + llm_provider, llm_model, llm_tokens, execution_time_ms, | |
| 346 | + statistics_date, create_time | |
| 347 | + ) VALUES ( | |
| 348 | + %s, %s, %s, %s, %s, %s, | |
| 349 | + %s, %s, %s, %s, %s, | |
| 350 | + %s, %s, %s, %s, %s, %s, | |
| 351 | + %s, %s, %s, %s, %s, NOW() | |
| 352 | + ) | |
| 353 | + """ | |
| 354 | + | |
| 355 | + values = ( | |
| 356 | + report.task_no, | |
| 357 | + report.group_id, | |
| 358 | + report.dealer_grouping_id, | |
| 359 | + report.dealer_grouping_name, | |
| 360 | + report.brand_grouping_id, | |
| 361 | + report.report_type, | |
| 362 | + json.dumps(report.replenishment_insights, ensure_ascii=False) if report.replenishment_insights else None, | |
| 363 | + json.dumps(report.urgency_assessment, ensure_ascii=False) if report.urgency_assessment else None, | |
| 364 | + json.dumps(report.strategy_recommendations, ensure_ascii=False) if report.strategy_recommendations else None, | |
| 365 | + json.dumps(report.execution_guide, ensure_ascii=False) if report.execution_guide else None, | |
| 366 | + json.dumps(report.expected_outcomes, ensure_ascii=False) if report.expected_outcomes else None, | |
| 367 | + report.total_suggest_cnt, | |
| 368 | + float(report.total_suggest_amount), | |
| 369 | + report.shortage_risk_cnt, | |
| 370 | + report.excess_risk_cnt, | |
| 371 | + report.stagnant_cnt, | |
| 372 | + report.low_freq_cnt, | |
| 373 | + report.llm_provider, | |
| 374 | + report.llm_model, | |
| 375 | + report.llm_tokens, | |
| 376 | + report.execution_time_ms, | |
| 377 | + report.statistics_date, | |
| 378 | + ) | |
| 379 | + | |
| 380 | + cursor.execute(sql, values) | |
| 381 | + conn.commit() | |
| 382 | + | |
| 383 | + report_id = cursor.lastrowid | |
| 384 | + logger.info(f"保存分析报告: task_no={report.task_no}, id={report_id}") | |
| 385 | + return report_id | |
| 386 | + | |
| 387 | + finally: | |
| 388 | + cursor.close() | |
| 389 | + | ... | ... |
tests/test_analysis_report.py
0 → 100644
| 1 | +""" | |
| 2 | +测试分析报告生成功能 | |
| 3 | +""" | |
| 4 | +import sys | |
| 5 | +sys.path.insert(0, "src") | |
| 6 | + | |
| 7 | +from fw_pms_ai.agent.analysis_report_node import generate_analysis_report_node | |
| 8 | + | |
| 9 | +def test_generate_analysis_report(): | |
| 10 | + """测试为 AI-FB34CA0EE6C4 生成分析报告""" | |
| 11 | + | |
| 12 | + # 模拟数据 | |
| 13 | + part_ratios = [ | |
| 14 | + {"part_code": "C211F280503-1800-AA", "part_name": "牌照灯总成", | |
| 15 | + "valid_storage_cnt": 0, "avg_sales_cnt": 2, "out_stock_cnt": 5, "cost_price": 14}, | |
| 16 | + {"part_code": "TEST-001", "part_name": "测试配件1", | |
| 17 | + "valid_storage_cnt": 10, "avg_sales_cnt": 0, "out_stock_cnt": 0, "cost_price": 100}, | |
| 18 | + {"part_code": "TEST-002", "part_name": "测试配件2", | |
| 19 | + "valid_storage_cnt": 0, "avg_sales_cnt": 0.5, "out_stock_cnt": 0, "cost_price": 50}, | |
| 20 | + ] | |
| 21 | + | |
| 22 | + part_results = [ | |
| 23 | + {"part_code": "C211F280503-1800-AA", "part_name": "牌照灯总成", | |
| 24 | + "total_suggest_cnt": 4, "total_suggest_amount": 56.0, "priority": 1}, | |
| 25 | + ] | |
| 26 | + | |
| 27 | + allocated_details = [ | |
| 28 | + {"part_code": "C211F280503-1800-AA", "suggest_cnt": 4, "suggest_amount": 56.0}, | |
| 29 | + ] | |
| 30 | + | |
| 31 | + # 构建 state | |
| 32 | + state = { | |
| 33 | + "task_no": "AI-FB34CA0EE6C4", | |
| 34 | + "group_id": 2, | |
| 35 | + "dealer_grouping_id": 48, | |
| 36 | + "dealer_grouping_name": "测试分组", | |
| 37 | + "brand_grouping_id": None, | |
| 38 | + "statistics_date": "2026-02-05", | |
| 39 | + "part_ratios": part_ratios, | |
| 40 | + "part_results": part_results, | |
| 41 | + "allocated_details": allocated_details, | |
| 42 | + } | |
| 43 | + | |
| 44 | + print("开始生成分析报告...") | |
| 45 | + result = generate_analysis_report_node(state) | |
| 46 | + | |
| 47 | + if "error" in result.get("analysis_report", {}): | |
| 48 | + print(f"\n❌ 生成失败: {result['analysis_report']['error']}") | |
| 49 | + return False | |
| 50 | + else: | |
| 51 | + print(f"\n✅ 分析报告生成成功!") | |
| 52 | + report = result.get("analysis_report", {}) | |
| 53 | + print(f" - replenishment_insights: {str(report.get('replenishment_insights', ''))[:100]}...") | |
| 54 | + print(f" - urgency_assessment: {str(report.get('urgency_assessment', ''))[:100]}...") | |
| 55 | + return True | |
| 56 | + | |
| 57 | +if __name__ == "__main__": | |
| 58 | + test_generate_analysis_report() | ... | ... |