materialize.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import unicodedata
  5. import uuid
  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. EvidencePackage,
  11. PlatformDemand,
  12. PlatformDemandCategoryRel,
  13. PlatformDemandVersion,
  14. RawDemandExpression,
  15. StandardDemandAlias,
  16. StandardDemandTerm,
  17. )
  18. from supply_infra.db.models.demand_grade import DemandGrade
  19. from supply_infra.db.models.demand_grade_category_rel import DemandGradeCategoryRel
  20. from supply_infra.db.models.demand_feedback import DemandFeedback
  21. from supply_infra.db.repositories.multi_demand_pool_di_repo import (
  22. MultiDemandPoolDiRepository,
  23. )
  24. from supply_infra.db.session import get_session
  25. from supply_infra.pipeline.contracts import StepContext
  26. _NAMESPACE = uuid.UUID("53a14b2c-8ec3-5b88-a684-aa09737b6570")
  27. def _uuid(key: str) -> str:
  28. return str(uuid.uuid5(_NAMESPACE, key))
  29. def _normalized_text(value: str) -> str:
  30. return " ".join(unicodedata.normalize("NFKC", str(value)).strip().casefold().split())
  31. def _hash_payload(payload: Any) -> str:
  32. canonical = json.dumps(
  33. payload,
  34. ensure_ascii=False,
  35. sort_keys=True,
  36. separators=(",", ":"),
  37. default=str,
  38. )
  39. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  40. def _parse_int_list(raw: str | None) -> list[int]:
  41. if not raw:
  42. return []
  43. try:
  44. parsed = json.loads(raw)
  45. except (TypeError, json.JSONDecodeError):
  46. return []
  47. if not isinstance(parsed, list):
  48. return []
  49. result: list[int] = []
  50. for value in parsed:
  51. try:
  52. result.append(int(value))
  53. except (TypeError, ValueError):
  54. continue
  55. return list(dict.fromkeys(result))
  56. def _score_confidence(score: Decimal | float | None) -> Decimal:
  57. if score is None:
  58. return Decimal("0.50000")
  59. normalized = max(0.0, min(1.0, float(score) / 100.0))
  60. return Decimal(str(round(normalized, 5)))
  61. def _pool_payload(row: Any) -> dict[str, Any]:
  62. return {
  63. "id": int(row.id),
  64. "strategy": row.strategy,
  65. "demand_id": row.demand_id,
  66. "demand_name": row.demand_name,
  67. "weight": float(row.weight) if row.weight is not None else None,
  68. "type": row.type,
  69. "video_count": int(row.video_count or 0),
  70. "video_list": row.video_list,
  71. "extend": row.extend,
  72. "real_rov_7d": (
  73. float(row.real_rov_7d) if row.real_rov_7d is not None else None
  74. ),
  75. "real_vov_7d": (
  76. float(row.real_vov_7d) if row.real_vov_7d is not None else None
  77. ),
  78. "biz_dt": row.biz_dt,
  79. }
  80. def _existing_result(session, package: EvidencePackage) -> dict[str, Any]:
  81. version_count = int(
  82. session.scalar(
  83. select(func.count())
  84. .select_from(PlatformDemandVersion)
  85. .where(PlatformDemandVersion.run_id == package.run_id)
  86. )
  87. or 0
  88. )
  89. return {
  90. "success": True,
  91. "idempotent_replay": True,
  92. "package_id": package.package_id,
  93. "biz_dt": package.biz_dt,
  94. "evidence_count": package.evidence_count,
  95. "platform_demand_versions": version_count,
  96. "source_snapshot_hash": package.source_snapshot_hash,
  97. }
  98. def materialize_platform_demands(context: StepContext) -> dict[str, Any]:
  99. """
  100. Freeze raw expressions and map today's DemandGrade rows into stable,
  101. append-only platform-demand versions.
  102. This is an intentionally conservative MVP: one current representative
  103. demand name maps to one stable canonical object. Future merge/split review
  104. can re-point terms without changing raw evidence.
  105. """
  106. package_id = _uuid(f"evidence-package:{context.run_id}")
  107. with get_session() as session:
  108. existing_package = session.scalar(
  109. select(EvidencePackage).where(EvidencePackage.run_id == context.run_id)
  110. )
  111. if existing_package is not None:
  112. return _existing_result(session, existing_package)
  113. grades = list(
  114. session.scalars(
  115. select(DemandGrade)
  116. .where(DemandGrade.biz_dt == context.biz_dt)
  117. .order_by(DemandGrade.id)
  118. ).all()
  119. )
  120. if not grades:
  121. return {
  122. "success": False,
  123. "error_code": "platform_demand_no_grades",
  124. "error": f"biz_dt={context.biz_dt} 没有可物化的 DemandGrade",
  125. }
  126. grade_ids = [int(grade.id) for grade in grades]
  127. category_map: dict[int, list[int]] = {}
  128. category_stmt = select(
  129. DemandGradeCategoryRel.demand_grade_id,
  130. DemandGradeCategoryRel.category_id,
  131. ).where(DemandGradeCategoryRel.demand_grade_id.in_(grade_ids))
  132. for grade_id, category_id in session.execute(category_stmt).all():
  133. category_map.setdefault(int(grade_id), []).append(int(category_id))
  134. package = EvidencePackage(
  135. package_id=package_id,
  136. run_id=context.run_id,
  137. biz_dt=context.biz_dt,
  138. package_version=1,
  139. status="building",
  140. source_snapshot_hash="0" * 64,
  141. source_versions_json={
  142. "run_id": context.run_id,
  143. "biz_dt": context.biz_dt,
  144. "date_snapshot": context.date_snapshot,
  145. "config_snapshot": context.config_snapshot,
  146. "input_snapshot": context.input_snapshot,
  147. },
  148. evidence_count=0,
  149. )
  150. session.add(package)
  151. session.flush()
  152. all_content_hashes: set[str] = set()
  153. created_expressions: set[str] = set()
  154. version_count = 0
  155. relation_count = 0
  156. for grade in grades:
  157. normalized = _normalized_text(grade.demand_name)
  158. canonical_key = _hash_payload({"normalized_text": normalized})
  159. term_id = _uuid(f"standard-term:{canonical_key}")
  160. platform_demand_id = _uuid(f"platform-demand:{canonical_key}")
  161. term = session.scalar(
  162. select(StandardDemandTerm).where(
  163. StandardDemandTerm.canonical_key == canonical_key
  164. )
  165. )
  166. if term is None:
  167. term = StandardDemandTerm(
  168. term_id=term_id,
  169. canonical_key=canonical_key,
  170. canonical_text=grade.demand_name,
  171. normalized_text=normalized,
  172. status="active",
  173. )
  174. session.add(term)
  175. platform = session.scalar(
  176. select(PlatformDemand).where(
  177. PlatformDemand.canonical_key == canonical_key
  178. )
  179. )
  180. if platform is None:
  181. platform = PlatformDemand(
  182. platform_demand_id=platform_demand_id,
  183. canonical_key=canonical_key,
  184. name=grade.demand_name,
  185. description=f"可搜索、可供给的平台需求:{grade.demand_name}",
  186. status="active",
  187. lifecycle_state="new",
  188. )
  189. session.add(platform)
  190. session.flush()
  191. pool_ids = _parse_int_list(grade.related_pool_ids)
  192. pool_rows = MultiDemandPoolDiRepository(session).get_by_ids(pool_ids)
  193. source_items: list[tuple[str, str, dict[str, Any]]] = []
  194. for pool_row in pool_rows:
  195. source_items.append(
  196. (
  197. "multi_demand_pool_di",
  198. str(pool_row.id),
  199. _pool_payload(pool_row),
  200. )
  201. )
  202. feedback_rows = list(
  203. session.scalars(
  204. select(DemandFeedback).where(
  205. DemandFeedback.platform_demand_id == platform_demand_id,
  206. DemandFeedback.processing_status == "accepted",
  207. DemandFeedback.consumed_run_id.is_(None),
  208. )
  209. ).all()
  210. )
  211. for feedback in feedback_rows:
  212. source_items.append(
  213. (
  214. "demand_feedback",
  215. str(feedback.id),
  216. {
  217. "id": int(feedback.id),
  218. "demand_name": (
  219. feedback.content
  220. or feedback.reason_code
  221. or feedback.feedback_action
  222. ),
  223. "feedback_action": feedback.feedback_action,
  224. "reason_code": feedback.reason_code,
  225. "content": feedback.content,
  226. "resolution_reason": feedback.resolution_reason,
  227. "impact_json": feedback.impact_json,
  228. "feedback_user_id": feedback.feedback_user_id,
  229. "created_at": (
  230. feedback.created_at.isoformat()
  231. if feedback.created_at is not None
  232. else None
  233. ),
  234. },
  235. )
  236. )
  237. feedback.consumed_run_id = context.run_id
  238. if not source_items:
  239. source_items.append(
  240. (
  241. "demand_grade",
  242. str(grade.id),
  243. {
  244. "id": int(grade.id),
  245. "demand_name": grade.demand_name,
  246. "grade": grade.grade,
  247. "score": (
  248. float(grade.score) if grade.score is not None else None
  249. ),
  250. "reason": grade.reason,
  251. "biz_dt": grade.biz_dt,
  252. },
  253. )
  254. )
  255. expression_ids: list[str] = []
  256. expression_hashes: list[str] = []
  257. for source_type, source_record_id, source_payload in source_items:
  258. expression_id = _uuid(
  259. f"raw-expression:{package_id}:{source_type}:{source_record_id}"
  260. )
  261. content_hash = _hash_payload(source_payload)
  262. all_content_hashes.add(content_hash)
  263. expression_ids.append(expression_id)
  264. expression_hashes.append(content_hash)
  265. expression = session.get(RawDemandExpression, expression_id)
  266. if expression is None:
  267. expression = RawDemandExpression(
  268. expression_id=expression_id,
  269. package_id=package_id,
  270. source_type=source_type,
  271. source_record_id=source_record_id,
  272. raw_text=str(source_payload["demand_name"]),
  273. original_payload_json=source_payload,
  274. content_hash=content_hash,
  275. observed_biz_dt=context.biz_dt,
  276. data_quality="available",
  277. )
  278. session.add(expression)
  279. created_expressions.add(expression_id)
  280. alias_id = _uuid(f"standard-alias:{term_id}:{expression_id}")
  281. if session.get(StandardDemandAlias, alias_id) is None:
  282. session.add(
  283. StandardDemandAlias(
  284. alias_id=alias_id,
  285. term_id=term_id,
  286. expression_id=expression_id,
  287. alias_text=str(source_payload["demand_name"]),
  288. relation_type="source_expression",
  289. confidence=Decimal("1.00000"),
  290. reason="由当日原始需求表达直接归一到代表需求词",
  291. )
  292. )
  293. category_ids = sorted(set(category_map.get(int(grade.id), [])))
  294. if not category_ids:
  295. category_ids = sorted(set(_parse_int_list(grade.category_ids)))
  296. evidence_hash = _hash_payload(
  297. {
  298. "grade_id": int(grade.id),
  299. "grade": grade.grade,
  300. "score": grade.score,
  301. "reason": grade.reason,
  302. "expression_hashes": sorted(expression_hashes),
  303. "category_ids": category_ids,
  304. }
  305. )
  306. existing_version = session.scalar(
  307. select(PlatformDemandVersion).where(
  308. PlatformDemandVersion.platform_demand_id == platform_demand_id,
  309. PlatformDemandVersion.run_id == context.run_id,
  310. )
  311. )
  312. if existing_version is not None:
  313. continue
  314. previous = session.scalar(
  315. select(PlatformDemandVersion)
  316. .where(
  317. PlatformDemandVersion.platform_demand_id == platform_demand_id
  318. )
  319. .order_by(PlatformDemandVersion.version_no.desc())
  320. .limit(1)
  321. )
  322. version_no = int(previous.version_no) + 1 if previous is not None else 1
  323. change_type = (
  324. "created"
  325. if previous is None
  326. else (
  327. "unchanged"
  328. if previous.evidence_hash == evidence_hash
  329. else "updated"
  330. )
  331. )
  332. version_id = _uuid(
  333. f"platform-demand-version:{platform_demand_id}:{context.run_id}"
  334. )
  335. confidence = _score_confidence(grade.score)
  336. session.add(
  337. PlatformDemandVersion(
  338. platform_demand_version_id=version_id,
  339. platform_demand_id=platform_demand_id,
  340. term_id=term_id,
  341. package_id=package_id,
  342. run_id=context.run_id,
  343. source_demand_grade_id=int(grade.id),
  344. version_no=version_no,
  345. biz_dt=context.biz_dt,
  346. name=grade.demand_name,
  347. description=f"可搜索、可验证的平台需求:{grade.demand_name}",
  348. cognition_confidence=confidence,
  349. reason=grade.reason,
  350. change_type=change_type,
  351. evidence_hash=evidence_hash,
  352. )
  353. )
  354. for index, category_id in enumerate(category_ids):
  355. relation_type = (
  356. "primary_attachment" if index == 0 else "auxiliary_attachment"
  357. )
  358. session.add(
  359. PlatformDemandCategoryRel(
  360. rel_id=_uuid(
  361. f"platform-category-rel:{version_id}:"
  362. f"{category_id}:{relation_type}"
  363. ),
  364. platform_demand_version_id=version_id,
  365. category_id=category_id,
  366. relation_type=relation_type,
  367. relation_source="demand_grade_snapshot",
  368. reason=grade.reason,
  369. confidence=confidence,
  370. is_inferred=True,
  371. status="active",
  372. valid_from_biz_dt=context.biz_dt,
  373. )
  374. )
  375. relation_count += 1
  376. version_count += 1
  377. package.source_snapshot_hash = _hash_payload(sorted(all_content_hashes))
  378. package.evidence_count = len(created_expressions)
  379. package.status = "frozen"
  380. session.flush()
  381. return {
  382. "success": True,
  383. "idempotent_replay": False,
  384. "package_id": package.package_id,
  385. "biz_dt": context.biz_dt,
  386. "evidence_count": package.evidence_count,
  387. "platform_demand_versions": version_count,
  388. "category_relations": relation_count,
  389. "source_snapshot_hash": package.source_snapshot_hash,
  390. }