Track 3: read-only source preview (vet a feed before adding)

- feeds.preview_feed(): fetch + score a sample WITHOUT persisting; returns
  freshness, acceptance rate, cortisol/ragebait/PR averages, and example
  accepted/rejected items. With an LLM client it also returns topic/flavor mix
  and the model's (accurate) acceptance view.
- CLI 'preview-source URL [--sample] [--classify]'.
- API 'GET /api/source-preview?url=&sample=&classify=' with an http(s)-only
  guard (SSRF note left for go-public hardening).
- Site 'Suggest a source' panel with Quick check (heuristic, instant) and Deep
  check (model, accurate), rendered DOM-safely.
- Tests: network-free preview_feed tests via monkeypatched fetch (45 total).
- README documents the command, endpoint, and updated roadmap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 19:37:34 +00:00
parent cabe0b6049
commit 95195daff8
6 changed files with 304 additions and 5 deletions
+79
View File
@@ -88,6 +88,19 @@
.panel .reset { margin-top: 6px; background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 0.8rem; text-decoration: underline; }
.calm-note { color: var(--muted); font-size: 0.8rem; margin: -8px 0 14px; }
.addsource { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 18px; }
.addsource .hint { color: var(--muted); font-size: 0.82rem; margin: 0 0 10px; }
.addsource-row { display: flex; gap: 8px; flex-wrap: wrap; }
.addsource input { flex: 1 1 240px; border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; font-size: 0.9rem; }
.addbtn { border: 1px solid var(--accent); background: var(--accent); color: #fff;
border-radius: 8px; padding: 7px 14px; cursor: pointer; font-size: 0.85rem; }
.addbtn.ghost { background: none; color: var(--accent); }
.preview-metrics { margin-top: 14px; }
.preview-metrics .stat { font-size: 0.9rem; margin: 2px 0; }
.preview-metrics .label { color: var(--muted); }
.preview-metrics ul { margin: 4px 0 10px; padding-left: 18px; font-size: 0.86rem; }
footer { text-align: center; color: var(--muted); font-size: 0.78rem; padding: 20px; }
footer a { color: var(--accent); }
</style>
@@ -130,6 +143,17 @@
<div id="topic-chips" class="chips"></div>
<div id="flavor-chips" class="chips"></div>
<div id="feed"></div>
<div class="section-title">Suggest a source</div>
<div class="addsource">
<p class="hint">Paste a feed URL to see how calm it is before anyone adds it. Nothing is saved — this just samples and scores recent items.</p>
<div class="addsource-row">
<input id="src-url" type="text" placeholder="https://example.com/feed/" />
<button id="src-quick" class="addbtn">Quick check</button>
<button id="src-deep" class="addbtn ghost">Deep check (uses model)</button>
</div>
<div id="src-result"></div>
</div>
</main>
<footer>
goodNews · metadata &amp; links only, no stored articles ·
@@ -354,6 +378,11 @@
fc.appendChild(chip("all", true, () => setFlavor(null)));
cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key))));
// source preview controls
el("src-quick").onclick = () => previewSource(false);
el("src-deep").onclick = () => previewSource(true);
el("src-url").addEventListener("keydown", (e) => { if (e.key === "Enter") previewSource(false); });
// panel controls
el("calm-toggle").onclick = () => el("panel").classList.toggle("open");
el("term-add").onclick = addTerm;
@@ -363,6 +392,56 @@
refreshAll();
}
async function previewSource(deep) {
const url = el("src-url").value.trim();
const out = el("src-result");
out.replaceChildren();
if (!/^https?:\/\//i.test(url)) {
out.append(node("div", "empty", "Enter a URL starting with http:// or https://"));
return;
}
out.append(node("div", "empty", deep ? "Deep checking with the model — this can take a moment…" : "Checking…"));
try {
const p = await getJSON(`/api/source-preview?url=${encodeURIComponent(url)}&classify=${deep}`);
renderPreview(out, p);
} catch (e) {
out.replaceChildren(node("div", "empty", "Could not read that feed."));
}
}
function renderPreview(out, p) {
out.replaceChildren();
const box = node("div", "preview-metrics");
const stat = (label, value) => {
const d = node("div", "stat");
d.append(node("span", "label", label + " "));
d.append(document.createTextNode(String(value)));
box.append(d);
};
stat("Mode:", p.classified ? "model (accurate)" : "heuristic (quick, conservative)");
stat("Acceptance:", `${Math.round(p.acceptance_rate * 100)}% (${p.accepted}/${p.sampled})`);
stat("Freshness:", `${p.recent_7d}/${p.sampled} in last 7 days · newest ${(p.newest_published||"unknown").slice(0,10)}`);
stat("Calm averages:", `cortisol ${p.avg_cortisol} · ragebait ${p.avg_ragebait} · PR ${p.avg_pr_risk}`);
const mix = (m) => Object.entries(m).map(([k, v]) => `${k} ${v}`).join(" · ") || "—";
if (p.classified) {
stat("Topics:", mix(p.topic_mix));
stat("Flavors:", mix(p.flavor_mix));
}
if (p.examples_accepted.length) {
box.append(node("div", "stat label", "Would surface:"));
const ul = node("ul");
p.examples_accepted.forEach(t => ul.append(node("li", null, t)));
box.append(ul);
}
if (p.examples_rejected.length) {
box.append(node("div", "stat label", "Would skip:"));
const ul = node("ul");
p.examples_rejected.forEach(e => ul.append(node("li", null, e.title)));
box.append(ul);
}
out.append(box);
}
function addTerm() {
const v = el("term-input").value.trim();
if (v && !prefs.avoid_terms.includes(v)) { prefs.avoid_terms.push(v); savePrefs(); }