outline_agent.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  1. """
  2. 报告大纲生成Agent (Report Outline Generation Agent)
  3. ===============================================
  4. 此Agent负责根据用户需求和数据样本,生成专业的报告大纲结构。
  5. 核心功能:
  6. 1. 分析用户需求:理解报告目标和关键指标
  7. 2. 数据结构分析:识别可用字段和数据特征
  8. 3. 大纲生成:创建结构化的报告章节和指标需求
  9. 4. 智能推断:自动推断所需字段和计算逻辑
  10. 工作流程:
  11. 1. 接收用户查询和数据样本
  12. 2. 分析数据结构和可用字段
  13. 3. 生成报告标题和章节结构
  14. 4. 定义全局指标需求
  15. 5. 返回结构化的大纲对象
  16. 技术实现:
  17. - 使用LangChain和结构化输出
  18. - 支持异步处理
  19. - 自动字段推断和补全
  20. - 错误处理和默认值提供
  21. 作者: Big Agent Team
  22. 版本: 1.0.0
  23. 创建时间: 2024-12-20
  24. """
  25. from typing import List, Dict, Any
  26. from langchain_openai import ChatOpenAI
  27. from langchain_core.prompts import ChatPromptTemplate
  28. import json
  29. import os
  30. import uuid
  31. import requests
  32. from datetime import datetime
  33. from pydantic import BaseModel, Field
  34. from llmops.config import RULES_ENGINE_BASE_URL
  35. # 数据模型定义(与现有项目兼容)
  36. class MetricRequirement(BaseModel):
  37. """指标需求定义"""
  38. metric_id: str = Field(description="指标唯一标识,如 'total_income_jan'")
  39. metric_name: str = Field(description="指标中文名称")
  40. calculation_logic: str = Field(description="计算逻辑描述")
  41. required_fields: List[str] = Field(description="所需字段")
  42. dependencies: List[str] = Field(default_factory=list, description="依赖的其他指标ID")
  43. class ReportSection(BaseModel):
  44. """报告大纲章节"""
  45. section_id: str = Field(description="章节ID")
  46. title: str = Field(description="章节标题")
  47. description: str = Field(description="章节内容要求")
  48. metrics_needed: List[str] = Field(description="所需指标ID列表")
  49. class ReportOutline(BaseModel):
  50. """完整报告大纲"""
  51. report_title: str = Field(description="报告标题")
  52. sections: List[ReportSection] = Field(description="章节列表")
  53. global_metrics: List[MetricRequirement] = Field(description="全局指标列表")
  54. class OutlineGeneratorAgent:
  55. """大纲生成智能体:将报告需求转化为结构化大纲"""
  56. def __init__(self, api_key: str, base_url: str = "https://api.deepseek.com"):
  57. """
  58. 初始化大纲生成Agent
  59. Args:
  60. api_key: DeepSeek API密钥
  61. base_url: DeepSeek API基础URL
  62. """
  63. self.llm = ChatOpenAI(
  64. model="deepseek-chat",
  65. api_key=api_key,
  66. base_url=base_url,
  67. temperature=0.1
  68. )
  69. # 初始化API调用跟踪
  70. self.api_calls = []
  71. # 获取可用的知识元数据
  72. self.available_knowledge = self._load_available_knowledge()
  73. def _convert_new_format_to_outline(self, new_format_data: Dict[str, Any]) -> Dict[str, Any]:
  74. """将新的JSON格式转换为原来的ReportOutline格式"""
  75. # 转换sections
  76. sections = []
  77. for section_data in new_format_data.get("sections", []):
  78. # 从metrics中提取指标名称
  79. metrics_needed = []
  80. for metric_type in ["calculation_metrics", "statistical_metrics", "analysis_metrics"]:
  81. for metric in section_data.get("metrics", {}).get(metric_type, []):
  82. # 这里可以根据metric_name映射到实际的metric_id
  83. # 暂时使用metric_name作为metric_id
  84. metrics_needed.append(metric.get("metric_name", ""))
  85. section = {
  86. "section_id": section_data.get("section_id", ""),
  87. "title": section_data.get("section_title", ""),
  88. "description": section_data.get("section_description", ""),
  89. "metrics_needed": metrics_needed
  90. }
  91. sections.append(section)
  92. # 生成global_metrics:使用知识ID进行匹配,并强制添加更多农业相关指标
  93. global_metrics = []
  94. used_knowledge_ids = set()
  95. # 首先处理LLM生成的指标
  96. for section in sections:
  97. for metric_name in section["metrics_needed"]:
  98. # 查找对应的指标描述(从原始数据中获取)
  99. metric_description = ""
  100. for section_data in new_format_data.get("sections", []):
  101. for metric_type in ["calculation_metrics", "statistical_metrics", "analysis_metrics"]:
  102. for metric in section_data.get("metrics", {}).get(metric_type, []):
  103. if metric.get("metric_name") == metric_name:
  104. metric_description = metric.get("metric_description", "")
  105. break
  106. if metric_description:
  107. break
  108. if metric_description:
  109. break
  110. # 使用知识ID匹配算法找到最佳匹配
  111. knowledge_id = self._match_metric_to_knowledge(metric_name, metric_description)
  112. # 如果找到匹配的知识ID,使用它作为metric_id
  113. if knowledge_id and knowledge_id not in used_knowledge_ids:
  114. global_metrics.append({
  115. "metric_id": knowledge_id, # 使用知识ID作为metric_id
  116. "metric_name": metric_name,
  117. "calculation_logic": f"使用规则引擎计算{metric_name}: {metric_description}",
  118. "required_fields": ["transactions"], # 规则引擎使用transactions数据
  119. "dependencies": []
  120. })
  121. used_knowledge_ids.add(knowledge_id)
  122. else:
  123. # 如果没有找到匹配的知识ID,生成一个基本的MetricRequirement作为备选
  124. if not any(m.get("metric_id") == metric_name for m in global_metrics):
  125. print(f"⚠️ 指标 '{metric_name}' 未找到匹配的知识ID,使用默认配置")
  126. global_metrics.append({
  127. "metric_id": metric_name,
  128. "metric_name": metric_name,
  129. "calculation_logic": f"计算{metric_name}: {metric_description}",
  130. "required_fields": ["txAmount", "txDirection"],
  131. "dependencies": []
  132. })
  133. # 完全依赖LLM生成包含所有必需指标的大纲
  134. print(f"🤖 大模型生成 {len(global_metrics)} 个指标")
  135. return {
  136. "report_title": new_format_data.get("chapter_title", "流水分析报告"),
  137. "sections": sections,
  138. "global_metrics": global_metrics
  139. }
  140. def create_prompt(self, question: str, industry: str) -> str:
  141. """创建大纲生成提示"""
  142. # 从API动态获取可用的指标列表
  143. available_metrics = self._load_available_metrics()
  144. # 构建指标列表文本
  145. metrics_list_text = "指标名称\t指标类型\t指标描述\n"
  146. for metric in available_metrics:
  147. metrics_list_text += f"{metric['name']}\t{metric.get('type', '计算型指标')}\t{metric.get('description', '')}\n"
  148. # 构建基础提示词
  149. base_prompt = f"""[角色定义]
  150. 你的角色是: 流水分析报告的大纲生成模块。
  151. 你的目标是:{question},生成一份针对{industry}行业的全面的流水分析报告大纲。
  152. 生成结构清晰、可被程序解析的JSON格式大纲,明确每个章节及其下属分析主题所需的分析指标。
  153. [职责边界]
  154. 你只能完成以下事项:
  155. 1.确定{industry}流水分析报告应包含的章节结构
  156. 2.明确每个章节下需要覆盖的分析主题
  157. 3.为每个分析主题列出所需的计算指标、统计指标或分析指标
  158. 你不得做以下任何事情:
  159. 1.不得计算任何指标
  160. 2.不得对流水数据进行分析
  161. 3.不得判断交易是否异常或存在风险
  162. 4.不得生成任何分析结论、判断性描述或报告正文
  163. 5.不得决定分析执行顺序或分析方法
  164. 你输出的内容仅是"分析需求清单",而不是"分析结果"。
  165. [可用指标总览]
  166. 系统当前支持 {len(available_metrics)} 个指标。
  167. 指标内容为{available_metrics}
  168. [重要要求]
  169. 请根据用户需求和可用指标列表,从上述指标中选择最相关的指标。必须基于用户查询的具体需求进行智能匹配,确保选择的指标能够充分满足分析需求。
  170. [强制要求]
  171. 生成大纲时,请:
  172. 1. 仔细分析用户查询,识别所有提到的分析需求点
  173. 2. 从可用指标中选择能够满足这些需求的完整指标组合
  174. 3. 基于语义相关性进行指标筛选,不要过于保守
  175. 4. 在各章节的metrics对象中,按照指标类型(calculation_metrics/statistical_metrics/analysis_metrics)列出选定的指标
  176. 5. 为每个指标提供metric_name和metric_description字段
  177. 6. 优先选择与用户查询直接相关的指标
  178. [可选择的指标列表]
  179. {metrics_list_text}
  180. [重要说明]
  181. 请确保:
  182. - 从提供的可用指标列表中选择最相关的指标
  183. - 为每个选定的指标提供清晰的名称和描述
  184. - 输出格式必须严格遵循上述JSON结构
  185. - 确保选择的指标能够满足用户查询的具体分析需求
  186. [输出格式要求]
  187. 你必须且只能以 JSON 字符串 形式输出分析大纲,不得输出任何解释性自然语言。
  188. JSON 必须严格遵循以下结构约定:
  189. {{
  190. "chapter_title": "string",
  191. "sections": [
  192. {{
  193. "section_id": "string",
  194. "section_title": "string",
  195. "section_description": "string",
  196. "metrics": {{
  197. "calculation_metrics": [
  198. {{
  199. "metric_name": "string",
  200. "metric_description": "string"
  201. }}
  202. ],
  203. "statistical_metrics": [
  204. {{
  205. "metric_name": "string",
  206. "metric_description": "string"
  207. }}
  208. ],
  209. "analysis_metrics": [
  210. {{
  211. "metric_name": "string",
  212. "metric_description": "string"
  213. }}
  214. ]
  215. }}
  216. }}
  217. ]
  218. }}"""
  219. return base_prompt
  220. print(f"📊 最终生成 {len(global_metrics)} 个指标")
  221. return {
  222. "report_title": new_format_data.get("chapter_title", "流水分析报告"),
  223. "sections": sections,
  224. "global_metrics": global_metrics
  225. }
  226. async def generate_outline(self, question: str, industry: str, sample_data: List[Dict[str, Any]]) -> ReportOutline:
  227. """异步生成大纲(修复版:自动补全缺失字段)"""
  228. prompt = self.create_prompt(question, industry)
  229. # 在prompt末尾添加业务背景信息
  230. full_prompt = f"""{prompt}
  231. 【业务背景信息】
  232. 行业:{industry}
  233. 产品类型:经营贷
  234. 客群类型:小微企业"""
  235. messages = [
  236. ("system", "你是一名专业的报告大纲生成专家,必须输出完整、有效的JSON格式,包含所有必需字段。"),
  237. ("user", full_prompt)
  238. ]
  239. # 记录大模型输入
  240. print("========================================")
  241. print("[AGENT] OutlineGeneratorAgent (大纲生成Agent)")
  242. print(f"[KNOWLEDGE_BASE] 已加载 {len(self.available_knowledge)} 个知识元数据")
  243. if self.available_knowledge:
  244. sample_knowledge = self.available_knowledge[:3] # 显示前3个作为示例
  245. print(f"[KNOWLEDGE_SAMPLE] 示例知识: {[k.get('id', '') for k in sample_knowledge]}")
  246. print("[MODEL_INPUT] OutlineGeneratorAgent:")
  247. print(f"[CONTEXT] 基于用户需求和数据样本生成报告大纲")
  248. print(f"Question: {question}")
  249. print(f"Sample data count: {len(sample_data)}")
  250. print("========================================")
  251. # 执行API调用
  252. start_time = datetime.now()
  253. response = await self.llm.ainvoke(messages)
  254. end_time = datetime.now()
  255. # 解析JSON响应
  256. try:
  257. # 从响应中提取JSON内容
  258. content = response.content if hasattr(response, 'content') else str(response)
  259. # 尝试找到JSON部分
  260. json_start = content.find('{')
  261. json_end = content.rfind('}') + 1
  262. if json_start >= 0 and json_end > json_start:
  263. json_str = content[json_start:json_end]
  264. outline_data = json.loads(json_str)
  265. # 转换新的JSON格式为原来的ReportOutline格式
  266. converted_data = self._convert_new_format_to_outline(outline_data)
  267. outline = ReportOutline(**converted_data)
  268. else:
  269. raise ValueError("No JSON found in response")
  270. except Exception as e:
  271. print(f"解析大纲响应失败: {e},使用默认大纲")
  272. # 不在这里创建大纲,在函数末尾统一处理
  273. # 记录API调用结果
  274. call_id = f"api_mll_大纲生成_{'{:.2f}'.format((end_time - start_time).total_seconds())}"
  275. api_call_info = {
  276. "call_id": call_id,
  277. "timestamp": end_time.isoformat(),
  278. "agent": "OutlineGeneratorAgent",
  279. "model": "deepseek-chat",
  280. "request": {
  281. "question": question,
  282. "sample_data_count": len(sample_data),
  283. "prompt": prompt,
  284. "start_time": start_time.isoformat()
  285. },
  286. "response": {
  287. "content": content,
  288. "end_time": end_time.isoformat(),
  289. "duration": (end_time - start_time).total_seconds()
  290. },
  291. "success": True
  292. }
  293. self.api_calls.append(api_call_info)
  294. # 保存API结果到文件
  295. api_results_dir = "api_results"
  296. os.makedirs(api_results_dir, exist_ok=True)
  297. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  298. filename = f"{timestamp}_{call_id}.json"
  299. filepath = os.path.join(api_results_dir, filename)
  300. try:
  301. with open(filepath, 'w', encoding='utf-8') as f:
  302. json.dump(api_call_info, f, ensure_ascii=False, indent=2)
  303. print(f"[API_RESULT] 保存API结果文件: {filepath}")
  304. except Exception as e:
  305. print(f"[ERROR] 保存API结果文件失败: {filepath}, 错误: {str(e)}")
  306. # 记录大模型输出
  307. print(f"[MODEL_OUTPUT] OutlineGeneratorAgent: {json.dumps(outline.dict() if hasattr(outline, 'dict') else outline, ensure_ascii=False)}")
  308. print("========================================")
  309. # 后处理,补全缺失的section_id和metric_id
  310. outline = self._post_process_outline(outline, question, industry)
  311. return outline
  312. def _post_process_outline(self, outline: ReportOutline, question: str, industry: str) -> ReportOutline:
  313. """
  314. 后处理大纲,自动补全缺失的必需字段,并基于查询优化指标
  315. """
  316. # 为章节补全section_id
  317. for idx, section in enumerate(outline.sections):
  318. if not section.section_id:
  319. section.section_id = f"sec_{idx + 1}"
  320. # 确保metrics_needed是列表
  321. if not isinstance(section.metrics_needed, list):
  322. section.metrics_needed = []
  323. # 为指标补全metric_id和dependencies
  324. for idx, metric in enumerate(outline.global_metrics):
  325. if not metric.metric_id:
  326. metric.metric_id = f"metric_{idx + 1}"
  327. # 确保dependencies是列表
  328. if not isinstance(metric.dependencies, list):
  329. metric.dependencies = []
  330. # 推断required_fields(如果为空)
  331. if not metric.required_fields:
  332. metric.required_fields = self._infer_required_fields(
  333. metric.calculation_logic
  334. )
  335. # 基于用户查询进行指标优化筛选
  336. if hasattr(outline, 'global_metrics') and outline.global_metrics:
  337. print(f"📊 AI选择了 {len(outline.global_metrics)} 个指标,进行智能优化...")
  338. outline = self._optimize_metrics_by_query(outline, question, industry)
  339. return outline
  340. def _infer_required_fields(self, logic: str) -> List[str]:
  341. """从计算逻辑推断所需字段"""
  342. field_mapping = {
  343. "收入": ["txAmount", "txDirection"],
  344. "支出": ["txAmount", "txDirection"],
  345. "余额": ["txBalance"],
  346. "对手方": ["txCounterparty"],
  347. "日期": ["txDate"],
  348. "时间": ["txTime", "txDate"],
  349. "摘要": ["txSummary"],
  350. "创建时间": ["createdAt"]
  351. }
  352. fields = []
  353. for keyword, field_list in field_mapping.items():
  354. if keyword in logic:
  355. fields.extend(field_list)
  356. return list(set(fields))
  357. def _optimize_metrics_by_query(self, outline: ReportOutline, question: str, industry: str) -> ReportOutline:
  358. """
  359. 基于用户查询进行智能指标优化
  360. """
  361. # 获取所有可用指标
  362. available_metrics = self._load_available_metrics()
  363. # 已选择的指标名称集合
  364. selected_metric_names = {m.metric_name for m in outline.global_metrics}
  365. # 基于用户查询进行语义匹配,找出缺失的关键指标
  366. query_keywords = self._extract_query_keywords(question, industry)
  367. missing_key_metrics = self._find_missing_key_metrics(
  368. available_metrics, selected_metric_names, query_keywords,industry
  369. )
  370. # 补充缺失的关键指标
  371. supplemented_count = 0
  372. for metric_name in missing_key_metrics:
  373. # 找到对应的可用指标信息
  374. available_metric = next((m for m in available_metrics if m['name'] == metric_name), None)
  375. if available_metric:
  376. # 创建MetricRequirement对象
  377. metric_req = MetricRequirement(
  378. metric_id=f"metric-{metric_name}",
  379. metric_name=metric_name,
  380. calculation_logic=f"使用规则引擎计算{metric_name}",
  381. required_fields=["transactions"],
  382. dependencies=[]
  383. )
  384. outline.global_metrics.append(metric_req)
  385. supplemented_count += 1
  386. print(f" 补充关键指标: {metric_name}")
  387. if supplemented_count > 0:
  388. print(f"✅ 基于查询分析补充了 {supplemented_count} 个关键指标,总计 {len(outline.global_metrics)} 个指标")
  389. # 智能分配章节指标需求
  390. self._smart_assign_section_metrics(outline)
  391. return outline
  392. def _extract_query_keywords(self, question: str, industry: str) -> List[str]:
  393. """
  394. 通过大模型从用户查询中提取关键词
  395. Args:
  396. question: 用户查询
  397. industry: 行业信息
  398. Returns:
  399. 关键词列表
  400. """
  401. try:
  402. keyword_prompt = ChatPromptTemplate.from_messages([
  403. ("system", """你是一个专业的关键词提取专家,需要从用户查询中提取关键的分析指标和业务术语。
  404. 请分析查询内容,识别出用户关心的核心指标、分析维度和业务概念。
  405. 返回格式:
  406. 请以逗号分隔的关键词列表形式返回,不要其他解释。
  407. 示例:
  408. 收入分析, 支出统计, 交易对手, 时间趋势, 占比分析"""),
  409. ("human", """用户查询:{question}
  410. 行业背景:{industry}
  411. 请提取这个查询中的关键分析指标和业务术语。""")
  412. ])
  413. chain = keyword_prompt | self.llm
  414. result = chain.invoke({
  415. "question": question,
  416. "industry": industry
  417. })
  418. keywords_text = result.content.strip()
  419. # 按逗号分割并清理空白
  420. keywords = [kw.strip() for kw in keywords_text.split(',') if kw.strip()]
  421. print(f"🔍 提取到查询关键词: {keywords}")
  422. return keywords
  423. except Exception as e:
  424. print(f"⚠️ 关键词提取失败,使用简单分词: {str(e)}")
  425. # 备选方案:简单的文本分词
  426. import re
  427. # 移除标点符号,提取中文词组
  428. text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z]', ' ', question)
  429. words = [w for w in text.split() if len(w) > 1]
  430. print(f"🔄 备选关键词: {words}")
  431. return words
  432. def _find_missing_key_metrics(self, available_metrics: List[Dict], selected_metric_names: set,
  433. query_keywords: List[str],industry: str) -> List[str]:
  434. """
  435. 基于查询关键词找出缺失的关键指标
  436. Args:
  437. available_metrics: 所有可用指标
  438. selected_metric_names: 已选择的指标名称集合
  439. query_keywords: 查询关键词
  440. Returns:
  441. 缺失的关键指标名称列表
  442. """
  443. if not query_keywords or not available_metrics:
  444. return []
  445. try:
  446. missing_prompt = ChatPromptTemplate.from_messages([
  447. ("system", """
  448. 你是一个专业的指标推荐专家,需要根据用户查询的关键词,识别出可能缺失的关键指标。
  449. 强制要求:只能选择和{industry}相关的指标
  450. 请分析:
  451. 1. 用户关心的分析维度(收入、支出、排名、趋势等)
  452. 2. 已选择的指标
  453. 3. 可用的指标库
  454. 推荐一些重要的缺失指标,帮助完善分析报告。
  455. 返回格式:
  456. 只返回指标名称列表,用换行符分隔,不要其他解释。
  457. 示例:
  458. 总收入分析
  459. 支出占比统计
  460. 交易对手排名"""),
  461. ("human", """查询关键词:{keywords}
  462. 已选择的指标:
  463. {selected_metrics}
  464. 可用指标库:
  465. {available_metrics}
  466. 请推荐一些重要的缺失指标。""")
  467. ])
  468. # 格式化输入
  469. selected_list = '\n'.join(selected_metric_names) if selected_metric_names else '无'
  470. available_list = '\n'.join([m.get('name', '') for m in available_metrics if m.get('name')])
  471. chain = missing_prompt | self.llm
  472. result = chain.invoke({
  473. "keywords": ', '.join(query_keywords),
  474. "selected_metrics": selected_list,
  475. "available_metrics": available_list,
  476. "industry": industry
  477. })
  478. # 解析结果
  479. recommended_metrics = []
  480. for line in result.content.strip().split('\n'):
  481. metric_name = line.strip()
  482. if metric_name and metric_name not in selected_metric_names:
  483. # 验证指标是否存在于可用指标库中
  484. if any(m.get('name') == metric_name for m in available_metrics):
  485. recommended_metrics.append(metric_name)
  486. print(f"📊 推荐缺失指标: {recommended_metrics}")
  487. return recommended_metrics
  488. except Exception as e:
  489. print(f"⚠️ 指标推荐失败: {str(e)}")
  490. return []
  491. def _smart_assign_section_metrics(self, outline: ReportOutline) -> None:
  492. """
  493. 智能分配章节指标需求
  494. Args:
  495. outline: 报告大纲对象,会被原地修改
  496. """
  497. if not outline.sections or not outline.global_metrics:
  498. return
  499. try:
  500. # 获取所有可用的指标ID
  501. available_metric_ids = {m.metric_id for m in outline.global_metrics}
  502. assign_prompt = ChatPromptTemplate.from_messages([
  503. ("system", """
  504. 你是一个专业的报告结构专家,需要将全局指标智能分配到各个章节。
  505. 分配原则:
  506. 1. 每个章节分配3-5个最相关的指标
  507. 2. 指标应与章节内容高度相关
  508. 3. 避免重复分配相同的指标
  509. 4. 优先分配核心指标到主要章节
  510. 返回格式:
  511. 为每个章节返回指标ID列表,用分号分隔章节,格式如下:
  512. 章节ID:指标ID1,指标ID2,指标ID3
  513. 示例:
  514. sec_1:metric-total_income,metric-expense_trend,metric-profit_margin
  515. sec_2:metric-customer_analysis,metric-market_share"""),
  516. ("human", """报告标题:{report_title}
  517. 章节列表:
  518. {sections}
  519. 可用指标:
  520. {available_metrics}
  521. 请为每个章节分配最合适的指标ID。""")
  522. ])
  523. # 格式化输入
  524. sections_text = '\n'.join([
  525. f"{section.section_id}: {section.title} - {section.description}"
  526. for section in outline.sections
  527. ])
  528. available_metrics_text = '\n'.join([
  529. f"{m.metric_id}: {m.metric_name} - {m.calculation_logic}"
  530. for m in outline.global_metrics
  531. ])
  532. chain = assign_prompt | self.llm
  533. result = chain.invoke({
  534. "report_title": outline.report_title,
  535. "sections": sections_text,
  536. "available_metrics": available_metrics_text
  537. })
  538. # 解析结果并分配指标
  539. lines = result.content.strip().split('\n')
  540. assigned_metrics = set() # 避免重复分配
  541. for line in lines:
  542. if ':' not in line:
  543. continue
  544. section_id, metrics_str = line.split(':', 1)
  545. section_id = section_id.strip()
  546. # 找到对应的章节
  547. section = next((s for s in outline.sections if s.section_id == section_id), None)
  548. if not section:
  549. continue
  550. # 解析指标ID列表
  551. metric_ids = [mid.strip() for mid in metrics_str.split(',') if mid.strip()]
  552. # 验证指标ID并分配(避免重复)
  553. valid_metrics = []
  554. for metric_id in metric_ids:
  555. if metric_id in available_metric_ids and metric_id not in assigned_metrics:
  556. valid_metrics.append(metric_id)
  557. assigned_metrics.add(metric_id)
  558. # 每个章节最多分配5个指标
  559. if len(valid_metrics) >= 5:
  560. break
  561. section.metrics_needed = valid_metrics
  562. print(f"📋 章节 '{section.title}' 分配了 {len(valid_metrics)} 个指标: {valid_metrics}")
  563. # 检查是否有章节没有分配到指标,如果有则平均分配剩余指标
  564. unassigned_sections = [s for s in outline.sections if not s.metrics_needed]
  565. remaining_metrics = [m.metric_id for m in outline.global_metrics if m.metric_id not in assigned_metrics]
  566. if unassigned_sections and remaining_metrics:
  567. print(f"🔄 为 {len(unassigned_sections)} 个未分配章节平均分配剩余指标")
  568. metrics_per_section = max(1, len(remaining_metrics) // len(unassigned_sections))
  569. for i, section in enumerate(unassigned_sections):
  570. start_idx = i * metrics_per_section
  571. end_idx = min(start_idx + metrics_per_section, len(remaining_metrics))
  572. section.metrics_needed = remaining_metrics[start_idx:end_idx]
  573. print(f"📋 章节 '{section.title}' 分配了 {len(section.metrics_needed)} 个指标: {section.metrics_needed}")
  574. except Exception as e:
  575. print(f"⚠️ 智能指标分配失败,使用平均分配: {str(e)}")
  576. # 备选方案:平均分配所有指标到各个章节
  577. if outline.sections and outline.global_metrics:
  578. all_metric_ids = [m.metric_id for m in outline.global_metrics]
  579. metrics_per_section = max(1, len(all_metric_ids) // len(outline.sections))
  580. for i, section in enumerate(outline.sections):
  581. start_idx = i * metrics_per_section
  582. end_idx = min(start_idx + metrics_per_section, len(all_metric_ids))
  583. section.metrics_needed = all_metric_ids[start_idx:end_idx]
  584. print(f"🔄 备选分配 - 章节 '{section.title}' 分配了 {len(section.metrics_needed)} 个指标")
  585. def _load_available_knowledge(self) -> List[Dict[str, Any]]:
  586. """
  587. 从规则引擎获取可用的知识元数据
  588. Returns:
  589. 知识元数据列表,包含id和description
  590. """
  591. try:
  592. url = f"{RULES_ENGINE_BASE_URL}/api/rules/getKnowledgeMeta"
  593. headers = {
  594. "Accept": "*/*",
  595. "Accept-Encoding": "gzip, deflate, br",
  596. "Connection": "keep-alive",
  597. "Content-Type": "application/json",
  598. "User-Agent": "PostmanRuntime-ApipostRuntime/1.1.0"
  599. }
  600. response = requests.post(url, headers=headers, json={}, timeout=30)
  601. if response.status_code == 200:
  602. knowledge_meta = response.json()
  603. if isinstance(knowledge_meta, list):
  604. print(f"✅ 成功获取 {len(knowledge_meta)} 个知识元数据")
  605. return knowledge_meta
  606. else:
  607. print(f"⚠️ 知识元数据格式异常: {knowledge_meta}")
  608. return []
  609. else:
  610. print(f"❌ 获取知识元数据失败,状态码: {response.status_code}")
  611. print(f"响应内容: {response.text}")
  612. return []
  613. except Exception as e:
  614. print(f"❌ 获取知识元数据时发生错误: {str(e)}")
  615. return []
  616. def _load_available_metrics(self) -> List[Dict[str, str]]:
  617. """
  618. 从知识库中提取可用的指标列表
  619. Returns:
  620. 指标列表,包含name和description字段
  621. """
  622. knowledge_list = self._load_available_knowledge()
  623. metrics = []
  624. for knowledge in knowledge_list:
  625. knowledge_id = knowledge.get("id", "")
  626. description = knowledge.get("description", "")
  627. # 从知识ID中提取指标名称
  628. if knowledge_id.startswith("metric-"):
  629. metric_name = knowledge_id.replace("metric-", "")
  630. # 从描述中提取更简洁的指标描述
  631. short_description = self._extract_metric_description(description)
  632. metrics.append({
  633. "name": metric_name,
  634. "description": short_description,
  635. "type": self._classify_metric_type(metric_name, description)
  636. })
  637. print(f"✅ 从知识库中提取了 {len(metrics)} 个可用指标")
  638. return metrics
  639. def _extract_metric_description(self, full_description: str) -> str:
  640. """从完整描述中提取简洁的指标描述"""
  641. # 移除"因子概述:"等前缀
  642. description = full_description.replace("因子概述:", "").strip()
  643. # 如果描述太长,取前50个字符
  644. if len(description) > 50:
  645. description = description[:50] + "..."
  646. return description
  647. def _classify_metric_type(self, metric_name: str, description: str) -> str:
  648. """根据指标名称和描述分类指标类型"""
  649. if any(keyword in metric_name for keyword in ["收入", "支出", "金额", "交易笔数"]):
  650. return "基础统计指标"
  651. elif any(keyword in metric_name for keyword in ["时间范围", "时间跨度"]):
  652. return "时间分析指标"
  653. elif any(keyword in metric_name for keyword in ["比例", "占比", "构成"]):
  654. return "结构分析指标"
  655. elif any(keyword in metric_name for keyword in ["排名", "TOP", "前三"]):
  656. return "专项分析指标"
  657. elif any(keyword in metric_name for keyword in ["账户", "数量"]):
  658. return "账户分析指标"
  659. else:
  660. return "其他指标"
  661. def _match_metric_to_knowledge(self, metric_name: str, metric_description: str) -> str:
  662. """
  663. 通过大模型判断指标是否与可用知识匹配
  664. Args:
  665. metric_name: 指标名称
  666. metric_description: 指标描述
  667. Returns:
  668. 匹配的知识ID,如果没有找到则返回空字符串
  669. """
  670. if not self.available_knowledge:
  671. return ""
  672. # 首先尝试精确匹配:直接用指标名称匹配知识ID
  673. for knowledge in self.available_knowledge:
  674. knowledge_id = knowledge.get("id", "")
  675. # 去掉前缀匹配,如 "metric-分析账户数量" 匹配 "分析账户数量"
  676. if knowledge_id.startswith("metric-") and knowledge_id.replace("metric-", "") == metric_name:
  677. print(f"🔗 精确匹配指标 '{metric_name}' -> 知识ID: {knowledge_id}")
  678. return knowledge_id
  679. # 使用大模型进行语义匹配
  680. match_prompt = ChatPromptTemplate.from_messages([
  681. ("system", """
  682. 你是一个专业的指标匹配专家,需要根据指标名称和描述,从提供的知识库中找到最合适的匹配项。
  683. 请分析指标的语义含义和计算逻辑,判断哪个知识项最匹配。
  684. 返回格式:
  685. 如果找到匹配:返回知识ID
  686. 如果未找到匹配:返回空字符串 ""
  687. 只返回知识ID或空字符串,不要其他解释。"""),
  688. ("human", """指标信息:
  689. 名称:{metric_name}
  690. 描述:{metric_description}
  691. 可用知识库:
  692. {knowledge_list}
  693. 请判断这个指标是否与知识库中的某个项目匹配。如果匹配,返回对应的知识ID;如果不匹配,返回空字符串。""")
  694. ])
  695. # 构建知识库描述
  696. knowledge_list = "\n".join([
  697. f"ID: {k.get('id', '')}\n描述: {k.get('description', '')}"
  698. for k in self.available_knowledge
  699. ])
  700. try:
  701. # 调用大模型进行匹配
  702. chain = match_prompt | self.llm
  703. result = chain.invoke({
  704. "metric_name": metric_name,
  705. "metric_description": metric_description or "无描述",
  706. "knowledge_list": knowledge_list
  707. })
  708. matched_knowledge_id = result.content.strip()
  709. # 验证返回的知识ID是否存在于可用知识中
  710. if matched_knowledge_id and any(k.get("id") == matched_knowledge_id for k in self.available_knowledge):
  711. print(f"🤖 大模型匹配指标 '{metric_name}' -> 知识ID: {matched_knowledge_id}")
  712. return matched_knowledge_id
  713. else:
  714. print(f"❌ 大模型未找到指标 '{metric_name}' 的匹配项")
  715. return ""
  716. except Exception as e:
  717. print(f"⚠️ 大模型匹配失败,使用备选方案: {str(e)}")
  718. # 备选方案:简单的关键词匹配(不包含特定业务逻辑)
  719. for knowledge in self.available_knowledge:
  720. knowledge_id = knowledge.get("id", "")
  721. knowledge_desc = knowledge.get("description", "").lower()
  722. # 检查指标名称是否在知识描述中出现
  723. if metric_name.lower() in knowledge_desc:
  724. print(f"🔄 备选匹配指标 '{metric_name}' -> 知识ID: {knowledge_id}")
  725. return knowledge_id
  726. print(f"❌ 指标 '{metric_name}' 未找到匹配的知识ID")
  727. return ""
  728. async def generate_report_outline(question: str, industry: str, sample_data: List[Dict[str, Any]], api_key: str, max_retries: int = 3, retry_delay: float = 2.0) -> ReportOutline:
  729. """
  730. 生成报告大纲的主函数,支持重试机制
  731. Args:
  732. question: 用户查询问题
  733. industry: 行业
  734. sample_data: 数据样本
  735. api_key: API密钥
  736. max_retries: 最大重试次数,默认3次
  737. retry_delay: 重试间隔时间(秒),默认2秒
  738. Returns:
  739. 生成的报告大纲
  740. """
  741. import asyncio
  742. import time
  743. agent = OutlineGeneratorAgent(api_key)
  744. print(f"📝 开始生成报告大纲(最多重试 {max_retries} 次)...")
  745. for attempt in range(max_retries):
  746. try:
  747. print(f" 尝试 {attempt + 1}/{max_retries}...")
  748. start_time = time.time()
  749. outline = await agent.generate_outline(question, industry, sample_data)
  750. elapsed_time = time.time() - start_time
  751. print(".2f")
  752. print("\n📝 大纲生成成功:")
  753. print(f" 标题:{outline.report_title}")
  754. print(f" 章节数:{len(outline.sections)}")
  755. print(f" 指标数:{len(outline.global_metrics)}")
  756. return outline
  757. except Exception as e:
  758. elapsed_time = time.time() - start_time if 'start_time' in locals() else 0
  759. print(".2f")
  760. print(f" 错误详情: {str(e)}")
  761. # 如果不是最后一次尝试,等待后重试
  762. if attempt < max_retries - 1:
  763. print(f" ⏳ {retry_delay} 秒后进行第 {attempt + 2} 次重试...")
  764. await asyncio.sleep(retry_delay)
  765. # 增加重试间隔,避免频繁调用
  766. retry_delay = min(retry_delay * 1.5, 10.0) # 最多等待10秒
  767. else:
  768. print(f" ❌ 已达到最大重试次数 ({max_retries}),使用默认结构")
  769. # 所有重试都失败后,使用默认结构
  770. print("⚠️ 所有重试均失败,使用默认大纲结构")
  771. # 获取实际可用的指标来构建默认大纲
  772. available_metrics = agent._load_available_metrics()
  773. # 选择一些基础指标作为默认值
  774. default_metric_ids = []
  775. default_global_metrics = []
  776. # 优先选择基础统计指标
  777. base_metrics = [m for m in available_metrics if m.get('type') == '基础统计指标']
  778. if base_metrics:
  779. # 选择前3个基础指标
  780. for metric in base_metrics[:3]:
  781. metric_name = metric['name']
  782. knowledge_id = f"metric-{metric_name}"
  783. default_metric_ids.append(knowledge_id)
  784. default_global_metrics.append(MetricRequirement(
  785. metric_id=knowledge_id,
  786. metric_name=metric_name,
  787. calculation_logic=f"使用规则引擎计算{metric_name}: {metric.get('description', '')}",
  788. required_fields=["transactions"],
  789. dependencies=[]
  790. ))
  791. # 如果基础指标不够,补充其他类型的指标
  792. if len(default_metric_ids) < 3:
  793. other_metrics = [m for m in available_metrics if m.get('type') != '基础统计指标']
  794. for metric in other_metrics[:3-len(default_metric_ids)]:
  795. metric_name = metric['name']
  796. knowledge_id = f"metric-{metric_name}"
  797. default_metric_ids.append(knowledge_id)
  798. default_global_metrics.append(MetricRequirement(
  799. metric_id=knowledge_id,
  800. metric_name=metric_name,
  801. calculation_logic=f"使用规则引擎计算{metric_name}: {metric.get('description', '')}",
  802. required_fields=["transactions"],
  803. dependencies=[]
  804. ))
  805. # 创建使用实际指标的默认大纲
  806. default_outline = ReportOutline(
  807. report_title="默认交易分析报告",
  808. sections=[
  809. ReportSection(
  810. section_id="sec_1",
  811. title="交易概览",
  812. description="基础交易情况分析",
  813. metrics_needed=default_metric_ids
  814. )
  815. ],
  816. global_metrics=default_global_metrics
  817. )
  818. return default_outline