返回 last30days-skill
schema.py
根目录 / skills / last30days / scripts / lib / schema.py
1 """Core data model for the v3.0.0 last30days pipeline."""
2
3 from __future__ import annotations
4
5 import copy
6 from dataclasses import asdict, dataclass, field, is_dataclass
7 from datetime import datetime, timezone
8 from typing import Any, Literal
9
10 from . import health
11
12
13 def _drop_none(value: Any) -> Any:
14 """Recursively remove None values from dataclass-derived structures."""
15 if is_dataclass(value):
16 return _drop_none(asdict(value))
17 if isinstance(value, dict):
18 return {
19 key: _drop_none(item)
20 for key, item in value.items()
21 if item is not None
22 }
23 if isinstance(value, list):
24 return [_drop_none(item) for item in value]
25 return value
26
27
28 def _first_non_none(*values: Any) -> Any:
29 for value in values:
30 if value is not None:
31 return value
32 return None
33
34
35 @dataclass(frozen=True)
36 class ProviderRuntime:
37 """Resolved runtime provider selection."""
38
39 reasoning_provider: Literal["gemini", "openai", "xai", "local"]
40 planner_model: str
41 rerank_model: str
42 x_search_backend: Literal["xai", "grok", "bird", "xurl", "xquik", "xapi"] | None = None
43
44
45 @dataclass(frozen=True)
46 class SubQuery:
47 """Planner-emitted retrieval unit."""
48
49 label: str
50 search_query: str
51 ranking_query: str
52 sources: list[str]
53 weight: float = 1.0
54
55 def __post_init__(self) -> None:
56 if not self.sources:
57 raise ValueError("SubQuery must have at least one source")
58 if self.weight <= 0:
59 raise ValueError(f"SubQuery weight must be positive, got {self.weight}")
60
61
62 @dataclass
63 class QueryPlan:
64 """Planner output."""
65
66 intent: str
67 freshness_mode: str
68 cluster_mode: str
69 raw_topic: str
70 subqueries: list[SubQuery]
71 source_weights: dict[str, float]
72 notes: list[str] = field(default_factory=list)
73
74
75 @dataclass
76 class SourceItem:
77 """Generic normalized evidence item."""
78
79 item_id: str
80 source: str
81 title: str
82 body: str
83 url: str
84 author: str | None = None
85 container: str | None = None
86 published_at: str | None = None
87 date_confidence: Literal["high", "med", "low"] = "low"
88 engagement: dict[str, float | int] = field(default_factory=dict)
89 relevance_hint: float = 0.5
90 why_relevant: str = ""
91 snippet: str = ""
92 metadata: dict[str, Any] = field(default_factory=dict)
93 # Signal fields populated by signals.annotate_stream (after construction)
94 local_relevance: float | None = None
95 freshness: int | None = None
96 engagement_score: float | None = None
97 source_quality: float | None = None
98 local_rank_score: float | None = None
99
100
101 @dataclass
102 class Candidate:
103 """Global candidate after fusion and reranking."""
104
105 candidate_id: str
106 item_id: str
107 source: str
108 title: str
109 url: str
110 snippet: str
111 subquery_labels: list[str]
112 native_ranks: dict[str, int]
113 local_relevance: float
114 freshness: int
115 engagement: int | float | None
116 source_quality: float
117 rrf_score: float
118 sources: list[str] = field(default_factory=list)
119 source_items: list[SourceItem] = field(default_factory=list)
120 rerank_score: float | None = None
121 final_score: float = 0.0
122 explanation: str | None = None
123 fun_score: float | None = None
124 fun_explanation: str | None = None
125 cluster_id: str | None = None
126 metadata: dict[str, Any] = field(default_factory=dict)
127
128
129 @dataclass
130 class Cluster:
131 """Ranked cluster of related candidates."""
132
133 cluster_id: str
134 title: str
135 candidate_ids: list[str]
136 representative_ids: list[str]
137 sources: list[str]
138 score: float
139 uncertainty: Literal["single-source", "thin-evidence"] | None = None
140
141 def __post_init__(self) -> None:
142 if not set(self.representative_ids) <= set(self.candidate_ids):
143 raise ValueError("representative_ids must be a subset of candidate_ids")
144
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
254 @dataclass
255 class Report:
256 """Final pipeline output."""
257
258 topic: str
259 range_from: str
260 range_to: str
261 generated_at: str
262 provider_runtime: ProviderRuntime
263 query_plan: QueryPlan
264 clusters: list[Cluster]
265 ranked_candidates: list[Candidate]
266 items_by_source: dict[str, list[SourceItem]]
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)
270 warnings: list[str] = field(default_factory=list)
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
340
341
342 @dataclass
343 class RetrievalBundle:
344 """Structured retrieval output before global ranking."""
345
346 items_by_source_and_query: dict[tuple[str, str], list[SourceItem]] = field(default_factory=dict)
347 items_by_source: dict[str, list[SourceItem]] = field(default_factory=dict)
348 errors_by_source: dict[str, str] = field(default_factory=dict)
349 source_status: dict[str, SourceOutcome] = field(default_factory=dict)
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)
420
421 def add_items(self, label: str, source: str, items: list[SourceItem]) -> None:
422 """Atomically append items to both items_by_source_and_query and items_by_source."""
423 self.items_by_source_and_query.setdefault((label, source), []).extend(items)
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 )
460
461
462 def to_dict(value: Any) -> Any:
463 """Serialize dataclasses and nested containers."""
464 return _drop_none(value)
465
466
467 def provider_runtime_from_dict(payload: dict[str, Any]) -> ProviderRuntime:
468 return ProviderRuntime(
469 reasoning_provider=payload["reasoning_provider"],
470 planner_model=payload["planner_model"],
471 rerank_model=payload["rerank_model"],
472 x_search_backend=payload.get("x_search_backend"),
473 )
474
475
476 def subquery_from_dict(payload: dict[str, Any]) -> SubQuery:
477 return SubQuery(
478 label=payload["label"],
479 search_query=payload["search_query"],
480 ranking_query=payload["ranking_query"],
481 sources=list(payload.get("sources") or []),
482 weight=float(payload.get("weight") or 1.0),
483 )
484
485
486 def query_plan_from_dict(payload: dict[str, Any]) -> QueryPlan:
487 return QueryPlan(
488 intent=payload["intent"],
489 freshness_mode=payload["freshness_mode"],
490 cluster_mode=payload["cluster_mode"],
491 raw_topic=payload["raw_topic"],
492 subqueries=[subquery_from_dict(item) for item in payload.get("subqueries") or []],
493 source_weights=dict(payload.get("source_weights") or {}),
494 notes=list(payload.get("notes") or []),
495 )
496
497
498 def source_item_from_dict(payload: dict[str, Any]) -> SourceItem:
499 meta = payload.get("metadata") or {}
500 return SourceItem(
501 item_id=payload["item_id"],
502 source=payload["source"],
503 title=payload["title"],
504 body=payload.get("body") or "",
505 url=payload.get("url") or "",
506 author=payload.get("author"),
507 container=payload.get("container"),
508 published_at=payload.get("published_at"),
509 date_confidence=payload.get("date_confidence") or "low",
510 engagement=dict(payload.get("engagement") or {}),
511 relevance_hint=float(_first_non_none(payload.get("relevance_hint"), 0.5)),
512 why_relevant=payload.get("why_relevant") or "",
513 snippet=payload.get("snippet") or "",
514 metadata=dict(meta),
515 local_relevance=_first_non_none(payload.get("local_relevance"), meta.get("local_relevance")),
516 freshness=_first_non_none(payload.get("freshness"), meta.get("freshness")),
517 engagement_score=_first_non_none(payload.get("engagement_score"), meta.get("engagement_score")),
518 source_quality=_first_non_none(payload.get("source_quality"), meta.get("source_quality")),
519 local_rank_score=_first_non_none(payload.get("local_rank_score"), meta.get("local_rank_score")),
520 )
521
522
523 def candidate_from_dict(payload: dict[str, Any]) -> Candidate:
524 return Candidate(
525 candidate_id=payload["candidate_id"],
526 item_id=payload["item_id"],
527 source=payload["source"],
528 title=payload["title"],
529 url=payload.get("url") or "",
530 snippet=payload.get("snippet") or "",
531 subquery_labels=list(payload.get("subquery_labels") or []),
532 native_ranks={key: int(value) for key, value in (payload.get("native_ranks") or {}).items()},
533 local_relevance=float(_first_non_none(payload.get("local_relevance"), 0.0)),
534 freshness=int(_first_non_none(payload.get("freshness"), 0)),
535 engagement=payload.get("engagement"),
536 source_quality=float(_first_non_none(payload.get("source_quality"), 0.0)),
537 rrf_score=float(_first_non_none(payload.get("rrf_score"), 0.0)),
538 sources=list(payload.get("sources") or []),
539 source_items=[source_item_from_dict(item) for item in payload.get("source_items") or []],
540 rerank_score=float(payload["rerank_score"]) if payload.get("rerank_score") is not None else None,
541 final_score=float(_first_non_none(payload.get("final_score"), 0.0)),
542 explanation=payload.get("explanation"),
543 fun_score=float(payload["fun_score"]) if payload.get("fun_score") is not None else None,
544 fun_explanation=payload.get("fun_explanation"),
545 cluster_id=payload.get("cluster_id"),
546 metadata=dict(payload.get("metadata") or {}),
547 )
548
549
550 def cluster_from_dict(payload: dict[str, Any]) -> Cluster:
551 return Cluster(
552 cluster_id=payload["cluster_id"],
553 title=payload["title"],
554 candidate_ids=list(payload.get("candidate_ids") or []),
555 representative_ids=list(payload.get("representative_ids") or []),
556 sources=list(payload.get("sources") or []),
557 score=float(_first_non_none(payload.get("score"), 0.0)),
558 uncertainty=payload.get("uncertainty"),
559 )
560
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
581 def report_from_dict(payload: dict[str, Any]) -> Report:
582 return Report(
583 topic=payload["topic"],
584 range_from=payload["range_from"],
585 range_to=payload["range_to"],
586 generated_at=payload["generated_at"],
587 provider_runtime=provider_runtime_from_dict(payload["provider_runtime"]),
588 query_plan=query_plan_from_dict(payload["query_plan"]),
589 clusters=[cluster_from_dict(item) for item in payload.get("clusters") or []],
590 ranked_candidates=[candidate_from_dict(item) for item in payload.get("ranked_candidates") or []],
591 items_by_source={
592 source: [source_item_from_dict(item) for item in items]
593 for source, items in (payload.get("items_by_source") or {}).items()
594 },
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 ],
617 warnings=list(payload.get("warnings") or []),
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"),
633 )
634
635
636 def candidate_sources(candidate: Candidate) -> list[str]:
637 if candidate.sources:
638 return candidate.sources
639 return [candidate.source] if candidate.source else []
640
641
642 def candidate_source_label(candidate: Candidate) -> str:
643 sources = candidate_sources(candidate)
644 return ", ".join(sources) if sources else "unknown"
645
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
679 def candidate_best_published_at(candidate: Candidate) -> str | None:
680 return max(
681 (item.published_at for item in candidate.source_items if item.published_at),
682 default=None,
683 )
684
685
686 def candidate_primary_item(candidate: Candidate) -> SourceItem | None:
687 if not candidate.source_items:
688 return None
689 for item in candidate.source_items:
690 if item.source == candidate.source:
691 return item
692 return candidate.source_items[0]
693
694
695 AGENT_EXPORT_SCHEMA_VERSION = "1.3"
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 }
946
947
948 # Discovery nominations handoff bundle (leg 1 of the three-command
949 # host-judged protocol). The bundle serializes the FULL judge pool losslessly
950 # so leg 2 can recompute floor/velocity/entity-token disambiguation exactly
951 # as an in-memory run would. Bump the version on any incompatible change to
952 # the bundle shape; the handoff reader rejects other versions outright.
953 DISCOVERY_NOMINATIONS_SCHEMA_VERSION = "1.0"
954 DISCOVERY_NOMINATIONS_KIND = "discovery-nominations"
955
956 # Pending-report contract (leg 2 -> leg 3 of the host-judged protocol). Leg 2
957 # persists the floored/folded/ranked report plus the per-topic angle inputs;
958 # leg 3 rebuilds the report from it and never re-runs anything. Bump the
959 # version on any incompatible change; the handoff reader rejects others.
960 DISCOVERY_PENDING_SCHEMA_VERSION = "1.0"
961 DISCOVERY_PENDING_KIND = "discovery-pending"
962
963
964 def discovery_topic_from_dict(payload: dict[str, Any]) -> DiscoveryTopic:
965 """Parse one serialized DiscoveryTopic back (to_dict drops None fields,
966 so every optional field restores through its dataclass default)."""
967 return DiscoveryTopic(
968 rank=int(payload["rank"]),
969 name=payload["name"],
970 why_spiking=payload.get("why_spiking") or "",
971 momentum=payload.get("momentum") or "building",
972 velocity_score=float(_first_non_none(payload.get("velocity_score"), 0.0)),
973 sources=list(payload.get("sources") or []),
974 engagement_by_source={
975 str(source): dict(metrics)
976 for source, metrics in (payload.get("engagement_by_source") or {}).items()
977 if isinstance(metrics, dict)
978 },
979 command=payload.get("command") or "",
980 evidence_urls=list(payload.get("evidence_urls") or []),
981 top_comment=payload.get("top_comment"),
982 corroboration_count=int(payload.get("corroboration_count") or 0),
983 podcast_angle=payload.get("podcast_angle"),
984 x_article_angle=payload.get("x_article_angle"),
985 previously_surfaced_count=int(payload.get("previously_surfaced_count") or 0),
986 last_surfaced=payload.get("last_surfaced"),
987 covered=bool(payload.get("covered")),
988 )
989
990
991 def discovery_report_from_dict(payload: dict[str, Any]) -> DiscoveryReport:
992 """Rebuild a DiscoveryReport from its ``to_dict`` form (the pending-report
993 round trip the finalize leg performs; mirrors ``report_from_dict``)."""
994 plan = payload.get("plan") or {}
995 return DiscoveryReport(
996 domain=payload.get("domain") or "",
997 range_from=payload["range_from"],
998 range_to=payload["range_to"],
999 generated_at=payload["generated_at"],
1000 plan=DiscoveryPlan(
1001 domain=plan.get("domain") or "",
1002 category=plan.get("category"),
1003 subreddits=list(plan.get("subreddits") or []),
1004 sources=list(plan.get("sources") or []),
1005 ),
1006 topics=[
1007 discovery_topic_from_dict(topic)
1008 for topic in payload.get("topics") or []
1009 ],
1010 source_status=_source_status_from_dict(payload),
1011 warnings=list(payload.get("warnings") or []),
1012 outcome=payload.get("outcome") or "ok",
1013 weak_signal=payload.get("weak_signal"),
1014 )
1015
1016
1017 def nomination_to_dict(nomination: Any) -> dict[str, Any]:
1018 """Serialize a nominate-stage Nomination to a plain dict.
1019
1020 Duck-typed on the Nomination fields (name, seed_score, items, summary,
1021 junk_shape, worthiness) because the dataclass lives in ``pipeline``,
1022 which this module must not import. Seed items serialize through
1023 ``to_dict`` so the full evidence set round-trips losslessly.
1024 """
1025 return {
1026 "name": nomination.name,
1027 "seed_score": nomination.seed_score,
1028 "summary": nomination.summary,
1029 "junk_shape": bool(nomination.junk_shape),
1030 "worthiness": nomination.worthiness,
1031 "items": [to_dict(item) for item in nomination.items],
1032 }
1033
1034
1035 def nomination_kwargs_from_dict(payload: dict[str, Any]) -> dict[str, Any]:
1036 """Parse a serialized nomination back to Nomination constructor kwargs.
1037
1038 Returns kwargs rather than an instance because the Nomination dataclass
1039 lives in ``pipeline``, which this module must not import; the caller
1040 (``discovery_handoff``) constructs ``pipeline.Nomination(**kwargs)``.
1041 """
1042 return {
1043 "name": payload["name"],
1044 "seed_score": float(_first_non_none(payload.get("seed_score"), 0.0)),
1045 "items": [source_item_from_dict(item) for item in payload.get("items") or []],
1046 "summary": payload.get("summary") or "",
1047 "junk_shape": bool(payload.get("junk_shape")),
1048 "worthiness": (
1049 float(payload["worthiness"])
1050 if payload.get("worthiness") is not None
1051 else None
1052 ),
1053 }
1054
1055
1056 def to_discovery_export(report: DiscoveryReport) -> dict[str, Any]:
1057 """Serialize discovery output without changing the normal agent contract."""
1058 start = datetime.fromisoformat(report.range_from).date()
1059 end = datetime.fromisoformat(report.range_to).date()
1060 return {
1061 "schema_version": DISCOVERY_EXPORT_SCHEMA_VERSION,
1062 "kind": "discovery",
1063 "domain": report.domain,
1064 "generated_at": _agent_generated_at(report.generated_at),
1065 "window_days": max(0, (end - start).days),
1066 "source_status": {
1067 source: outcome.state
1068 for source, outcome in sorted(report.source_status.items())
1069 },
1070 "feeds": {
1071 "category": report.plan.category,
1072 "subreddits": list(report.plan.subreddits),
1073 "sources": list(report.plan.sources),
1074 },
1075 "results": [
1076 {
1077 "rank": topic.rank,
1078 "topic": topic.name,
1079 "why_spiking": topic.why_spiking,
1080 "momentum": topic.momentum,
1081 "velocity_score": topic.velocity_score,
1082 "sources": list(topic.sources),
1083 "engagement": topic.engagement_by_source,
1084 "command": topic.command,
1085 "evidence_urls": list(topic.evidence_urls),
1086 "top_comment": topic.top_comment,
1087 "corroboration_count": topic.corroboration_count,
1088 "podcast_angle": topic.podcast_angle,
1089 "x_article_angle": topic.x_article_angle,
1090 "previously_surfaced_count": topic.previously_surfaced_count,
1091 "last_surfaced": topic.last_surfaced,
1092 "covered": topic.covered,
1093 }
1094 for topic in report.topics
1095 ],
1096 "warnings": list(report.warnings),
1097 "outcome": report.outcome,
1098 "weak_signal": report.weak_signal,
1099 }
1100
1100 lines PYTHON