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:
@@ -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 & 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(); }
|
||||
|
||||
Reference in New Issue
Block a user