luckiest-co 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -2,9 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import copy
5
6
  from dataclasses import asdict, dataclass, field, is_dataclass
7
+ from datetime import datetime, timezone
6
8
  from typing import Any, Literal
7
9
 
10
+ from . import health
11
+
8
12
 
9
13
  def _drop_none(value: Any) -> Any:
10
14
  """Recursively remove None values from dataclass-derived structures."""
@@ -35,7 +39,7 @@ class ProviderRuntime:
35
39
  reasoning_provider: Literal["gemini", "openai", "xai", "local"]
36
40
  planner_model: str
37
41
  rerank_model: str
38
- x_search_backend: Literal["xai", "bird"] | None = None
42
+ x_search_backend: Literal["xai", "grok", "bird", "xurl", "xquik", "xapi"] | None = None
39
43
 
40
44
 
41
45
  @dataclass(frozen=True)
@@ -139,6 +143,114 @@ class Cluster:
139
143
  raise ValueError("representative_ids must be a subset of candidate_ids")
140
144
 
141
145
 
146
+ RunOutcomeState = Literal[
147
+ "ok",
148
+ "no-results",
149
+ "partial",
150
+ "rate-limited",
151
+ "auth-failed",
152
+ "payment-required",
153
+ "unreachable",
154
+ "timeout",
155
+ "schema-drift",
156
+ "skipped-unconfigured",
157
+ "error",
158
+ ]
159
+
160
+ FreshnessVerdictState = Literal[
161
+ "current",
162
+ "stale",
163
+ "contradicted",
164
+ "unsupported",
165
+ ]
166
+
167
+ NO_RESULTS = health.NO_RESULTS
168
+ PARTIAL = health.PARTIAL
169
+ RATE_LIMITED = health.RATE_LIMITED
170
+ AUTH_FAILED = health.AUTH_FAILED
171
+ PAYMENT_REQUIRED = health.PAYMENT_REQUIRED
172
+ UNREACHABLE = health.UNREACHABLE
173
+ SCHEMA_DRIFT = health.SCHEMA_DRIFT
174
+ SKIPPED_UNCONFIGURED = health.SKIPPED_UNCONFIGURED
175
+
176
+
177
+ def _utc_now() -> str:
178
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
179
+
180
+
181
+ @dataclass
182
+ class SourceOutcome:
183
+ """What happened to one source during this run.
184
+
185
+ Doctor predicts whether a source is configured and healthy before a run;
186
+ this records the observed retrieval result. Shared states reuse
187
+ ``health.py`` values (``ok``, ``timeout``, ``error``), while the remaining
188
+ states describe run-only outcomes.
189
+ """
190
+
191
+ source: str
192
+ state: RunOutcomeState
193
+ items_returned: int = 0
194
+ attempted: bool = True
195
+ detail: str | None = None
196
+ at: str = field(default_factory=_utc_now)
197
+ # Most specific state among sub-request failures the adapter swallowed
198
+ # while still delivering items. Stays informational while items exist;
199
+ # becomes the outcome state if post-retrieval filtering empties the source.
200
+ lane_failure_state: RunOutcomeState | None = None
201
+ fix_hint: str | None = None
202
+
203
+ def __post_init__(self) -> None:
204
+ valid_states = {
205
+ health.OK,
206
+ health.TIMEOUT,
207
+ health.ERROR,
208
+ NO_RESULTS,
209
+ PARTIAL,
210
+ RATE_LIMITED,
211
+ AUTH_FAILED,
212
+ PAYMENT_REQUIRED,
213
+ UNREACHABLE,
214
+ SCHEMA_DRIFT,
215
+ SKIPPED_UNCONFIGURED,
216
+ }
217
+ if self.state not in valid_states:
218
+ raise ValueError(f"Unknown source outcome state: {self.state}")
219
+ if self.items_returned < 0:
220
+ raise ValueError("items_returned cannot be negative")
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class FreshnessVerdict:
225
+ """Act-time verification result for one source-grounded claim."""
226
+
227
+ claim_id: str
228
+ candidate_id: str
229
+ claim: str
230
+ source: str
231
+ source_item_id: str
232
+ verdict: FreshnessVerdictState
233
+ checked_at: str
234
+ source_url: str = ""
235
+ source_timestamp: str | None = None
236
+ evidence_url: str = ""
237
+ evidence_timestamp: str | None = None
238
+ original_value: Any = None
239
+ current_value: Any = None
240
+ detail: str | None = None
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class LibraryContext:
245
+ """One prior research run relevant to the current report."""
246
+
247
+ topic: str
248
+ published_date: str
249
+ headline: str
250
+ summary: str
251
+ source_kind: Literal["brief", "store"]
252
+
253
+
142
254
  @dataclass
143
255
  class Report:
144
256
  """Final pipeline output."""
@@ -153,8 +265,78 @@ class Report:
153
265
  ranked_candidates: list[Candidate]
154
266
  items_by_source: dict[str, list[SourceItem]]
155
267
  errors_by_source: dict[str, str]
268
+ source_status: dict[str, SourceOutcome] = field(default_factory=dict)
269
+ freshness_verdicts: list[FreshnessVerdict] = field(default_factory=list)
156
270
  warnings: list[str] = field(default_factory=list)
157
271
  artifacts: dict[str, Any] = field(default_factory=dict)
272
+ library_context: list[LibraryContext] = field(default_factory=list)
273
+ drill_of: str | None = None
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class DiscoveryPlan:
278
+ """Topic-less listing feeds selected for a domain sweep."""
279
+
280
+ domain: str
281
+ category: str | None
282
+ subreddits: list[str]
283
+ sources: list[str]
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class DiscoveryTopic:
288
+ """One engagement-ranked topic produced by a discovery sweep.
289
+
290
+ ``top_comment`` is the strongest verbatim community comment from the
291
+ topic's enriched corpus (with attribution), present only on enriched runs.
292
+ ``corroboration_count`` is the number of distinct sources confirming the
293
+ topic - the floor's cross-source signal, surfaced for readers.
294
+
295
+ ``podcast_angle`` and ``x_article_angle`` are engine-generated content
296
+ hooks; ``None`` when no reasoning provider produced them.
297
+ ``previously_surfaced_count``, ``last_surfaced``, and ``covered`` are
298
+ topic-queue annotations; they keep their defaults when the queue is off.
299
+ """
300
+
301
+ rank: int
302
+ name: str
303
+ why_spiking: str
304
+ momentum: Literal["new-this-week", "building"]
305
+ velocity_score: float
306
+ sources: list[str]
307
+ engagement_by_source: dict[str, dict[str, float | int]]
308
+ command: str
309
+ evidence_urls: list[str] = field(default_factory=list)
310
+ top_comment: str | None = None
311
+ corroboration_count: int = 0
312
+ podcast_angle: str | None = None
313
+ x_article_angle: str | None = None
314
+ previously_surfaced_count: int = 0
315
+ last_surfaced: str | None = None
316
+ covered: bool = False
317
+
318
+
319
+ @dataclass
320
+ class DiscoveryReport:
321
+ """Versioned result of a domain-level listing sweep.
322
+
323
+ ``outcome`` is "ok" when at least one topic cleared the confidence floor,
324
+ "nothing-solid" when the window's evidence was all sub-floor - an honest
325
+ empty result instead of ranked noise. ``weak_signal`` optionally names the
326
+ strongest sub-floor topic so a nothing-solid brief can still say what came
327
+ closest.
328
+ """
329
+
330
+ domain: str
331
+ range_from: str
332
+ range_to: str
333
+ generated_at: str
334
+ plan: DiscoveryPlan
335
+ topics: list[DiscoveryTopic]
336
+ source_status: dict[str, SourceOutcome] = field(default_factory=dict)
337
+ warnings: list[str] = field(default_factory=list)
338
+ outcome: str = "ok"
339
+ weak_signal: str | None = None
158
340
 
159
341
 
160
342
  @dataclass
@@ -164,12 +346,117 @@ class RetrievalBundle:
164
346
  items_by_source_and_query: dict[tuple[str, str], list[SourceItem]] = field(default_factory=dict)
165
347
  items_by_source: dict[str, list[SourceItem]] = field(default_factory=dict)
166
348
  errors_by_source: dict[str, str] = field(default_factory=dict)
349
+ source_status: dict[str, SourceOutcome] = field(default_factory=dict)
167
350
  artifacts: dict[str, Any] = field(default_factory=dict)
351
+ # Swallowed sub-request failures on a source that still delivered items.
352
+ # These never change the outcome state (the source succeeded); they ride
353
+ # along as ``SourceOutcome.detail`` so ``doctor --postmortem`` can show
354
+ # what a healthy-looking run lost without branding the source partial.
355
+ detail_by_source: dict[str, str] = field(default_factory=dict)
356
+ lane_state_by_source: dict[str, RunOutcomeState] = field(default_factory=dict)
357
+
358
+ def mark_attempted(self, source: str) -> None:
359
+ """Register a planned source before its first retrieval starts."""
360
+ self.source_status.setdefault(
361
+ source,
362
+ SourceOutcome(source=source, state=NO_RESULTS),
363
+ )
364
+
365
+ def record_failure(
366
+ self,
367
+ source: str,
368
+ state: RunOutcomeState,
369
+ detail: str,
370
+ *,
371
+ attempted: bool = True,
372
+ ) -> None:
373
+ """Record a failure, preserving already-returned items as partial.
374
+
375
+ AUTH_FAILED is preserved even when items exist, since the re-login
376
+ signal shouldn't be downgraded to generic PARTIAL guidance.
377
+ """
378
+ count = len(self.items_by_source.get(source, []))
379
+ # Preserve AUTH_FAILED even when items exist: it's an actionable signal
380
+ # (re-login needed) that shouldn't be downgraded to PARTIAL.
381
+ if state == AUTH_FAILED:
382
+ outcome_state: RunOutcomeState = AUTH_FAILED
383
+ else:
384
+ outcome_state = PARTIAL if count else state
385
+ self.errors_by_source.setdefault(source, detail)
386
+ self.source_status[source] = SourceOutcome(
387
+ source=source,
388
+ state=outcome_state,
389
+ items_returned=count,
390
+ attempted=attempted,
391
+ detail=detail,
392
+ fix_hint="doctor",
393
+ )
394
+
395
+ def record_detail(
396
+ self,
397
+ source: str,
398
+ detail: str,
399
+ state: RunOutcomeState | None = None,
400
+ ) -> None:
401
+ """Note a swallowed lane failure on a source that still delivered items.
402
+
403
+ Unlike :meth:`record_failure`, this never changes the outcome state and
404
+ sets no ``fix_hint``. The note is merged into the ``ok`` outcome on the
405
+ next :meth:`add_items` and survives later clean subqueries.
406
+ """
407
+ detail = " ".join((detail or "").split())
408
+ if not detail:
409
+ return
410
+ existing = self.detail_by_source.get(source)
411
+ if existing and detail not in existing:
412
+ detail = f"{existing}; {detail}"
413
+ self.detail_by_source[source] = detail
414
+ if state:
415
+ self.lane_state_by_source[source] = state
416
+ current = self.source_status.get(source)
417
+ if current and current.state in (health.OK, NO_RESULTS):
418
+ current.detail = detail
419
+ current.lane_failure_state = self.lane_state_by_source.get(source)
168
420
 
169
421
  def add_items(self, label: str, source: str, items: list[SourceItem]) -> None:
170
422
  """Atomically append items to both items_by_source_and_query and items_by_source."""
171
423
  self.items_by_source_and_query.setdefault((label, source), []).extend(items)
172
424
  self.items_by_source.setdefault(source, []).extend(items)
425
+ previous = self.source_status.get(source)
426
+ state: RunOutcomeState = health.OK if items else NO_RESULTS
427
+ detail = None
428
+ fix_hint = None
429
+ if previous and previous.state == health.OK and not items:
430
+ # A later empty subquery must not downgrade a source that already
431
+ # delivered items to no-results.
432
+ state = health.OK
433
+ lane_state = None
434
+ if state in (health.OK, NO_RESULTS):
435
+ detail = self.detail_by_source.get(source)
436
+ lane_state = self.lane_state_by_source.get(source)
437
+ if state == NO_RESULTS and lane_state and not self.items_by_source[source]:
438
+ # Nothing delivered and sub-requests failed: that is the
439
+ # failure, not a clean empty result.
440
+ state = lane_state
441
+ if previous and previous.state not in (health.OK, NO_RESULTS):
442
+ # Preserve AUTH_FAILED state even when items are added: it's an
443
+ # actionable signal (re-login needed) that shouldn't be downgraded
444
+ # to PARTIAL. Other failure states become PARTIAL when items exist.
445
+ if previous.state == AUTH_FAILED:
446
+ state = AUTH_FAILED
447
+ else:
448
+ state = PARTIAL if self.items_by_source[source] else previous.state
449
+ detail = previous.detail
450
+ fix_hint = previous.fix_hint
451
+ self.source_status[source] = SourceOutcome(
452
+ source=source,
453
+ state=state,
454
+ items_returned=len(self.items_by_source[source]),
455
+ attempted=True,
456
+ detail=detail,
457
+ fix_hint=fix_hint,
458
+ lane_failure_state=lane_state,
459
+ )
173
460
 
174
461
 
175
462
  def to_dict(value: Any) -> Any:
@@ -272,6 +559,25 @@ def cluster_from_dict(payload: dict[str, Any]) -> Cluster:
272
559
  )
273
560
 
274
561
 
562
+ def _source_status_from_dict(payload: dict[str, Any]) -> dict[str, "SourceOutcome"]:
563
+ """Rebuild the per-source outcome map shared by every report
564
+ deserializer, so the SourceOutcome reconstruction cannot drift between
565
+ them."""
566
+ return {
567
+ source: SourceOutcome(
568
+ source=outcome.get("source") or source,
569
+ state=outcome["state"],
570
+ items_returned=int(outcome.get("items_returned") or 0),
571
+ attempted=bool(outcome.get("attempted", True)),
572
+ detail=outcome.get("detail"),
573
+ lane_failure_state=outcome.get("lane_failure_state"),
574
+ at=outcome.get("at") or _utc_now(),
575
+ fix_hint=outcome.get("fix_hint"),
576
+ )
577
+ for source, outcome in (payload.get("source_status") or {}).items()
578
+ }
579
+
580
+
275
581
  def report_from_dict(payload: dict[str, Any]) -> Report:
276
582
  return Report(
277
583
  topic=payload["topic"],
@@ -287,8 +593,43 @@ def report_from_dict(payload: dict[str, Any]) -> Report:
287
593
  for source, items in (payload.get("items_by_source") or {}).items()
288
594
  },
289
595
  errors_by_source=dict(payload.get("errors_by_source") or {}),
596
+ source_status=_source_status_from_dict(payload),
597
+ freshness_verdicts=[
598
+ FreshnessVerdict(
599
+ claim_id=item["claim_id"],
600
+ candidate_id=item["candidate_id"],
601
+ claim=item["claim"],
602
+ source=item["source"],
603
+ source_item_id=item["source_item_id"],
604
+ verdict=item["verdict"],
605
+ checked_at=item["checked_at"],
606
+ source_url=item.get("source_url") or "",
607
+ source_timestamp=item.get("source_timestamp"),
608
+ evidence_url=item.get("evidence_url") or "",
609
+ evidence_timestamp=item.get("evidence_timestamp"),
610
+ original_value=item.get("original_value"),
611
+ current_value=item.get("current_value"),
612
+ detail=item.get("detail"),
613
+ )
614
+ for item in (payload.get("freshness_verdicts") or [])
615
+ if isinstance(item, dict)
616
+ ],
290
617
  warnings=list(payload.get("warnings") or []),
291
618
  artifacts=dict(payload.get("artifacts") or {}),
619
+ library_context=[
620
+ LibraryContext(
621
+ topic=str(item.get("topic") or ""),
622
+ published_date=str(item.get("published_date") or ""),
623
+ headline=str(item.get("headline") or ""),
624
+ summary=str(item.get("summary") or ""),
625
+ source_kind=(
626
+ "store" if item.get("source_kind") == "store" else "brief"
627
+ ),
628
+ )
629
+ for item in (payload.get("library_context") or [])
630
+ if isinstance(item, dict)
631
+ ],
632
+ drill_of=payload.get("drill_of"),
292
633
  )
293
634
 
294
635
 
@@ -303,6 +644,38 @@ def candidate_source_label(candidate: Candidate) -> str:
303
644
  return ", ".join(sources) if sources else "unknown"
304
645
 
305
646
 
647
+ def candidate_out_of_window(candidate: Candidate) -> bool:
648
+ """True when every dated item behind this candidate falls outside the window.
649
+
650
+ Window membership is derived from the actual ``published_at`` date compared
651
+ to the run's ``range_from``/``range_to`` (stored in candidate.metadata by
652
+ fusion.weighted_rrf). Some adapters provide ``date_confidence="high"`` for
653
+ old dates, so relying solely on adapter-provided confidence is insufficient.
654
+
655
+ Candidates with no dated item at all are not treated as out of window — an
656
+ unknown date is a coverage gap, not a stale item.
657
+ """
658
+ dated = [item for item in candidate.source_items if item.published_at]
659
+ if not dated:
660
+ return False
661
+
662
+ range_from = candidate.metadata.get("range_from")
663
+ range_to = candidate.metadata.get("range_to")
664
+ if range_from and range_to:
665
+ try:
666
+ start = datetime.fromisoformat(range_from).date()
667
+ end = datetime.fromisoformat(range_to).date()
668
+ for item in dated:
669
+ item_date = datetime.fromisoformat(item.published_at[:10]).date()
670
+ if start <= item_date <= end:
671
+ return False
672
+ return True
673
+ except (ValueError, TypeError):
674
+ pass
675
+
676
+ return all(item.date_confidence != "high" for item in dated)
677
+
678
+
306
679
  def candidate_best_published_at(candidate: Candidate) -> str | None:
307
680
  return max(
308
681
  (item.published_at for item in candidate.source_items if item.published_at),
@@ -317,3 +690,411 @@ def candidate_primary_item(candidate: Candidate) -> SourceItem | None:
317
690
  if item.source == candidate.source:
318
691
  return item
319
692
  return candidate.source_items[0]
693
+
694
+
695
+ AGENT_EXPORT_SCHEMA_VERSION = "1.4"
696
+
697
+
698
+ def without_sources(report: Report, excluded_sources: set[str]) -> Report:
699
+ """Return a deep-copied report with private source evidence removed.
700
+
701
+ This is the publication boundary used by agent JSON, hosted HTML, and
702
+ future outbound surfaces. Cluster titles are rebuilt when a removed item
703
+ participated so text derived from a private representative cannot survive
704
+ after its candidate is gone.
705
+ """
706
+ excluded = {source.lower() for source in excluded_sources}
707
+ if not excluded:
708
+ return copy.deepcopy(report)
709
+ clean = copy.deepcopy(report)
710
+ clean.items_by_source = {
711
+ source: items
712
+ for source, items in clean.items_by_source.items()
713
+ if source.lower() not in excluded
714
+ }
715
+ clean.errors_by_source = {
716
+ source: detail
717
+ for source, detail in clean.errors_by_source.items()
718
+ if source.lower() not in excluded
719
+ }
720
+ clean.source_status = {
721
+ source: outcome
722
+ for source, outcome in clean.source_status.items()
723
+ if source.lower() not in excluded
724
+ }
725
+ clean.query_plan.source_weights = {
726
+ source: weight
727
+ for source, weight in clean.query_plan.source_weights.items()
728
+ if source.lower() not in excluded
729
+ }
730
+ for subquery in clean.query_plan.subqueries:
731
+ subquery.sources[:] = [
732
+ source for source in subquery.sources if source.lower() not in excluded
733
+ ]
734
+
735
+ kept_candidates: list[Candidate] = []
736
+ removed_candidate_ids: set[str] = set()
737
+ for candidate in clean.ranked_candidates:
738
+ if candidate.source.lower() in excluded:
739
+ removed_candidate_ids.add(candidate.candidate_id)
740
+ continue
741
+ candidate.source_items = [
742
+ item for item in candidate.source_items if item.source.lower() not in excluded
743
+ ]
744
+ candidate.sources = [
745
+ source for source in candidate.sources if source.lower() not in excluded
746
+ ]
747
+ candidate.native_ranks = {
748
+ key: rank
749
+ for key, rank in candidate.native_ranks.items()
750
+ if key.rsplit(":", 1)[-1].lower() not in excluded
751
+ }
752
+ kept_candidates.append(candidate)
753
+ clean.ranked_candidates = kept_candidates
754
+ candidate_by_id = {
755
+ candidate.candidate_id: candidate for candidate in clean.ranked_candidates
756
+ }
757
+
758
+ kept_clusters: list[Cluster] = []
759
+ for cluster in clean.clusters:
760
+ original_ids = list(cluster.candidate_ids)
761
+ cluster.candidate_ids = [
762
+ candidate_id for candidate_id in original_ids if candidate_id in candidate_by_id
763
+ ]
764
+ if not cluster.candidate_ids:
765
+ continue
766
+ cluster.representative_ids = [
767
+ candidate_id
768
+ for candidate_id in cluster.representative_ids
769
+ if candidate_id in candidate_by_id
770
+ ] or [cluster.candidate_ids[0]]
771
+ cluster.sources = sorted({
772
+ source
773
+ for candidate_id in cluster.candidate_ids
774
+ for source in candidate_sources(candidate_by_id[candidate_id])
775
+ if source.lower() not in excluded
776
+ })
777
+ if any(candidate_id in removed_candidate_ids for candidate_id in original_ids):
778
+ cluster.title = candidate_by_id[cluster.representative_ids[0]].title
779
+ kept_clusters.append(cluster)
780
+ clean.clusters = kept_clusters
781
+ clean.freshness_verdicts = [
782
+ verdict
783
+ for verdict in clean.freshness_verdicts
784
+ if verdict.source.lower() not in excluded
785
+ and verdict.candidate_id in candidate_by_id
786
+ ]
787
+ for key in list(clean.artifacts):
788
+ if any(source in key.lower() for source in excluded):
789
+ del clean.artifacts[key]
790
+ return clean
791
+
792
+
793
+ DISCOVERY_EXPORT_SCHEMA_VERSION = "1.1"
794
+
795
+
796
+ def _agent_summary(candidate: Candidate) -> str:
797
+ primary = candidate_primary_item(candidate)
798
+ return (
799
+ candidate.snippet
800
+ or (primary.snippet if primary else "")
801
+ or candidate.explanation
802
+ or (primary.body if primary else "")
803
+ )
804
+
805
+
806
+ def _agent_engagement(candidate: Candidate) -> dict[str, float | int]:
807
+ primary = candidate_primary_item(candidate)
808
+ return dict(primary.engagement) if primary else {}
809
+
810
+
811
+ _HEADLINE_ENGAGEMENT_FIELDS_BY_SOURCE = {
812
+ "digg": ("postCount",),
813
+ "reddit": ("score",),
814
+ "stocktwits": ("likes", "reshares"),
815
+ }
816
+
817
+
818
+ def _is_counter_field(field: str) -> bool:
819
+ normalized = field.lower()
820
+ return not (
821
+ # Author-reach and position/score metadata, not per-item engagement.
822
+ normalized in {"rank", "rating", "score", "trustscore", "followers", "subscribers"}
823
+ or normalized.endswith(("_rank", "_score", "_ratio", "_rate", "_followers"))
824
+ )
825
+
826
+
827
+ def _headline_engagement(candidate: Candidate) -> float:
828
+ """Return the primary item's largest native engagement counter."""
829
+ engagement = _agent_engagement(candidate)
830
+ preferred_fields = _HEADLINE_ENGAGEMENT_FIELDS_BY_SOURCE.get(candidate.source, ())
831
+ preferred_values = [
832
+ float(engagement[field])
833
+ for field in preferred_fields
834
+ if isinstance(engagement.get(field), (int, float))
835
+ and not isinstance(engagement[field], bool)
836
+ ]
837
+ if preferred_values:
838
+ return max(preferred_values)
839
+
840
+ values = [
841
+ float(value)
842
+ for field, value in engagement.items()
843
+ if _is_counter_field(field)
844
+ and isinstance(value, (int, float))
845
+ and not isinstance(value, bool)
846
+ ]
847
+ return max(values, default=0.0)
848
+
849
+
850
+ def _window_days(report: Report) -> int:
851
+ start = datetime.fromisoformat(report.range_from).date()
852
+ end = datetime.fromisoformat(report.range_to).date()
853
+ return max(0, (end - start).days)
854
+
855
+
856
+ def _agent_generated_at(value: str) -> str:
857
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
858
+ if parsed.tzinfo is None:
859
+ return value
860
+ return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
861
+
862
+
863
+ def to_agent_export(
864
+ report: Report,
865
+ *,
866
+ corpus_in_export: bool | None = None,
867
+ ) -> dict[str, Any]:
868
+ """Serialize a report to the stable, versioned agent JSON contract.
869
+
870
+ Local corpus evidence is private by default. Callers must opt in explicitly
871
+ either with ``corpus_in_export=True`` or the CLI-populated report artifact.
872
+ """
873
+ if corpus_in_export is None:
874
+ corpus_in_export = bool(report.artifacts.get("corpus_in_export"))
875
+ if not corpus_in_export:
876
+ report = without_sources(report, {"corpus"})
877
+ candidates = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
878
+ cluster_by_candidate: dict[str, int] = {}
879
+ cluster_by_id: dict[str, int] = {}
880
+ exported_clusters: list[dict[str, Any]] = []
881
+
882
+ for index, cluster in enumerate(report.clusters):
883
+ cluster_by_id[cluster.cluster_id] = index
884
+ for candidate_id in cluster.candidate_ids:
885
+ cluster_by_candidate.setdefault(candidate_id, index)
886
+ representative = next(
887
+ (candidates[candidate_id] for candidate_id in cluster.representative_ids if candidate_id in candidates),
888
+ None,
889
+ )
890
+ engagement_total = sum(
891
+ _headline_engagement(candidates[candidate_id])
892
+ for candidate_id in cluster.candidate_ids
893
+ if candidate_id in candidates
894
+ )
895
+ exported_clusters.append(
896
+ {
897
+ "title": cluster.title,
898
+ "summary": _agent_summary(representative) if representative else "",
899
+ "sources": list(cluster.sources),
900
+ "engagement_total": (
901
+ int(engagement_total) if engagement_total.is_integer() else engagement_total
902
+ ),
903
+ }
904
+ )
905
+
906
+ results: list[dict[str, Any]] = []
907
+ for candidate in report.ranked_candidates:
908
+ primary = candidate_primary_item(candidate)
909
+ cluster_index = cluster_by_id.get(candidate.cluster_id or "")
910
+ if cluster_index is None:
911
+ cluster_index = cluster_by_candidate.get(candidate.candidate_id)
912
+ results.append(
913
+ _drop_none(
914
+ {
915
+ "candidate_id": candidate.candidate_id,
916
+ "title": candidate.title,
917
+ "source": candidate.source,
918
+ "url": candidate.url,
919
+ "published_at": primary.published_at if primary else None,
920
+ "summary": _agent_summary(candidate),
921
+ "engagement": _agent_engagement(candidate),
922
+ "relevance_score": round(
923
+ max(0.0, min(1.0, candidate.final_score / 100.0)),
924
+ 4,
925
+ ),
926
+ "cluster": cluster_index,
927
+ }
928
+ )
929
+ )
930
+
931
+ return {
932
+ "schema_version": AGENT_EXPORT_SCHEMA_VERSION,
933
+ "query": report.topic,
934
+ "generated_at": _agent_generated_at(report.generated_at),
935
+ "window_days": _window_days(report),
936
+ "source_status": {
937
+ source: outcome.state
938
+ for source, outcome in sorted(report.source_status.items())
939
+ },
940
+ "freshness_verdicts": [
941
+ _drop_none(asdict(verdict)) for verdict in report.freshness_verdicts
942
+ ],
943
+ "clusters": exported_clusters,
944
+ "results": results,
945
+ "usage": report.artifacts.get("usage"),
946
+ }
947
+
948
+
949
+ # Discovery nominations handoff bundle (leg 1 of the three-command
950
+ # host-judged protocol). The bundle serializes the FULL judge pool losslessly
951
+ # so leg 2 can recompute floor/velocity/entity-token disambiguation exactly
952
+ # as an in-memory run would. Bump the version on any incompatible change to
953
+ # the bundle shape; the handoff reader rejects other versions outright.
954
+ DISCOVERY_NOMINATIONS_SCHEMA_VERSION = "1.0"
955
+ DISCOVERY_NOMINATIONS_KIND = "discovery-nominations"
956
+
957
+ # Pending-report contract (leg 2 -> leg 3 of the host-judged protocol). Leg 2
958
+ # persists the floored/folded/ranked report plus the per-topic angle inputs;
959
+ # leg 3 rebuilds the report from it and never re-runs anything. Bump the
960
+ # version on any incompatible change; the handoff reader rejects others.
961
+ DISCOVERY_PENDING_SCHEMA_VERSION = "1.0"
962
+ DISCOVERY_PENDING_KIND = "discovery-pending"
963
+
964
+
965
+ def discovery_topic_from_dict(payload: dict[str, Any]) -> DiscoveryTopic:
966
+ """Parse one serialized DiscoveryTopic back (to_dict drops None fields,
967
+ so every optional field restores through its dataclass default)."""
968
+ return DiscoveryTopic(
969
+ rank=int(payload["rank"]),
970
+ name=payload["name"],
971
+ why_spiking=payload.get("why_spiking") or "",
972
+ momentum=payload.get("momentum") or "building",
973
+ velocity_score=float(_first_non_none(payload.get("velocity_score"), 0.0)),
974
+ sources=list(payload.get("sources") or []),
975
+ engagement_by_source={
976
+ str(source): dict(metrics)
977
+ for source, metrics in (payload.get("engagement_by_source") or {}).items()
978
+ if isinstance(metrics, dict)
979
+ },
980
+ command=payload.get("command") or "",
981
+ evidence_urls=list(payload.get("evidence_urls") or []),
982
+ top_comment=payload.get("top_comment"),
983
+ corroboration_count=int(payload.get("corroboration_count") or 0),
984
+ podcast_angle=payload.get("podcast_angle"),
985
+ x_article_angle=payload.get("x_article_angle"),
986
+ previously_surfaced_count=int(payload.get("previously_surfaced_count") or 0),
987
+ last_surfaced=payload.get("last_surfaced"),
988
+ covered=bool(payload.get("covered")),
989
+ )
990
+
991
+
992
+ def discovery_report_from_dict(payload: dict[str, Any]) -> DiscoveryReport:
993
+ """Rebuild a DiscoveryReport from its ``to_dict`` form (the pending-report
994
+ round trip the finalize leg performs; mirrors ``report_from_dict``)."""
995
+ plan = payload.get("plan") or {}
996
+ return DiscoveryReport(
997
+ domain=payload.get("domain") or "",
998
+ range_from=payload["range_from"],
999
+ range_to=payload["range_to"],
1000
+ generated_at=payload["generated_at"],
1001
+ plan=DiscoveryPlan(
1002
+ domain=plan.get("domain") or "",
1003
+ category=plan.get("category"),
1004
+ subreddits=list(plan.get("subreddits") or []),
1005
+ sources=list(plan.get("sources") or []),
1006
+ ),
1007
+ topics=[
1008
+ discovery_topic_from_dict(topic)
1009
+ for topic in payload.get("topics") or []
1010
+ ],
1011
+ source_status=_source_status_from_dict(payload),
1012
+ warnings=list(payload.get("warnings") or []),
1013
+ outcome=payload.get("outcome") or "ok",
1014
+ weak_signal=payload.get("weak_signal"),
1015
+ )
1016
+
1017
+
1018
+ def nomination_to_dict(nomination: Any) -> dict[str, Any]:
1019
+ """Serialize a nominate-stage Nomination to a plain dict.
1020
+
1021
+ Duck-typed on the Nomination fields (name, seed_score, items, summary,
1022
+ junk_shape, worthiness) because the dataclass lives in ``pipeline``,
1023
+ which this module must not import. Seed items serialize through
1024
+ ``to_dict`` so the full evidence set round-trips losslessly.
1025
+ """
1026
+ return {
1027
+ "name": nomination.name,
1028
+ "seed_score": nomination.seed_score,
1029
+ "summary": nomination.summary,
1030
+ "junk_shape": bool(nomination.junk_shape),
1031
+ "worthiness": nomination.worthiness,
1032
+ "items": [to_dict(item) for item in nomination.items],
1033
+ }
1034
+
1035
+
1036
+ def nomination_kwargs_from_dict(payload: dict[str, Any]) -> dict[str, Any]:
1037
+ """Parse a serialized nomination back to Nomination constructor kwargs.
1038
+
1039
+ Returns kwargs rather than an instance because the Nomination dataclass
1040
+ lives in ``pipeline``, which this module must not import; the caller
1041
+ (``discovery_handoff``) constructs ``pipeline.Nomination(**kwargs)``.
1042
+ """
1043
+ return {
1044
+ "name": payload["name"],
1045
+ "seed_score": float(_first_non_none(payload.get("seed_score"), 0.0)),
1046
+ "items": [source_item_from_dict(item) for item in payload.get("items") or []],
1047
+ "summary": payload.get("summary") or "",
1048
+ "junk_shape": bool(payload.get("junk_shape")),
1049
+ "worthiness": (
1050
+ float(payload["worthiness"])
1051
+ if payload.get("worthiness") is not None
1052
+ else None
1053
+ ),
1054
+ }
1055
+
1056
+
1057
+ def to_discovery_export(report: DiscoveryReport) -> dict[str, Any]:
1058
+ """Serialize discovery output without changing the normal agent contract."""
1059
+ start = datetime.fromisoformat(report.range_from).date()
1060
+ end = datetime.fromisoformat(report.range_to).date()
1061
+ return {
1062
+ "schema_version": DISCOVERY_EXPORT_SCHEMA_VERSION,
1063
+ "kind": "discovery",
1064
+ "domain": report.domain,
1065
+ "generated_at": _agent_generated_at(report.generated_at),
1066
+ "window_days": max(0, (end - start).days),
1067
+ "source_status": {
1068
+ source: outcome.state
1069
+ for source, outcome in sorted(report.source_status.items())
1070
+ },
1071
+ "feeds": {
1072
+ "category": report.plan.category,
1073
+ "subreddits": list(report.plan.subreddits),
1074
+ "sources": list(report.plan.sources),
1075
+ },
1076
+ "results": [
1077
+ {
1078
+ "rank": topic.rank,
1079
+ "topic": topic.name,
1080
+ "why_spiking": topic.why_spiking,
1081
+ "momentum": topic.momentum,
1082
+ "velocity_score": topic.velocity_score,
1083
+ "sources": list(topic.sources),
1084
+ "engagement": topic.engagement_by_source,
1085
+ "command": topic.command,
1086
+ "evidence_urls": list(topic.evidence_urls),
1087
+ "top_comment": topic.top_comment,
1088
+ "corroboration_count": topic.corroboration_count,
1089
+ "podcast_angle": topic.podcast_angle,
1090
+ "x_article_angle": topic.x_article_angle,
1091
+ "previously_surfaced_count": topic.previously_surfaced_count,
1092
+ "last_surfaced": topic.last_surfaced,
1093
+ "covered": topic.covered,
1094
+ }
1095
+ for topic in report.topics
1096
+ ],
1097
+ "warnings": list(report.warnings),
1098
+ "outcome": report.outcome,
1099
+ "weak_signal": report.weak_signal,
1100
+ }