Merge pull request 'fix(learning): כרטיס-אוצֵר כן-3-ערוצים + לכידת ממצאי-אוצֵר (source='curator')' (#339) from worktree-curator-learning-surface into main
This commit was merged in pull request #339.
This commit is contained in:
104
web/app.py
104
web/app.py
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user