service.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """Unified post reader for image-text and video decode inputs."""
  2. from __future__ import annotations
  3. from typing import Any, Protocol
  4. from acquisition.content_mode import infer_content_mode
  5. from acquisition.domain import CandidateItem, MediaAsset
  6. from core.config import Settings
  7. from core.models import Card, ExtractedContent, Post
  8. from decode_content.models import ReadCard, ReadResult
  9. from decode_content.readers.imgtext import BailianExtractor, read_imgtext
  10. from decode_content.readers.video import read_video
  11. from pipeline.tracing import TraceContext, TraceWriter
  12. class ImageReader(Protocol):
  13. def __call__(self, post: Post) -> ExtractedContent:
  14. ...
  15. class VideoReader(Protocol):
  16. def __call__(self, post: Post) -> ExtractedContent:
  17. ...
  18. class UnsupportedPostModeError(ValueError):
  19. pass
  20. def _nested_text(payload: dict, *path: str) -> str:
  21. current: Any = payload
  22. for key in path:
  23. if not isinstance(current, dict):
  24. return ""
  25. current = current.get(key)
  26. return current if isinstance(current, str) else ""
  27. def _body_text_from_payload(source_payload: dict) -> str:
  28. return (
  29. _nested_text(source_payload, "body_text")
  30. or _nested_text(source_payload, "detail", "body_text")
  31. or _nested_text(source_payload, "detail", "data", "data", "body_text")
  32. or _nested_text(source_payload, "candidate", "body_text")
  33. or _nested_text(source_payload, "candidate", "raw", "body_text")
  34. or _nested_text(source_payload, "desc")
  35. or _nested_text(source_payload, "content")
  36. )
  37. def _media_url(asset: MediaAsset) -> str | None:
  38. return asset.cdn_url or asset.oss_url or asset.source_url
  39. def _stable_media_url(asset: MediaAsset) -> str | None:
  40. return asset.cdn_url or asset.oss_url
  41. def post_from_candidate_item(item: CandidateItem, media_assets: list[MediaAsset]) -> Post:
  42. """Build the reader input from formal acquisition DB objects."""
  43. ordered = sorted(media_assets, key=lambda asset: asset.position)
  44. image_urls: list[str] = []
  45. video_urls: list[str] = []
  46. cards: list[Card] = []
  47. media_count = 0
  48. cover_url: str | None = None
  49. for asset in ordered:
  50. url = _media_url(asset)
  51. if not url:
  52. continue
  53. media_count += 1
  54. media_type = asset.media_type.lower()
  55. if media_type == "video":
  56. stable_url = _stable_media_url(asset)
  57. if stable_url:
  58. video_urls.append(stable_url)
  59. continue
  60. if media_type in {"image", "cover", "frame"}:
  61. if cover_url is None:
  62. cover_url = url
  63. image_urls.append(url)
  64. cards.append(
  65. Card(
  66. index=len(cards) + 1,
  67. kind="frame" if media_type == "frame" else "image",
  68. url=url,
  69. timestamp=asset.metadata.get("timestamp"),
  70. )
  71. )
  72. source_payload = item.source_payload or {}
  73. body_text = item.body_text or _body_text_from_payload(source_payload) or item.raw_summary or ""
  74. content_id = item.platform_item_id or (str(item.id) if item.id else "")
  75. content_mode = item.content_mode or infer_content_mode(
  76. platform=item.platform,
  77. content_type=item.content_type or "",
  78. body_text=body_text,
  79. image_urls=image_urls,
  80. video_urls=video_urls,
  81. raw=source_payload if isinstance(source_payload, dict) else {},
  82. )
  83. return Post(
  84. id=f"{item.platform}_{content_id}" if content_id and not content_id.startswith(f"{item.platform}_") else content_id,
  85. platform=item.platform,
  86. url=item.canonical_url or "",
  87. content_id=content_id,
  88. unique_key=item.unique_key or "",
  89. title=item.title or "",
  90. content_type=item.content_type or ("video" if content_mode == "video_post" else "图文"),
  91. content_mode=content_mode,
  92. body_text=body_text,
  93. media_count=media_count,
  94. cover_url=cover_url,
  95. topic_list=source_payload.get("topic_list") or source_payload.get("topics") or [],
  96. image_urls=image_urls,
  97. video_urls=video_urls,
  98. cards=[] if video_urls else cards,
  99. author_name=item.author_name,
  100. raw=source_payload,
  101. )
  102. def read_result_from_extracted(post: Post, extracted: ExtractedContent) -> ReadResult:
  103. cmap = {card.index: card.content for card in extracted.cards}
  104. cards: list[ReadCard] = []
  105. source_cards = post.cards
  106. if not source_cards and post.image_urls:
  107. source_cards = [
  108. {"index": idx, "kind": "image", "url": url}
  109. for idx, url in enumerate(post.image_urls, start=1)
  110. ]
  111. for raw in source_cards:
  112. if isinstance(raw, dict):
  113. index = int(raw.get("index") or 0)
  114. kind = str(raw.get("kind") or "image")
  115. url = raw.get("url")
  116. timestamp = raw.get("timestamp")
  117. start = raw.get("start")
  118. end = raw.get("end")
  119. else:
  120. index = raw.index
  121. kind = raw.kind
  122. url = raw.url
  123. timestamp = raw.timestamp
  124. start = raw.start
  125. end = raw.end
  126. cards.append(
  127. ReadCard(
  128. index=index,
  129. kind=kind,
  130. url=url,
  131. timestamp=timestamp,
  132. start=start,
  133. end=end,
  134. content=cmap.get(index, ""),
  135. )
  136. )
  137. parts = [extracted.text]
  138. if extracted.from_image:
  139. parts.append("【图片要点】\n" + extracted.from_image)
  140. if extracted.from_video:
  141. parts.append("【视频要点】\n" + extracted.from_video)
  142. parts.extend(f"【卡片{card.index}】{card.content}" for card in extracted.cards if card.content)
  143. text = "\n\n".join(p for p in parts if p)
  144. media = (
  145. {"type": "video", "video_url": post.video_urls[0] if post.video_urls else None, "images": []}
  146. if post.video_urls
  147. else {"type": "image", "video_url": None, "images": list(post.image_urls)}
  148. )
  149. return ReadResult(text=text, cards=cards, media=media, is_empty=bool(extracted.is_empty))
  150. def read_post(
  151. post: Post,
  152. *,
  153. settings: Settings | None = None,
  154. extractor: BailianExtractor | None = None,
  155. image_reader: ImageReader | None = None,
  156. video_reader: VideoReader | None = None,
  157. trace_writer: TraceWriter | None = None,
  158. trace_context: TraceContext | None = None,
  159. ) -> ReadResult:
  160. mode = post.content_mode or ("video_post" if post.video_urls else "image_post")
  161. if mode == "unsupported":
  162. raise UnsupportedPostModeError("unsupported_content_mode")
  163. if mode == "video_post":
  164. if not post.video_urls:
  165. raise UnsupportedPostModeError("video_url_missing")
  166. if video_reader is not None:
  167. extracted = video_reader(post)
  168. else:
  169. if settings is None:
  170. raise ValueError("settings is required when reading video posts")
  171. extracted = read_video(
  172. post,
  173. settings=settings,
  174. oss_video_url=post.video_urls[0],
  175. trace_writer=trace_writer,
  176. trace_context=trace_context,
  177. )
  178. else:
  179. extracted = image_reader(post) if image_reader is not None else read_imgtext(
  180. post,
  181. extractor=extractor,
  182. trace_writer=trace_writer,
  183. trace_context=trace_context,
  184. )
  185. return read_result_from_extracted(post, extracted)
  186. def read_item(
  187. item: CandidateItem,
  188. media_assets: list[MediaAsset],
  189. *,
  190. settings: Settings | None = None,
  191. extractor: BailianExtractor | None = None,
  192. image_reader: ImageReader | None = None,
  193. video_reader: VideoReader | None = None,
  194. trace_writer: TraceWriter | None = None,
  195. trace_context: TraceContext | None = None,
  196. ) -> ReadResult:
  197. return read_post(
  198. post_from_candidate_item(item, media_assets),
  199. settings=settings,
  200. extractor=extractor,
  201. image_reader=image_reader,
  202. video_reader=video_reader,
  203. trace_writer=trace_writer,
  204. trace_context=trace_context,
  205. )
  206. __all__ = [
  207. "ImageReader",
  208. "UnsupportedPostModeError",
  209. "VideoReader",
  210. "post_from_candidate_item",
  211. "read_item",
  212. "read_post",
  213. "read_result_from_extracted",
  214. ]