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" ? ( + + ) : ( + + )} + + )}