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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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
|
+
}
|