evaluate.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  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 func, select
  9. from supply_infra.db.models.business_harness import (
  10. DailyDemandPackage,
  11. DailyDemandTask,
  12. DemandAttributionSnapshot,
  13. DemandEvaluationSnapshot,
  14. DemandScoreContribution,
  15. EvidencePackage,
  16. PlatformDemandCategoryRel,
  17. PlatformDemandVersion,
  18. RawDemandExpression,
  19. StandardDemandAlias,
  20. StrategyVersion,
  21. )
  22. from supply_infra.db.models.demand_grade import DemandGrade
  23. from supply_infra.db.session import get_session
  24. from supply_infra.pipeline.contracts import StepContext
  25. from supply_infra.pipeline.dates import china_now
  26. _NAMESPACE = uuid.UUID("53a14b2c-8ec3-5b88-a684-aa09737b6570")
  27. _STRATEGY_KEY = "harness-v1"
  28. _STRATEGY_DEFINITION: dict[str, Any] = {
  29. "validity_weights": {
  30. "legacy_grade_score": 0.35,
  31. "prior_heat": 0.20,
  32. "source_coverage": 0.15,
  33. "posterior_rov": 0.15,
  34. "attributed_feedback": 0.15,
  35. },
  36. "local_priority_weights": {
  37. "validity": 0.30,
  38. "prior_heat": 0.20,
  39. "grade_band": 0.15,
  40. "supply_gap": 0.20,
  41. "attributed_feedback": 0.15,
  42. },
  43. "action_thresholds": {
  44. "assure_supply": {"validity": 0.75, "priority": 0.55},
  45. "priority": {"validity": 0.60, "priority": 0.70},
  46. "targeted_validation": {"validity": 0.45, "confidence_below": 0.75},
  47. "exploration": {"validity": 0.25},
  48. },
  49. "posterior_min_samples": 1,
  50. "exploration_allocation": 1,
  51. "schema_version": 1,
  52. }
  53. def _uuid(key: str) -> str:
  54. return str(uuid.uuid5(_NAMESPACE, key))
  55. def _hash(payload: Any) -> str:
  56. canonical = json.dumps(
  57. payload,
  58. ensure_ascii=False,
  59. sort_keys=True,
  60. separators=(",", ":"),
  61. default=str,
  62. )
  63. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  64. def _clamp(value: float) -> float:
  65. return max(0.0, min(1.0, value))
  66. def _json_list(raw: str | None) -> list[Any]:
  67. if not raw:
  68. return []
  69. try:
  70. value = json.loads(raw)
  71. except (TypeError, json.JSONDecodeError):
  72. return []
  73. return value if isinstance(value, list) else []
  74. def _weighted_score(
  75. dimensions: list[dict[str, Any]],
  76. ) -> tuple[Decimal, list[dict[str, Any]]]:
  77. available = [item for item in dimensions if item["normalized"] is not None]
  78. weight_sum = sum(float(item["weight"]) for item in available)
  79. if weight_sum <= 0:
  80. return Decimal("0.00000"), []
  81. contributions: list[dict[str, Any]] = []
  82. total = 0.0
  83. for item in available:
  84. effective_weight = float(item["weight"]) / weight_sum
  85. contribution = _clamp(float(item["normalized"])) * effective_weight
  86. total += contribution
  87. contributions.append(
  88. {
  89. **item,
  90. "effective_weight": effective_weight,
  91. "contribution": contribution,
  92. }
  93. )
  94. return Decimal(str(round(_clamp(total), 5))), contributions
  95. def _grade_band(grade: str) -> float:
  96. return {
  97. "S": 1.0,
  98. "A": 0.85,
  99. "B": 0.65,
  100. "C": 0.40,
  101. "D": 0.20,
  102. }.get(str(grade).upper(), 0.30)
  103. def _action_tier(
  104. definition: dict[str, Any],
  105. validity: float,
  106. priority: float,
  107. confidence: float,
  108. *,
  109. has_posterior: bool,
  110. ) -> str:
  111. thresholds = definition["action_thresholds"]
  112. assure = thresholds["assure_supply"]
  113. if (
  114. has_posterior
  115. and validity >= assure["validity"]
  116. and priority >= assure["priority"]
  117. ):
  118. return "assure_supply"
  119. priority_rule = thresholds["priority"]
  120. if validity >= priority_rule["validity"] and priority >= priority_rule["priority"]:
  121. return "priority"
  122. validate = thresholds["targeted_validation"]
  123. if (
  124. validity >= validate["validity"]
  125. and confidence < validate["confidence_below"]
  126. ):
  127. return "targeted_validation"
  128. if validity >= thresholds["exploration"]["validity"]:
  129. return "exploration"
  130. return "suppress"
  131. def _ensure_strategy(session) -> StrategyVersion:
  132. strategy = session.scalar(
  133. select(StrategyVersion)
  134. .where(StrategyVersion.status == "active")
  135. .order_by(StrategyVersion.activated_at.desc())
  136. .limit(1)
  137. )
  138. if strategy is not None:
  139. return strategy
  140. legacy = session.scalar(
  141. select(StrategyVersion).where(StrategyVersion.version_key == _STRATEGY_KEY)
  142. )
  143. if legacy is not None:
  144. legacy.status = "active"
  145. legacy.activated_at = china_now()
  146. session.flush()
  147. return legacy
  148. strategy = StrategyVersion(
  149. strategy_version_id=_uuid(f"strategy-version:{_STRATEGY_KEY}"),
  150. version_key=_STRATEGY_KEY,
  151. status="active",
  152. definition_json=_STRATEGY_DEFINITION,
  153. change_reason="平台需求双评分与五类行动层初始显式策略",
  154. created_by="system",
  155. activated_at=china_now(),
  156. )
  157. session.add(strategy)
  158. session.flush()
  159. return strategy
  160. def _raw_dimension(
  161. name: str,
  162. raw: Any,
  163. normalized: float | None,
  164. weight: float,
  165. reason: str,
  166. ) -> dict[str, Any]:
  167. return {
  168. "dimension": name,
  169. "raw": {"value": raw},
  170. "normalized": normalized,
  171. "weight": weight,
  172. "data_status": "available" if normalized is not None else "missing",
  173. "reason": reason,
  174. }
  175. def _evaluation_inputs(
  176. session,
  177. version: PlatformDemandVersion,
  178. grade: DemandGrade,
  179. definition: dict[str, Any],
  180. ) -> tuple[
  181. dict[str, Any],
  182. list[dict[str, Any]],
  183. list[dict[str, Any]],
  184. list[str],
  185. ]:
  186. strategies = [str(value) for value in _json_list(grade.strategies)]
  187. videos = [str(value) for value in _json_list(grade.video_list)]
  188. posterior_available = bool(grade.has_posterior and grade.posterior_rov_count > 0)
  189. posterior_raw = (
  190. float(grade.posterior_rov_avg)
  191. if posterior_available and grade.posterior_rov_avg is not None
  192. else None
  193. )
  194. attributed = session.execute(
  195. select(
  196. func.avg(DemandAttributionSnapshot.validity_influence),
  197. func.avg(DemandAttributionSnapshot.priority_influence),
  198. func.count(),
  199. )
  200. .join(
  201. PlatformDemandVersion,
  202. PlatformDemandVersion.platform_demand_version_id
  203. == DemandAttributionSnapshot.platform_demand_version_id,
  204. )
  205. .where(
  206. PlatformDemandVersion.platform_demand_id
  207. == version.platform_demand_id,
  208. DemandAttributionSnapshot.support_state.in_(("support", "oppose")),
  209. )
  210. ).one()
  211. attributed_validity = (
  212. float(attributed[0]) if attributed[0] is not None else None
  213. )
  214. attributed_priority = (
  215. float(attributed[1]) if attributed[1] is not None else None
  216. )
  217. validity_weights = definition["validity_weights"]
  218. validity_dimensions = [
  219. _raw_dimension(
  220. "legacy_grade_score",
  221. float(grade.score) if grade.score is not None else None,
  222. _clamp(float(grade.score) / 100.0) if grade.score is not None else None,
  223. float(validity_weights["legacy_grade_score"]),
  224. "旧分级来源内排名归一到 0~1,仅作为过渡信号",
  225. ),
  226. _raw_dimension(
  227. "prior_heat",
  228. (
  229. float(grade.prior_total_score)
  230. if grade.prior_total_score is not None
  231. else None
  232. ),
  233. (
  234. _clamp(float(grade.prior_total_score) / 4.0)
  235. if grade.prior_total_score is not None
  236. else None
  237. ),
  238. float(validity_weights["prior_heat"]),
  239. "四类先验总分按理论范围 0~4 归一",
  240. ),
  241. _raw_dimension(
  242. "source_coverage",
  243. {"strategies": strategies, "count": len(strategies)},
  244. _clamp(len(strategies) / 4.0),
  245. float(validity_weights["source_coverage"]),
  246. "来源覆盖越多,需求成立的交叉支持越充分",
  247. ),
  248. _raw_dimension(
  249. "posterior_rov",
  250. {
  251. "rov": posterior_raw,
  252. "sample_count": int(grade.posterior_rov_count or 0),
  253. },
  254. _clamp(0.5 + posterior_raw / 2.0) if posterior_raw is not None else None,
  255. float(validity_weights["posterior_rov"]),
  256. "仅在存在有效样本时使用后验;缺失不按零处理",
  257. ),
  258. _raw_dimension(
  259. "attributed_feedback",
  260. {
  261. "validity_influence": attributed_validity,
  262. "sample_count": int(attributed[2] or 0),
  263. },
  264. (
  265. _clamp(0.5 + attributed_validity * 5.0)
  266. if attributed_validity is not None
  267. else None
  268. ),
  269. float(validity_weights["attributed_feedback"]),
  270. "只消费已关联内容且完成表现归因的支持/反对反馈",
  271. ),
  272. ]
  273. validity, _ = _weighted_score(validity_dimensions)
  274. local_weights = definition["local_priority_weights"]
  275. local_dimensions = [
  276. _raw_dimension(
  277. "validity",
  278. float(validity),
  279. float(validity),
  280. float(local_weights["validity"]),
  281. "局部供给首先受需求成立度约束",
  282. ),
  283. _raw_dimension(
  284. "prior_heat",
  285. (
  286. float(grade.prior_total_score)
  287. if grade.prior_total_score is not None
  288. else None
  289. ),
  290. (
  291. _clamp(float(grade.prior_total_score) / 4.0)
  292. if grade.prior_total_score is not None
  293. else None
  294. ),
  295. float(local_weights["prior_heat"]),
  296. "当前主题内先验热度",
  297. ),
  298. _raw_dimension(
  299. "grade_band",
  300. grade.grade,
  301. _grade_band(grade.grade),
  302. float(local_weights["grade_band"]),
  303. "旧等级仅作为过渡期的相对位置快照",
  304. ),
  305. _raw_dimension(
  306. "supply_gap",
  307. {"known_content_count": len(videos)},
  308. 1.0 - _clamp(len(videos) / 10.0) if grade.video_list is not None else None,
  309. float(local_weights["supply_gap"]),
  310. "已有内容越少,局部补供价值越高;未知供给不按零处理",
  311. ),
  312. _raw_dimension(
  313. "attributed_feedback",
  314. {
  315. "priority_influence": attributed_priority,
  316. "sample_count": int(attributed[2] or 0),
  317. },
  318. (
  319. _clamp(0.5 + attributed_priority * 5.0)
  320. if attributed_priority is not None
  321. else None
  322. ),
  323. float(local_weights["attributed_feedback"]),
  324. "内容表现归因对次日局部供给优先级的影响",
  325. ),
  326. ]
  327. missing = [
  328. item["dimension"]
  329. for item in validity_dimensions + local_dimensions
  330. if item["normalized"] is None
  331. ]
  332. metrics = {
  333. "grade": grade.grade,
  334. "strategies": strategies,
  335. "videos": videos,
  336. "posterior_available": posterior_available,
  337. "posterior_rov_count": int(grade.posterior_rov_count or 0),
  338. "attributed_feedback_count": int(attributed[2] or 0),
  339. }
  340. return metrics, validity_dimensions, local_dimensions, list(dict.fromkeys(missing))
  341. def _store_contributions(
  342. session,
  343. evaluation_id: str,
  344. score_type: str,
  345. contributions: list[dict[str, Any]],
  346. ) -> None:
  347. for item in contributions:
  348. session.add(
  349. DemandScoreContribution(
  350. contribution_id=_uuid(
  351. f"score-contribution:{evaluation_id}:"
  352. f"{score_type}:{item['dimension']}"
  353. ),
  354. evaluation_id=evaluation_id,
  355. score_type=score_type,
  356. dimension=item["dimension"],
  357. raw_value_json=item["raw"],
  358. normalized_value=Decimal(str(round(item["normalized"], 7))),
  359. configured_weight=Decimal(str(round(item["weight"], 7))),
  360. effective_weight=Decimal(
  361. str(round(item["effective_weight"], 7))
  362. ),
  363. contribution=Decimal(str(round(item["contribution"], 7))),
  364. data_status=item["data_status"],
  365. reason=item["reason"],
  366. )
  367. )
  368. def publish_daily_demand_package(context: StepContext) -> dict[str, Any]:
  369. with get_session() as session:
  370. existing = session.scalar(
  371. select(DailyDemandPackage).where(
  372. DailyDemandPackage.run_id == context.run_id
  373. )
  374. )
  375. if existing is not None:
  376. return {
  377. "success": existing.status == "published",
  378. "idempotent_replay": True,
  379. "demand_package_id": existing.demand_package_id,
  380. "biz_dt": existing.biz_dt,
  381. "item_count": existing.item_count,
  382. "status": existing.status,
  383. "content_hash": existing.content_hash,
  384. }
  385. evidence_package = session.scalar(
  386. select(EvidencePackage).where(EvidencePackage.run_id == context.run_id)
  387. )
  388. if evidence_package is None or evidence_package.status != "frozen":
  389. return {
  390. "success": False,
  391. "error_code": "evidence_package_not_frozen",
  392. "error": "平台需求证据包不存在或尚未冻结",
  393. }
  394. versions = list(
  395. session.scalars(
  396. select(PlatformDemandVersion)
  397. .where(PlatformDemandVersion.run_id == context.run_id)
  398. .order_by(PlatformDemandVersion.platform_demand_version_id)
  399. ).all()
  400. )
  401. if not versions:
  402. return {
  403. "success": False,
  404. "error_code": "platform_demand_versions_missing",
  405. "error": "没有可发布的平台需求版本",
  406. }
  407. strategy = _ensure_strategy(session)
  408. previous_package_version = int(
  409. session.scalar(
  410. select(func.max(DailyDemandPackage.package_version)).where(
  411. DailyDemandPackage.biz_dt == context.biz_dt
  412. )
  413. )
  414. or 0
  415. )
  416. package_id = _uuid(f"daily-demand-package:{context.run_id}")
  417. package = DailyDemandPackage(
  418. demand_package_id=package_id,
  419. run_id=context.run_id,
  420. evidence_package_id=evidence_package.package_id,
  421. strategy_version_id=strategy.strategy_version_id,
  422. biz_dt=context.biz_dt,
  423. package_version=previous_package_version + 1,
  424. status="building",
  425. content_hash="0" * 64,
  426. item_count=0,
  427. published_at=None,
  428. )
  429. session.add(package)
  430. session.flush()
  431. task_payloads: list[dict[str, Any]] = []
  432. tier_counts: dict[str, int] = {}
  433. for version in versions:
  434. grade = session.get(DemandGrade, int(version.source_demand_grade_id))
  435. if grade is None:
  436. raise ValueError(
  437. "平台需求版本引用的 DemandGrade 不存在: "
  438. f"{version.source_demand_grade_id}"
  439. )
  440. definition = dict(strategy.definition_json)
  441. metrics, validity_dims, local_dims, missing = _evaluation_inputs(
  442. session,
  443. version,
  444. grade,
  445. definition,
  446. )
  447. validity, validity_contributions = _weighted_score(validity_dims)
  448. local_priority, local_contributions = _weighted_score(local_dims)
  449. configured_validity_weight = sum(float(item["weight"]) for item in validity_dims)
  450. available_validity_weight = sum(
  451. float(item["weight"])
  452. for item in validity_dims
  453. if item["normalized"] is not None
  454. )
  455. data_confidence = Decimal(
  456. str(
  457. round(
  458. _clamp(
  459. available_validity_weight
  460. / configured_validity_weight
  461. ),
  462. 5,
  463. )
  464. )
  465. )
  466. has_posterior = bool(metrics["posterior_available"])
  467. action_tier = _action_tier(
  468. definition,
  469. float(validity),
  470. float(local_priority),
  471. float(data_confidence),
  472. has_posterior=has_posterior,
  473. )
  474. posterior_state = "available" if has_posterior else "missing"
  475. evaluation_id = _uuid(
  476. f"demand-evaluation:{version.platform_demand_version_id}"
  477. )
  478. reason = (
  479. f"成立度={validity},局部供给优先级={local_priority},"
  480. f"数据置信度={data_confidence},行动层={action_tier};"
  481. f"缺失维度={missing or ['无']}。"
  482. )
  483. evaluation = DemandEvaluationSnapshot(
  484. evaluation_id=evaluation_id,
  485. platform_demand_version_id=version.platform_demand_version_id,
  486. run_id=context.run_id,
  487. strategy_version_id=strategy.strategy_version_id,
  488. biz_dt=context.biz_dt,
  489. validity_score=validity,
  490. local_supply_priority=local_priority,
  491. data_confidence=data_confidence,
  492. posterior_state=posterior_state,
  493. action_tier=action_tier,
  494. metrics_json=metrics,
  495. missing_dimensions_json=missing,
  496. decision_reason=reason,
  497. )
  498. session.add(evaluation)
  499. _store_contributions(
  500. session,
  501. evaluation_id,
  502. "validity",
  503. validity_contributions,
  504. )
  505. _store_contributions(
  506. session,
  507. evaluation_id,
  508. "local_supply_priority",
  509. local_contributions,
  510. )
  511. relation_rows = list(
  512. session.scalars(
  513. select(PlatformDemandCategoryRel).where(
  514. PlatformDemandCategoryRel.platform_demand_version_id
  515. == version.platform_demand_version_id
  516. )
  517. ).all()
  518. )
  519. aliases = [
  520. str(value)
  521. for value in session.scalars(
  522. select(RawDemandExpression.raw_text)
  523. .join(
  524. StandardDemandAlias,
  525. StandardDemandAlias.expression_id
  526. == RawDemandExpression.expression_id,
  527. )
  528. .where(StandardDemandAlias.term_id == version.term_id)
  529. ).all()
  530. ]
  531. search_terms = list(dict.fromkeys([version.name, *aliases]))
  532. existing_content = [str(value) for value in metrics["videos"]]
  533. hypotheses = (
  534. ["需要通过新内容验证该需求的真实 ROV/VOV"]
  535. if not has_posterior
  536. else ["验证后验表现是否能在新增内容上持续"]
  537. )
  538. task_payload = {
  539. "platform_demand_id": version.platform_demand_id,
  540. "platform_demand_version_id": version.platform_demand_version_id,
  541. "version_no": version.version_no,
  542. "biz_dt": context.biz_dt,
  543. "run_id": context.run_id,
  544. "strategy_version": strategy.version_key,
  545. "name": version.name,
  546. "description": version.description,
  547. "reason": version.reason,
  548. "category_relations": [
  549. {
  550. "category_id": int(row.category_id),
  551. "relation_type": row.relation_type,
  552. "relation_source": row.relation_source,
  553. "reason": row.reason,
  554. "confidence": float(row.confidence),
  555. "is_inferred": bool(row.is_inferred),
  556. }
  557. for row in relation_rows
  558. ],
  559. "validity_score": float(validity),
  560. "local_supply_priority": float(local_priority),
  561. "data_confidence": float(data_confidence),
  562. "posterior_state": posterior_state,
  563. "action_tier": action_tier,
  564. "search_terms": search_terms,
  565. "exclude_terms": [],
  566. "hit_rules": {
  567. "semantic_match_required": True,
  568. "must_reference_platform_demand_version": True,
  569. },
  570. "hypotheses": hypotheses,
  571. "evidence_gaps": missing,
  572. "existing_content": existing_content,
  573. "allocation": {
  574. "mode": action_tier,
  575. "exploration_quota": (
  576. definition["exploration_allocation"]
  577. if action_tier == "exploration"
  578. else 0
  579. ),
  580. },
  581. "decision_reason": reason,
  582. }
  583. session.add(
  584. DailyDemandTask(
  585. task_id=_uuid(
  586. f"daily-demand-task:{package_id}:"
  587. f"{version.platform_demand_version_id}"
  588. ),
  589. demand_package_id=package_id,
  590. platform_demand_version_id=version.platform_demand_version_id,
  591. evaluation_id=evaluation_id,
  592. action_tier=action_tier,
  593. search_terms_json=search_terms,
  594. exclude_terms_json=[],
  595. hit_rules_json=task_payload["hit_rules"],
  596. hypotheses_json=hypotheses,
  597. evidence_gaps_json=missing,
  598. existing_content_json=existing_content,
  599. allocation_json=task_payload["allocation"],
  600. task_payload_json=task_payload,
  601. )
  602. )
  603. task_payloads.append(task_payload)
  604. tier_counts[action_tier] = tier_counts.get(action_tier, 0) + 1
  605. package.item_count = len(task_payloads)
  606. package.content_hash = _hash(task_payloads)
  607. package.status = "published"
  608. package.published_at = china_now()
  609. session.flush()
  610. return {
  611. "success": True,
  612. "idempotent_replay": False,
  613. "demand_package_id": package_id,
  614. "biz_dt": context.biz_dt,
  615. "package_version": package.package_version,
  616. "strategy_version": strategy.version_key,
  617. "status": package.status,
  618. "item_count": package.item_count,
  619. "tier_counts": tier_counts,
  620. "content_hash": package.content_hash,
  621. "published_at": (
  622. package.published_at.isoformat()
  623. if isinstance(package.published_at, datetime)
  624. else None
  625. ),
  626. }