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
|
@@ -4,11 +4,27 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import pathlib
|
|
7
|
+
import re
|
|
7
8
|
from collections import Counter
|
|
8
9
|
from datetime import date
|
|
9
10
|
from urllib.parse import urlparse
|
|
10
11
|
|
|
11
|
-
from . import
|
|
12
|
+
from . import (
|
|
13
|
+
amazon,
|
|
14
|
+
dates,
|
|
15
|
+
fusion,
|
|
16
|
+
health,
|
|
17
|
+
hiring_signals,
|
|
18
|
+
library_index,
|
|
19
|
+
meta_ads,
|
|
20
|
+
registers,
|
|
21
|
+
relevance,
|
|
22
|
+
rerank,
|
|
23
|
+
schema,
|
|
24
|
+
signals,
|
|
25
|
+
skill_meta,
|
|
26
|
+
x_envelope,
|
|
27
|
+
)
|
|
12
28
|
|
|
13
29
|
|
|
14
30
|
def _skill_version() -> str:
|
|
@@ -61,10 +77,154 @@ def _render_badge() -> list[str]:
|
|
|
61
77
|
"",
|
|
62
78
|
]
|
|
63
79
|
|
|
80
|
+
|
|
81
|
+
def _ordinal(count: int) -> str:
|
|
82
|
+
"""1 -> 1st, 2 -> 2nd, 3 -> 3rd, 11-13 -> th (Pipeline card line)."""
|
|
83
|
+
if 10 <= count % 100 <= 20:
|
|
84
|
+
suffix = "th"
|
|
85
|
+
else:
|
|
86
|
+
suffix = {1: "st", 2: "nd", 3: "rd"}.get(count % 10, "th")
|
|
87
|
+
return f"{count}{suffix}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_discovery_engagement(
|
|
91
|
+
engagement: dict[str, dict[str, float | int]],
|
|
92
|
+
) -> str:
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
for source, metrics in engagement.items():
|
|
95
|
+
metric_parts = [
|
|
96
|
+
f"{field.replace('_', ' ')} {value:,.0f}"
|
|
97
|
+
for field, value in metrics.items()
|
|
98
|
+
if value
|
|
99
|
+
]
|
|
100
|
+
if metric_parts:
|
|
101
|
+
parts.append(
|
|
102
|
+
f"{SOURCE_LABELS.get(source, source.title())}: {', '.join(metric_parts)}"
|
|
103
|
+
)
|
|
104
|
+
return " · ".join(parts) or "No native engagement counters reported"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render_discovery(report: schema.DiscoveryReport) -> str:
|
|
108
|
+
"""Render a compact topic-per-section discovery brief."""
|
|
109
|
+
title = (
|
|
110
|
+
f"# Trending discovery: {report.domain}" if report.domain else "# Trending now"
|
|
111
|
+
)
|
|
112
|
+
lines = [
|
|
113
|
+
*_render_badge(),
|
|
114
|
+
title,
|
|
115
|
+
"",
|
|
116
|
+
f"Window: {report.range_from} to {report.range_to}",
|
|
117
|
+
f"Feeds: {', '.join(report.plan.sources)}",
|
|
118
|
+
]
|
|
119
|
+
if report.plan.subreddits:
|
|
120
|
+
lines.append(
|
|
121
|
+
"Communities: " + ", ".join(f"r/{sub}" for sub in report.plan.subreddits)
|
|
122
|
+
)
|
|
123
|
+
lines.append("")
|
|
124
|
+
|
|
125
|
+
if not report.topics:
|
|
126
|
+
if report.outcome == "nothing-solid":
|
|
127
|
+
lines.extend(
|
|
128
|
+
[
|
|
129
|
+
"**Nothing solid this window.** No topic cleared the confidence "
|
|
130
|
+
"floor - not enough cross-source confirmation or engagement to "
|
|
131
|
+
"call anything a trend, and ranked noise would be worse than an "
|
|
132
|
+
"honest empty result.",
|
|
133
|
+
"",
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
if report.weak_signal:
|
|
137
|
+
lines.extend(
|
|
138
|
+
[
|
|
139
|
+
f"Closest weak signal: {report.weak_signal} (sub-floor; "
|
|
140
|
+
"single-source or too little engagement).",
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
lines.extend(["No trending topic clusters survived this sweep.", ""])
|
|
146
|
+
for topic in report.topics:
|
|
147
|
+
momentum = "New this week" if topic.momentum == "new-this-week" else "Building"
|
|
148
|
+
confirmation = (
|
|
149
|
+
f" · confirmed across {topic.corroboration_count} sources"
|
|
150
|
+
if topic.corroboration_count >= 2
|
|
151
|
+
else ""
|
|
152
|
+
)
|
|
153
|
+
lines.extend(
|
|
154
|
+
[
|
|
155
|
+
f"## {topic.rank}. {topic.name}",
|
|
156
|
+
"",
|
|
157
|
+
f"**Momentum:** {momentum} · velocity {topic.velocity_score:,.2f}{confirmation}",
|
|
158
|
+
"",
|
|
159
|
+
topic.why_spiking,
|
|
160
|
+
"",
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
if topic.top_comment:
|
|
164
|
+
lines.extend(
|
|
165
|
+
[
|
|
166
|
+
f"**Community voice:** {topic.top_comment}",
|
|
167
|
+
"",
|
|
168
|
+
]
|
|
169
|
+
)
|
|
170
|
+
if topic.podcast_angle:
|
|
171
|
+
lines.extend(
|
|
172
|
+
[
|
|
173
|
+
f"**Podcast angle:** {topic.podcast_angle}",
|
|
174
|
+
"",
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
if topic.x_article_angle:
|
|
178
|
+
lines.extend(
|
|
179
|
+
[
|
|
180
|
+
f"**X article angle:** {topic.x_article_angle}",
|
|
181
|
+
"",
|
|
182
|
+
]
|
|
183
|
+
)
|
|
184
|
+
pipeline_notes: list[str] = []
|
|
185
|
+
if topic.previously_surfaced_count > 0:
|
|
186
|
+
# previously_surfaced_count is PRIOR appearances, so this
|
|
187
|
+
# appearance is the (count + 1)-th. The queue is all-time.
|
|
188
|
+
pipeline_notes.append(
|
|
189
|
+
f"surfaced {_ordinal(topic.previously_surfaced_count + 1)} time"
|
|
190
|
+
)
|
|
191
|
+
if topic.covered:
|
|
192
|
+
# last_surfaced is the last surfacing date, not the covered date,
|
|
193
|
+
# so no date is rendered here.
|
|
194
|
+
pipeline_notes.append("marked covered")
|
|
195
|
+
if pipeline_notes:
|
|
196
|
+
lines.extend(
|
|
197
|
+
[
|
|
198
|
+
f"**Pipeline:** {', '.join(pipeline_notes)}",
|
|
199
|
+
"",
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
lines.extend(
|
|
203
|
+
[
|
|
204
|
+
f"**Evidence:** {_format_discovery_engagement(topic.engagement_by_source)}",
|
|
205
|
+
"",
|
|
206
|
+
f"**Research next:** `{topic.command}`",
|
|
207
|
+
"",
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if report.warnings:
|
|
212
|
+
lines.extend(["### Coverage notes", ""])
|
|
213
|
+
lines.extend(f"- {warning}" for warning in report.warnings)
|
|
214
|
+
lines.append("")
|
|
215
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
216
|
+
|
|
217
|
+
|
|
64
218
|
SOURCE_LABELS = {
|
|
219
|
+
"reddit": "Reddit",
|
|
220
|
+
"youtube": "YouTube",
|
|
221
|
+
"tiktok": "TikTok",
|
|
222
|
+
"instagram": "Instagram",
|
|
65
223
|
"grounding": "Web",
|
|
66
224
|
"hackernews": "Hacker News",
|
|
225
|
+
"devto": "dev.to",
|
|
67
226
|
"truthsocial": "Truth Social",
|
|
227
|
+
"linkedin": "LinkedIn",
|
|
68
228
|
"xiaohongshu": "Xiaohongshu",
|
|
69
229
|
"x": "X",
|
|
70
230
|
"github": "GitHub",
|
|
@@ -72,10 +232,16 @@ SOURCE_LABELS = {
|
|
|
72
232
|
"arxiv": "arXiv",
|
|
73
233
|
"techmeme": "Techmeme",
|
|
74
234
|
"trustpilot": "Trustpilot",
|
|
235
|
+
"amazon": "Amazon",
|
|
236
|
+
"meta_ads": "Meta Ads",
|
|
75
237
|
"perplexity": "Perplexity",
|
|
76
238
|
"jobs": "Jobs",
|
|
239
|
+
"corpus": "Your files",
|
|
77
240
|
}
|
|
78
241
|
|
|
242
|
+
PRIVATE_CORPUS_START = "<!-- LAST30DAYS_PRIVATE_CORPUS_START -->"
|
|
243
|
+
PRIVATE_CORPUS_END = "<!-- LAST30DAYS_PRIVATE_CORPUS_END -->"
|
|
244
|
+
|
|
79
245
|
|
|
80
246
|
# vote_weight = max points a fully on-topic, max-upvoted top comment can add to
|
|
81
247
|
# the LLM humor score. Tuned against real runs: typical funny comments score
|
|
@@ -109,7 +275,527 @@ def _assistant_safety_lines() -> list[str]:
|
|
|
109
275
|
]
|
|
110
276
|
|
|
111
277
|
|
|
112
|
-
def
|
|
278
|
+
def _render_drill_context(report: schema.Report) -> list[str]:
|
|
279
|
+
context = report.artifacts.get("drill_context") or {}
|
|
280
|
+
if not report.drill_of or not context:
|
|
281
|
+
return []
|
|
282
|
+
titles = context.get("cluster_titles") or [report.drill_of]
|
|
283
|
+
sources = context.get("sources") or []
|
|
284
|
+
source_text = ", ".join(_source_label(source) for source in sources) or "none"
|
|
285
|
+
original = context.get("original_summary") or "No cached summary was available."
|
|
286
|
+
return [
|
|
287
|
+
"## Drill Follow-up",
|
|
288
|
+
"",
|
|
289
|
+
f"- Target: {context.get('target') or report.drill_of}",
|
|
290
|
+
f"- Matched: {', '.join(titles)}",
|
|
291
|
+
"",
|
|
292
|
+
"### Original",
|
|
293
|
+
"",
|
|
294
|
+
str(original),
|
|
295
|
+
"",
|
|
296
|
+
"### Deeper",
|
|
297
|
+
"",
|
|
298
|
+
f"- {int(context.get('new_items') or 0)} new items after dedupe",
|
|
299
|
+
f"- Re-researched sources: {source_text}",
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _render_library_context(report: schema.Report) -> list[str]:
|
|
304
|
+
if not report.library_context:
|
|
305
|
+
return []
|
|
306
|
+
lines = [
|
|
307
|
+
library_index.LIBRARY_CONTEXT_START,
|
|
308
|
+
"## From your library",
|
|
309
|
+
"",
|
|
310
|
+
"_Prior saved runs on this topic from your local research library "
|
|
311
|
+
"(historical context, not fresh evidence; set "
|
|
312
|
+
"LAST30DAYS_LIBRARY_CONTEXT=off to hide)._",
|
|
313
|
+
"",
|
|
314
|
+
]
|
|
315
|
+
for item in report.library_context:
|
|
316
|
+
detail = _truncate(item.summary or item.headline, 220)
|
|
317
|
+
lines.append(
|
|
318
|
+
f"- You researched **{item.topic}** on {item.published_date} - "
|
|
319
|
+
f"key finding then: {detail}"
|
|
320
|
+
)
|
|
321
|
+
lines.append(library_index.LIBRARY_CONTEXT_END)
|
|
322
|
+
return lines
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def render_library_search(
|
|
326
|
+
query: str,
|
|
327
|
+
matches: list[library_index.LibrarySearchMatch],
|
|
328
|
+
) -> str:
|
|
329
|
+
"""Render dated FTS matches grouped by the topic run that produced them."""
|
|
330
|
+
if not matches:
|
|
331
|
+
return (
|
|
332
|
+
f"# Library search: {query}\n\n"
|
|
333
|
+
"No saved briefs or store sightings matched this query.\n"
|
|
334
|
+
)
|
|
335
|
+
groups: dict[tuple[str, date], list[library_index.LibrarySearchMatch]] = {}
|
|
336
|
+
for match in matches:
|
|
337
|
+
groups.setdefault(match.run_key, []).append(match)
|
|
338
|
+
lines = [
|
|
339
|
+
f"# Library search: {query}",
|
|
340
|
+
"",
|
|
341
|
+
_AI_SAFETY_NOTE,
|
|
342
|
+
"",
|
|
343
|
+
f"Found {len(matches)} match(es) across {len(groups)} topic run(s).",
|
|
344
|
+
"",
|
|
345
|
+
]
|
|
346
|
+
for (topic, published), run_matches in groups.items():
|
|
347
|
+
lines.extend([f"## {topic} - {published.isoformat()}", ""])
|
|
348
|
+
for match in run_matches:
|
|
349
|
+
label = "Saved brief" if match.source_kind == "brief" else "Store sighting"
|
|
350
|
+
engagement = ""
|
|
351
|
+
if match.engagement is not None:
|
|
352
|
+
engagement = (
|
|
353
|
+
f"; {_format_library_engagement(match.engagement)} engagement"
|
|
354
|
+
)
|
|
355
|
+
lines.append(f"- **{label}:** {match.headline}{engagement}")
|
|
356
|
+
if match.snippet and match.snippet != match.headline:
|
|
357
|
+
lines.append(f" {match.snippet}")
|
|
358
|
+
location = match.url or match.source_path
|
|
359
|
+
if location:
|
|
360
|
+
lines.append(f" Source: {location}")
|
|
361
|
+
lines.append("")
|
|
362
|
+
return "\n".join(lines).strip() + "\n"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _format_library_engagement(value: float) -> str:
|
|
366
|
+
if value >= 1_000_000:
|
|
367
|
+
return f"{value / 1_000_000:.1f}M"
|
|
368
|
+
if value >= 1_000:
|
|
369
|
+
return f"{value / 1_000:.1f}K"
|
|
370
|
+
return f"{value:g}"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _qualifying_representative_ids(
|
|
374
|
+
cluster: schema.Cluster,
|
|
375
|
+
candidate_by_id: dict[str, schema.Candidate],
|
|
376
|
+
*,
|
|
377
|
+
limit: int | None = None,
|
|
378
|
+
fallback_limit: int = 1,
|
|
379
|
+
) -> list[str]:
|
|
380
|
+
"""Keep qualifying MMR representatives, or promote a conservative fallback."""
|
|
381
|
+
representative_ids = [
|
|
382
|
+
candidate_id
|
|
383
|
+
for candidate_id in cluster.representative_ids
|
|
384
|
+
if candidate_id in candidate_by_id
|
|
385
|
+
and _best_take_relevance_ok(candidate_by_id[candidate_id])
|
|
386
|
+
]
|
|
387
|
+
if not representative_ids:
|
|
388
|
+
representative_ids = [
|
|
389
|
+
candidate_id
|
|
390
|
+
for candidate_id in cluster.candidate_ids
|
|
391
|
+
if candidate_id in candidate_by_id
|
|
392
|
+
and _best_take_relevance_ok(candidate_by_id[candidate_id])
|
|
393
|
+
][:fallback_limit]
|
|
394
|
+
return representative_ids[:limit] if limit is not None else representative_ids
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _render_ranked_clusters(
|
|
398
|
+
report: schema.Report,
|
|
399
|
+
clusters: list[schema.Cluster],
|
|
400
|
+
) -> list[str]:
|
|
401
|
+
lines = ["## Ranked Evidence Clusters", ""]
|
|
402
|
+
candidate_by_id = {
|
|
403
|
+
candidate.candidate_id: candidate for candidate in report.ranked_candidates
|
|
404
|
+
}
|
|
405
|
+
solid_clusters = _clusters_clearing_relevance_floor(report, clusters)
|
|
406
|
+
if clusters and not solid_clusters:
|
|
407
|
+
lines.extend(
|
|
408
|
+
[
|
|
409
|
+
"**Nothing solid this window.**",
|
|
410
|
+
"",
|
|
411
|
+
"No recent evidence cluster cleared the relevance floor. "
|
|
412
|
+
"Do not infer findings or quote community comments from this run.",
|
|
413
|
+
"",
|
|
414
|
+
]
|
|
415
|
+
)
|
|
416
|
+
for index, cluster in enumerate(solid_clusters, start=1):
|
|
417
|
+
lines.append(
|
|
418
|
+
f"### {index}. {_safe_title(cluster.title)} "
|
|
419
|
+
f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} "
|
|
420
|
+
f"item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
|
|
421
|
+
f"sources: {', '.join(_source_label(source) for source in cluster.sources)})"
|
|
422
|
+
)
|
|
423
|
+
if cluster.uncertainty:
|
|
424
|
+
lines.append(f"- Uncertainty: {cluster.uncertainty}")
|
|
425
|
+
representative_ids = _qualifying_representative_ids(
|
|
426
|
+
cluster,
|
|
427
|
+
candidate_by_id,
|
|
428
|
+
)
|
|
429
|
+
for rep_index, candidate_id in enumerate(representative_ids, start=1):
|
|
430
|
+
candidate = candidate_by_id.get(candidate_id)
|
|
431
|
+
if not candidate:
|
|
432
|
+
continue
|
|
433
|
+
lines.extend(
|
|
434
|
+
_render_candidate(candidate, prefix=f"{rep_index}.", report=report)
|
|
435
|
+
)
|
|
436
|
+
lines.append("")
|
|
437
|
+
return lines
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _auxiliary_candidate_pool(
|
|
441
|
+
report: schema.Report,
|
|
442
|
+
visible_clusters: list[schema.Cluster],
|
|
443
|
+
solid_clusters: list[schema.Cluster],
|
|
444
|
+
) -> list[schema.Candidate]:
|
|
445
|
+
"""Candidates for Best Takes and Top Community Comments.
|
|
446
|
+
|
|
447
|
+
Those sections are cross-cutting evidence surfaces, so they read every
|
|
448
|
+
cluster that clears the relevance floor, not only the ``cluster_limit``
|
|
449
|
+
clusters shown in ``## Ranked Evidence Clusters``: a 3,000-vote comment on
|
|
450
|
+
the thread in cluster eleven is exactly what the brief is for. The
|
|
451
|
+
nothing-solid gate is unchanged: when the visible set has clusters but
|
|
452
|
+
none clear the floor, the pool is empty.
|
|
453
|
+
"""
|
|
454
|
+
if visible_clusters and not solid_clusters:
|
|
455
|
+
return []
|
|
456
|
+
all_solid = _clusters_clearing_relevance_floor(report, report.clusters)
|
|
457
|
+
return _candidates_for_auxiliary_sections(report, report.clusters, all_solid)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _clusters_clearing_relevance_floor(
|
|
461
|
+
report: schema.Report,
|
|
462
|
+
clusters: list[schema.Cluster],
|
|
463
|
+
) -> list[schema.Cluster]:
|
|
464
|
+
"""Return visible clusters with positive, non-entity-miss evidence.
|
|
465
|
+
|
|
466
|
+
A zero-score cluster is diagnostic retrieval residue rather than evidence.
|
|
467
|
+
Likewise, a positive cluster with known members but no qualifying member
|
|
468
|
+
must not be promoted by engagement into the synthesis. Every cluster member
|
|
469
|
+
is considered because MMR representatives can omit valid evidence. Missing
|
|
470
|
+
member records are not treated as misses: score remains the only signal
|
|
471
|
+
available when no member record is present.
|
|
472
|
+
"""
|
|
473
|
+
candidate_by_id = {
|
|
474
|
+
candidate.candidate_id: candidate for candidate in report.ranked_candidates
|
|
475
|
+
}
|
|
476
|
+
solid: list[schema.Cluster] = []
|
|
477
|
+
for cluster in clusters:
|
|
478
|
+
if cluster.score <= 0:
|
|
479
|
+
continue
|
|
480
|
+
members = [
|
|
481
|
+
candidate_by_id[candidate_id]
|
|
482
|
+
for candidate_id in cluster.candidate_ids
|
|
483
|
+
if candidate_id in candidate_by_id
|
|
484
|
+
]
|
|
485
|
+
if members and not any(
|
|
486
|
+
_best_take_relevance_ok(candidate) for candidate in members
|
|
487
|
+
):
|
|
488
|
+
continue
|
|
489
|
+
solid.append(cluster)
|
|
490
|
+
return solid
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _candidates_in_clusters(
|
|
494
|
+
report: schema.Report,
|
|
495
|
+
clusters: list[schema.Cluster],
|
|
496
|
+
) -> list[schema.Candidate]:
|
|
497
|
+
"""Return ranked candidates belonging to the supplied visible clusters."""
|
|
498
|
+
candidate_ids = {
|
|
499
|
+
candidate_id for cluster in clusters for candidate_id in cluster.candidate_ids
|
|
500
|
+
}
|
|
501
|
+
return [
|
|
502
|
+
candidate
|
|
503
|
+
for candidate in report.ranked_candidates
|
|
504
|
+
if candidate.candidate_id in candidate_ids
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _candidates_for_auxiliary_sections(
|
|
509
|
+
report: schema.Report,
|
|
510
|
+
requested_clusters: list[schema.Cluster],
|
|
511
|
+
visible_clusters: list[schema.Cluster],
|
|
512
|
+
) -> list[schema.Candidate]:
|
|
513
|
+
"""Exclude rejected cluster members while preserving unclustered evidence."""
|
|
514
|
+
if requested_clusters and not visible_clusters:
|
|
515
|
+
return []
|
|
516
|
+
clustered_ids = {
|
|
517
|
+
candidate_id
|
|
518
|
+
for cluster in report.clusters
|
|
519
|
+
for candidate_id in cluster.candidate_ids
|
|
520
|
+
}
|
|
521
|
+
visible_ids = {
|
|
522
|
+
candidate_id
|
|
523
|
+
for cluster in visible_clusters
|
|
524
|
+
for candidate_id in cluster.candidate_ids
|
|
525
|
+
}
|
|
526
|
+
return [
|
|
527
|
+
candidate
|
|
528
|
+
for candidate in report.ranked_candidates
|
|
529
|
+
if candidate.candidate_id not in clustered_ids
|
|
530
|
+
or candidate.candidate_id in visible_ids
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _visible_clusters_fail_relevance_floor(
|
|
535
|
+
report: schema.Report,
|
|
536
|
+
clusters: list[schema.Cluster],
|
|
537
|
+
) -> bool:
|
|
538
|
+
"""Whether a non-empty visible cluster set contains no usable evidence."""
|
|
539
|
+
return bool(clusters) and not _clusters_clearing_relevance_floor(report, clusters)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _render_corpus_section(report: schema.Report, limit: int = 8) -> list[str]:
|
|
543
|
+
"""Render private local evidence in one removable, clearly badged block."""
|
|
544
|
+
candidates = [
|
|
545
|
+
candidate
|
|
546
|
+
for candidate in report.ranked_candidates
|
|
547
|
+
if candidate.source == "corpus"
|
|
548
|
+
][:limit]
|
|
549
|
+
if not candidates:
|
|
550
|
+
return []
|
|
551
|
+
lines = [
|
|
552
|
+
PRIVATE_CORPUS_START,
|
|
553
|
+
"## From your files",
|
|
554
|
+
"",
|
|
555
|
+
"> 🔒 **LOCAL ONLY** - excluded from hosted publishing and agent JSON unless explicitly opted in.",
|
|
556
|
+
"",
|
|
557
|
+
]
|
|
558
|
+
for candidate in candidates:
|
|
559
|
+
primary = schema.candidate_primary_item(candidate)
|
|
560
|
+
path = str((primary.metadata if primary else {}).get("relative_path") or "")
|
|
561
|
+
published = primary.published_at if primary else None
|
|
562
|
+
detail = f"modified {published}" if published else "modification date unknown"
|
|
563
|
+
# Corpus content sits inside the EVIDENCE FOR SYNTHESIS envelope, so it
|
|
564
|
+
# needs the engine-sentinel defanging every other evidence path gets
|
|
565
|
+
# (#1053) on top of the corpus-marker defanging. A local file is not
|
|
566
|
+
# automatically trustworthy input: it may have been downloaded, shared,
|
|
567
|
+
# or generated by someone else.
|
|
568
|
+
lines.append(
|
|
569
|
+
f"- **{_defang_corpus_sentinels(_safe_title(candidate.title))}** "
|
|
570
|
+
f"({detail}, relevance {candidate.final_score:.0f})"
|
|
571
|
+
)
|
|
572
|
+
if path:
|
|
573
|
+
lines.append(
|
|
574
|
+
f" - File: `{_defang_corpus_sentinels(_safe_title(path))}`"
|
|
575
|
+
)
|
|
576
|
+
if candidate.snippet:
|
|
577
|
+
lines.append(
|
|
578
|
+
" - "
|
|
579
|
+
+ _defang_corpus_sentinels(
|
|
580
|
+
_format_untrusted_evidence(
|
|
581
|
+
candidate.snippet, 300, continuation_indent=" "
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
lines.append(PRIVATE_CORPUS_END)
|
|
586
|
+
return lines
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _defang_corpus_sentinels(value: str) -> str:
|
|
590
|
+
"""Source content must not be able to terminate the private-block markers.
|
|
591
|
+
|
|
592
|
+
A note containing the literal end marker would otherwise close the block
|
|
593
|
+
early, leaving later corpus snippets in publishable output.
|
|
594
|
+
"""
|
|
595
|
+
return value.replace("LAST30DAYS_PRIVATE_CORPUS", "LAST30DAYS_PRIVATE-CORPUS")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _defang_engine_sentinels(value: str) -> str:
|
|
599
|
+
"""Source content must not be able to forge the engine's own block markers.
|
|
600
|
+
|
|
601
|
+
The EVIDENCE FOR SYNTHESIS and PASS-THROUGH FOOTER envelopes are HTML
|
|
602
|
+
comments, and LAW 5 tells the host model to emit the footer block
|
|
603
|
+
verbatim. Scraped text carrying those markers could therefore close the
|
|
604
|
+
evidence envelope early or open a footer the model relays to the user
|
|
605
|
+
unmodified. Breaking the comment delimiters is what actually neutralizes
|
|
606
|
+
them; the phrase substitutions are defense in depth for a model that
|
|
607
|
+
pattern-matches on the wording rather than the comment syntax.
|
|
608
|
+
"""
|
|
609
|
+
return (
|
|
610
|
+
value.replace("<!--", "<!- -")
|
|
611
|
+
.replace("-->", "- ->")
|
|
612
|
+
.replace("PASS-THROUGH FOOTER", "PASS-THROUGH-FOOTER")
|
|
613
|
+
.replace("EVIDENCE FOR SYNTHESIS", "EVIDENCE-FOR-SYNTHESIS")
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _safe_title(title: str) -> str:
|
|
618
|
+
"""Render a scraped title as one defanged line.
|
|
619
|
+
|
|
620
|
+
On X, TikTok, Instagram and LinkedIn the title *is* the post body
|
|
621
|
+
(``normalize.py`` takes ``text[:140]``), and normalization only strips
|
|
622
|
+
the ends, so internal newlines survive. Unlike snippets, titles are
|
|
623
|
+
interpolated straight into engine-authored structure, so an unflattened
|
|
624
|
+
one can open its own markdown blocks or forge the sentinels above.
|
|
625
|
+
Collapsing whitespace first is what keeps the title on the line the
|
|
626
|
+
caller built for it.
|
|
627
|
+
"""
|
|
628
|
+
return _defang_engine_sentinels(" ".join(title.split()))
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
_FRESHNESS_PRIORITY = {
|
|
632
|
+
"contradicted": 0,
|
|
633
|
+
"stale": 1,
|
|
634
|
+
"unsupported": 2,
|
|
635
|
+
"current": 3,
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _candidate_freshness_flag(report: schema.Report, candidate_id: str) -> str:
|
|
640
|
+
states = {
|
|
641
|
+
verdict.verdict
|
|
642
|
+
for verdict in report.freshness_verdicts
|
|
643
|
+
if verdict.candidate_id == candidate_id
|
|
644
|
+
}
|
|
645
|
+
if not states:
|
|
646
|
+
return ""
|
|
647
|
+
ordered = sorted(states, key=lambda state: _FRESHNESS_PRIORITY[state])
|
|
648
|
+
return " [freshness:" + ",".join(ordered) + "]"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _render_freshness_verdicts(report: schema.Report) -> list[str]:
|
|
652
|
+
if not report.freshness_verdicts:
|
|
653
|
+
return []
|
|
654
|
+
lines = [
|
|
655
|
+
"## Freshness Verification",
|
|
656
|
+
"",
|
|
657
|
+
"| Verdict | Claim | Evidence | Checked |",
|
|
658
|
+
"| --- | --- | --- | --- |",
|
|
659
|
+
]
|
|
660
|
+
for verdict in report.freshness_verdicts:
|
|
661
|
+
claim = verdict.claim.replace("|", "\\|")
|
|
662
|
+
if verdict.detail:
|
|
663
|
+
# The verifier's detail carries the formatted movement for stale
|
|
664
|
+
# rows and the reason a claim could not be re-checked otherwise.
|
|
665
|
+
claim += f" ({verdict.detail.replace('|', chr(92) + '|')})"
|
|
666
|
+
evidence_label = (
|
|
667
|
+
verdict.evidence_timestamp or verdict.source_timestamp or "source"
|
|
668
|
+
)
|
|
669
|
+
evidence = (
|
|
670
|
+
f"[{evidence_label}]({verdict.evidence_url})"
|
|
671
|
+
if verdict.evidence_url
|
|
672
|
+
else evidence_label
|
|
673
|
+
)
|
|
674
|
+
lines.append(
|
|
675
|
+
f"| **{verdict.verdict}** | {claim} | {evidence} | {verdict.checked_at} |"
|
|
676
|
+
)
|
|
677
|
+
return lines
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _clusters_for_register(
|
|
681
|
+
report: schema.Report,
|
|
682
|
+
audience: registers.AudienceRegister,
|
|
683
|
+
fallback_limit: int,
|
|
684
|
+
) -> list[schema.Cluster]:
|
|
685
|
+
"""Apply a preset's source emphasis without mutating pipeline rankings."""
|
|
686
|
+
|
|
687
|
+
clusters = list(report.clusters)
|
|
688
|
+
if audience.emphasis_weights:
|
|
689
|
+
clusters.sort(
|
|
690
|
+
key=lambda cluster: (
|
|
691
|
+
-cluster.score
|
|
692
|
+
* max(
|
|
693
|
+
(audience.emphasis_for(source) for source in cluster.sources),
|
|
694
|
+
default=1.0,
|
|
695
|
+
)
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
return clusters[: audience.budget_for("clusters", fallback_limit)]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _render_registered_sections(
|
|
702
|
+
report: schema.Report,
|
|
703
|
+
audience: registers.AudienceRegister,
|
|
704
|
+
fun_params: dict[str, float | int],
|
|
705
|
+
cluster_limit: int,
|
|
706
|
+
*,
|
|
707
|
+
include_source_diagnostics: bool = True,
|
|
708
|
+
) -> list[str]:
|
|
709
|
+
"""Render one audience preset's ordered, budgeted evidence sections."""
|
|
710
|
+
|
|
711
|
+
visible_clusters = _clusters_for_register(report, audience, cluster_limit)
|
|
712
|
+
solid_clusters = _clusters_clearing_relevance_floor(report, visible_clusters)
|
|
713
|
+
visible_candidates = _candidates_for_auxiliary_sections(
|
|
714
|
+
report,
|
|
715
|
+
visible_clusters,
|
|
716
|
+
solid_clusters,
|
|
717
|
+
)
|
|
718
|
+
no_solid_evidence = bool(visible_clusters) and not solid_clusters
|
|
719
|
+
aux_candidates = _auxiliary_candidate_pool(report, visible_clusters, solid_clusters)
|
|
720
|
+
if no_solid_evidence:
|
|
721
|
+
best_takes: list[str] = []
|
|
722
|
+
top_comments: list[str] = []
|
|
723
|
+
else:
|
|
724
|
+
best_takes = _render_best_takes(
|
|
725
|
+
aux_candidates,
|
|
726
|
+
limit=audience.budget_for("best_takes", int(fun_params["limit"])),
|
|
727
|
+
threshold=float(fun_params["threshold"]),
|
|
728
|
+
vote_weight=float(fun_params.get("vote_weight", 18.0)),
|
|
729
|
+
# The preset's source emphasis must reach the lead section's own
|
|
730
|
+
# ranking: a creator register surfaces TikTok/IG/YouTube takes ahead
|
|
731
|
+
# of equally-rated HN or GitHub ones.
|
|
732
|
+
source_weight=(
|
|
733
|
+
audience.emphasis_for if audience.emphasis_weights else None
|
|
734
|
+
),
|
|
735
|
+
)
|
|
736
|
+
if not best_takes:
|
|
737
|
+
best_takes = [
|
|
738
|
+
"## Best Takes",
|
|
739
|
+
"",
|
|
740
|
+
"- No qualifying takes surfaced in this run.",
|
|
741
|
+
]
|
|
742
|
+
|
|
743
|
+
top_comments = _render_top_comments(
|
|
744
|
+
report,
|
|
745
|
+
limit=audience.budget_for("top_comments", 8),
|
|
746
|
+
candidates=aux_candidates,
|
|
747
|
+
)
|
|
748
|
+
if not top_comments:
|
|
749
|
+
top_comments = [
|
|
750
|
+
"## Top Community Comments",
|
|
751
|
+
"",
|
|
752
|
+
"- No qualifying community comments surfaced in this run.",
|
|
753
|
+
]
|
|
754
|
+
|
|
755
|
+
sections = {
|
|
756
|
+
"hiring_signals": (
|
|
757
|
+
[]
|
|
758
|
+
if no_solid_evidence
|
|
759
|
+
else _render_hiring_signals(
|
|
760
|
+
report,
|
|
761
|
+
candidates=None if not visible_clusters else visible_candidates,
|
|
762
|
+
)
|
|
763
|
+
),
|
|
764
|
+
"clusters": _render_ranked_clusters(
|
|
765
|
+
report,
|
|
766
|
+
visible_clusters,
|
|
767
|
+
),
|
|
768
|
+
"stats": _render_stats(report),
|
|
769
|
+
"best_takes": best_takes,
|
|
770
|
+
"top_comments": top_comments,
|
|
771
|
+
"source_outcomes": _render_source_outcome_note(report),
|
|
772
|
+
"source_coverage": _render_source_coverage(report, include_errors=False),
|
|
773
|
+
}
|
|
774
|
+
lines: list[str] = []
|
|
775
|
+
for section_name in audience.section_order:
|
|
776
|
+
if not include_source_diagnostics and section_name in {
|
|
777
|
+
"source_outcomes",
|
|
778
|
+
"source_coverage",
|
|
779
|
+
}:
|
|
780
|
+
continue
|
|
781
|
+
block = sections[section_name]
|
|
782
|
+
if not block:
|
|
783
|
+
continue
|
|
784
|
+
if lines and lines[-1] != "":
|
|
785
|
+
lines.append("")
|
|
786
|
+
lines.extend(block)
|
|
787
|
+
return lines
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def render_compact(
|
|
791
|
+
report: schema.Report,
|
|
792
|
+
cluster_limit: int = 8,
|
|
793
|
+
fun_level: str = "medium",
|
|
794
|
+
save_path: str | None = None,
|
|
795
|
+
register: str = "default",
|
|
796
|
+
) -> str:
|
|
797
|
+
audience = registers.get_register(register)
|
|
798
|
+
evidence_report = schema.without_sources(report, {"corpus"})
|
|
113
799
|
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
|
|
114
800
|
lines = [
|
|
115
801
|
*_render_badge(),
|
|
@@ -117,21 +803,32 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
|
|
|
117
803
|
"",
|
|
118
804
|
*_assistant_safety_lines(),
|
|
119
805
|
f"- Date range: {report.range_from} to {report.range_to}",
|
|
120
|
-
f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
|
|
806
|
+
f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
|
|
807
|
+
if non_empty
|
|
808
|
+
else "- Sources: none",
|
|
121
809
|
"",
|
|
122
810
|
]
|
|
811
|
+
drill_context = _render_drill_context(report)
|
|
812
|
+
if drill_context:
|
|
813
|
+
lines.extend([*drill_context, ""])
|
|
814
|
+
library_context = _render_library_context(report)
|
|
815
|
+
if library_context:
|
|
816
|
+
lines.extend([*library_context, ""])
|
|
123
817
|
|
|
124
818
|
freshness_warning = _assess_data_freshness(report)
|
|
125
819
|
if freshness_warning:
|
|
126
|
-
lines.extend(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
820
|
+
lines.extend(
|
|
821
|
+
[
|
|
822
|
+
"## Freshness",
|
|
823
|
+
f"- {freshness_warning}",
|
|
824
|
+
"",
|
|
825
|
+
]
|
|
826
|
+
)
|
|
131
827
|
|
|
132
|
-
|
|
828
|
+
user_warnings = _warnings_without_source_failures(report.warnings)
|
|
829
|
+
if user_warnings:
|
|
133
830
|
lines.append("## Warnings")
|
|
134
|
-
lines.extend(f"- {warning}" for warning in
|
|
831
|
+
lines.extend(f"- {warning}" for warning in user_warnings)
|
|
135
832
|
lines.append("")
|
|
136
833
|
|
|
137
834
|
# LAW 7 backstop: emit the DEGRADED RUN WARNING block BEFORE the evidence
|
|
@@ -150,46 +847,84 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
|
|
|
150
847
|
# verbatim as user output. The envelope comments give the model an
|
|
151
848
|
# unambiguous scope for "pass through verbatim" (the PASS-THROUGH FOOTER
|
|
152
849
|
# block below) vs "synthesize from" (this block).
|
|
153
|
-
lines.append(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if hiring_block:
|
|
157
|
-
lines.extend(hiring_block)
|
|
158
|
-
lines.append("")
|
|
159
|
-
lines.append("## Ranked Evidence Clusters")
|
|
850
|
+
lines.append(
|
|
851
|
+
"<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into `What I learned:` prose per LAW 2. -->"
|
|
852
|
+
)
|
|
160
853
|
lines.append("")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
854
|
+
# Echo the synthesis contract early so it survives tail truncation (#726).
|
|
855
|
+
lines.extend(_render_synthesis_directive())
|
|
856
|
+
visible_clusters = evidence_report.clusters[:cluster_limit]
|
|
857
|
+
solid_clusters = _clusters_clearing_relevance_floor(
|
|
858
|
+
evidence_report,
|
|
859
|
+
visible_clusters,
|
|
860
|
+
)
|
|
861
|
+
visible_candidates = _candidates_for_auxiliary_sections(
|
|
862
|
+
evidence_report,
|
|
863
|
+
visible_clusters,
|
|
864
|
+
solid_clusters,
|
|
865
|
+
)
|
|
866
|
+
no_solid_evidence = bool(visible_clusters) and not solid_clusters
|
|
867
|
+
aux_candidates = _auxiliary_candidate_pool(
|
|
868
|
+
evidence_report, visible_clusters, solid_clusters
|
|
869
|
+
)
|
|
870
|
+
hiring_block = (
|
|
871
|
+
[]
|
|
872
|
+
if no_solid_evidence
|
|
873
|
+
else _render_hiring_signals(
|
|
874
|
+
evidence_report,
|
|
875
|
+
candidates=None if not visible_clusters else visible_candidates,
|
|
167
876
|
)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
candidate = candidate_by_id.get(candidate_id)
|
|
172
|
-
if not candidate:
|
|
173
|
-
continue
|
|
174
|
-
lines.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
|
|
877
|
+
)
|
|
878
|
+
if hiring_block and audience.name in {"default", "eli5"}:
|
|
879
|
+
lines.extend(hiring_block)
|
|
175
880
|
lines.append("")
|
|
176
|
-
|
|
177
|
-
lines.extend(_render_stats(report))
|
|
178
|
-
|
|
179
881
|
fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
882
|
+
if audience.name in {"default", "eli5"}:
|
|
883
|
+
# Keep this legacy assembly byte-for-byte stable. ELI5 has always been
|
|
884
|
+
# a synthesis-only voice change, so it intentionally takes this path.
|
|
885
|
+
lines.extend(_render_ranked_clusters(evidence_report, visible_clusters))
|
|
886
|
+
lines.extend(_render_stats(evidence_report))
|
|
887
|
+
|
|
888
|
+
if not no_solid_evidence:
|
|
889
|
+
best_takes = _render_best_takes(
|
|
890
|
+
aux_candidates,
|
|
891
|
+
limit=fun_params["limit"],
|
|
892
|
+
threshold=fun_params["threshold"],
|
|
893
|
+
vote_weight=fun_params.get("vote_weight", 18.0),
|
|
894
|
+
)
|
|
895
|
+
if best_takes:
|
|
896
|
+
lines.extend([""] + best_takes)
|
|
897
|
+
|
|
898
|
+
top_comments = _render_top_comments(
|
|
899
|
+
evidence_report,
|
|
900
|
+
candidates=aux_candidates,
|
|
901
|
+
)
|
|
902
|
+
if top_comments:
|
|
903
|
+
lines.extend([""] + top_comments)
|
|
183
904
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
905
|
+
outcome_note = _render_source_outcome_note(report)
|
|
906
|
+
if outcome_note:
|
|
907
|
+
lines.extend([""] + outcome_note)
|
|
187
908
|
|
|
188
|
-
|
|
909
|
+
lines.extend(_render_source_coverage(report, include_errors=False))
|
|
910
|
+
else:
|
|
911
|
+
lines.extend(
|
|
912
|
+
_render_registered_sections(
|
|
913
|
+
evidence_report, audience, fun_params, cluster_limit
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
corpus_section = _render_corpus_section(report)
|
|
917
|
+
if corpus_section:
|
|
918
|
+
lines.extend(["", *corpus_section])
|
|
189
919
|
# Close EVIDENCE FOR SYNTHESIS envelope before anything that passes through verbatim.
|
|
190
920
|
lines.append("")
|
|
191
921
|
lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
|
|
192
922
|
|
|
923
|
+
freshness_verdicts = _render_freshness_verdicts(report)
|
|
924
|
+
if freshness_verdicts:
|
|
925
|
+
lines.append("")
|
|
926
|
+
lines.extend(freshness_verdicts)
|
|
927
|
+
|
|
193
928
|
pre_research_warning = _render_pre_research_warning(report)
|
|
194
929
|
if pre_research_warning:
|
|
195
930
|
lines.append("")
|
|
@@ -203,7 +938,9 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
|
|
|
203
938
|
footer = _render_emoji_footer(report, save_path)
|
|
204
939
|
if footer:
|
|
205
940
|
lines.append("")
|
|
206
|
-
lines.append(
|
|
941
|
+
lines.append(
|
|
942
|
+
"<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
|
|
943
|
+
)
|
|
207
944
|
lines.extend(footer)
|
|
208
945
|
lines.append("<!-- END PASS-THROUGH FOOTER -->")
|
|
209
946
|
|
|
@@ -217,6 +954,8 @@ def render_for_html(
|
|
|
217
954
|
synthesis_md: str | None = None,
|
|
218
955
|
*,
|
|
219
956
|
save_path: str | None = None,
|
|
957
|
+
fun_level: str = "medium",
|
|
958
|
+
register: str = "default",
|
|
220
959
|
) -> str:
|
|
221
960
|
"""Render markdown intended for shareable HTML conversion.
|
|
222
961
|
|
|
@@ -226,20 +965,59 @@ def render_for_html(
|
|
|
226
965
|
model-facing safety note, and evidence scratchpad emitted by
|
|
227
966
|
render_compact().
|
|
228
967
|
|
|
229
|
-
|
|
230
|
-
metadata, optional data quality note, and
|
|
968
|
+
With the default/eli5 register and no synthesis_md, the body is
|
|
969
|
+
intentionally sparse: badge, metadata, optional data quality note, and
|
|
970
|
+
engine footer only. Other named registers render their ordered evidence
|
|
971
|
+
sections so direct HTML output reflects the selected audience preset.
|
|
231
972
|
"""
|
|
973
|
+
audience = registers.get_register(register)
|
|
974
|
+
evidence_report = schema.without_sources(report, {"corpus"})
|
|
232
975
|
lines = [
|
|
233
976
|
*_render_badge(),
|
|
234
977
|
*_render_html_metadata(report),
|
|
235
978
|
]
|
|
236
|
-
|
|
979
|
+
drill_context = _render_drill_context(report)
|
|
980
|
+
if drill_context:
|
|
981
|
+
lines.extend(["", *drill_context])
|
|
982
|
+
html_clusters = _clusters_clearing_relevance_floor(
|
|
983
|
+
evidence_report,
|
|
984
|
+
evidence_report.clusters,
|
|
985
|
+
)
|
|
986
|
+
html_candidates = _candidates_for_auxiliary_sections(
|
|
987
|
+
evidence_report,
|
|
988
|
+
evidence_report.clusters,
|
|
989
|
+
html_clusters,
|
|
990
|
+
)
|
|
991
|
+
hiring_block = _render_hiring_signals(
|
|
992
|
+
evidence_report,
|
|
993
|
+
candidates=html_candidates if evidence_report.clusters else None,
|
|
994
|
+
)
|
|
237
995
|
if synthesis_md:
|
|
238
996
|
lines.extend(["", synthesis_md.strip()])
|
|
239
997
|
if hiring_block and "## Hiring Signals" not in synthesis_md:
|
|
240
998
|
lines.extend(["", *hiring_block])
|
|
241
|
-
elif hiring_block:
|
|
999
|
+
elif hiring_block and audience.name in {"default", "eli5"}:
|
|
242
1000
|
lines.extend(["", *hiring_block])
|
|
1001
|
+
if not synthesis_md and audience.name not in {"default", "eli5"}:
|
|
1002
|
+
fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
|
|
1003
|
+
lines.extend(
|
|
1004
|
+
[
|
|
1005
|
+
"",
|
|
1006
|
+
*_render_registered_sections(
|
|
1007
|
+
evidence_report,
|
|
1008
|
+
audience,
|
|
1009
|
+
fun_params,
|
|
1010
|
+
8,
|
|
1011
|
+
include_source_diagnostics=False,
|
|
1012
|
+
),
|
|
1013
|
+
]
|
|
1014
|
+
)
|
|
1015
|
+
corpus_section = _render_corpus_section(report)
|
|
1016
|
+
if corpus_section:
|
|
1017
|
+
lines.extend(["", *corpus_section])
|
|
1018
|
+
freshness_verdicts = _render_freshness_verdicts(report)
|
|
1019
|
+
if freshness_verdicts:
|
|
1020
|
+
lines.extend(["", *freshness_verdicts])
|
|
243
1021
|
# Data quality warnings are NOT rendered into the HTML artifact. The HTML
|
|
244
1022
|
# is meant to be shared (Slack, email, Notion); recipients haven't asked
|
|
245
1023
|
# for technical commentary about how the run was produced. Generators see
|
|
@@ -275,6 +1053,13 @@ def render_for_html_comparison(
|
|
|
275
1053
|
]
|
|
276
1054
|
if synthesis_md:
|
|
277
1055
|
lines.extend(["", synthesis_md.strip()])
|
|
1056
|
+
for label, report in entity_reports:
|
|
1057
|
+
freshness_verdicts = _render_freshness_verdicts(report)
|
|
1058
|
+
if freshness_verdicts:
|
|
1059
|
+
lines.extend(["", f"## {label}", "", *freshness_verdicts])
|
|
1060
|
+
corpus_section = _render_corpus_section(report)
|
|
1061
|
+
if corpus_section:
|
|
1062
|
+
lines.extend(["", f"## {label}", "", *corpus_section])
|
|
278
1063
|
# Comparison data quality notes also go to stderr, not into the artifact.
|
|
279
1064
|
_append_html_footer(lines, main_report, save_path)
|
|
280
1065
|
return "\n".join(lines).strip() + "\n"
|
|
@@ -289,9 +1074,13 @@ def collect_html_warnings(report: schema.Report) -> list[str]:
|
|
|
289
1074
|
"""
|
|
290
1075
|
notes: list[str] = []
|
|
291
1076
|
if _render_degraded_run_warning(report):
|
|
292
|
-
notes.append(
|
|
1077
|
+
notes.append(
|
|
1078
|
+
"Run was missing pre-flight resolution. Re-run with `--plan` for richer results."
|
|
1079
|
+
)
|
|
293
1080
|
elif _render_pre_research_warning(report):
|
|
294
|
-
notes.append(
|
|
1081
|
+
notes.append(
|
|
1082
|
+
"Pre-research was skipped, so results may be thinner than a resolved run."
|
|
1083
|
+
)
|
|
295
1084
|
freshness_warning = _assess_data_freshness(report)
|
|
296
1085
|
if freshness_warning:
|
|
297
1086
|
notes.append(freshness_warning)
|
|
@@ -332,10 +1121,14 @@ def _render_html_data_quality_note(report: schema.Report) -> str | None:
|
|
|
332
1121
|
notes: list[str] = []
|
|
333
1122
|
degraded_warning = _render_degraded_run_warning(report)
|
|
334
1123
|
if degraded_warning:
|
|
335
|
-
notes.append(
|
|
1124
|
+
notes.append(
|
|
1125
|
+
"This run was missing pre-flight resolution. Re-run with `--plan` for richer results."
|
|
1126
|
+
)
|
|
336
1127
|
pre_research_warning = _render_pre_research_warning(report)
|
|
337
1128
|
if pre_research_warning and not degraded_warning:
|
|
338
|
-
notes.append(
|
|
1129
|
+
notes.append(
|
|
1130
|
+
"Pre-research was skipped, so results may be thinner than a resolved run."
|
|
1131
|
+
)
|
|
339
1132
|
freshness_warning = _assess_data_freshness(report)
|
|
340
1133
|
if freshness_warning:
|
|
341
1134
|
notes.append(freshness_warning)
|
|
@@ -371,14 +1164,48 @@ def _dedupe_notes(notes: list[str]) -> list[str]:
|
|
|
371
1164
|
return out
|
|
372
1165
|
|
|
373
1166
|
|
|
374
|
-
def _append_html_footer(
|
|
1167
|
+
def _append_html_footer(
|
|
1168
|
+
lines: list[str], report: schema.Report, save_path: str | None
|
|
1169
|
+
) -> None:
|
|
375
1170
|
footer = _render_emoji_footer(report, save_path)
|
|
376
1171
|
lines.append("")
|
|
377
|
-
lines.append(
|
|
1172
|
+
lines.append(
|
|
1173
|
+
"<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
|
|
1174
|
+
)
|
|
378
1175
|
lines.extend(footer)
|
|
379
1176
|
lines.append("<!-- END PASS-THROUGH FOOTER -->")
|
|
380
1177
|
|
|
381
1178
|
|
|
1179
|
+
def _render_synthesis_directive() -> list[str]:
|
|
1180
|
+
"""Echo the synthesis contract at the TOP of the evidence envelope.
|
|
1181
|
+
|
|
1182
|
+
Added 2026-06-30 for issue #726 (Grok Build v0.2.67 emitted only logs and
|
|
1183
|
+
raw evidence clusters instead of the canonical synthesis). Root cause: the
|
|
1184
|
+
strong directive only lived in `_render_canonical_boundary` — the very END
|
|
1185
|
+
of stdout, AFTER the whole evidence block and footer. Hosts that truncate
|
|
1186
|
+
the tail (`engine | head -N`, timeout-backgrounding that captures partial
|
|
1187
|
+
output, scrollback caps) keep the badge and the `### N.` clusters but never
|
|
1188
|
+
reach the instruction that says "synthesize, don't dump", so they fall into
|
|
1189
|
+
the LAW 6 failure mode and emit the raw evidence.
|
|
1190
|
+
|
|
1191
|
+
This block restates the contract in the head region that survives
|
|
1192
|
+
truncation. It lives INSIDE the EVIDENCE FOR SYNTHESIS envelope (a model
|
|
1193
|
+
instruction, not user output), mirroring how the DEGRADED RUN WARNING is
|
|
1194
|
+
positioned early so the pass-through contract still carries it.
|
|
1195
|
+
"""
|
|
1196
|
+
return [
|
|
1197
|
+
"> **SYNTHESIS CONTRACT — read before emitting anything.** Everything below this",
|
|
1198
|
+
"> line, up to where this evidence envelope closes, is raw evidence for you to",
|
|
1199
|
+
"> READ, not text to emit. Transform it into `What I learned:` prose paragraphs",
|
|
1200
|
+
"> per LAW 2. Do NOT pass the `### N.` evidence clusters or the stats and",
|
|
1201
|
+
"> source-coverage blocks through verbatim. The ONLY block you emit verbatim is",
|
|
1202
|
+
"> the PASS-THROUGH FOOTER (the emoji tree) lower down. The full contract repeats",
|
|
1203
|
+
"> at the end-of-output boundary near the bottom; if your captured output was",
|
|
1204
|
+
"> truncated and never reached it, this contract still binds.",
|
|
1205
|
+
"",
|
|
1206
|
+
]
|
|
1207
|
+
|
|
1208
|
+
|
|
382
1209
|
def _render_canonical_boundary() -> list[str]:
|
|
383
1210
|
"""Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
|
|
384
1211
|
|
|
@@ -409,10 +1236,9 @@ def _render_canonical_boundary() -> list[str]:
|
|
|
409
1236
|
"of synthesizing - STOP and regenerate. This is the 2026-04-19 Hermes Agent",
|
|
410
1237
|
"Use Cases failure mode (LAW 6).",
|
|
411
1238
|
"",
|
|
412
|
-
"
|
|
413
|
-
"the
|
|
414
|
-
"
|
|
415
|
-
"apply to last30days output.",
|
|
1239
|
+
"Cite web pages inline next to the claims they support (LAW 1), then end at",
|
|
1240
|
+
"the invitation, counting web pages in its link line. The emoji-tree footer",
|
|
1241
|
+
"above already summarizes the sources, so do not add a `Sources:` list.",
|
|
416
1242
|
]
|
|
417
1243
|
|
|
418
1244
|
|
|
@@ -479,7 +1305,7 @@ def _render_pre_research_warning(report: schema.Report) -> list[str]:
|
|
|
479
1305
|
f"rerun `/last30days {report.topic}`. The skill will resolve handles",
|
|
480
1306
|
"and communities before calling the engine this time, producing richer results.",
|
|
481
1307
|
"",
|
|
482
|
-
|
|
1308
|
+
'If this topic really is abstract (e.g. "AI regulation") and doesn\'t need',
|
|
483
1309
|
"handle resolution, add `--auto-resolve` to the engine command or ignore this",
|
|
484
1310
|
"warning - the current results are the keyword-search fallback.",
|
|
485
1311
|
]
|
|
@@ -517,7 +1343,7 @@ def _render_degraded_run_warning(report: schema.Report) -> list[str]:
|
|
|
517
1343
|
"<!-- USER-VISIBLE BANNER: emit verbatim before synthesis per LAW 5 / LAW 7. -->",
|
|
518
1344
|
"## DEGRADED RUN WARNING",
|
|
519
1345
|
"",
|
|
520
|
-
f
|
|
1346
|
+
f'⚠️ This run was called BARE on a named-entity topic ("{report.topic}"):',
|
|
521
1347
|
"no `--plan` JSON, no pre-flight handle resolution. The engine ran",
|
|
522
1348
|
"deterministic fallback retrieval, which is the cron/headless path -",
|
|
523
1349
|
"weaker than the path where the hosting reasoning model authors its own",
|
|
@@ -539,25 +1365,23 @@ def _render_degraded_run_warning(report: schema.Report) -> list[str]:
|
|
|
539
1365
|
"",
|
|
540
1366
|
"**If you are a user reading this:** the assistant skipped its own",
|
|
541
1367
|
"planning step. Ask it to regenerate following Step 0.55 and Step 0.75",
|
|
542
|
-
"of
|
|
1368
|
+
"of the skill's research runbook.",
|
|
543
1369
|
"<!-- END USER-VISIBLE BANNER -->",
|
|
544
1370
|
]
|
|
545
1371
|
|
|
546
1372
|
|
|
547
1373
|
def _parse_comparison_entities(topic: str) -> list[str] | None:
|
|
548
|
-
"""Return
|
|
1374
|
+
"""Return entity names if topic is a comparison query, else None.
|
|
549
1375
|
|
|
550
|
-
|
|
551
|
-
|
|
1376
|
+
Delegates to ``planner._comparison_entities`` so scaffold columns match
|
|
1377
|
+
vs-routing (including `/`, trailing-context strip, and dedup).
|
|
552
1378
|
"""
|
|
553
1379
|
if not topic:
|
|
554
1380
|
return None
|
|
555
|
-
import
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if len(
|
|
559
|
-
return None
|
|
560
|
-
return parts[:4]
|
|
1381
|
+
from . import planner
|
|
1382
|
+
|
|
1383
|
+
entities = planner._comparison_entities(topic)
|
|
1384
|
+
return entities if len(entities) >= 2 else None
|
|
561
1385
|
|
|
562
1386
|
|
|
563
1387
|
def _render_comparison_scaffold(topic: str) -> list[str]:
|
|
@@ -597,7 +1421,7 @@ def _render_comparison_scaffold(topic: str) -> list[str]:
|
|
|
597
1421
|
fill_instructions = (
|
|
598
1422
|
"Fill each cell based on the research above. Keep cells short (5-15 words). "
|
|
599
1423
|
"Use ' - ' (hyphen with spaces) not em-dashes. Write N/A for axes that do not apply to this topic class. "
|
|
600
|
-
|
|
1424
|
+
'Ground the "What it is" row in first-party positioning fetched during this run\'s research when '
|
|
601
1425
|
"available - describe each entity as it pitches itself today, never from memory. "
|
|
602
1426
|
"This scaffold matches the April 9 launch-video exemplar shape."
|
|
603
1427
|
)
|
|
@@ -611,7 +1435,7 @@ def _render_comparison_scaffold(topic: str) -> list[str]:
|
|
|
611
1435
|
separator,
|
|
612
1436
|
*body,
|
|
613
1437
|
"",
|
|
614
|
-
"After the table, write the Bottom Line section with one Choose-X-if paragraph per entity, then the emerging stack paragraph. See the comparison template in
|
|
1438
|
+
"After the table, write the Bottom Line section with one Choose-X-if paragraph per entity, then the emerging stack paragraph. See the comparison template in the skill's references/comparison.md for the full structure.",
|
|
615
1439
|
]
|
|
616
1440
|
|
|
617
1441
|
|
|
@@ -667,6 +1491,8 @@ def render_comparison_multi(
|
|
|
667
1491
|
"`What I learned:` prose per LAW 2. Each entity has its own evidence subsection. -->"
|
|
668
1492
|
)
|
|
669
1493
|
lines.append("")
|
|
1494
|
+
# Echo the synthesis contract early so it survives tail truncation (#726).
|
|
1495
|
+
lines.extend(_render_synthesis_directive())
|
|
670
1496
|
|
|
671
1497
|
resolved_block = _render_resolved_entities_block(entity_reports)
|
|
672
1498
|
if resolved_block:
|
|
@@ -675,16 +1501,23 @@ def render_comparison_multi(
|
|
|
675
1501
|
|
|
676
1502
|
fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
|
|
677
1503
|
for label, report in entity_reports:
|
|
678
|
-
lines.extend(
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1504
|
+
lines.extend(
|
|
1505
|
+
_render_entity_evidence_block(
|
|
1506
|
+
label=label,
|
|
1507
|
+
report=report,
|
|
1508
|
+
cluster_limit=cluster_limit,
|
|
1509
|
+
fun_params=fun_params,
|
|
1510
|
+
)
|
|
1511
|
+
)
|
|
684
1512
|
|
|
685
1513
|
lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
|
|
686
1514
|
lines.append("")
|
|
687
1515
|
|
|
1516
|
+
for label, report in entity_reports:
|
|
1517
|
+
freshness_verdicts = _render_freshness_verdicts(report)
|
|
1518
|
+
if freshness_verdicts:
|
|
1519
|
+
lines.extend([f"## {label}", "", *freshness_verdicts, ""])
|
|
1520
|
+
|
|
688
1521
|
# Reuse the existing comparison scaffold by feeding it the synthesized
|
|
689
1522
|
# topic. _parse_comparison_entities splits on " vs " so the scaffold
|
|
690
1523
|
# picks up all N entities automatically.
|
|
@@ -694,7 +1527,9 @@ def render_comparison_multi(
|
|
|
694
1527
|
footer = _render_emoji_footer(main_report, save_path)
|
|
695
1528
|
if footer:
|
|
696
1529
|
lines.append("")
|
|
697
|
-
lines.append(
|
|
1530
|
+
lines.append(
|
|
1531
|
+
"<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
|
|
1532
|
+
)
|
|
698
1533
|
lines.extend(footer)
|
|
699
1534
|
lines.append("<!-- END PASS-THROUGH FOOTER -->")
|
|
700
1535
|
|
|
@@ -731,15 +1566,20 @@ def _render_resolved_entities_block(
|
|
|
731
1566
|
|
|
732
1567
|
x_display = f"@{x_handle}" if x_handle else "-"
|
|
733
1568
|
subs_display = (
|
|
734
|
-
|
|
735
|
-
|
|
1569
|
+
(
|
|
1570
|
+
", ".join(f"r/{s}" for s in subs[:5])
|
|
1571
|
+
+ (f" (+{len(subs) - 5})" if len(subs) > 5 else "")
|
|
736
1572
|
)
|
|
737
|
-
|
|
1573
|
+
if subs
|
|
1574
|
+
else "-"
|
|
1575
|
+
)
|
|
738
1576
|
gh_display = f"@{gh_user}" if gh_user else "-"
|
|
739
1577
|
if gh_repos:
|
|
740
|
-
gh_display +=
|
|
741
|
-
f"
|
|
742
|
-
|
|
1578
|
+
gh_display += (
|
|
1579
|
+
f" ({', '.join(gh_repos[:3])}"
|
|
1580
|
+
+ (f" +{len(gh_repos) - 3}" if len(gh_repos) > 3 else "")
|
|
1581
|
+
+ ")"
|
|
1582
|
+
)
|
|
743
1583
|
context_display = _truncate(context, 120) if context else "-"
|
|
744
1584
|
|
|
745
1585
|
out.append(
|
|
@@ -757,34 +1597,66 @@ def _render_entity_evidence_block(
|
|
|
757
1597
|
fun_params: dict,
|
|
758
1598
|
) -> list[str]:
|
|
759
1599
|
"""Render one entity's clusters and best-takes inside the evidence envelope."""
|
|
760
|
-
|
|
1600
|
+
evidence_report = schema.without_sources(report, {"corpus"})
|
|
1601
|
+
candidate_by_id = {c.candidate_id: c for c in evidence_report.ranked_candidates}
|
|
1602
|
+
requested_clusters = evidence_report.clusters[:cluster_limit]
|
|
1603
|
+
visible_clusters = _clusters_clearing_relevance_floor(
|
|
1604
|
+
evidence_report,
|
|
1605
|
+
requested_clusters,
|
|
1606
|
+
)
|
|
761
1607
|
out: list[str] = [f"## {label}", ""]
|
|
762
1608
|
|
|
763
|
-
if not
|
|
1609
|
+
if not evidence_report.clusters:
|
|
764
1610
|
out.append("(no significant discussion this month)")
|
|
765
1611
|
out.append("")
|
|
1612
|
+
corpus_section = _render_corpus_section(report)
|
|
1613
|
+
if corpus_section:
|
|
1614
|
+
out.extend(corpus_section)
|
|
1615
|
+
out.append("")
|
|
766
1616
|
return out
|
|
767
1617
|
|
|
768
1618
|
out.append("### Ranked Evidence Clusters")
|
|
769
1619
|
out.append("")
|
|
770
|
-
|
|
1620
|
+
if requested_clusters and not visible_clusters:
|
|
1621
|
+
out.extend(
|
|
1622
|
+
[
|
|
1623
|
+
"**Nothing solid this window.**",
|
|
1624
|
+
"",
|
|
1625
|
+
"No recent evidence cluster cleared the relevance floor.",
|
|
1626
|
+
"",
|
|
1627
|
+
]
|
|
1628
|
+
)
|
|
1629
|
+
for index, cluster in enumerate(visible_clusters, start=1):
|
|
771
1630
|
out.append(
|
|
772
|
-
f"#### {index}. {cluster.title} "
|
|
1631
|
+
f"#### {index}. {_safe_title(cluster.title)} "
|
|
773
1632
|
f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item"
|
|
774
1633
|
f"{'s' if len(cluster.candidate_ids) != 1 else ''}, "
|
|
775
1634
|
f"sources: {', '.join(_source_label(s) for s in cluster.sources)})"
|
|
776
1635
|
)
|
|
777
1636
|
if cluster.uncertainty:
|
|
778
1637
|
out.append(f"- Uncertainty: {cluster.uncertainty}")
|
|
779
|
-
|
|
1638
|
+
representative_ids = _qualifying_representative_ids(
|
|
1639
|
+
cluster,
|
|
1640
|
+
candidate_by_id,
|
|
1641
|
+
)
|
|
1642
|
+
for rep_index, candidate_id in enumerate(representative_ids, start=1):
|
|
780
1643
|
candidate = candidate_by_id.get(candidate_id)
|
|
781
1644
|
if not candidate:
|
|
782
1645
|
continue
|
|
783
|
-
out.extend(
|
|
1646
|
+
out.extend(
|
|
1647
|
+
_render_candidate(
|
|
1648
|
+
candidate, prefix=f"{rep_index}.", report=evidence_report
|
|
1649
|
+
)
|
|
1650
|
+
)
|
|
784
1651
|
out.append("")
|
|
785
1652
|
|
|
1653
|
+
comparison_candidates = _candidates_for_auxiliary_sections(
|
|
1654
|
+
evidence_report,
|
|
1655
|
+
requested_clusters,
|
|
1656
|
+
visible_clusters,
|
|
1657
|
+
)
|
|
786
1658
|
best_takes = _render_best_takes(
|
|
787
|
-
|
|
1659
|
+
comparison_candidates,
|
|
788
1660
|
limit=fun_params["limit"],
|
|
789
1661
|
threshold=fun_params["threshold"],
|
|
790
1662
|
vote_weight=fun_params.get("vote_weight", 18.0),
|
|
@@ -793,6 +1665,11 @@ def _render_entity_evidence_block(
|
|
|
793
1665
|
out.extend(best_takes)
|
|
794
1666
|
out.append("")
|
|
795
1667
|
|
|
1668
|
+
corpus_section = _render_corpus_section(report)
|
|
1669
|
+
if corpus_section:
|
|
1670
|
+
out.extend(corpus_section)
|
|
1671
|
+
out.append("")
|
|
1672
|
+
|
|
796
1673
|
return out
|
|
797
1674
|
|
|
798
1675
|
|
|
@@ -815,23 +1692,121 @@ def render_comparison_multi_context(
|
|
|
815
1692
|
if resolved_block:
|
|
816
1693
|
lines.extend(resolved_block)
|
|
817
1694
|
lines.append("")
|
|
1695
|
+
# render_context surfaces warnings for a single-entity run; omitting them
|
|
1696
|
+
# here left context mode the one supported output where a dropped entity
|
|
1697
|
+
# or a failed source is invisible. Placed above the per-entity sections so
|
|
1698
|
+
# it survives tail truncation, matching render_comparison_multi.
|
|
1699
|
+
aggregated_warnings = [
|
|
1700
|
+
f"[{label}] {warning}"
|
|
1701
|
+
for label, report in entity_reports
|
|
1702
|
+
for warning in report.warnings
|
|
1703
|
+
]
|
|
1704
|
+
if aggregated_warnings:
|
|
1705
|
+
lines.append("Warnings:")
|
|
1706
|
+
lines.extend(f"- {warning}" for warning in aggregated_warnings)
|
|
1707
|
+
lines.append("")
|
|
818
1708
|
for label, report in entity_reports:
|
|
1709
|
+
evidence_report = schema.without_sources(report, {"corpus"})
|
|
1710
|
+
requested_clusters = evidence_report.clusters[:cluster_limit]
|
|
1711
|
+
visible_clusters = _clusters_clearing_relevance_floor(
|
|
1712
|
+
evidence_report,
|
|
1713
|
+
requested_clusters,
|
|
1714
|
+
)
|
|
819
1715
|
lines.append(f"## {label}")
|
|
820
1716
|
lines.append(f"Intent: {report.query_plan.intent}")
|
|
821
|
-
if not
|
|
1717
|
+
if not evidence_report.clusters:
|
|
822
1718
|
lines.append("- (no significant discussion this month)")
|
|
1719
|
+
elif not visible_clusters:
|
|
1720
|
+
lines.append("- Nothing solid this window.")
|
|
823
1721
|
else:
|
|
824
|
-
for cluster in
|
|
1722
|
+
for cluster in visible_clusters:
|
|
825
1723
|
lines.append(
|
|
826
|
-
f"- {cluster.title} "
|
|
1724
|
+
f"- {_safe_title(cluster.title)} "
|
|
827
1725
|
f"[{', '.join(_source_label(s) for s in cluster.sources)}]"
|
|
828
1726
|
)
|
|
1727
|
+
corpus_section = _render_corpus_section(report)
|
|
1728
|
+
if corpus_section:
|
|
1729
|
+
lines.extend(["", *corpus_section])
|
|
829
1730
|
lines.append("")
|
|
830
1731
|
return "\n".join(lines).strip() + "\n"
|
|
831
1732
|
|
|
832
1733
|
|
|
833
|
-
|
|
834
|
-
|
|
1734
|
+
_SAFE_MARKDOWN_LINK_SCHEMES = ("http", "https")
|
|
1735
|
+
_MARKDOWN_LINK_UNSAFE_CHARS = ("(", ")", "[", "]", "\\", "<", ">", "`")
|
|
1736
|
+
_MARKDOWN_PLAIN_TEXT_ESCAPES = re.compile(r"([\\`*_{}\[\]()#+\-.!|~:])")
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def _sanitize_url_for_single_line_output(url: str) -> str:
|
|
1740
|
+
"""Collapse embedded newlines/carriage-returns out of an untrusted URL.
|
|
1741
|
+
|
|
1742
|
+
A URL is not supposed to contain raw line breaks; a source-controlled
|
|
1743
|
+
value that does could otherwise inject fabricated report structure
|
|
1744
|
+
(fake headings, list items) into the saved single-line output --
|
|
1745
|
+
whether or not it ends up wrapped in markdown link syntax. Applied
|
|
1746
|
+
before either the link-safety check or the plain-text fallback below,
|
|
1747
|
+
so this closes the injection at the root rather than only for links.
|
|
1748
|
+
"""
|
|
1749
|
+
return "".join(
|
|
1750
|
+
" "
|
|
1751
|
+
if ch.isspace() or ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F
|
|
1752
|
+
else ch
|
|
1753
|
+
for ch in url
|
|
1754
|
+
)
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
def _escape_markdown_plain_text(value: str) -> str:
|
|
1758
|
+
"""Make untrusted text inert in Markdown without hiding its contents."""
|
|
1759
|
+
value = value.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
1760
|
+
return _MARKDOWN_PLAIN_TEXT_ESCAPES.sub(r"\\\1", value)
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def _markdown_url_link(url: str) -> str:
|
|
1764
|
+
"""Render ``url`` as a markdown link when it's safe to, else escaped text.
|
|
1765
|
+
|
|
1766
|
+
Source URLs are untrusted API responses, not authored content: `(`/`)`/
|
|
1767
|
+
`[`/`]` would corrupt markdown link syntax, a backslash can escape
|
|
1768
|
+
adjacent markdown delimiters, and an unrestricted scheme (e.g.
|
|
1769
|
+
``javascript:``) would become an active link with none of the safety
|
|
1770
|
+
filtering ``html_render.py`` already applies via
|
|
1771
|
+
``html.escape(url, quote=True)``. Falls back to escaped plain text so
|
|
1772
|
+
rejected input cannot remain active Markdown or raw HTML.
|
|
1773
|
+
"""
|
|
1774
|
+
if not url:
|
|
1775
|
+
return ""
|
|
1776
|
+
sanitized_url = _sanitize_url_for_single_line_output(url)
|
|
1777
|
+
if not sanitized_url.strip():
|
|
1778
|
+
return ""
|
|
1779
|
+
|
|
1780
|
+
has_whitespace_or_control = any(
|
|
1781
|
+
ch.isspace() or ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F
|
|
1782
|
+
for ch in url
|
|
1783
|
+
)
|
|
1784
|
+
safe_destination = False
|
|
1785
|
+
if not has_whitespace_or_control and not any(
|
|
1786
|
+
ch in sanitized_url for ch in _MARKDOWN_LINK_UNSAFE_CHARS
|
|
1787
|
+
):
|
|
1788
|
+
try:
|
|
1789
|
+
parsed = urlparse(sanitized_url)
|
|
1790
|
+
_ = parsed.port
|
|
1791
|
+
safe_destination = (
|
|
1792
|
+
parsed.scheme.lower() in _SAFE_MARKDOWN_LINK_SCHEMES
|
|
1793
|
+
and bool(parsed.netloc and parsed.hostname)
|
|
1794
|
+
)
|
|
1795
|
+
except ValueError:
|
|
1796
|
+
safe_destination = False
|
|
1797
|
+
|
|
1798
|
+
if safe_destination:
|
|
1799
|
+
return f"[{sanitized_url}]({sanitized_url})"
|
|
1800
|
+
return _escape_markdown_plain_text(sanitized_url)
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def render_full(report: schema.Report, save_path: str | None = None) -> str:
|
|
1804
|
+
"""Full data dump: ALL clusters + ALL items by source. For saved files and debugging.
|
|
1805
|
+
|
|
1806
|
+
When ``save_path`` is provided, the deterministic emoji footer is appended
|
|
1807
|
+
so the saved artifact cites the file actually written (collision fallback
|
|
1808
|
+
included), matching the stdout footer contract."""
|
|
1809
|
+
evidence_report = schema.without_sources(report, {"corpus"})
|
|
835
1810
|
# Start with the same header as compact
|
|
836
1811
|
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
|
|
837
1812
|
lines = [
|
|
@@ -839,7 +1814,9 @@ def render_full(report: schema.Report) -> str:
|
|
|
839
1814
|
"",
|
|
840
1815
|
*_assistant_safety_lines(),
|
|
841
1816
|
f"- Date range: {report.range_from} to {report.range_to}",
|
|
842
|
-
f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
|
|
1817
|
+
f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
|
|
1818
|
+
if non_empty
|
|
1819
|
+
else "- Sources: none",
|
|
843
1820
|
"",
|
|
844
1821
|
]
|
|
845
1822
|
|
|
@@ -848,6 +1825,10 @@ def render_full(report: schema.Report) -> str:
|
|
|
848
1825
|
lines.extend(f"- {warning}" for warning in report.warnings)
|
|
849
1826
|
lines.append("")
|
|
850
1827
|
|
|
1828
|
+
library_context = _render_library_context(report)
|
|
1829
|
+
if library_context:
|
|
1830
|
+
lines.extend([*library_context, ""])
|
|
1831
|
+
|
|
851
1832
|
# When this Report is a per-entity sub-run from vs-mode / --competitors,
|
|
852
1833
|
# include the single-row Resolved Entities block so the saved file is
|
|
853
1834
|
# self-describing. The artifact is populated by last30days.py's
|
|
@@ -860,27 +1841,20 @@ def render_full(report: schema.Report) -> str:
|
|
|
860
1841
|
lines.append("")
|
|
861
1842
|
|
|
862
1843
|
# ALL clusters (no limit)
|
|
863
|
-
lines.
|
|
864
|
-
lines.append("")
|
|
865
|
-
candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
|
|
866
|
-
for index, cluster in enumerate(report.clusters, start=1):
|
|
867
|
-
lines.append(
|
|
868
|
-
f"### {index}. {cluster.title} "
|
|
869
|
-
f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
|
|
870
|
-
f"sources: {', '.join(_source_label(s) for s in cluster.sources)})"
|
|
871
|
-
)
|
|
872
|
-
if cluster.uncertainty:
|
|
873
|
-
lines.append(f"- Uncertainty: {cluster.uncertainty}")
|
|
874
|
-
for rep_index, cid in enumerate(cluster.representative_ids, start=1):
|
|
875
|
-
candidate = candidate_by_id.get(cid)
|
|
876
|
-
if not candidate:
|
|
877
|
-
continue
|
|
878
|
-
lines.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
|
|
879
|
-
lines.append("")
|
|
1844
|
+
lines.extend(_render_ranked_clusters(evidence_report, evidence_report.clusters))
|
|
880
1845
|
|
|
881
1846
|
fun_params = _FUN_LEVELS["medium"]
|
|
1847
|
+
full_clusters = _clusters_clearing_relevance_floor(
|
|
1848
|
+
evidence_report,
|
|
1849
|
+
evidence_report.clusters,
|
|
1850
|
+
)
|
|
1851
|
+
full_candidates = _candidates_for_auxiliary_sections(
|
|
1852
|
+
evidence_report,
|
|
1853
|
+
evidence_report.clusters,
|
|
1854
|
+
full_clusters,
|
|
1855
|
+
)
|
|
882
1856
|
best_takes = _render_best_takes(
|
|
883
|
-
|
|
1857
|
+
full_candidates,
|
|
884
1858
|
limit=fun_params["limit"],
|
|
885
1859
|
threshold=fun_params["threshold"],
|
|
886
1860
|
vote_weight=fun_params["vote_weight"],
|
|
@@ -892,33 +1866,79 @@ def render_full(report: schema.Report) -> str:
|
|
|
892
1866
|
# ALL items by source (flat dump, v2-style)
|
|
893
1867
|
lines.append("## All Items by Source")
|
|
894
1868
|
lines.append("")
|
|
895
|
-
source_order = [
|
|
896
|
-
|
|
1869
|
+
source_order = [
|
|
1870
|
+
"reddit",
|
|
1871
|
+
"x",
|
|
1872
|
+
"youtube",
|
|
1873
|
+
"tiktok",
|
|
1874
|
+
"instagram",
|
|
1875
|
+
"threads",
|
|
1876
|
+
"pinterest",
|
|
1877
|
+
"hackernews",
|
|
1878
|
+
"devto",
|
|
1879
|
+
"bluesky",
|
|
1880
|
+
"truthsocial",
|
|
1881
|
+
"polymarket",
|
|
1882
|
+
"grounding",
|
|
1883
|
+
"xiaohongshu",
|
|
1884
|
+
"github",
|
|
1885
|
+
"digg",
|
|
1886
|
+
"perplexity",
|
|
1887
|
+
"jobs",
|
|
1888
|
+
]
|
|
1889
|
+
# The list above fixes the display order for the sources it names, but it
|
|
1890
|
+
# is not the source registry and drifts every time one is added: amazon,
|
|
1891
|
+
# arxiv, techmeme, trustpilot, linkedin, and dripstack were all silently
|
|
1892
|
+
# absent from this dump while appearing normally in the ranked section
|
|
1893
|
+
# above, so the saved artifact -- the copy users keep -- was missing
|
|
1894
|
+
# evidence the run actually collected. Append whatever else the report
|
|
1895
|
+
# carries, sorted for determinism, so a new source is visible here the
|
|
1896
|
+
# day it lands rather than the day someone notices.
|
|
1897
|
+
source_order += sorted(
|
|
1898
|
+
source for source in evidence_report.items_by_source
|
|
1899
|
+
if source not in source_order
|
|
1900
|
+
)
|
|
897
1901
|
for source in source_order:
|
|
898
|
-
items =
|
|
1902
|
+
items = evidence_report.items_by_source.get(source, [])
|
|
899
1903
|
if not items:
|
|
900
1904
|
continue
|
|
901
1905
|
lines.append(f"### {_source_label(source)} ({len(items)} items)")
|
|
902
1906
|
lines.append("")
|
|
903
1907
|
for item in items:
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1908
|
+
# local_rank_score is 0..1; print it on the report's 0-100 scale
|
|
1909
|
+
# (":.0f" of the raw value rendered every item as score:0 or 1).
|
|
1910
|
+
score = (item.local_rank_score or 0) * 100.0
|
|
1911
|
+
lines.append(
|
|
1912
|
+
f"**{item.item_id}** (score:{score:.0f}) {item.author or ''} ({item.published_at or 'date unknown'}) [{_format_item_engagement(item)}]"
|
|
1913
|
+
)
|
|
1914
|
+
lines.append(f" {_safe_title(item.title)}")
|
|
907
1915
|
if item.url:
|
|
908
|
-
|
|
1916
|
+
rendered_url = _markdown_url_link(item.url)
|
|
1917
|
+
if rendered_url:
|
|
1918
|
+
lines.append(f" {rendered_url}")
|
|
909
1919
|
if item.container:
|
|
910
1920
|
lines.append(f" *{item.container}*")
|
|
911
1921
|
if item.snippet:
|
|
912
|
-
lines.append(
|
|
1922
|
+
lines.append(
|
|
1923
|
+
f" {_format_untrusted_evidence(item.snippet, 500, continuation_indent=' ')}"
|
|
1924
|
+
)
|
|
913
1925
|
# Top comments for Reddit, YouTube, TikTok, HackerNews.
|
|
914
1926
|
top_comments = item.metadata.get("top_comments", [])
|
|
915
1927
|
if top_comments and isinstance(top_comments[0], dict):
|
|
916
1928
|
vote_label = _vote_label_for(item.source)
|
|
917
1929
|
for tc in top_comments[:3]:
|
|
918
|
-
excerpt = tc.get("excerpt", tc.get("text", ""))
|
|
1930
|
+
excerpt = tc.get("excerpt", tc.get("text", ""))
|
|
919
1931
|
tc_score = tc.get("score", "")
|
|
920
1932
|
attribution = _comment_attribution(item.source, tc.get("author"))
|
|
921
|
-
|
|
1933
|
+
vote_part = (
|
|
1934
|
+
f" ({tc_score} {vote_label})"
|
|
1935
|
+
if tc_score is not None and tc_score != ""
|
|
1936
|
+
else ""
|
|
1937
|
+
)
|
|
1938
|
+
lines.append(
|
|
1939
|
+
f" Top comment {attribution}{vote_part}: "
|
|
1940
|
+
f"{_format_untrusted_evidence(excerpt, 200, continuation_indent=' ')}"
|
|
1941
|
+
)
|
|
922
1942
|
# Digg: inline X-post quotes attached to the cluster.
|
|
923
1943
|
for post in _digg_posts_for(item, limit=3):
|
|
924
1944
|
lines.append(f" > {_format_digg_quote(post)}")
|
|
@@ -927,18 +1947,28 @@ def render_full(report: schema.Report) -> str:
|
|
|
927
1947
|
if insights:
|
|
928
1948
|
lines.append(" Insights:")
|
|
929
1949
|
for ins in insights[:3]:
|
|
930
|
-
lines.append(
|
|
1950
|
+
lines.append(
|
|
1951
|
+
f" - {_format_untrusted_evidence(ins, 200, continuation_indent=' ')}"
|
|
1952
|
+
)
|
|
931
1953
|
# Transcript highlights for YouTube
|
|
932
1954
|
highlights = item.metadata.get("transcript_highlights", [])
|
|
933
1955
|
if highlights:
|
|
934
|
-
lines.append(
|
|
1956
|
+
lines.append(
|
|
1957
|
+
" Highlights (auto-generated transcript; may contain transcription errors):"
|
|
1958
|
+
)
|
|
935
1959
|
for hl in highlights[:5]:
|
|
936
|
-
lines.append(
|
|
1960
|
+
lines.append(
|
|
1961
|
+
f' - "{_format_untrusted_evidence(hl, 200, continuation_indent=" ")}"'
|
|
1962
|
+
)
|
|
937
1963
|
# Full transcript snippet for YouTube
|
|
938
1964
|
transcript = item.metadata.get("transcript_snippet", "")
|
|
939
1965
|
if transcript and len(transcript) > 100:
|
|
940
|
-
lines.append(
|
|
941
|
-
|
|
1966
|
+
lines.append(
|
|
1967
|
+
f" <details><summary>Transcript ({len(transcript.split())} words; auto-generated — may contain transcription errors)</summary>"
|
|
1968
|
+
)
|
|
1969
|
+
lines.append(
|
|
1970
|
+
f" {_format_untrusted_evidence(transcript, 5000, continuation_indent=' ')}"
|
|
1971
|
+
)
|
|
942
1972
|
lines.append(" </details>")
|
|
943
1973
|
# Polymarket outcome prices and market details
|
|
944
1974
|
outcome_prices = item.metadata.get("outcome_prices") or []
|
|
@@ -949,7 +1979,11 @@ def render_full(report: schema.Report) -> str:
|
|
|
949
1979
|
odds_parts = []
|
|
950
1980
|
for name, price in outcome_prices:
|
|
951
1981
|
if isinstance(price, (int, float)):
|
|
952
|
-
pct =
|
|
1982
|
+
pct = (
|
|
1983
|
+
f"{price * 100:.0f}%"
|
|
1984
|
+
if price >= 0.1
|
|
1985
|
+
else f"{price * 100:.1f}%"
|
|
1986
|
+
)
|
|
953
1987
|
odds_parts.append(f"{name}: {pct}")
|
|
954
1988
|
if odds_parts:
|
|
955
1989
|
lines.append(f" Odds: {' | '.join(odds_parts)}")
|
|
@@ -961,8 +1995,21 @@ def render_full(report: schema.Report) -> str:
|
|
|
961
1995
|
lines.append(f" Closes: {end_date}")
|
|
962
1996
|
lines.append("")
|
|
963
1997
|
|
|
964
|
-
|
|
965
|
-
|
|
1998
|
+
corpus_section = _render_corpus_section(report)
|
|
1999
|
+
if corpus_section:
|
|
2000
|
+
lines.extend(corpus_section)
|
|
2001
|
+
lines.append("")
|
|
2002
|
+
|
|
2003
|
+
freshness_verdicts = _render_freshness_verdicts(evidence_report)
|
|
2004
|
+
if freshness_verdicts:
|
|
2005
|
+
lines.extend(freshness_verdicts)
|
|
2006
|
+
lines.append("")
|
|
2007
|
+
lines.extend(_render_stats(evidence_report))
|
|
2008
|
+
lines.extend(_render_source_coverage(evidence_report))
|
|
2009
|
+
if save_path:
|
|
2010
|
+
footer_lines = _render_emoji_footer(evidence_report, save_path)
|
|
2011
|
+
if footer_lines:
|
|
2012
|
+
lines.extend(["", *footer_lines])
|
|
966
2013
|
return "\n".join(lines).strip() + "\n"
|
|
967
2014
|
|
|
968
2015
|
|
|
@@ -972,31 +2019,89 @@ def _format_item_engagement(item: schema.SourceItem) -> str:
|
|
|
972
2019
|
if not eng:
|
|
973
2020
|
return ""
|
|
974
2021
|
parts = []
|
|
975
|
-
for key in [
|
|
976
|
-
|
|
2022
|
+
for key in [
|
|
2023
|
+
"score",
|
|
2024
|
+
"likes",
|
|
2025
|
+
"views",
|
|
2026
|
+
"points",
|
|
2027
|
+
"reposts",
|
|
2028
|
+
"replies",
|
|
2029
|
+
"comments",
|
|
2030
|
+
"play_count",
|
|
2031
|
+
"digg_count",
|
|
2032
|
+
"share_count",
|
|
2033
|
+
"num_comments",
|
|
2034
|
+
"ratings",
|
|
2035
|
+
]:
|
|
977
2036
|
val = eng.get(key)
|
|
978
2037
|
if val is not None and val != 0:
|
|
979
2038
|
parts.append(f"{val} {key}")
|
|
2039
|
+
# Same drift as the source list above: this allowlist silently blanks the
|
|
2040
|
+
# engagement of any source whose metric is not on it (trustpilot's
|
|
2041
|
+
# `reviews`/`trustScore` today), so the item renders an empty `[]` in the
|
|
2042
|
+
# saved dump. Fall through only when nothing matched, which fixes the
|
|
2043
|
+
# blank case without adding previously-unshown keys to sources that
|
|
2044
|
+
# already render fine.
|
|
2045
|
+
if not parts:
|
|
2046
|
+
for key, val in sorted(eng.items()):
|
|
2047
|
+
if val not in (None, 0, ""):
|
|
2048
|
+
parts.append(f"{val} {key}")
|
|
980
2049
|
return ", ".join(parts) if parts else ""
|
|
981
2050
|
|
|
982
2051
|
|
|
983
2052
|
def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
|
|
984
|
-
|
|
2053
|
+
evidence_report = schema.without_sources(report, {"corpus"})
|
|
2054
|
+
candidate_by_id = {
|
|
2055
|
+
candidate.candidate_id: candidate
|
|
2056
|
+
for candidate in evidence_report.ranked_candidates
|
|
2057
|
+
}
|
|
2058
|
+
requested_clusters = evidence_report.clusters[:cluster_limit]
|
|
2059
|
+
visible_clusters = _clusters_clearing_relevance_floor(
|
|
2060
|
+
evidence_report,
|
|
2061
|
+
requested_clusters,
|
|
2062
|
+
)
|
|
2063
|
+
no_solid_evidence = bool(requested_clusters) and not visible_clusters
|
|
985
2064
|
lines = [
|
|
986
2065
|
f"Topic: {report.topic}",
|
|
987
2066
|
f"Intent: {report.query_plan.intent}",
|
|
988
2067
|
_AI_SAFETY_NOTE,
|
|
989
2068
|
]
|
|
2069
|
+
drill_context = _render_drill_context(report)
|
|
2070
|
+
if drill_context:
|
|
2071
|
+
lines.extend(["", *drill_context])
|
|
2072
|
+
library_context = _render_library_context(report)
|
|
2073
|
+
if library_context:
|
|
2074
|
+
lines.extend(["", *library_context])
|
|
990
2075
|
freshness_warning = _assess_data_freshness(report)
|
|
991
2076
|
if freshness_warning:
|
|
992
2077
|
lines.append(f"Freshness warning: {freshness_warning}")
|
|
993
|
-
|
|
2078
|
+
context_candidates = _candidates_for_auxiliary_sections(
|
|
2079
|
+
report,
|
|
2080
|
+
requested_clusters,
|
|
2081
|
+
visible_clusters,
|
|
2082
|
+
)
|
|
2083
|
+
hiring_block = (
|
|
2084
|
+
[]
|
|
2085
|
+
if no_solid_evidence
|
|
2086
|
+
else _render_hiring_signals(
|
|
2087
|
+
report,
|
|
2088
|
+
candidates=context_candidates if requested_clusters else None,
|
|
2089
|
+
)
|
|
2090
|
+
)
|
|
994
2091
|
if hiring_block:
|
|
995
2092
|
lines.extend(["", *hiring_block, ""])
|
|
996
2093
|
lines.append("Top clusters:")
|
|
997
|
-
|
|
998
|
-
lines.append(
|
|
999
|
-
|
|
2094
|
+
if no_solid_evidence:
|
|
2095
|
+
lines.append("- Nothing solid this window.")
|
|
2096
|
+
for cluster in visible_clusters:
|
|
2097
|
+
lines.append(
|
|
2098
|
+
f"- {_safe_title(cluster.title)} [{', '.join(_source_label(source) for source in cluster.sources)}]"
|
|
2099
|
+
)
|
|
2100
|
+
for candidate_id in _qualifying_representative_ids(
|
|
2101
|
+
cluster,
|
|
2102
|
+
candidate_by_id,
|
|
2103
|
+
limit=2,
|
|
2104
|
+
):
|
|
1000
2105
|
candidate = candidate_by_id.get(candidate_id)
|
|
1001
2106
|
if not candidate:
|
|
1002
2107
|
continue
|
|
@@ -1008,10 +2113,22 @@ def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
|
|
|
1008
2113
|
]
|
|
1009
2114
|
lines.append(f" - {' | '.join(detail_parts)}")
|
|
1010
2115
|
if candidate.snippet:
|
|
1011
|
-
lines.append(
|
|
2116
|
+
lines.append(
|
|
2117
|
+
f" Evidence: "
|
|
2118
|
+
f"{_format_untrusted_evidence(candidate.snippet, 180, continuation_indent=' ')}"
|
|
2119
|
+
)
|
|
2120
|
+
corpus_section = _render_corpus_section(report)
|
|
2121
|
+
if corpus_section:
|
|
2122
|
+
lines.extend(["", *corpus_section])
|
|
1012
2123
|
if report.warnings:
|
|
1013
2124
|
lines.append("Warnings:")
|
|
1014
2125
|
lines.extend(f"- {warning}" for warning in report.warnings)
|
|
2126
|
+
if report.freshness_verdicts:
|
|
2127
|
+
lines.append("Freshness verdicts:")
|
|
2128
|
+
lines.extend(
|
|
2129
|
+
f"- {verdict.verdict}: {verdict.claim} ({verdict.evidence_url or verdict.source_url})"
|
|
2130
|
+
for verdict in report.freshness_verdicts
|
|
2131
|
+
)
|
|
1015
2132
|
return "\n".join(lines).strip() + "\n"
|
|
1016
2133
|
|
|
1017
2134
|
|
|
@@ -1023,36 +2140,76 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
|
|
|
1023
2140
|
Audience Questions, and Source Clusters. Sections 2-4 are omitted when there
|
|
1024
2141
|
is no matching data; Sections 1 and 5 always appear.
|
|
1025
2142
|
"""
|
|
2143
|
+
evidence_report = schema.without_sources(report, {"corpus"})
|
|
1026
2144
|
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
|
|
1027
2145
|
lines = [
|
|
1028
2146
|
f"# Production Brief: {report.topic}",
|
|
1029
2147
|
"",
|
|
1030
2148
|
*_assistant_safety_lines(),
|
|
1031
2149
|
f"- Date range: {report.range_from} to {report.range_to}",
|
|
1032
|
-
f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
|
|
2150
|
+
f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
|
|
2151
|
+
if non_empty
|
|
2152
|
+
else "- Sources: none",
|
|
1033
2153
|
"",
|
|
1034
2154
|
]
|
|
2155
|
+
drill_context = _render_drill_context(report)
|
|
2156
|
+
if drill_context:
|
|
2157
|
+
lines.extend([*drill_context, ""])
|
|
2158
|
+
library_context = _render_library_context(report)
|
|
2159
|
+
if library_context:
|
|
2160
|
+
lines.extend([*library_context, ""])
|
|
1035
2161
|
|
|
1036
2162
|
lines.append("## Ranked Storylines")
|
|
1037
2163
|
lines.append("")
|
|
1038
|
-
candidate_by_id = {c.candidate_id: c for c in
|
|
1039
|
-
|
|
2164
|
+
candidate_by_id = {c.candidate_id: c for c in evidence_report.ranked_candidates}
|
|
2165
|
+
requested_clusters = evidence_report.clusters[:cluster_limit]
|
|
2166
|
+
visible_clusters = _clusters_clearing_relevance_floor(
|
|
2167
|
+
evidence_report,
|
|
2168
|
+
requested_clusters,
|
|
2169
|
+
)
|
|
2170
|
+
brief_candidates = _candidates_for_auxiliary_sections(
|
|
2171
|
+
evidence_report,
|
|
2172
|
+
requested_clusters,
|
|
2173
|
+
visible_clusters,
|
|
2174
|
+
)
|
|
2175
|
+
qualifying_candidates = [
|
|
2176
|
+
candidate
|
|
2177
|
+
for candidate in brief_candidates
|
|
2178
|
+
if _best_take_relevance_ok(candidate)
|
|
2179
|
+
]
|
|
2180
|
+
if requested_clusters and not visible_clusters:
|
|
2181
|
+
lines.extend(["**Nothing solid this window.**", ""])
|
|
2182
|
+
for i, cluster in enumerate(visible_clusters, start=1):
|
|
1040
2183
|
source_tags = ", ".join(_source_label(s) for s in cluster.sources)
|
|
1041
|
-
qualifier =
|
|
1042
|
-
|
|
1043
|
-
|
|
2184
|
+
qualifier = (
|
|
2185
|
+
f" [{cluster.uncertainty.replace('-', ' ')}]" if cluster.uncertainty else ""
|
|
2186
|
+
)
|
|
2187
|
+
lines.append(
|
|
2188
|
+
f"### {i}. {_safe_title(cluster.title)} (score {cluster.score:.0f}, {source_tags}){qualifier}"
|
|
2189
|
+
)
|
|
2190
|
+
for cid in _qualifying_representative_ids(
|
|
2191
|
+
cluster,
|
|
2192
|
+
candidate_by_id,
|
|
2193
|
+
limit=2,
|
|
2194
|
+
):
|
|
1044
2195
|
candidate = candidate_by_id.get(cid)
|
|
1045
2196
|
if not candidate:
|
|
1046
2197
|
continue
|
|
1047
2198
|
if candidate.snippet:
|
|
1048
|
-
lines.append(
|
|
2199
|
+
lines.append(
|
|
2200
|
+
f"- {_format_untrusted_evidence(candidate.snippet, 280, continuation_indent=' ')}"
|
|
2201
|
+
)
|
|
1049
2202
|
explanation = _format_explanation(candidate)
|
|
1050
2203
|
if explanation:
|
|
1051
2204
|
lines.append(f" _Why: {explanation}_")
|
|
1052
2205
|
lines.append("")
|
|
1053
2206
|
|
|
1054
2207
|
hooks = sorted(
|
|
1055
|
-
(
|
|
2208
|
+
(
|
|
2209
|
+
c
|
|
2210
|
+
for c in qualifying_candidates
|
|
2211
|
+
if c.fun_score is not None and c.fun_score >= 70
|
|
2212
|
+
),
|
|
1056
2213
|
key=lambda c: -(c.fun_score or 0),
|
|
1057
2214
|
)
|
|
1058
2215
|
if hooks:
|
|
@@ -1071,7 +2228,8 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
|
|
|
1071
2228
|
attribution = source_label
|
|
1072
2229
|
reason = (
|
|
1073
2230
|
f" — {candidate.fun_explanation}"
|
|
1074
|
-
if candidate.fun_explanation
|
|
2231
|
+
if candidate.fun_explanation
|
|
2232
|
+
and candidate.fun_explanation != "heuristic-fallback"
|
|
1075
2233
|
else ""
|
|
1076
2234
|
)
|
|
1077
2235
|
lines.append(
|
|
@@ -1080,17 +2238,21 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
|
|
|
1080
2238
|
)
|
|
1081
2239
|
lines.append("")
|
|
1082
2240
|
|
|
1083
|
-
tensions = [c for c in
|
|
2241
|
+
tensions = [c for c in visible_clusters if c.uncertainty]
|
|
1084
2242
|
if tensions:
|
|
1085
2243
|
lines.append("## Topic Tensions")
|
|
1086
2244
|
lines.append("")
|
|
1087
2245
|
for cluster in tensions[:cluster_limit]:
|
|
1088
|
-
label =
|
|
2246
|
+
label = (
|
|
2247
|
+
cluster.uncertainty.replace("-", " ").title()
|
|
2248
|
+
if cluster.uncertainty
|
|
2249
|
+
else ""
|
|
2250
|
+
)
|
|
1089
2251
|
source_tags = ", ".join(_source_label(s) for s in cluster.sources)
|
|
1090
|
-
lines.append(f"- **{cluster.title}** [{label}]: {source_tags}")
|
|
2252
|
+
lines.append(f"- **{_safe_title(cluster.title)}** [{label}]: {source_tags}")
|
|
1091
2253
|
lines.append("")
|
|
1092
2254
|
|
|
1093
|
-
questions = _extract_audience_questions(
|
|
2255
|
+
questions = _extract_audience_questions(qualifying_candidates)
|
|
1094
2256
|
if questions:
|
|
1095
2257
|
lines.append("## Audience Questions")
|
|
1096
2258
|
lines.append("")
|
|
@@ -1100,11 +2262,21 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
|
|
|
1100
2262
|
|
|
1101
2263
|
lines.append("## Source Clusters")
|
|
1102
2264
|
lines.append("")
|
|
1103
|
-
for cluster in
|
|
2265
|
+
for cluster in visible_clusters:
|
|
1104
2266
|
source_tags = " + ".join(_source_label(s) for s in cluster.sources)
|
|
1105
|
-
lines.append(f"- **{cluster.title}**: {source_tags}")
|
|
2267
|
+
lines.append(f"- **{_safe_title(cluster.title)}**: {source_tags}")
|
|
1106
2268
|
lines.append("")
|
|
1107
2269
|
|
|
2270
|
+
corpus_section = _render_corpus_section(report)
|
|
2271
|
+
if corpus_section:
|
|
2272
|
+
lines.extend(corpus_section)
|
|
2273
|
+
lines.append("")
|
|
2274
|
+
|
|
2275
|
+
freshness_verdicts = _render_freshness_verdicts(report)
|
|
2276
|
+
if freshness_verdicts:
|
|
2277
|
+
lines.extend(freshness_verdicts)
|
|
2278
|
+
lines.append("")
|
|
2279
|
+
|
|
1108
2280
|
return "\n".join(lines).strip() + "\n"
|
|
1109
2281
|
|
|
1110
2282
|
|
|
@@ -1124,13 +2296,54 @@ def _extract_audience_questions(candidates: list[schema.Candidate]) -> list[str]
|
|
|
1124
2296
|
return questions
|
|
1125
2297
|
|
|
1126
2298
|
|
|
1127
|
-
def _render_hiring_signals(
|
|
2299
|
+
def _render_hiring_signals(
|
|
2300
|
+
report: schema.Report,
|
|
2301
|
+
*,
|
|
2302
|
+
candidates: list[schema.Candidate] | None = None,
|
|
2303
|
+
) -> list[str]:
|
|
1128
2304
|
summary = report.artifacts.get("hiring_signals")
|
|
1129
2305
|
if not isinstance(summary, dict):
|
|
1130
2306
|
return []
|
|
2307
|
+
mode = summary.get("mode") or "standard"
|
|
2308
|
+
rejected_jobs = set(summary.get("rejected_job_keys") or [])
|
|
2309
|
+
if candidates is not None or rejected_jobs:
|
|
2310
|
+
solid_clusters = _clusters_clearing_relevance_floor(report, report.clusters)
|
|
2311
|
+
accepted = _candidates_for_auxiliary_sections(
|
|
2312
|
+
report, report.clusters, solid_clusters,
|
|
2313
|
+
)
|
|
2314
|
+
accepted_ids = {
|
|
2315
|
+
candidate.candidate_id for candidate in accepted
|
|
2316
|
+
if _best_take_relevance_ok(candidate)
|
|
2317
|
+
}
|
|
2318
|
+
rejected_jobs.update(
|
|
2319
|
+
fusion.candidate_key(item)
|
|
2320
|
+
for candidate in report.ranked_candidates
|
|
2321
|
+
if candidate.candidate_id not in accepted_ids
|
|
2322
|
+
for item in candidate.source_items
|
|
2323
|
+
if item.source == "jobs"
|
|
2324
|
+
)
|
|
2325
|
+
# Board size and rare-role signals must survive the ranking pool and
|
|
2326
|
+
# display limits, while explicitly rejected evidence stays excluded.
|
|
2327
|
+
job_items = {
|
|
2328
|
+
fusion.candidate_key(item): item
|
|
2329
|
+
for item in report.items_by_source.get("jobs", [])
|
|
2330
|
+
if fusion.candidate_key(item) not in rejected_jobs
|
|
2331
|
+
}
|
|
2332
|
+
for candidate in accepted:
|
|
2333
|
+
if candidate.candidate_id not in accepted_ids:
|
|
2334
|
+
continue
|
|
2335
|
+
for item in candidate.source_items:
|
|
2336
|
+
if item.source == "jobs":
|
|
2337
|
+
job_items[fusion.candidate_key(item)] = item
|
|
2338
|
+
if not job_items:
|
|
2339
|
+
return []
|
|
2340
|
+
summary = hiring_signals.analyze(
|
|
2341
|
+
list(job_items.values()),
|
|
2342
|
+
explicit=mode == "explicit",
|
|
2343
|
+
topic=report.topic,
|
|
2344
|
+
)
|
|
1131
2345
|
signals = summary.get("signals") or []
|
|
1132
2346
|
include = bool(summary.get("include"))
|
|
1133
|
-
mode = summary.get("mode") or "standard"
|
|
1134
2347
|
if not include and mode != "explicit":
|
|
1135
2348
|
return []
|
|
1136
2349
|
|
|
@@ -1173,7 +2386,7 @@ def _render_hiring_signals(report: schema.Report) -> list[str]:
|
|
|
1173
2386
|
out.append(
|
|
1174
2387
|
"- Strategic single-role signals (judge novelty yourself - a founding "
|
|
1175
2388
|
"or first-of-function role can outweigh a whole department; in synthesis, "
|
|
1176
|
-
|
|
2389
|
+
'distinguish "new bets" from "doubling down"):'
|
|
1177
2390
|
)
|
|
1178
2391
|
for cand in strategic[:8]:
|
|
1179
2392
|
title = cand.get("title") or "Job posting"
|
|
@@ -1189,7 +2402,11 @@ def _render_hiring_signals(report: schema.Report) -> list[str]:
|
|
|
1189
2402
|
return out
|
|
1190
2403
|
|
|
1191
2404
|
|
|
1192
|
-
def _render_candidate(
|
|
2405
|
+
def _render_candidate(
|
|
2406
|
+
candidate: schema.Candidate,
|
|
2407
|
+
prefix: str,
|
|
2408
|
+
report: schema.Report | None = None,
|
|
2409
|
+
) -> list[str]:
|
|
1193
2410
|
primary = schema.candidate_primary_item(candidate)
|
|
1194
2411
|
detail_parts = [
|
|
1195
2412
|
_format_date(primary),
|
|
@@ -1207,10 +2424,14 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
|
|
|
1207
2424
|
detail_parts.append("interaction:→@" + ",@".join(interaction_targets[:2]))
|
|
1208
2425
|
details = " | ".join(part for part in detail_parts if part)
|
|
1209
2426
|
lines = [
|
|
1210
|
-
f"{prefix} [{schema.candidate_source_label(candidate)}] {candidate.title}"
|
|
2427
|
+
f"{prefix} [{schema.candidate_source_label(candidate)}] {_safe_title(candidate.title)}"
|
|
2428
|
+
+ (_candidate_freshness_flag(report, candidate.candidate_id) if report else ""),
|
|
1211
2429
|
f" - {details}",
|
|
1212
|
-
f" - URL: {candidate.url}",
|
|
1213
2430
|
]
|
|
2431
|
+
if candidate.url:
|
|
2432
|
+
rendered_url = _markdown_url_link(candidate.url)
|
|
2433
|
+
if rendered_url:
|
|
2434
|
+
lines.append(f" - URL: {rendered_url}")
|
|
1214
2435
|
corroboration = _format_corroboration(candidate)
|
|
1215
2436
|
if corroboration:
|
|
1216
2437
|
lines.append(f" - {corroboration}")
|
|
@@ -1218,24 +2439,44 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
|
|
|
1218
2439
|
if explanation:
|
|
1219
2440
|
lines.append(f" - Why: {explanation}")
|
|
1220
2441
|
if candidate.snippet:
|
|
1221
|
-
lines.append(
|
|
2442
|
+
lines.append(
|
|
2443
|
+
f" - Evidence: {_format_untrusted_evidence(candidate.snippet, 360)}"
|
|
2444
|
+
)
|
|
1222
2445
|
for tc in _top_comments_list(primary):
|
|
1223
2446
|
excerpt = tc.get("excerpt") or tc.get("text") or ""
|
|
1224
2447
|
score = tc.get("score", "")
|
|
1225
2448
|
vote_label = _vote_label_for(primary.source) if primary else "upvotes"
|
|
1226
2449
|
source = primary.source if primary else None
|
|
1227
2450
|
attribution = _comment_attribution(source, tc.get("author"))
|
|
1228
|
-
|
|
2451
|
+
vote_part = (
|
|
2452
|
+
f" ({score} {vote_label})"
|
|
2453
|
+
if score is not None and score != ""
|
|
2454
|
+
else ""
|
|
2455
|
+
)
|
|
2456
|
+
# HN comment lines carry their own link: the candidate URL above is
|
|
2457
|
+
# the article, not where the quote was said.
|
|
2458
|
+
hn_link = (
|
|
2459
|
+
_comment_link(primary, tc)
|
|
2460
|
+
if primary is not None and primary.source == "hackernews"
|
|
2461
|
+
else ""
|
|
2462
|
+
)
|
|
2463
|
+
lines.append(
|
|
2464
|
+
f" - {attribution}{vote_part}: "
|
|
2465
|
+
f"{_format_untrusted_evidence(excerpt.strip(), 240)}"
|
|
2466
|
+
+ (f" — {hn_link}" if hn_link else "")
|
|
2467
|
+
)
|
|
1229
2468
|
for post in _digg_posts_for(primary):
|
|
1230
2469
|
lines.append(f" - {_format_digg_quote(post)}")
|
|
1231
2470
|
insight = _comment_insight(primary)
|
|
1232
2471
|
if insight:
|
|
1233
|
-
lines.append(f" - Insight: {
|
|
2472
|
+
lines.append(f" - Insight: {_format_untrusted_evidence(insight, 220)}")
|
|
1234
2473
|
highlights = _transcript_highlights(primary)
|
|
1235
2474
|
if highlights:
|
|
1236
|
-
lines.append(
|
|
2475
|
+
lines.append(
|
|
2476
|
+
" - Highlights (auto-generated transcript; may contain transcription errors):"
|
|
2477
|
+
)
|
|
1237
2478
|
for hl in highlights:
|
|
1238
|
-
lines.append(f' - "{
|
|
2479
|
+
lines.append(f' - "{_format_untrusted_evidence(hl, 200)}"')
|
|
1239
2480
|
return lines
|
|
1240
2481
|
|
|
1241
2482
|
|
|
@@ -1270,13 +2511,23 @@ def _shorten_polymarket_title(title: str) -> str:
|
|
|
1270
2511
|
t = t[5:].strip()
|
|
1271
2512
|
|
|
1272
2513
|
# Drop "by <Month> <Day>" or "by <Month> <Day>, <Year>" tail
|
|
1273
|
-
t = re.sub(
|
|
2514
|
+
t = re.sub(
|
|
2515
|
+
r"\s+by\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:,\s*\d{4})?$",
|
|
2516
|
+
"",
|
|
2517
|
+
t,
|
|
2518
|
+
flags=re.IGNORECASE,
|
|
2519
|
+
)
|
|
1274
2520
|
# Drop "in <Year>" tail (e.g. "separate in 2026")
|
|
1275
2521
|
t = re.sub(r"\s+in\s+\d{4}$", "", t, flags=re.IGNORECASE)
|
|
1276
2522
|
# Drop "by <Year>" tail
|
|
1277
2523
|
t = re.sub(r"\s+by\s+\d{4}$", "", t, flags=re.IGNORECASE)
|
|
1278
2524
|
# Drop "before <Month> <Day>" tail
|
|
1279
|
-
t = re.sub(
|
|
2525
|
+
t = re.sub(
|
|
2526
|
+
r"\s+before\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+$",
|
|
2527
|
+
"",
|
|
2528
|
+
t,
|
|
2529
|
+
flags=re.IGNORECASE,
|
|
2530
|
+
)
|
|
1280
2531
|
|
|
1281
2532
|
# Pattern: "<Subject> visit <Place>" -> "<Place> visit"
|
|
1282
2533
|
m = re.match(r"^(.+?)\s+visit\s+(?:the\s+)?(.+)$", t, flags=re.IGNORECASE)
|
|
@@ -1292,12 +2543,14 @@ def _shorten_polymarket_title(title: str) -> str:
|
|
|
1292
2543
|
t = " ".join(words[:6])
|
|
1293
2544
|
|
|
1294
2545
|
# Drop a leading article so the descriptor doesn't read "an Anthropic Claude..."
|
|
1295
|
-
t = re.sub(r"^(?:a|an|the)\s+", "", t, flags=re.
|
|
2546
|
+
t = re.sub(r"^(?:a|an|the)\s+", "", t, flags=re.IGNORECASE)
|
|
1296
2547
|
|
|
1297
2548
|
return t
|
|
1298
2549
|
|
|
1299
2550
|
|
|
1300
|
-
def _polymarket_top_markets(
|
|
2551
|
+
def _polymarket_top_markets(
|
|
2552
|
+
items: list[schema.SourceItem], limit: int = 3
|
|
2553
|
+
) -> list[str]:
|
|
1301
2554
|
"""Build short summary strings for the top Polymarket markets by volume.
|
|
1302
2555
|
|
|
1303
2556
|
Returns list like: ['UK visit 5.5%', 'Israel visit 8%', 'blocked from entering 36%']
|
|
@@ -1319,9 +2572,15 @@ def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> l
|
|
|
1319
2572
|
if not isinstance(lead_price, (int, float)):
|
|
1320
2573
|
continue
|
|
1321
2574
|
|
|
1322
|
-
pct =
|
|
2575
|
+
pct = (
|
|
2576
|
+
f"{lead_price * 100:.0f}%"
|
|
2577
|
+
if lead_price >= 0.1
|
|
2578
|
+
else f"{lead_price * 100:.1f}%"
|
|
2579
|
+
)
|
|
1323
2580
|
|
|
1324
|
-
descriptor = _shorten_polymarket_title(
|
|
2581
|
+
descriptor = _shorten_polymarket_title(
|
|
2582
|
+
item.metadata.get("question") or item.title or ""
|
|
2583
|
+
)
|
|
1325
2584
|
if not descriptor:
|
|
1326
2585
|
continue
|
|
1327
2586
|
|
|
@@ -1344,14 +2603,42 @@ def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> l
|
|
|
1344
2603
|
return summaries
|
|
1345
2604
|
|
|
1346
2605
|
|
|
1347
|
-
|
|
2606
|
+
# Warnings that only restate a per-source outcome. The compact (model-facing)
|
|
2607
|
+
# stdout carries those through ## Partial Coverage, and the user-facing
|
|
2608
|
+
# footer carries counts only; doctor --postmortem, the saved raw file, and
|
|
2609
|
+
# --emit=json keep the full list.
|
|
2610
|
+
_SOURCE_FAILURE_WARNING_PREFIXES = (
|
|
2611
|
+
"Some sources failed",
|
|
2612
|
+
"Some sources returned partial results",
|
|
2613
|
+
)
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
def _warnings_without_source_failures(warnings: list[str]) -> list[str]:
|
|
2617
|
+
return [
|
|
2618
|
+
warning
|
|
2619
|
+
for warning in warnings
|
|
2620
|
+
if not warning.startswith(_SOURCE_FAILURE_WARNING_PREFIXES)
|
|
2621
|
+
]
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
def _render_source_coverage(
|
|
2625
|
+
report: schema.Report,
|
|
2626
|
+
*,
|
|
2627
|
+
include_errors: bool = True,
|
|
2628
|
+
) -> list[str]:
|
|
1348
2629
|
lines = [
|
|
1349
2630
|
"## Source Coverage",
|
|
1350
2631
|
"",
|
|
1351
2632
|
]
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
2633
|
+
sources = sorted(set(report.items_by_source) | set(report.source_status))
|
|
2634
|
+
for source in sources:
|
|
2635
|
+
items = report.items_by_source.get(source, [])
|
|
2636
|
+
line = f"- {_source_label(source)}: {len(items)} item{'s' if len(items) != 1 else ''}"
|
|
2637
|
+
outcome = report.source_status.get(source)
|
|
2638
|
+
if outcome and outcome.state != health.OK:
|
|
2639
|
+
line += f" ({_format_outcome(outcome)})"
|
|
2640
|
+
lines.append(line)
|
|
2641
|
+
if include_errors and report.errors_by_source:
|
|
1355
2642
|
lines.append("")
|
|
1356
2643
|
lines.append("## Source Errors")
|
|
1357
2644
|
lines.append("")
|
|
@@ -1360,6 +2647,57 @@ def _render_source_coverage(report: schema.Report) -> list[str]:
|
|
|
1360
2647
|
return lines
|
|
1361
2648
|
|
|
1362
2649
|
|
|
2650
|
+
def _render_source_outcome_note(report: schema.Report) -> list[str]:
|
|
2651
|
+
"""Tell the synthesizer that a failed source is not evidence of silence."""
|
|
2652
|
+
affected = [
|
|
2653
|
+
outcome
|
|
2654
|
+
for outcome in report.source_status.values()
|
|
2655
|
+
if outcome.state not in (health.OK, schema.NO_RESULTS)
|
|
2656
|
+
]
|
|
2657
|
+
if not affected:
|
|
2658
|
+
return []
|
|
2659
|
+
summaries = "; ".join(
|
|
2660
|
+
f"{_source_label(outcome.source)} {_format_outcome(outcome)}"
|
|
2661
|
+
for outcome in sorted(affected, key=lambda item: item.source)
|
|
2662
|
+
)
|
|
2663
|
+
return [
|
|
2664
|
+
"## Partial Coverage",
|
|
2665
|
+
"",
|
|
2666
|
+
f"> {summaries}.",
|
|
2667
|
+
"> Do not interpret a failed source as no discussion on that source. "
|
|
2668
|
+
"Synthesize only from available evidence; run `doctor` for fix prescriptions.",
|
|
2669
|
+
]
|
|
2670
|
+
|
|
2671
|
+
|
|
2672
|
+
def _format_outcome(outcome: schema.SourceOutcome) -> str:
|
|
2673
|
+
detail = " ".join((outcome.detail or "").split())
|
|
2674
|
+
if len(detail) > 140:
|
|
2675
|
+
detail = detail[:137].rstrip() + "..."
|
|
2676
|
+
state = outcome.state
|
|
2677
|
+
if state == schema.PARTIAL:
|
|
2678
|
+
noun = "item" if outcome.items_returned == 1 else "items"
|
|
2679
|
+
summary = f"partial — {outcome.items_returned} {noun} returned"
|
|
2680
|
+
detail_l = detail.lower()
|
|
2681
|
+
if (
|
|
2682
|
+
"429" in detail_l
|
|
2683
|
+
or "rate-limited" in detail_l
|
|
2684
|
+
or "rate limit" in detail_l
|
|
2685
|
+
or "too many requests" in detail_l
|
|
2686
|
+
):
|
|
2687
|
+
summary += ", some requests rate-limited"
|
|
2688
|
+
elif state == schema.NO_RESULTS:
|
|
2689
|
+
summary = "no results"
|
|
2690
|
+
elif state == schema.PAYMENT_REQUIRED:
|
|
2691
|
+
summary = health.credits_exhausted_label(outcome.source)
|
|
2692
|
+
else:
|
|
2693
|
+
summary = state
|
|
2694
|
+
if detail:
|
|
2695
|
+
summary += f": {detail}"
|
|
2696
|
+
if outcome.fix_hint == "doctor":
|
|
2697
|
+
summary += " (run doctor for fixes)"
|
|
2698
|
+
return summary
|
|
2699
|
+
|
|
2700
|
+
|
|
1363
2701
|
# Known publications for the Web line of the emoji-tree footer.
|
|
1364
2702
|
# Maps apex domain to a clean display name. Unknown domains fall back to
|
|
1365
2703
|
# the bare domain string (protocol stripped, www. removed).
|
|
@@ -1439,8 +2777,7 @@ def _site_name_for_url(url: str) -> str:
|
|
|
1439
2777
|
# urlparse needs a scheme to resolve the netloc; prepend http:// if missing.
|
|
1440
2778
|
parsed = urlparse(u if "://" in u else f"http://{u}")
|
|
1441
2779
|
host = (parsed.netloc or parsed.path.split("/", 1)[0]).lower()
|
|
1442
|
-
|
|
1443
|
-
host = host[4:]
|
|
2780
|
+
host = host.removeprefix("www.")
|
|
1444
2781
|
if not host:
|
|
1445
2782
|
return u[:40]
|
|
1446
2783
|
if host in _SITE_NAMES:
|
|
@@ -1479,25 +2816,81 @@ def _format_web_line_sources(items: list[schema.SourceItem], limit: int = 8) ->
|
|
|
1479
2816
|
# Report.items_by_source. Order here is the render order.
|
|
1480
2817
|
_FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
|
|
1481
2818
|
# (source_key, emoji, display_name, item_word_singular, [(engagement_key, word)])
|
|
1482
|
-
(
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
("
|
|
1490
|
-
(
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
2819
|
+
(
|
|
2820
|
+
"reddit",
|
|
2821
|
+
"🟠",
|
|
2822
|
+
"Reddit",
|
|
2823
|
+
"thread",
|
|
2824
|
+
[("score", "upvotes"), ("num_comments", "comments")],
|
|
2825
|
+
),
|
|
2826
|
+
("x", "🔵", "X", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
|
2827
|
+
(
|
|
2828
|
+
"youtube",
|
|
2829
|
+
"🔴",
|
|
2830
|
+
"YouTube",
|
|
2831
|
+
"video",
|
|
2832
|
+
[("views", "views")],
|
|
2833
|
+
), # transcripts appended below in _build_source_footer_lines
|
|
2834
|
+
("tiktok", "🎵", "TikTok", "video", [("views", "views"), ("likes", "likes")]),
|
|
2835
|
+
("instagram", "📸", "Instagram", "reel", [("views", "views"), ("likes", "likes")]),
|
|
2836
|
+
("threads", "🧵", "Threads", "post", [("likes", "likes"), ("replies", "replies")]),
|
|
2837
|
+
(
|
|
2838
|
+
"pinterest",
|
|
2839
|
+
"📌",
|
|
2840
|
+
"Pinterest",
|
|
2841
|
+
"pin",
|
|
2842
|
+
[("saves", "saves"), ("comments", "comments")],
|
|
2843
|
+
),
|
|
2844
|
+
(
|
|
2845
|
+
"hackernews",
|
|
2846
|
+
"🟡",
|
|
2847
|
+
"HN",
|
|
2848
|
+
"story",
|
|
2849
|
+
[("points", "points"), ("comments", "comments")],
|
|
2850
|
+
),
|
|
2851
|
+
("devto", "👩💻", "dev.to", "article", [("reactions", "reactions"), ("comments", "comments")]),
|
|
2852
|
+
("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
|
|
2853
|
+
(
|
|
2854
|
+
"truthsocial",
|
|
2855
|
+
"🇺🇸",
|
|
2856
|
+
"Truth Social",
|
|
2857
|
+
"post",
|
|
2858
|
+
[("likes", "likes"), ("reposts", "reposts")],
|
|
2859
|
+
),
|
|
2860
|
+
(
|
|
2861
|
+
"linkedin",
|
|
2862
|
+
"👔",
|
|
2863
|
+
"LinkedIn",
|
|
2864
|
+
"post",
|
|
2865
|
+
[("likes", "likes"), ("comments", "comments")],
|
|
2866
|
+
),
|
|
2867
|
+
(
|
|
2868
|
+
"github",
|
|
2869
|
+
"🐙",
|
|
2870
|
+
"GitHub",
|
|
2871
|
+
"item",
|
|
2872
|
+
[
|
|
2873
|
+
("stars", "stars"),
|
|
2874
|
+
("merged_prs", "merged"),
|
|
2875
|
+
("reactions", "reactions"),
|
|
2876
|
+
("comments", "comments"),
|
|
2877
|
+
],
|
|
2878
|
+
),
|
|
2879
|
+
(
|
|
2880
|
+
"digg",
|
|
2881
|
+
"⛏️",
|
|
2882
|
+
"Digg",
|
|
2883
|
+
"cluster",
|
|
2884
|
+
[("postCount", "posts"), ("uniqueAuthors", "authors")],
|
|
2885
|
+
),
|
|
2886
|
+
("arxiv", "📄", "arXiv", "paper", []),
|
|
2887
|
+
("techmeme", "📰", "Techmeme", "headline", []),
|
|
2888
|
+
("trustpilot", "⭐", "Trustpilot", "review", [("reviews", "reviews")]),
|
|
1497
2889
|
# Jobs must appear so a scoped --hiring-signals run (jobs-only) still emits
|
|
1498
2890
|
# the LAW 5 footer; without it the footer was dropped entirely.
|
|
1499
|
-
("jobs",
|
|
1500
|
-
("perplexity",
|
|
2891
|
+
("jobs", "💼", "Jobs", "role", []),
|
|
2892
|
+
("perplexity", "🧠", "Perplexity", "result", [("citations", "citations")]),
|
|
2893
|
+
("corpus", "🔒", "Your files", "file", []),
|
|
1501
2894
|
]
|
|
1502
2895
|
|
|
1503
2896
|
|
|
@@ -1514,18 +2907,28 @@ def _sum_engagement(items: list[schema.SourceItem], key: str) -> int:
|
|
|
1514
2907
|
return total
|
|
1515
2908
|
|
|
1516
2909
|
|
|
1517
|
-
def _footer_line_for_source(
|
|
2910
|
+
def _footer_line_for_source(
|
|
2911
|
+
emoji: str, label: str, count: int, item_word: str, stats: str
|
|
2912
|
+
) -> str:
|
|
1518
2913
|
count_str = f"{count:,}" if count >= 1000 else str(count)
|
|
1519
|
-
|
|
2914
|
+
if count == 1:
|
|
2915
|
+
plural = item_word
|
|
2916
|
+
elif len(item_word) > 1 and item_word.endswith("y") and item_word[-2] not in "aeiou":
|
|
2917
|
+
plural = f"{item_word[:-1]}ies"
|
|
2918
|
+
else:
|
|
2919
|
+
plural = f"{item_word}s"
|
|
1520
2920
|
if stats:
|
|
1521
2921
|
return f"{emoji} {label}: {count_str} {plural} │ {stats}"
|
|
1522
2922
|
return f"{emoji} {label}: {count_str} {plural}"
|
|
1523
2923
|
|
|
1524
2924
|
|
|
1525
2925
|
def _build_source_footer_lines(report: schema.Report) -> list[str]:
|
|
1526
|
-
"""Return emoji-tree
|
|
2926
|
+
"""Return emoji-tree lines for populated sources only (>=1 item).
|
|
1527
2927
|
|
|
1528
|
-
|
|
2928
|
+
Sources that returned zero items - clean NO_RESULTS or a failure - are
|
|
2929
|
+
omitted; their outcome still surfaces in the ## Source Coverage /
|
|
2930
|
+
## Partial Coverage evidence blocks. The caller adds the tree characters
|
|
2931
|
+
(├─ / └─) after assembling all lines.
|
|
1529
2932
|
"""
|
|
1530
2933
|
out: list[str] = []
|
|
1531
2934
|
for source_key, emoji, label, item_word, engagement_fields in _FOOTER_SOURCES:
|
|
@@ -1544,12 +2947,24 @@ def _build_source_footer_lines(report: schema.Report) -> list[str]:
|
|
|
1544
2947
|
# case that needs to be loud is the one previously omitted from the footer.
|
|
1545
2948
|
if source_key == "youtube":
|
|
1546
2949
|
with_transcripts = sum(
|
|
1547
|
-
1
|
|
1548
|
-
|
|
2950
|
+
1
|
|
2951
|
+
for it in items
|
|
2952
|
+
if (
|
|
2953
|
+
it.metadata.get("transcript_highlights")
|
|
2954
|
+
or it.metadata.get("transcript_snippet")
|
|
2955
|
+
)
|
|
1549
2956
|
)
|
|
1550
2957
|
parts.append(f"{with_transcripts}/{len(items)} with transcripts")
|
|
2958
|
+
provenance = report.artifacts.get("x_provenance") if source_key == "x" else None
|
|
2959
|
+
if provenance in x_envelope.PROVENANCE_LABELS:
|
|
2960
|
+
# Host-fetched X names its lane on the user-facing line too.
|
|
2961
|
+
parts.append(f"via {x_envelope.PROVENANCE_LABELS[provenance]}")
|
|
1551
2962
|
stats = " │ ".join(parts)
|
|
1552
|
-
|
|
2963
|
+
line = _footer_line_for_source(emoji, label, len(items), item_word, stats)
|
|
2964
|
+
# Counts only: run diagnostics live in doctor --postmortem, the saved
|
|
2965
|
+
# raw file, and the model-facing ## Partial Coverage note, never on
|
|
2966
|
+
# the user-facing conclusion surface.
|
|
2967
|
+
out.append(line)
|
|
1553
2968
|
|
|
1554
2969
|
# Polymarket (special: count + odds string from existing helper)
|
|
1555
2970
|
polymarket_items = report.items_by_source.get("polymarket") or []
|
|
@@ -1560,9 +2975,18 @@ def _build_source_footer_lines(report: schema.Report) -> list[str]:
|
|
|
1560
2975
|
count_str = f"{count:,}" if count >= 1000 else str(count)
|
|
1561
2976
|
plural = "markets" if count != 1 else "market"
|
|
1562
2977
|
if odds_str:
|
|
1563
|
-
|
|
2978
|
+
line = f"📊 Polymarket: {count_str} {plural} │ {odds_str}"
|
|
1564
2979
|
else:
|
|
1565
|
-
|
|
2980
|
+
line = f"📊 Polymarket: {count_str} {plural}"
|
|
2981
|
+
out.append(line)
|
|
2982
|
+
|
|
2983
|
+
amazon_line = _amazon_footer_line(report)
|
|
2984
|
+
if amazon_line:
|
|
2985
|
+
out.append(amazon_line)
|
|
2986
|
+
|
|
2987
|
+
meta_ads_line = _meta_ads_footer_line(report)
|
|
2988
|
+
if meta_ads_line:
|
|
2989
|
+
out.append(meta_ads_line)
|
|
1566
2990
|
|
|
1567
2991
|
# Web (sources from grounding)
|
|
1568
2992
|
web_items = report.items_by_source.get("grounding") or []
|
|
@@ -1572,13 +2996,252 @@ def _build_source_footer_lines(report: schema.Report) -> list[str]:
|
|
|
1572
2996
|
count_str = f"{count:,}" if count >= 1000 else str(count)
|
|
1573
2997
|
plural = "pages" if count != 1 else "page"
|
|
1574
2998
|
if names:
|
|
1575
|
-
|
|
2999
|
+
line = f"🌐 Web: {count_str} {plural} - {names}"
|
|
1576
3000
|
else:
|
|
1577
|
-
|
|
1578
|
-
|
|
3001
|
+
line = f"🌐 Web: {count_str} {plural}"
|
|
3002
|
+
out.append(line)
|
|
3003
|
+
|
|
3004
|
+
# Only populated sources (>=1 item) get an emoji-tree line. A source that
|
|
3005
|
+
# returned zero items - whether it completed cleanly (NO_RESULTS) or failed
|
|
3006
|
+
# (rate-limited / unreachable / etc.) - is omitted from the user-facing
|
|
3007
|
+
# footer. Its failure signal remains visible to synthesis in the
|
|
3008
|
+
# ## Partial Coverage / ## Source Coverage evidence blocks, so nothing is
|
|
3009
|
+
# silently lost; the conclusion surface just stays clean.
|
|
1579
3010
|
return out
|
|
1580
3011
|
|
|
1581
3012
|
|
|
3013
|
+
def _amazon_footer_line(report: schema.Report) -> str | None:
|
|
3014
|
+
"""Build the 📦 Amazon emoji-footer line (R1c).
|
|
3015
|
+
|
|
3016
|
+
Follows the Polymarket shape -- unit count, then *named products
|
|
3017
|
+
carrying their own numbers* -- rather than the three-count inventory
|
|
3018
|
+
shape every social source uses. ``3 products │ 611 ratings │ 56
|
|
3019
|
+
reviews`` fits the box and says nothing; a named product with the
|
|
3020
|
+
direction its rating moved this month is the whole reason the source
|
|
3021
|
+
exists.
|
|
3022
|
+
|
|
3023
|
+
Three renderings:
|
|
3024
|
+
|
|
3025
|
+
* **Default/deep** -- per-product drift entries (see
|
|
3026
|
+
``amazon.footer_entry``).
|
|
3027
|
+
* **Quick depth** -- no review pulls means no recent window, so the
|
|
3028
|
+
inventory form is the honest one here and only here. Never render a
|
|
3029
|
+
``→`` against a null window.
|
|
3030
|
+
* **Empty search** -- name the keyword rather than suppressing the
|
|
3031
|
+
line. Observability, not spend: an empty result means the model's
|
|
3032
|
+
keyword or its relevance judgment was wrong, and a hidden line means
|
|
3033
|
+
nobody ever finds out.
|
|
3034
|
+
"""
|
|
3035
|
+
items = report.items_by_source.get("amazon") or []
|
|
3036
|
+
keyword = str((report.artifacts or {}).get("amazon_query") or "").strip()
|
|
3037
|
+
outcome = report.source_status.get("amazon")
|
|
3038
|
+
failed = bool(outcome and outcome.state != health.OK)
|
|
3039
|
+
|
|
3040
|
+
if not items:
|
|
3041
|
+
if not keyword:
|
|
3042
|
+
return None
|
|
3043
|
+
# An empty result is only a keyword problem when the search actually
|
|
3044
|
+
# ran and came back empty. On an expired token or a CLI failure,
|
|
3045
|
+
# "no products matched" sends the user to fix the wrong thing --
|
|
3046
|
+
# so lead with the real outcome, same as every other footer branch.
|
|
3047
|
+
if failed:
|
|
3048
|
+
# Counts-only footer: the outcome itself lives in ## Partial
|
|
3049
|
+
# Coverage and doctor --postmortem, so just avoid the misleading
|
|
3050
|
+
# "no products matched" wording when the search never ran.
|
|
3051
|
+
return f'📦 Amazon: no results for "{keyword}"'
|
|
3052
|
+
return f'📦 Amazon: no products matched "{keyword}"'
|
|
3053
|
+
|
|
3054
|
+
count = len(items)
|
|
3055
|
+
plural = "products" if count != 1 else "product"
|
|
3056
|
+
all_stats = [amazon.stats_from_item(item) for item in items]
|
|
3057
|
+
|
|
3058
|
+
# Quick depth pulls no reviews at all, so nothing has a recent window.
|
|
3059
|
+
if not any(s.get("reviews_pulled") for s in all_stats):
|
|
3060
|
+
rated = [s["all_time"] for s in all_stats if s.get("all_time") is not None]
|
|
3061
|
+
total_ratings = sum(s.get("ratings_total") or 0 for s in all_stats)
|
|
3062
|
+
parts = [f"{count} {plural}"]
|
|
3063
|
+
if rated:
|
|
3064
|
+
parts.append(f"{sum(rated) / len(rated):.1f}★ average")
|
|
3065
|
+
if total_ratings:
|
|
3066
|
+
parts.append(f"{total_ratings:,} ratings")
|
|
3067
|
+
line = f"📦 Amazon: {' │ '.join(parts)}"
|
|
3068
|
+
return line
|
|
3069
|
+
|
|
3070
|
+
# Only the *sampled* products earn a slot. A run can carry a dozen
|
|
3071
|
+
# discovered products, but only the two or three that got a review pull
|
|
3072
|
+
# have a recent window at all -- rendering the rest appends a string of
|
|
3073
|
+
# `quiet` entries that push the line past every other source in the box
|
|
3074
|
+
# while adding nothing (observed live: 12 entries, 9 of them padding).
|
|
3075
|
+
# The count still reports everything found, so nothing is hidden.
|
|
3076
|
+
sampled = [
|
|
3077
|
+
(s, item) for s, item in zip(all_stats, items) if s.get("reviews_pulled")
|
|
3078
|
+
]
|
|
3079
|
+
# Variants of one product share a short name; showing both reads as a
|
|
3080
|
+
# rendering bug even though the ASINs differ.
|
|
3081
|
+
stats, shown_items, seen_names = [], [], set()
|
|
3082
|
+
for stat, item in sampled:
|
|
3083
|
+
key = (stat.get("short_name") or "").strip().lower()
|
|
3084
|
+
if key and key in seen_names:
|
|
3085
|
+
continue
|
|
3086
|
+
if key:
|
|
3087
|
+
seen_names.add(key)
|
|
3088
|
+
stats.append(stat)
|
|
3089
|
+
shown_items.append(item)
|
|
3090
|
+
items = shown_items
|
|
3091
|
+
|
|
3092
|
+
# Deliberately no quote fragment here. The design called for one
|
|
3093
|
+
# model-written phrase on the sharpest negative drift ("... ↓ \"the lid
|
|
3094
|
+
# jams\""), but this footer is rendered by the engine *before* the model
|
|
3095
|
+
# ever sees the report, and the model passes it through verbatim -- so
|
|
3096
|
+
# there is no weave-time write path for the model to supply one. Rather
|
|
3097
|
+
# than ship a branch that can never fire, the quote is deferred: the
|
|
3098
|
+
# same evidence reaches the reader through the body section's
|
|
3099
|
+
# Loved/Gripes/Watch line, which the model does author. `footer_entry`
|
|
3100
|
+
# still accepts a quote so a future writer can supply one.
|
|
3101
|
+
entries = [amazon.footer_entry(s) for s in stats]
|
|
3102
|
+
line = f"📦 Amazon: {count} {plural} │ {', '.join(entries)}"
|
|
3103
|
+
return line
|
|
3104
|
+
|
|
3105
|
+
|
|
3106
|
+
# Longest advertiser or candidate name the footer will print. The footer is one
|
|
3107
|
+
# scannable line inside a box; an unbounded source-controlled name would push
|
|
3108
|
+
# every other source's line out of view.
|
|
3109
|
+
_META_ADS_NAME_MAX = 32
|
|
3110
|
+
|
|
3111
|
+
|
|
3112
|
+
def _footer_safe(value: str, limit: int = _META_ADS_NAME_MAX) -> str:
|
|
3113
|
+
"""Make a source-controlled string safe to interpolate into a footer line.
|
|
3114
|
+
|
|
3115
|
+
The advertiser name, the closest-candidate name, and the promo codes all
|
|
3116
|
+
come from the upstream API, so they are attacker-influenceable. Left raw, a
|
|
3117
|
+
name carrying a newline or the footer's own separator could forge extra
|
|
3118
|
+
footer rows in the passed-through emoji tree, which the model relays
|
|
3119
|
+
verbatim. Collapse control characters and the separator, then clip.
|
|
3120
|
+
"""
|
|
3121
|
+
cleaned = _sanitize_url_for_single_line_output(str(value or "")).replace("│", "|")
|
|
3122
|
+
cleaned = " ".join(cleaned.split()).strip()
|
|
3123
|
+
if len(cleaned) > limit:
|
|
3124
|
+
cleaned = cleaned[:limit].rstrip() + "…"
|
|
3125
|
+
return cleaned
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
_PLACEMENT_SHORT = {
|
|
3129
|
+
"FACEBOOK": "FB",
|
|
3130
|
+
"INSTAGRAM": "IG",
|
|
3131
|
+
"THREADS": "Threads",
|
|
3132
|
+
"MESSENGER": "Messenger",
|
|
3133
|
+
"WHATSAPP": "WhatsApp",
|
|
3134
|
+
"AUDIENCE_NETWORK": "Audience",
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
|
|
3138
|
+
def _meta_ads_footer_line(report: schema.Report) -> str | None:
|
|
3139
|
+
"""Build the 📣 Meta Ads emoji-footer line.
|
|
3140
|
+
|
|
3141
|
+
Every count comes from the adapter's tally, never from ``items_by_source``.
|
|
3142
|
+
The pipeline truncates each source's stream to a per-depth limit (12 at
|
|
3143
|
+
default) before rendering, so counting surviving items would report 12
|
|
3144
|
+
creatives for a page that ran thirty, and would under-count the video and
|
|
3145
|
+
transcript work that was actually paid for.
|
|
3146
|
+
|
|
3147
|
+
Four shapes, because an empty line is never the right answer here -- if the
|
|
3148
|
+
lane ran and found nothing, the reader needs to know which nothing it was:
|
|
3149
|
+
|
|
3150
|
+
* **Resolved with creatives** -- advertiser, what launched this window, and
|
|
3151
|
+
the paid-media detail (placements, promo codes, transcripts).
|
|
3152
|
+
* **Resolved, nothing new** -- name the advertiser and how much it is still
|
|
3153
|
+
running from before, so "no ads" is not confused with "not advertising".
|
|
3154
|
+
* **Unresolved** -- name the closest candidate and point at the override.
|
|
3155
|
+
* **Failed** -- name the outcome rather than implying an empty Ad Library.
|
|
3156
|
+
"""
|
|
3157
|
+
tally = (report.artifacts or {}).get("meta_ads_tally") or {}
|
|
3158
|
+
page = (report.artifacts or {}).get("meta_ads_page") or {}
|
|
3159
|
+
outcome = report.source_status.get("meta_ads")
|
|
3160
|
+
if not tally and not page and outcome is None:
|
|
3161
|
+
return None
|
|
3162
|
+
|
|
3163
|
+
# Three distinct states, and conflating any two of them misleads:
|
|
3164
|
+
# failed -- the lane could not run; say so, never "found nothing".
|
|
3165
|
+
# partial -- it ran and was cut short; its counts are a floor, so a
|
|
3166
|
+
# "nothing new" conclusion drawn from them would be a
|
|
3167
|
+
# claim the data does not support.
|
|
3168
|
+
# no results -- it ran to completion and the answer is genuinely empty.
|
|
3169
|
+
# NO_RESULTS is what the pipeline stamps on any zero-item source, so
|
|
3170
|
+
# treating it as failure would collapse every honest empty state below into
|
|
3171
|
+
# a generic "no ads pulled".
|
|
3172
|
+
state = getattr(outcome, "state", None)
|
|
3173
|
+
detail = str(getattr(outcome, "detail", "") or "").strip()
|
|
3174
|
+
if outcome and state not in (health.OK, schema.PARTIAL, schema.NO_RESULTS):
|
|
3175
|
+
return f"📣 Meta Ads: no ads pulled ({detail})" if detail else "📣 Meta Ads: no ads pulled"
|
|
3176
|
+
cut_short = state == schema.PARTIAL
|
|
3177
|
+
|
|
3178
|
+
resolution = str(tally.get("resolution") or "")
|
|
3179
|
+
if resolution == meta_ads.NO_CANDIDATES:
|
|
3180
|
+
return "📣 Meta Ads: no advertiser candidates returned │ pass --meta-ads-page to target one"
|
|
3181
|
+
if resolution == meta_ads.UNRESOLVED or not page:
|
|
3182
|
+
candidate = _footer_safe(tally.get("top_candidate") or "")
|
|
3183
|
+
if candidate:
|
|
3184
|
+
return (
|
|
3185
|
+
f"📣 Meta Ads: no advertiser matched │ closest: {candidate} "
|
|
3186
|
+
f"│ pass --meta-ads-page to target one"
|
|
3187
|
+
)
|
|
3188
|
+
return "📣 Meta Ads: no advertiser matched │ pass --meta-ads-page to target one"
|
|
3189
|
+
|
|
3190
|
+
advertiser = _footer_safe(page.get("name") or tally.get("advertiser") or "")
|
|
3191
|
+
launched = int(tally.get("launched_in_window") or 0)
|
|
3192
|
+
still = int(tally.get("still_running") or 0)
|
|
3193
|
+
|
|
3194
|
+
if not launched:
|
|
3195
|
+
# A cut-short lane never reached the end of the page, so "no new
|
|
3196
|
+
# creatives" would state a conclusion its own data cannot support.
|
|
3197
|
+
if cut_short:
|
|
3198
|
+
head = f"📣 Meta Ads: {advertiser} │ incomplete".rstrip()
|
|
3199
|
+
parts = [head, "no new creatives seen before the run was cut short"]
|
|
3200
|
+
if detail:
|
|
3201
|
+
parts.append(_footer_safe(detail, 60))
|
|
3202
|
+
return " │ ".join(parts)
|
|
3203
|
+
parts = [f"📣 Meta Ads: no new creatives for {advertiser}".rstrip()]
|
|
3204
|
+
if still:
|
|
3205
|
+
parts.append(f"{still} still running from before")
|
|
3206
|
+
return " │ ".join(parts)
|
|
3207
|
+
|
|
3208
|
+
fetched = int(tally.get("fetched") or 0)
|
|
3209
|
+
total = int(tally.get("endpoint_total") or 0)
|
|
3210
|
+
# A page bigger than the depth cap is a sample, and saying so is the
|
|
3211
|
+
# difference between "this brand launched 12 creatives" and "we looked at
|
|
3212
|
+
# 60 of its 222 live ads and 12 of those were new". The two numbers are
|
|
3213
|
+
# different units on purpose -- deduped creatives against raw ads -- so
|
|
3214
|
+
# both get named rather than sharing one bare noun.
|
|
3215
|
+
if tally.get("cursor_remaining") and total > fetched > 0:
|
|
3216
|
+
head = f"{launched} new of {fetched} ads fetched ({total:,} live)"
|
|
3217
|
+
else:
|
|
3218
|
+
head = f"{launched} new {'creative' if launched == 1 else 'creatives'}"
|
|
3219
|
+
|
|
3220
|
+
parts = [f"📣 Meta Ads: {advertiser} │ {head}"] if advertiser else [f"📣 Meta Ads: {head}"]
|
|
3221
|
+
if cut_short:
|
|
3222
|
+
parts.append("incomplete")
|
|
3223
|
+
if still:
|
|
3224
|
+
parts.append(f"{still} running from before")
|
|
3225
|
+
# Resolution that rested on substring containment is the weakest tier the
|
|
3226
|
+
# resolver accepts, so the reader is told to check the advertiser rather
|
|
3227
|
+
# than left to assume the name was confirmed.
|
|
3228
|
+
if str(tally.get("match_strength") or "") == meta_ads.MATCH_CONTAINED:
|
|
3229
|
+
parts.append("matched by partial name")
|
|
3230
|
+
placements = [
|
|
3231
|
+
_PLACEMENT_SHORT.get(str(p).upper(), _footer_safe(p, 12))
|
|
3232
|
+
for p in (tally.get("placements") or [])
|
|
3233
|
+
]
|
|
3234
|
+
if placements:
|
|
3235
|
+
parts.append(", ".join(placements))
|
|
3236
|
+
codes = [_footer_safe(c, 16) for c in (tally.get("promo_codes") or []) if c]
|
|
3237
|
+
if codes:
|
|
3238
|
+
parts.append(f"code {', '.join(codes)}")
|
|
3239
|
+
transcribed = int(tally.get("transcribed") or 0)
|
|
3240
|
+
if transcribed:
|
|
3241
|
+
parts.append(f"{transcribed} transcribed")
|
|
3242
|
+
return " │ ".join(parts)
|
|
3243
|
+
|
|
3244
|
+
|
|
1582
3245
|
def _top_voices_footer_line(report: schema.Report) -> str | None:
|
|
1583
3246
|
"""Return the 🗣️ Top voices line or None if no meaningful voices exist.
|
|
1584
3247
|
|
|
@@ -1587,7 +3250,15 @@ def _top_voices_footer_line(report: schema.Report) -> str | None:
|
|
|
1587
3250
|
"""
|
|
1588
3251
|
handle_items = {
|
|
1589
3252
|
source: report.items_by_source.get(source) or []
|
|
1590
|
-
for source in (
|
|
3253
|
+
for source in (
|
|
3254
|
+
"x",
|
|
3255
|
+
"bluesky",
|
|
3256
|
+
"truthsocial",
|
|
3257
|
+
"youtube",
|
|
3258
|
+
"tiktok",
|
|
3259
|
+
"instagram",
|
|
3260
|
+
"threads",
|
|
3261
|
+
)
|
|
1591
3262
|
}
|
|
1592
3263
|
handle_counts: Counter[str] = Counter()
|
|
1593
3264
|
for items in handle_items.values():
|
|
@@ -1617,22 +3288,36 @@ def _render_emoji_footer(report: schema.Report, save_path: str | None) -> list[s
|
|
|
1617
3288
|
"""Produce the deterministic magic footer block.
|
|
1618
3289
|
|
|
1619
3290
|
Returns a list of markdown lines, including enclosing ``---`` separators.
|
|
1620
|
-
Returns an empty list
|
|
3291
|
+
Returns an empty list only when there is nothing to report - no populated
|
|
3292
|
+
sources, no top voices, and no save path. When every source returned zero
|
|
3293
|
+
items but a save path exists, the banner and the 'Raw results saved to' line
|
|
3294
|
+
still render so the durable raw-file citation is never silently dropped.
|
|
1621
3295
|
"""
|
|
1622
3296
|
source_lines = _build_source_footer_lines(report)
|
|
1623
|
-
if not source_lines:
|
|
1624
|
-
return []
|
|
1625
|
-
|
|
1626
3297
|
voices_line = _top_voices_footer_line(report)
|
|
3298
|
+
# The freshness verdict is computed for the report body, but a reader who
|
|
3299
|
+
# only scans this footer never sees it — and it is the one line that says
|
|
3300
|
+
# how much of the evidence is actually recent.
|
|
3301
|
+
freshness_warning = _assess_data_freshness(report)
|
|
3302
|
+
freshness_line = f"🕒 {freshness_warning}" if freshness_warning else None
|
|
1627
3303
|
raw_line = f"📎 Raw results saved to {save_path}" if save_path else None
|
|
1628
3304
|
|
|
1629
3305
|
body: list[str] = []
|
|
1630
3306
|
body.extend(source_lines)
|
|
1631
3307
|
if voices_line:
|
|
1632
3308
|
body.append(voices_line)
|
|
3309
|
+
# Append freshness whenever it would annotate something: either the body
|
|
3310
|
+
# already has content, or the raw-results line will make the footer
|
|
3311
|
+
# non-empty. An otherwise empty run stays silent rather than announcing
|
|
3312
|
+
# its own emptiness.
|
|
3313
|
+
if freshness_line and (body or raw_line):
|
|
3314
|
+
body.append(freshness_line)
|
|
1633
3315
|
if raw_line:
|
|
1634
3316
|
body.append(raw_line)
|
|
1635
3317
|
|
|
3318
|
+
if not body:
|
|
3319
|
+
return []
|
|
3320
|
+
|
|
1636
3321
|
# Apply tree characters: ├─ for all but the last body line, └─ for the last.
|
|
1637
3322
|
tree_lines: list[str] = []
|
|
1638
3323
|
for i, line in enumerate(body):
|
|
@@ -1691,6 +3376,10 @@ def _render_stats(report: schema.Report) -> list[str]:
|
|
|
1691
3376
|
actor_summary = _top_actor_summary(source, items)
|
|
1692
3377
|
if actor_summary:
|
|
1693
3378
|
parts.append(actor_summary)
|
|
3379
|
+
provenance = report.artifacts.get("x_provenance") if source == "x" else None
|
|
3380
|
+
if provenance in x_envelope.PROVENANCE_LABELS:
|
|
3381
|
+
# Host-fetched lane (--x-posts): name the provenance in the footer.
|
|
3382
|
+
parts.append(f"via {x_envelope.PROVENANCE_LABELS[provenance]}")
|
|
1694
3383
|
lines.append(f"- {_source_label(source)}: {' | '.join(parts)}")
|
|
1695
3384
|
lines.append("")
|
|
1696
3385
|
return lines
|
|
@@ -1713,7 +3402,9 @@ def _assess_data_freshness(report: schema.Report) -> str | None:
|
|
|
1713
3402
|
item.published_at,
|
|
1714
3403
|
reference_date=report.range_to,
|
|
1715
3404
|
)
|
|
1716
|
-
)
|
|
3405
|
+
)
|
|
3406
|
+
is not None
|
|
3407
|
+
and _days_ago <= 7
|
|
1717
3408
|
]
|
|
1718
3409
|
if len(recent_items) < 3:
|
|
1719
3410
|
return f"Limited recent data: only {len(recent_items)} of {len(dated_items)} dated items are from the last 7 days."
|
|
@@ -1748,21 +3439,30 @@ def _format_actor(item: schema.SourceItem | None) -> str | None:
|
|
|
1748
3439
|
|
|
1749
3440
|
# Per-source engagement display fields: list of (field_name, label) tuples.
|
|
1750
3441
|
ENGAGEMENT_DISPLAY: dict[str, list[tuple[str, str]]] = {
|
|
1751
|
-
"reddit":
|
|
1752
|
-
"x":
|
|
1753
|
-
"youtube":
|
|
1754
|
-
"tiktok":
|
|
1755
|
-
"instagram":
|
|
1756
|
-
"threads":
|
|
1757
|
-
"pinterest":
|
|
1758
|
-
"hackernews":
|
|
1759
|
-
"
|
|
1760
|
-
"
|
|
1761
|
-
"
|
|
1762
|
-
"
|
|
1763
|
-
"
|
|
1764
|
-
"
|
|
1765
|
-
|
|
3442
|
+
"reddit": [("score", "pts"), ("num_comments", "cmt")],
|
|
3443
|
+
"x": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
|
|
3444
|
+
"youtube": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
|
|
3445
|
+
"tiktok": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
|
|
3446
|
+
"instagram": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
|
|
3447
|
+
"threads": [("likes", "likes"), ("replies", "re")],
|
|
3448
|
+
"pinterest": [("saves", "saves"), ("comments", "cmt")],
|
|
3449
|
+
"hackernews": [("points", "pts"), ("comments", "cmt")],
|
|
3450
|
+
"devto": [("reactions", "react"), ("comments", "cmt")],
|
|
3451
|
+
"bluesky": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
|
|
3452
|
+
"truthsocial": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
|
|
3453
|
+
"linkedin": [("likes", "likes"), ("comments", "cmt")],
|
|
3454
|
+
"polymarket": [],
|
|
3455
|
+
"github": [
|
|
3456
|
+
("stars", "stars"),
|
|
3457
|
+
("merged_prs", "merged"),
|
|
3458
|
+
("reactions", "react"),
|
|
3459
|
+
("comments", "cmt"),
|
|
3460
|
+
],
|
|
3461
|
+
"perplexity": [("citations", "cite")],
|
|
3462
|
+
"digg": [("postCount", "posts"), ("uniqueAuthors", "auth")],
|
|
3463
|
+
"trustpilot": [("reviews", "reviews")],
|
|
3464
|
+
"amazon": [("ratings", "ratings")],
|
|
3465
|
+
"meta_ads": [("variants", "variants")],
|
|
1766
3466
|
}
|
|
1767
3467
|
|
|
1768
3468
|
|
|
@@ -1832,7 +3532,9 @@ def _top_actor_summary(source: str, items: list[schema.SourceItem]) -> str | Non
|
|
|
1832
3532
|
return f"{label}: {', '.join(actors)}"
|
|
1833
3533
|
|
|
1834
3534
|
|
|
1835
|
-
def _top_actors_for_source(
|
|
3535
|
+
def _top_actors_for_source(
|
|
3536
|
+
source: str, items: list[schema.SourceItem], limit: int = 3
|
|
3537
|
+
) -> list[str]:
|
|
1836
3538
|
counts: Counter[str] = Counter()
|
|
1837
3539
|
for item in items:
|
|
1838
3540
|
actor = _stats_actor(item)
|
|
@@ -1841,7 +3543,9 @@ def _top_actors_for_source(source: str, items: list[schema.SourceItem], limit: i
|
|
|
1841
3543
|
return [actor for actor, _ in counts.most_common(limit)]
|
|
1842
3544
|
|
|
1843
3545
|
|
|
1844
|
-
def _top_voices_overall(
|
|
3546
|
+
def _top_voices_overall(
|
|
3547
|
+
items_by_source: dict[str, list[schema.SourceItem]], limit: int = 5
|
|
3548
|
+
) -> list[str]:
|
|
1845
3549
|
counts: Counter[str] = Counter()
|
|
1846
3550
|
for items in items_by_source.values():
|
|
1847
3551
|
for item in items:
|
|
@@ -1890,13 +3594,22 @@ _TOP_COMMENT_MIN_SCORE: dict[str, int] = {
|
|
|
1890
3594
|
"reddit": 10,
|
|
1891
3595
|
"youtube": 50,
|
|
1892
3596
|
"tiktok": 500,
|
|
1893
|
-
"
|
|
3597
|
+
"instagram": 5,
|
|
3598
|
+
# Zero, not a tuned floor: the Algolia items endpoint returns points=null
|
|
3599
|
+
# for every comment child (only stories carry points), so any positive
|
|
3600
|
+
# threshold here rejects the entire source rather than filtering it.
|
|
3601
|
+
"hackernews": 0,
|
|
3602
|
+
# Replies are the only comment signal the dev.to API exposes; most top
|
|
3603
|
+
# comments have none, so any positive floor would drop the whole source.
|
|
3604
|
+
"devto": 0,
|
|
1894
3605
|
}
|
|
1895
3606
|
_TOP_COMMENT_VOTE_LABEL: dict[str, str] = {
|
|
1896
3607
|
"reddit": "upvotes",
|
|
1897
3608
|
"hackernews": "points",
|
|
3609
|
+
"devto": "replies",
|
|
1898
3610
|
"youtube": "likes",
|
|
1899
3611
|
"tiktok": "likes",
|
|
3612
|
+
"instagram": "likes",
|
|
1900
3613
|
}
|
|
1901
3614
|
|
|
1902
3615
|
|
|
@@ -1931,11 +3644,33 @@ def _comment_attribution(source: str | None, author: str | None) -> str:
|
|
|
1931
3644
|
# Some sources (YouTube/TikTok) already store the author with a leading '@';
|
|
1932
3645
|
# strip it before re-prefixing so we don't emit '@@handle'.
|
|
1933
3646
|
if prefix and author.startswith(prefix):
|
|
1934
|
-
author = author[len(prefix):]
|
|
3647
|
+
author = author[len(prefix) :]
|
|
1935
3648
|
return f"{prefix}{author}" if prefix else author
|
|
1936
3649
|
|
|
1937
3650
|
|
|
1938
|
-
def
|
|
3651
|
+
def _comment_link(item: schema.SourceItem, comment: dict, fallback: str = "") -> str:
|
|
3652
|
+
"""Link for a quoted comment: where it was said, never the HN article.
|
|
3653
|
+
|
|
3654
|
+
HN items carry the article as their URL, so HN comments fall back to the
|
|
3655
|
+
item's discussion page (``hn_url`` metadata, else built from a numeric item
|
|
3656
|
+
id) instead of ``fallback``. Other sources keep ``fallback``.
|
|
3657
|
+
"""
|
|
3658
|
+
if comment.get("url"):
|
|
3659
|
+
return str(comment["url"])
|
|
3660
|
+
if item.source != "hackernews":
|
|
3661
|
+
return fallback or ""
|
|
3662
|
+
hn_url = (item.metadata or {}).get("hn_url")
|
|
3663
|
+
if hn_url:
|
|
3664
|
+
return str(hn_url)
|
|
3665
|
+
item_id = str(item.item_id or "")
|
|
3666
|
+
if item_id.isdigit():
|
|
3667
|
+
return f"https://news.ycombinator.com/item?id={item_id}"
|
|
3668
|
+
return ""
|
|
3669
|
+
|
|
3670
|
+
|
|
3671
|
+
def _top_comments_list(
|
|
3672
|
+
item: schema.SourceItem | None, limit: int = 3, min_score: int | None = None
|
|
3673
|
+
) -> list[dict]:
|
|
1939
3674
|
"""Return up to `limit` top comments with score at or above the source's minimum.
|
|
1940
3675
|
|
|
1941
3676
|
If `min_score` is passed explicitly it overrides the per-source default;
|
|
@@ -2004,24 +3739,15 @@ def _source_label(source: str) -> str:
|
|
|
2004
3739
|
return SOURCE_LABELS.get(source, source.replace("_", " ").title())
|
|
2005
3740
|
|
|
2006
3741
|
|
|
2007
|
-
|
|
2008
3742
|
def _best_take_relevance_ok(candidate) -> bool:
|
|
2009
3743
|
"""Exclude off-topic-but-viral candidates from Best Takes.
|
|
2010
3744
|
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
Plain ``fallback-local-score`` (without entity-miss) is NOT a demotion --
|
|
2016
|
-
it is the default reason when LLM rerank didn't score an item -- so it is
|
|
2017
|
-
not gated here.
|
|
3745
|
+
Delegates to ``rerank.candidate_relevance_ok``, which owns the entity-miss
|
|
3746
|
+
demotion test. Do not re-implement the check here: this site previously
|
|
3747
|
+
carried its own copy, which meant the first-party carve-out applied in
|
|
3748
|
+
rerank never reached Best Takes or cluster visibility.
|
|
2018
3749
|
"""
|
|
2019
|
-
|
|
2020
|
-
if "entity-miss" in explanation:
|
|
2021
|
-
return False
|
|
2022
|
-
if (candidate.final_score or 0.0) <= 0.0:
|
|
2023
|
-
return False
|
|
2024
|
-
return True
|
|
3750
|
+
return rerank.candidate_relevance_ok(candidate)
|
|
2025
3751
|
|
|
2026
3752
|
|
|
2027
3753
|
def _effective_fun_score(candidate, vote_weight: float) -> float:
|
|
@@ -2040,32 +3766,84 @@ def _effective_fun_score(candidate, vote_weight: float) -> float:
|
|
|
2040
3766
|
return base + vote_weight * confidence * vote_signal
|
|
2041
3767
|
|
|
2042
3768
|
|
|
2043
|
-
def _render_best_takes(
|
|
3769
|
+
def _render_best_takes(
|
|
3770
|
+
candidates,
|
|
3771
|
+
limit=5,
|
|
3772
|
+
threshold=70.0,
|
|
3773
|
+
vote_weight=_FUN_LEVELS["medium"]["vote_weight"],
|
|
3774
|
+
source_weight=None,
|
|
3775
|
+
):
|
|
2044
3776
|
eligible = [
|
|
2045
|
-
c
|
|
3777
|
+
c
|
|
3778
|
+
for c in candidates
|
|
2046
3779
|
if c.fun_score is not None
|
|
2047
3780
|
and c.fun_score >= _BEST_TAKE_FUNNY_FLOOR
|
|
2048
3781
|
and _best_take_relevance_ok(c)
|
|
2049
3782
|
]
|
|
2050
3783
|
scored = [(c, _effective_fun_score(c, vote_weight)) for c in eligible]
|
|
3784
|
+
# Audience presets promote sources INSIDE the ranking (a pre-sort of the
|
|
3785
|
+
# input is discarded by this sort): weight the ordering, not the
|
|
3786
|
+
# threshold, so emphasis reorders takes without inventing eligibility.
|
|
3787
|
+
rank_key = (
|
|
3788
|
+
(lambda pair: -pair[1] * source_weight(pair[0].source))
|
|
3789
|
+
if source_weight
|
|
3790
|
+
else (lambda pair: -pair[1])
|
|
3791
|
+
)
|
|
2051
3792
|
# Carry the effective score forward so the display loop doesn't recompute it.
|
|
2052
|
-
gems = [(c, eff) for c, eff in sorted(scored, key=
|
|
3793
|
+
gems = [(c, eff) for c, eff in sorted(scored, key=rank_key) if eff >= threshold]
|
|
2053
3794
|
if len(gems) < 2:
|
|
2054
3795
|
return []
|
|
2055
3796
|
lines = ["## Best Takes", ""]
|
|
2056
3797
|
for candidate, effective in gems[:limit]:
|
|
2057
3798
|
text = candidate.title.strip()
|
|
3799
|
+
selected_comment_item = None
|
|
3800
|
+
hackernews_take = None
|
|
2058
3801
|
for item in candidate.source_items:
|
|
2059
3802
|
for comment in item.metadata.get("top_comments", [])[:3]:
|
|
2060
|
-
body = (
|
|
3803
|
+
body = (
|
|
3804
|
+
(
|
|
3805
|
+
comment.get("body")
|
|
3806
|
+
or comment.get("text")
|
|
3807
|
+
or (
|
|
3808
|
+
comment.get("excerpt")
|
|
3809
|
+
if item.source == "hackernews"
|
|
3810
|
+
else ""
|
|
3811
|
+
)
|
|
3812
|
+
or ""
|
|
3813
|
+
)
|
|
3814
|
+
if isinstance(comment, dict)
|
|
3815
|
+
else str(comment)
|
|
3816
|
+
)
|
|
2061
3817
|
body = body.strip()
|
|
2062
|
-
if
|
|
3818
|
+
if not body or len(body) <= 10:
|
|
3819
|
+
continue
|
|
3820
|
+
if item.source == "hackernews" and (
|
|
3821
|
+
hackernews_take is None or len(body) < len(hackernews_take[0])
|
|
3822
|
+
):
|
|
3823
|
+
hackernews_take = (body, item)
|
|
3824
|
+
elif hackernews_take is None and len(body) < len(text):
|
|
2063
3825
|
text = body
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
3826
|
+
selected_comment_item = item
|
|
3827
|
+
if hackernews_take is not None:
|
|
3828
|
+
text, selected_comment_item = hackernews_take
|
|
3829
|
+
attribution_item = (
|
|
3830
|
+
selected_comment_item
|
|
3831
|
+
or (candidate.source_items[0] if candidate.source_items else None)
|
|
3832
|
+
)
|
|
3833
|
+
attribution_source = (
|
|
3834
|
+
selected_comment_item.source
|
|
3835
|
+
if selected_comment_item is not None
|
|
3836
|
+
else candidate.source
|
|
3837
|
+
)
|
|
3838
|
+
source_label = _source_label(attribution_source)
|
|
3839
|
+
author = attribution_item.author if attribution_item else None
|
|
3840
|
+
attribution = (
|
|
3841
|
+
f"@{author} on {source_label}"
|
|
3842
|
+
if author and attribution_source in ("x", "tiktok", "instagram", "threads")
|
|
3843
|
+
else f"{source_label}"
|
|
3844
|
+
)
|
|
3845
|
+
if author and attribution_source == "reddit":
|
|
3846
|
+
container = attribution_item.container if attribution_item else None
|
|
2069
3847
|
attribution = f"r/{container} comment" if container else "Reddit"
|
|
2070
3848
|
# fun: is the LLM humor score; flag when crowd votes materially lifted
|
|
2071
3849
|
# this item's ranking, so a lower-fun item ranking above a higher-fun one
|
|
@@ -2073,12 +3851,25 @@ def _render_best_takes(candidates, limit=5, threshold=70.0, vote_weight=_FUN_LEV
|
|
|
2073
3851
|
crowd_boost = effective - (candidate.fun_score or 0.0)
|
|
2074
3852
|
crowd_tag = " +crowd" if crowd_boost >= 5.0 else ""
|
|
2075
3853
|
score_tag = f"(fun:{candidate.fun_score:.0f}{crowd_tag})"
|
|
2076
|
-
reason =
|
|
2077
|
-
|
|
3854
|
+
reason = (
|
|
3855
|
+
f" -- {candidate.fun_explanation}"
|
|
3856
|
+
if candidate.fun_explanation
|
|
3857
|
+
and candidate.fun_explanation != "heuristic-fallback"
|
|
3858
|
+
else ""
|
|
3859
|
+
)
|
|
3860
|
+
lines.append(
|
|
3861
|
+
f'- "{_format_untrusted_evidence(text, 280, continuation_indent=" ")}" '
|
|
3862
|
+
f"-- {attribution} {score_tag}{reason}"
|
|
3863
|
+
)
|
|
2078
3864
|
return lines
|
|
2079
3865
|
|
|
2080
3866
|
|
|
2081
|
-
def _render_top_comments(
|
|
3867
|
+
def _render_top_comments(
|
|
3868
|
+
report,
|
|
3869
|
+
limit: int = 8,
|
|
3870
|
+
*,
|
|
3871
|
+
candidates: list[schema.Candidate] | None = None,
|
|
3872
|
+
) -> list[str]:
|
|
2082
3873
|
"""Vote-ranked community comments across ALL ranked candidates — not just the
|
|
2083
3874
|
top-cluster representatives — surfaced into the EVIDENCE block so the reading
|
|
2084
3875
|
model can weave the funniest/highest-engagement lines into the synthesis.
|
|
@@ -2093,33 +3884,87 @@ def _render_top_comments(report, limit: int = 8) -> list[str]:
|
|
|
2093
3884
|
"""
|
|
2094
3885
|
seen: set[str] = set()
|
|
2095
3886
|
scored: list[tuple[float, schema.Candidate, schema.SourceItem, dict, str]] = []
|
|
2096
|
-
|
|
3887
|
+
candidate_pool = report.ranked_candidates if candidates is None else candidates
|
|
3888
|
+
floor_candidates = [
|
|
3889
|
+
cand
|
|
3890
|
+
for cand in candidate_pool
|
|
3891
|
+
if _best_take_relevance_ok(cand)
|
|
3892
|
+
and (cand.local_relevance or 0.0) >= relevance.RELEVANCE_FLOOR
|
|
3893
|
+
]
|
|
3894
|
+
apply_relevance_floor = len(floor_candidates) >= relevance.MIN_ON_TOPIC
|
|
3895
|
+
for cand in candidate_pool:
|
|
3896
|
+
if not _best_take_relevance_ok(cand):
|
|
3897
|
+
continue
|
|
3898
|
+
# Skip comments from off-topic threads when enough candidates clear the
|
|
3899
|
+
# floor; sparse niche topics still surface their best comments (#641).
|
|
3900
|
+
if (
|
|
3901
|
+
apply_relevance_floor
|
|
3902
|
+
and (cand.local_relevance or 0.0) < relevance.RELEVANCE_FLOOR
|
|
3903
|
+
):
|
|
3904
|
+
continue
|
|
2097
3905
|
for item in cand.source_items:
|
|
2098
|
-
#
|
|
2099
|
-
# the
|
|
2100
|
-
|
|
3906
|
+
# Pass min_score=0 here: the cross-platform list deliberately does
|
|
3907
|
+
# NOT gate on the per-platform absolute floor, because a less-watched
|
|
3908
|
+
# video's killer low-vote top comment is gold too. The 3-per-item cap
|
|
3909
|
+
# still applies; cross-platform fairness is handled by the rank-based
|
|
3910
|
+
# round-robin below, and the model makes the final quotable pick.
|
|
3911
|
+
for tc in _top_comments_list(item, min_score=0):
|
|
2101
3912
|
if not isinstance(tc, dict):
|
|
2102
3913
|
continue
|
|
2103
|
-
body = (
|
|
3914
|
+
body = (
|
|
3915
|
+
tc.get("excerpt") or tc.get("text") or tc.get("body") or ""
|
|
3916
|
+
).strip()
|
|
2104
3917
|
if len(body) < 12:
|
|
2105
3918
|
continue
|
|
2106
3919
|
key = body[:60].lower()
|
|
2107
3920
|
if key in seen:
|
|
2108
3921
|
continue
|
|
2109
3922
|
seen.add(key)
|
|
2110
|
-
strength
|
|
3923
|
+
# Blend vote strength (60%) with thread relevance (40%) so comments
|
|
3924
|
+
# from on-topic threads rank above off-topic viral comments.
|
|
3925
|
+
vote_strength = signals.normalized_comment_vote(
|
|
3926
|
+
item.source, tc.get("score")
|
|
3927
|
+
)
|
|
3928
|
+
strength = 0.6 * vote_strength + 0.4 * (cand.local_relevance or 0.0)
|
|
2111
3929
|
scored.append((strength, cand, item, tc, body))
|
|
2112
3930
|
if len(scored) < 2:
|
|
2113
3931
|
return []
|
|
2114
|
-
|
|
3932
|
+
# Rank-based cross-platform diversity: group by platform, rank each
|
|
3933
|
+
# platform's comments by within-platform vote strength, then interleave by
|
|
3934
|
+
# rank -- every platform's #1, then every #2, then every #3, and so on. This
|
|
3935
|
+
# makes the top-3-of-each-platform outrank the 4th-of-any and guarantees each
|
|
3936
|
+
# platform's #1 a slot, instead of a global vote sort where one viral
|
|
3937
|
+
# platform sweeps the list. Absolute vote counts are NOT compared across
|
|
3938
|
+
# platforms (a less-watched video's killer 50-like comment is gold too);
|
|
3939
|
+
# vote strength only orders comments *within* a platform and breaks ties
|
|
3940
|
+
# among same-rank picks. The model still makes the final quotable pick.
|
|
3941
|
+
by_source: dict[str, list] = {}
|
|
3942
|
+
for row in scored:
|
|
3943
|
+
by_source.setdefault(row[2].source, []).append(row)
|
|
3944
|
+
for src_rows in by_source.values():
|
|
3945
|
+
src_rows.sort(key=lambda row: -row[0])
|
|
3946
|
+
ordered: list = []
|
|
3947
|
+
deepest = max(len(rows) for rows in by_source.values())
|
|
3948
|
+
for rank in range(deepest):
|
|
3949
|
+
tier = [rows[rank] for rows in by_source.values() if len(rows) > rank]
|
|
3950
|
+
tier.sort(key=lambda row: -row[0]) # among same-rank picks, strongest first
|
|
3951
|
+
ordered.extend(tier)
|
|
2115
3952
|
lines = ["## Top Community Comments", ""]
|
|
2116
|
-
for _strength, cand,
|
|
3953
|
+
for _strength, cand, item, tc, body in ordered[:limit]:
|
|
2117
3954
|
score = tc.get("score", "")
|
|
2118
|
-
vote_label = _vote_label_for(
|
|
2119
|
-
attribution = _comment_attribution(
|
|
2120
|
-
url =
|
|
3955
|
+
vote_label = _vote_label_for(item.source)
|
|
3956
|
+
attribution = _comment_attribution(item.source, tc.get("author"))
|
|
3957
|
+
url = _comment_link(item, tc, fallback=cand.url)
|
|
2121
3958
|
url_part = f" — {url}" if url else ""
|
|
2122
|
-
|
|
3959
|
+
vote_part = (
|
|
3960
|
+
f" ({score} {vote_label})"
|
|
3961
|
+
if score is not None and score != ""
|
|
3962
|
+
else ""
|
|
3963
|
+
)
|
|
3964
|
+
lines.append(
|
|
3965
|
+
f'- "{_format_untrusted_evidence(body, 240, continuation_indent=" ")}" '
|
|
3966
|
+
f"— {attribution}{vote_part}{url_part}"
|
|
3967
|
+
)
|
|
2123
3968
|
return lines
|
|
2124
3969
|
|
|
2125
3970
|
|
|
@@ -2128,3 +3973,48 @@ def _truncate(text: str, limit: int) -> str:
|
|
|
2128
3973
|
if len(text) <= limit:
|
|
2129
3974
|
return text
|
|
2130
3975
|
return text[: limit - 3].rstrip() + "..."
|
|
3976
|
+
|
|
3977
|
+
|
|
3978
|
+
_ATX_HEADING_PREFIX = re.compile(r"^(#{1,6})(\s|$)")
|
|
3979
|
+
|
|
3980
|
+
|
|
3981
|
+
def _escape_atx_heading_prefix(line: str) -> str:
|
|
3982
|
+
"""Neutralize leading ATX heading markers so scraped text cannot mint sections."""
|
|
3983
|
+
stripped = line.lstrip()
|
|
3984
|
+
if not stripped:
|
|
3985
|
+
return line
|
|
3986
|
+
leading = line[: len(line) - len(stripped)]
|
|
3987
|
+
match = _ATX_HEADING_PREFIX.match(stripped)
|
|
3988
|
+
if not match:
|
|
3989
|
+
return line
|
|
3990
|
+
hashes = match.group(1)
|
|
3991
|
+
rest = stripped[len(hashes) :]
|
|
3992
|
+
return f"{leading}{'\\#' * len(hashes)}{rest}"
|
|
3993
|
+
|
|
3994
|
+
|
|
3995
|
+
def _format_untrusted_evidence(
|
|
3996
|
+
text: str,
|
|
3997
|
+
limit: int,
|
|
3998
|
+
*,
|
|
3999
|
+
continuation_indent: str = " ",
|
|
4000
|
+
) -> str:
|
|
4001
|
+
"""Truncate scraped text and keep it from injecting markdown structure.
|
|
4002
|
+
|
|
4003
|
+
Multi-line snippets previously broke out of the `` - Evidence:`` indent
|
|
4004
|
+
so a bare ``##`` from a jobs page became a sibling of engine section
|
|
4005
|
+
headings inside the EVIDENCE FOR SYNTHESIS block (#874). Continuation
|
|
4006
|
+
lines stay indented (CommonMark ATX headings need ≤3 leading spaces), and
|
|
4007
|
+
leading ``#`` runs are escaped as defense in depth.
|
|
4008
|
+
|
|
4009
|
+
Indentation stops CommonMark from parsing a forged heading, but it does
|
|
4010
|
+
nothing to an HTML comment a model reads as text, so the engine's own
|
|
4011
|
+
block sentinels are defanged here too (#1053).
|
|
4012
|
+
"""
|
|
4013
|
+
truncated = _truncate(text, limit)
|
|
4014
|
+
if not truncated:
|
|
4015
|
+
return truncated
|
|
4016
|
+
lines = _defang_engine_sentinels(truncated).splitlines()
|
|
4017
|
+
safe: list[str] = [_escape_atx_heading_prefix(lines[0])]
|
|
4018
|
+
for line in lines[1:]:
|
|
4019
|
+
safe.append(continuation_indent + _escape_atx_heading_prefix(line))
|
|
4020
|
+
return "\n".join(safe)
|