| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071 |
- """素材策略学习数据持久化。
- 本模块保存高消耗素材快照与视觉标注,并刻意与广告/创意创建链路解耦;导入
- 学习数据绝不能创建或修改腾讯广告。
- """
- from __future__ import annotations
- import csv
- import hashlib
- import json
- import logging
- import os
- import re
- from dataclasses import dataclass
- from datetime import date
- from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
- from pathlib import Path
- from typing import Any, Iterable, Sequence
- import httpx
- logger = logging.getLogger(__name__)
- CREATE_STRATEGY_LEARNING_TABLES_SQL = [
- """
- CREATE TABLE IF NOT EXISTS material_performance_snapshot_run (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
- run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
- window_start DATE NOT NULL COMMENT '统计窗口开始日期',
- window_end DATE NOT NULL COMMENT '统计窗口结束日期',
- top_n INT NOT NULL DEFAULT 5000 COMMENT '拉取TopN',
- source VARCHAR(64) NOT NULL DEFAULT 'odps' COMMENT '数据来源',
- sql_file VARCHAR(255) DEFAULT NULL COMMENT 'SQL文件路径',
- row_count INT NOT NULL DEFAULT 0 COMMENT '明细行数',
- total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内总消耗(分)',
- status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS' COMMENT '任务状态',
- error_message MEDIUMTEXT DEFAULT NULL COMMENT '错误信息',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- UNIQUE KEY uk_run_id (run_id),
- KEY idx_window (window_start, window_end)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照任务'
- """,
- """
- CREATE TABLE IF NOT EXISTS material_performance_snapshot_item (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
- run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
- rank_no INT NOT NULL COMMENT '消耗排名',
- account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
- ad_id BIGINT NOT NULL COMMENT '广告ID',
- creative_id BIGINT NOT NULL COMMENT '创意ID',
- creative_name VARCHAR(255) DEFAULT NULL COMMENT '创意名称',
- ad_name VARCHAR(255) DEFAULT NULL COMMENT '广告名称',
- video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
- title VARCHAR(512) DEFAULT NULL COMMENT '素材标题',
- image_url VARCHAR(1024) DEFAULT NULL COMMENT '素材图片URL',
- image_hash VARCHAR(64) DEFAULT NULL COMMENT '图片内容hash',
- crowd_package VARCHAR(255) DEFAULT NULL COMMENT '人群包名称',
- optimization_goal VARCHAR(128) DEFAULT NULL COMMENT '优化目标',
- bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)',
- day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)',
- cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '消耗(分)',
- impressions BIGINT NOT NULL DEFAULT 0 COMMENT '曝光',
- clicks BIGINT NOT NULL DEFAULT 0 COMMENT '点击',
- ctr DECIMAL(10, 6) DEFAULT NULL COMMENT '点击率',
- key_page_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '关键页访问次数',
- key_page_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '关键页访问/点击',
- conversions_count BIGINT NOT NULL DEFAULT 0 COMMENT '转化数',
- conversion_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '转化率',
- active_days INT NOT NULL DEFAULT 0 COMMENT '有消耗天数',
- first_dt VARCHAR(16) DEFAULT NULL COMMENT '首次投放日期',
- last_dt VARCHAR(16) DEFAULT NULL COMMENT '最后投放日期',
- raw_json MEDIUMTEXT DEFAULT NULL COMMENT '原始行JSON',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- UNIQUE KEY uk_run_creative (run_id, creative_id),
- KEY idx_creative (creative_id),
- KEY idx_image_hash (image_hash),
- KEY idx_video (video_id),
- KEY idx_package_cost (crowd_package, cost_fen)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照明细'
- """,
- """
- CREATE TABLE IF NOT EXISTS material_visual_annotation (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
- image_hash VARCHAR(64) NOT NULL COMMENT '图片内容hash',
- image_url VARCHAR(1024) NOT NULL COMMENT '素材图片URL',
- annotation_version VARCHAR(64) NOT NULL COMMENT '标注版本',
- annotator VARCHAR(64) NOT NULL COMMENT '标注来源',
- creative_id BIGINT DEFAULT NULL COMMENT '样本创意ID',
- visual_template VARCHAR(128) DEFAULT NULL COMMENT '视觉模板',
- hook_category VARCHAR(255) DEFAULT NULL COMMENT '标题钩子分类',
- title_text VARCHAR(512) DEFAULT NULL COMMENT '图片/素材标题',
- title_length INT DEFAULT NULL COMMENT '标题长度',
- scene_type VARCHAR(128) DEFAULT NULL COMMENT '场景类型',
- person_type VARCHAR(128) DEFAULT NULL COMMENT '人物/主体估计',
- has_human TINYINT DEFAULT NULL COMMENT '是否有人物',
- text_area_level VARCHAR(32) DEFAULT NULL COMMENT '文字面积估计等级',
- color_style VARCHAR(128) DEFAULT NULL COMMENT '颜色/调性',
- button_like_element TINYINT NOT NULL DEFAULT 0 COMMENT '是否疑似按钮诱导',
- fake_ui_risk TINYINT NOT NULL DEFAULT 0 COMMENT '假界面风险',
- official_policy_risk TINYINT NOT NULL DEFAULT 0 COMMENT '官方/政策承诺风险',
- medical_health_risk TINYINT NOT NULL DEFAULT 0 COMMENT '医疗健康风险',
- politics_sensitive_risk TINYINT NOT NULL DEFAULT 0 COMMENT '涉政/国家情绪风险',
- celebrity_or_history_risk TINYINT NOT NULL DEFAULT 0 COMMENT '名人/历史人物风险',
- strong_inducement_risk TINYINT NOT NULL DEFAULT 0 COMMENT '强诱导风险',
- greeting_blessing_risk TINYINT NOT NULL DEFAULT 0 COMMENT '早晚安/祝福风险',
- compliance_level VARCHAR(32) NOT NULL DEFAULT 'caution' COMMENT '生成可学习等级',
- learnable_points MEDIUMTEXT DEFAULT NULL COMMENT '可学习点',
- avoid_points MEDIUMTEXT DEFAULT NULL COMMENT '避让点',
- raw_annotation MEDIUMTEXT DEFAULT NULL COMMENT '原始标注JSON',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- UNIQUE KEY uk_image_version (image_hash, annotation_version),
- KEY idx_creative (creative_id),
- KEY idx_template (visual_template),
- KEY idx_compliance (compliance_level)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材图片结构化标注'
- """,
- """
- CREATE TABLE IF NOT EXISTS material_creative_pattern (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
- pattern_version VARCHAR(64) NOT NULL COMMENT '策略版本',
- pattern_key VARCHAR(128) NOT NULL COMMENT '策略唯一键',
- pattern_name VARCHAR(128) NOT NULL COMMENT '策略名称',
- hook_category VARCHAR(128) NOT NULL COMMENT '标题钩子类型',
- visual_template VARCHAR(128) NOT NULL COMMENT '视觉模板',
- applicable_crowd_packages VARCHAR(1024) DEFAULT NULL COMMENT '适用人群包',
- applicable_placements VARCHAR(1024) DEFAULT NULL COMMENT '适用版位',
- target_age_min INT DEFAULT NULL COMMENT '适用年龄下限',
- target_age_max INT DEFAULT NULL COMMENT '适用年龄上限',
- title_hook_rule MEDIUMTEXT NOT NULL COMMENT '标题钩子规则',
- visual_rule MEDIUMTEXT NOT NULL COMMENT '视觉规则',
- relevance_rule MEDIUMTEXT NOT NULL COMMENT '视频相关性规则',
- compliance_rule MEDIUMTEXT NOT NULL COMMENT '合规规则',
- selector_config MEDIUMTEXT DEFAULT NULL COMMENT '选择器配置JSON:match/exclude/score',
- positive_examples MEDIUMTEXT DEFAULT NULL COMMENT '正例JSON',
- negative_examples MEDIUMTEXT DEFAULT NULL COMMENT '反例JSON',
- source_run_id VARCHAR(64) DEFAULT NULL COMMENT '来源快照ID',
- source_material_count INT NOT NULL DEFAULT 0 COMMENT '来源素材数',
- source_total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '来源素材消耗(分)',
- status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED',
- reviewed_by VARCHAR(64) DEFAULT NULL COMMENT '审核人',
- reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核时间',
- enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- UNIQUE KEY uk_version_key (pattern_version, pattern_key),
- KEY idx_status_enabled (status, enabled),
- KEY idx_hook_template (hook_category, visual_template)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成素材可学习创意模式'
- """,
- """
- CREATE TABLE IF NOT EXISTS material_strategy_learning_report (
- id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
- report_id VARCHAR(64) NOT NULL COMMENT '报告ID',
- run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
- report_version VARCHAR(64) NOT NULL COMMENT '报告版本',
- summary MEDIUMTEXT NOT NULL COMMENT '报告摘要',
- top_patterns MEDIUMTEXT DEFAULT NULL COMMENT '核心模式JSON',
- risk_summary MEDIUMTEXT DEFAULT NULL COMMENT '风险摘要JSON',
- recommended_actions MEDIUMTEXT DEFAULT NULL COMMENT '建议动作JSON',
- report_path VARCHAR(512) DEFAULT NULL COMMENT '本地报告路径',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
- UNIQUE KEY uk_report_id (report_id),
- KEY idx_run_id (run_id)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材策略学习周报'
- """,
- ]
- STRATEGY_LEARNING_MIGRATIONS_SQL = [
- """
- ALTER TABLE material_performance_snapshot_item
- MODIFY COLUMN bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)'
- """,
- """
- ALTER TABLE material_performance_snapshot_item
- MODIFY COLUMN day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)'
- """,
- ]
- @dataclass(frozen=True)
- class ImportResult:
- run_id: str
- snapshot_rows: int
- annotation_rows: int
- report_rows: int
- @dataclass(frozen=True)
- class CreativePattern:
- pattern_version: str
- pattern_key: str
- pattern_name: str
- hook_category: str
- visual_template: str
- title_hook_rule: str
- visual_rule: str
- relevance_rule: str
- compliance_rule: str
- selector_config: dict[str, Any] | None = None
- applicable_crowd_packages: str = ""
- applicable_placements: str = ""
- target_age_min: int | None = 60
- target_age_max: int | None = 75
- positive_examples: list[str] | None = None
- negative_examples: list[str] | None = None
- source_run_id: str | None = None
- source_material_count: int = 0
- source_total_cost_fen: int = 0
- status: str = "DRAFT"
- enabled: int = 0
- @dataclass(frozen=True)
- class PatternSelection:
- pattern: CreativePattern
- score: float
- reasons: list[str]
- penalties: list[str]
- matched_features: list[str]
- DEFAULT_PATTERN_VERSION = "seed_20260708_v1"
- DEFAULT_SOURCE_RUN_ID = "material_30d_20260607_20260706_top5000"
- DEFAULT_PATTERN_SEED_PATH = (
- Path(__file__).resolve().parents[1] / "configs" / "material_creative_patterns_seed.json"
- )
- DEFAULT_PATTERN_SELECTOR_PROMPT_PATH = (
- Path(__file__).resolve().parents[1] / "prompts" / "ai_pattern_selector.md"
- )
- OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
- "OPENROUTER_CHAT_COMPLETIONS_URL",
- "https://openrouter.ai/api/v1/chat/completions",
- )
- OPENROUTER_TEXT_MODEL = os.getenv("OPENROUTER_TEXT_MODEL", "google/gemini-3-flash-preview")
- PATTERN_SELECTOR_MIN_MODEL_SCORE = float(os.getenv("PATTERN_SELECTOR_MIN_MODEL_SCORE", "75"))
- def ensure_strategy_learning_tables() -> None:
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- for sql in CREATE_STRATEGY_LEARNING_TABLES_SQL:
- cur.execute(sql)
- for sql in STRATEGY_LEARNING_MIGRATIONS_SQL:
- cur.execute(sql)
- cur.execute("SHOW COLUMNS FROM material_creative_pattern LIKE 'selector_config'")
- if not cur.fetchone():
- cur.execute(
- """
- ALTER TABLE material_creative_pattern
- ADD COLUMN selector_config MEDIUMTEXT DEFAULT NULL
- COMMENT '选择器配置JSON:match/exclude/score'
- AFTER compliance_rule
- """
- )
- conn.commit()
- finally:
- conn.close()
- def _pattern_from_row(row: dict[str, Any]) -> CreativePattern:
- def _loads_list(value: Any) -> list[str]:
- if not value:
- return []
- try:
- parsed = json.loads(str(value))
- except json.JSONDecodeError:
- return []
- if isinstance(parsed, list):
- return [str(item) for item in parsed]
- return []
- def _loads_dict(value: Any) -> dict[str, Any]:
- if not value:
- return {}
- if isinstance(value, dict):
- return value
- try:
- parsed = json.loads(str(value))
- except json.JSONDecodeError:
- return {}
- return parsed if isinstance(parsed, dict) else {}
- return CreativePattern(
- pattern_version=str(row.get("pattern_version") or ""),
- pattern_key=str(row.get("pattern_key") or ""),
- pattern_name=str(row.get("pattern_name") or ""),
- hook_category=str(row.get("hook_category") or ""),
- visual_template=str(row.get("visual_template") or ""),
- applicable_crowd_packages=str(row.get("applicable_crowd_packages") or ""),
- applicable_placements=str(row.get("applicable_placements") or ""),
- target_age_min=_as_int(row.get("target_age_min")),
- target_age_max=_as_int(row.get("target_age_max")),
- title_hook_rule=str(row.get("title_hook_rule") or ""),
- visual_rule=str(row.get("visual_rule") or ""),
- relevance_rule=str(row.get("relevance_rule") or ""),
- compliance_rule=str(row.get("compliance_rule") or ""),
- selector_config=_loads_dict(row.get("selector_config")),
- positive_examples=_loads_list(row.get("positive_examples")),
- negative_examples=_loads_list(row.get("negative_examples")),
- source_run_id=str(row.get("source_run_id") or "") or None,
- source_material_count=_as_required_int(row.get("source_material_count")),
- source_total_cost_fen=_as_required_int(row.get("source_total_cost_fen")),
- status=str(row.get("status") or "DRAFT"),
- enabled=_as_required_int(row.get("enabled")),
- )
- def _load_seed_patterns(path: Path = DEFAULT_PATTERN_SEED_PATH) -> list[CreativePattern]:
- raw_patterns = json.loads(path.read_text(encoding="utf-8"))
- patterns: list[CreativePattern] = []
- for raw in raw_patterns:
- patterns.append(CreativePattern(
- pattern_version=str(raw.get("pattern_version") or DEFAULT_PATTERN_VERSION),
- pattern_key=str(raw["pattern_key"]),
- pattern_name=str(raw["pattern_name"]),
- hook_category=str(raw.get("hook_category") or ""),
- visual_template=str(raw.get("visual_template") or ""),
- applicable_crowd_packages=str(raw.get("applicable_crowd_packages") or ""),
- applicable_placements=str(raw.get("applicable_placements") or ""),
- target_age_min=_as_int(raw.get("target_age_min")) or 60,
- target_age_max=_as_int(raw.get("target_age_max")) or 75,
- title_hook_rule=str(raw.get("title_hook_rule") or ""),
- visual_rule=str(raw.get("visual_rule") or ""),
- relevance_rule=str(raw.get("relevance_rule") or ""),
- compliance_rule=str(raw.get("compliance_rule") or ""),
- selector_config=raw.get("selector_config") or {},
- positive_examples=[str(v) for v in raw.get("positive_examples") or []],
- negative_examples=[str(v) for v in raw.get("negative_examples") or []],
- source_run_id=str(raw.get("source_run_id") or "") or None,
- source_material_count=_as_required_int(raw.get("source_material_count")),
- source_total_cost_fen=_as_required_int(raw.get("source_total_cost_fen")),
- status=str(raw.get("status") or "DRAFT"),
- enabled=_as_required_int(raw.get("enabled")),
- ))
- return patterns
- def seed_default_draft_patterns(seed_path: Path = DEFAULT_PATTERN_SEED_PATH) -> int:
- """更新或插入从 Top100 分析得到的初始 DRAFT 创意模式。"""
- ensure_strategy_learning_tables()
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.executemany(
- """
- INSERT INTO material_creative_pattern (
- pattern_version, pattern_key, pattern_name, hook_category, visual_template,
- applicable_crowd_packages, applicable_placements, target_age_min, target_age_max,
- title_hook_rule, visual_rule, relevance_rule, compliance_rule, selector_config,
- positive_examples, negative_examples, source_run_id, source_material_count,
- source_total_cost_fen, status, enabled
- ) VALUES (
- %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
- )
- ON DUPLICATE KEY UPDATE
- pattern_name=VALUES(pattern_name),
- hook_category=VALUES(hook_category),
- visual_template=VALUES(visual_template),
- applicable_crowd_packages=VALUES(applicable_crowd_packages),
- applicable_placements=VALUES(applicable_placements),
- target_age_min=VALUES(target_age_min),
- target_age_max=VALUES(target_age_max),
- title_hook_rule=VALUES(title_hook_rule),
- visual_rule=VALUES(visual_rule),
- relevance_rule=VALUES(relevance_rule),
- compliance_rule=VALUES(compliance_rule),
- selector_config=VALUES(selector_config),
- positive_examples=VALUES(positive_examples),
- negative_examples=VALUES(negative_examples),
- source_run_id=VALUES(source_run_id),
- source_material_count=VALUES(source_material_count),
- source_total_cost_fen=VALUES(source_total_cost_fen),
- status=VALUES(status),
- enabled=VALUES(enabled),
- updated_at=CURRENT_TIMESTAMP
- """,
- [
- (
- p.pattern_version,
- p.pattern_key,
- p.pattern_name,
- p.hook_category,
- p.visual_template,
- p.applicable_crowd_packages or None,
- p.applicable_placements or None,
- p.target_age_min,
- p.target_age_max,
- p.title_hook_rule,
- p.visual_rule,
- p.relevance_rule,
- p.compliance_rule,
- _json_dumps(p.selector_config or {}),
- _json_dumps(p.positive_examples or []),
- _json_dumps(p.negative_examples or []),
- p.source_run_id,
- p.source_material_count,
- p.source_total_cost_fen,
- p.status,
- p.enabled,
- )
- for p in _load_seed_patterns(seed_path)
- ],
- )
- conn.commit()
- finally:
- conn.close()
- return len(_load_seed_patterns(seed_path))
- def load_creative_patterns(include_draft: bool = False) -> list[CreativePattern]:
- """读取可选创意模式;生产环境应保持 include_draft=False。"""
- ensure_strategy_learning_tables()
- from db.connection import get_connection
- if include_draft:
- where_sql = "status IN ('DRAFT', 'APPROVED')"
- else:
- where_sql = "status = 'APPROVED' AND enabled = 1"
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- f"""
- SELECT *
- FROM material_creative_pattern
- WHERE {where_sql}
- ORDER BY enabled DESC, source_total_cost_fen DESC, id ASC
- """
- )
- rows = cur.fetchall() or []
- finally:
- conn.close()
- return [_pattern_from_row(row) for row in rows]
- def _feature_texts(video_features: Sequence[Any]) -> list[str]:
- texts: list[str] = []
- for feature in video_features:
- element_dimension = str(getattr(feature, "element_dimension", "") or "")
- point_type = str(getattr(feature, "point_type", "") or "")
- standard_element = str(getattr(feature, "standard_element", "") or "")
- text = " ".join(part for part in [element_dimension, point_type, standard_element] if part)
- if text:
- texts.append(text)
- return texts
- def _contains_any(text: str, keywords: Sequence[str]) -> bool:
- return any(keyword and keyword in text for keyword in keywords)
- def _selection_context_bonus(
- pattern: CreativePattern,
- *,
- placement: str,
- ) -> tuple[float, list[str], list[str]]:
- score = 0.0
- reasons: list[str] = []
- penalties: list[str] = []
- if pattern.status == "APPROVED" and pattern.enabled:
- score += 20
- reasons.append("approved_enabled")
- elif pattern.status == "DRAFT":
- score += 5
- reasons.append("draft_for_review")
- if pattern.applicable_placements:
- placements = [p.strip() for p in pattern.applicable_placements.split(",") if p.strip()]
- if placement and placement in placements:
- score += 6
- reasons.append("placement_matched")
- else:
- score -= 6
- penalties.append("placement_not_matched")
- return score, reasons, penalties
- def _fallback_score_pattern(
- pattern: CreativePattern,
- *,
- feature_texts: Sequence[str],
- placement: str,
- ) -> PatternSelection:
- """模型选择器不可用时采用稳定的兜底评分。
- 这里刻意不使用关键词相关性匹配;模式相关性由模型节点负责,兜底逻辑只保证
- 系统可用且可审计。
- """
- joined_features = " ".join(feature_texts)
- score = 10.0
- context_score, reasons, penalties = _selection_context_bonus(
- pattern,
- placement=placement,
- )
- score += context_score
- reasons = ["fallback_candidate", *reasons]
- matched_features: list[str] = list(feature_texts[:3])
- # 仅使用历史素材量作为弱先验,不代表语义相关性。
- if pattern.source_total_cost_fen:
- score += min(15.0, pattern.source_total_cost_fen / 10_000_000)
- reasons.append("historical_cost_prior")
- if pattern.source_material_count:
- score += min(5.0, pattern.source_material_count / 2)
- reasons.append("historical_sample_count_prior")
- if not feature_texts:
- score -= 15
- penalties.append("no_video_features")
- deduped_matches = []
- seen_matches: set[str] = set()
- for text in matched_features:
- if text not in seen_matches:
- seen_matches.add(text)
- deduped_matches.append(text)
- return PatternSelection(
- pattern=pattern,
- score=round(score, 2),
- reasons=reasons,
- penalties=penalties,
- matched_features=deduped_matches[:5],
- )
- def _openrouter_api_key() -> str:
- # 保持与 tools/ai_generated_material.py 相同的密钥读取优先级。
- key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
- if not key:
- raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法用模型选择pattern")
- return key
- def _extract_json_object(text: str) -> dict[str, Any]:
- raw = str(text or "").strip()
- if raw.startswith("```"):
- raw = re.sub(r"^```(?:json)?", "", raw).strip()
- raw = re.sub(r"```$", "", raw).strip()
- try:
- parsed = json.loads(raw)
- except json.JSONDecodeError:
- match = re.search(r"\{.*\}", raw, flags=re.S)
- if not match:
- raise
- parsed = json.loads(match.group(0))
- if not isinstance(parsed, dict):
- raise ValueError("模型返回不是JSON object")
- return parsed
- def _load_pattern_selector_policy(path: Path = DEFAULT_PATTERN_SELECTOR_PROMPT_PATH) -> str:
- return path.read_text(encoding="utf-8").strip()
- def _model_rerank_pattern_selections(
- *,
- feature_texts: Sequence[str],
- placement: str,
- candidates: Sequence[PatternSelection],
- top_k: int,
- model: str | None = None,
- ) -> list[PatternSelection]:
- """使用 LLM 节点从既有候选模式中选择。
- 模型只能重排或选择数据库产生的候选,不能编造模式键,也不能绕过合规和风险上下文。
- """
- if not candidates:
- return []
- model_name = model or OPENROUTER_TEXT_MODEL
- candidate_payload = [
- {
- "pattern_key": item.pattern.pattern_key,
- "pattern_name": item.pattern.pattern_name,
- "hook_category": item.pattern.hook_category,
- "visual_template": item.pattern.visual_template,
- "title_hook_rule": item.pattern.title_hook_rule,
- "visual_rule": item.pattern.visual_rule,
- "relevance_rule": item.pattern.relevance_rule,
- "compliance_rule": item.pattern.compliance_rule,
- "fallback_score": item.score,
- "fallback_reasons": item.reasons,
- "fallback_penalties": item.penalties,
- "positive_examples": item.pattern.positive_examples or [],
- "negative_examples": item.pattern.negative_examples or [],
- }
- for item in candidates
- ]
- prompt_payload = {
- "selection_policy": _load_pattern_selector_policy(),
- "video_features": list(feature_texts),
- "placement": placement,
- "top_k": top_k,
- "candidate_patterns": candidate_payload,
- "output_schema": {
- "selected": [
- {
- "pattern_key": "候选pattern_key",
- "score": "0-100整数,表示模型选择置信度",
- "reason": "为什么这个pattern最匹配视频",
- "matched_features": ["命中的视频特征文本"],
- "risk_notes": ["需要注意的合规风险,没有则空数组"],
- }
- ],
- "no_match_example": {"selected": []},
- },
- }
- resp = httpx.post(
- OPENROUTER_CHAT_COMPLETIONS_URL,
- headers={
- "Authorization": f"Bearer {_openrouter_api_key()}",
- "Content-Type": "application/json",
- },
- json={
- "model": model_name,
- "messages": [
- {
- "role": "system",
- "content": "你是广告创意策略选择器,只做结构化JSON输出。",
- },
- {
- "role": "user",
- "content": json.dumps(prompt_payload, ensure_ascii=False),
- },
- ],
- "temperature": 0.2,
- },
- timeout=45,
- )
- resp.raise_for_status()
- data = resp.json()
- content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "")
- parsed = _extract_json_object(content)
- selected = parsed.get("selected") or []
- by_key = {item.pattern.pattern_key: item for item in candidates}
- out: list[PatternSelection] = []
- seen: set[str] = set()
- for raw in selected:
- if not isinstance(raw, dict):
- continue
- key = str(raw.get("pattern_key") or "")
- if not key or key in seen or key not in by_key:
- continue
- seen.add(key)
- base = by_key[key]
- try:
- model_score = float(raw.get("score"))
- except (TypeError, ValueError):
- model_score = base.score
- if model_score < PATTERN_SELECTOR_MIN_MODEL_SCORE:
- continue
- reason = str(raw.get("reason") or "").strip()
- risk_notes = [str(v) for v in raw.get("risk_notes") or [] if str(v)]
- matched = [str(v) for v in raw.get("matched_features") or [] if str(v)]
- out.append(PatternSelection(
- pattern=base.pattern,
- score=round(model_score, 2),
- reasons=[*base.reasons, "model_selected", *(["model_reason:" + reason] if reason else [])],
- penalties=[*base.penalties, *["model_risk:" + note for note in risk_notes]],
- matched_features=matched or base.matched_features,
- ))
- if len(out) >= top_k:
- break
- return out
- def select_creative_patterns(
- *,
- video_features: Sequence[Any],
- crowd_package: str = "",
- placement: str = "",
- include_draft: bool = False,
- top_k: int = 3,
- use_model: bool = True,
- model: str | None = None,
- ) -> list[PatternSelection]:
- """为一个视频选择创意模式。
- 生产流程:先从数据库读取模式,再把全部可用候选交给模型做语义选择;模型
- 失败时使用不依赖语义的稳定兜底。此处刻意忽略 crowd_package,因为素材风格
- 选择在不同人群包之间共享。
- """
- patterns = load_creative_patterns(include_draft=include_draft)
- feature_texts = _feature_texts(video_features)
- placement = str(placement or "")
- selections = [
- _fallback_score_pattern(
- pattern,
- feature_texts=feature_texts,
- placement=placement,
- )
- for pattern in patterns
- ]
- selections.sort(key=lambda item: item.score, reverse=True)
- if use_model:
- try:
- return _model_rerank_pattern_selections(
- feature_texts=feature_texts,
- placement=placement,
- candidates=selections,
- top_k=max(1, int(top_k)),
- model=model,
- )
- except Exception as e:
- logger.warning("[pattern_selector] model selection failed, fallback to stable score: %s", e)
- return selections[:max(1, int(top_k))]
- def _read_csv(path: Path) -> list[dict[str, str]]:
- with path.open("r", encoding="utf-8-sig", newline="") as f:
- return list(csv.DictReader(f))
- def _read_json(path: Path) -> Any:
- return json.loads(path.read_text(encoding="utf-8"))
- def _json_dumps(data: Any) -> str:
- return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
- def _as_int(value: Any) -> int | None:
- text = str(value or "").strip()
- if not text:
- return None
- try:
- return int(Decimal(text))
- except (InvalidOperation, ValueError):
- return None
- def _as_required_int(value: Any, default: int = 0) -> int:
- parsed = _as_int(value)
- return default if parsed is None else parsed
- def _as_decimal(value: Any, places: str = "0.000001") -> Decimal | None:
- text = str(value or "").strip()
- if not text:
- return None
- try:
- return Decimal(text).quantize(Decimal(places), rounding=ROUND_HALF_UP)
- except (InvalidOperation, ValueError):
- return None
- def _yuan_to_fen(value: Any) -> int:
- text = str(value or "").strip()
- if not text:
- return 0
- try:
- yuan = Decimal(text)
- except (InvalidOperation, ValueError):
- return 0
- return int((yuan * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
- def _bool_int(value: Any) -> int:
- return 1 if str(value or "").strip().lower() in {"1", "true", "yes", "y", "是"} else 0
- def _risk_flags(row: dict[str, str]) -> set[str]:
- raw = row.get("risk_flags") or ""
- return {part.strip() for part in raw.split(",") if part.strip()}
- def _file_or_url_hash(image_path: str, image_url: str, base_dir: Path) -> str:
- path = Path(image_path) if image_path else Path()
- if image_path and not path.is_absolute():
- path = base_dir / path
- if image_path and path.exists():
- return hashlib.sha256(path.read_bytes()).hexdigest()
- return hashlib.sha256(str(image_url or "").encode("utf-8")).hexdigest()
- def _annotation_hash_map(annotation_rows: Iterable[dict[str, str]], base_dir: Path) -> dict[int, str]:
- out: dict[int, str] = {}
- for row in annotation_rows:
- creative_id = _as_int(row.get("creative_id"))
- if creative_id is None:
- continue
- out[creative_id] = _file_or_url_hash(
- row.get("image_path") or "",
- row.get("image_url") or "",
- base_dir,
- )
- return out
- def import_current_material_analysis(
- *,
- run_id: str,
- window_start: date,
- window_end: date,
- performance_csv: Path,
- performance_summary_json: Path,
- visual_annotations_csv: Path,
- visual_summary_json: Path,
- report_path: Path,
- sql_file: str,
- top_n: int = 5000,
- annotation_version: str = "top100_rule_v1",
- annotator: str = "rule_contact_sheet_review",
- report_version: str = "top100_visual_v1",
- ) -> ImportResult:
- """将已有的本地高消耗素材分析导入数据库。"""
- ensure_strategy_learning_tables()
- performance_rows = _read_csv(performance_csv)
- performance_summary = _read_json(performance_summary_json)
- annotation_rows = _read_csv(visual_annotations_csv)
- visual_summary = _read_json(visual_summary_json)
- image_hash_by_creative = _annotation_hash_map(annotation_rows, Path.cwd())
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- """
- INSERT INTO material_performance_snapshot_run (
- run_id, window_start, window_end, top_n, source, sql_file,
- row_count, total_cost_fen, status, error_message
- ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
- ON DUPLICATE KEY UPDATE
- window_start=VALUES(window_start),
- window_end=VALUES(window_end),
- top_n=VALUES(top_n),
- source=VALUES(source),
- sql_file=VALUES(sql_file),
- row_count=VALUES(row_count),
- total_cost_fen=VALUES(total_cost_fen),
- status=VALUES(status),
- error_message=VALUES(error_message),
- updated_at=CURRENT_TIMESTAMP
- """,
- (
- run_id,
- window_start,
- window_end,
- top_n,
- "odps",
- sql_file,
- int(performance_summary.get("rows") or len(performance_rows)),
- _yuan_to_fen(performance_summary.get("total_cost_yuan")),
- "SUCCESS",
- None,
- ),
- )
- snapshot_values = []
- for idx, row in enumerate(performance_rows, start=1):
- creative_id = _as_required_int(row.get("creative_id"))
- rank_no = _as_int(row.get("rank")) or idx
- snapshot_values.append(
- (
- run_id,
- rank_no,
- _as_required_int(row.get("account_id")),
- _as_required_int(row.get("ad_id")),
- creative_id,
- row.get("creative_name") or None,
- row.get("ad_name") or None,
- _as_int(row.get("video_id")),
- row.get("title") or None,
- row.get("image_url") or None,
- image_hash_by_creative.get(creative_id),
- row.get("package_name") or None,
- row.get("optimization_goal") or None,
- _as_int(row.get("bid_amount")),
- _as_int(row.get("day_amount")),
- _yuan_to_fen(row.get("cost_yuan")),
- _as_required_int(row.get("view_count")),
- _as_required_int(row.get("valid_click_count")),
- _as_decimal(row.get("ctr")),
- _as_required_int(row.get("key_page_view_count")),
- _as_decimal(row.get("key_page_rate")),
- _as_required_int(row.get("conversions_count")),
- _as_decimal(row.get("conversion_rate")),
- _as_required_int(row.get("active_days")),
- row.get("first_dt") or None,
- row.get("last_dt") or None,
- _json_dumps(row),
- )
- )
- cur.executemany(
- """
- INSERT INTO material_performance_snapshot_item (
- run_id, rank_no, account_id, ad_id, creative_id, creative_name, ad_name,
- video_id, title, image_url, image_hash, crowd_package, optimization_goal,
- bid_amount_fen, day_amount_fen, cost_fen, impressions, clicks, ctr,
- key_page_view_count, key_page_rate, conversions_count, conversion_rate,
- active_days, first_dt, last_dt, raw_json
- ) VALUES (
- %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
- )
- ON DUPLICATE KEY UPDATE
- rank_no=VALUES(rank_no),
- account_id=VALUES(account_id),
- ad_id=VALUES(ad_id),
- creative_name=VALUES(creative_name),
- ad_name=VALUES(ad_name),
- video_id=VALUES(video_id),
- title=VALUES(title),
- image_url=VALUES(image_url),
- image_hash=VALUES(image_hash),
- crowd_package=VALUES(crowd_package),
- optimization_goal=VALUES(optimization_goal),
- bid_amount_fen=VALUES(bid_amount_fen),
- day_amount_fen=VALUES(day_amount_fen),
- cost_fen=VALUES(cost_fen),
- impressions=VALUES(impressions),
- clicks=VALUES(clicks),
- ctr=VALUES(ctr),
- key_page_view_count=VALUES(key_page_view_count),
- key_page_rate=VALUES(key_page_rate),
- conversions_count=VALUES(conversions_count),
- conversion_rate=VALUES(conversion_rate),
- active_days=VALUES(active_days),
- first_dt=VALUES(first_dt),
- last_dt=VALUES(last_dt),
- raw_json=VALUES(raw_json),
- updated_at=CURRENT_TIMESTAMP
- """,
- snapshot_values,
- )
- annotation_values = []
- for row in annotation_rows:
- flags = _risk_flags(row)
- image_hash = _file_or_url_hash(
- row.get("image_path") or "",
- row.get("image_url") or "",
- Path.cwd(),
- )
- annotation_values.append(
- (
- image_hash,
- row.get("image_url") or "",
- annotation_version,
- annotator,
- _as_int(row.get("creative_id")),
- row.get("visual_template") or None,
- row.get("hook_categories") or None,
- row.get("title") or None,
- len(row.get("title") or ""),
- row.get("scene_type") or None,
- row.get("main_subject_est") or None,
- _bool_int(row.get("has_human_est")),
- row.get("text_area_ratio_est") or None,
- row.get("dominant_tone") or None,
- _bool_int(row.get("button_like_element_est")),
- 1 if "fake_ui" in flags or "fake_button" in flags else 0,
- 1 if "policy_money_claim_risk" in flags else 0,
- 1 if "medical_health_risk" in flags else 0,
- 1 if "politics_country_sensitive" in flags else 0,
- 1 if "celebrity_or_history_person" in flags else 0,
- 1 if "strong_inducement" in flags or "button_like_inducement" in flags else 0,
- 1 if "greeting_blessing_filtered" in flags else 0,
- row.get("risk_level_for_generation") or "caution",
- row.get("learnable_points") or None,
- row.get("avoid_points") or None,
- _json_dumps(row),
- )
- )
- cur.executemany(
- """
- INSERT INTO material_visual_annotation (
- image_hash, image_url, annotation_version, annotator, creative_id,
- visual_template, hook_category, title_text, title_length, scene_type,
- person_type, has_human, text_area_level, color_style,
- button_like_element, fake_ui_risk, official_policy_risk,
- medical_health_risk, politics_sensitive_risk, celebrity_or_history_risk,
- strong_inducement_risk, greeting_blessing_risk, compliance_level,
- learnable_points, avoid_points, raw_annotation
- ) VALUES (
- %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
- )
- ON DUPLICATE KEY UPDATE
- image_url=VALUES(image_url),
- annotator=VALUES(annotator),
- creative_id=VALUES(creative_id),
- visual_template=VALUES(visual_template),
- hook_category=VALUES(hook_category),
- title_text=VALUES(title_text),
- title_length=VALUES(title_length),
- scene_type=VALUES(scene_type),
- person_type=VALUES(person_type),
- has_human=VALUES(has_human),
- text_area_level=VALUES(text_area_level),
- color_style=VALUES(color_style),
- button_like_element=VALUES(button_like_element),
- fake_ui_risk=VALUES(fake_ui_risk),
- official_policy_risk=VALUES(official_policy_risk),
- medical_health_risk=VALUES(medical_health_risk),
- politics_sensitive_risk=VALUES(politics_sensitive_risk),
- celebrity_or_history_risk=VALUES(celebrity_or_history_risk),
- strong_inducement_risk=VALUES(strong_inducement_risk),
- greeting_blessing_risk=VALUES(greeting_blessing_risk),
- compliance_level=VALUES(compliance_level),
- learnable_points=VALUES(learnable_points),
- avoid_points=VALUES(avoid_points),
- raw_annotation=VALUES(raw_annotation),
- updated_at=CURRENT_TIMESTAMP
- """,
- annotation_values,
- )
- report_id = f"{run_id}_{report_version}"
- summary_text = report_path.read_text(encoding="utf-8") if report_path.exists() else ""
- cur.execute(
- """
- INSERT INTO material_strategy_learning_report (
- report_id, run_id, report_version, summary, top_patterns,
- risk_summary, recommended_actions, report_path
- ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
- ON DUPLICATE KEY UPDATE
- run_id=VALUES(run_id),
- report_version=VALUES(report_version),
- summary=VALUES(summary),
- top_patterns=VALUES(top_patterns),
- risk_summary=VALUES(risk_summary),
- recommended_actions=VALUES(recommended_actions),
- report_path=VALUES(report_path),
- updated_at=CURRENT_TIMESTAMP
- """,
- (
- report_id,
- run_id,
- report_version,
- summary_text,
- _json_dumps(visual_summary.get("visual_template_counts") or {}),
- _json_dumps(
- {
- "risk_level_counts": visual_summary.get("risk_level_counts") or {},
- "risk_flag_counts": visual_summary.get("risk_flag_counts") or [],
- }
- ),
- _json_dumps(
- [
- "学习大字信息差结构,不要复制历史标题",
- "避免伪按钮、假界面、强诱导、涉政和医疗恐吓",
- "生成素材仍以承接视频ODPS内容特征为主",
- ]
- ),
- str(report_path),
- ),
- )
- conn.commit()
- finally:
- conn.close()
- return ImportResult(
- run_id=run_id,
- snapshot_rows=len(performance_rows),
- annotation_rows=len(annotation_rows),
- report_rows=1,
- )
|