| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- """Orchestrate one idempotent daily ROI metric and approval batch."""
- from __future__ import annotations
- import hashlib
- import logging
- import os
- import re
- from datetime import datetime, timedelta
- from pathlib import Path
- from typing import Any
- from zoneinfo import ZoneInfo
- from storage import initialize_schema, load_managed_accounts
- from tencent_client import ACTIVE_STATUS, TencentClient
- from .agency_delivery import agency_route_key, publish_agency_reports
- from .config import AgencyWebhookConfig, RoiConfig
- from .data_source import (
- ODPSClient,
- date_window,
- fetch_ad_age,
- fetch_daily_data,
- resolve_end_date,
- )
- from .feishu import RoiFeishuPublisher
- from .metrics import ENTITY_SELF, METRIC_RUN_SUFFIX, METRIC_VERSION
- from .fission_multiplier import (
- DEFAULT_FISSION_PARAMETER_VERSION,
- FissionMultiplierParameters,
- parameters_from_database,
- )
- from .policy import annotate_execution
- from .reporting import (
- REPORT_RUN_SUFFIX,
- REPORT_VERSION,
- write_agency_workbooks,
- write_workbook,
- )
- from .rules import (
- POLICY_RUN_SUFFIX,
- POLICY_VERSION,
- RuleConfig,
- evaluate_rules,
- )
- from .repository import (
- FINAL_STATUSES,
- create_or_load_run,
- load_agency_deliveries,
- load_fission_parameter_release,
- mark_failed,
- mark_published,
- replace_run_results,
- )
- SHANGHAI = ZoneInfo("Asia/Shanghai")
- logger = logging.getLogger("auto_put_ad_mini.roi_control")
- def _redact_agency_webhooks(
- error: Exception,
- config: AgencyWebhookConfig,
- ) -> str:
- message = str(error)
- for webhook_url in (config.webhooks or {}).values():
- message = message.replace(webhook_url, "<redacted>")
- return message
- def _internal_source_revision(config: RoiConfig, now: datetime) -> str:
- if config.internal_test_dedup_enabled:
- return "internal_test"
- return f"it_{now.strftime('%H%M%S%f')}"
- def _build_internal_reports(
- *,
- agency_reports: list[dict[str, object]],
- ) -> list[dict[str, object]]:
- reports = [
- {
- **report,
- "title": f"内部测试_{Path(str(report['report'])).stem}",
- }
- for report in agency_reports
- if agency_route_key(str(report["agency_name"])) == "自动化投放"
- ]
- if len(reports) != 1:
- raise RuntimeError(
- "Internal ROI test requires exactly one 自动化投放 agency report"
- )
- return reports
- def _internal_webhook_config(
- reports: list[dict[str, object]],
- webhook_url: str,
- ) -> AgencyWebhookConfig:
- return AgencyWebhookConfig(
- enabled=True,
- webhooks={
- agency_route_key(str(report["agency_name"])): webhook_url
- for report in reports
- },
- )
- def _annotate_current_creative_status(
- rows,
- tencent: TencentClient | None = None,
- ):
- """Read current Tencent status only for creative-level stop decisions."""
- result = rows.copy()
- result["当前创意状态"] = ""
- mask = result["entity_type"].eq(ENTITY_SELF) & result["动作"].eq("关停")
- if not mask.any():
- return result
- client = tencent or TencentClient()
- cache: dict[tuple[int, int], str] = {}
- try:
- for index, row in result.loc[mask].iterrows():
- try:
- account_id = int(row["账号id"])
- creative_id = int(row["创意id"])
- except (TypeError, ValueError):
- result.at[index, "当前创意状态"] = "读取失败"
- continue
- key = (account_id, creative_id)
- if key not in cache:
- try:
- creative = client.get_dynamic_creative(account_id, creative_id)
- status = str(creative.get("configured_status") or "")
- cache[key] = (
- "正常"
- if status == ACTIVE_STATUS
- else "已停止" if status else "读取失败"
- )
- except Exception as exc:
- logger.warning(
- "Failed to read creative status account=%s creative=%s: %s",
- account_id,
- creative_id,
- exc,
- )
- cache[key] = "读取失败"
- result.at[index, "当前创意状态"] = cache[key]
- finally:
- if tencent is None:
- client.session.close()
- return result
- def _rule_config(config: RoiConfig) -> RuleConfig:
- return RuleConfig(
- self_stop_min_age=config.self_stop_min_age,
- self_up_min_age=config.self_up_min_age,
- self_min_daily_uv=config.self_min_daily_uv,
- partner_min_daily_uv=config.partner_min_daily_uv,
- observe_min_latest_uv=config.observe_min_latest_uv,
- one_day_min_uv=config.one_day_min_uv,
- one_day_p30_min_uv=config.one_day_p30_min_uv,
- one_day_hard_stop_roi=config.one_day_hard_stop_roi,
- one_day_stop_quantile=config.one_day_stop_quantile,
- stop_quantile=config.stop_quantile,
- up_quantile=config.up_quantile,
- )
- def _dates(start_date: str) -> list[str]:
- start = datetime.strptime(start_date, "%Y%m%d")
- return [
- (start + timedelta(days=offset)).strftime("%Y%m%d")
- for offset in range(3)
- ]
- def _run_identity(
- end_date: str,
- fission_parameters: FissionMultiplierParameters,
- source_revision: str | None = None,
- ) -> tuple[str, str]:
- if source_revision and not re.fullmatch(
- r"[a-z0-9][a-z0-9_-]{0,63}", source_revision
- ):
- raise ValueError(
- "source_revision must use lowercase letters, numbers, '_' or '-'"
- )
- release = fission_parameters.release
- run_suffixes = [release.run_suffix]
- run_key_versions = [release.version]
- run_id = (
- f"roi_{end_date}_{METRIC_RUN_SUFFIX}_{POLICY_RUN_SUFFIX}_"
- f"{'_'.join(run_suffixes)}_{REPORT_RUN_SUFFIX}"
- )
- run_key = (
- f"{METRIC_VERSION}:{POLICY_VERSION}:{':'.join(run_key_versions)}:"
- f"{REPORT_VERSION}:{end_date}"
- )
- if source_revision:
- run_id = f"{run_id}_{source_revision}"
- revision_hash = hashlib.sha256(source_revision.encode("utf-8")).hexdigest()
- run_key = f"{run_key}:sr:{revision_hash[:16]}"
- if len(run_id) > 64:
- raise ValueError("ROI run_id exceeds database limit")
- if len(run_key) > 128:
- raise ValueError("ROI run_key exceeds database limit")
- return run_id, run_key
- def run_daily_roi(
- *,
- requested_end_date: str | None = None,
- output_dir: Path,
- send_feishu: bool,
- now: datetime | None = None,
- source_revision: str | None = None,
- internal_test: bool = False,
- ) -> dict[str, Any]:
- """Compute, snapshot, report, and optionally publish one ROI batch."""
- config = RoiConfig.from_env()
- agency_webhook_config = AgencyWebhookConfig.from_env()
- if internal_test and send_feishu:
- raise ValueError("internal_test cannot publish formal Feishu notifications")
- if internal_test and not (agency_webhook_config.webhooks or {}).get("内部"):
- raise ValueError("Internal ROI test requires agency webhook route: 内部")
- effective_now = now or datetime.now(SHANGHAI)
- if effective_now.tzinfo is None:
- effective_now = effective_now.replace(tzinfo=SHANGHAI)
- if internal_test:
- if source_revision:
- raise ValueError("internal_test manages source_revision automatically")
- source_revision = _internal_source_revision(config, effective_now)
- client = ODPSClient(project=os.getenv("ODPS_PROJECT", "loghubods"))
- end_date = resolve_end_date(
- client,
- requested_end_date,
- now=effective_now,
- )
- start_date, end_date = date_window(end_date)
- expected_dates = _dates(start_date)
- initialize_schema()
- fission_version = os.getenv(
- "ROI_FISSION_PARAMETER_VERSION",
- DEFAULT_FISSION_PARAMETER_VERSION,
- )
- fission_parameters = parameters_from_database(
- *load_fission_parameter_release(fission_version)
- )
- run_config = config.snapshot()
- run_config["fission_multiplier"] = fission_parameters.snapshot()
- run_config["report_version"] = REPORT_VERSION
- run_config["agency_webhook"] = agency_webhook_config.snapshot()
- run_config["internal_test"] = internal_test
- if source_revision:
- run_config["source_revision"] = source_revision
- run_id, run_key = _run_identity(
- end_date,
- fission_parameters,
- source_revision,
- )
- run = create_or_load_run(
- {
- "run_id": run_id,
- "run_key": run_key,
- "metric_version": METRIC_VERSION,
- "policy_version": POLICY_VERSION,
- "fission_parameter_version": fission_parameters.release.version,
- "fission_cohort_date": datetime.strptime(
- fission_parameters.release.cohort_date, "%Y%m%d"
- ).date(),
- "start_date": datetime.strptime(start_date, "%Y%m%d").date(),
- "end_date": datetime.strptime(end_date, "%Y%m%d").date(),
- "config": run_config,
- }
- )
- reusable_statuses = FINAL_STATUSES | {"PENDING_APPROVAL", "EXECUTING"}
- publish_requested = send_feishu or internal_test
- if run.get("status") in reusable_statuses or (
- run.get("status") == "COMPUTED" and not publish_requested
- ):
- logger.info("Reuse ROI run=%s status=%s", run_id, run.get("status"))
- reused_result: dict[str, Any] = {
- "run_id": run_id,
- "status": run.get("status"),
- "reused": True,
- "sheet_url": run.get("sheet_url"),
- }
- if send_feishu and agency_webhook_config.enabled:
- stored_deliveries = load_agency_deliveries(run_id)
- if stored_deliveries:
- retry_reports = [
- {
- "agency_name": row["agency_name"],
- "report_version": row["agency_report_version"],
- "report": row["file_path"],
- "creative_rows": row.get("creative_rows") or 0,
- "ad_rows": row.get("ad_rows") or 0,
- }
- for row in stored_deliveries
- ]
- publisher = RoiFeishuPublisher()
- try:
- try:
- reused_result["agency_deliveries"] = publish_agency_reports(
- run_id=run_id,
- reports=retry_reports,
- config=agency_webhook_config,
- publisher=publisher,
- now=now,
- )
- except Exception as exc:
- safe_error = _redact_agency_webhooks(
- exc,
- agency_webhook_config,
- )
- logger.error("Agency ROI retry failed: %s", safe_error)
- reused_result["agency_delivery_error"] = safe_error
- finally:
- publisher.close()
- return reused_result
- try:
- logger.info(
- "ROI %s loading ODPS daily data %s..%s fission_parameter=%s",
- run_id,
- start_date,
- end_date,
- fission_parameters.release.version,
- )
- daily = fetch_daily_data(client, start_date, end_date)
- ad_age = fetch_ad_age(client, end_date)
- _, thresholds, summary = evaluate_rules(
- daily,
- expected_dates,
- ad_age,
- _rule_config(config),
- fission_parameters=fission_parameters,
- )
- managed_ids = (
- set()
- if internal_test
- else {
- int(row["account_id"])
- for row in load_managed_accounts()
- }
- )
- annotated, snapshots, actions = annotate_execution(
- summary,
- managed_ids,
- run_id,
- )
- annotated = _annotate_current_creative_status(annotated)
- fission_match_summary = (
- summary.groupby(
- ["entity_type", "传播裂变系数匹配层级"],
- as_index=False,
- dropna=False,
- )
- .size()
- .rename(
- columns={
- "entity_type": "实体类型",
- "传播裂变系数匹配层级": "匹配层级",
- "size": "实体数",
- }
- )
- )
- channel_totals = fission_match_summary.groupby("实体类型")[
- "实体数"
- ].transform("sum")
- fission_match_summary["渠道实体数"] = channel_totals
- fission_match_summary["匹配率"] = (
- fission_match_summary["实体数"] / channel_totals
- )
- fission_match_summary["参数版本"] = fission_parameters.release.version
- recommendation_rows = annotated[annotated["动作"].ne("")].copy()
- report_rows = annotated[
- annotated["阈值样本状态"].isin(
- [
- "进入三日统一阈值样本池",
- "广告级三日合格_不进入阈值样本池",
- "单日补充决策_昨日UV>200",
- "补充观察_昨日UV>200",
- ]
- )
- ].copy()
- for row in snapshots:
- row["run_id"] = run_id
- for row in actions:
- row["run_id"] = run_id
- threshold_record = {
- "统计窗口": f"{start_date} 至 {end_date}",
- "整体三日关停线": thresholds.to_dict("records"),
- }
- replace_run_results(
- run_id,
- snapshots=snapshots,
- actions=actions,
- thresholds=threshold_record,
- )
- output_dir.mkdir(parents=True, exist_ok=True)
- batch_name = f"ROI调控_{start_date}-{end_date}"
- if source_revision:
- batch_name = f"{batch_name}_{source_revision}"
- output_path = output_dir / f"{batch_name}.xlsx"
- if not internal_test:
- write_workbook(
- report_rows,
- thresholds,
- expected_dates,
- output_path,
- run_config,
- fission_match_summary,
- )
- agency_report_date = effective_now.strftime("%Y%m%d")
- agency_reports = write_agency_workbooks(
- report_rows,
- output_dir / f"{agency_report_date}_调控建议",
- agency_report_date,
- agency_names={"自动化投放"} if internal_test else None,
- )
- internal_reports = (
- _build_internal_reports(agency_reports=agency_reports)
- if internal_test
- else []
- )
- result_report = (
- str(internal_reports[0]["report"])
- if internal_test
- else str(output_path)
- )
- result: dict[str, Any] = {
- "run_id": run_id,
- "batch_name": batch_name,
- "status": "COMPUTED",
- "reused": False,
- "start_date": start_date,
- "end_date": end_date,
- "source_revision": source_revision,
- "entity_count": len(snapshots),
- "candidate_count": len(recommendation_rows),
- "actionable_count": len(actions),
- "thresholds": threshold_record,
- "fission_multiplier_matches": fission_match_summary.to_dict(
- "records"
- ),
- "report": result_report,
- "agency_reports": agency_reports,
- }
- if not publish_requested:
- return result
- if internal_test:
- internal_url = (agency_webhook_config.webhooks or {})["内部"]
- internal_config = _internal_webhook_config(
- internal_reports,
- internal_url,
- )
- publisher = RoiFeishuPublisher(require_chat_ids=False)
- try:
- deliveries = publish_agency_reports(
- run_id=run_id,
- reports=internal_reports,
- config=internal_config,
- publisher=publisher,
- now=effective_now,
- )
- finally:
- publisher.close()
- result["internal_deliveries"] = deliveries
- failed = [row for row in deliveries if row.get("status") != "SENT"]
- if failed:
- raise RuntimeError(
- f"Internal ROI test delivery failed for {len(failed)} report(s)"
- )
- main_delivery = next(
- row for row in deliveries if row["agency_name"] == "自动化投放"
- )
- mark_published(
- run_id,
- sheet_token=str(main_delivery["sheet_token"]),
- sheet_url=str(main_delivery["sheet_url"]),
- message_id="internal-webhook",
- expires_at=None,
- requires_approval=False,
- )
- result.update(
- {
- "status": "COMPLETED",
- "sheet_url": main_delivery["sheet_url"],
- }
- )
- return result
- counts = recommendation_rows.groupby("动作").size().to_dict()
- summary_text = (
- f"统计窗口:{start_date} - {end_date}\n"
- f"关停建议:{counts.get('关停', 0)}\n"
- f"观察:{counts.get('观察', 0)}\n"
- f"可执行动作:{len(actions)}\n"
- f"审批有效期:发送后 {config.approval_ttl_minutes} 分钟"
- )
- publisher = RoiFeishuPublisher()
- try:
- published = publisher.publish(
- output_path,
- run_id=run_id,
- batch_name=batch_name,
- summary=summary_text,
- requires_approval=bool(actions),
- )
- expires_at = (
- effective_now
- + timedelta(minutes=config.approval_ttl_minutes)
- if actions
- else None
- )
- mark_published(
- run_id,
- sheet_token=published["sheet_token"],
- sheet_url=published["url"],
- message_id=published["message_id"],
- expires_at=expires_at,
- requires_approval=bool(actions),
- )
- try:
- result["agency_deliveries"] = publish_agency_reports(
- run_id=run_id,
- reports=agency_reports,
- config=agency_webhook_config,
- publisher=publisher,
- now=now,
- )
- except Exception as exc:
- safe_error = _redact_agency_webhooks(
- exc,
- agency_webhook_config,
- )
- logger.error("Agency ROI delivery phase failed: %s", safe_error)
- result["agency_delivery_error"] = safe_error
- finally:
- publisher.close()
- result.update(
- {
- "status": "PENDING_APPROVAL" if actions else "COMPLETED",
- "sheet_url": published["url"],
- "expires_at": expires_at.isoformat() if expires_at else None,
- }
- )
- return result
- except Exception as exc:
- mark_failed(run_id, str(exc))
- raise
|