diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte
index 7bd1e14..87f7b79 100644
--- a/frontend/src/routes/admin/+page.svelte
+++ b/frontend/src/routes/admin/+page.svelte
@@ -344,7 +344,7 @@
{s.served} |
{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'} |
- {s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'} |
+ {s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'} |
{s.last_success_at ? fwhen(s.last_success_at) : '—'} |
{s.active ? fwhen(s.next_due_at) : '—'} |
{s.failures || ''} |
diff --git a/goodnews/queries.py b/goodnews/queries.py
index 982d82a..5959bce 100644
--- a/goodnews/queries.py
+++ b/goodnews/queries.py
@@ -320,8 +320,12 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
for r in rows:
d = dict(r)
total = d["total_articles"] or 0
- d["acceptance_rate"] = round(100 * d["accepted_total"] / total) if total else None
+ accepted = d["accepted_total"] or 0
+ d["acceptance_rate"] = round(100 * accepted / total) if total else None
d["duplicate_rate"] = round(100 * d["duplicates"] / total) if total else None
+ # Curation quality: of what this source got ACCEPTED, how much was a
+ # duplicate of content already served (accepted_total − served = accepted dupes).
+ d["accepted_dup_rate"] = round(100 * (accepted - d["served"]) / accepted) if accepted else None
out.append(d)
return out
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index c729ca0..1fb3ee7 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -47,6 +47,8 @@ def test_source_health_orders_failing_first_and_computes_next_due(tmp_path):
assert flaky["next_due_at"] is not None
good = next(s for s in sh if s["name"] == "Good")
assert good["served"] == 1 and good["next_due_at"] is None # never attempted → due now
+ # source 1: 2 accepted (art 1, art 3) but art 3 is a duplicate → 50% of accepted were dupes
+ assert good["accepted_dup_rate"] == 50
def test_attention_flags_resting_flagged_and_brief():