Merge pull request 'fix(learning): כרטיס-אוצֵר כן-3-ערוצים + לכידת ממצאי-אוצֵר (source='curator')' (#339) from worktree-curator-learning-surface into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s

This commit was merged in pull request #339.
This commit is contained in:
2026-06-28 21:28:53 +00:00
9 changed files with 392 additions and 58 deletions

View File

@@ -1318,31 +1318,77 @@ async def get_style_analyzer_prompt():
@app.get("/api/training/curator/stats")
async def get_curator_stats():
"""Cheap aggregate stats over decision_lessons + style_corpus.
"""Aggregate over the THREE learning channels that feed the writer, for the
/training "אוצֵר" tab. Each channel is surfaced honestly with its real
backing store and how much of it actually reaches the writer:
Used by the Curator-Portrait tab to show "10 curator findings across 24
decisions". We deliberately keep this server-side and aggregate so the
UI can render a single card without fanning out N queries.
A. distillation — Claude draft↔final diff → methodology overrides
(appeal_type_rules['_global']). Folded items flow to the writer now.
B. panel — DeepSeek+Gemini 2/2 vote → decision_lessons
(source='panel:deepseek+gemini'). Flow to the writer only once the
chair approves (review_status='approved', INV-LRN1/G10).
C. curator — the agent's Opus-read qualitative findings → decision_lessons
(source='curator'). Same chair gate as the panel.
Earlier this endpoint counted ONLY source='curator' and reported it as the
whole story, so it read 0 while the panel/methodology channels carried all
the real learning. The card was measuring a column nothing wrote (the agent
posted findings as Paperclip comments, never as decision_lessons — a
spec↔impl drift since closed). It now reports all three (INV-IA2/IA5).
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
total_lessons = await conn.fetchval(
"SELECT count(*) FROM decision_lessons WHERE source = 'curator'"
total_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus") or 0
# ── Channel A — distillation → methodology overrides ──────────────
meth_rows = await conn.fetch(
"SELECT rule_category, coalesce(jsonb_array_length(rule_value), 0) AS n "
"FROM appeal_type_rules "
"WHERE appeal_type = '_global' "
" AND rule_category IN ('discussion_rules', 'transition_phrases')"
)
decisions_with_findings = await conn.fetchval(
meth = {r["rule_category"]: r["n"] for r in meth_rows}
discussion_rules = meth.get("discussion_rules", 0)
transition_phrases = meth.get("transition_phrases", 0)
pairs_folded = await conn.fetchval(
"SELECT count(*) FROM draft_final_pairs WHERE status = 'lessons_folded'"
) or 0
pairs_total = await conn.fetchval("SELECT count(*) FROM draft_final_pairs") or 0
# ── Channels B & C — decision_lessons by source × review_status ───
ls_rows = await conn.fetch(
"SELECT source, review_status, count(*) AS n FROM decision_lessons "
"GROUP BY source, review_status"
)
def _channel(src_predicate) -> dict:
buckets = {"approved": 0, "proposed": 0, "rejected": 0}
for r in ls_rows:
if src_predicate(r["source"] or ""):
st = r["review_status"] or "proposed"
buckets[st] = buckets.get(st, 0) + r["n"]
buckets["total"] = sum(buckets.values())
return buckets
panel = _channel(lambda s: s.startswith("panel:"))
curator = _channel(lambda s: s == "curator")
panel_decisions = await conn.fetchval(
"SELECT count(DISTINCT style_corpus_id) FROM decision_lessons "
"WHERE source LIKE 'panel:%'"
) or 0
curator_decisions = await conn.fetchval(
"SELECT count(DISTINCT style_corpus_id) FROM decision_lessons "
"WHERE source = 'curator'"
) or 0
pa_rows = await conn.fetch(
"SELECT sc.practice_area, count(*) AS n "
"FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
"WHERE dl.source LIKE 'panel:%' GROUP BY sc.practice_area"
)
total_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus")
# LRN-5 (INV-IA5): count the *real* consumer-mapped gate — review_status
# 'approved' is what flows to the writer (INV-LRN1, #126). The old count
# of applied_to_skill was a KPI over an informative-only flag (LRN-1)
# that writes nowhere, so it reported adoption that never happened.
approved = await conn.fetchval(
"SELECT count(*) FROM decision_lessons "
"WHERE source = 'curator' AND review_status = 'approved'"
)
# Last 10 curator findings — newest first
panel_by_practice = {(r["practice_area"] or ""): r["n"] for r in pa_rows}
# Recent curator findings (channel C) — newest first
recent_rows = await conn.fetch(
"""
SELECT dl.id, dl.lesson_text, dl.category, dl.review_status,
@@ -1355,11 +1401,27 @@ async def get_curator_stats():
LIMIT 10
"""
)
return {
"total_findings": total_lessons or 0,
"decisions_with_findings": decisions_with_findings or 0,
"decisions_total": total_corpus or 0,
"findings_approved": approved or 0,
"decisions_total": total_corpus,
"channels": {
"distillation": {
"discussion_rules": discussion_rules,
"transition_phrases": transition_phrases,
"items_total": discussion_rules + transition_phrases,
"pairs_folded": pairs_folded,
"pairs_total": pairs_total,
},
"panel": {
**panel,
"decisions": panel_decisions,
"by_practice_area": panel_by_practice,
},
"curator": {
**curator,
"decisions_reviewed": curator_decisions,
},
},
"recent_findings": [
{
"id": str(r["id"]),