""" 调用大模型生成专家级分析报告。 将结构化数据 + Prompt 发送给 LLM,返回深度洞察。 """ import json import logging import os from typing import Any import requests from dotenv import load_dotenv from pathlib import Path load_dotenv(Path(__file__).resolve().parent.parent / ".env") log = logging.getLogger("ad-agent") DEFAULT_API_BASE = "https://api.openai.com/v1/chat/completions" DEFAULT_MODEL = "gpt-4o-mini" TREND_METRICS_FOR_DISPLAY = ["Cost", "Ads.Sales", "ROAS", "CPC", "CTR", "CVR", "GA4.Sessions"] def match_business_events(config: dict, period_label: str) -> list[dict]: """根据分析周期匹配 config 中的 business_events,返回命中的事件列表。""" from datetime import datetime, date events = config.get("business_events") or [] if not events: return [] dates_in_period: list[date] = [] # 尝试解析各种 period_label 格式 # 日期: "2026-03-03" try: dates_in_period.append(datetime.strptime(period_label.strip(), "%Y-%m-%d").date()) except ValueError: pass # 时间段: "2026-01-15 ~ 2026-03-03" if "~" in period_label: parts = period_label.split("~") try: d1 = datetime.strptime(parts[0].strip(), "%Y-%m-%d").date() d2 = datetime.strptime(parts[1].strip(), "%Y-%m-%d").date() dates_in_period.extend([d1, d2]) except ValueError: pass # 月度: "2026年2月" import re m = re.search(r"(\d{4})年(\d{1,2})月", period_label) if m: y, mo = int(m.group(1)), int(m.group(2)) dates_in_period.append(date(y, mo, 1)) last_day = 28 for d in [31, 30, 29, 28]: try: dates_in_period.append(date(y, mo, d)) break except ValueError: continue # 周度: "2026 W9" — 粗略用 ISO 周的周一 m = re.search(r"(\d{4})\s*W(\d{1,2})", period_label) if m: y, w = int(m.group(1)), int(m.group(2)) try: d = datetime.strptime(f"{y}-W{w:02d}-1", "%Y-W%W-%w").date() dates_in_period.extend([d + __import__("datetime").timedelta(days=i) for i in range(7)]) except ValueError: pass if not dates_in_period: return [] matched = [] for ev in events: try: ev_start = datetime.strptime(str(ev["start"]), "%Y-%m-%d").date() ev_end = datetime.strptime(str(ev["end"]), "%Y-%m-%d").date() except (ValueError, KeyError): continue for d in dates_in_period: if ev_start <= d <= ev_end: matched.append(ev) break return matched def build_business_context(config: dict, period_label: str, user_note: str = "") -> str | None: """构建业务背景上下文字符串。""" parts = [] events = match_business_events(config, period_label) if events: parts.append("【业务背景 — 系统自动匹配】") for ev in events: line = f"- {ev.get('start', '')}~{ev.get('end', '')}: {ev.get('event', '')}" if ev.get("note"): line += f"({ev['note']})" parts.append(line) if user_note and user_note.strip(): parts.append("【用户补充的业务备注】") parts.append(user_note.strip()) return "\n".join(parts) if parts else None def _get_config() -> tuple[str, str, str]: api_key = os.environ.get("OPENAI_API_KEY", "").strip() api_base = os.environ.get("OPENAI_API_BASE", DEFAULT_API_BASE).strip() model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL).strip() return api_key, api_base, model def _build_data_summary( curr: dict[str, Any], prev: dict[str, Any] | None, wow_total: dict | None, wow_by_platform: dict | None, wow_by_campaign: dict | None, wow_by_country: dict | None, yoy_data: dict[str, Any] | None = None, yoy_total: dict | None = None, yoy_by_platform: dict | None = None, yoy_by_country: dict | None = None, trend_table: list[dict] | None = None, memory_context: str | None = None, business_context: str | None = None, ) -> str: """将结构化指标数据压缩为 LLM 可读的文本摘要。""" lines = [] def fmt(d: dict, prefix: str = ""): parts = [] for k, v in d.items(): if isinstance(v, (int, float)) and v == v: if abs(v) >= 1000: parts.append(f"{k}: {v:,.0f}") else: parts.append(f"{k}: {v:.4f}") return f"{prefix}{', '.join(parts)}" def fmt_chg(d: dict, prefix: str = ""): parts = [] for k, v in d.items(): if v is not None and v == v: parts.append(f"{k}: {v:+.1f}%") return f"{prefix}{', '.join(parts)}" tc = curr.get("total", {}) lines.append("【整体数据】") lines.append(fmt(tc)) if wow_total: lines.append(fmt_chg(wow_total, "环比变化: ")) if yoy_total: lines.append(fmt_chg(yoy_total, "同比变化: ")) for plat in sorted(curr.get("by_platform", {}).keys()): pd_ = curr["by_platform"][plat] if pd_: lines.append(f"\n【{plat} 平台】") lines.append(fmt(pd_)) if wow_by_platform and plat in wow_by_platform: lines.append(fmt_chg(wow_by_platform[plat], "环比: ")) if yoy_by_platform and plat in yoy_by_platform: lines.append(fmt_chg(yoy_by_platform[plat], "同比: ")) for ct in ["Awareness", "Consideration", "Conversion"]: cd = curr.get("by_campaign_type", {}).get(ct) if cd: lines.append(f"\n【{ct} 漏斗】") lines.append(fmt(cd)) if wow_by_campaign and ct in wow_by_campaign: lines.append(fmt_chg(wow_by_campaign[ct], "环比: ")) for cn in sorted(curr.get("by_country", {}).keys()): cd = curr["by_country"][cn] lines.append(f"\n【{cn} 市场】") lines.append(fmt(cd)) if wow_by_country and cn in wow_by_country: lines.append(fmt_chg(wow_by_country[cn], "环比: ")) if yoy_by_country and cn in yoy_by_country: lines.append(fmt_chg(yoy_by_country[cn], "同比: ")) if trend_table and len(trend_table) >= 2: lines.append("\n【近期趋势】") header = "周期 | " + " | ".join(TREND_METRICS_FOR_DISPLAY) lines.append(header) for row in trend_table: vals = [] for m in TREND_METRICS_FOR_DISPLAY: v = row.get(m) if v is None or v != v: vals.append("—") elif m in ("CTR", "CVR"): vals.append(f"{v*100:.2f}%") elif abs(v) >= 1000: vals.append(f"{v:,.0f}") else: vals.append(f"{v:.2f}") lines.append(f"{row['label']} | " + " | ".join(vals)) if business_context: lines.append(f"\n{business_context}") if memory_context: lines.append(f"\n{memory_context}") return "\n".join(lines) SYSTEM_PROMPT = """\ 你是「陈策」,一位拥有 12 年经验的 Performance Marketing 总监。 ## 你的背景 - 曾在 WPP 旗下 GroupM 担任 Digital Lead,服务过多个 DTC 品牌的北美市场投放 - 精通 Google Ads(Search / PMax / YouTube)和 Meta Ads(全漏斗) - 擅长从数据中发现「钱花在哪了、花得值不值、下一步怎么调」 - 你有连续记忆:系统会提供你之前分析的历史结论,你应该主动引用和追踪 - 你能看到近期趋势数据,善于发现连续性变化(如"连续3周下滑") ## 你的分析原则(最重要) - 数据好不等于投放好。永远先问「为什么好/为什么差」再给建议 - 如果有业务背景信息(促销、节日、Campaign 变动),必须优先考虑: - 促销期间数据上涨是预期内的,不要简单建议"加码" - 要区分「促销驱动的虚高」vs「投放本身的真实进步」 - 促销期应关注:促销 ROI 是否达标、促销结束后的自然回落预估 - 非促销期才适合评估真实的投放效率和扩量空间 - 建议必须考虑可持续性:短期促销红利不等于长期策略方向 - 看到数据异常时,先排除外部因素(促销/节日/季节/竞品动作),再归因到投放策略 ## 你的风格 - 说话像资深老板,不像AI:有判断力、有立场、敢下结论 - 先给结论,再给证据。不废话、不铺垫 - 如果之前给过建议,这次要追踪结果("上周我说过CPC在涨,这周果然…") - 发现连续趋势时主动指出("ROAS已连续3周走低"而非只说"本周ROAS下降") - 预警分级:🔴 立即处理、🟡 持续观察、🟢 正向信号 - 建议必须具体到「对谁做什么」,拒绝「优化素材」「提升效率」这类废话 - 每句话都要有信息增量,但允许你用自然的语言来表达,不需要特别简短 ## 输出格式 严格返回以下 JSON,不要加 markdown 代码块标记: { "context_factors": ["影响本期数据的外部因素,如: 情人节促销期间数据虚高", "如无外部因素填 null"], "verdict": "2-3句话的总体判断:先交代外部因素对数据的影响,再评估投放本身的真实表现", "key_findings": [ "发现1:带关键数字,如果有连续趋势请指出,注明是否受促销/节日影响", "发现2", "发现3" ], "alerts": [ {"level": "red/yellow/green", "msg": "预警内容,说清楚问题和严重程度"} ], "next_action": "本周期最应该立刻执行的1个动作,具体到平台/漏斗/市场,促销期建议侧重促销ROI评估而非盲目扩量" } 注意: - context_factors: 列出影响数据的非投放因素(促销、节日、新品上线等),没有则返回空数组 [] - key_findings 3-5条,alerts 最多4条。宁少勿多,每条都要有价值 - 促销期不要建议"加码",应关注促销 ROI 和结束后的过渡计划""" CHAT_SYSTEM_PROMPT = """\ 你是「陈策」,一位拥有 12 年经验的 Performance Marketing 总监。 你刚刚完成了一份广告投放分析报告,用户正在就报告内容向你提问和讨论。 ## 你的分析原则 - 数据好不等于投放好。回答任何问题前先考虑「为什么」 - 如果用户提到或系统提供了业务背景(促销、节日、Campaign 变动),必须纳入分析: - 不要把促销期的数据上涨当作投放能力提升 - 区分「促销红利」vs「真实效率改善」 - 促销期关注促销 ROI 是否合理;非促销期才适合评估扩量空间 - 用户提出新的业务背景时(比如"其实那个月有促销"),要主动修正之前的判断 - 建议必须考虑可持续性和外部因素 ## 你的风格 - 说话像一个经验丰富的老板在跟团队讨论,不像客服在回答问题 - 有判断力、有立场、敢下结论,不说"这取决于"之类的废话 - 引用数据来支撑观点,但不要变成数据的复读机 - 如果用户问的你不确定,坦率说"从现有数据看不出来,需要看XXX" - 建议要具体可执行,不要空洞的"建议优化" - 如果用户补充了背景信息推翻了你之前的结论,大方承认并给出修正后的判断 - 回复用自然的中文,不需要返回 JSON 格式 - 控制篇幅:简单问题 2-3 句话,复杂问题也不超过 200 字""" PARSE_SYSTEM_PROMPT = """\ 你是一个广告数据查询的意图解析器。用户会用自然语言描述想看的时间范围,你需要将其转为结构化 JSON。 今天的日期是 {today}({weekday})。 ## 输出格式(严格 JSON,不加 markdown 标记) 根据用户意图选择最合适的一种: 1. 单日: {{"mode": "day", "date": "YYYY-MM-DD"}} 2. 自定义时间段(多天): {{"mode": "range", "start": "YYYY-MM-DD", "end": "YYYY-MM-DD"}} 3. 指定自然月: {{"mode": "month", "year": 2026, "month": 3}} 4. 指定 ISO 周: {{"mode": "week", "year": 2026, "week": 9}} 5. 数据中最新一周: {{"mode": "latest"}} 6. 上一周: {{"mode": "prev"}} 7. 无法理解 / 不是数据查询: {{"mode": "unknown"}} ## 判断规则 - "昨天"/"昨日" → day(昨天日期) - "前天" → day(前天日期) - "今天" → day(今天日期) - "最近三天" → range(从3天前到昨天) - "上周三到这周一" → range(具体日期) - "情人节" → day(2月14日) - "黑五" → day(11月第4个周五) - "看看二月份" / "2月" → month - "上个月" → month(上一个自然月) - "Q1" / "第一季度" → range(1.1~3.31) - "国庆期间" → range(10.1~10.7) - "最近一个月" → range(过去30天) - "最新一周" / "本周" / "这周" → latest - "上周" → prev - "W9" / "第9周" → week - "1.15~3.3" / "1月15号到3月3号" → range - "分析昨天" → day(忽略"分析"前缀) 只返回 JSON,不要任何解释文字。""" WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] def ai_parse_query(text: str) -> tuple[dict | None, str | None]: """ 用 AI 理解用户查询意图。 返回 (params_dict, error_message)。 成功时 error 为 None;失败时 params 为 None, error 说明原因。 """ api_key, api_base, model = _get_config() if not api_key: return None, "未配置 OPENAI_API_KEY" from datetime import datetime now = datetime.now() today = now.strftime("%Y-%m-%d") weekday = WEEKDAY_CN[now.weekday()] system = PARSE_SYSTEM_PROMPT.replace("{today}", today).replace("{weekday}", weekday) try: resp = requests.post( api_base, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, json={ "model": model, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": text}, ], "temperature": 0, "max_tokens": 150, }, timeout=15, ) resp.raise_for_status() content = resp.json()["choices"][0]["message"]["content"].strip() if content.startswith("```"): content = content.split("\n", 1)[1] if "\n" in content else content[3:] if content.endswith("```"): content = content[:-3] content = content.strip() result = json.loads(content) mode = result.get("mode") if mode == "unknown": return None, "无法理解你的查询意图,试试:昨天表现怎么样 / 看看上个月 / 最近三天" if mode == "day" and "date" not in result: return None, "AI 解析缺少日期" if mode == "range" and ("start" not in result or "end" not in result): return None, "AI 解析缺少起止日期" if mode == "month" and ("year" not in result or "month" not in result): return None, "AI 解析缺少年月" if mode == "week" and ("year" not in result or "week" not in result): return None, "AI 解析缺少年和周号" log.info(f"AI 解析: {text!r} → {result}") return result, None except requests.exceptions.Timeout: return None, "AI 解析超时,请重试" except json.JSONDecodeError: return None, "AI 返回格式异常,请重试" except Exception as e: log.warning(f"AI 意图解析异常: {e}") return None, f"AI 解析出错: {e}" def generate_ai_analysis( curr: dict[str, Any], prev: dict[str, Any] | None, wow_total: dict | None, wow_by_platform: dict | None, wow_by_campaign: dict | None, wow_by_country: dict | None, *, yoy_data: dict[str, Any] | None = None, yoy_total: dict | None = None, yoy_by_platform: dict | None = None, yoy_by_country: dict | None = None, period_label: str = "", trend_table: list[dict] | None = None, memory_context: str | None = None, business_context: str | None = None, ) -> dict[str, Any] | None: """ 调用大模型生成专家分析。成功返回结构化 dict,失败抛出异常。 """ api_key, api_base, model = _get_config() if not api_key: raise RuntimeError("未配置 OPENAI_API_KEY,请在环境变量中设置") data_summary = _build_data_summary( curr, prev, wow_total, wow_by_platform, wow_by_campaign, wow_by_country, yoy_data=yoy_data, yoy_total=yoy_total, yoy_by_platform=yoy_by_platform, yoy_by_country=yoy_by_country, trend_table=trend_table, memory_context=memory_context, business_context=business_context, ) user_msg = f"以下是 {period_label} 的广告投放数据,请进行专家级分析:\n\n{data_summary}" try: resp = requests.post( api_base, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, json={ "model": model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, ], "temperature": 0.3, "max_tokens": 800, }, timeout=25, ) resp.raise_for_status() result = resp.json() content = result["choices"][0]["message"]["content"].strip() # 清理可能的 markdown 代码块标记 if content.startswith("```"): content = content.split("\n", 1)[1] if "\n" in content else content[3:] if content.endswith("```"): content = content[:-3] content = content.strip() analysis = json.loads(content) log.info(f"AI 分析完成(模型: {model})") return analysis except json.JSONDecodeError as e: raise RuntimeError(f"AI 返回内容无法解析: {e}") from e except requests.exceptions.Timeout: raise RuntimeError("AI 分析超时,请稍后重试") except Exception as e: raise RuntimeError(f"AI 分析失败: {e}") from e def chat_with_chence( messages: list[dict[str, str]], report_context: str, business_context: str | None = None, ) -> str: """ 多轮对话:用户就报告内容追问,陈策用自然语言回复。 messages: [{"role": "user"/"assistant", "content": "..."}] report_context: 当次报告的数据摘要(首次分析时生成) business_context: 促销/节日/用户备注等业务背景 """ api_key, api_base, model = _get_config() if not api_key: raise RuntimeError("未配置 OPENAI_API_KEY") system = CHAT_SYSTEM_PROMPT + f"\n\n## 你刚才分析的报告数据\n{report_context}" if business_context: system += f"\n\n{business_context}" api_messages = [{"role": "system", "content": system}] api_messages.extend(messages) try: resp = requests.post( api_base, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, json={ "model": model, "messages": api_messages, "temperature": 0.4, "max_tokens": 600, }, timeout=25, ) resp.raise_for_status() content = resp.json()["choices"][0]["message"]["content"].strip() log.info("陈策对话回复完成") return content except requests.exceptions.Timeout: return "抱歉,我刚才想太久了。请再问一次。" except Exception as e: log.warning(f"对话异常: {e}") return f"出了点问题: {e}"