report_generator.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. """
  2. 报告生成工具 — auto_put_ad_mini V3
  3. 职责:
  4. - 读取决策引擎输出
  5. - 生成 CSV(程序用)+ XLSX(人工查阅,带条件格式)
  6. - 输出到 outputs/reports/
  7. """
  8. import logging
  9. from datetime import datetime, timedelta
  10. from pathlib import Path
  11. from typing import Optional
  12. import pandas as pd
  13. from agent.tools import tool
  14. from agent.tools.models import ToolContext, ToolResult
  15. logger = logging.getLogger(__name__)
  16. _MINI_DIR = Path(__file__).resolve().parent.parent
  17. _REPORTS_DIR = _MINI_DIR / "outputs" / "reports"
  18. # 最终输出列顺序(审批表格:简洁版,去掉技术性列)
  19. # 与 im_approval.py 的 APPROVAL_COLUMNS 保持一致(15列精简版)
  20. OUTPUT_COLUMNS = [
  21. # 核心标识(前5列,含决策动作)
  22. "approval_date", "account_id", "ad_id", "cost_7d_avg", "action",
  23. # 基础信息
  24. "ad_name", "audience_tier", "ad_age_days", "bid_amount",
  25. # 关键指标
  26. "动态ROI_7日均值", "cost_7d_total", "revenue_7d_total",
  27. # 决策详情
  28. "dimension", "reason",
  29. "recommended_change_pct",
  30. ]
  31. # 中文列名映射
  32. CN_COLUMNS = {
  33. "approval_date": "日期",
  34. "account_id": "账户ID",
  35. "ad_id": "广告ID",
  36. "cost_7d_avg": "广告消耗(7日日均/元)",
  37. "ad_name": "广告名称",
  38. "audience_tier": "人群包",
  39. "create_time": "创建时间",
  40. "ad_age_days": "投放天数",
  41. "configured_status": "广告状态",
  42. "bid_amount": "出价(元)",
  43. "yesterday_cost": "昨日消耗(元)",
  44. "yesterday_revenue": "昨日收入(元)",
  45. "yesterday_roi": "昨日ROI",
  46. "cost_7d_total": "7日总消耗(元)",
  47. "revenue_7d_total": "7日总收入(元)",
  48. "T0裂变系数_latest": "T0裂变系数(最新)",
  49. "arpu_latest": "ARPU(最新)",
  50. "a_latest": "a值(最新)",
  51. "b_7d_mean": "b值(7日均值)",
  52. "T0裂变系数_7d_mean": "T0裂变系数(7日均值)",
  53. "e_factor": "e因子",
  54. "f_7日动态ROI": "7日均值动态ROI",
  55. "动态ROI": "动态ROI(单日)",
  56. "动态ROI_7日均值": "动态ROI(7日均值)",
  57. "cost_30d_total": "30日总消耗(元)",
  58. "cost_30d_avg": "30日日均消耗(元)",
  59. "stable_spend_days_30d": "稳定消耗天数(30日)",
  60. "creative_count": "创意数",
  61. "action": "决策动作",
  62. "dimension": "命中维度",
  63. "reason": "决策理由",
  64. "recommended_change_pct": "建议调幅(%)",
  65. "current_bid": "当前出价(元)",
  66. "recommended_bid": "建议出价(元)",
  67. "guardrail_reason": "护栏说明",
  68. "execution_status": "执行状态",
  69. "f_7日动态ROI_mean_all": "全体动态ROI均值",
  70. "source": "数据来源",
  71. }
  72. # 动作中文映射
  73. ACTION_CN_MAP = {
  74. "pause": "关停",
  75. "bid_down": "降价",
  76. "bid_up": "提价",
  77. "hold": "保持",
  78. "scale_up": "扩量",
  79. "observe": "观察",
  80. }
  81. def _write_xlsx_with_format(df: pd.DataFrame, path: Path) -> None:
  82. """生成带条件格式的 XLSX 文件。"""
  83. try:
  84. import openpyxl
  85. from openpyxl.styles import Font, Alignment, PatternFill
  86. from openpyxl.utils import get_column_letter
  87. except ImportError:
  88. logger.warning("openpyxl 未安装,跳过 XLSX 生成")
  89. return
  90. # 动作中文化
  91. if "action" in df.columns:
  92. df["action"] = df["action"].map(ACTION_CN_MAP).fillna(df["action"])
  93. # 中文列名
  94. df_cn = df.rename(columns=CN_COLUMNS)
  95. df_cn.to_excel(path, index=False, engine="openpyxl")
  96. wb = openpyxl.load_workbook(path)
  97. ws = wb.active
  98. # 表头样式(加粗 + 灰色背景)
  99. header_font = Font(bold=True, size=10)
  100. header_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") # 灰色背景
  101. center_alignment = Alignment(horizontal="center", vertical="center")
  102. for cell in ws[1]:
  103. cell.font = header_font
  104. cell.fill = header_fill
  105. cell.alignment = center_alignment
  106. # 所有数据单元格居中对齐
  107. for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
  108. for cell in row:
  109. cell.alignment = center_alignment
  110. # 自动列宽(增加30%)
  111. for col_idx in range(1, ws.max_column + 1):
  112. col_letter = get_column_letter(col_idx)
  113. max_len = max(
  114. len(str(ws.cell(row=r, column=col_idx).value or ""))
  115. for r in range(1, min(ws.max_row + 1, 50))
  116. )
  117. # 列宽增加30%:原公式 max_len + 4,现改为 (max_len + 4) * 1.3
  118. ws.column_dimensions[col_letter].width = min((max_len + 4) * 1.3, 40)
  119. # 决策动作列条件格式化
  120. action_col_idx = None
  121. for idx, cell in enumerate(ws[1], 1):
  122. if cell.value == "决策动作":
  123. action_col_idx = idx
  124. # E1 单元格(决策动作表头)黄色高亮
  125. yellow_header_fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")
  126. cell.fill = yellow_header_fill
  127. break
  128. if action_col_idx:
  129. # 颜色定义
  130. yellow_fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid") # 黄色(整列默认)
  131. green_fill = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid") # 浅绿色
  132. red_fill = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid") # 红色
  133. orange_fill = PatternFill(start_color="FFB84D", end_color="FFB84D", fill_type="solid") # 橘黄色
  134. # 为决策动作列的所有数据单元格设置颜色
  135. for row_idx in range(2, ws.max_row + 1):
  136. cell = ws.cell(row=row_idx, column=action_col_idx)
  137. value = str(cell.value).strip() if cell.value else ""
  138. # 根据值设置颜色
  139. if value == "保持":
  140. cell.fill = green_fill
  141. elif value == "关停":
  142. cell.fill = red_fill
  143. elif value == "降价":
  144. cell.fill = orange_fill
  145. else:
  146. # 其他值(如"提价")使用黄色
  147. cell.fill = yellow_fill
  148. # 启用自动筛选(首行)
  149. ws.auto_filter.ref = ws.dimensions
  150. # 冻结窗格:锁定第一行和前五列(冻结到F2单元格)
  151. ws.freeze_panes = "F2"
  152. wb.save(path)
  153. logger.info("XLSX 已生成: %s", path)
  154. @tool(description="生成决策报告(CSV + XLSX 带条件格式)")
  155. async def generate_report(
  156. ctx: ToolContext = None,
  157. decision_csv: str = "",
  158. end_date: str = "yesterday",
  159. ) -> ToolResult:
  160. """
  161. 生成决策报告。
  162. Args:
  163. decision_csv: 决策结果 CSV 路径(apply_decisions 输出)
  164. end_date: 结束日期
  165. Returns:
  166. ToolResult,包含报告路径
  167. """
  168. try:
  169. if not decision_csv:
  170. decision_csv = str(_MINI_DIR / "outputs" / "decision_temp.csv")
  171. df = pd.read_csv(decision_csv)
  172. if df.empty:
  173. return ToolResult(title="报告生成", output="决策数据为空")
  174. if end_date == "yesterday":
  175. end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
  176. _REPORTS_DIR.mkdir(parents=True, exist_ok=True)
  177. # 添加审批日期列(当前日期)
  178. df["approval_date"] = datetime.now().strftime("%Y-%m-%d")
  179. # 选择输出列(存在的列)
  180. cols = [c for c in OUTPUT_COLUMNS if c in df.columns]
  181. df_out = df[cols].copy()
  182. # 排序:有消耗的在前,无消耗的在后,同组内按消耗降序
  183. sort_cols = []
  184. ascending_flags = []
  185. if "cost_7d_avg" in df_out.columns:
  186. # 无消耗(cost_7d_avg<10元)放最后
  187. df_out["_has_spend"] = (df_out["cost_7d_avg"] >= 10.0).astype(int)
  188. sort_cols.append("_has_spend")
  189. ascending_flags.append(False) # 1在前,0在后
  190. if "cost_7d_total" in df_out.columns:
  191. sort_cols.append("cost_7d_total")
  192. ascending_flags.append(False)
  193. if sort_cols:
  194. df_out = df_out.sort_values(sort_cols, ascending=ascending_flags)
  195. if "_has_spend" in df_out.columns:
  196. df_out.drop(columns=["_has_spend"], inplace=True)
  197. # CSV
  198. csv_path = _REPORTS_DIR / f"decision_{end_date}.csv"
  199. df_out.to_csv(csv_path, index=False, encoding="utf-8-sig")
  200. logger.info("CSV 已生成: %s", csv_path)
  201. # XLSX
  202. xlsx_path = _REPORTS_DIR / f"decision_{end_date}.xlsx"
  203. _write_xlsx_with_format(df_out, xlsx_path)
  204. pause_count = (df_out["action"] == "pause").sum() if "action" in df_out.columns else 0
  205. total_count = len(df_out)
  206. return ToolResult(
  207. title=f"报告已生成({total_count}个广告)",
  208. output=f"报告已生成:\n CSV: {csv_path}\n XLSX: {xlsx_path}\n\n共 {total_count} 个广告,其中 {pause_count} 个建议关停",
  209. metadata={
  210. "csv_path": str(csv_path),
  211. "xlsx_path": str(xlsx_path),
  212. "total_ads": total_count,
  213. "pause_ads": pause_count,
  214. },
  215. )
  216. except Exception as e:
  217. logger.error("报告生成失败: %s", e, exc_info=True)
  218. return ToolResult(title="报告生成失败", output=str(e))