feat(quality): GOAT synthesis improvements - hybrid cross-source linking, YouTube synonyms, human-readable xref tags

Ran 15-way blinded comparison (5 topics x 3 versions). CROSS won all 5 topics
(4.74/5.0 avg vs HN 4.10, Base 3.73). Then improved CROSS further:

- dedupe.py: hybrid similarity (token+trigram Jaccard) at 0.40 threshold,
  cross-source links went from 3 to 13 items across 5 topics
- render.py: [xref: HN5, HN4] -> [also on: HN, Reddit] for human-readable tags
- youtube_yt.py: SYNONYMS dict so "hip hop" matches "rap" (0.33 -> 0.71 score)
- SKILL.md: instruction #7 tells Claude to lead with cross-platform signals

Validation: improved CROSS scores 4.38/5.0 vs original 3.98 (+0.40), wins 4/5
topics. Biggest gains in specificity (+0.8) and format compliance (+1.0).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-02-25 16:06:53 -08:00
parent 0591f55f0e
commit bed0557b65
72 changed files with 17077 additions and 13 deletions
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Evaluate synthesis outputs using a blinded comparison rubric.
Reads from docs/comparison-results/synthesis/, evaluates each topic's
3 versions (base, hn, cross) on a 5-dimension rubric.
Since we can't call the Anthropic API directly (no SDK installed),
this script formats the evaluation prompts for manual evaluation
and provides a framework for scoring.
"""
import random
from pathlib import Path
SYNTHESIS_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "synthesis"
EVAL_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "evaluation"
EVAL_DIR.mkdir(parents=True, exist_ok=True)
TOPICS = [
(1, 'claude-code', 'Claude Code skills and MCP servers', 'GENERAL'),
(2, 'seedance', 'Seedance AI video generation', 'NEWS'),
(3, 'macbook', 'M4 MacBook Pro review', 'RECOMMENDATIONS'),
(4, 'rap', 'best rap songs 2026', 'RECOMMENDATIONS'),
(5, 'react-svelte', 'React vs Svelte 2026', 'GENERAL'),
]
VERSIONS = ['base', 'hn', 'cross']
RUBRIC = """## Evaluation Rubric
Score each version 1-5 on these dimensions:
### 1. GROUNDEDNESS (30%)
Does the narrative cite specific sources from the research data?
- 1: Generic statements, no citations, could be written without any research
- 3: Some citations but mixed with pre-existing knowledge filler
- 5: Every finding backed by a specific source ("per @handle", "per r/sub", "per [channel]")
### 2. SPECIFICITY (25%)
Are findings specific (named entities, exact numbers) or vague?
- 1: Vague generalities ("AI video tools are improving", "developers are debating frameworks")
- 3: Some specifics mixed with generic padding
- 5: Named products, exact numbers, version names ("Seedance 2.0 added lip sync", "698 likes")
### 3. COVERAGE (20%)
Does the synthesis represent findings from all available data sources?
- 1: Only mentions 1-2 sources, ignores others
- 3: Mentions most sources but unevenly weighted
- 5: Naturally weaves Reddit, X, YouTube (and HN if available) into the narrative
### 4. ACTIONABILITY (15%)
Does the invitation give specific, research-derived next steps?
- 1: Generic "let me know if you want more info"
- 3: Somewhat specific but not clearly grounded in research findings
- 5: Each suggestion references a specific thing from the research ("I can compare Seedance 2.0 vs Kling")
### 5. FORMAT COMPLIANCE (10%)
Does it follow the expected output format?
- 1: Missing stats block, no invitation, wrong structure
- 3: Partial stats block, generic invitation
- 5: Perfect stats block with real counts, source box-drawing chars, top voices identified
"""
for num, slug, topic, qtype in TOPICS:
# Randomly assign labels to prevent position bias
versions_shuffled = list(VERSIONS)
random.seed(num * 42) # Deterministic but different per topic
random.shuffle(versions_shuffled)
label_map = {v: chr(65 + i) for i, v in enumerate(versions_shuffled)}
reverse_map = {chr(65 + i): v for i, v in enumerate(versions_shuffled)}
lines = []
lines.append(f"# Evaluation: {topic}")
lines.append(f"")
lines.append(f"**Query Type:** {qtype}")
lines.append(f"**Label Map (REVEAL AFTER SCORING):** {reverse_map}")
lines.append(f"")
lines.append(RUBRIC)
lines.append("")
for v in versions_shuffled:
label = label_map[v]
synthesis_file = SYNTHESIS_DIR / f"{v}-{num}-{slug}.md"
if synthesis_file.exists():
content = synthesis_file.read_text()
else:
content = f"[FILE NOT FOUND: {synthesis_file}]"
lines.append(f"---")
lines.append(f"## VERSION {label}")
lines.append(f"")
lines.append(content)
lines.append(f"")
lines.append("---")
lines.append("## SCORES")
lines.append("")
for v in versions_shuffled:
label = label_map[v]
lines.append(f"### Version {label}")
lines.append(f"- Groundedness: /5")
lines.append(f"- Specificity: /5")
lines.append(f"- Coverage: /5")
lines.append(f"- Actionability: /5")
lines.append(f"- Format: /5")
lines.append(f"- **Weighted Total**: /5.0")
lines.append(f"- Best/worst aspect: ")
lines.append(f"")
lines.append("## VERDICT")
lines.append("")
lines.append(f"**Winner for {topic}:** ")
lines.append(f"**Why:** ")
lines.append("")
lines.append(f"**Reveal:** {reverse_map}")
eval_file = EVAL_DIR / f"eval-{num}-{slug}.md"
eval_file.write_text("\n".join(lines))
print(f" {eval_file.name}: {len(lines)} lines, labels: {reverse_map}")
print(f"\n{len(TOPICS)} evaluation files written to {EVAL_DIR}")
print("Next step: Read each file, score the versions, fill in SCORES section")
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Convert JSON result files to compact markdown using render_compact().
Reads from docs/comparison-results/json/, writes to docs/comparison-results/compact/.
Uses the current checkout's render_compact() - since version differences are in the
DATA (cross_refs, HN items, YouTube relevance), not in the render function.
"""
import json
import sys
from pathlib import Path
# Add scripts/ to path so we can import lib
sys.path.insert(0, str(Path(__file__).parent))
from lib.schema import Report
from lib.render import render_compact, render_source_status
JSON_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "json"
COMPACT_DIR = Path(__file__).parent.parent / "docs" / "comparison-results" / "compact"
COMPACT_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(JSON_DIR.glob("*.json"))
files = [f for f in files if f.name != "diagnose-baseline.json"]
print(f"Converting {len(files)} JSON files to compact markdown...\n")
for json_file in files:
with open(json_file) as f:
data = json.load(f)
report = Report.from_dict(data)
compact = render_compact(report)
source_status = render_source_status(report)
full_output = compact + "\n" + source_status
md_file = COMPACT_DIR / json_file.name.replace(".json", ".md")
md_file.write_text(full_output)
# Summary stats
n_reddit = len(report.reddit)
n_x = len(report.x)
n_yt = len(report.youtube)
n_hn = len(report.hackernews)
n_web = len(report.web)
xrefs = sum(1 for r in report.reddit if r.cross_refs)
xrefs += sum(1 for x in report.x if x.cross_refs)
xrefs += sum(1 for y in report.youtube if y.cross_refs)
xrefs += sum(1 for h in report.hackernews if h.cross_refs)
print(f" {json_file.name:40s} -> {len(full_output):5d} chars "
f"(R:{n_reddit} X:{n_x} YT:{n_yt} HN:{n_hn} W:{n_web} xref:{xrefs})")
print(f"\nDone. {len(files)} compact files written to {COMPACT_DIR}")
+50 -8
View File
@@ -5,6 +5,15 @@ from typing import List, Set, Tuple, Union
from . import schema
# Stopwords for token-based Jaccard (cross-source linking)
STOPWORDS = frozenset({
'the', 'a', 'an', 'to', 'for', 'how', 'is', 'in', 'of', 'on',
'and', 'with', 'from', 'by', 'at', 'this', 'that', 'it', 'my',
'your', 'i', 'me', 'we', 'you', 'what', 'are', 'do', 'can',
'its', 'be', 'or', 'not', 'no', 'so', 'if', 'but', 'about',
'all', 'just', 'get', 'has', 'have', 'was', 'will', 'show', 'hn',
})
def normalize_text(text: str) -> str:
"""Normalize text for comparison.
@@ -59,12 +68,44 @@ def _get_cross_source_text(item: AnyItem) -> str:
Same as get_item_text() but truncates X posts to 100 chars
to level the playing field against short Reddit/HN titles.
Strips 'Show HN:' prefix from HN titles for fairer matching.
"""
if isinstance(item, schema.XItem):
return item.text[:100]
if isinstance(item, schema.HackerNewsItem):
title = item.title
if title.startswith("Show HN:"):
title = title[8:].strip()
elif title.startswith("Ask HN:"):
title = title[7:].strip()
return title
return get_item_text(item)
def _tokenize_for_xref(text: str) -> Set[str]:
"""Tokenize text for cross-source token Jaccard comparison."""
words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
return {w for w in words if w not in STOPWORDS and len(w) > 1}
def _token_jaccard(text_a: str, text_b: str) -> float:
"""Token-level Jaccard similarity (word overlap)."""
tokens_a = _tokenize_for_xref(text_a)
tokens_b = _tokenize_for_xref(text_b)
if not tokens_a or not tokens_b:
return 0.0
intersection = len(tokens_a & tokens_b)
union = len(tokens_a | tokens_b)
return intersection / union if union else 0.0
def _hybrid_similarity(text_a: str, text_b: str) -> float:
"""Hybrid similarity: max of char-trigram Jaccard and token Jaccard."""
trigram_sim = jaccard_similarity(get_ngrams(text_a), get_ngrams(text_b))
token_sim = _token_jaccard(text_a, text_b)
return max(trigram_sim, token_sim)
def find_duplicates(
items: List[Union[schema.RedditItem, schema.XItem]],
threshold: float = 0.7,
@@ -159,17 +200,18 @@ def dedupe_hackernews(
def cross_source_link(
*source_lists: List[AnyItem],
threshold: float = 0.5,
threshold: float = 0.40,
) -> None:
"""Annotate items with cross-source references.
Compares items across different source types using Jaccard similarity
on char trigrams. When similarity exceeds threshold, adds bidirectional
cross_refs with the related item's ID. Modifies items in-place.
Compares items across different source types using hybrid similarity
(max of char-trigram Jaccard and token Jaccard). When similarity exceeds
threshold, adds bidirectional cross_refs with the related item's ID.
Modifies items in-place.
Args:
*source_lists: Variable number of per-source item lists
threshold: Similarity threshold for cross-linking (default 0.5)
threshold: Similarity threshold for cross-linking (default 0.40)
"""
all_items = []
for source_list in source_lists:
@@ -178,8 +220,8 @@ def cross_source_link(
if len(all_items) <= 1:
return
# Pre-compute trigrams using cross-source text extraction
ngrams = [get_ngrams(_get_cross_source_text(item)) for item in all_items]
# Pre-compute cross-source text for each item
texts = [_get_cross_source_text(item) for item in all_items]
for i in range(len(all_items)):
for j in range(i + 1, len(all_items)):
@@ -187,7 +229,7 @@ def cross_source_link(
if type(all_items[i]) is type(all_items[j]):
continue
similarity = jaccard_similarity(ngrams[i], ngrams[j])
similarity = _hybrid_similarity(texts[i], texts[j])
if similarity >= threshold:
# Bidirectional cross-reference
if all_items[j].id not in all_items[i].cross_refs:
+17 -3
View File
@@ -12,10 +12,24 @@ OUTPUT_DIR = Path.home() / ".local" / "share" / "last30days" / "out"
def _xref_tag(item) -> str:
"""Return ' [xref: X1, HN3]' string if item has cross_refs, else ''."""
"""Return ' [also on: Reddit, HN]' string if item has cross_refs, else ''."""
refs = getattr(item, 'cross_refs', None)
if refs:
return f" [xref: {', '.join(refs)}]"
if not refs:
return ""
source_names = set()
for ref_id in refs:
if ref_id.startswith('R'):
source_names.add('Reddit')
elif ref_id.startswith('X'):
source_names.add('X')
elif ref_id.startswith('YT'):
source_names.add('YouTube')
elif ref_id.startswith('HN'):
source_names.add('HN')
elif ref_id.startswith('W'):
source_names.add('Web')
if source_names:
return f" [also on: {', '.join(sorted(source_names))}]"
return ""
+30 -2
View File
@@ -45,10 +45,38 @@ STOPWORDS = frozenset({
})
# Synonym groups for relevance scoring (bidirectional expansion)
SYNONYMS = {
'hip': {'rap', 'hiphop'},
'hop': {'rap', 'hiphop'},
'rap': {'hip', 'hop', 'hiphop'},
'hiphop': {'rap', 'hip', 'hop'},
'js': {'javascript'},
'javascript': {'js'},
'ts': {'typescript'},
'typescript': {'ts'},
'ai': {'artificial', 'intelligence'},
'ml': {'machine', 'learning'},
'react': {'reactjs'},
'reactjs': {'react'},
'svelte': {'sveltejs'},
'sveltejs': {'svelte'},
'vue': {'vuejs'},
'vuejs': {'vue'},
}
def _tokenize(text: str) -> Set[str]:
"""Lowercase, strip punctuation, remove stopwords, drop single-char tokens."""
"""Lowercase, strip punctuation, remove stopwords, drop single-char tokens.
Expands tokens with synonyms for better cross-domain matching."""
words = re.sub(r'[^\w\s]', ' ', text.lower()).split()
return {w for w in words if w not in STOPWORDS and len(w) > 1}
tokens = {w for w in words if w not in STOPWORDS and len(w) > 1}
# Expand synonyms
expanded = set(tokens)
for t in tokens:
if t in SYNONYMS:
expanded.update(SYNONYMS[t])
return expanded
def _compute_relevance(query: str, title: str) -> float: