content_feedback.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import uuid
  5. from datetime import datetime
  6. from decimal import Decimal
  7. from typing import Any
  8. from sqlalchemy import select
  9. from supply_infra.db.models.business_harness import (
  10. ContentDemandCoverage,
  11. ContentPerformanceFact,
  12. DailyDemandPackage,
  13. DailyDemandTask,
  14. DemandAttributionSnapshot,
  15. )
  16. from supply_infra.db.models.video_discovery import (
  17. VideoDiscoveryCandidate,
  18. VideoDiscoveryRun,
  19. )
  20. from supply_infra.db.session import get_session
  21. from supply_infra.pipeline.contracts import StepContext
  22. from supply_infra.pipeline.dates import china_now
  23. _NAMESPACE = uuid.UUID("53a14b2c-8ec3-5b88-a684-aa09737b6570")
  24. def _uuid(key: str) -> str:
  25. return str(uuid.uuid5(_NAMESPACE, key))
  26. def _hash(payload: Any) -> str:
  27. canonical = json.dumps(
  28. payload,
  29. ensure_ascii=False,
  30. sort_keys=True,
  31. separators=(",", ":"),
  32. default=str,
  33. )
  34. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  35. def _clamp(value: float) -> float:
  36. return max(0.0, min(1.0, value))
  37. def _attribution_values(
  38. coverage: ContentDemandCoverage,
  39. fact: ContentPerformanceFact,
  40. ) -> dict[str, Any]:
  41. coverage_score = float(coverage.coverage_score)
  42. quality = float(fact.content_quality) if fact.content_quality is not None else 0.5
  43. sample_factor = _clamp(float(fact.sample_size or 0) / 1000.0)
  44. confidence = _clamp(coverage_score * quality * sample_factor)
  45. if fact.rov is None and fact.vov is None:
  46. return {
  47. "support_state": "inconclusive",
  48. "attributable_rov": None,
  49. "attributable_vov": None,
  50. "confidence": confidence,
  51. "validity_influence": 0.0,
  52. "priority_influence": 0.0,
  53. "reason": "内容承接关系已建立,但缺少 ROV/VOV,暂不影响次日评分",
  54. }
  55. attributable_rov = (
  56. float(fact.rov) * coverage_score if fact.rov is not None else None
  57. )
  58. attributable_vov = (
  59. float(fact.vov) * coverage_score if fact.vov is not None else None
  60. )
  61. signal = attributable_rov if attributable_rov is not None else 0.0
  62. support_state = (
  63. "support"
  64. if signal > 0.05
  65. else "oppose"
  66. if signal < -0.05
  67. else "inconclusive"
  68. )
  69. validity_influence = max(-0.1, min(0.1, signal * confidence * 0.1))
  70. priority_influence = max(
  71. -0.1,
  72. min(0.1, (attributable_vov or signal) * confidence * 0.1),
  73. )
  74. return {
  75. "support_state": support_state,
  76. "attributable_rov": attributable_rov,
  77. "attributable_vov": attributable_vov,
  78. "confidence": confidence,
  79. "validity_influence": validity_influence,
  80. "priority_influence": priority_influence,
  81. "reason": (
  82. f"覆盖度={coverage_score:.3f}、内容质量={quality:.3f}、"
  83. f"样本因子={sample_factor:.3f},归因状态={support_state}"
  84. ),
  85. }
  86. def _ensure_attribution(
  87. session,
  88. coverage: ContentDemandCoverage,
  89. fact: ContentPerformanceFact,
  90. strategy_version_id: str,
  91. ) -> DemandAttributionSnapshot:
  92. attribution_id = _uuid(
  93. f"demand-attribution:{coverage.coverage_id}:"
  94. f"{fact.performance_fact_id}:{strategy_version_id}"
  95. )
  96. existing = session.get(DemandAttributionSnapshot, attribution_id)
  97. if existing is not None:
  98. return existing
  99. values = _attribution_values(coverage, fact)
  100. row = DemandAttributionSnapshot(
  101. attribution_id=attribution_id,
  102. platform_demand_version_id=coverage.platform_demand_version_id,
  103. coverage_id=coverage.coverage_id,
  104. performance_fact_id=fact.performance_fact_id,
  105. strategy_version_id=strategy_version_id,
  106. support_state=values["support_state"],
  107. attributable_rov=(
  108. Decimal(str(values["attributable_rov"]))
  109. if values["attributable_rov"] is not None
  110. else None
  111. ),
  112. attributable_vov=(
  113. Decimal(str(values["attributable_vov"]))
  114. if values["attributable_vov"] is not None
  115. else None
  116. ),
  117. attribution_confidence=Decimal(str(round(values["confidence"], 5))),
  118. validity_influence=Decimal(
  119. str(round(values["validity_influence"], 7))
  120. ),
  121. priority_influence=Decimal(
  122. str(round(values["priority_influence"], 7))
  123. ),
  124. reason=values["reason"],
  125. )
  126. session.add(row)
  127. return row
  128. def ingest_content_performance_fact(
  129. *,
  130. content_id: str,
  131. biz_dt: str,
  132. source: str,
  133. raw_payload: dict[str, Any],
  134. observed_at: datetime,
  135. exposure_count: int | None = None,
  136. sample_size: int | None = None,
  137. content_quality: float | None = None,
  138. rov: float | None = None,
  139. vov: float | None = None,
  140. ) -> dict[str, Any]:
  141. """Append one immutable observation and attribute it to known demand coverage."""
  142. if not content_id.strip() or not source.strip():
  143. raise ValueError("content_id and source are required")
  144. if len(biz_dt) != 8 or not biz_dt.isdigit():
  145. raise ValueError("biz_dt must be YYYYMMDD")
  146. if sample_size is not None and sample_size < 0:
  147. raise ValueError("sample_size must be non-negative")
  148. if exposure_count is not None and exposure_count < 0:
  149. raise ValueError("exposure_count must be non-negative")
  150. if content_quality is not None and not 0 <= content_quality <= 1:
  151. raise ValueError("content_quality must be between 0 and 1")
  152. canonical_payload = {
  153. "raw_payload": raw_payload,
  154. "exposure_count": exposure_count,
  155. "sample_size": sample_size,
  156. "content_quality": content_quality,
  157. "rov": rov,
  158. "vov": vov,
  159. "observed_at": observed_at.isoformat(),
  160. }
  161. payload_hash = _hash(canonical_payload)
  162. fact_id = _uuid(
  163. f"content-performance:{content_id}:{biz_dt}:{source}:{payload_hash}"
  164. )
  165. with get_session() as session:
  166. fact = session.get(ContentPerformanceFact, fact_id)
  167. idempotent_replay = fact is not None
  168. if fact is None:
  169. fact = ContentPerformanceFact(
  170. performance_fact_id=fact_id,
  171. content_id=content_id.strip(),
  172. biz_dt=biz_dt,
  173. source=source.strip()[:64],
  174. payload_hash=payload_hash,
  175. exposure_count=exposure_count,
  176. sample_size=sample_size,
  177. content_quality=(
  178. Decimal(str(content_quality))
  179. if content_quality is not None
  180. else None
  181. ),
  182. rov=Decimal(str(rov)) if rov is not None else None,
  183. vov=Decimal(str(vov)) if vov is not None else None,
  184. raw_payload_json=raw_payload,
  185. observed_at=observed_at,
  186. )
  187. session.add(fact)
  188. session.flush()
  189. rows = session.execute(
  190. select(ContentDemandCoverage, DailyDemandPackage.strategy_version_id)
  191. .join(
  192. DailyDemandTask,
  193. DailyDemandTask.task_id == ContentDemandCoverage.task_id,
  194. )
  195. .join(
  196. DailyDemandPackage,
  197. DailyDemandPackage.demand_package_id
  198. == DailyDemandTask.demand_package_id,
  199. )
  200. .where(ContentDemandCoverage.content_id == content_id.strip())
  201. ).all()
  202. attribution_ids: list[str] = []
  203. for coverage, strategy_version_id in rows:
  204. attribution = _ensure_attribution(
  205. session,
  206. coverage,
  207. fact,
  208. str(strategy_version_id),
  209. )
  210. attribution_ids.append(attribution.attribution_id)
  211. session.flush()
  212. return {
  213. "success": True,
  214. "performance_fact_id": fact.performance_fact_id,
  215. "content_id": fact.content_id,
  216. "attribution_ids": attribution_ids,
  217. "attribution_count": len(attribution_ids),
  218. "idempotent_replay": idempotent_replay,
  219. }
  220. def materialize_content_feedback(context: StepContext) -> dict[str, Any]:
  221. """Persist Find Agent coverage, raw performance facts and attribution."""
  222. with get_session() as session:
  223. package = session.scalar(
  224. select(DailyDemandPackage).where(
  225. DailyDemandPackage.run_id == context.run_id,
  226. DailyDemandPackage.status == "published",
  227. )
  228. )
  229. if package is None:
  230. return {
  231. "success": False,
  232. "error_code": "daily_demand_package_missing",
  233. "error": "找片结果无法关联到已发布任务包",
  234. }
  235. discovery_runs = list(
  236. session.scalars(
  237. select(VideoDiscoveryRun).where(
  238. VideoDiscoveryRun.demand_package_id
  239. == package.demand_package_id
  240. )
  241. ).all()
  242. )
  243. run_by_id = {str(row.run_id): row for row in discovery_runs}
  244. candidates = (
  245. list(
  246. session.scalars(
  247. select(VideoDiscoveryCandidate).where(
  248. VideoDiscoveryCandidate.run_id.in_(run_by_id),
  249. VideoDiscoveryCandidate.decision_bucket == "primary",
  250. )
  251. ).all()
  252. )
  253. if run_by_id
  254. else []
  255. )
  256. coverage_created = 0
  257. facts_created = 0
  258. attributions_created = 0
  259. for candidate in candidates:
  260. discovery_run = run_by_id[str(candidate.run_id)]
  261. if (
  262. not discovery_run.daily_demand_task_id
  263. or not discovery_run.platform_demand_version_id
  264. ):
  265. raise ValueError(
  266. f"video discovery run {discovery_run.run_id} 缺少统一任务引用"
  267. )
  268. task = session.get(
  269. DailyDemandTask,
  270. discovery_run.daily_demand_task_id,
  271. )
  272. if task is None:
  273. raise ValueError(
  274. f"daily demand task not found: {discovery_run.daily_demand_task_id}"
  275. )
  276. coverage_id = _uuid(
  277. "content-coverage:"
  278. f"{discovery_run.platform_demand_version_id}:"
  279. f"{candidate.aweme_id}:primary"
  280. )
  281. coverage = session.get(ContentDemandCoverage, coverage_id)
  282. if coverage is None:
  283. relevance = (
  284. float(candidate.relevance_score)
  285. if candidate.relevance_score is not None
  286. else 0.5
  287. )
  288. coverage = ContentDemandCoverage(
  289. coverage_id=coverage_id,
  290. platform_demand_version_id=(
  291. discovery_run.platform_demand_version_id
  292. ),
  293. task_id=task.task_id,
  294. content_id=str(candidate.aweme_id),
  295. content_url=candidate.content_link,
  296. source_type="find_agent",
  297. coverage_role="primary",
  298. coverage_score=Decimal(str(round(_clamp(relevance), 5))),
  299. evidence_json={
  300. "decision_reason": candidate.decision_reason,
  301. "source_keywords_json": candidate.source_keywords_json,
  302. "gate_results_json": candidate.gate_results_json,
  303. "video_discovery_run_id": discovery_run.run_id,
  304. },
  305. status="discovered",
  306. discovered_at=candidate.create_time,
  307. content_published_at=candidate.publish_at,
  308. )
  309. session.add(coverage)
  310. session.flush()
  311. coverage_created += 1
  312. raw_fact = {
  313. "play_count": candidate.play_count,
  314. "like_count": candidate.like_count,
  315. "comment_count": candidate.comment_count,
  316. "collect_count": candidate.collect_count,
  317. "share_count": candidate.share_count,
  318. "gate_status": candidate.gate_status,
  319. "value_score": (
  320. float(candidate.value_score)
  321. if candidate.value_score is not None
  322. else None
  323. ),
  324. }
  325. payload_hash = _hash(raw_fact)
  326. fact_id = _uuid(
  327. f"content-performance:{candidate.aweme_id}:"
  328. f"{context.biz_dt}:find_agent:{payload_hash}"
  329. )
  330. fact = session.get(ContentPerformanceFact, fact_id)
  331. if fact is None:
  332. quality = 1.0 if candidate.gate_status == "pass" else 0.5
  333. fact = ContentPerformanceFact(
  334. performance_fact_id=fact_id,
  335. content_id=str(candidate.aweme_id),
  336. biz_dt=context.biz_dt,
  337. source="find_agent_snapshot",
  338. payload_hash=payload_hash,
  339. exposure_count=candidate.play_count,
  340. sample_size=candidate.play_count,
  341. content_quality=Decimal(str(quality)),
  342. rov=None,
  343. vov=None,
  344. raw_payload_json=raw_fact,
  345. observed_at=candidate.update_time or china_now(),
  346. )
  347. session.add(fact)
  348. session.flush()
  349. facts_created += 1
  350. before = session.get(
  351. DemandAttributionSnapshot,
  352. _uuid(
  353. f"demand-attribution:{coverage.coverage_id}:"
  354. f"{fact.performance_fact_id}:{package.strategy_version_id}"
  355. ),
  356. )
  357. _ensure_attribution(
  358. session,
  359. coverage,
  360. fact,
  361. package.strategy_version_id,
  362. )
  363. if before is None:
  364. attributions_created += 1
  365. session.flush()
  366. return {
  367. "success": True,
  368. "biz_dt": context.biz_dt,
  369. "demand_package_id": package.demand_package_id,
  370. "candidate_count": len(candidates),
  371. "coverage_created": coverage_created,
  372. "performance_facts_created": facts_created,
  373. "attributions_created": attributions_created,
  374. }