material_strategy_learning.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. """Material strategy learning persistence.
  2. This module stores high-consumption material snapshots and visual annotations.
  3. It is intentionally separate from the ad/creative creation pipeline: importing
  4. learning data must not create or modify Tencent ads.
  5. """
  6. from __future__ import annotations
  7. import csv
  8. import hashlib
  9. import json
  10. import logging
  11. import os
  12. import re
  13. from dataclasses import dataclass
  14. from datetime import date
  15. from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
  16. from pathlib import Path
  17. from typing import Any, Iterable, Sequence
  18. import httpx
  19. logger = logging.getLogger(__name__)
  20. CREATE_STRATEGY_LEARNING_TABLES_SQL = [
  21. """
  22. CREATE TABLE IF NOT EXISTS material_performance_snapshot_run (
  23. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  24. run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
  25. window_start DATE NOT NULL COMMENT '统计窗口开始日期',
  26. window_end DATE NOT NULL COMMENT '统计窗口结束日期',
  27. top_n INT NOT NULL DEFAULT 5000 COMMENT '拉取TopN',
  28. source VARCHAR(64) NOT NULL DEFAULT 'odps' COMMENT '数据来源',
  29. sql_file VARCHAR(255) DEFAULT NULL COMMENT 'SQL文件路径',
  30. row_count INT NOT NULL DEFAULT 0 COMMENT '明细行数',
  31. total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内总消耗(分)',
  32. status VARCHAR(32) NOT NULL DEFAULT 'SUCCESS' COMMENT '任务状态',
  33. error_message MEDIUMTEXT DEFAULT NULL COMMENT '错误信息',
  34. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  35. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  36. UNIQUE KEY uk_run_id (run_id),
  37. KEY idx_window (window_start, window_end)
  38. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照任务'
  39. """,
  40. """
  41. CREATE TABLE IF NOT EXISTS material_performance_snapshot_item (
  42. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  43. run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
  44. rank_no INT NOT NULL COMMENT '消耗排名',
  45. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  46. ad_id BIGINT NOT NULL COMMENT '广告ID',
  47. creative_id BIGINT NOT NULL COMMENT '创意ID',
  48. creative_name VARCHAR(255) DEFAULT NULL COMMENT '创意名称',
  49. ad_name VARCHAR(255) DEFAULT NULL COMMENT '广告名称',
  50. video_id BIGINT DEFAULT NULL COMMENT '承接视频ID',
  51. title VARCHAR(512) DEFAULT NULL COMMENT '素材标题',
  52. image_url VARCHAR(1024) DEFAULT NULL COMMENT '素材图片URL',
  53. image_hash VARCHAR(64) DEFAULT NULL COMMENT '图片内容hash',
  54. crowd_package VARCHAR(255) DEFAULT NULL COMMENT '人群包名称',
  55. optimization_goal VARCHAR(128) DEFAULT NULL COMMENT '优化目标',
  56. bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)',
  57. day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)',
  58. cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '消耗(分)',
  59. impressions BIGINT NOT NULL DEFAULT 0 COMMENT '曝光',
  60. clicks BIGINT NOT NULL DEFAULT 0 COMMENT '点击',
  61. ctr DECIMAL(10, 6) DEFAULT NULL COMMENT '点击率',
  62. key_page_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '关键页访问次数',
  63. key_page_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '关键页访问/点击',
  64. conversions_count BIGINT NOT NULL DEFAULT 0 COMMENT '转化数',
  65. conversion_rate DECIMAL(10, 6) DEFAULT NULL COMMENT '转化率',
  66. active_days INT NOT NULL DEFAULT 0 COMMENT '有消耗天数',
  67. first_dt VARCHAR(16) DEFAULT NULL COMMENT '首次投放日期',
  68. last_dt VARCHAR(16) DEFAULT NULL COMMENT '最后投放日期',
  69. raw_json MEDIUMTEXT DEFAULT NULL COMMENT '原始行JSON',
  70. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  71. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  72. UNIQUE KEY uk_run_creative (run_id, creative_id),
  73. KEY idx_creative (creative_id),
  74. KEY idx_image_hash (image_hash),
  75. KEY idx_video (video_id),
  76. KEY idx_package_cost (crowd_package, cost_fen)
  77. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='高消耗素材表现快照明细'
  78. """,
  79. """
  80. CREATE TABLE IF NOT EXISTS material_visual_annotation (
  81. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  82. image_hash VARCHAR(64) NOT NULL COMMENT '图片内容hash',
  83. image_url VARCHAR(1024) NOT NULL COMMENT '素材图片URL',
  84. annotation_version VARCHAR(64) NOT NULL COMMENT '标注版本',
  85. annotator VARCHAR(64) NOT NULL COMMENT '标注来源',
  86. creative_id BIGINT DEFAULT NULL COMMENT '样本创意ID',
  87. visual_template VARCHAR(128) DEFAULT NULL COMMENT '视觉模板',
  88. hook_category VARCHAR(255) DEFAULT NULL COMMENT '标题钩子分类',
  89. title_text VARCHAR(512) DEFAULT NULL COMMENT '图片/素材标题',
  90. title_length INT DEFAULT NULL COMMENT '标题长度',
  91. scene_type VARCHAR(128) DEFAULT NULL COMMENT '场景类型',
  92. person_type VARCHAR(128) DEFAULT NULL COMMENT '人物/主体估计',
  93. has_human TINYINT DEFAULT NULL COMMENT '是否有人物',
  94. text_area_level VARCHAR(32) DEFAULT NULL COMMENT '文字面积估计等级',
  95. color_style VARCHAR(128) DEFAULT NULL COMMENT '颜色/调性',
  96. button_like_element TINYINT NOT NULL DEFAULT 0 COMMENT '是否疑似按钮诱导',
  97. fake_ui_risk TINYINT NOT NULL DEFAULT 0 COMMENT '假界面风险',
  98. official_policy_risk TINYINT NOT NULL DEFAULT 0 COMMENT '官方/政策承诺风险',
  99. medical_health_risk TINYINT NOT NULL DEFAULT 0 COMMENT '医疗健康风险',
  100. politics_sensitive_risk TINYINT NOT NULL DEFAULT 0 COMMENT '涉政/国家情绪风险',
  101. celebrity_or_history_risk TINYINT NOT NULL DEFAULT 0 COMMENT '名人/历史人物风险',
  102. strong_inducement_risk TINYINT NOT NULL DEFAULT 0 COMMENT '强诱导风险',
  103. greeting_blessing_risk TINYINT NOT NULL DEFAULT 0 COMMENT '早晚安/祝福风险',
  104. compliance_level VARCHAR(32) NOT NULL DEFAULT 'caution' COMMENT '生成可学习等级',
  105. learnable_points MEDIUMTEXT DEFAULT NULL COMMENT '可学习点',
  106. avoid_points MEDIUMTEXT DEFAULT NULL COMMENT '避让点',
  107. raw_annotation MEDIUMTEXT DEFAULT NULL COMMENT '原始标注JSON',
  108. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  109. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  110. UNIQUE KEY uk_image_version (image_hash, annotation_version),
  111. KEY idx_creative (creative_id),
  112. KEY idx_template (visual_template),
  113. KEY idx_compliance (compliance_level)
  114. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材图片结构化标注'
  115. """,
  116. """
  117. CREATE TABLE IF NOT EXISTS material_creative_pattern (
  118. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  119. pattern_version VARCHAR(64) NOT NULL COMMENT '策略版本',
  120. pattern_key VARCHAR(128) NOT NULL COMMENT '策略唯一键',
  121. pattern_name VARCHAR(128) NOT NULL COMMENT '策略名称',
  122. hook_category VARCHAR(128) NOT NULL COMMENT '标题钩子类型',
  123. visual_template VARCHAR(128) NOT NULL COMMENT '视觉模板',
  124. applicable_crowd_packages VARCHAR(1024) DEFAULT NULL COMMENT '适用人群包',
  125. applicable_placements VARCHAR(1024) DEFAULT NULL COMMENT '适用版位',
  126. target_age_min INT DEFAULT NULL COMMENT '适用年龄下限',
  127. target_age_max INT DEFAULT NULL COMMENT '适用年龄上限',
  128. title_hook_rule MEDIUMTEXT NOT NULL COMMENT '标题钩子规则',
  129. visual_rule MEDIUMTEXT NOT NULL COMMENT '视觉规则',
  130. relevance_rule MEDIUMTEXT NOT NULL COMMENT '视频相关性规则',
  131. compliance_rule MEDIUMTEXT NOT NULL COMMENT '合规规则',
  132. selector_config MEDIUMTEXT DEFAULT NULL COMMENT '选择器配置JSON:match/exclude/score',
  133. positive_examples MEDIUMTEXT DEFAULT NULL COMMENT '正例JSON',
  134. negative_examples MEDIUMTEXT DEFAULT NULL COMMENT '反例JSON',
  135. source_run_id VARCHAR(64) DEFAULT NULL COMMENT '来源快照ID',
  136. source_material_count INT NOT NULL DEFAULT 0 COMMENT '来源素材数',
  137. source_total_cost_fen BIGINT NOT NULL DEFAULT 0 COMMENT '来源素材消耗(分)',
  138. status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED',
  139. reviewed_by VARCHAR(64) DEFAULT NULL COMMENT '审核人',
  140. reviewed_at TIMESTAMP NULL DEFAULT NULL COMMENT '审核时间',
  141. enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用',
  142. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  143. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  144. UNIQUE KEY uk_version_key (pattern_version, pattern_key),
  145. KEY idx_status_enabled (status, enabled),
  146. KEY idx_hook_template (hook_category, visual_template)
  147. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成素材可学习创意模式'
  148. """,
  149. """
  150. CREATE TABLE IF NOT EXISTS material_strategy_learning_report (
  151. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  152. report_id VARCHAR(64) NOT NULL COMMENT '报告ID',
  153. run_id VARCHAR(64) NOT NULL COMMENT '快照任务ID',
  154. report_version VARCHAR(64) NOT NULL COMMENT '报告版本',
  155. summary MEDIUMTEXT NOT NULL COMMENT '报告摘要',
  156. top_patterns MEDIUMTEXT DEFAULT NULL COMMENT '核心模式JSON',
  157. risk_summary MEDIUMTEXT DEFAULT NULL COMMENT '风险摘要JSON',
  158. recommended_actions MEDIUMTEXT DEFAULT NULL COMMENT '建议动作JSON',
  159. report_path VARCHAR(512) DEFAULT NULL COMMENT '本地报告路径',
  160. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  161. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  162. UNIQUE KEY uk_report_id (report_id),
  163. KEY idx_run_id (run_id)
  164. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='素材策略学习周报'
  165. """,
  166. ]
  167. STRATEGY_LEARNING_MIGRATIONS_SQL = [
  168. """
  169. ALTER TABLE material_performance_snapshot_item
  170. MODIFY COLUMN bid_amount_fen BIGINT DEFAULT NULL COMMENT '出价(分)'
  171. """,
  172. """
  173. ALTER TABLE material_performance_snapshot_item
  174. MODIFY COLUMN day_amount_fen BIGINT DEFAULT NULL COMMENT '日预算(分)'
  175. """,
  176. ]
  177. @dataclass(frozen=True)
  178. class ImportResult:
  179. run_id: str
  180. snapshot_rows: int
  181. annotation_rows: int
  182. report_rows: int
  183. @dataclass(frozen=True)
  184. class CreativePattern:
  185. pattern_version: str
  186. pattern_key: str
  187. pattern_name: str
  188. hook_category: str
  189. visual_template: str
  190. title_hook_rule: str
  191. visual_rule: str
  192. relevance_rule: str
  193. compliance_rule: str
  194. selector_config: dict[str, Any] | None = None
  195. applicable_crowd_packages: str = ""
  196. applicable_placements: str = ""
  197. target_age_min: int | None = 60
  198. target_age_max: int | None = 75
  199. positive_examples: list[str] | None = None
  200. negative_examples: list[str] | None = None
  201. source_run_id: str | None = None
  202. source_material_count: int = 0
  203. source_total_cost_fen: int = 0
  204. status: str = "DRAFT"
  205. enabled: int = 0
  206. @dataclass(frozen=True)
  207. class PatternSelection:
  208. pattern: CreativePattern
  209. score: float
  210. reasons: list[str]
  211. penalties: list[str]
  212. matched_features: list[str]
  213. DEFAULT_PATTERN_VERSION = "seed_20260708_v1"
  214. DEFAULT_SOURCE_RUN_ID = "material_30d_20260607_20260706_top5000"
  215. DEFAULT_PATTERN_SEED_PATH = (
  216. Path(__file__).resolve().parents[1] / "configs" / "material_creative_patterns_seed.json"
  217. )
  218. DEFAULT_PATTERN_SELECTOR_PROMPT_PATH = (
  219. Path(__file__).resolve().parents[1] / "prompts" / "ai_pattern_selector.md"
  220. )
  221. OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
  222. "OPENROUTER_CHAT_COMPLETIONS_URL",
  223. "https://openrouter.ai/api/v1/chat/completions",
  224. )
  225. OPENROUTER_TEXT_MODEL = os.getenv("OPENROUTER_TEXT_MODEL", "google/gemini-3-flash-preview")
  226. PATTERN_SELECTOR_MIN_MODEL_SCORE = float(os.getenv("PATTERN_SELECTOR_MIN_MODEL_SCORE", "75"))
  227. def ensure_strategy_learning_tables() -> None:
  228. from db.connection import get_connection
  229. conn = get_connection()
  230. try:
  231. with conn.cursor() as cur:
  232. for sql in CREATE_STRATEGY_LEARNING_TABLES_SQL:
  233. cur.execute(sql)
  234. for sql in STRATEGY_LEARNING_MIGRATIONS_SQL:
  235. cur.execute(sql)
  236. cur.execute("SHOW COLUMNS FROM material_creative_pattern LIKE 'selector_config'")
  237. if not cur.fetchone():
  238. cur.execute(
  239. """
  240. ALTER TABLE material_creative_pattern
  241. ADD COLUMN selector_config MEDIUMTEXT DEFAULT NULL
  242. COMMENT '选择器配置JSON:match/exclude/score'
  243. AFTER compliance_rule
  244. """
  245. )
  246. conn.commit()
  247. finally:
  248. conn.close()
  249. def _pattern_from_row(row: dict[str, Any]) -> CreativePattern:
  250. def _loads_list(value: Any) -> list[str]:
  251. if not value:
  252. return []
  253. try:
  254. parsed = json.loads(str(value))
  255. except json.JSONDecodeError:
  256. return []
  257. if isinstance(parsed, list):
  258. return [str(item) for item in parsed]
  259. return []
  260. def _loads_dict(value: Any) -> dict[str, Any]:
  261. if not value:
  262. return {}
  263. if isinstance(value, dict):
  264. return value
  265. try:
  266. parsed = json.loads(str(value))
  267. except json.JSONDecodeError:
  268. return {}
  269. return parsed if isinstance(parsed, dict) else {}
  270. return CreativePattern(
  271. pattern_version=str(row.get("pattern_version") or ""),
  272. pattern_key=str(row.get("pattern_key") or ""),
  273. pattern_name=str(row.get("pattern_name") or ""),
  274. hook_category=str(row.get("hook_category") or ""),
  275. visual_template=str(row.get("visual_template") or ""),
  276. applicable_crowd_packages=str(row.get("applicable_crowd_packages") or ""),
  277. applicable_placements=str(row.get("applicable_placements") or ""),
  278. target_age_min=_as_int(row.get("target_age_min")),
  279. target_age_max=_as_int(row.get("target_age_max")),
  280. title_hook_rule=str(row.get("title_hook_rule") or ""),
  281. visual_rule=str(row.get("visual_rule") or ""),
  282. relevance_rule=str(row.get("relevance_rule") or ""),
  283. compliance_rule=str(row.get("compliance_rule") or ""),
  284. selector_config=_loads_dict(row.get("selector_config")),
  285. positive_examples=_loads_list(row.get("positive_examples")),
  286. negative_examples=_loads_list(row.get("negative_examples")),
  287. source_run_id=str(row.get("source_run_id") or "") or None,
  288. source_material_count=_as_required_int(row.get("source_material_count")),
  289. source_total_cost_fen=_as_required_int(row.get("source_total_cost_fen")),
  290. status=str(row.get("status") or "DRAFT"),
  291. enabled=_as_required_int(row.get("enabled")),
  292. )
  293. def _load_seed_patterns(path: Path = DEFAULT_PATTERN_SEED_PATH) -> list[CreativePattern]:
  294. raw_patterns = json.loads(path.read_text(encoding="utf-8"))
  295. patterns: list[CreativePattern] = []
  296. for raw in raw_patterns:
  297. patterns.append(CreativePattern(
  298. pattern_version=str(raw.get("pattern_version") or DEFAULT_PATTERN_VERSION),
  299. pattern_key=str(raw["pattern_key"]),
  300. pattern_name=str(raw["pattern_name"]),
  301. hook_category=str(raw.get("hook_category") or ""),
  302. visual_template=str(raw.get("visual_template") or ""),
  303. applicable_crowd_packages=str(raw.get("applicable_crowd_packages") or ""),
  304. applicable_placements=str(raw.get("applicable_placements") or ""),
  305. target_age_min=_as_int(raw.get("target_age_min")) or 60,
  306. target_age_max=_as_int(raw.get("target_age_max")) or 75,
  307. title_hook_rule=str(raw.get("title_hook_rule") or ""),
  308. visual_rule=str(raw.get("visual_rule") or ""),
  309. relevance_rule=str(raw.get("relevance_rule") or ""),
  310. compliance_rule=str(raw.get("compliance_rule") or ""),
  311. selector_config=raw.get("selector_config") or {},
  312. positive_examples=[str(v) for v in raw.get("positive_examples") or []],
  313. negative_examples=[str(v) for v in raw.get("negative_examples") or []],
  314. source_run_id=str(raw.get("source_run_id") or "") or None,
  315. source_material_count=_as_required_int(raw.get("source_material_count")),
  316. source_total_cost_fen=_as_required_int(raw.get("source_total_cost_fen")),
  317. status=str(raw.get("status") or "DRAFT"),
  318. enabled=_as_required_int(raw.get("enabled")),
  319. ))
  320. return patterns
  321. def seed_default_draft_patterns(seed_path: Path = DEFAULT_PATTERN_SEED_PATH) -> int:
  322. """Upsert initial DRAFT creative patterns learned from Top100 analysis."""
  323. ensure_strategy_learning_tables()
  324. from db.connection import get_connection
  325. conn = get_connection()
  326. try:
  327. with conn.cursor() as cur:
  328. cur.executemany(
  329. """
  330. INSERT INTO material_creative_pattern (
  331. pattern_version, pattern_key, pattern_name, hook_category, visual_template,
  332. applicable_crowd_packages, applicable_placements, target_age_min, target_age_max,
  333. title_hook_rule, visual_rule, relevance_rule, compliance_rule, selector_config,
  334. positive_examples, negative_examples, source_run_id, source_material_count,
  335. source_total_cost_fen, status, enabled
  336. ) VALUES (
  337. %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s
  338. )
  339. ON DUPLICATE KEY UPDATE
  340. pattern_name=VALUES(pattern_name),
  341. hook_category=VALUES(hook_category),
  342. visual_template=VALUES(visual_template),
  343. applicable_crowd_packages=VALUES(applicable_crowd_packages),
  344. applicable_placements=VALUES(applicable_placements),
  345. target_age_min=VALUES(target_age_min),
  346. target_age_max=VALUES(target_age_max),
  347. title_hook_rule=VALUES(title_hook_rule),
  348. visual_rule=VALUES(visual_rule),
  349. relevance_rule=VALUES(relevance_rule),
  350. compliance_rule=VALUES(compliance_rule),
  351. selector_config=VALUES(selector_config),
  352. positive_examples=VALUES(positive_examples),
  353. negative_examples=VALUES(negative_examples),
  354. source_run_id=VALUES(source_run_id),
  355. source_material_count=VALUES(source_material_count),
  356. source_total_cost_fen=VALUES(source_total_cost_fen),
  357. status=VALUES(status),
  358. enabled=VALUES(enabled),
  359. updated_at=CURRENT_TIMESTAMP
  360. """,
  361. [
  362. (
  363. p.pattern_version,
  364. p.pattern_key,
  365. p.pattern_name,
  366. p.hook_category,
  367. p.visual_template,
  368. p.applicable_crowd_packages or None,
  369. p.applicable_placements or None,
  370. p.target_age_min,
  371. p.target_age_max,
  372. p.title_hook_rule,
  373. p.visual_rule,
  374. p.relevance_rule,
  375. p.compliance_rule,
  376. _json_dumps(p.selector_config or {}),
  377. _json_dumps(p.positive_examples or []),
  378. _json_dumps(p.negative_examples or []),
  379. p.source_run_id,
  380. p.source_material_count,
  381. p.source_total_cost_fen,
  382. p.status,
  383. p.enabled,
  384. )
  385. for p in _load_seed_patterns(seed_path)
  386. ],
  387. )
  388. conn.commit()
  389. finally:
  390. conn.close()
  391. return len(_load_seed_patterns(seed_path))
  392. def load_creative_patterns(include_draft: bool = False) -> list[CreativePattern]:
  393. """Load selectable patterns. Production should keep include_draft=False."""
  394. ensure_strategy_learning_tables()
  395. from db.connection import get_connection
  396. if include_draft:
  397. where_sql = "status IN ('DRAFT', 'APPROVED')"
  398. else:
  399. where_sql = "status = 'APPROVED' AND enabled = 1"
  400. conn = get_connection()
  401. try:
  402. with conn.cursor() as cur:
  403. cur.execute(
  404. f"""
  405. SELECT *
  406. FROM material_creative_pattern
  407. WHERE {where_sql}
  408. ORDER BY enabled DESC, source_total_cost_fen DESC, id ASC
  409. """
  410. )
  411. rows = cur.fetchall() or []
  412. finally:
  413. conn.close()
  414. return [_pattern_from_row(row) for row in rows]
  415. def _feature_texts(video_features: Sequence[Any]) -> list[str]:
  416. texts: list[str] = []
  417. for feature in video_features:
  418. element_dimension = str(getattr(feature, "element_dimension", "") or "")
  419. point_type = str(getattr(feature, "point_type", "") or "")
  420. standard_element = str(getattr(feature, "standard_element", "") or "")
  421. text = " ".join(part for part in [element_dimension, point_type, standard_element] if part)
  422. if text:
  423. texts.append(text)
  424. return texts
  425. def _contains_any(text: str, keywords: Sequence[str]) -> bool:
  426. return any(keyword and keyword in text for keyword in keywords)
  427. def _selection_context_bonus(
  428. pattern: CreativePattern,
  429. *,
  430. placement: str,
  431. ) -> tuple[float, list[str], list[str]]:
  432. score = 0.0
  433. reasons: list[str] = []
  434. penalties: list[str] = []
  435. if pattern.status == "APPROVED" and pattern.enabled:
  436. score += 20
  437. reasons.append("approved_enabled")
  438. elif pattern.status == "DRAFT":
  439. score += 5
  440. reasons.append("draft_for_review")
  441. if pattern.applicable_placements:
  442. placements = [p.strip() for p in pattern.applicable_placements.split(",") if p.strip()]
  443. if placement and placement in placements:
  444. score += 6
  445. reasons.append("placement_matched")
  446. else:
  447. score -= 6
  448. penalties.append("placement_not_matched")
  449. return score, reasons, penalties
  450. def _fallback_score_pattern(
  451. pattern: CreativePattern,
  452. *,
  453. feature_texts: Sequence[str],
  454. placement: str,
  455. ) -> PatternSelection:
  456. """Stable fallback score when the model selector is unavailable.
  457. This intentionally does not use keyword relevance matching. Pattern
  458. relevance is the model node's responsibility. Fallback only keeps the system
  459. available and auditable.
  460. """
  461. joined_features = " ".join(feature_texts)
  462. score = 10.0
  463. context_score, reasons, penalties = _selection_context_bonus(
  464. pattern,
  465. placement=placement,
  466. )
  467. score += context_score
  468. reasons = ["fallback_candidate", *reasons]
  469. matched_features: list[str] = list(feature_texts[:3])
  470. # Weak prior from historical material volume, not semantic relevance.
  471. if pattern.source_total_cost_fen:
  472. score += min(15.0, pattern.source_total_cost_fen / 10_000_000)
  473. reasons.append("historical_cost_prior")
  474. if pattern.source_material_count:
  475. score += min(5.0, pattern.source_material_count / 2)
  476. reasons.append("historical_sample_count_prior")
  477. if not feature_texts:
  478. score -= 15
  479. penalties.append("no_video_features")
  480. deduped_matches = []
  481. seen_matches: set[str] = set()
  482. for text in matched_features:
  483. if text not in seen_matches:
  484. seen_matches.add(text)
  485. deduped_matches.append(text)
  486. return PatternSelection(
  487. pattern=pattern,
  488. score=round(score, 2),
  489. reasons=reasons,
  490. penalties=penalties,
  491. matched_features=deduped_matches[:5],
  492. )
  493. def _openrouter_api_key() -> str:
  494. # Keep the same precedence as tools/ai_generated_material.py.
  495. key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
  496. if not key:
  497. raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法用模型选择pattern")
  498. return key
  499. def _extract_json_object(text: str) -> dict[str, Any]:
  500. raw = str(text or "").strip()
  501. if raw.startswith("```"):
  502. raw = re.sub(r"^```(?:json)?", "", raw).strip()
  503. raw = re.sub(r"```$", "", raw).strip()
  504. try:
  505. parsed = json.loads(raw)
  506. except json.JSONDecodeError:
  507. match = re.search(r"\{.*\}", raw, flags=re.S)
  508. if not match:
  509. raise
  510. parsed = json.loads(match.group(0))
  511. if not isinstance(parsed, dict):
  512. raise ValueError("模型返回不是JSON object")
  513. return parsed
  514. def _load_pattern_selector_policy(path: Path = DEFAULT_PATTERN_SELECTOR_PROMPT_PATH) -> str:
  515. return path.read_text(encoding="utf-8").strip()
  516. def _model_rerank_pattern_selections(
  517. *,
  518. feature_texts: Sequence[str],
  519. placement: str,
  520. candidates: Sequence[PatternSelection],
  521. top_k: int,
  522. model: str | None = None,
  523. ) -> list[PatternSelection]:
  524. """Use an LLM node to choose from pre-existing candidate patterns.
  525. The model can only rerank/select candidates produced from DB. It cannot
  526. invent pattern keys or bypass compliance/risk context.
  527. """
  528. if not candidates:
  529. return []
  530. model_name = model or OPENROUTER_TEXT_MODEL
  531. candidate_payload = [
  532. {
  533. "pattern_key": item.pattern.pattern_key,
  534. "pattern_name": item.pattern.pattern_name,
  535. "hook_category": item.pattern.hook_category,
  536. "visual_template": item.pattern.visual_template,
  537. "title_hook_rule": item.pattern.title_hook_rule,
  538. "visual_rule": item.pattern.visual_rule,
  539. "relevance_rule": item.pattern.relevance_rule,
  540. "compliance_rule": item.pattern.compliance_rule,
  541. "fallback_score": item.score,
  542. "fallback_reasons": item.reasons,
  543. "fallback_penalties": item.penalties,
  544. "positive_examples": item.pattern.positive_examples or [],
  545. "negative_examples": item.pattern.negative_examples or [],
  546. }
  547. for item in candidates
  548. ]
  549. prompt_payload = {
  550. "selection_policy": _load_pattern_selector_policy(),
  551. "video_features": list(feature_texts),
  552. "placement": placement,
  553. "top_k": top_k,
  554. "candidate_patterns": candidate_payload,
  555. "output_schema": {
  556. "selected": [
  557. {
  558. "pattern_key": "候选pattern_key",
  559. "score": "0-100整数,表示模型选择置信度",
  560. "reason": "为什么这个pattern最匹配视频",
  561. "matched_features": ["命中的视频特征文本"],
  562. "risk_notes": ["需要注意的合规风险,没有则空数组"],
  563. }
  564. ],
  565. "no_match_example": {"selected": []},
  566. },
  567. }
  568. resp = httpx.post(
  569. OPENROUTER_CHAT_COMPLETIONS_URL,
  570. headers={
  571. "Authorization": f"Bearer {_openrouter_api_key()}",
  572. "Content-Type": "application/json",
  573. },
  574. json={
  575. "model": model_name,
  576. "messages": [
  577. {
  578. "role": "system",
  579. "content": "你是广告创意策略选择器,只做结构化JSON输出。",
  580. },
  581. {
  582. "role": "user",
  583. "content": json.dumps(prompt_payload, ensure_ascii=False),
  584. },
  585. ],
  586. "temperature": 0.2,
  587. },
  588. timeout=45,
  589. )
  590. resp.raise_for_status()
  591. data = resp.json()
  592. content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "")
  593. parsed = _extract_json_object(content)
  594. selected = parsed.get("selected") or []
  595. by_key = {item.pattern.pattern_key: item for item in candidates}
  596. out: list[PatternSelection] = []
  597. seen: set[str] = set()
  598. for raw in selected:
  599. if not isinstance(raw, dict):
  600. continue
  601. key = str(raw.get("pattern_key") or "")
  602. if not key or key in seen or key not in by_key:
  603. continue
  604. seen.add(key)
  605. base = by_key[key]
  606. try:
  607. model_score = float(raw.get("score"))
  608. except (TypeError, ValueError):
  609. model_score = base.score
  610. if model_score < PATTERN_SELECTOR_MIN_MODEL_SCORE:
  611. continue
  612. reason = str(raw.get("reason") or "").strip()
  613. risk_notes = [str(v) for v in raw.get("risk_notes") or [] if str(v)]
  614. matched = [str(v) for v in raw.get("matched_features") or [] if str(v)]
  615. out.append(PatternSelection(
  616. pattern=base.pattern,
  617. score=round(model_score, 2),
  618. reasons=[*base.reasons, "model_selected", *(["model_reason:" + reason] if reason else [])],
  619. penalties=[*base.penalties, *["model_risk:" + note for note in risk_notes]],
  620. matched_features=matched or base.matched_features,
  621. ))
  622. if len(out) >= top_k:
  623. break
  624. return out
  625. def select_creative_patterns(
  626. *,
  627. video_features: Sequence[Any],
  628. crowd_package: str = "",
  629. placement: str = "",
  630. include_draft: bool = False,
  631. top_k: int = 3,
  632. use_model: bool = True,
  633. model: str | None = None,
  634. ) -> list[PatternSelection]:
  635. """Select creative patterns for one video.
  636. Production shape:
  637. 1. Load patterns from DB.
  638. 2. Send all available candidates to the model for semantic selection.
  639. 3. If the model fails, use a non-semantic stable fallback.
  640. crowd_package is intentionally ignored: material style selection is shared
  641. across audience packages.
  642. """
  643. patterns = load_creative_patterns(include_draft=include_draft)
  644. feature_texts = _feature_texts(video_features)
  645. placement = str(placement or "")
  646. selections = [
  647. _fallback_score_pattern(
  648. pattern,
  649. feature_texts=feature_texts,
  650. placement=placement,
  651. )
  652. for pattern in patterns
  653. ]
  654. selections.sort(key=lambda item: item.score, reverse=True)
  655. if use_model:
  656. try:
  657. return _model_rerank_pattern_selections(
  658. feature_texts=feature_texts,
  659. placement=placement,
  660. candidates=selections,
  661. top_k=max(1, int(top_k)),
  662. model=model,
  663. )
  664. except Exception as e:
  665. logger.warning("[pattern_selector] model selection failed, fallback to stable score: %s", e)
  666. return selections[:max(1, int(top_k))]
  667. def _read_csv(path: Path) -> list[dict[str, str]]:
  668. with path.open("r", encoding="utf-8-sig", newline="") as f:
  669. return list(csv.DictReader(f))
  670. def _read_json(path: Path) -> Any:
  671. return json.loads(path.read_text(encoding="utf-8"))
  672. def _json_dumps(data: Any) -> str:
  673. return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
  674. def _as_int(value: Any) -> int | None:
  675. text = str(value or "").strip()
  676. if not text:
  677. return None
  678. try:
  679. return int(Decimal(text))
  680. except (InvalidOperation, ValueError):
  681. return None
  682. def _as_required_int(value: Any, default: int = 0) -> int:
  683. parsed = _as_int(value)
  684. return default if parsed is None else parsed
  685. def _as_decimal(value: Any, places: str = "0.000001") -> Decimal | None:
  686. text = str(value or "").strip()
  687. if not text:
  688. return None
  689. try:
  690. return Decimal(text).quantize(Decimal(places), rounding=ROUND_HALF_UP)
  691. except (InvalidOperation, ValueError):
  692. return None
  693. def _yuan_to_fen(value: Any) -> int:
  694. text = str(value or "").strip()
  695. if not text:
  696. return 0
  697. try:
  698. yuan = Decimal(text)
  699. except (InvalidOperation, ValueError):
  700. return 0
  701. return int((yuan * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
  702. def _bool_int(value: Any) -> int:
  703. return 1 if str(value or "").strip().lower() in {"1", "true", "yes", "y", "是"} else 0
  704. def _risk_flags(row: dict[str, str]) -> set[str]:
  705. raw = row.get("risk_flags") or ""
  706. return {part.strip() for part in raw.split(",") if part.strip()}
  707. def _file_or_url_hash(image_path: str, image_url: str, base_dir: Path) -> str:
  708. path = Path(image_path) if image_path else Path()
  709. if image_path and not path.is_absolute():
  710. path = base_dir / path
  711. if image_path and path.exists():
  712. return hashlib.sha256(path.read_bytes()).hexdigest()
  713. return hashlib.sha256(str(image_url or "").encode("utf-8")).hexdigest()
  714. def _annotation_hash_map(annotation_rows: Iterable[dict[str, str]], base_dir: Path) -> dict[int, str]:
  715. out: dict[int, str] = {}
  716. for row in annotation_rows:
  717. creative_id = _as_int(row.get("creative_id"))
  718. if creative_id is None:
  719. continue
  720. out[creative_id] = _file_or_url_hash(
  721. row.get("image_path") or "",
  722. row.get("image_url") or "",
  723. base_dir,
  724. )
  725. return out
  726. def import_current_material_analysis(
  727. *,
  728. run_id: str,
  729. window_start: date,
  730. window_end: date,
  731. performance_csv: Path,
  732. performance_summary_json: Path,
  733. visual_annotations_csv: Path,
  734. visual_summary_json: Path,
  735. report_path: Path,
  736. sql_file: str,
  737. top_n: int = 5000,
  738. annotation_version: str = "top100_rule_v1",
  739. annotator: str = "rule_contact_sheet_review",
  740. report_version: str = "top100_visual_v1",
  741. ) -> ImportResult:
  742. """Import the existing local high-consumption material analysis into DB."""
  743. ensure_strategy_learning_tables()
  744. performance_rows = _read_csv(performance_csv)
  745. performance_summary = _read_json(performance_summary_json)
  746. annotation_rows = _read_csv(visual_annotations_csv)
  747. visual_summary = _read_json(visual_summary_json)
  748. image_hash_by_creative = _annotation_hash_map(annotation_rows, Path.cwd())
  749. from db.connection import get_connection
  750. conn = get_connection()
  751. try:
  752. with conn.cursor() as cur:
  753. cur.execute(
  754. """
  755. INSERT INTO material_performance_snapshot_run (
  756. run_id, window_start, window_end, top_n, source, sql_file,
  757. row_count, total_cost_fen, status, error_message
  758. ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
  759. ON DUPLICATE KEY UPDATE
  760. window_start=VALUES(window_start),
  761. window_end=VALUES(window_end),
  762. top_n=VALUES(top_n),
  763. source=VALUES(source),
  764. sql_file=VALUES(sql_file),
  765. row_count=VALUES(row_count),
  766. total_cost_fen=VALUES(total_cost_fen),
  767. status=VALUES(status),
  768. error_message=VALUES(error_message),
  769. updated_at=CURRENT_TIMESTAMP
  770. """,
  771. (
  772. run_id,
  773. window_start,
  774. window_end,
  775. top_n,
  776. "odps",
  777. sql_file,
  778. int(performance_summary.get("rows") or len(performance_rows)),
  779. _yuan_to_fen(performance_summary.get("total_cost_yuan")),
  780. "SUCCESS",
  781. None,
  782. ),
  783. )
  784. snapshot_values = []
  785. for idx, row in enumerate(performance_rows, start=1):
  786. creative_id = _as_required_int(row.get("creative_id"))
  787. rank_no = _as_int(row.get("rank")) or idx
  788. snapshot_values.append(
  789. (
  790. run_id,
  791. rank_no,
  792. _as_required_int(row.get("account_id")),
  793. _as_required_int(row.get("ad_id")),
  794. creative_id,
  795. row.get("creative_name") or None,
  796. row.get("ad_name") or None,
  797. _as_int(row.get("video_id")),
  798. row.get("title") or None,
  799. row.get("image_url") or None,
  800. image_hash_by_creative.get(creative_id),
  801. row.get("package_name") or None,
  802. row.get("optimization_goal") or None,
  803. _as_int(row.get("bid_amount")),
  804. _as_int(row.get("day_amount")),
  805. _yuan_to_fen(row.get("cost_yuan")),
  806. _as_required_int(row.get("view_count")),
  807. _as_required_int(row.get("valid_click_count")),
  808. _as_decimal(row.get("ctr")),
  809. _as_required_int(row.get("key_page_view_count")),
  810. _as_decimal(row.get("key_page_rate")),
  811. _as_required_int(row.get("conversions_count")),
  812. _as_decimal(row.get("conversion_rate")),
  813. _as_required_int(row.get("active_days")),
  814. row.get("first_dt") or None,
  815. row.get("last_dt") or None,
  816. _json_dumps(row),
  817. )
  818. )
  819. cur.executemany(
  820. """
  821. INSERT INTO material_performance_snapshot_item (
  822. run_id, rank_no, account_id, ad_id, creative_id, creative_name, ad_name,
  823. video_id, title, image_url, image_hash, crowd_package, optimization_goal,
  824. bid_amount_fen, day_amount_fen, cost_fen, impressions, clicks, ctr,
  825. key_page_view_count, key_page_rate, conversions_count, conversion_rate,
  826. active_days, first_dt, last_dt, raw_json
  827. ) VALUES (
  828. %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
  829. )
  830. ON DUPLICATE KEY UPDATE
  831. rank_no=VALUES(rank_no),
  832. account_id=VALUES(account_id),
  833. ad_id=VALUES(ad_id),
  834. creative_name=VALUES(creative_name),
  835. ad_name=VALUES(ad_name),
  836. video_id=VALUES(video_id),
  837. title=VALUES(title),
  838. image_url=VALUES(image_url),
  839. image_hash=VALUES(image_hash),
  840. crowd_package=VALUES(crowd_package),
  841. optimization_goal=VALUES(optimization_goal),
  842. bid_amount_fen=VALUES(bid_amount_fen),
  843. day_amount_fen=VALUES(day_amount_fen),
  844. cost_fen=VALUES(cost_fen),
  845. impressions=VALUES(impressions),
  846. clicks=VALUES(clicks),
  847. ctr=VALUES(ctr),
  848. key_page_view_count=VALUES(key_page_view_count),
  849. key_page_rate=VALUES(key_page_rate),
  850. conversions_count=VALUES(conversions_count),
  851. conversion_rate=VALUES(conversion_rate),
  852. active_days=VALUES(active_days),
  853. first_dt=VALUES(first_dt),
  854. last_dt=VALUES(last_dt),
  855. raw_json=VALUES(raw_json),
  856. updated_at=CURRENT_TIMESTAMP
  857. """,
  858. snapshot_values,
  859. )
  860. annotation_values = []
  861. for row in annotation_rows:
  862. flags = _risk_flags(row)
  863. image_hash = _file_or_url_hash(
  864. row.get("image_path") or "",
  865. row.get("image_url") or "",
  866. Path.cwd(),
  867. )
  868. annotation_values.append(
  869. (
  870. image_hash,
  871. row.get("image_url") or "",
  872. annotation_version,
  873. annotator,
  874. _as_int(row.get("creative_id")),
  875. row.get("visual_template") or None,
  876. row.get("hook_categories") or None,
  877. row.get("title") or None,
  878. len(row.get("title") or ""),
  879. row.get("scene_type") or None,
  880. row.get("main_subject_est") or None,
  881. _bool_int(row.get("has_human_est")),
  882. row.get("text_area_ratio_est") or None,
  883. row.get("dominant_tone") or None,
  884. _bool_int(row.get("button_like_element_est")),
  885. 1 if "fake_ui" in flags or "fake_button" in flags else 0,
  886. 1 if "policy_money_claim_risk" in flags else 0,
  887. 1 if "medical_health_risk" in flags else 0,
  888. 1 if "politics_country_sensitive" in flags else 0,
  889. 1 if "celebrity_or_history_person" in flags else 0,
  890. 1 if "strong_inducement" in flags or "button_like_inducement" in flags else 0,
  891. 1 if "greeting_blessing_filtered" in flags else 0,
  892. row.get("risk_level_for_generation") or "caution",
  893. row.get("learnable_points") or None,
  894. row.get("avoid_points") or None,
  895. _json_dumps(row),
  896. )
  897. )
  898. cur.executemany(
  899. """
  900. INSERT INTO material_visual_annotation (
  901. image_hash, image_url, annotation_version, annotator, creative_id,
  902. visual_template, hook_category, title_text, title_length, scene_type,
  903. person_type, has_human, text_area_level, color_style,
  904. button_like_element, fake_ui_risk, official_policy_risk,
  905. medical_health_risk, politics_sensitive_risk, celebrity_or_history_risk,
  906. strong_inducement_risk, greeting_blessing_risk, compliance_level,
  907. learnable_points, avoid_points, raw_annotation
  908. ) VALUES (
  909. %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
  910. )
  911. ON DUPLICATE KEY UPDATE
  912. image_url=VALUES(image_url),
  913. annotator=VALUES(annotator),
  914. creative_id=VALUES(creative_id),
  915. visual_template=VALUES(visual_template),
  916. hook_category=VALUES(hook_category),
  917. title_text=VALUES(title_text),
  918. title_length=VALUES(title_length),
  919. scene_type=VALUES(scene_type),
  920. person_type=VALUES(person_type),
  921. has_human=VALUES(has_human),
  922. text_area_level=VALUES(text_area_level),
  923. color_style=VALUES(color_style),
  924. button_like_element=VALUES(button_like_element),
  925. fake_ui_risk=VALUES(fake_ui_risk),
  926. official_policy_risk=VALUES(official_policy_risk),
  927. medical_health_risk=VALUES(medical_health_risk),
  928. politics_sensitive_risk=VALUES(politics_sensitive_risk),
  929. celebrity_or_history_risk=VALUES(celebrity_or_history_risk),
  930. strong_inducement_risk=VALUES(strong_inducement_risk),
  931. greeting_blessing_risk=VALUES(greeting_blessing_risk),
  932. compliance_level=VALUES(compliance_level),
  933. learnable_points=VALUES(learnable_points),
  934. avoid_points=VALUES(avoid_points),
  935. raw_annotation=VALUES(raw_annotation),
  936. updated_at=CURRENT_TIMESTAMP
  937. """,
  938. annotation_values,
  939. )
  940. report_id = f"{run_id}_{report_version}"
  941. summary_text = report_path.read_text(encoding="utf-8") if report_path.exists() else ""
  942. cur.execute(
  943. """
  944. INSERT INTO material_strategy_learning_report (
  945. report_id, run_id, report_version, summary, top_patterns,
  946. risk_summary, recommended_actions, report_path
  947. ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
  948. ON DUPLICATE KEY UPDATE
  949. run_id=VALUES(run_id),
  950. report_version=VALUES(report_version),
  951. summary=VALUES(summary),
  952. top_patterns=VALUES(top_patterns),
  953. risk_summary=VALUES(risk_summary),
  954. recommended_actions=VALUES(recommended_actions),
  955. report_path=VALUES(report_path),
  956. updated_at=CURRENT_TIMESTAMP
  957. """,
  958. (
  959. report_id,
  960. run_id,
  961. report_version,
  962. summary_text,
  963. _json_dumps(visual_summary.get("visual_template_counts") or {}),
  964. _json_dumps(
  965. {
  966. "risk_level_counts": visual_summary.get("risk_level_counts") or {},
  967. "risk_flag_counts": visual_summary.get("risk_flag_counts") or [],
  968. }
  969. ),
  970. _json_dumps(
  971. [
  972. "学习大字信息差结构,不要复制历史标题",
  973. "避免伪按钮、假界面、强诱导、涉政和医疗恐吓",
  974. "生成素材仍以承接视频ODPS内容特征为主",
  975. ]
  976. ),
  977. str(report_path),
  978. ),
  979. )
  980. conn.commit()
  981. finally:
  982. conn.close()
  983. return ImportResult(
  984. run_id=run_id,
  985. snapshot_rows=len(performance_rows),
  986. annotation_rows=len(annotation_rows),
  987. report_rows=1,
  988. )