From 6e69c1dc38b3cfb5cfcd51fa89071e0585785715 Mon Sep 17 00:00:00 2001 From: Chaim Date: Thu, 11 Jun 2026 21:04:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(ia):=20IA=20=D7=92=D7=9C-2=20=E2=80=94=20?= =?UTF-8?q?=D7=90=D7=99=D7=97=D7=95=D7=93-=D7=9E=D7=A9=D7=98=D7=97=D7=99?= =?UTF-8?q?=D7=9D:=20=D7=A2=D7=A8=D7=95=D7=A5-=D7=9C=D7=9E=D7=99=D7=93?= =?UTF-8?q?=D7=94=20=D7=90=D7=97=D7=93=20=C2=B7=20/operations=E2=8A=87/dia?= =?UTF-8?q?gnostics=20=C2=B7=20MET-2/3=20(#131,=20X17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit גל-2 מבקלוג #127 — איחוד-משטחים לפי משטח-היעד של X17. מקיים INV-IA1/IA3/IA4 + דלתות-הספ (X6 INV-UI7/8, 07-learning §0.4, 00-constitution G2). שומר G10/INV-LRN1 (לא הוסר שום שער-אנושי — רק שער/דגל כפול). א) תיבת-אישור אחת (INV-IA1): כרטיסי "אישור הלכות"+"פסיקה חסרה" ב-/operations מצביעים ל-/approvals (לתיבת-האישורים ←) — /operations מנטר, /approvals מחליט. ב) ערוץ-למידה אחד (INV-IA3): הוסר applied_to_skill end-to-end — - UI: כפתור "סמן כ'אומץ'" + badge "אומץ" ב-lessons-tab; badge ב-curator-portrait. - API: LessonPatch, _lesson_to_json, patch call, curator recent_findings (→review_status). - db.py: list/add/update_decision_lesson לא בוחרים/כותבים applied_to_skill; הפרמטר הוסר. העמודה+אינדקס נשמרים (back-compat, ללא migration), מסומנים DEPRECATED. - types: DecisionLesson/LessonPatch/CuratorFinding. review_status='approved' = הסטטוס היחיד "זורם-לכותב" (INV-LRN1, #126). ג) MET-2/3 lost-update (INV-IA3): _append_methodology_override רץ עכשיו בטרנזקציה אחת עם SELECT ... FOR UPDATE — אין read-modify-write מתפצל מול עורך-המתודולוגיה או promote מקביל. /methodology = העורך-הקנוני; promote מבטל את ה-cache (גל-1 MET-1). ד) /operations⊇/diagnostics (INV-IA4): גוף /diagnostics חולץ ל- ומורנדר ב-/operations תחת "בריאות-מערכת". /diagnostics → redirect ל-/operations. /diagnostics הוסר מהניווט. משטח-ניטור יחיד. ה) דלתות-ספ (≥3 מקורות ב-X17, אושר ע"י חיים /goal): - X6: INV-UI7 (aggregate=SSoT, mutation מבטל queryKey) + INV-UI8 (render-or-remove, חלקיות). - 07-learning §0.4: שער-אחד + טרנזקציה-אחת + applied_to_skill מוסר. - 00-constitution G2: תאום-המתודולוגיה כהפרה-ידועה-ממותנת. - X17 דלתות-ספ סומנו ✅ קודדו. בדיקות: py_compile app.py + db.py ✓ · tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109 קיים-מראש). next build נכשל ב-worktree רק בגלל symlink (Turbopack) — Docker/CI תקין. api:types יתרענן בדפלוי (curator/lessons אינם response-modeled; הטיפוסים יד-כתובים עודכנו). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/spec/00-constitution.md | 5 + docs/spec/07-learning.md | 4 +- docs/spec/X17-information-architecture.md | 10 +- docs/spec/X6-ui-api-contract.md | 19 ++ mcp-server/src/legal_mcp/services/db.py | 16 +- web-ui/src/app/diagnostics/page.tsx | 272 +----------------- web-ui/src/app/operations/page.tsx | 39 ++- web-ui/src/components/app-shell.tsx | 3 +- .../operations/system-health-section.tsx | 255 ++++++++++++++++ .../training/curator-portrait-panel.tsx | 4 +- .../src/components/training/lessons-tab.tsx | 31 +- web-ui/src/lib/api/training.ts | 6 +- web/app.py | 55 ++-- 13 files changed, 378 insertions(+), 341 deletions(-) create mode 100644 web-ui/src/components/operations/system-health-section.tsx diff --git a/docs/spec/00-constitution.md b/docs/spec/00-constitution.md index 2f40a52..8577b6d 100644 --- a/docs/spec/00-constitution.md +++ b/docs/spec/00-constitution.md @@ -109,6 +109,11 @@ Fowler (Canonical Data Model) · SSOT (Single Source of Truth) | סטטוס: ver `ingest_internal_decision`) שמתפצלים — לדוגמה: המסלול החיצוני מתזמן חילוץ metadata (`request_metadata_extraction`), והמסלול הפנימי לא — ולכן ערן סופר 8046/24 נקלטה בלי metadata → ממצא ל-[audit](../audit-report.md). +**הפרה ידועה (תאום-מתודולוגיה, MET-2/3 — מותנה בגל-2 #131):** `discussion_rules['universal']` +ו-`transition_phrases['universal']` ב-`appeal_type_rules` נכתבים ע"י **שני** מסלולים — עורך-המתודולוגיה +(PUT, overwrite) ו-promote-הלמידה (append). **מותן:** ה-append רץ בטרנזקציה אחת נעולה (FOR UPDATE) + +promote מבטל את ה-cache של /methodology (גל-1 MET-1), כך שעורך-המתודולוגיה הוא העורך-הקנוני שעורך +תמיד מצב פוסט-append. שאריות (עריכה בטאב באמת-stale) מקובלות בכלי-יחיד-משתמש; ראה [X17 INV-IA3](X17-information-architecture.md), [ia-audit-redesign.md](../ia-audit-redesign.md) §2.5. ### INV-G3: ingest אחיד ו-idempotent **כלל:** קליטה היא **אחידה ו-idempotent** — upsert על מפתח דטרמיניסטי. קליטה חוזרת של diff --git a/docs/spec/07-learning.md b/docs/spec/07-learning.md index 7a8421f..1ef5055 100644 --- a/docs/spec/07-learning.md +++ b/docs/spec/07-learning.md @@ -43,7 +43,9 @@ `mark-final` → [1] INTAKE (snapshot של הטיוטה) → [2] PAIRING (בלוק↔בלוק) → [3] ALIGNMENT (diff פר-בלוק) → [4] DISTILLATION (מפריד סגנון↔מהות) → [5] CURATION (Hermes + שער-יו"ר) → [6] FEEDBACK (ניתוב לערוץ A/B/C) → [7] MEASUREMENT (מדד-מרחק-סגנון). ### 0.4 ניהול ב-UI -`/methodology` = **עורך-הפרופיל** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה). +`/methodology` = **עורך-הפרופיל היחיד** (declarative: יחסי-זהב, כללי-דיון, צ׳קליסטים, ביטויי-מעבר, אנטי-דפוסים, voice-invariants). `/training` = **שולחן-הלמידה** (קורפוס, פורטרט-סגנון, השוואת draft↔final, curator, מדד-מרחק, פנקס-התאמה). + +**שער-אישור אחד · טרנזקציית-כותב אחת (INV-IA3 → [X17](X17-information-architecture.md)):** ל-`decision_lesson` יש **סטטוס-יחיד** שקובע "זורם-לכותב" — `review_status='approved'` (INV-LRN1/G10). הדגל `applied_to_skill` **הוסר** (היה אינפורמטיבי-בלבד, נכתב-לשומקום → בלבל את היו"ר ב"שני שערים"; גל-2 #131). לקח שהיו"ר מחבר ידנית נוצר כבר כ-`approved`; לקח-פאנל נוצר כ-`proposed` וממתין לשער. promote של זוג draft↔final מטמיע את הלקחים/הביטויים שהיו"ר בחר **דרך appeal_type_rules בטרנזקציה אחת נעולה (FOR UPDATE)** — מסלול-כתיבה-יחיד, ללא read-modify-write מתפצל מול עורך-המתודולוגיה (MET-2/3, להלן G2 הפרות-ידועות). ### 0.5 Invariants חדשים **INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`. diff --git a/docs/spec/X17-information-architecture.md b/docs/spec/X17-information-architecture.md index c7a51aa..2ac0c5d 100644 --- a/docs/spec/X17-information-architecture.md +++ b/docs/spec/X17-information-architecture.md @@ -65,12 +65,12 @@ --- -## דלתות-ספ (deltas — לאישור-יו"ר לפני יישום) -> כל שינוי-ספ דורש ≥3 מקורות (לעיל) + אישור-יו"ר. עד אז — המלצות. +## דלתות-ספ (deltas — ✅ קודדו בגל-2 #131) +> כל שינוי-ספ דורש ≥3 מקורות (לעיל) + אישור-יו"ר. אושר ע"י חיים (2026-06-11, /goal "בצע את כל הגלים"). -1. **[X6](X6-ui-api-contract.md):** להוסיף INV-UI7 (aggregate-נגזר=SSoT; mutation מבטל queryKey; אין מונה-מתחרה — מקדד INV-IA1/IA2) · INV-UI8 (שדה-response מרונדר-או-מוסר; חלקיות מוצגת — INV-IA5). -2. **[07-learning §0.4/§0.6](07-learning.md):** שער-אישור **אחד**, טרנזקציית-כותב **אחת**, `applied_to_skill` מוסר; לקחים-מאושרים נכתבים רק דרך מסלול-המתודולוגיה (מקדד INV-IA3; מיישב את "שני-השערים" של [[feedback_operational_simplicity]]). -3. **[00-constitution §G2 "הפרות ידועות"](00-constitution.md):** להוסיף את תאום-המתודולוגיה (`discussion_rules['universal']` נכתב ע"י PUT וגם promote — MET-2/3). +1. **[X6](X6-ui-api-contract.md):** ✅ נוספו **INV-UI7** (aggregate-נגזר=SSoT; mutation מבטל queryKey; אין מונה-מתחרה — מקדד INV-IA1/IA2) · **INV-UI8** (שדה-response מרונדר-או-מוסר; חלקיות מוצגת — INV-IA5). +2. **[07-learning §0.4](07-learning.md):** ✅ שער-אישור **אחד** (`review_status='approved'`), טרנזקציית-כותב **אחת** (FOR UPDATE), `applied_to_skill` **הוסר** (מקדד INV-IA3; מיישב את "שני-השערים" של [[feedback_operational_simplicity]]). +3. **[00-constitution §G2 "הפרות ידועות"](00-constitution.md):** ✅ נוסף תאום-המתודולוגיה (`discussion_rules['universal']` נכתב ע"י PUT וגם promote — MET-2/3) + המיתון (append אטומי FOR UPDATE + invalidation). ## הפניות-אחיות - [`../ia-audit-redesign.md`](../ia-audit-redesign.md) — מצב-קיים: 34 משטחים, 37 ממצאים (file:line), כיוון-יעד פר-אשכול. diff --git a/docs/spec/X6-ui-api-contract.md b/docs/spec/X6-ui-api-contract.md index 0997662..28c7c8a 100644 --- a/docs/spec/X6-ui-api-contract.md +++ b/docs/spec/X6-ui-api-contract.md @@ -86,6 +86,25 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co מוצגים כשדות-עריכה רגילים ללא סימון. **הפרה ידועה:** [precedents/[id]/page.tsx](../../web-ui/src/app/precedents/%5Bid%5D/page.tsx) — `summary`/`headnote`/`key_quote` ללא חיווי-מקור; אין חיווי `searchable` ([gap-audit GAP-36](gap-audit.md)). +### INV-UI7: aggregate-נגזר = מקור-אמת יחיד · כל mutation מבטל כל קורא (G2 בשכבת-ה-cache) +**כלל:** ל-aggregate/מונה נגזר-שרת (למשל `/api/chair/pending`) יש **משטח-בעלים יחיד** שמריץ את השאילתה; +משטחים אחרים **מצביעים** אליו ולא מריצים מונה-מתחרה client-side. **כל mutation** שמשנה ערך הנקרא במשטח +אחר **חייב לבטל כל `queryKey` שקורא אותו** — כולל aggregators חוצי-namespace — כך שאף משטח לא יציג ערך +תקוע אחרי שינוי במשטח אחר. מקדד את **[X17 INV-IA1/IA2](X17-information-architecture.md)**; מופע של +[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) בשכבת-ה-TanStack-Query. +**מקור-סמכות:** [X17](X17-information-architecture.md) (≥3 מקורות: TanStack Query invalidation · TkDodo "deriving client state" · NN/g consistency); [ia-audit-redesign.md](../ia-audit-redesign.md) §D1. +**אכיפה:** מונה-הגייטים חי רק ב-`/approvals`+`['chair','pending']`; `/operations` מצביע. כל mutation להלכות/ +פסיקה-חסרה/הערות מבטל `['chair','pending']` (גל-1 #130, PR #207). + +### INV-UI8: שדה-response מרונדר-או-מוסר · חלקיות מוצגת +**כלל:** שדה שמופיע ב-response **לרנדר או להסיר** — אסור לזרוק אותו בשקט ב-frontend (אם אין צרכן — +להסירו מה-response). KPI מוצג חייב להיות ממופה ל**צרכן אמיתי** (לא דגל אינפורמטיבי-בלבד). aggregate +מדויק כש-partial-failure השמיט תורם — **להציג חלקיות** ("+"/"חלקי"), לא מספר-מוקטן-כאילו-שלם. מקדד את +**[X17 INV-IA5](X17-information-architecture.md)**. +**מקור-סמכות:** [X17](X17-information-architecture.md) (NN/g visibility-of-system-status · GOV.UK design-with-data); [ia-audit-redesign.md](../ia-audit-redesign.md) §D3. +**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי) +החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130). + --- ## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 226a4e4..7cfb28f 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -213,7 +213,7 @@ CREATE TABLE IF NOT EXISTS decision_lessons ( lesson_text TEXT NOT NULL, category TEXT DEFAULT 'general', -- style / structure / lexicon / tabular / general source TEXT DEFAULT 'manual', -- manual / curator / chair / style_analyzer - applied_to_skill BOOLEAN DEFAULT false, -- has this been promoted into SKILL.md? + applied_to_skill BOOLEAN DEFAULT false, -- DEPRECATED (LRN-1/INV-IA3, #131): informative-only flag, no longer read/written; review_status is the single writer gate. Column kept for back-compat (no migration). created_by TEXT DEFAULT 'chaim', created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() @@ -2269,9 +2269,12 @@ async def update_style_corpus_metadata( async def list_decision_lessons(corpus_id: UUID) -> list[dict]: pool = await get_pool() async with pool.acquire() as conn: + # applied_to_skill column retained for back-compat but no longer selected + # (LRN-1/INV-IA3: the informative-only flag was removed from the surface; + # review_status is the single writer gate). rows = await conn.fetch( "SELECT id, style_corpus_id, lesson_text, category, source, " - " applied_to_skill, review_status, created_by, created_at, updated_at " + " review_status, created_by, created_at, updated_at " "FROM decision_lessons WHERE style_corpus_id = $1 " "ORDER BY created_at DESC", corpus_id, @@ -2298,7 +2301,7 @@ async def add_decision_lesson( "(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, review_status, created_by, created_at, updated_at", + " review_status, created_by, created_at, updated_at", corpus_id, lesson_text, category, source, created_by, review_status, ) return dict(row) if row else {} @@ -2309,16 +2312,15 @@ 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: + # applied_to_skill removed (LRN-1/INV-IA3): it was an informative-only flag + # that wrote nowhere; review_status is the single writer gate (INV-LRN1). sets: dict = {} if lesson_text is not None: sets["lesson_text"] = lesson_text if category is not None: 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: @@ -2333,7 +2335,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, review_status, updated_at", + f" review_status, updated_at", lesson_id, *values, ) if not row: diff --git a/web-ui/src/app/diagnostics/page.tsx b/web-ui/src/app/diagnostics/page.tsx index 9d2f6b6..2884eff 100644 --- a/web-ui/src/app/diagnostics/page.tsx +++ b/web-ui/src/app/diagnostics/page.tsx @@ -1,270 +1,8 @@ -"use client"; - -import Link from "next/link"; -import { AlertTriangle, CheckCircle2, Clock, Database } from "lucide-react"; -import { AppShell } from "@/components/app-shell"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useDiagnostics, type DiagDoc } from "@/lib/api/system"; - -const TABLE_LABELS: Record = { - cases: "תיקים", - documents: "מסמכים", - document_chunks: "chunks", - style_corpus: "קורפוס סגנון", - style_patterns: "דפוסי סגנון", -}; - -function formatRelativeTime(iso: string | null) { - if (!iso) return "—"; - const then = new Date(iso); - const diffMs = Date.now() - then.getTime(); - const min = Math.floor(diffMs / 60000); - if (min < 1) return "עכשיו"; - if (min < 60) return `לפני ${min} דקות`; - const hr = Math.floor(min / 60); - if (hr < 24) return `לפני ${hr} שעות`; - const days = Math.floor(hr / 24); - if (days < 30) return `לפני ${days} ימים`; - return then.toLocaleDateString("he-IL"); -} - -function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) { - const cls = - tone === "danger" ? "bg-danger-bg/60 border-danger/30" : "bg-warn-bg/60 border-warn/30"; - return ( -
  • -
    -
    - {doc.title || "(ללא כותרת)"} -
    -
    - {doc.case_number && ( - - ערר {doc.case_number} - - )} - {formatRelativeTime(doc.created_at)} -
    -
    - {doc.status} -
  • - ); -} +import { redirect } from "next/navigation"; +// INV-IA4: /diagnostics was merged into /operations (one monitoring surface). +// The system-health cards now render inside /operations as . +// This redirect keeps old links/bookmarks working. export default function DiagnosticsPage() { - const { data, isPending, error } = useDiagnostics(); - - return ( - -
    -
    - -

    אבחון מערכת

    -

    - מצב ה-DB, מסמכים שנכשלו או תקועים, ומשימות רקע פעילות. מתעדכן כל 10 - שניות. -

    -
    - -
    - - {error ? ( - - - {error.message} - - - ) : ( - <> - {/* DB status + table counts */} -
    - - -
    - - מצב DB -
    - {isPending ? ( - - ) : data?.db_ok ? ( -
    - - מחובר -
    - ) : ( -
    - - מנותק -
    - )} -
    -
    - - - -
    - ספירת טבלאות -
    -
    - {Object.keys(TABLE_LABELS).map((key) => ( -
    -
    - {TABLE_LABELS[key]} -
    -
    - {isPending ? "—" : (data?.tables[key] ?? "—")} -
    -
    - ))} -
    -
    -
    -
    - - {/* Halacha review backlog (ADM-1, INV-IA5): render the human-gate - counter the backend returns; the action lives at /approvals - (INV-IA1 — this surface only points, never decides). */} - - -

    - - תור אישור הלכות - - {isPending ? "—" : (data?.halacha_backlog?.pending_review ?? 0)} - - - לאישור ← - -

    - {isPending ? ( - - ) : ( -
    -
    -
    ממתינים (נקי)
    -
    - {data?.halacha_backlog?.pending_clean ?? 0} -
    -
    -
    -
    דורש תיקון
    -
    - {data?.halacha_backlog?.pending_flagged ?? 0} -
    -
    -
    -
    אושרו
    -
    - {data?.halacha_backlog?.approved ?? 0} -
    -
    -
    -
    הוותיק ביותר
    -
    - {formatRelativeTime(data?.halacha_backlog?.oldest_pending_at ?? null)} -
    -
    -
    - )} -
    -
    - - {/* Active tasks */} - - -

    - - משימות רקע פעילות - - {data?.active_tasks.length ?? 0} - -

    - {isPending ? ( - - ) : data?.active_tasks.length === 0 ? ( -

    אין משימות פעילות

    - ) : ( -
      - {data?.active_tasks.map((t) => ( -
    • - - {t.filename || t.task_id} - - - {t.step || t.status} - -
    • - ))} -
    - )} -
    -
    - - {/* Failed + stuck docs */} -
    - - -

    - - מסמכים שנכשלו - - {data?.failed_documents.length ?? 0} - -

    - {isPending ? ( - - ) : data?.failed_documents.length === 0 ? ( -

    אין כשלונות

    - ) : ( -
      - {data?.failed_documents.map((d) => ( - - ))} -
    - )} -
    -
    - - - -

    - - תקועים (> 10 דק׳) - - {data?.stuck_documents.length ?? 0} - -

    - {isPending ? ( - - ) : data?.stuck_documents.length === 0 ? ( -

    אין מסמכים תקועים

    - ) : ( -
      - {data?.stuck_documents.map((d) => ( - - ))} -
    - )} -
    -
    -
    - - )} -
    -
    - ); + redirect("/operations"); } diff --git a/web-ui/src/app/operations/page.tsx b/web-ui/src/app/operations/page.tsx index 78a239f..fd7f184 100644 --- a/web-ui/src/app/operations/page.tsx +++ b/web-ui/src/app/operations/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/app-shell"; +import { SystemHealthSection } from "@/components/operations/system-health-section"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -333,17 +334,33 @@ function PipelineCard({ title, desc, children, + href, + hrefLabel, }: { title: string; desc: string; children: React.ReactNode; + // INV-IA1: a human-gate card is a *pointer* — the action lives at `href` + // (/approvals), never duplicated here. /operations only monitors. + href?: string; + hrefLabel?: string; }) { return ( -
    -

    {title}

    -

    {desc}

    +
    +
    +

    {title}

    +

    {desc}

    +
    + {href && ( + + {hrefLabel ?? "לטיפול ←"} + + )}
    {children} @@ -619,7 +636,9 @@ export default function OperationsPage() { @@ -627,6 +646,8 @@ export default function OperationsPage() { @@ -688,6 +709,16 @@ export default function OperationsPage() {
    + + {/* INV-IA4: the former /diagnostics surface, folded in here — one + monitoring intent, one surface. /diagnostics now redirects here. */} +
    +

    בריאות-מערכת

    +

    + מצב ה-DB, תורי-אישור, ומסמכים שנכשלו/תקועים. (אוחד מ״אבחון״.) +

    + +
    )} diff --git a/web-ui/src/components/app-shell.tsx b/web-ui/src/components/app-shell.tsx index 46c6693..acfc950 100644 --- a/web-ui/src/components/app-shell.tsx +++ b/web-ui/src/components/app-shell.tsx @@ -71,9 +71,10 @@ const KNOWLEDGE_MENUS: NavMenuDef[] = [ const ADMIN_ITEMS: NavItem[] = [ { href: "/skills", label: "מיומנויות" }, + // INV-IA4: /diagnostics folded into /operations (one monitoring surface); + // /diagnostics now redirects there, so it's dropped from the nav. { href: "/operations", label: "תפעול" }, { href: "/scripts", label: "סקריפטים" }, - { href: "/diagnostics", label: "אבחון" }, { href: "/settings", label: "הגדרות" }, ]; diff --git a/web-ui/src/components/operations/system-health-section.tsx b/web-ui/src/components/operations/system-health-section.tsx new file mode 100644 index 0000000..e0a7da9 --- /dev/null +++ b/web-ui/src/components/operations/system-health-section.tsx @@ -0,0 +1,255 @@ +"use client"; + +/* + * System-health monitoring section (INV-IA4): DB health, table counts, the + * halacha review backlog (read-only pointer to /approvals), active background + * tasks, and failed/stuck documents. Extracted from the former /diagnostics + * page so /operations is the single monitoring surface — /diagnostics now + * redirects here (one intent = one surface). + */ + +import Link from "next/link"; +import { AlertTriangle, CheckCircle2, Clock, Database } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useDiagnostics, type DiagDoc } from "@/lib/api/system"; + +const TABLE_LABELS: Record = { + cases: "תיקים", + documents: "מסמכים", + document_chunks: "chunks", + style_corpus: "קורפוס סגנון", + style_patterns: "דפוסי סגנון", +}; + +function formatRelativeTime(iso: string | null) { + if (!iso) return "—"; + const then = new Date(iso); + const diffMs = Date.now() - then.getTime(); + const min = Math.floor(diffMs / 60000); + if (min < 1) return "עכשיו"; + if (min < 60) return `לפני ${min} דקות`; + const hr = Math.floor(min / 60); + if (hr < 24) return `לפני ${hr} שעות`; + const days = Math.floor(hr / 24); + if (days < 30) return `לפני ${days} ימים`; + return then.toLocaleDateString("he-IL"); +} + +function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) { + const cls = + tone === "danger" ? "bg-danger-bg/60 border-danger/30" : "bg-warn-bg/60 border-warn/30"; + return ( +
  • +
    +
    + {doc.title || "(ללא כותרת)"} +
    +
    + {doc.case_number && ( + + ערר {doc.case_number} + + )} + {formatRelativeTime(doc.created_at)} +
    +
    + {doc.status} +
  • + ); +} + +export function SystemHealthSection() { + const { data, isPending, error } = useDiagnostics(); + + if (error) { + return ( + + + {error.message} + + + ); + } + + return ( +
    + {/* DB status + table counts */} +
    + + +
    + + מצב DB +
    + {isPending ? ( + + ) : data?.db_ok ? ( +
    + + מחובר +
    + ) : ( +
    + + מנותק +
    + )} +
    +
    + + + +
    + ספירת טבלאות +
    +
    + {Object.keys(TABLE_LABELS).map((key) => ( +
    +
    {TABLE_LABELS[key]}
    +
    + {isPending ? "—" : (data?.tables[key] ?? "—")} +
    +
    + ))} +
    +
    +
    +
    + + {/* Halacha review backlog (ADM-1, INV-IA5): render the human-gate counter + the backend returns; the action lives at /approvals (INV-IA1). */} + + +

    + + תור אישור הלכות + + {isPending ? "—" : (data?.halacha_backlog?.pending_review ?? 0)} + + + לאישור ← + +

    + {isPending ? ( + + ) : ( +
    +
    +
    ממתינים (נקי)
    +
    + {data?.halacha_backlog?.pending_clean ?? 0} +
    +
    +
    +
    דורש תיקון
    +
    + {data?.halacha_backlog?.pending_flagged ?? 0} +
    +
    +
    +
    אושרו
    +
    + {data?.halacha_backlog?.approved ?? 0} +
    +
    +
    +
    הוותיק ביותר
    +
    + {formatRelativeTime(data?.halacha_backlog?.oldest_pending_at ?? null)} +
    +
    +
    + )} +
    +
    + + {/* Active tasks */} + + +

    + + משימות רקע פעילות + + {data?.active_tasks.length ?? 0} + +

    + {isPending ? ( + + ) : data?.active_tasks.length === 0 ? ( +

    אין משימות פעילות

    + ) : ( +
      + {data?.active_tasks.map((t) => ( +
    • + + {t.filename || t.task_id} + + + {t.step || t.status} + +
    • + ))} +
    + )} +
    +
    + + {/* Failed + stuck docs */} +
    + + +

    + + מסמכים שנכשלו + + {data?.failed_documents.length ?? 0} + +

    + {isPending ? ( + + ) : data?.failed_documents.length === 0 ? ( +

    אין כשלונות

    + ) : ( +
      + {data?.failed_documents.map((d) => ( + + ))} +
    + )} +
    +
    + + + +

    + + תקועים (> 10 דק׳) + + {data?.stuck_documents.length ?? 0} + +

    + {isPending ? ( + + ) : data?.stuck_documents.length === 0 ? ( +

    אין מסמכים תקועים

    + ) : ( +
      + {data?.stuck_documents.map((d) => ( + + ))} +
    + )} +
    +
    +
    +
    + ); +} diff --git a/web-ui/src/components/training/curator-portrait-panel.tsx b/web-ui/src/components/training/curator-portrait-panel.tsx index 670ccdd..c3f1c9c 100644 --- a/web-ui/src/components/training/curator-portrait-panel.tsx +++ b/web-ui/src/components/training/curator-portrait-panel.tsx @@ -149,11 +149,11 @@ function RecentFindings() { {f.decision_number || "—"} - {f.applied_to_skill && ( + {f.review_status === "approved" && ( - אומץ + מאושר )} diff --git a/web-ui/src/components/training/lessons-tab.tsx b/web-ui/src/components/training/lessons-tab.tsx index baa3581..fe48bb1 100644 --- a/web-ui/src/components/training/lessons-tab.tsx +++ b/web-ui/src/components/training/lessons-tab.tsx @@ -9,9 +9,9 @@ * The chair can: * - Add a lesson typed manually (category = "general" by default) * - Edit / delete existing lessons - * - Mark a lesson as "applied_to_skill" (informational — doesn't - * auto-commit anything to SKILL.md; chair still curates that file - * manually in git). + * - Approve / reject a lesson (review_status — INV-LRN1/G10): only an + * approved lesson flows to the writer. This is the SINGLE writer gate; + * the old informative-only applied_to_skill flag was removed (LRN-1/INV-IA3). * * Lessons from the curator arrive with source="curator" and are visually * distinguished by a badge so the chair can audit auto-suggestions. @@ -19,7 +19,7 @@ import { useState } from "react"; import { - Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles, Check, X, Undo2, + Plus, Save, Trash2, Loader2, Check, X, Undo2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -191,17 +191,6 @@ function LessonItem({ } }; - const onToggleApplied = async () => { - try { - await patch.mutateAsync({ - id: lesson.id, - patch: { applied_to_skill: !lesson.applied_to_skill }, - }); - } catch (e) { - toast.error(e instanceof Error ? e.message : "כשל בעדכון"); - } - }; - const onDelete = async () => { if (!window.confirm("למחוק את הלקח?")) return; try { @@ -226,13 +215,6 @@ function LessonItem({ {reviewBadge.label} - {lesson.applied_to_skill && ( - - - אומץ - - )} {new Date(lesson.created_at).toLocaleDateString("he-IL")} @@ -307,11 +289,6 @@ function LessonItem({ )} )} - diff --git a/web-ui/src/lib/api/training.ts b/web-ui/src/lib/api/training.ts index f531472..3f0b19f 100644 --- a/web-ui/src/lib/api/training.ts +++ b/web-ui/src/lib/api/training.ts @@ -295,7 +295,8 @@ export type CuratorFinding = { id: string; lesson_text: string; category: string; - applied_to_skill: boolean; + // review_status replaces the removed applied_to_skill flag (LRN-1/INV-IA3). + review_status: "proposed" | "approved" | "rejected"; decision_number: string; decision_date: string; created_at: string; @@ -479,9 +480,9 @@ export type DecisionLesson = { // "panel:deepseek+gemini" — the two-judge style panel; (string) keeps it open // to future panel sources without a type break. source: "manual" | "curator" | "chair" | "style_analyzer" | (string & {}); - applied_to_skill: boolean; // review gate (INV-LRN1/G10): only "approved" lessons flow to the writer. // Panel-written lessons start as "proposed" and wait for the chair here. + // (applied_to_skill removed — LRN-1/INV-IA3: review_status is the single gate.) review_status: "proposed" | "approved" | "rejected"; created_by: string; created_at: string; @@ -499,7 +500,6 @@ export type LessonCreate = { export type LessonPatch = { lesson_text?: string; category?: DecisionLesson["category"]; - applied_to_skill?: boolean; review_status?: DecisionLesson["review_status"]; }; diff --git a/web/app.py b/web/app.py index a323636..bda56a8 100644 --- a/web/app.py +++ b/web/app.py @@ -1338,7 +1338,7 @@ async def get_curator_stats(): # Last 10 curator findings — newest first recent_rows = await conn.fetch( """ - SELECT dl.id, dl.lesson_text, dl.category, dl.applied_to_skill, + SELECT dl.id, dl.lesson_text, dl.category, dl.review_status, dl.created_at, sc.decision_number, sc.decision_date FROM decision_lessons dl @@ -1358,7 +1358,7 @@ async def get_curator_stats(): "id": str(r["id"]), "lesson_text": r["lesson_text"], "category": r["category"], - "applied_to_skill": bool(r["applied_to_skill"]), + "review_status": r["review_status"] or "proposed", "decision_number": r["decision_number"] or "", "decision_date": str(r["decision_date"]) if r["decision_date"] else "", "created_at": r["created_at"].isoformat() if r["created_at"] else "", @@ -1454,7 +1454,6 @@ class LessonCreate(BaseModel): class LessonPatch(BaseModel): lesson_text: str | None = None category: str | None = None - applied_to_skill: bool | None = None review_status: str | None = None # proposed | approved | rejected (INV-LRN1 gate) @@ -1470,8 +1469,8 @@ def _lesson_to_json(row: dict) -> dict: "lesson_text": row["lesson_text"], "category": row["category"], "source": row["source"], - "applied_to_skill": bool(row["applied_to_skill"]), # review gate (INV-LRN1/G10): only 'approved' flows to the writer. + # (applied_to_skill removed — LRN-1/INV-IA3, was informative-only.) "review_status": row.get("review_status", "proposed"), "created_by": row.get("created_by", ""), "created_at": row["created_at"].isoformat() if row.get("created_at") else "", @@ -1527,7 +1526,6 @@ async def patch_corpus_lesson(lesson_id: str, body: LessonPatch): lid, lesson_text=body.lesson_text, category=body.category, - applied_to_skill=body.applied_to_skill, review_status=body.review_status, ) if not result.get("updated"): @@ -4657,26 +4655,35 @@ class PromoteLearningRequest(BaseModel): 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.""" + Shared by the T14 approval gate to fold approved learnings into writer-consumed channels. + + MET-2/3 (INV-IA3): the read-modify-write runs inside ONE transaction with the + existing override row locked FOR UPDATE, so a concurrent promote (or a methodology + PUT) can't interleave between the read and the write and silently drop items. + The methodology PUT overwrites the same row; the MET-1 invalidation (גל-1) makes + the /methodology editor refetch on promote so it edits post-append state.""" 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), - ) + async with pool.acquire() as conn: + async with conn.transaction(): + row = await conn.fetchrow( + "SELECT rule_value FROM appeal_type_rules " + "WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2 " + "FOR UPDATE", + 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 conn.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")