From 4b01283e3bde45ea3c75b808f650f06543ca6e6a Mon Sep 17 00:00:00 2001 From: Chaim Date: Thu, 11 Jun 2026 18:13:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(learning):=20=D7=A9=D7=A2=D7=A8-=D7=90?= =?UTF-8?q?=D7=99=D7=A9=D7=95=D7=A8=20=D7=9C-decision=5Flessons=20?= =?UTF-8?q?=E2=80=94=20=D7=A8=D7=A7=20=D7=9C=D7=A7=D7=97=20=D7=9E=D7=90?= =?UTF-8?q?=D7=95=D7=A9=D7=A8=20=D7=96=D7=95=D7=A8=D7=9D=20=D7=9C=D7=9B?= =?UTF-8?q?=D7=95=D7=AA=D7=91=20(INV-LRN1,=20#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit אודיט #122 חשף שלקחי-הפאנל (decision_lessons) זרמו לכותב אוטומטית (block_writer → get_recent_decision_lessons) ללא סינון-אישור — הפאנל כתב, והכותב צרך מיד, בעקיפת שער-היו"ר (INV-LRN1/G10). מנגד, מה שהיו"ר אישר ב-promote הלך לערוץ נפרד (appeal_type_rules). תוצאה: דליפה — תוכן לא-מאושר השפיע על הכתיבה. התיקון — שער-אישור מפורש: - עמודת review_status (proposed|approved|rejected) ל-decision_lessons (SCHEMA_V34). - get_recent_decision_lessons (צרכן-הכותב) מחזיר רק review_status='approved'. - הפאנל (style_lesson_panel) כותב 'proposed' (ברירת-מחדל) → לא זורם עד אישור. - לקח שהיו"ר מקליד ידנית ב-/training = 'approved' מיידית (מדלג על שער-ההצעה). - UI (lessons-tab, טאב "קורפוס" ב-/training): תג-סטטוס + כפתורי אשר/דחה/בטל-אישור. הכרעת-יו"ר (2026-06-11): כל הלקחים שקדמו לשער (41) מתאפסים ל-'proposed' — שום לקח לא זורם עד אישור מפורש (ברירת-המחדל של העמודה מיישמת זאת על הקיימים). Invariants: - INV-LRN1 / G10 (מקיים) — עדכון-ידע לערוץ-הכותב דורש אישור-יו"ר מפורש; אין auto-commit. - INV-LRN5 (נשמר) — substance ממילא מסונן בפאנל; השער הוא על style_method בלבד. - G1 (מקיים) — סינון-במקור (get_recent) ולא תיקון-בקריאה אצל הכותב. - G2 (מקיים) — אותו פנקס decision_lessons; אין מסלול מקביל. api:types: להריץ npm run api:types אחרי deploy (review_status נוסף ל-payload; הטיפוסים הידניים ב-training.ts כבר מעודכנים, tsc עובר). ref: #122 · #126 · data/audit/learning-loop-activity-20260611.md Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 43 ++++++++++--- .../src/components/training/lessons-tab.tsx | 60 ++++++++++++++++++- web-ui/src/lib/api/training.ts | 6 ++ web/app.py | 10 ++++ 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 216fec8..226a4e4 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1442,6 +1442,19 @@ CREATE TABLE IF NOT EXISTS drain_controls ( ); """ +SCHEMA_V34_SQL = """ +-- decision_lessons review gate (INV-LRN1/G10): a lesson must be chair-APPROVED +-- before it flows to the writer. The style panel writes proposals +-- (review_status='proposed'); get_recent_decision_lessons (writer-consumed) returns +-- only 'approved'. The chair approves/rejects in /training. Existing rows take the +-- column default — per chair decision (2026-06-11) all pre-gate lessons reset to +-- 'proposed' (nothing flows until re-approved). +ALTER TABLE decision_lessons + ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'proposed'; +CREATE INDEX IF NOT EXISTS idx_decision_lessons_review + ON decision_lessons(review_status); +""" + async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async with pool.acquire() as conn: @@ -1479,6 +1492,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None: await conn.execute(SCHEMA_V31_SQL) await conn.execute(SCHEMA_V32_SQL) await conn.execute(SCHEMA_V33_SQL) + await conn.execute(SCHEMA_V34_SQL) logger.info("Database schema initialized (v1-v33)") @@ -2257,7 +2271,7 @@ async def list_decision_lessons(corpus_id: UUID) -> list[dict]: async with pool.acquire() as conn: rows = await conn.fetch( "SELECT id, style_corpus_id, lesson_text, category, source, " - " applied_to_skill, created_by, created_at, updated_at " + " applied_to_skill, review_status, created_by, created_at, updated_at " "FROM decision_lessons WHERE style_corpus_id = $1 " "ORDER BY created_at DESC", corpus_id, @@ -2272,16 +2286,20 @@ async def add_decision_lesson( category: str = "general", source: str = "manual", created_by: str = "chaim", + review_status: str = "proposed", ) -> dict: + # review_status gate (INV-LRN1/G10): panel-written lessons default to 'proposed' + # and do NOT flow to the writer until the chair approves. A lesson the chair + # authors directly is passed review_status='approved' by the caller. pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( "INSERT INTO decision_lessons " - "(style_corpus_id, lesson_text, category, source, created_by) " - "VALUES ($1, $2, $3, $4, $5) " + "(style_corpus_id, lesson_text, category, source, created_by, review_status) " + "VALUES ($1, $2, $3, $4, $5, $6) " "RETURNING id, style_corpus_id, lesson_text, category, source, " - " applied_to_skill, created_by, created_at, updated_at", - corpus_id, lesson_text, category, source, created_by, + " applied_to_skill, review_status, created_by, created_at, updated_at", + corpus_id, lesson_text, category, source, created_by, review_status, ) return dict(row) if row else {} @@ -2292,6 +2310,7 @@ async def update_decision_lesson( lesson_text: str | None = None, category: str | None = None, applied_to_skill: bool | None = None, + review_status: str | None = None, ) -> dict: sets: dict = {} if lesson_text is not None: @@ -2300,6 +2319,8 @@ async def update_decision_lesson( sets["category"] = category if applied_to_skill is not None: sets["applied_to_skill"] = applied_to_skill + if review_status is not None: + sets["review_status"] = review_status if not sets: return {"updated": False, "reason": "nothing to update"} sets["updated_at"] = "now()" # sentinel — replaced inline below @@ -2312,7 +2333,7 @@ async def update_decision_lesson( row = await conn.fetchrow( f"UPDATE decision_lessons SET {set_clause} WHERE id = $1 " f"RETURNING id, style_corpus_id, lesson_text, category, source, " - f" applied_to_skill, updated_at", + f" applied_to_skill, review_status, updated_at", lesson_id, *values, ) if not row: @@ -2494,7 +2515,12 @@ async def get_methodology_overrides(category: str) -> dict: async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") -> list[dict]: """Per-decision learnings the chair/curator attached in /training (decision_lessons), - so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first.""" + so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first. + + 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. + """ pool = await get_pool() async with pool.acquire() as conn: rows = await conn.fetch( @@ -2502,7 +2528,8 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") sc.decision_number, sc.practice_area FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id - WHERE ($2 = '' OR sc.practice_area = $2) + WHERE dl.review_status = 'approved' + AND ($2 = '' OR sc.practice_area = $2) ORDER BY dl.created_at DESC LIMIT $1""", limit, practice_area, diff --git a/web-ui/src/components/training/lessons-tab.tsx b/web-ui/src/components/training/lessons-tab.tsx index cc2ac88..baa3581 100644 --- a/web-ui/src/components/training/lessons-tab.tsx +++ b/web-ui/src/components/training/lessons-tab.tsx @@ -18,7 +18,9 @@ */ import { useState } from "react"; -import { Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles } from "lucide-react"; +import { + Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles, Check, X, Undo2, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -56,6 +58,13 @@ const SOURCE_BADGE: Record = { const SOURCE_BADGE_FALLBACK = { label: "פאנל", cls: "bg-rule-soft text-ink-soft" }; +// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer. +const REVIEW_BADGE: Record = { + proposed: { label: "ממתין לאישור", cls: "bg-gold-wash text-gold-deep border-gold/40" }, + approved: { label: "מאושר · זורם לכותב", cls: "bg-success-bg text-success border-success/40" }, + rejected: { label: "נדחה", cls: "bg-rule-soft text-ink-muted line-through" }, +}; + export function LessonsTab({ corpusId }: { corpusId: string }) { const { data, isPending } = useCorpusLessons(corpusId); const add = useAddLesson(corpusId); @@ -153,8 +162,22 @@ function LessonItem({ const del = useDeleteLesson(corpusId); const sourceBadge = SOURCE_BADGE[lesson.source] ?? SOURCE_BADGE_FALLBACK; + const reviewBadge = REVIEW_BADGE[lesson.review_status] ?? REVIEW_BADGE.proposed; const dirty = text !== lesson.lesson_text || category !== lesson.category; + const onSetReview = async (review_status: DecisionLesson["review_status"]) => { + try { + await patch.mutateAsync({ id: lesson.id, patch: { review_status } }); + toast.success( + review_status === "approved" ? "אושר — הלקח יזרום לכותב" + : review_status === "rejected" ? "נדחה — לא יזרום לכותב" + : "הוחזר להמתנה", + ); + } catch (e) { + toast.error(e instanceof Error ? e.message : "כשל בעדכון"); + } + }; + const onSave = async () => { try { await patch.mutateAsync({ @@ -200,6 +223,9 @@ function LessonItem({ {sourceBadge.label} + + {reviewBadge.label} + {lesson.applied_to_skill && ( @@ -249,6 +275,38 @@ function LessonItem({ {lesson.lesson_text}

+ {/* review gate — only an approved lesson flows to the writer */} + {lesson.review_status === "approved" ? ( + + ) : ( + <> + + {lesson.review_status === "rejected" ? ( + + ) : ( + + )} + + )}