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
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
"""File contracts for the three-command host-judged discovery protocol.
|
|
2
|
+
|
|
3
|
+
Leg 1 (``--discover --nominate-only``) writes the nominations bundle: the
|
|
4
|
+
FULL judge pool, each nomination with its complete seed item set, serialized
|
|
5
|
+
losslessly so leg 2 can recompute floor/velocity/entity-token disambiguation
|
|
6
|
+
exactly as an in-memory run would. Leg 2 (``--discover --judgments <file>``)
|
|
7
|
+
reads host judgments (names/junk/worthiness) bound to the bundle by
|
|
8
|
+
bundle_id. Leg 3 (``--discover --finalize [--angles <file>]``) applies
|
|
9
|
+
host-written content angles.
|
|
10
|
+
|
|
11
|
+
This module owns the handoff contracts - bundle writer/reader, judgments
|
|
12
|
+
reader, pending-report reader (the leg-2 output leg 3 finalizes from),
|
|
13
|
+
angles reader - plus the host-facing digest and the post-judgment
|
|
14
|
+
name-collision resolver. Readers are strict at the top level (typed
|
|
15
|
+
``HandoffContractError``, mapped to exit 2 by the CLI layer) and lenient per
|
|
16
|
+
row: a malformed or omitted row falls back to the bundle's heuristics rather
|
|
17
|
+
than failing the run.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import secrets
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Iterator, Sequence
|
|
28
|
+
|
|
29
|
+
from . import env, log, pipeline, rerank, schema
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# How long a nominations bundle stays valid. Deliberately a module constant
|
|
33
|
+
# and NOT the LAST30DAYS_REPORT_CACHE_TTL_SECONDS env knob: a user who
|
|
34
|
+
# lowered the report-cache TTL for drill freshness must not shrink the
|
|
35
|
+
# window a host has to author judgments.
|
|
36
|
+
DISCOVERY_HANDOFF_TTL_SECONDS = 3600.0
|
|
37
|
+
|
|
38
|
+
NOMINATIONS_BUNDLE_FILENAME = "discover-nominations.json"
|
|
39
|
+
PENDING_REPORT_FILENAME = "discover-pending.json"
|
|
40
|
+
|
|
41
|
+
_VALID_TIERS = ("deep", "shallow")
|
|
42
|
+
|
|
43
|
+
_RESWEEP_REMEDY = "Run a fresh `--discover --nominate-only` re-sweep."
|
|
44
|
+
|
|
45
|
+
# Leg-3 remedy: the pending report is leg-2 output, so the first fix is to
|
|
46
|
+
# re-run the resume leg; only when the bundle itself has also gone stale does
|
|
47
|
+
# the whole protocol restart.
|
|
48
|
+
_RESUME_REMEDY = (
|
|
49
|
+
"Re-run the resume leg (`--discover --judgments <file>`), or the full "
|
|
50
|
+
"protocol from `--discover --nominate-only` if the bundle is stale too."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Defensive caps on host-supplied text, ported from the retired engine-judge
|
|
54
|
+
# pass: names become search queries and the /last30days handoff, angles
|
|
55
|
+
# render verbatim on trend cards, so a runaway (or adversarial) value never
|
|
56
|
+
# yields an unbounded string.
|
|
57
|
+
_NAME_MAX_CHARS = 96
|
|
58
|
+
_ANGLE_MAX_CHARS = 200
|
|
59
|
+
|
|
60
|
+
# Unified trailing-punctuation charset for word-boundary truncation: names
|
|
61
|
+
# and angle sentences share it so the strip sets cannot drift.
|
|
62
|
+
_TRUNCATE_STRIP_CHARS = " \"'`.,;:!?-"
|
|
63
|
+
|
|
64
|
+
# Digest evidence caps: the surface the engine judge used to see per
|
|
65
|
+
# nomination (leader title, leader snippet, strongest community comment).
|
|
66
|
+
_DIGEST_TITLE_MAX_CHARS = 220
|
|
67
|
+
_DIGEST_SNIPPET_MAX_CHARS = 420
|
|
68
|
+
_DIGEST_COMMENT_MAX_CHARS = 340
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HandoffContractError(Exception):
|
|
72
|
+
"""A handoff file failed its contract: unreadable, invalid JSON, wrong
|
|
73
|
+
shape or schema version, stale, or not bound to the current bundle.
|
|
74
|
+
The CLI layer maps this to exit code 2."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, message: str) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.message = message
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class PoolEntry:
|
|
83
|
+
"""One judge-pool nomination as handed to the bundle writer (leg 1).
|
|
84
|
+
|
|
85
|
+
``heuristic_name`` and ``heuristic_junk`` are the deterministic
|
|
86
|
+
topic_shape fallbacks, kept alongside the nomination so leg 2 can fill
|
|
87
|
+
any row the host omitted without re-deriving them.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
nomination: pipeline.Nomination
|
|
91
|
+
cluster_id: str
|
|
92
|
+
heuristic_name: str
|
|
93
|
+
heuristic_junk: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class BundleNomination:
|
|
98
|
+
"""One nomination read back from a bundle, with its stable id."""
|
|
99
|
+
|
|
100
|
+
nomination_id: str
|
|
101
|
+
nomination: pipeline.Nomination
|
|
102
|
+
cluster_id: str
|
|
103
|
+
heuristic_name: str
|
|
104
|
+
heuristic_junk: bool
|
|
105
|
+
sources: list[str]
|
|
106
|
+
engagement_by_source: dict[str, dict[str, float | int]] = field(
|
|
107
|
+
default_factory=dict
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class NominationsBundle:
|
|
113
|
+
"""A parsed leg-1 nominations bundle (also returned by the writer).
|
|
114
|
+
|
|
115
|
+
``source_status`` is the leg-1 sweep's finalized per-source outcome map:
|
|
116
|
+
legs 2 and 3 restore it so degraded sweep coverage survives the protocol
|
|
117
|
+
instead of silently reading as clean. ``mock`` is the writing run's
|
|
118
|
+
provenance - mock-born state must never be finalized by a real run (and
|
|
119
|
+
vice versa); files written before either field existed read as an empty
|
|
120
|
+
map and a real run. ``warnings`` retains informational sweep coverage
|
|
121
|
+
separately from source outcomes; older files read with no warnings."""
|
|
122
|
+
|
|
123
|
+
schema_version: str
|
|
124
|
+
bundle_id: str
|
|
125
|
+
generated_at: str
|
|
126
|
+
from_date: str
|
|
127
|
+
to_date: str
|
|
128
|
+
domain: str
|
|
129
|
+
tier: str
|
|
130
|
+
enrichment_source_boundary: list[str] | None
|
|
131
|
+
requested_sources: list[str] | None
|
|
132
|
+
lookback_days: int
|
|
133
|
+
nominations: list[BundleNomination]
|
|
134
|
+
source_status: dict[str, schema.SourceOutcome] = field(default_factory=dict)
|
|
135
|
+
mock: bool = False
|
|
136
|
+
path: Path | None = None
|
|
137
|
+
warnings: list[str] = field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class HostJudgment:
|
|
142
|
+
"""One host verdict row. ``None`` on any field means the host left it
|
|
143
|
+
absent for that row and the caller falls back to the bundle's heuristic
|
|
144
|
+
value (name/junk) or to no worthiness signal."""
|
|
145
|
+
|
|
146
|
+
name: str | None
|
|
147
|
+
junk: bool | None
|
|
148
|
+
worthiness: int | None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# The per-row-absent marker: what ``judgment_for`` returns for a nomination
|
|
152
|
+
# the host omitted entirely. Every field falls back to the bundle heuristics.
|
|
153
|
+
ROW_ABSENT = HostJudgment(name=None, junk=None, worthiness=None)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class HostAngles:
|
|
158
|
+
"""One host-written angle row; either field may be absent."""
|
|
159
|
+
|
|
160
|
+
podcast: str | None
|
|
161
|
+
x_article: str | None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class PendingReport:
|
|
166
|
+
"""A parsed leg-2 pending report: the floored/folded/ranked discovery
|
|
167
|
+
report (as its raw ``schema.to_dict`` payload - leg 3 rebuilds it via
|
|
168
|
+
``schema.discovery_report_from_dict``) plus the angle inputs keyed by
|
|
169
|
+
surviving nomination id. ``run_ref`` is the leg-2 run identity the
|
|
170
|
+
finalize leg replays into the topic queue so retries stay idempotent."""
|
|
171
|
+
|
|
172
|
+
schema_version: str
|
|
173
|
+
bundle_id: str
|
|
174
|
+
generated_at: str
|
|
175
|
+
run_ref: str
|
|
176
|
+
report: dict[str, Any]
|
|
177
|
+
angle_inputs: dict[str, dict[str, str]]
|
|
178
|
+
# Leg-2 provenance: True when a --mock resume wrote this file. Files
|
|
179
|
+
# written before the flag existed read as real (False).
|
|
180
|
+
mock: bool = False
|
|
181
|
+
path: Path | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _warn(message: str) -> None:
|
|
185
|
+
log.source_log("Discover", message, tty_only=False)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def handoff_state_dir(
|
|
189
|
+
save_dir: str | Path | None,
|
|
190
|
+
config_dir: Path | None,
|
|
191
|
+
) -> Path | None:
|
|
192
|
+
"""Resolve the handoff state directory: ``save_dir`` when provided, else
|
|
193
|
+
the config dir (mirrors the report-cache convention in last30days.py).
|
|
194
|
+
Both are accepted as arguments so this module never imports the CLI
|
|
195
|
+
layer above it. Returns None when neither location is available."""
|
|
196
|
+
if save_dir:
|
|
197
|
+
return Path(save_dir).expanduser().resolve()
|
|
198
|
+
if config_dir is not None:
|
|
199
|
+
return Path(config_dir)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def nominations_bundle_path(state_dir: str | Path) -> Path:
|
|
204
|
+
"""The nominations bundle file inside a handoff state directory."""
|
|
205
|
+
return Path(state_dir) / NOMINATIONS_BUNDLE_FILENAME
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def pending_report_path(state_dir: str | Path) -> Path:
|
|
209
|
+
"""The leg-2 pending-report file inside a handoff state directory."""
|
|
210
|
+
return Path(state_dir) / PENDING_REPORT_FILENAME
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _search_paths(
|
|
214
|
+
save_dir: str | Path | None,
|
|
215
|
+
config_dir: Path | None,
|
|
216
|
+
path_fn: Callable[[Path], Path],
|
|
217
|
+
) -> list[Path]:
|
|
218
|
+
"""Candidate handoff-file locations: ONLY the save dir when one was
|
|
219
|
+
supplied, else the config dir. An explicit save dir is the protocol's
|
|
220
|
+
single handoff store (mirroring ``_scoped_store_db`` and SKILL.md's "a
|
|
221
|
+
different or missing save dir on a later leg means the leg cannot find
|
|
222
|
+
them" contract), so a handoff file in the config dir must never silently
|
|
223
|
+
satisfy a save-dir run. ``path_fn`` picks which handoff file (bundle vs
|
|
224
|
+
pending)."""
|
|
225
|
+
if save_dir:
|
|
226
|
+
return [path_fn(Path(save_dir).expanduser().resolve())]
|
|
227
|
+
if config_dir is not None:
|
|
228
|
+
return [path_fn(Path(config_dir))]
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _searched_lines(searched: list[Path]) -> str:
|
|
233
|
+
if not searched:
|
|
234
|
+
return " (no --save-dir and no config directory available)"
|
|
235
|
+
return "\n".join(f" - {path}" for path in searched)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def write_nominations_bundle(
|
|
239
|
+
entries: Sequence[PoolEntry],
|
|
240
|
+
*,
|
|
241
|
+
domain: str,
|
|
242
|
+
tier: str,
|
|
243
|
+
from_date: str,
|
|
244
|
+
to_date: str,
|
|
245
|
+
lookback_days: int,
|
|
246
|
+
enrichment_source_boundary: list[str] | None,
|
|
247
|
+
requested_sources: list[str] | None,
|
|
248
|
+
source_status: dict[str, schema.SourceOutcome] | None = None,
|
|
249
|
+
warnings: Sequence[str] | None = None,
|
|
250
|
+
mock: bool = False,
|
|
251
|
+
save_dir: str | Path | None = None,
|
|
252
|
+
config_dir: Path | None = None,
|
|
253
|
+
) -> NominationsBundle:
|
|
254
|
+
"""Write the leg-1 nominations bundle and return its parsed form.
|
|
255
|
+
|
|
256
|
+
Nomination ids are assigned ``n1, n2, ...`` in pool order. The leg-1
|
|
257
|
+
invocation context (enrichment source boundary, requested discovery
|
|
258
|
+
sources, lookback days) rides along so leg 2 resumes with identical
|
|
259
|
+
settings. ``None`` boundaries are preserved as null - "no boundary" and
|
|
260
|
+
"empty boundary" are different contracts. ``source_status`` is the
|
|
261
|
+
sweep's finalized per-source outcome map (serialized via the same
|
|
262
|
+
``schema.to_dict`` round trip every report uses) so degraded coverage
|
|
263
|
+
survives into legs 2-3; ``warnings`` preserves informational coverage
|
|
264
|
+
receipts, and ``mock`` stamps the writing run's provenance.
|
|
265
|
+
"""
|
|
266
|
+
if tier not in _VALID_TIERS:
|
|
267
|
+
raise ValueError(f"tier must be one of {_VALID_TIERS}, got {tier!r}")
|
|
268
|
+
state_dir = handoff_state_dir(save_dir, config_dir)
|
|
269
|
+
if state_dir is None:
|
|
270
|
+
raise HandoffContractError(
|
|
271
|
+
"No handoff location available to write the nominations bundle: "
|
|
272
|
+
"pass --save-dir or configure ~/.config/last30days/."
|
|
273
|
+
)
|
|
274
|
+
bundle_id = secrets.token_hex(8)
|
|
275
|
+
generated_at = schema._utc_now()
|
|
276
|
+
|
|
277
|
+
rows: list[dict[str, Any]] = []
|
|
278
|
+
nominations: list[BundleNomination] = []
|
|
279
|
+
for index, entry in enumerate(entries, start=1):
|
|
280
|
+
nomination_id = f"n{index}"
|
|
281
|
+
sources = sorted({item.source for item in entry.nomination.items})
|
|
282
|
+
engagement = pipeline._discovery_engagement(entry.nomination.items)
|
|
283
|
+
rows.append({
|
|
284
|
+
"id": nomination_id,
|
|
285
|
+
"cluster_id": entry.cluster_id,
|
|
286
|
+
"heuristic_name": entry.heuristic_name,
|
|
287
|
+
"heuristic_junk": bool(entry.heuristic_junk),
|
|
288
|
+
"sources": sources,
|
|
289
|
+
"engagement_by_source": engagement,
|
|
290
|
+
"nomination": schema.nomination_to_dict(entry.nomination),
|
|
291
|
+
})
|
|
292
|
+
nominations.append(BundleNomination(
|
|
293
|
+
nomination_id=nomination_id,
|
|
294
|
+
nomination=entry.nomination,
|
|
295
|
+
cluster_id=entry.cluster_id,
|
|
296
|
+
heuristic_name=entry.heuristic_name,
|
|
297
|
+
heuristic_junk=bool(entry.heuristic_junk),
|
|
298
|
+
sources=sources,
|
|
299
|
+
engagement_by_source=engagement,
|
|
300
|
+
))
|
|
301
|
+
|
|
302
|
+
payload = {
|
|
303
|
+
"schema_version": schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
|
|
304
|
+
"kind": schema.DISCOVERY_NOMINATIONS_KIND,
|
|
305
|
+
"bundle_id": bundle_id,
|
|
306
|
+
"generated_at": generated_at,
|
|
307
|
+
"from_date": from_date,
|
|
308
|
+
"to_date": to_date,
|
|
309
|
+
"domain": domain,
|
|
310
|
+
"tier": tier,
|
|
311
|
+
"mock": bool(mock),
|
|
312
|
+
"warnings": list(warnings or []),
|
|
313
|
+
"source_status": {
|
|
314
|
+
source: schema.to_dict(outcome)
|
|
315
|
+
for source, outcome in (source_status or {}).items()
|
|
316
|
+
},
|
|
317
|
+
"context": {
|
|
318
|
+
"enrichment_source_boundary": (
|
|
319
|
+
list(enrichment_source_boundary)
|
|
320
|
+
if enrichment_source_boundary is not None
|
|
321
|
+
else None
|
|
322
|
+
),
|
|
323
|
+
"requested_sources": (
|
|
324
|
+
list(requested_sources) if requested_sources is not None else None
|
|
325
|
+
),
|
|
326
|
+
"lookback_days": int(lookback_days),
|
|
327
|
+
},
|
|
328
|
+
"nominations": rows,
|
|
329
|
+
}
|
|
330
|
+
path = nominations_bundle_path(state_dir)
|
|
331
|
+
try:
|
|
332
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
# A locked/read-only/full disk is the protocol's clean exit-2 path,
|
|
336
|
+
# never a traceback.
|
|
337
|
+
raise HandoffContractError(
|
|
338
|
+
f"Could not write nominations bundle {path}: {exc}"
|
|
339
|
+
) from exc
|
|
340
|
+
return NominationsBundle(
|
|
341
|
+
schema_version=schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
|
|
342
|
+
bundle_id=bundle_id,
|
|
343
|
+
generated_at=generated_at,
|
|
344
|
+
from_date=from_date,
|
|
345
|
+
to_date=to_date,
|
|
346
|
+
domain=domain,
|
|
347
|
+
tier=tier,
|
|
348
|
+
enrichment_source_boundary=(
|
|
349
|
+
list(enrichment_source_boundary)
|
|
350
|
+
if enrichment_source_boundary is not None
|
|
351
|
+
else None
|
|
352
|
+
),
|
|
353
|
+
requested_sources=(
|
|
354
|
+
list(requested_sources) if requested_sources is not None else None
|
|
355
|
+
),
|
|
356
|
+
lookback_days=int(lookback_days),
|
|
357
|
+
nominations=nominations,
|
|
358
|
+
source_status=dict(source_status or {}),
|
|
359
|
+
mock=bool(mock),
|
|
360
|
+
path=path,
|
|
361
|
+
warnings=list(warnings or []),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def read_nominations_bundle(
|
|
366
|
+
*,
|
|
367
|
+
save_dir: str | Path | None = None,
|
|
368
|
+
config_dir: Path | None = None,
|
|
369
|
+
) -> NominationsBundle:
|
|
370
|
+
"""Locate and parse the nominations bundle for legs 2 and 3.
|
|
371
|
+
|
|
372
|
+
The bundle lives in the save dir when one was supplied, else the config
|
|
373
|
+
dir - never both (no cross-store fallback). Raises HandoffContractError
|
|
374
|
+
(naming the searched location and the re-sweep remedy) when no bundle
|
|
375
|
+
exists, and for any top-level contract violation in the file found.
|
|
376
|
+
"""
|
|
377
|
+
searched = _search_paths(save_dir, config_dir, nominations_bundle_path)
|
|
378
|
+
path = next((candidate for candidate in searched if candidate.exists()), None)
|
|
379
|
+
if path is None:
|
|
380
|
+
raise HandoffContractError(
|
|
381
|
+
"No discovery nominations bundle found. Searched:\n"
|
|
382
|
+
f"{_searched_lines(searched)}\n{_RESWEEP_REMEDY}"
|
|
383
|
+
)
|
|
384
|
+
return _parse_bundle_file(path)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _parse_handoff_envelope(
|
|
388
|
+
path: Path,
|
|
389
|
+
*,
|
|
390
|
+
label: str,
|
|
391
|
+
kind: str,
|
|
392
|
+
schema_version: str,
|
|
393
|
+
remedy: str,
|
|
394
|
+
missing_id_context: str,
|
|
395
|
+
stale_context: str,
|
|
396
|
+
) -> tuple[dict[str, Any], str, Any]:
|
|
397
|
+
"""Shared strict top-level validation for the two engine-written handoff
|
|
398
|
+
files (nominations bundle, pending report): readable, valid JSON object,
|
|
399
|
+
right kind and schema version, bundle_id present, within TTL. Returns
|
|
400
|
+
(payload, bundle_id, generated_at)."""
|
|
401
|
+
try:
|
|
402
|
+
raw = path.read_text(encoding="utf-8")
|
|
403
|
+
except OSError as exc:
|
|
404
|
+
raise HandoffContractError(
|
|
405
|
+
f"Could not read {label.lower()} {path}: {exc}"
|
|
406
|
+
) from exc
|
|
407
|
+
try:
|
|
408
|
+
payload = json.loads(raw)
|
|
409
|
+
except json.JSONDecodeError as exc:
|
|
410
|
+
raise HandoffContractError(
|
|
411
|
+
f"{label} {path} is not valid JSON: {exc}"
|
|
412
|
+
) from exc
|
|
413
|
+
if not isinstance(payload, dict):
|
|
414
|
+
raise HandoffContractError(
|
|
415
|
+
f"{label} {path} must be a top-level JSON object, "
|
|
416
|
+
f"got {type(payload).__name__}."
|
|
417
|
+
)
|
|
418
|
+
version = payload.get("schema_version")
|
|
419
|
+
if version != schema_version:
|
|
420
|
+
raise HandoffContractError(
|
|
421
|
+
f"{label} {path} has schema version {version!r}; this "
|
|
422
|
+
f"build reads {schema_version!r}. {remedy}"
|
|
423
|
+
)
|
|
424
|
+
file_kind = payload.get("kind")
|
|
425
|
+
if file_kind != kind:
|
|
426
|
+
raise HandoffContractError(
|
|
427
|
+
f"{label} {path} has kind {file_kind!r}; expected "
|
|
428
|
+
f"{kind!r}. {remedy}"
|
|
429
|
+
)
|
|
430
|
+
bundle_id = str(payload.get("bundle_id") or "")
|
|
431
|
+
if not bundle_id:
|
|
432
|
+
raise HandoffContractError(
|
|
433
|
+
f"{label} {path} is missing its bundle_id; "
|
|
434
|
+
f"{missing_id_context}. {remedy}"
|
|
435
|
+
)
|
|
436
|
+
generated_at = payload.get("generated_at")
|
|
437
|
+
if not env.is_timestamp_fresh(generated_at, DISCOVERY_HANDOFF_TTL_SECONDS):
|
|
438
|
+
raise HandoffContractError(
|
|
439
|
+
f"{label} {path} is stale (generated_at="
|
|
440
|
+
f"{generated_at!r}, TTL {int(DISCOVERY_HANDOFF_TTL_SECONDS)}s): "
|
|
441
|
+
f"{stale_context}. {remedy}"
|
|
442
|
+
)
|
|
443
|
+
return payload, bundle_id, generated_at
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _parse_bundle_file(path: Path) -> NominationsBundle:
|
|
447
|
+
payload, bundle_id, generated_at = _parse_handoff_envelope(
|
|
448
|
+
path,
|
|
449
|
+
label="Nominations bundle",
|
|
450
|
+
kind=schema.DISCOVERY_NOMINATIONS_KIND,
|
|
451
|
+
schema_version=schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
|
|
452
|
+
remedy=_RESWEEP_REMEDY,
|
|
453
|
+
missing_id_context="judgments cannot bind to it",
|
|
454
|
+
stale_context="the momentum window it captured has moved on",
|
|
455
|
+
)
|
|
456
|
+
version = payload.get("schema_version")
|
|
457
|
+
|
|
458
|
+
context = payload.get("context") or {}
|
|
459
|
+
boundary = context.get("enrichment_source_boundary")
|
|
460
|
+
requested = context.get("requested_sources")
|
|
461
|
+
try:
|
|
462
|
+
lookback_days = int(context.get("lookback_days") or 30)
|
|
463
|
+
except (TypeError, ValueError):
|
|
464
|
+
lookback_days = 30
|
|
465
|
+
|
|
466
|
+
rows_raw = payload.get("nominations")
|
|
467
|
+
if not isinstance(rows_raw, list):
|
|
468
|
+
raise HandoffContractError(
|
|
469
|
+
f"Nominations bundle {path} must carry a top-level "
|
|
470
|
+
f"\"nominations\" list, got {type(rows_raw).__name__}. "
|
|
471
|
+
f"{_RESWEEP_REMEDY}"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
nominations: list[BundleNomination] = []
|
|
475
|
+
for position, row in enumerate(rows_raw, start=1):
|
|
476
|
+
# Lenient per row: the bundle is engine-written, but one corrupted
|
|
477
|
+
# row must not discard the rest of the pool.
|
|
478
|
+
if not isinstance(row, dict):
|
|
479
|
+
_warn(
|
|
480
|
+
f"skipping malformed nomination row {position} in "
|
|
481
|
+
f"{path.name} (not an object)"
|
|
482
|
+
)
|
|
483
|
+
continue
|
|
484
|
+
try:
|
|
485
|
+
nomination = pipeline.Nomination(
|
|
486
|
+
**schema.nomination_kwargs_from_dict(row.get("nomination") or {})
|
|
487
|
+
)
|
|
488
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
489
|
+
_warn(
|
|
490
|
+
f"skipping unparseable nomination row {position} in "
|
|
491
|
+
f"{path.name}: {type(exc).__name__}: {exc}"
|
|
492
|
+
)
|
|
493
|
+
continue
|
|
494
|
+
engagement_raw = row.get("engagement_by_source")
|
|
495
|
+
engagement = {
|
|
496
|
+
str(source): dict(metrics)
|
|
497
|
+
for source, metrics in (
|
|
498
|
+
engagement_raw.items() if isinstance(engagement_raw, dict) else ()
|
|
499
|
+
)
|
|
500
|
+
if isinstance(metrics, dict)
|
|
501
|
+
}
|
|
502
|
+
nominations.append(BundleNomination(
|
|
503
|
+
nomination_id=str(row.get("id") or f"n{position}"),
|
|
504
|
+
nomination=nomination,
|
|
505
|
+
cluster_id=str(row.get("cluster_id") or ""),
|
|
506
|
+
heuristic_name=str(row.get("heuristic_name") or ""),
|
|
507
|
+
heuristic_junk=bool(row.get("heuristic_junk")),
|
|
508
|
+
sources=[str(source) for source in row.get("sources") or []],
|
|
509
|
+
engagement_by_source=engagement,
|
|
510
|
+
))
|
|
511
|
+
|
|
512
|
+
if not nominations:
|
|
513
|
+
# Leg 1 never writes an empty bundle (a zero-nomination sweep
|
|
514
|
+
# short-circuits with no bundle file), so an empty or all-invalid
|
|
515
|
+
# nominations array is corrupt state: fail closed, never hand the
|
|
516
|
+
# resume leg a silently empty pool.
|
|
517
|
+
raise HandoffContractError(
|
|
518
|
+
f"Nominations bundle {path} contains no readable nominations "
|
|
519
|
+
f"(leg 1 never writes an empty pool). {_RESWEEP_REMEDY}"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# Sweep status is advisory coverage context: restore it through the same
|
|
523
|
+
# deserializer every report uses, but degrade a malformed map to empty
|
|
524
|
+
# rather than discarding an otherwise-valid pool.
|
|
525
|
+
try:
|
|
526
|
+
source_status = schema._source_status_from_dict(payload)
|
|
527
|
+
except (AttributeError, KeyError, TypeError, ValueError):
|
|
528
|
+
_warn(f"ignoring malformed source_status map in {path.name}")
|
|
529
|
+
source_status = {}
|
|
530
|
+
|
|
531
|
+
warnings = payload.get("warnings", [])
|
|
532
|
+
if not isinstance(warnings, list) or any(not isinstance(warning, str) for warning in warnings):
|
|
533
|
+
_warn(f"ignoring malformed warnings list in {path.name}")
|
|
534
|
+
warnings = []
|
|
535
|
+
|
|
536
|
+
return NominationsBundle(
|
|
537
|
+
schema_version=str(version),
|
|
538
|
+
bundle_id=bundle_id,
|
|
539
|
+
generated_at=str(generated_at or ""),
|
|
540
|
+
from_date=str(payload.get("from_date") or ""),
|
|
541
|
+
to_date=str(payload.get("to_date") or ""),
|
|
542
|
+
domain=str(payload.get("domain") or ""),
|
|
543
|
+
tier=str(payload.get("tier") or "deep"),
|
|
544
|
+
enrichment_source_boundary=(
|
|
545
|
+
[str(source) for source in boundary]
|
|
546
|
+
if isinstance(boundary, list) else None
|
|
547
|
+
),
|
|
548
|
+
requested_sources=(
|
|
549
|
+
[str(source) for source in requested]
|
|
550
|
+
if isinstance(requested, list) else None
|
|
551
|
+
),
|
|
552
|
+
lookback_days=lookback_days,
|
|
553
|
+
nominations=nominations,
|
|
554
|
+
source_status=source_status,
|
|
555
|
+
mock=bool(payload.get("mock")),
|
|
556
|
+
path=path,
|
|
557
|
+
warnings=warnings,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def read_pending_report(
|
|
562
|
+
*,
|
|
563
|
+
save_dir: str | Path | None = None,
|
|
564
|
+
config_dir: Path | None = None,
|
|
565
|
+
) -> PendingReport:
|
|
566
|
+
"""Locate and parse the leg-2 pending report for the finalize leg.
|
|
567
|
+
|
|
568
|
+
Same strictness family as the bundle reader: missing file (the searched
|
|
569
|
+
location named - save dir when supplied, else config dir, never a
|
|
570
|
+
cross-store fallback), unreadable, invalid JSON, wrong kind or schema version,
|
|
571
|
+
missing bundle_id, or stale TTL all raise HandoffContractError (mapped to
|
|
572
|
+
exit 2 by the CLI layer). Staleness is measured from the PENDING report's
|
|
573
|
+
own generated_at - the leg-2 write started a fresh authoring window - and
|
|
574
|
+
the remedy is the resume leg, not a full re-sweep.
|
|
575
|
+
"""
|
|
576
|
+
searched = _search_paths(save_dir, config_dir, pending_report_path)
|
|
577
|
+
path = next((candidate for candidate in searched if candidate.exists()), None)
|
|
578
|
+
if path is None:
|
|
579
|
+
raise HandoffContractError(
|
|
580
|
+
"No pending discovery report found. Searched:\n"
|
|
581
|
+
f"{_searched_lines(searched)}\n{_RESUME_REMEDY}"
|
|
582
|
+
)
|
|
583
|
+
return _parse_pending_file(path)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _parse_pending_file(path: Path) -> PendingReport:
|
|
587
|
+
payload, bundle_id, generated_at = _parse_handoff_envelope(
|
|
588
|
+
path,
|
|
589
|
+
label="Pending discovery report",
|
|
590
|
+
kind=schema.DISCOVERY_PENDING_KIND,
|
|
591
|
+
schema_version=schema.DISCOVERY_PENDING_SCHEMA_VERSION,
|
|
592
|
+
remedy=_RESUME_REMEDY,
|
|
593
|
+
missing_id_context="angles cannot bind to it",
|
|
594
|
+
stale_context="the judged window it captured has moved on",
|
|
595
|
+
)
|
|
596
|
+
version = payload.get("schema_version")
|
|
597
|
+
report = payload.get("report")
|
|
598
|
+
if not isinstance(report, dict):
|
|
599
|
+
raise HandoffContractError(
|
|
600
|
+
f"Pending discovery report {path} must carry a top-level "
|
|
601
|
+
f"\"report\" object. {_RESUME_REMEDY}"
|
|
602
|
+
)
|
|
603
|
+
# Lenient per row (engine-written, but one corrupt row must not discard
|
|
604
|
+
# the rest): keep only well-shaped angle-input entries.
|
|
605
|
+
angle_inputs_raw = payload.get("angle_inputs")
|
|
606
|
+
angle_inputs = {
|
|
607
|
+
str(nomination_id): {
|
|
608
|
+
str(key): str(value) for key, value in info.items()
|
|
609
|
+
}
|
|
610
|
+
for nomination_id, info in (
|
|
611
|
+
angle_inputs_raw.items() if isinstance(angle_inputs_raw, dict) else ()
|
|
612
|
+
)
|
|
613
|
+
if isinstance(info, dict)
|
|
614
|
+
}
|
|
615
|
+
return PendingReport(
|
|
616
|
+
schema_version=str(version),
|
|
617
|
+
bundle_id=bundle_id,
|
|
618
|
+
generated_at=str(generated_at or ""),
|
|
619
|
+
run_ref=str(payload.get("run_ref") or ""),
|
|
620
|
+
report=report,
|
|
621
|
+
angle_inputs=angle_inputs,
|
|
622
|
+
mock=bool(payload.get("mock")),
|
|
623
|
+
path=path,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _load_host_file(path: str | Path, label: str) -> dict[str, Any]:
|
|
628
|
+
"""Load a host-authored handoff file with strict top-level checks."""
|
|
629
|
+
file_path = Path(path).expanduser()
|
|
630
|
+
try:
|
|
631
|
+
raw = file_path.read_text(encoding="utf-8")
|
|
632
|
+
except OSError as exc:
|
|
633
|
+
raise HandoffContractError(
|
|
634
|
+
f"Could not read {label} file {file_path}: {exc}"
|
|
635
|
+
) from exc
|
|
636
|
+
try:
|
|
637
|
+
payload = json.loads(raw)
|
|
638
|
+
except json.JSONDecodeError as exc:
|
|
639
|
+
raise HandoffContractError(
|
|
640
|
+
f"{label.capitalize()} file {file_path} is not valid JSON: {exc}"
|
|
641
|
+
) from exc
|
|
642
|
+
if not isinstance(payload, dict):
|
|
643
|
+
raise HandoffContractError(
|
|
644
|
+
f"{label.capitalize()} file {file_path} must be a top-level JSON "
|
|
645
|
+
f"object, got {type(payload).__name__}."
|
|
646
|
+
)
|
|
647
|
+
return payload
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _require_bundle_binding(
|
|
651
|
+
payload: dict[str, Any],
|
|
652
|
+
bundle: NominationsBundle | PendingReport,
|
|
653
|
+
*,
|
|
654
|
+
label: str,
|
|
655
|
+
save_dir: str | Path | None,
|
|
656
|
+
config_dir: Path | None,
|
|
657
|
+
) -> None:
|
|
658
|
+
"""Enforce bundle-id binding between a host file and the current bundle
|
|
659
|
+
(or, on the finalize leg, the pending report that inherited its id).
|
|
660
|
+
The mismatch message names the file actually validated against - the
|
|
661
|
+
pending report on the finalize leg - so a host's retry is not misdirected
|
|
662
|
+
at the nominations bundle. A mismatch means the host echoed the wrong id
|
|
663
|
+
into an otherwise-current file, so the remedy is the cheap one - correct
|
|
664
|
+
the bundle_id field and re-run this same leg - never the expensive
|
|
665
|
+
re-sweep/resume remedies (those belong to missing/stale state)."""
|
|
666
|
+
file_bundle_id = str(payload.get("bundle_id") or "")
|
|
667
|
+
if file_bundle_id == bundle.bundle_id:
|
|
668
|
+
return
|
|
669
|
+
if isinstance(bundle, PendingReport):
|
|
670
|
+
searched = _search_paths(save_dir, config_dir, pending_report_path)
|
|
671
|
+
noun = "current pending discovery report"
|
|
672
|
+
location_label = "Pending-report locations searched"
|
|
673
|
+
else:
|
|
674
|
+
searched = _search_paths(save_dir, config_dir, nominations_bundle_path)
|
|
675
|
+
noun = "current nominations bundle"
|
|
676
|
+
location_label = "Bundle locations searched"
|
|
677
|
+
if not searched and bundle.path is not None:
|
|
678
|
+
searched = [bundle.path]
|
|
679
|
+
raise HandoffContractError(
|
|
680
|
+
f"The {label} file is bound to bundle_id {file_bundle_id!r} but the "
|
|
681
|
+
f"{noun} is {bundle.bundle_id!r}. {location_label}:\n"
|
|
682
|
+
f"{_searched_lines(searched)}\n"
|
|
683
|
+
f"Correct the bundle_id field in your {label} file to "
|
|
684
|
+
f"{bundle.bundle_id!r} and re-run this same leg."
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _truncate_at_word(text: str, max_chars: int) -> str:
|
|
689
|
+
"""Cap ``text`` at ``max_chars``, cutting back to a word boundary and
|
|
690
|
+
stripping trailing punctuation. Text within the cap passes through
|
|
691
|
+
untouched."""
|
|
692
|
+
if len(text) <= max_chars:
|
|
693
|
+
return text
|
|
694
|
+
return text[:max_chars].rsplit(" ", 1)[0].rstrip(_TRUNCATE_STRIP_CHARS)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _sanitized_name(raw: object) -> str | None:
|
|
698
|
+
"""One whitespace-collapsed, punctuation-stripped, length-capped topic
|
|
699
|
+
name, or None for anything unusable (non-strings, and names that
|
|
700
|
+
sanitize to empty - e.g. emoji-only - count as per-row-absent)."""
|
|
701
|
+
if not isinstance(raw, str):
|
|
702
|
+
return None
|
|
703
|
+
name = " ".join(raw.split()).strip(_TRUNCATE_STRIP_CHARS)
|
|
704
|
+
name = _truncate_at_word(name, _NAME_MAX_CHARS)
|
|
705
|
+
if not any(char.isalnum() for char in name):
|
|
706
|
+
return None
|
|
707
|
+
return name
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _sanitized_angle(raw: object) -> str | None:
|
|
711
|
+
"""One whitespace-collapsed, length-capped angle sentence, or None for
|
|
712
|
+
anything unusable. Non-strings are rejected outright, never coerced."""
|
|
713
|
+
if not isinstance(raw, str):
|
|
714
|
+
return None
|
|
715
|
+
text = _truncate_at_word(" ".join(raw.split()), _ANGLE_MAX_CHARS)
|
|
716
|
+
return text or None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _known_rows(
|
|
720
|
+
rows: list[Any],
|
|
721
|
+
known: set[str],
|
|
722
|
+
*,
|
|
723
|
+
row_label: str,
|
|
724
|
+
unknown_label: str,
|
|
725
|
+
) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
726
|
+
"""Shared lenient per-row gate for host-authored files: skip non-object
|
|
727
|
+
rows, rows with no nomination id, and rows for unknown ids - warning on
|
|
728
|
+
each - and yield (row_id, row) for the rest."""
|
|
729
|
+
for row in rows:
|
|
730
|
+
if not isinstance(row, dict):
|
|
731
|
+
_warn(f"skipping malformed {row_label} row (not an object)")
|
|
732
|
+
continue
|
|
733
|
+
row_id = str(row.get("id") or "").strip()
|
|
734
|
+
if not row_id:
|
|
735
|
+
_warn(f"skipping {row_label} row with no nomination id")
|
|
736
|
+
continue
|
|
737
|
+
if row_id not in known:
|
|
738
|
+
_warn(f"ignoring {unknown_label} for unknown nomination id {row_id!r}")
|
|
739
|
+
continue
|
|
740
|
+
yield row_id, row
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _clamped_worthiness(raw: object) -> int | None:
|
|
744
|
+
"""Worthiness clamped to 0-100 integers; anything non-numeric is absent."""
|
|
745
|
+
if isinstance(raw, bool):
|
|
746
|
+
return None
|
|
747
|
+
try:
|
|
748
|
+
value = float(raw) # type: ignore[arg-type]
|
|
749
|
+
except (TypeError, ValueError):
|
|
750
|
+
return None
|
|
751
|
+
return max(0, min(100, round(value)))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def read_judgments(
|
|
755
|
+
path: str | Path,
|
|
756
|
+
bundle: NominationsBundle,
|
|
757
|
+
*,
|
|
758
|
+
save_dir: str | Path | None = None,
|
|
759
|
+
config_dir: Path | None = None,
|
|
760
|
+
) -> dict[str, HostJudgment]:
|
|
761
|
+
"""Read the host judgments file for leg 2, keyed by nomination id.
|
|
762
|
+
|
|
763
|
+
Strict at the top level (readable, valid JSON object, ``judgments`` list,
|
|
764
|
+
bundle_id bound to ``bundle``), lenient per row: an unknown id is warned
|
|
765
|
+
and ignored, a missing/unusable name or junk field is per-row-absent, and
|
|
766
|
+
worthiness is clamped to 0-100 integers. Nominations with no row at all
|
|
767
|
+
are simply missing from the mapping - use ``judgment_for`` to get the
|
|
768
|
+
ROW_ABSENT marker for them.
|
|
769
|
+
"""
|
|
770
|
+
payload = _load_host_file(path, "judgments")
|
|
771
|
+
_require_bundle_binding(
|
|
772
|
+
payload, bundle, label="judgments", save_dir=save_dir, config_dir=config_dir,
|
|
773
|
+
)
|
|
774
|
+
rows = payload.get("judgments")
|
|
775
|
+
if not isinstance(rows, list):
|
|
776
|
+
raise HandoffContractError(
|
|
777
|
+
f"Judgments file {path} must carry a top-level \"judgments\" list."
|
|
778
|
+
)
|
|
779
|
+
known = {entry.nomination_id for entry in bundle.nominations}
|
|
780
|
+
judgments: dict[str, HostJudgment] = {}
|
|
781
|
+
for row_id, row in _known_rows(
|
|
782
|
+
rows, known, row_label="judgments", unknown_label="judgment"
|
|
783
|
+
):
|
|
784
|
+
# Only a real JSON boolean is a junk verdict: null, "false", 0, or
|
|
785
|
+
# any other non-bool value is per-row-absent (bundle heuristic),
|
|
786
|
+
# never coerced - bool("false") is True.
|
|
787
|
+
raw_junk = row.get("junk")
|
|
788
|
+
judgments[row_id] = HostJudgment(
|
|
789
|
+
name=_sanitized_name(row.get("name")),
|
|
790
|
+
junk=raw_junk if isinstance(raw_junk, bool) else None,
|
|
791
|
+
worthiness=_clamped_worthiness(row.get("worthiness")),
|
|
792
|
+
)
|
|
793
|
+
return judgments
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def judgment_for(
|
|
797
|
+
judgments: dict[str, HostJudgment],
|
|
798
|
+
nomination_id: str,
|
|
799
|
+
) -> HostJudgment:
|
|
800
|
+
"""The host's verdict for one nomination, or ROW_ABSENT when the host
|
|
801
|
+
omitted the row (caller falls back to the bundle's heuristic name/junk)."""
|
|
802
|
+
return judgments.get(nomination_id, ROW_ABSENT)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def read_angles(
|
|
806
|
+
path: str | Path | None,
|
|
807
|
+
bundle: NominationsBundle | PendingReport,
|
|
808
|
+
*,
|
|
809
|
+
save_dir: str | Path | None = None,
|
|
810
|
+
config_dir: Path | None = None,
|
|
811
|
+
) -> dict[str, HostAngles]:
|
|
812
|
+
"""Read the host angles file for leg 3, keyed by nomination id.
|
|
813
|
+
|
|
814
|
+
``bundle`` is the binding target: the finalize leg passes the pending
|
|
815
|
+
report (the bundle_id echo validates against it, and the known ids are
|
|
816
|
+
its surviving ``angle_inputs`` ids), while a NominationsBundle binds
|
|
817
|
+
against the full pool. A missing angles file is legal: ``path=None``
|
|
818
|
+
returns an empty mapping and every topic ships without angles. When a
|
|
819
|
+
path is given the same strict-top-level / lenient-per-row rules as
|
|
820
|
+
judgments apply; angle sentences are word-boundary capped at 200 chars.
|
|
821
|
+
"""
|
|
822
|
+
if path is None:
|
|
823
|
+
return {}
|
|
824
|
+
payload = _load_host_file(path, "angles")
|
|
825
|
+
_require_bundle_binding(
|
|
826
|
+
payload, bundle, label="angles", save_dir=save_dir, config_dir=config_dir,
|
|
827
|
+
)
|
|
828
|
+
rows = payload.get("angles")
|
|
829
|
+
if not isinstance(rows, list):
|
|
830
|
+
raise HandoffContractError(
|
|
831
|
+
f"Angles file {path} must carry a top-level \"angles\" list."
|
|
832
|
+
)
|
|
833
|
+
known = (
|
|
834
|
+
set(bundle.angle_inputs)
|
|
835
|
+
if isinstance(bundle, PendingReport)
|
|
836
|
+
else {entry.nomination_id for entry in bundle.nominations}
|
|
837
|
+
)
|
|
838
|
+
angles: dict[str, HostAngles] = {}
|
|
839
|
+
for row_id, row in _known_rows(
|
|
840
|
+
rows, known, row_label="angles", unknown_label="angles"
|
|
841
|
+
):
|
|
842
|
+
podcast = _sanitized_angle(row.get("podcast"))
|
|
843
|
+
x_article = _sanitized_angle(row.get("x_article"))
|
|
844
|
+
if podcast is None and x_article is None:
|
|
845
|
+
# No usable hook at all: treat the row as absent.
|
|
846
|
+
continue
|
|
847
|
+
angles[row_id] = HostAngles(podcast=podcast, x_article=x_article)
|
|
848
|
+
return angles
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def resolve_name_collisions(
|
|
852
|
+
pairs: Sequence[tuple[pipeline.Nomination, str]],
|
|
853
|
+
) -> list[str]:
|
|
854
|
+
"""Re-run the nominate-stage casefold/entity-token collision rules over
|
|
855
|
+
host-applied names, returning one collision-free name per input pair in
|
|
856
|
+
order.
|
|
857
|
+
|
|
858
|
+
Short host-judged names collide far more often than raw titles; a
|
|
859
|
+
colliding name gets the later nomination's strongest non-shared entity
|
|
860
|
+
token appended (``pipeline._disambiguated_topic_name``, fed synthetic
|
|
861
|
+
per-nomination clusters built from the seed items). Unlike the nominate
|
|
862
|
+
stage, a collision can never DROP a nomination here - the pool already
|
|
863
|
+
de-duplicated same-story clusters at leg 1 - so when no distinguishing
|
|
864
|
+
entity token exists the name falls back to an ordinal suffix.
|
|
865
|
+
"""
|
|
866
|
+
candidate_map: dict[str, schema.Candidate] = {}
|
|
867
|
+
clusters: list[schema.Cluster] = []
|
|
868
|
+
for index, (nomination, _applied) in enumerate(pairs):
|
|
869
|
+
candidate_ids: list[str] = []
|
|
870
|
+
for item_index, item in enumerate(nomination.items):
|
|
871
|
+
candidate_id = f"handoff-{index}-{item_index}"
|
|
872
|
+
candidate_map[candidate_id] = schema.Candidate(
|
|
873
|
+
candidate_id=candidate_id,
|
|
874
|
+
item_id=item.item_id,
|
|
875
|
+
source=item.source,
|
|
876
|
+
title=item.title,
|
|
877
|
+
url=item.url,
|
|
878
|
+
snippet=item.snippet,
|
|
879
|
+
subquery_labels=[],
|
|
880
|
+
native_ranks={},
|
|
881
|
+
local_relevance=0.0,
|
|
882
|
+
freshness=0,
|
|
883
|
+
engagement=None,
|
|
884
|
+
source_quality=0.0,
|
|
885
|
+
rrf_score=0.0,
|
|
886
|
+
)
|
|
887
|
+
candidate_ids.append(candidate_id)
|
|
888
|
+
clusters.append(schema.Cluster(
|
|
889
|
+
cluster_id=f"handoff-n{index}",
|
|
890
|
+
title=nomination.name,
|
|
891
|
+
candidate_ids=candidate_ids,
|
|
892
|
+
representative_ids=candidate_ids[:1],
|
|
893
|
+
sources=sorted({item.source for item in nomination.items}),
|
|
894
|
+
score=nomination.seed_score,
|
|
895
|
+
))
|
|
896
|
+
|
|
897
|
+
resolved_names: list[str] = []
|
|
898
|
+
taken: dict[str, schema.Cluster] = {}
|
|
899
|
+
entity_counts_cache: dict[str, Counter] = {}
|
|
900
|
+
for index, (_nomination, applied) in enumerate(pairs):
|
|
901
|
+
cluster = clusters[index]
|
|
902
|
+
name = applied
|
|
903
|
+
key = name.casefold()
|
|
904
|
+
if key in taken:
|
|
905
|
+
resolved = pipeline._disambiguated_topic_name(
|
|
906
|
+
name, cluster, taken[key], candidate_map, entity_counts_cache,
|
|
907
|
+
taken,
|
|
908
|
+
)
|
|
909
|
+
if resolved is None:
|
|
910
|
+
# Indistinguishable by content: keep the nomination anyway
|
|
911
|
+
# (distinct stories at leg 1) under an ordinal suffix.
|
|
912
|
+
suffix = 2
|
|
913
|
+
while f"{name} {suffix}".casefold() in taken:
|
|
914
|
+
suffix += 1
|
|
915
|
+
resolved = f"{name} {suffix}"
|
|
916
|
+
name = resolved
|
|
917
|
+
key = name.casefold()
|
|
918
|
+
taken[key] = cluster
|
|
919
|
+
resolved_names.append(name)
|
|
920
|
+
return resolved_names
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _one_line(text: str) -> str:
|
|
924
|
+
return " ".join(text.split())
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def build_host_digest(bundle: NominationsBundle) -> str:
|
|
928
|
+
"""The host-facing judging digest for a nominations bundle: plain,
|
|
929
|
+
promptable text with one structural line per nomination (id, seed source
|
|
930
|
+
names, velocity/engagement signal) plus capped evidence lines (leader
|
|
931
|
+
title, leader snippet, strongest community comment - the surface the
|
|
932
|
+
engine judge used to see). Names the bundle file and instructs the host
|
|
933
|
+
to read its full evidence before judging.
|
|
934
|
+
|
|
935
|
+
The evidence lines are scraped third-party text, so they are fenced the
|
|
936
|
+
way the deleted engine judge fenced its candidate block (the exact
|
|
937
|
+
``rerank._fenced_untrusted_content`` fence: a security-notice header
|
|
938
|
+
stating the fenced content is data, never instructions, around
|
|
939
|
+
``<untrusted_content>`` tags). The structural lines - nomination ids,
|
|
940
|
+
sources, signal, bundle path, judging instructions - stay outside the
|
|
941
|
+
fence."""
|
|
942
|
+
location = str(bundle.path) if bundle.path is not None else (
|
|
943
|
+
NOMINATIONS_BUNDLE_FILENAME
|
|
944
|
+
)
|
|
945
|
+
domain_label = bundle.domain or "global trending (no domain filter)"
|
|
946
|
+
lines = [
|
|
947
|
+
f"Discovery nominations awaiting host judgment "
|
|
948
|
+
f"({len(bundle.nominations)} topics).",
|
|
949
|
+
f"Domain: {domain_label} | window {bundle.from_date} -> "
|
|
950
|
+
f"{bundle.to_date} | tier {bundle.tier}",
|
|
951
|
+
f"Bundle file: {location} (bundle_id {bundle.bundle_id})",
|
|
952
|
+
"Read the bundle file's per-nomination evidence before judging; the "
|
|
953
|
+
"lines below are only a digest.",
|
|
954
|
+
"",
|
|
955
|
+
]
|
|
956
|
+
evidence_lines: list[str] = []
|
|
957
|
+
for entry in bundle.nominations:
|
|
958
|
+
items = entry.nomination.items
|
|
959
|
+
leader = items[0] if items else None
|
|
960
|
+
title = _one_line((leader.title if leader else "") or entry.nomination.name)
|
|
961
|
+
sources = ", ".join(entry.sources) if entry.sources else "unknown"
|
|
962
|
+
native_total = sum(
|
|
963
|
+
rerank.discovery_engagement_total(item) for item in items
|
|
964
|
+
)
|
|
965
|
+
lines.append(
|
|
966
|
+
f"{entry.nomination_id} | sources: {sources} | "
|
|
967
|
+
f"signal: seed velocity {entry.nomination.seed_score:.1f}, "
|
|
968
|
+
f"{native_total:,.0f} native interactions"
|
|
969
|
+
)
|
|
970
|
+
evidence_lines.append(f"- id: {entry.nomination_id}")
|
|
971
|
+
evidence_lines.append(f" title: {title[:_DIGEST_TITLE_MAX_CHARS]}")
|
|
972
|
+
snippet_text = _one_line(
|
|
973
|
+
(leader.snippet if leader else "") or entry.nomination.summary
|
|
974
|
+
)
|
|
975
|
+
if snippet_text:
|
|
976
|
+
evidence_lines.append(
|
|
977
|
+
f" snippet: {snippet_text[:_DIGEST_SNIPPET_MAX_CHARS]}"
|
|
978
|
+
)
|
|
979
|
+
top_comment = pipeline._best_community_comment(items)
|
|
980
|
+
if top_comment:
|
|
981
|
+
evidence_lines.append(
|
|
982
|
+
f" top comment: "
|
|
983
|
+
f"{_one_line(top_comment)[:_DIGEST_COMMENT_MAX_CHARS]}"
|
|
984
|
+
)
|
|
985
|
+
if evidence_lines:
|
|
986
|
+
lines.append("")
|
|
987
|
+
lines.append(rerank._fenced_untrusted_content("\n".join(evidence_lines)))
|
|
988
|
+
return "\n".join(lines)
|