From f20a3a09fd7818391ba0e421b410349f0a14feea Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 6 Jun 2026 19:17:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(style-acq=20T14):=20=D7=A9=D7=A2=D7=A8-?= =?UTF-8?q?=D7=99=D7=95"=D7=A8=20=D7=9C=D7=90=D7=99=D7=A9=D7=95=D7=A8=20?= =?UTF-8?q?=D7=94=D7=A6=D7=A2=D7=95=D7=AA-curator=20=E2=86=92=20=D7=94?= =?UTF-8?q?=D7=98=D7=9E=D7=A2=D7=94=20=D7=9C=D7=A4=D7=A8=D7=95=D7=A4=D7=99?= =?UTF-8?q?=D7=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit סוגר את הלולאה מקצה-לקצה (INV-G10/LRN1): ה-curator מציע (status=analyzed), היו"ר מאשרת, והלקחים נכתבים לערוצים שהכותב צורך (T15) — אין auto-commit. - db.get_draft_final_pair(id) — שורת-פנקס מלאה כולל analysis. - app.py: GET /api/learning/pairs/{id} (חושף רק changes מסוג style_method — INV-LRN5) + POST .../promote (לקחים→discussion_rules['universal'], ביטויים→transition_phrases['universal'] דרך merge ל-appeal_type_rules; status→lessons_folded). _append_methodology_override משותף. - web-ui: usePairDetail/usePromoteLearning + ProposalReview (בחירת לקחים/ ביטויים לאימוץ) בטאב "למידה" עבור pairs במצב analyzed. INV-G10 (שער-יו"ר) · INV-LRN1 (אין auto-commit) · INV-LRN5 (טוהר). Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 15 +++ .../components/training/learning-panel.tsx | 98 ++++++++++++++++++- web-ui/src/lib/api/learning.ts | 49 +++++++++- web/app.py | 87 ++++++++++++++++ 4 files changed, 244 insertions(+), 5 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 18806eb..f9185c6 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -2352,6 +2352,21 @@ async def list_draft_final_pairs(status: str | None = None, limit: int = 200) -> return [dict(r) for r in rows] +async def get_draft_final_pair(pair_id: UUID) -> dict | None: + """Full pairing row incl. analysis (curator proposal) — for the T14 approval gate.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """SELECT p.id, p.case_id, c.case_number, c.title, p.status, + p.draft_text, p.final_text, p.diff_stats, p.analysis, + p.created_at, p.updated_at + FROM draft_final_pairs p LEFT JOIN cases c ON c.id = p.case_id + WHERE p.id = $1""", + pair_id, + ) + return dict(row) if row else None + + async def insert_style_exemplar( decision_number: str, source: str, practice_area: str, outcome: str, section: str, paragraph_text: str, word_count: int, embedding: list[float], diff --git a/web-ui/src/components/training/learning-panel.tsx b/web-ui/src/components/training/learning-panel.tsx index 21da129..0c460c6 100644 --- a/web-ui/src/components/training/learning-panel.tsx +++ b/web-ui/src/components/training/learning-panel.tsx @@ -1,10 +1,12 @@ "use client"; import { useState } from "react"; -import { Loader2, ChevronLeft } from "lucide-react"; +import { Loader2, ChevronLeft, Check } from "lucide-react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { - useReconciliationLedger, useStyleDistance, + useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning, type DraftFinalPair, } from "@/lib/api/learning"; @@ -64,6 +66,91 @@ function StyleDistanceDetail({ caseNumber }: { caseNumber: string }) { ); } +function SelectableItem({ text, selected, onToggle }: { + text: string; selected: boolean; onToggle: () => void; +}) { + return ( + + ); +} + +/** T14 — chair approval gate (INV-G10/LRN1): review the curator's style_method + * proposal and fold approved lessons/phrases into writer-consumed channels. */ +function ProposalReview({ pairId }: { pairId: string }) { + const { data, isPending, error } = usePairDetail(pairId); + const promote = usePromoteLearning(pairId); + const [lessons, setLessons] = useState>(new Set()); + const [phrases, setPhrases] = useState>(new Set()); + + if (isPending) return ; + if (error || !data) return

שגיאה בטעינת ההצעה.

; + + const lessonTexts = data.changes.map((c) => c.lesson).filter((l): l is string => Boolean(l)); + const toggle = (set: Set, setFn: (s: Set) => void, v: string) => { + const next = new Set(set); + next.has(v) ? next.delete(v) : next.add(v); + setFn(next); + }; + const total = lessons.size + phrases.size; + + return ( +
+

+ הצעת הדיסטילציה (סגנון/שיטה בלבד — INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו"ר). +

+ {data.overall_assessment && ( +

{data.overall_assessment}

+ )} + {lessonTexts.length > 0 && ( +
+
לקחי-סגנון → כללי-דיון
+ {lessonTexts.map((l, i) => ( + toggle(lessons, setLessons, l)} /> + ))} +
+ )} + {data.new_expressions.length > 0 && ( +
+
ביטויי-מעבר חדשים
+ {data.new_expressions.map((p, i) => ( + toggle(phrases, setPhrases, p)} /> + ))} +
+ )} + {lessonTexts.length === 0 && data.new_expressions.length === 0 && ( +

אין הצעות style_method.

+ )} +
+ +
+
+ ); +} + function Row({ pair }: { pair: DraftFinalPair }) { const [open, setOpen] = useState(false); return ( @@ -82,8 +169,11 @@ function Row({ pair }: { pair: DraftFinalPair }) { {STATUS_LABEL[pair.status] ?? pair.status} - {open && pair.case_number && ( -
+ {open && ( +
+ {pair.status === "analyzed" && } + {pair.case_number && } +
)} ); diff --git a/web-ui/src/lib/api/learning.ts b/web-ui/src/lib/api/learning.ts index 0003db7..f2ed7e7 100644 --- a/web-ui/src/lib/api/learning.ts +++ b/web-ui/src/lib/api/learning.ts @@ -3,7 +3,7 @@ * Backs the /training "למידה" tab. Endpoints under /api/learning. */ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "./client"; export type DraftFinalPair = { @@ -67,3 +67,50 @@ export function useStyleDistance(caseNumber: string | null) { staleTime: 15_000, }); } + +// ── T14: curator distillation proposal + chair approval gate ────── + +export type DistillationChange = { + type?: string; + domain?: string; + block?: string; + description?: string; + draft_text?: string; + final_text?: string; + lesson?: string; +}; + +export type PairDetail = { + id: string; + case_number: string; + title: string; + status: string; + diff_stats: { change_percent?: number } | null; + overall_assessment: string; + changes: DistillationChange[]; // style_method only (server-filtered) + new_expressions: string[]; +}; + +export function usePairDetail(pairId: string | null) { + return useQuery({ + queryKey: [...learningKeys.all, "pair", pairId ?? ""] as const, + queryFn: ({ signal }) => + apiRequest(`/api/learning/pairs/${pairId}`, { signal }), + enabled: Boolean(pairId), + staleTime: 15_000, + }); +} + +export function usePromoteLearning(pairId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: { lessons: string[]; phrases: string[] }) => + apiRequest<{ id: string; status: string; folded_lessons: number; folded_phrases: number }>( + `/api/learning/pairs/${pairId}/promote`, + { method: "POST", body }, + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: learningKeys.all }); + }, + }); +} diff --git a/web/app.py b/web/app.py index 34f9353..d3b6b32 100644 --- a/web/app.py +++ b/web/app.py @@ -4151,6 +4151,93 @@ async def api_learning_style_distance(case_number: str): return await _sd.style_distance(case_number) +def _coerce_json(raw): + if isinstance(raw, str): + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return None + return raw + + +@app.get("/api/learning/pairs/{pair_id}") +async def api_learning_pair_detail(pair_id: str): + """פירוט שורת-פנקס כולל הצעת-הדיסטילציה (analysis) לאישור יו"ר (T14).""" + try: + pid = UUID(pair_id) + except ValueError: + raise HTTPException(400, "pair_id לא תקין") + p = await db.get_draft_final_pair(pid) + if not p: + raise HTTPException(404, "לא נמצא") + analysis = _coerce_json(p.get("analysis")) or {} + # surface only style_method changes (INV-LRN5 — substance never enters the voice) + changes = [c for c in analysis.get("changes", []) if c.get("domain") == "style_method"] + return { + "id": str(p["id"]), + "case_number": p.get("case_number") or "", + "title": p.get("title") or "", + "status": p.get("status") or "", + "diff_stats": _coerce_json(p.get("diff_stats")), + "overall_assessment": analysis.get("overall_assessment", ""), + "changes": changes, + "new_expressions": analysis.get("new_expressions", []), + } + + +class PromoteLearningRequest(BaseModel): + lessons: list[str] = [] # style_method lessons → discussion_rules['universal'] + phrases: list[str] = [] # new transition phrases → transition_phrases['universal'] + + +async def _append_methodology_override(category: str, key: str, items: list[str]) -> None: + """Read current (override-or-default) list value, append new items, upsert override. + Shared by the T14 approval gate to fold approved learnings into writer-consumed channels.""" + pool = await db.get_pool() + row = await pool.fetchrow( + "SELECT rule_value FROM appeal_type_rules " + "WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2", + category, key, + ) + if row: + current = _coerce_json(row["rule_value"]) or [] + else: + current = list(_METHODOLOGY_DEFAULTS.get(category, {}).get(key, [])) + if not isinstance(current, list): + current = [] + merged = current + [s for s in items if s and s not in current] + await pool.execute( + "INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) " + "VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) " + "ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb", + category, key, json.dumps(merged, ensure_ascii=False), + ) + + +@app.post("/api/learning/pairs/{pair_id}/promote") +async def api_learning_promote(pair_id: str, req: PromoteLearningRequest): + """שער-יו"ר (INV-G10/LRN1): מאשר לקחי-סגנון + ביטויי-מעבר מהצעת-הדיסטילציה + ומטמיע אותם בערוצים שהכותב צורך (methodology overrides → T15). מקדם status.""" + try: + pid = UUID(pair_id) + except ValueError: + raise HTTPException(400, "pair_id לא תקין") + p = await db.get_draft_final_pair(pid) + if not p: + raise HTTPException(404, "לא נמצא") + + if req.lessons: + await _append_methodology_override("discussion_rules", "universal", req.lessons) + if req.phrases: + await _append_methodology_override("transition_phrases", "universal", req.phrases) + + await db.update_draft_final_pair(pid, status="lessons_folded") + return { + "id": pair_id, "status": "lessons_folded", + "folded_lessons": len(req.lessons), "folded_phrases": len(req.phrases), + } + + # ── Skill Management API ─────────────────────────────────────────── -- 2.49.1