decode_runner.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """Pipeline adapter for decoding creation candidate items."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass, field
  4. from typing import Protocol
  5. from uuid import UUID
  6. from acquisition.domain import CandidateItem, MediaAsset
  7. from core.text_limits import ERROR_MESSAGE_MAX_CHARS, clip_text
  8. from decode_content.readers.service import post_from_candidate_item
  9. from decode_content.service import DecodeService, DecodeWorkflowOutput
  10. from pipeline.dedupe import dedupe_candidate_items, should_decode_item
  11. from pipeline.tracing import NoopTraceWriter, TraceContext, TraceWriter
  12. class DecodeCandidateRepository(Protocol):
  13. def list_creation_candidate_items(
  14. self,
  15. *,
  16. run_id: UUID | None = None,
  17. limit: int = 100,
  18. ) -> list[CandidateItem]:
  19. ...
  20. def list_media_assets_for_item(self, item_id: UUID) -> list[MediaAsset]:
  21. ...
  22. @dataclass(frozen=True)
  23. class DecodeBatchResult:
  24. total: int
  25. decoded: int
  26. skipped: int
  27. failed: int
  28. outputs: list[DecodeWorkflowOutput]
  29. failures: list[dict[str, str]] = field(default_factory=list)
  30. def _record_failure(decode_service: DecodeService, item: CandidateItem, exc: Exception) -> dict[str, str]:
  31. message = clip_text(str(exc) or exc.__class__.__name__, ERROR_MESSAGE_MAX_CHARS)
  32. failure = {
  33. "item_id": str(item.id),
  34. "platform": item.platform,
  35. "title": item.title or "",
  36. "error": message,
  37. }
  38. repo = getattr(decode_service, "repository", None)
  39. if repo is not None and item.id is not None:
  40. mark_jobs = getattr(repo, "mark_running_decode_jobs_failed", None)
  41. if mark_jobs is not None:
  42. mark_jobs(item.id, message)
  43. save_result = getattr(repo, "save_decode_result", None)
  44. if save_result is not None:
  45. save_result(
  46. item_id=item.id,
  47. read_result={"is_empty": True, "text": "", "metadata": {"decode_error": message}},
  48. gate_result={"passed": False, "reason": "decode_failed", "details": {"error": message}},
  49. framing_result={"error": message},
  50. status="failed",
  51. )
  52. return failure
  53. def run_decode_stage(
  54. *,
  55. candidate_repo: DecodeCandidateRepository,
  56. decode_service: DecodeService,
  57. run_id: UUID | None = None,
  58. limit: int = 100,
  59. decoded_item_ids: set[str] | None = None,
  60. trace_writer: TraceWriter | None = None,
  61. trace_context: TraceContext | None = None,
  62. ) -> DecodeBatchResult:
  63. items = dedupe_candidate_items(candidate_repo.list_creation_candidate_items(run_id=run_id, limit=limit))
  64. trace_writer = trace_writer or NoopTraceWriter()
  65. base_context = trace_context or TraceContext(acquisition_run_id=run_id, stage="decode")
  66. trace_writer.event(
  67. context=base_context,
  68. stage="decode",
  69. event_type="decode_stage_started",
  70. status="running",
  71. payload={"run_id": str(run_id) if run_id else None, "candidate_count": len(items), "limit": limit},
  72. )
  73. outputs: list[DecodeWorkflowOutput] = []
  74. failures: list[dict[str, str]] = []
  75. decoded = skipped = failed = 0
  76. for item in items:
  77. if item.id is None:
  78. skipped += 1
  79. trace_writer.event(
  80. context=base_context.child(platform=item.platform),
  81. stage="decode",
  82. event_type="decode_item_skipped",
  83. status="skipped",
  84. payload={"reason": "missing_item_id", "title": item.title or ""},
  85. )
  86. continue
  87. item_context = base_context.child(
  88. item_id=item.id,
  89. acquisition_job_id=item.job_id,
  90. query_id=item.query_id,
  91. platform=item.platform,
  92. )
  93. decision = should_decode_item(item, decoded_item_ids=decoded_item_ids)
  94. if not decision.keep:
  95. skipped += 1
  96. trace_writer.event(
  97. context=item_context,
  98. stage="decode",
  99. event_type="decode_item_skipped",
  100. status="skipped",
  101. target_table="candidate_items",
  102. target_id=item.id,
  103. payload={"reason": decision.reason},
  104. )
  105. continue
  106. try:
  107. trace_writer.event(
  108. context=item_context,
  109. stage="decode",
  110. event_type="decode_item_started",
  111. status="running",
  112. target_table="candidate_items",
  113. target_id=item.id,
  114. )
  115. media = candidate_repo.list_media_assets_for_item(item.id)
  116. post = post_from_candidate_item(item, media)
  117. outputs.append(decode_service.decode_post(item_id=item.id, post=post))
  118. decoded += 1
  119. trace_writer.event(
  120. context=item_context,
  121. stage="decode",
  122. event_type="decode_item_finished",
  123. status="done",
  124. target_table="candidate_items",
  125. target_id=item.id,
  126. )
  127. except Exception as exc:
  128. failed += 1
  129. failures.append(_record_failure(decode_service, item, exc))
  130. trace_writer.event(
  131. context=item_context,
  132. stage="decode",
  133. event_type="decode_item_failed",
  134. status="failed",
  135. severity="error",
  136. target_table="candidate_items",
  137. target_id=item.id,
  138. error_message=str(exc),
  139. )
  140. trace_writer.event(
  141. context=base_context,
  142. stage="decode",
  143. event_type="decode_stage_finished",
  144. status="done" if failed == 0 else "partial",
  145. payload={"total": len(items), "decoded": decoded, "skipped": skipped, "failed": failed},
  146. )
  147. return DecodeBatchResult(
  148. total=len(items),
  149. decoded=decoded,
  150. skipped=skipped,
  151. failed=failed,
  152. outputs=outputs,
  153. failures=failures,
  154. )