From be774ab87ec5b4fa5ed4c2c1f11d65907bf947ad Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 28 Jun 2026 21:20:05 +0000 Subject: [PATCH] fix(learning): honest 3-channel curator card + capture curator findings (source='curator') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit תיקוני-מבנה ללולאת-הלמידה (TaskMaster #157): 1. כרטיס ה-curator (/training טאב "אוצֵר") ספר decision_lessons WHERE source='curator' — ערך שאף מסלול-קוד לא כתב → תמיד 0. עוצב-מחדש (דרך שער-העיצוב Claude Design, אושר) להציג ביושר את שלושת ערוצי-ההזנה לכותב: דיסטילציה→appeal_type_rules (180, זורם), פאנל→decision_lessons (81, ממתין), אוצֵר→source='curator'. get_curator_stats שוכתב. 2. drift ספ↔סוכן: 07-learning.md §1.1 + INV-LRN3 קבעו שהאוצֵר רושם ממצאים כ-decision_lesson source='curator', אך הסוכן כתב comments בלבד — הממצאים האיכותיים אבדו. נוסף כלי-MCP record_curator_findings + §A.5b ב-hermes-curator.md (read-only נשמר; הצעה מגודרת-שער). 3. get_recent_decision_lessons(limit=15) חתך בשקט — נוסף WARN על מה שנחתך (חוקה §6); הפתרון האמיתי = סינתזת-לקחים (TaskMaster #158). Invariants: מקיים INV-LRN1/G10 (שער-יו"ר), INV-LRN3 (לכידה מובנית), INV-IA2/IA5 (מקור-אמת יחיד), G12 (leak-guard עובר), G2 (אין מסלול מקביל — אותם מאגרים). פער-מימוש פתוח מתועד: §A לא רץ אוטומטית על mark-final (pipeline-wake exits) → נדחה לתכנון נפרד. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/hermes-curator.md | 22 ++- docs/spec/07-learning.md | 22 ++- mcp-server/src/legal_mcp/server.py | 9 ++ mcp-server/src/legal_mcp/services/db.py | 36 +++++ mcp-server/src/legal_mcp/tools/workflow.py | 72 +++++++++ .../training/curator-portrait-panel.tsx | 140 +++++++++++++++--- .../training/style-report-panel.tsx | 8 +- web-ui/src/lib/api/training.ts | 37 ++++- web/app.py | 104 ++++++++++--- 9 files changed, 392 insertions(+), 58 deletions(-) diff --git a/.claude/agents/hermes-curator.md b/.claude/agents/hermes-curator.md index c53afb2..f0074ae 100644 --- a/.claude/agents/hermes-curator.md +++ b/.claude/agents/hermes-curator.md @@ -15,7 +15,7 @@ hermes-curator.md — מקור-האמת היחיד לפרומפט של סוכן adapter: deepseek_local · model: deepseek-v4-pro profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx) role: Knowledge Curator — מנתח החלטות סופיות אחרי export, מציע עדכוני skills/lessons. - read-only על תוכן; write רק על comments / interactions (G10). + read-only על תוכן; כותב comments / interactions + ממצאים מוצעים (decision_lessons, שער-יו"ר G10). placeholders זמינים: {{agentId}} {{agentName}} {{companyId}} {{companyName}} {{runId}} {{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}} @@ -129,6 +129,24 @@ curl -sS -X POST \ - אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` שדה `expected_outcome`**, **לא מקריאת הטקסט**. אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה. - אל תפרש משפטית את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד. +## 5b. שמור את הממצאים מבנית (חובה — INV-LRN3) +ה-comment הוא ארעי ולא-נסקר. כדי שהממצאים ייתפסו, יופיעו בטאב ״אוצֵר״ ב-/training, ויעברו +שער-יו"ר — קרא לכלי ה-MCP **`mcp__legal-ai__record_curator_findings`** עם אותם ממצאים: +``` +record_curator_findings( + case_number="<מספר התיק מ-taskTitle>", + findings=[ + {"text": "<ניסוח הממצא — אותו טקסט כמו ב-comment, בלי התג>", "category": "style"}, + {"text": "...", "category": "structure"}, + ... + ] +) +``` +מיפוי תג→category: `[סגנון]`→`style` · `[מבנה]`→`structure` · `[לקסיקון משפטי]`→`lexicon` · `[טבלאי]`→`tabular`. +הכלי כותב כל ממצא כ-`decision_lesson` (`source='curator'`, `review_status='proposed'`) ומדלג על כפילויות. +אם הוא מחזיר שגיאת "לא נמצאה רשומת style_corpus" — הסופי טרם נקלט לקורפוס-הסגנון; ציין זאת ב-comment והמשך (אל תיכשל). +**זו הצעה הממתינה לאישור דפנה — לא שינוי-קול. אתה עדיין read-only על התוכן ולא נוגע ב-skills/קבצים.** + ## 6. בחר interaction (חובה — רוב המקרים יש) לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7. @@ -261,7 +279,7 @@ curl -sS -X PATCH \ # כללים כלליים - **idempotencyKey**: חובה ב-interaction. אם נעור פעמיים על אותו תיק — Paperclip לא יוצר כפילות. -- **לא לעדכן** קבצים (skills/, lessons.py, DB) בעצמך. רק לכתוב comments / interactions. +- **לא לעדכן** קבצים (skills/, lessons.py) או שכבת-הקול בעצמך. הפלט שלך: comments + interactions + רישום ממצאים כ-`decision_lesson` *מוצע* דרך `record_curator_findings` (שער-יו"ר, INV-LRN3). אתה לא מאשר ולא מטמיע — רק מציע. - **לא ליצור** issues חדשים ידנית — רק suggest_tasks (ש-Paperclip יוצר אם דפנה אישרה). - **לא להעיר** סוכנים אחרים. - **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף. diff --git a/docs/spec/07-learning.md b/docs/spec/07-learning.md index 8929e67..2d07350 100644 --- a/docs/spec/07-learning.md +++ b/docs/spec/07-learning.md @@ -87,10 +87,24 @@ (`hermes-curator.md:60-70`). - מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` / `[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`). -- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך - `POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI - תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`). -- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`). +- **מציע** — comment + interaction בערוץ-הפלטפורמה, וגם **רושם כל ממצא מבנית** כ-`decision_lesson` + (`source='curator'`, `review_status='proposed'`) דרך כלי-ה-MCP `record_curator_findings` + (`hermes-curator.md` §A.5b → `tools/workflow.py::record_curator_findings` → `db.add_decision_lesson`). + הממצאים מופיעים ב-`/training` טאב "אוצֵר" (ערוץ ג׳) ועוברים את שער-היו"ר (INV-LRN1). + > **דריפט-מימוש שנסגר (2026-06-28):** עד גל זה הסוכן כתב comment בלבד והספ תיאר endpoint שלא נקרא — + > הממצאים האיכותיים אבדו, וכרטיס-האוצֵר (`get_curator_stats`) ספר `source='curator'` שאיש לא כתב → 0. + > `record_curator_findings` מממש את INV-LRN3 בפועל. +- **אינו מאשר ואינו מטמיע** — read-only על התוכן; רישום-הממצא הוא *הצעה* מגודרת-שער, לא שינוי-קול. + אינו עורך `skills/` / `lessons.py` / שכבת-הקול בעצמו (`hermes-curator.md` §"כללים כלליים"). +- **שני ערוצי-למידה נפרדים על אותו סופי:** ערוץ-האוצֵר (כאן, qualitative, `source='curator'`) ו**פאנל-הסגנון** + הדו-סוכני שהצינור מפעיל (§0.6 — DeepSeek+Gemini, `source='panel:deepseek+gemini'`). שניהם + `decision_lessons` ממתיני-שער; ערוץ הדיסטילציה (`appeal_type_rules`, §0.4) הוא השלישי. + +> **פער-מימוש פתוח (TaskMaster #157):** ה-`PIPELINE-WAKE BRANCH` (`hermes-curator.md:24-43`) מפעיל את +> הצינור על `final_learning_*` ו**מסיים מיד** — ניתוח-§A האיכותי (וממילא `record_curator_findings`) +> **אינו רץ אוטומטית על mark-final**, אלא רק ביקיצת-תגובה/ידנית. כדי שממצאי-האוצֵר ייתפסו על כל סופי +> יש להכריע: שהסוכן ימשיך ל-§A אחרי הפעלת-הצינור (לטפל במירוץ enroll_style_corpus), או שהצינור עצמו +> יפלוט `source='curator'`. נדחה לתכנון נפרד. ### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים) diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 3b6e936..99fa0f8 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -1176,6 +1176,15 @@ async def list_chair_feedback( return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit)) +@mcp.tool() +async def record_curator_findings(case_number: str, findings: list[dict]) -> str: + """לכידת ממצאי-האוצֵר על החלטה סופית כ-decision_lessons מובְנים (source='curator', + ממתינים לשער-יו"ר) — INV-LRN3. כל ממצא: {"text": "...", "category": style/structure/ + lexicon/tabular} (או "tag" עברי). מחזיר כמה נרשמו וכמה כפילויות דולגו. read-only על + התוכן — הרישום הצעה הממתינה לאישור דפנה ב-/training, לא שינוי-קול ישיר (G10).""" + return await workflow.record_curator_findings(case_number, findings) + + @mcp.tool() async def halacha_corroboration(halacha_id: str) -> dict: """החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only).""" diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 75fdae7..6c7938e 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -2651,6 +2651,22 @@ async def add_decision_lesson( return dict(row) if row else {} +async def get_style_corpus_id_by_decision(decision_number: str) -> UUID | None: + """Resolve the style_corpus row id for a final decision by its number. + + The curator knows the case_number; lessons attach to the corpus row the + learning pipeline enrolled (enroll_style_corpus). Returns None if the final + has not been enrolled yet (caller surfaces it — no silent attach). + """ + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id FROM style_corpus WHERE decision_number = $1 LIMIT 1", + decision_number, + ) + return row["id"] if row else None + + async def update_decision_lesson( lesson_id: UUID, *, @@ -2866,6 +2882,12 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") Gate (INV-LRN1/G10): returns only CHAIR-APPROVED lessons (review_status='approved'). Panel-written proposals stay out of the writer's context until the chair approves them in /training — the chair's review is the gate, not the panel's 2/2 vote. + + No silent cap (חוקה §6): when more approved lessons match than `limit`, the + overflow is dropped by ORDER BY created_at DESC — log it so coverage loss is + observable, not invisible. The real fix is lesson synthesis (TaskMaster #158): + consolidate overlapping lessons into fewer, richer ones so the set fits without + truncation. Until then this WARN is the audit trail. """ pool = await get_pool() async with pool.acquire() as conn: @@ -2880,6 +2902,20 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") LIMIT $1""", limit, practice_area, ) + if len(rows) >= limit: + total = await conn.fetchval( + """SELECT count(*) FROM decision_lessons dl + JOIN style_corpus sc ON sc.id = dl.style_corpus_id + WHERE dl.review_status = 'approved' + AND ($1 = '' OR sc.practice_area = $1)""", + practice_area, + ) or 0 + if total > limit: + logger.warning( + "get_recent_decision_lessons: capped %d→%d approved lessons " + "(practice_area=%r) — %d not reaching the writer. See TaskMaster #158 (synthesis).", + total, limit, practice_area or "*", total - limit, + ) return [dict(r) for r in rows] diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index 589c28c..4469b5d 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -403,6 +403,78 @@ async def record_chair_feedback( }, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.") +_CURATOR_FINDING_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"} +# Agent tags ([סגנון]/[מבנה]/[לקסיקון משפטי]/[טבלאי]) → decision_lessons.category +_CURATOR_TAG_TO_CATEGORY = { + "סגנון": "style", "מבנה": "structure", + "לקסיקון משפטי": "lexicon", "לקסיקון": "lexicon", "טבלאי": "tabular", +} + + +async def record_curator_findings(case_number: str, findings: list[dict]) -> str: + """לכידת ממצאי-האוצֵר כ-decision_lessons מובְנים (source='curator', proposed) — INV-LRN3. + + האוצֵר מזהה דפוסי-סגנון בקריאת הסופי; עד כה הם חיו רק כהערה ארעית בערוץ-התגובות + (אובד, לא נסקר). כאן הם נתפסים מבנית כך שיופיעו בטאב ״אוצֵר״ ויעברו שער-יו"ר (INV-LRN1/G10). + האוצֵר נשאר read-only על התוכן — הרישום הוא הצעה הממתינה לאישור, לא שינוי-קול. + + Args: + case_number: מספר התיק הסופי (= decision_number בקורפוס-הסגנון). + findings: רשימת ממצאים, כל אחד {"text": "...", "category"/"tag": "..."}. + category ∈ style/structure/lexicon/tabular/general (או tag עברי). + """ + if not findings: + return err("findings ריק — אין ממצאים לרשום.") + corpus_id = await db.get_style_corpus_id_by_decision(case_number) + if not corpus_id: + return err( + f"לא נמצאה רשומת style_corpus ל-{case_number} — ודא שהסופי נקלט לקורפוס-הסגנון " + "(enroll_style_corpus) לפני רישום ממצאים." + ) + # Dedup against lessons already on this corpus (any source) — re-running §A + # must not pile duplicates (INV-LRN3 reliability). + existing = {(_norm(r["lesson_text"])) for r in await db.list_decision_lessons(corpus_id)} + written, skipped_dup, skipped_empty = [], 0, 0 + for f in findings: + text = (f.get("text") or "").strip() + if not text: + skipped_empty += 1 + continue + if _norm(text) in existing: + skipped_dup += 1 + continue + raw_cat = (f.get("category") or f.get("tag") or "general").strip() + category = _CURATOR_TAG_TO_CATEGORY.get(raw_cat, raw_cat) + if category not in _CURATOR_FINDING_CATEGORIES: + category = "general" + row = await db.add_decision_lesson( + corpus_id, + lesson_text=text, + category=category, + source="curator", + created_by="curator", + review_status="proposed", + ) + if row: + written.append(str(row["id"])) + existing.add(_norm(text)) + return ok({ + "corpus_id": str(corpus_id), + "written": len(written), + "skipped_duplicate": skipped_dup, + "skipped_empty": skipped_empty, + "lesson_ids": written, + }, message=( + f"נרשמו {len(written)} ממצאי-אוצֵר (source=curator, ממתינים לשער-יו\"ר ב-/training). " + f"{skipped_dup} כפילויות דולגו." + )) + + +def _norm(s: str) -> str: + """Normalize lesson text for dedup — collapse whitespace, strip.""" + return " ".join((s or "").split()) + + async def list_chair_feedback( case_number: str = "", category: str = "", diff --git a/web-ui/src/components/training/curator-portrait-panel.tsx b/web-ui/src/components/training/curator-portrait-panel.tsx index c3f1c9c..512b6b6 100644 --- a/web-ui/src/components/training/curator-portrait-panel.tsx +++ b/web-ui/src/components/training/curator-portrait-panel.tsx @@ -45,6 +45,15 @@ export function CuratorPortraitPanel() { +
+ 🔒 +
+ שער-היו״ר (INV-LRN1 / G10): כל הערוצים מציעים בלבד. + ממצא או לקח משפיע על הכתיבה רק אחרי שדפנה אישרה אותו. האוצֵר עצמו read-only על התוכן — + מזהה דפוסים ומגיש, לעולם לא משנה קבצים או סגנון בעצמו. +
+
+ פרומפט ה-Curator @@ -67,38 +76,127 @@ export function CuratorPortraitPanel() { // ── stats card ───────────────────────────────────────────────────── +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** One of the three learning channels — engine, backing store, count, flow gate. */ +function ChannelCard({ + letter, title, engine, target, store, value, unit, flows, flowLabel, meta, +}: { + letter: string; title: string; engine: string; target: string; store: string; + value: number; unit: string; flows: boolean; flowLabel: string; meta: string; +}) { + return ( + + +
+ + {letter} + +
+

{title}

+
{engine}
+
+
+
+ ← {target} {store} +
+
+ {value} + {unit} +
+ + {flowLabel} + +
{meta}
+
+
+ ); +} + function StatsCard() { const { data, isPending } = useCuratorStats(); if (isPending) { return ( -
- {[...Array(4)].map((_, i) => )} +
+ +
+ {[...Array(3)].map((_, i) => )} +
); } if (!data) return null; + const { distillation: a, panel: b, curator: c } = data.channels; + return ( -
- } /> - } /> - } /> - 0 - ? (data.total_findings / data.decisions_with_findings).toFixed(1) - : "—" - } - icon={} - /> +
+
+ +

+ הלמידה זורמת לכותב בשלושה ערוצים נפרדים, כל אחד + מופעל כשדפנה מסמנת החלטה כסופית. הכרטיס מראה כמה כל ערוץ תרם ומה כבר עובר בפועל לכותב — + רק ידע שאושר בשער-היו״ר משפיע על הטיוטות (INV-LRN1). +

+
+ +
+ שלושת ערוצי-ההזנה לכותב +
+ 0} flowLabel="זורם לכותב" + meta={`${a.discussion_rules} כללי-דיון + ${a.transition_phrases} ביטויי-מעבר · אושרו ב-${a.pairs_folded} זוגות דרך טאב ״למידה״.`} + /> + 0} flowLabel={`${b.approved} אושרו · ${b.proposed} ממתינים`} + meta={`על פני ${b.decisions} החלטות · אישור per-החלטה בטאב הקורפוס.`} + /> + 0} flowLabel={`${c.approved} אושרו · ${c.proposed} ממתינים`} + meta="3–5 דפוסים מתויגים להחלטה (סגנון/מבנה/לקסיקון/טבלאי) — נתפסים מבנית, לא רק כהערה." + /> +
+
+ +
+ ערוץ האוצֵר — מבט מקרוב +
+ } /> + } /> + } /> + } /> +
+
); } function Kpi({ - label, value, icon, -}: { label: string; value: string | number; icon: React.ReactNode }) { + label, value, icon, tone, +}: { label: string; value: string | number; icon: React.ReactNode; tone?: "success" | "gold" }) { + const valueCls = tone === "success" ? "text-success" : tone === "gold" ? "text-gold-deep" : "text-navy"; return ( @@ -106,7 +204,7 @@ function Kpi({ {icon} {label}
-

{value}

+

{value}

); @@ -123,10 +221,10 @@ function RecentFindings() { if (!data || data.recent_findings.length === 0) { return ( - - אין עדיין ממצאים של ה-Curator. הוא מופעל אוטומטית כאשר דפנה מסמנת - החלטה כסופית (mark-final), ושומר את ממצאיו כ-decision_lessons עם - source="curator". + + אין עדיין ממצאי-אוצֵר. כשדפנה מסמנת החלטה כסופית, האוצֵר קורא את הסופי, מזהה + 3–5 דפוסי-סגנון, ושומר אותם כ-decision_lessons (source="curator") + הממתינים לאישורה — בנפרד מלקחי-הפאנל ומכללי-המתודולוגיה שלמעלה. ); diff --git a/web-ui/src/components/training/style-report-panel.tsx b/web-ui/src/components/training/style-report-panel.tsx index 90a9d65..14257e8 100644 --- a/web-ui/src/components/training/style-report-panel.tsx +++ b/web-ui/src/components/training/style-report-panel.tsx @@ -197,7 +197,7 @@ export function StyleReportPanel() {

אוצֵר — ממצאי-סגנון

- {curator.data ? curator.data.findings_approved : "—"} + {curator.data ? curator.data.channels.curator.approved : "—"}
@@ -214,8 +214,8 @@ export function StyleReportPanel() { {curator.data ? Math.max( 0, - curator.data.total_findings - - curator.data.findings_approved, + curator.data.channels.curator.total - + curator.data.channels.curator.approved, ) : "—"}
@@ -223,7 +223,7 @@ export function StyleReportPanel() {
- {curator.data ? curator.data.total_findings : "—"} + {curator.data ? curator.data.channels.curator.total : "—"}
סך ממצאים
diff --git a/web-ui/src/lib/api/training.ts b/web-ui/src/lib/api/training.ts index 3f0b19f..09eebc3 100644 --- a/web-ui/src/lib/api/training.ts +++ b/web-ui/src/lib/api/training.ts @@ -302,14 +302,39 @@ export type CuratorFinding = { created_at: string; }; +// One decision_lessons channel split by the INV-LRN1 review gate. +export type LessonChannelStats = { + total: number; + approved: number; + proposed: number; + rejected: number; +}; + +// The three honest learning channels that feed the writer (INV-IA2/IA5). +// Earlier this was a single flat count of source='curator' — which nothing +// wrote, so it always read 0 while the panel/methodology channels carried all +// the real learning. See get_curator_stats (web/app.py). export type CuratorStats = { - total_findings: number; - decisions_with_findings: number; decisions_total: number; - // LRN-5 (INV-IA5): the count of curator lessons with review_status='approved' - // (the real INV-LRN1 writer gate) — replaces the old findings_applied, which - // counted the informative-only applied_to_skill flag. - findings_approved: number; + channels: { + // A — Claude draft↔final distillation → methodology overrides; flows now. + distillation: { + discussion_rules: number; + transition_phrases: number; + items_total: number; + pairs_folded: number; + pairs_total: number; + }; + // B — DeepSeek+Gemini panel → decision_lessons; flows when chair-approved. + panel: LessonChannelStats & { + decisions: number; + by_practice_area: Record; + }; + // C — curator agent's qualitative findings → decision_lessons (source='curator'). + curator: LessonChannelStats & { + decisions_reviewed: number; + }; + }; recent_findings: CuratorFinding[]; }; diff --git a/web/app.py b/web/app.py index a1b141a..9c3dfb4 100644 --- a/web/app.py +++ b/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"]),