返回 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", "bird"] | 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 "unreachable",
153 "timeout",
154 "schema-drift",
155 "skipped-unconfigured",
156 "error",
157 ]
158
159 FreshnessVerdictState = Literal[
160 "current",
161 "stale",
162 "contradicted",
163 "unsupported",
164 ]
165
166 NO_RESULTS = health.NO_RESULTS
167 PARTIAL = health.PARTIAL
168 RATE_LIMITED = health.RATE_LIMITED
169 AUTH_FAILED = health.AUTH_FAILED
170 UNREACHABLE = health.UNREACHABLE
171 SCHEMA_DRIFT = health.SCHEMA_DRIFT
172 SKIPPED_UNCONFIGURED = health.SKIPPED_UNCONFIGURED
173
174
175 def _utc_now() -> str:
176 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
177
178
179 @dataclass
180 class SourceOutcome:
181 """What happened to one source during this run.
182
183 Doctor predicts whether a source is configured and healthy before a run;
184 this records the observed retrieval result. Shared states reuse
185 ``health.py`` values (``ok``, ``timeout``, ``error``), while the remaining
186 states describe run-only outcomes.
187 """
188
189 source: str
190 state: RunOutcomeState
191 items_returned: int = 0
192 attempted: bool = True
193 detail: str | None = None
194 at: str = field(default_factory=_utc_now)
195 fix_hint: str | None = None
196
197 def __post_init__(self) -> None:
198 valid_states = {
199 health.OK,
200 health.TIMEOUT,
201 health.ERROR,
202 NO_RESULTS,
203 PARTIAL,
204 RATE_LIMITED,
205 AUTH_FAILED,
206 UNREACHABLE,
207 SCHEMA_DRIFT,
208 SKIPPED_UNCONFIGURED,
209 }
210 if self.state not in valid_states:
211 raise ValueError(f"Unknown source outcome state: {self.state}")
212 if self.items_returned < 0:
213 raise ValueError("items_returned cannot be negative")
214
215
216 @dataclass(frozen=True)
217 class FreshnessVerdict:
218 """Act-time verification result for one source-grounded claim."""
219
220 claim_id: str
221 candidate_id: str
222 claim: str
223 source: str
224 source_item_id: str
225 verdict: FreshnessVerdictState
226 checked_at: str
227 source_url: str = ""
228 source_timestamp: str | None = None
229 evidence_url: str = ""
230 evidence_timestamp: str | None = None
231 original_value: Any = None
232 current_value: Any = None
233 detail: str | None = None
234
235
236 @dataclass(frozen=True)
237 class LibraryContext:
238 """One prior research run relevant to the current report."""
239
240 topic: str
241 published_date: str
242 headline: str
243 summary: str
244 source_kind: Literal["brief", "store"]
245
246
247 @dataclass
248 class Report:
249 """Final pipeline output."""
250
251 topic: str
252 range_from: str
253 range_to: str
254 generated_at: str
255 provider_runtime: ProviderRuntime
256 query_plan: QueryPlan
257 clusters: list[Cluster]
258 ranked_candidates: list[Candidate]
259 items_by_source: dict[str, list[SourceItem]]
260 errors_by_source: dict[str, str]
261 source_status: dict[str, SourceOutcome] = field(default_factory=dict)
262 freshness_verdicts: list[FreshnessVerdict] = field(default_factory=list)
263 warnings: list[str] = field(default_factory=list)
264 artifacts: dict[str, Any] = field(default_factory=dict)
265 library_context: list[LibraryContext] = field(default_factory=list)
266 drill_of: str | None = None
267
268
269 @dataclass(frozen=True)
270 class DiscoveryPlan:
271 """Topic-less listing feeds selected for a domain sweep."""
272
273 domain: str
274 category: str | None
275 subreddits: list[str]
276 sources: list[str]
277
278
279 @dataclass(frozen=True)
280 class DiscoveryTopic:
281 """One engagement-ranked topic produced by a discovery sweep.
282
283 ``top_comment`` is the strongest verbatim community comment from the
284 topic's enriched corpus (with attribution), present only on enriched runs.
285 ``corroboration_count`` is the number of distinct sources confirming the
286 topic - the floor's cross-source signal, surfaced for readers.
287
288 ``podcast_angle`` and ``x_article_angle`` are engine-generated content
289 hooks; ``None`` when no reasoning provider produced them.
290 ``previously_surfaced_count``, ``last_surfaced``, and ``covered`` are
291 topic-queue annotations; they keep their defaults when the queue is off.
292 """
293
294 rank: int
295 name: str
296 why_spiking: str
297 momentum: Literal["new-this-week", "building"]
298 velocity_score: float
299 sources: list[str]
300 engagement_by_source: dict[str, dict[str, float | int]]
301 command: str
302 evidence_urls: list[str] = field(default_factory=list)
303 top_comment: str | None = None
304 corroboration_count: int = 0
305 podcast_angle: str | None = None
306 x_article_angle: str | None = None
307 previously_surfaced_count: int = 0
308 last_surfaced: str | None = None
309 covered: bool = False
310
311
312 @dataclass
313 class DiscoveryReport:
314 """Versioned result of a domain-level listing sweep.
315
316 ``outcome`` is "ok" when at least one topic cleared the confidence floor,
317 "nothing-solid" when the window's evidence was all sub-floor - an honest
318 empty result instead of ranked noise. ``weak_signal`` optionally names the
319 strongest sub-floor topic so a nothing-solid brief can still say what came
320 closest.
321 """
322
323 domain: str
324 range_from: str
325 range_to: str
326 generated_at: str
327 plan: DiscoveryPlan
328 topics: list[DiscoveryTopic]
329 source_status: dict[str, SourceOutcome] = field(default_factory=dict)
330 warnings: list[str] = field(default_factory=list)
331 outcome: str = "ok"
332 weak_signal: str | None = None
333
334
335 @dataclass
336 class RetrievalBundle:
337 """Structured retrieval output before global ranking."""
338
339 items_by_source_and_query: dict[tuple[str, str], list[SourceItem]] = field(default_factory=dict)
340 items_by_source: dict[str, list[SourceItem]] = field(default_factory=dict)
341 errors_by_source: dict[str, str] = field(default_factory=dict)
342 source_status: dict[str, SourceOutcome] = field(default_factory=dict)
343 artifacts: dict[str, Any] = field(default_factory=dict)
344
345 def mark_attempted(self, source: str) -> None:
346 """Register a planned source before its first retrieval starts."""
347 self.source_status.setdefault(
348 source,
349 SourceOutcome(source=source, state=NO_RESULTS),
350 )
351
352 def record_failure(
353 self,
354 source: str,
355 state: RunOutcomeState,
356 detail: str,
357 *,
358 attempted: bool = True,
359 ) -> None:
360 """Record a failure, preserving already-returned items as partial."""
361 count = len(self.items_by_source.get(source, []))
362 outcome_state: RunOutcomeState = PARTIAL if count else state
363 self.errors_by_source.setdefault(source, detail)
364 self.source_status[source] = SourceOutcome(
365 source=source,
366 state=outcome_state,
367 items_returned=count,
368 attempted=attempted,
369 detail=detail,
370 fix_hint="doctor",
371 )
372
373 def add_items(self, label: str, source: str, items: list[SourceItem]) -> None:
374 """Atomically append items to both items_by_source_and_query and items_by_source."""
375 self.items_by_source_and_query.setdefault((label, source), []).extend(items)
376 self.items_by_source.setdefault(source, []).extend(items)
377 previous = self.source_status.get(source)
378 state: RunOutcomeState = health.OK if items else NO_RESULTS
379 detail = None
380 fix_hint = None
381 if previous and previous.state not in (health.OK, NO_RESULTS):
382 state = PARTIAL if self.items_by_source[source] else previous.state
383 detail = previous.detail
384 fix_hint = previous.fix_hint
385 self.source_status[source] = SourceOutcome(
386 source=source,
387 state=state,
388 items_returned=len(self.items_by_source[source]),
389 attempted=True,
390 detail=detail,
391 fix_hint=fix_hint,
392 )
393
394
395 def to_dict(value: Any) -> Any:
396 """Serialize dataclasses and nested containers."""
397 return _drop_none(value)
398
399
400 def provider_runtime_from_dict(payload: dict[str, Any]) -> ProviderRuntime:
401 return ProviderRuntime(
402 reasoning_provider=payload["reasoning_provider"],
403 planner_model=payload["planner_model"],
404 rerank_model=payload["rerank_model"],
405 x_search_backend=payload.get("x_search_backend"),
406 )
407
408
409 def subquery_from_dict(payload: dict[str, Any]) -> SubQuery:
410 return SubQuery(
411 label=payload["label"],
412 search_query=payload["search_query"],
413 ranking_query=payload["ranking_query"],
414 sources=list(payload.get("sources") or []),
415 weight=float(payload.get("weight") or 1.0),
416 )
417
418
419 def query_plan_from_dict(payload: dict[str, Any]) -> QueryPlan:
420 return QueryPlan(
421 intent=payload["intent"],
422 freshness_mode=payload["freshness_mode"],
423 cluster_mode=payload["cluster_mode"],
424 raw_topic=payload["raw_topic"],
425 subqueries=[subquery_from_dict(item) for item in payload.get("subqueries") or []],
426 source_weights=dict(payload.get("source_weights") or {}),
427 notes=list(payload.get("notes") or []),
428 )
429
430
431 def source_item_from_dict(payload: dict[str, Any]) -> SourceItem:
432 meta = payload.get("metadata") or {}
433 return SourceItem(
434 item_id=payload["item_id"],
435 source=payload["source"],
436 title=payload["title"],
437 body=payload.get("body") or "",
438 url=payload.get("url") or "",
439 author=payload.get("author"),
440 container=payload.get("container"),
441 published_at=payload.get("published_at"),
442 date_confidence=payload.get("date_confidence") or "low",
443 engagement=dict(payload.get("engagement") or {}),
444 relevance_hint=float(_first_non_none(payload.get("relevance_hint"), 0.5)),
445 why_relevant=payload.get("why_relevant") or "",
446 snippet=payload.get("snippet") or "",
447 metadata=dict(meta),
448 local_relevance=_first_non_none(payload.get("local_relevance"), meta.get("local_relevance")),
449 freshness=_first_non_none(payload.get("freshness"), meta.get("freshness")),
450 engagement_score=_first_non_none(payload.get("engagement_score"), meta.get("engagement_score")),
451 source_quality=_first_non_none(payload.get("source_quality"), meta.get("source_quality")),
452 local_rank_score=_first_non_none(payload.get("local_rank_score"), meta.get("local_rank_score")),
453 )
454
455
456 def candidate_from_dict(payload: dict[str, Any]) -> Candidate:
457 return Candidate(
458 candidate_id=payload["candidate_id"],
459 item_id=payload["item_id"],
460 source=payload["source"],
461 title=payload["title"],
462 url=payload.get("url") or "",
463 snippet=payload.get("snippet") or "",
464 subquery_labels=list(payload.get("subquery_labels") or []),
465 native_ranks={key: int(value) for key, value in (payload.get("native_ranks") or {}).items()},
466 local_relevance=float(_first_non_none(payload.get("local_relevance"), 0.0)),
467 freshness=int(_first_non_none(payload.get("freshness"), 0)),
468 engagement=payload.get("engagement"),
469 source_quality=float(_first_non_none(payload.get("source_quality"), 0.0)),
470 rrf_score=float(_first_non_none(payload.get("rrf_score"), 0.0)),
471 sources=list(payload.get("sources") or []),
472 source_items=[source_item_from_dict(item) for item in payload.get("source_items") or []],
473 rerank_score=float(payload["rerank_score"]) if payload.get("rerank_score") is not None else None,
474 final_score=float(_first_non_none(payload.get("final_score"), 0.0)),
475 explanation=payload.get("explanation"),
476 fun_score=float(payload["fun_score"]) if payload.get("fun_score") is not None else None,
477 fun_explanation=payload.get("fun_explanation"),
478 cluster_id=payload.get("cluster_id"),
479 metadata=dict(payload.get("metadata") or {}),
480 )
481
482
483 def cluster_from_dict(payload: dict[str, Any]) -> Cluster:
484 return Cluster(
485 cluster_id=payload["cluster_id"],
486 title=payload["title"],
487 candidate_ids=list(payload.get("candidate_ids") or []),
488 representative_ids=list(payload.get("representative_ids") or []),
489 sources=list(payload.get("sources") or []),
490 score=float(_first_non_none(payload.get("score"), 0.0)),
491 uncertainty=payload.get("uncertainty"),
492 )
493
494
495 def _source_status_from_dict(payload: dict[str, Any]) -> dict[str, "SourceOutcome"]:
496 """Rebuild the per-source outcome map shared by every report
497 deserializer, so the SourceOutcome reconstruction cannot drift between
498 them."""
499 return {
500 source: SourceOutcome(
501 source=outcome.get("source") or source,
502 state=outcome["state"],
503 items_returned=int(outcome.get("items_returned") or 0),
504 attempted=bool(outcome.get("attempted", True)),
505 detail=outcome.get("detail"),
506 at=outcome.get("at") or _utc_now(),
507 fix_hint=outcome.get("fix_hint"),
508 )
509 for source, outcome in (payload.get("source_status") or {}).items()
510 }
511
512
513 def report_from_dict(payload: dict[str, Any]) -> Report:
514 return Report(
515 topic=payload["topic"],
516 range_from=payload["range_from"],
517 range_to=payload["range_to"],
518 generated_at=payload["generated_at"],
519 provider_runtime=provider_runtime_from_dict(payload["provider_runtime"]),
520 query_plan=query_plan_from_dict(payload["query_plan"]),
521 clusters=[cluster_from_dict(item) for item in payload.get("clusters") or []],
522 ranked_candidates=[candidate_from_dict(item) for item in payload.get("ranked_candidates") or []],
523 items_by_source={
524 source: [source_item_from_dict(item) for item in items]
525 for source, items in (payload.get("items_by_source") or {}).items()
526 },
527 errors_by_source=dict(payload.get("errors_by_source") or {}),
528 source_status=_source_status_from_dict(payload),
529 freshness_verdicts=[
530 FreshnessVerdict(
531 claim_id=item["claim_id"],
532 candidate_id=item["candidate_id"],
533 claim=item["claim"],
534 source=item["source"],
535 source_item_id=item["source_item_id"],
536 verdict=item["verdict"],
537 checked_at=item["checked_at"],
538 source_url=item.get("source_url") or "",
539 source_timestamp=item.get("source_timestamp"),
540 evidence_url=item.get("evidence_url") or "",
541 evidence_timestamp=item.get("evidence_timestamp"),
542 original_value=item.get("original_value"),
543 current_value=item.get("current_value"),
544 detail=item.get("detail"),
545 )
546 for item in (payload.get("freshness_verdicts") or [])
547 if isinstance(item, dict)
548 ],
549 warnings=list(payload.get("warnings") or []),
550 artifacts=dict(payload.get("artifacts") or {}),
551 library_context=[
552 LibraryContext(
553 topic=str(item.get("topic") or ""),
554 published_date=str(item.get("published_date") or ""),
555 headline=str(item.get("headline") or ""),
556 summary=str(item.get("summary") or ""),
557 source_kind=(
558 "store" if item.get("source_kind") == "store" else "brief"
559 ),
560 )
561 for item in (payload.get("library_context") or [])
562 if isinstance(item, dict)
563 ],
564 drill_of=payload.get("drill_of"),
565 )
566
567
568 def candidate_sources(candidate: Candidate) -> list[str]:
569 if candidate.sources:
570 return candidate.sources
571 return [candidate.source] if candidate.source else []
572
573
574 def candidate_source_label(candidate: Candidate) -> str:
575 sources = candidate_sources(candidate)
576 return ", ".join(sources) if sources else "unknown"
577
578
579 def candidate_best_published_at(candidate: Candidate) -> str | None:
580 return max(
581 (item.published_at for item in candidate.source_items if item.published_at),
582 default=None,
583 )
584
585
586 def candidate_primary_item(candidate: Candidate) -> SourceItem | None:
587 if not candidate.source_items:
588 return None
589 for item in candidate.source_items:
590 if item.source == candidate.source:
591 return item
592 return candidate.source_items[0]
593
594
595 AGENT_EXPORT_SCHEMA_VERSION = "1.2"
596
597
598 def without_sources(report: Report, excluded_sources: set[str]) -> Report:
599 """Return a deep-copied report with private source evidence removed.
600
601 This is the publication boundary used by agent JSON, hosted HTML, and
602 future outbound surfaces. Cluster titles are rebuilt when a removed item
603 participated so text derived from a private representative cannot survive
604 after its candidate is gone.
605 """
606 excluded = {source.lower() for source in excluded_sources}
607 if not excluded:
608 return copy.deepcopy(report)
609 clean = copy.deepcopy(report)
610 clean.items_by_source = {
611 source: items
612 for source, items in clean.items_by_source.items()
613 if source.lower() not in excluded
614 }
615 clean.errors_by_source = {
616 source: detail
617 for source, detail in clean.errors_by_source.items()
618 if source.lower() not in excluded
619 }
620 clean.source_status = {
621 source: outcome
622 for source, outcome in clean.source_status.items()
623 if source.lower() not in excluded
624 }
625 clean.query_plan.source_weights = {
626 source: weight
627 for source, weight in clean.query_plan.source_weights.items()
628 if source.lower() not in excluded
629 }
630 for subquery in clean.query_plan.subqueries:
631 subquery.sources[:] = [
632 source for source in subquery.sources if source.lower() not in excluded
633 ]
634
635 kept_candidates: list[Candidate] = []
636 removed_candidate_ids: set[str] = set()
637 for candidate in clean.ranked_candidates:
638 if candidate.source.lower() in excluded:
639 removed_candidate_ids.add(candidate.candidate_id)
640 continue
641 candidate.source_items = [
642 item for item in candidate.source_items if item.source.lower() not in excluded
643 ]
644 candidate.sources = [
645 source for source in candidate.sources if source.lower() not in excluded
646 ]
647 candidate.native_ranks = {
648 key: rank
649 for key, rank in candidate.native_ranks.items()
650 if key.rsplit(":", 1)[-1].lower() not in excluded
651 }
652 kept_candidates.append(candidate)
653 clean.ranked_candidates = kept_candidates
654 candidate_by_id = {
655 candidate.candidate_id: candidate for candidate in clean.ranked_candidates
656 }
657
658 kept_clusters: list[Cluster] = []
659 for cluster in clean.clusters:
660 original_ids = list(cluster.candidate_ids)
661 cluster.candidate_ids = [
662 candidate_id for candidate_id in original_ids if candidate_id in candidate_by_id
663 ]
664 if not cluster.candidate_ids:
665 continue
666 cluster.representative_ids = [
667 candidate_id
668 for candidate_id in cluster.representative_ids
669 if candidate_id in candidate_by_id
670 ] or [cluster.candidate_ids[0]]
671 cluster.sources = sorted({
672 source
673 for candidate_id in cluster.candidate_ids
674 for source in candidate_sources(candidate_by_id[candidate_id])
675 if source.lower() not in excluded
676 })
677 if any(candidate_id in removed_candidate_ids for candidate_id in original_ids):
678 cluster.title = candidate_by_id[cluster.representative_ids[0]].title
679 kept_clusters.append(cluster)
680 clean.clusters = kept_clusters
681 clean.freshness_verdicts = [
682 verdict
683 for verdict in clean.freshness_verdicts
684 if verdict.source.lower() not in excluded
685 and verdict.candidate_id in candidate_by_id
686 ]
687 for key in list(clean.artifacts):
688 if any(source in key.lower() for source in excluded):
689 del clean.artifacts[key]
690 return clean
691
692
693 DISCOVERY_EXPORT_SCHEMA_VERSION = "1.1"
694
695
696 def _agent_summary(candidate: Candidate) -> str:
697 primary = candidate_primary_item(candidate)
698 return (
699 candidate.snippet
700 or (primary.snippet if primary else "")
701 or candidate.explanation
702 or (primary.body if primary else "")
703 )
704
705
706 def _agent_engagement(candidate: Candidate) -> dict[str, float | int]:
707 primary = candidate_primary_item(candidate)
708 return dict(primary.engagement) if primary else {}
709
710
711 _HEADLINE_ENGAGEMENT_FIELDS_BY_SOURCE = {
712 "digg": ("postCount",),
713 "reddit": ("score",),
714 "stocktwits": ("likes", "reshares"),
715 }
716
717
718 def _is_counter_field(field: str) -> bool:
719 normalized = field.lower()
720 return not (
721 # Author-reach and position/score metadata, not per-item engagement.
722 normalized in {"rank", "rating", "score", "trustscore", "followers", "subscribers"}
723 or normalized.endswith(("_rank", "_score", "_ratio", "_rate", "_followers"))
724 )
725
726
727 def _headline_engagement(candidate: Candidate) -> float:
728 """Return the primary item's largest native engagement counter."""
729 engagement = _agent_engagement(candidate)
730 preferred_fields = _HEADLINE_ENGAGEMENT_FIELDS_BY_SOURCE.get(candidate.source, ())
731 preferred_values = [
732 float(engagement[field])
733 for field in preferred_fields
734 if isinstance(engagement.get(field), (int, float))
735 and not isinstance(engagement[field], bool)
736 ]
737 if preferred_values:
738 return max(preferred_values)
739
740 values = [
741 float(value)
742 for field, value in engagement.items()
743 if _is_counter_field(field)
744 and isinstance(value, (int, float))
745 and not isinstance(value, bool)
746 ]
747 return max(values, default=0.0)
748
749
750 def _window_days(report: Report) -> int:
751 start = datetime.fromisoformat(report.range_from).date()
752 end = datetime.fromisoformat(report.range_to).date()
753 return max(0, (end - start).days)
754
755
756 def _agent_generated_at(value: str) -> str:
757 parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
758 if parsed.tzinfo is None:
759 return value
760 return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
761
762
763 def to_agent_export(
764 report: Report,
765 *,
766 corpus_in_export: bool | None = None,
767 ) -> dict[str, Any]:
768 """Serialize a report to the stable, versioned agent JSON contract.
769
770 Local corpus evidence is private by default. Callers must opt in explicitly
771 either with ``corpus_in_export=True`` or the CLI-populated report artifact.
772 """
773 if corpus_in_export is None:
774 corpus_in_export = bool(report.artifacts.get("corpus_in_export"))
775 if not corpus_in_export:
776 report = without_sources(report, {"corpus"})
777 candidates = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
778 cluster_by_candidate: dict[str, int] = {}
779 cluster_by_id: dict[str, int] = {}
780 exported_clusters: list[dict[str, Any]] = []
781
782 for index, cluster in enumerate(report.clusters):
783 cluster_by_id[cluster.cluster_id] = index
784 for candidate_id in cluster.candidate_ids:
785 cluster_by_candidate.setdefault(candidate_id, index)
786 representative = next(
787 (candidates[candidate_id] for candidate_id in cluster.representative_ids if candidate_id in candidates),
788 None,
789 )
790 engagement_total = sum(
791 _headline_engagement(candidates[candidate_id])
792 for candidate_id in cluster.candidate_ids
793 if candidate_id in candidates
794 )
795 exported_clusters.append(
796 {
797 "title": cluster.title,
798 "summary": _agent_summary(representative) if representative else "",
799 "sources": list(cluster.sources),
800 "engagement_total": (
801 int(engagement_total) if engagement_total.is_integer() else engagement_total
802 ),
803 }
804 )
805
806 results: list[dict[str, Any]] = []
807 for candidate in report.ranked_candidates:
808 primary = candidate_primary_item(candidate)
809 cluster_index = cluster_by_id.get(candidate.cluster_id or "")
810 if cluster_index is None:
811 cluster_index = cluster_by_candidate.get(candidate.candidate_id)
812 results.append(
813 _drop_none(
814 {
815 "candidate_id": candidate.candidate_id,
816 "title": candidate.title,
817 "source": candidate.source,
818 "url": candidate.url,
819 "published_at": primary.published_at if primary else None,
820 "summary": _agent_summary(candidate),
821 "engagement": _agent_engagement(candidate),
822 "relevance_score": round(
823 max(0.0, min(1.0, candidate.final_score / 100.0)),
824 4,
825 ),
826 "cluster": cluster_index,
827 }
828 )
829 )
830
831 return {
832 "schema_version": AGENT_EXPORT_SCHEMA_VERSION,
833 "query": report.topic,
834 "generated_at": _agent_generated_at(report.generated_at),
835 "window_days": _window_days(report),
836 "source_status": {
837 source: outcome.state
838 for source, outcome in sorted(report.source_status.items())
839 },
840 "freshness_verdicts": [
841 _drop_none(asdict(verdict)) for verdict in report.freshness_verdicts
842 ],
843 "clusters": exported_clusters,
844 "results": results,
845 }
846
847
848 # Discovery nominations handoff bundle (leg 1 of the three-command
849 # host-judged protocol). The bundle serializes the FULL judge pool losslessly
850 # so leg 2 can recompute floor/velocity/entity-token disambiguation exactly
851 # as an in-memory run would. Bump the version on any incompatible change to
852 # the bundle shape; the handoff reader rejects other versions outright.
853 DISCOVERY_NOMINATIONS_SCHEMA_VERSION = "1.0"
854 DISCOVERY_NOMINATIONS_KIND = "discovery-nominations"
855
856 # Pending-report contract (leg 2 -> leg 3 of the host-judged protocol). Leg 2
857 # persists the floored/folded/ranked report plus the per-topic angle inputs;
858 # leg 3 rebuilds the report from it and never re-runs anything. Bump the
859 # version on any incompatible change; the handoff reader rejects others.
860 DISCOVERY_PENDING_SCHEMA_VERSION = "1.0"
861 DISCOVERY_PENDING_KIND = "discovery-pending"
862
863
864 def discovery_topic_from_dict(payload: dict[str, Any]) -> DiscoveryTopic:
865 """Parse one serialized DiscoveryTopic back (to_dict drops None fields,
866 so every optional field restores through its dataclass default)."""
867 return DiscoveryTopic(
868 rank=int(payload["rank"]),
869 name=payload["name"],
870 why_spiking=payload.get("why_spiking") or "",
871 momentum=payload.get("momentum") or "building",
872 velocity_score=float(_first_non_none(payload.get("velocity_score"), 0.0)),
873 sources=list(payload.get("sources") or []),
874 engagement_by_source={
875 str(source): dict(metrics)
876 for source, metrics in (payload.get("engagement_by_source") or {}).items()
877 if isinstance(metrics, dict)
878 },
879 command=payload.get("command") or "",
880 evidence_urls=list(payload.get("evidence_urls") or []),
881 top_comment=payload.get("top_comment"),
882 corroboration_count=int(payload.get("corroboration_count") or 0),
883 podcast_angle=payload.get("podcast_angle"),
884 x_article_angle=payload.get("x_article_angle"),
885 previously_surfaced_count=int(payload.get("previously_surfaced_count") or 0),
886 last_surfaced=payload.get("last_surfaced"),
887 covered=bool(payload.get("covered")),
888 )
889
890
891 def discovery_report_from_dict(payload: dict[str, Any]) -> DiscoveryReport:
892 """Rebuild a DiscoveryReport from its ``to_dict`` form (the pending-report
893 round trip the finalize leg performs; mirrors ``report_from_dict``)."""
894 plan = payload.get("plan") or {}
895 return DiscoveryReport(
896 domain=payload.get("domain") or "",
897 range_from=payload["range_from"],
898 range_to=payload["range_to"],
899 generated_at=payload["generated_at"],
900 plan=DiscoveryPlan(
901 domain=plan.get("domain") or "",
902 category=plan.get("category"),
903 subreddits=list(plan.get("subreddits") or []),
904 sources=list(plan.get("sources") or []),
905 ),
906 topics=[
907 discovery_topic_from_dict(topic)
908 for topic in payload.get("topics") or []
909 ],
910 source_status=_source_status_from_dict(payload),
911 warnings=list(payload.get("warnings") or []),
912 outcome=payload.get("outcome") or "ok",
913 weak_signal=payload.get("weak_signal"),
914 )
915
916
917 def nomination_to_dict(nomination: Any) -> dict[str, Any]:
918 """Serialize a nominate-stage Nomination to a plain dict.
919
920 Duck-typed on the Nomination fields (name, seed_score, items, summary,
921 junk_shape, worthiness) because the dataclass lives in ``pipeline``,
922 which this module must not import. Seed items serialize through
923 ``to_dict`` so the full evidence set round-trips losslessly.
924 """
925 return {
926 "name": nomination.name,
927 "seed_score": nomination.seed_score,
928 "summary": nomination.summary,
929 "junk_shape": bool(nomination.junk_shape),
930 "worthiness": nomination.worthiness,
931 "items": [to_dict(item) for item in nomination.items],
932 }
933
934
935 def nomination_kwargs_from_dict(payload: dict[str, Any]) -> dict[str, Any]:
936 """Parse a serialized nomination back to Nomination constructor kwargs.
937
938 Returns kwargs rather than an instance because the Nomination dataclass
939 lives in ``pipeline``, which this module must not import; the caller
940 (``discovery_handoff``) constructs ``pipeline.Nomination(**kwargs)``.
941 """
942 return {
943 "name": payload["name"],
944 "seed_score": float(_first_non_none(payload.get("seed_score"), 0.0)),
945 "items": [source_item_from_dict(item) for item in payload.get("items") or []],
946 "summary": payload.get("summary") or "",
947 "junk_shape": bool(payload.get("junk_shape")),
948 "worthiness": (
949 float(payload["worthiness"])
950 if payload.get("worthiness") is not None
951 else None
952 ),
953 }
954
955
956 def to_discovery_export(report: DiscoveryReport) -> dict[str, Any]:
957 """Serialize discovery output without changing the normal agent contract."""
958 start = datetime.fromisoformat(report.range_from).date()
959 end = datetime.fromisoformat(report.range_to).date()
960 return {
961 "schema_version": DISCOVERY_EXPORT_SCHEMA_VERSION,
962 "kind": "discovery",
963 "domain": report.domain,
964 "generated_at": _agent_generated_at(report.generated_at),
965 "window_days": max(0, (end - start).days),
966 "source_status": {
967 source: outcome.state
968 for source, outcome in sorted(report.source_status.items())
969 },
970 "feeds": {
971 "category": report.plan.category,
972 "subreddits": list(report.plan.subreddits),
973 "sources": list(report.plan.sources),
974 },
975 "results": [
976 {
977 "rank": topic.rank,
978 "topic": topic.name,
979 "why_spiking": topic.why_spiking,
980 "momentum": topic.momentum,
981 "velocity_score": topic.velocity_score,
982 "sources": list(topic.sources),
983 "engagement": topic.engagement_by_source,
984 "command": topic.command,
985 "evidence_urls": list(topic.evidence_urls),
986 "top_comment": topic.top_comment,
987 "corroboration_count": topic.corroboration_count,
988 "podcast_angle": topic.podcast_angle,
989 "x_article_angle": topic.x_article_angle,
990 "previously_surfaced_count": topic.previously_surfaced_count,
991 "last_surfaced": topic.last_surfaced,
992 "covered": topic.covered,
993 }
994 for topic in report.topics
995 ],
996 "warnings": list(report.warnings),
997 "outcome": report.outcome,
998 "weak_signal": report.weak_signal,
999 }
1000
1000 lines PYTHON