Sfoglia il codice sorgente

fix(roi): isolate scheduled report dependencies

刘立冬 14 ore fa
parent
commit
584158ec81

+ 6 - 6
AGENTS.md

@@ -31,7 +31,7 @@
 - 裸“暂停/停止/恢复”只能进入有过期时间的多轮补问,不能猜测范围。命令预览必须冻结广告 ID,逐账户展示腾讯当日消耗;报表读取失败时禁止确认。重叠待确认命令必须拒绝,不能自动覆盖或排队。
 - 裸“暂停/停止/恢复”只能进入有过期时间的多轮补问,不能猜测范围。命令预览必须冻结广告 ID,逐账户展示腾讯当日消耗;报表读取失败时禁止确认。重叠待确认命令必须拒绝,不能自动覆盖或排队。
 - `暂停` 只把广告 `begin_date` 延后到下一投放日,保留正常状态和原 `time_series`,由腾讯在次日 06:00 自动投放,系统不能再调用主动开启接口;`停止` 表示持续停止;`恢复` 只解除运营暂停,不能解除仍生效的 CPM 暂停。
 - `暂停` 只把广告 `begin_date` 延后到下一投放日,保留正常状态和原 `time_series`,由腾讯在次日 06:00 自动投放,系统不能再调用主动开启接口;`停止` 表示持续停止;`恢复` 只解除运营暂停,不能解除仍生效的 CPM 暂停。
 - 运营暂停、停止、恢复等群聊命令必须二次确认,且必须来自配置群并 @机器人,发送人必须在允许列表。
 - 运营暂停、停止、恢复等群聊命令必须二次确认,且必须来自配置群并 @机器人,发送人必须在允许列表。
-- ROI 表格逐行审批是独立入口:黄色【审批选择】列填写“批准”即为最终确认,不再经过群聊二次确认。审批表使用获得链接者可编辑权限,但执行目标必须只按数据库中的隐藏幂等键回读,不能信任表格内可编辑的账户、广告、创意、成本或 ROI 字段。
+- ROI 表格逐行审批是独立入口:默认隐藏的黄色【审批选择】列取消隐藏后填写“批准”即为最终确认,不再经过群聊二次确认。审批表使用获得链接者可编辑权限,但执行目标必须只按数据库中的隐藏幂等键回读,不能信任表格内可编辑的账户、广告、创意、成本或 ROI 字段。
 - 运营暂停状态和 CPM 暂停状态必须分开持久化。原本人工暂停的广告不能被系统认领或自动开启;腾讯后台人工重新开启时以人工操作为准。
 - 运营暂停状态和 CPM 暂停状态必须分开持久化。原本人工暂停的广告不能被系统认领或自动开启;腾讯后台人工重新开启时以人工操作为准。
 - 实时控制和飞书写操作必须共用 MySQL advisory lock,并执行腾讯写后回读校验。
 - 实时控制和飞书写操作必须共用 MySQL advisory lock,并执行腾讯写后回读校验。
 - 广告设置 `bid_hold` 时,实时 CPM 调控不得修改其出价;解除 `bid_hold` 只能恢复实时调价资格,不能自动恢复历史价格。
 - 广告设置 `bid_hold` 时,实时 CPM 调控不得修改其出价;解除 `bid_hold` 只能恢复实时调价资格,不能自动恢复历史价格。
@@ -41,18 +41,18 @@
 
 
 - ROI 是指导渠道、账户、广告和创意动作的核心北极星指标,必须作为独立领域模块维护,不能散落在飞书、腾讯 API 或调度代码中。
 - ROI 是指导渠道、账户、广告和创意动作的核心北极星指标,必须作为独立领域模块维护,不能散落在飞书、腾讯 API 或调度代码中。
 - ROI 指标计算必须保持纯数据输入/输出,不能依赖飞书审批、腾讯写操作或具体动作执行器。动作策略只能消费一个明确的 `metric_version`。
 - ROI 指标计算必须保持纯数据输入/输出,不能依赖飞书审批、腾讯写操作或具体动作执行器。动作策略只能消费一个明确的 `metric_version`。
-- 当前日级 ROI 指标版本为 `north_star_roi_t15_v7`,策略版本为 `roi_policy_v6`,报表版本为 `roi_report_v15`,使用已发布参数 `20260712_A0-A15_v2`。T0 裂变人数取 `SUM(t0_fission_uv_root)`,不得使用旧字段 `t0裂变人数`。首层效率收入取 `SUM(效率收入)`,T0 实际裂变收入取当天的 `SUM(裂变效率收入)`;只预测 T1-T15 增量:`T0实际裂变收入 * (传播裂变系数 - 1)`,预测总收入为 `首层效率收入 + T0实际裂变收入 + 预测T1-T15裂变收入`,禁止继续读取已废弃的 `多层t0裂变收入`。
+- 当前日级 ROI 指标版本为 `north_star_roi_t15_v8`,策略版本为 `roi_policy_v12`,报表版本为 `roi_report_v36`,使用已发布参数 `20260712_A0-A15_v2`。T0 裂变人数取 `SUM(t0_fission_uv_root)`,不得使用旧字段 `t0裂变人数`。首层效率收入取 `SUM(效率收入)`,T0 实际裂变收入取当天的 `SUM(裂变效率收入)`;只预测 T1-T15 增量:`T0实际裂变收入 * (传播裂变系数 - 1)`,预测总收入为 `首层效率收入 + T0实际裂变收入 + 预测T1-T15裂变收入`,禁止继续读取已废弃的 `多层t0裂变收入`。
 - 小程序传播裂变系数按 `人群包+转化目标精确 -> 人群包回退 -> 转化目标回退 -> 渠道回退` 匹配;公众号按 `合作方+公众号精确 -> 合作方回退 -> 渠道回退` 匹配。样本不足的精确实体不能使用自身系数。
 - 小程序传播裂变系数按 `人群包+转化目标精确 -> 人群包回退 -> 转化目标回退 -> 渠道回退` 匹配;公众号按 `合作方+公众号精确 -> 合作方回退 -> 渠道回退` 匹配。样本不足的精确实体不能使用自身系数。
 - 小程序日级 ODPS 数据必须保留 `广告优化目标`;缺失目标只能进入人群包回退,不得默认成关键页面访问。企微 ROI 口径未复核,优先使用已发布的合作方 SQL 计算结果,合作方未匹配、样本不足或旧版本无企微参数时默认参考系数为 2.5 并明确标注;企微不进入阈值样本池且不得生成调控动作。
 - 小程序日级 ODPS 数据必须保留 `广告优化目标`;缺失目标只能进入人群包回退,不得默认成关键页面访问。企微 ROI 口径未复核,优先使用已发布的合作方 SQL 计算结果,合作方未匹配、样本不足或旧版本无企微参数时默认参考系数为 2.5 并明确标注;企微不进入阈值样本池且不得生成调控动作。
-- ROI 报表使用长表结构,同一实体每天一行并用 `dt` 区分,每行展示当日首层 UV、T0 裂变人数、T0 裂变率、首层/T0/预测总效率收入、成本、效率 ROI、两个裂变系数、消耗加权分位和 P25 关停线;动作、审批和执行状态只能出现在最新日期行,历史日期行不得保留动作幂等键。两日汇总 ROI 只保留在隐藏审计列,不作为可见决策字段。每日 ROI 必须按各日收入/成本独立计算,不能对每日比例做算术平均
-- ROI 审批表应展示所有进入阈值样本池的小程序和公众号实体,以及全部企微参考实体;不能只展示有动作的数据。每个渠道 Sheet 默认按预测 ROI 升序排列
+- ROI 报表包含小程序创意级、小程序广告级和公众号的三日汇总与每日明细共 6 个主 Sheet,其中广告级两个 Sheet 默认隐藏。金额、UV 和人数显示三日总量/3;ROI 和裂变率使用三日汇总后的加权口径。可见尾部列按“当日效率ROI -> 预测总效率ROI -> 关停线(P20) -> 建议动作 -> 当前创意状态”排列;当前创意状态只对创意级关停建议只读腾讯并显示正常、已停止或读取失败,其他行留空。审批选择、整体三日排名百分位、创意前三20%标记、最新日UV、覆盖天数、动作原因、阈值样本状态和执行结果只保留为隐藏审计字段。“当日效率ROI”和“预测总效率ROI”均使用深红—黄—绿色阶,绿色代表表现好。普通数值显示两位小数,UV、人数、数量和广告年龄显示整数。汇总顺序为关停、扩量、其他中间 ROI、观察,第一条扩量行顶部使用粗线分隔。每日明细的 `dt` 必须位于第一列并按日期倒序。主 Sheet 冻结首行和前 7 列
+- ROI 审批表应展示所有进入阈值样本池的小程序和公众号实体,以及最新日首层 UV>200 的补充观察实体;不能只展示有动作的数据。企微暂不进入本版报表、阈值和动作。正式样本按关停、扩量、其他中间 ROI、观察排列,补充观察置底并按最新日 UV 倒序
 - 成熟参数是独立版本化只读资产。审计后的参数必须通过更新脚本发布到 MySQL 的 `roi_fission_parameter_release` 和 `roi_fission_parameter_value`;日级 ROI 只从数据库消费 `ROI_FISSION_PARAMETER_VERSION` 指定且通过内容哈希校验的版本,不能在日常任务中现场重算或静默回退到镜像文件。离线复算只能生成草稿,经恒等式、行数、匹配率和新旧结果对账后才能发布。
 - 成熟参数是独立版本化只读资产。审计后的参数必须通过更新脚本发布到 MySQL 的 `roi_fission_parameter_release` 和 `roi_fission_parameter_value`;日级 ROI 只从数据库消费 `ROI_FISSION_PARAMETER_VERSION` 指定且通过内容哈希校验的版本,不能在日常任务中现场重算或静默回退到镜像文件。离线复算只能生成草稿,经恒等式、行数、匹配率和新旧结果对账后才能发布。
 - ROI 报表只上传一次,同一在线表链接发送到 ROI 通知群和 `FEISHU_OPERATOR_CHAT_ID` 投放审批群;群 ID 相同时必须去重。
 - ROI 报表只上传一次,同一在线表链接发送到 ROI 通知群和 `FEISHU_OPERATOR_CHAT_ID` 投放审批群;群 ID 相同时必须去重。
 - ROI 公式、收入/成本归属、裂变口径或实体粒度发生语义变化时,必须升级指标版本并保存新快照;不得覆盖或重算成旧版本历史结果。
 - ROI 公式、收入/成本归属、裂变口径或实体粒度发生语义变化时,必须升级指标版本并保存新快照;不得覆盖或重算成旧版本历史结果。
 - 策略阈值和动作语义使用独立 `policy_version`;指标版本与策略版本必须同时写入每个运行批次。
 - 策略阈值和动作语义使用独立 `policy_version`;指标版本与策略版本必须同时写入每个运行批次。
 - 每次运行必须保存完整实体快照、阈值配置、动作建议、可执行性原因和最终执行审计,不能只保存候选或飞书表格。
 - 每次运行必须保存完整实体快照、阈值配置、动作建议、可执行性原因和最终执行审计,不能只保存候选或飞书表格。
-- 日级 ROI 当前读取 T-1 至 T-2 的连续两日数据。小程序与公众号按渠道、按日期分别计算阈值:关停线使用成本权重按 P95 封顶后的消耗加权 P25,扩量线保持实体等权 P80。只有连续两天单日首层 UV 都大于 200 且连续处于同一极端方向时才生成正式建议。最新日首层 UV 大于 100 但不满足连续两日样本条件的实体只标记为“观察”,不得生成腾讯写操作。所有渠道都进入指标和通知,只有当前自动化腾讯账户的可执行行允许在表格逐行批准后执行。
-- 当前低 ROI 动作只暂停 `dynamic_creative_id`,不能暂停整个广告;高 ROI 动作只调整广告永久基础出价,默认上调 10%
+- 日级 ROI 当前读取 T-1 至 T-3 的连续三日数据,并使用 `usersharedepth<=1`。连续三天每天首层 UV>200、成本>0 且 ROI 有效的小程序创意级和公众号实体合并后,按实体等权计算整体 P20 关停线。小程序广告级直接从 ODPS 原始数据按广告去重计算,复用同一关停线但不进入样本池。最新日首层 UV>200 但不满足三日条件的实体只标记为“观察”,不得生成腾讯写操作。
+- 当前低 ROI 使用创意级+公众号合格实体统一等权 P20;高 ROI 仅使用合格小程序创意实体等权 P80。非三日正式样本的小程序创意补充使用最新日 UV>200且预测 ROI≤0.20,或最新日 UV>500且预测 ROI≤实体等权 P30 的单日关停规则。创意级低质且广告 age>3 生成关停创意建议,创意级头部20%且广告 age≥3 生成扩量建议;小程序广告级低质且广告 age>3 时批准后暂停整个广告,公众号只通知参考
 - 同广告永久基础出价 3 天内最多上调一次,且不超过首次纳管基础价的 2 倍。调整后必须同步实时 CPM 模块的基础出价,避免恢复旧值。
 - 同广告永久基础出价 3 天内最多上调一次,且不超过首次纳管基础价的 2 倍。调整后必须同步实时 CPM 模块的基础出价,避免恢复旧值。
 - ROI 逐行审批有效期默认 120 分钟。腾讯写操作必须与实时调控共用数据库 advisory lock,执行前回读映射和状态,执行后再次回读校验;执行结果必须回写表格并发送飞书通知,通知失败只能重试通知,不能重复腾讯写操作。
 - ROI 逐行审批有效期默认 120 分钟。腾讯写操作必须与实时调控共用数据库 advisory lock,执行前回读映射和状态,执行后再次回读校验;执行结果必须回写表格并发送飞书通知,通知失败只能重试通知,不能重复腾讯写操作。
 - `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=0` 只允许计算、快照、报表和审批预览;只有 `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=1`、`ROI_SHEET_APPROVAL_ENABLED=1` 时,表格批准后才允许自动执行腾讯写操作。
 - `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=0` 只允许计算、快照、报表和审批预览;只有 `DAILY_ROI_ENABLED=1`、`ROI_APPLY_ENABLED=1`、`ROI_SHEET_APPROVAL_ENABLED=1` 时,表格批准后才允许自动执行腾讯写操作。

+ 16 - 13
examples/auto_put_ad_mini/roi_control/feishu.py

@@ -15,7 +15,12 @@ BASE_URL = "https://open.feishu.cn/open-apis"
 
 
 
 
 class RoiFeishuPublisher:
 class RoiFeishuPublisher:
-    def __init__(self, timeout: float = 30.0) -> None:
+    def __init__(
+        self,
+        timeout: float = 30.0,
+        *,
+        require_chat_ids: bool = True,
+    ) -> None:
         self.app_id = os.getenv("FEISHU_APP_ID", "").strip()
         self.app_id = os.getenv("FEISHU_APP_ID", "").strip()
         self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
         self.app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
         primary_chat_id = (
         primary_chat_id = (
@@ -31,18 +36,16 @@ class RoiFeishuPublisher:
                 if chat_id
                 if chat_id
             )
             )
         )
         )
-        missing = [
-            name
-            for name, value in {
-                "FEISHU_APP_ID": self.app_id,
-                "FEISHU_APP_SECRET": self.app_secret,
-                (
-                    "ROI_FEISHU_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID/"
-                    "RTC_COMMAND_CHAT_ID/FEISHU_OPERATOR_CHAT_ID"
-                ): self.chat_ids,
-            }.items()
-            if not value
-        ]
+        required = {
+            "FEISHU_APP_ID": self.app_id,
+            "FEISHU_APP_SECRET": self.app_secret,
+        }
+        if require_chat_ids:
+            required[
+                "ROI_FEISHU_CHAT_ID/FEISHU_AD_PROJECT_CHAT_ID/"
+                "RTC_COMMAND_CHAT_ID/FEISHU_OPERATOR_CHAT_ID"
+            ] = self.chat_ids
+        missing = [name for name, value in required.items() if not value]
         if missing:
         if missing:
             raise RuntimeError(f"Missing Feishu configuration: {', '.join(missing)}")
             raise RuntimeError(f"Missing Feishu configuration: {', '.join(missing)}")
         self.client = httpx.Client(timeout=timeout)
         self.client = httpx.Client(timeout=timeout)

+ 1 - 1
examples/auto_put_ad_mini/roi_control/service.py

@@ -464,7 +464,7 @@ def run_daily_roi(
                 internal_reports,
                 internal_reports,
                 internal_url,
                 internal_url,
             )
             )
-            publisher = RoiFeishuPublisher()
+            publisher = RoiFeishuPublisher(require_chat_ids=False)
             try:
             try:
                 deliveries = publish_agency_reports(
                 deliveries = publish_agency_reports(
                     run_id=run_id,
                     run_id=run_id,

+ 5 - 6
examples/auto_put_ad_mini/run_daily_service.py

@@ -51,7 +51,7 @@ def _notify_roi_failure(
     mode = "内部14:30测试" if "--internal-test" in (extra_args or []) else "正式09:00任务"
     mode = "内部14:30测试" if "--internal-test" in (extra_args or []) else "正式09:00任务"
     publisher: RoiFeishuPublisher | None = None
     publisher: RoiFeishuPublisher | None = None
     try:
     try:
-        publisher = RoiFeishuPublisher()
+        publisher = RoiFeishuPublisher(require_chat_ids=False)
         publisher.send_service_alert(
         publisher.send_service_alert(
             title="日级ROI任务失败",
             title="日级ROI任务失败",
             content=(
             content=(
@@ -155,11 +155,10 @@ def run_internal_roi_test() -> None:
 
 
 def main() -> None:
 def main() -> None:
     roi_config = RoiConfig.from_env()
     roi_config = RoiConfig.from_env()
-    agency_config = AgencyWebhookConfig.from_env()
-    if roi_config.internal_test_enabled and not (
-        agency_config.webhooks or {}
-    ).get("内部"):
-        raise ValueError("ROI internal test requires webhook route: 内部")
+    if roi_config.internal_test_enabled:
+        agency_config = AgencyWebhookConfig.from_env()
+        if not (agency_config.webhooks or {}).get("内部"):
+            raise ValueError("ROI internal test requires webhook route: 内部")
     initialize_schema()
     initialize_schema()
     if _env_flag("DAILY_SYNC_DELIVERY_TEMPLATE", False):
     if _env_flag("DAILY_SYNC_DELIVERY_TEMPLATE", False):
         changed = sync_enabled_delivery_templates()
         changed = sync_enabled_delivery_templates()

+ 63 - 0
examples/auto_put_ad_mini/test_roi_agency_delivery.py

@@ -333,6 +333,38 @@ class AgencyDeliveryTest(unittest.TestCase):
         request = publisher.client.post.call_args
         request = publisher.client.post.call_args
         self.assertEqual(request.kwargs["json"]["receive_id"], "chat-operator")
         self.assertEqual(request.kwargs["json"]["receive_id"], "chat-operator")
 
 
+    def test_alert_only_publisher_does_not_require_report_chat(self):
+        with patch.dict(
+            os.environ,
+            {
+                "FEISHU_APP_ID": "app-id",
+                "FEISHU_APP_SECRET": "app-secret",
+                "ROI_FEISHU_CHAT_ID": "",
+                "FEISHU_AD_PROJECT_CHAT_ID": "",
+                "RTC_COMMAND_CHAT_ID": "",
+                "FEISHU_OPERATOR_CHAT_ID": "",
+                "ROI_FAILURE_FEISHU_CHAT_ID": "chat-failure",
+            },
+            clear=True,
+        ):
+            publisher = RoiFeishuPublisher(require_chat_ids=False)
+            try:
+                self.assertEqual(publisher.chat_ids, [])
+            finally:
+                publisher.close()
+
+    def test_formal_publisher_still_requires_report_chat(self):
+        with patch.dict(
+            os.environ,
+            {
+                "FEISHU_APP_ID": "app-id",
+                "FEISHU_APP_SECRET": "app-secret",
+            },
+            clear=True,
+        ):
+            with self.assertRaisesRegex(RuntimeError, "ROI_FEISHU_CHAT_ID"):
+                RoiFeishuPublisher()
+
     def test_daily_roi_nonzero_exit_triggers_failure_alert(self):
     def test_daily_roi_nonzero_exit_triggers_failure_alert(self):
         previous_logging_disable = logging.root.manager.disable
         previous_logging_disable = logging.root.manager.disable
         logging.disable(logging.CRITICAL)
         logging.disable(logging.CRITICAL)
@@ -371,6 +403,37 @@ class AgencyDeliveryTest(unittest.TestCase):
             error="process exited with code 7",
             error="process exited with code 7",
         )
         )
 
 
+    def test_disabled_internal_job_does_not_parse_agency_routes_at_startup(self):
+        import run_daily_service as daily_service
+
+        scheduler = Mock()
+        scheduler.get_jobs.return_value = []
+        with (
+            patch.object(
+                daily_service.RoiConfig,
+                "from_env",
+                return_value=RoiConfig(
+                    daily_enabled=False,
+                    internal_test_enabled=False,
+                ),
+            ),
+            patch.object(
+                daily_service.AgencyWebhookConfig,
+                "from_env",
+            ) as agency_from_env,
+            patch.object(daily_service, "initialize_schema"),
+            patch.object(daily_service, "_env_flag", return_value=False),
+            patch.object(
+                daily_service,
+                "BlockingScheduler",
+                return_value=scheduler,
+            ),
+        ):
+            daily_service.main()
+
+        agency_from_env.assert_not_called()
+        scheduler.start.assert_called_once_with()
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()