Merge pull request 'feat(ia): IA גל-2 — איחוד-משטחים: ערוץ-למידה אחד · /operations⊇/diagnostics · MET-2/3 (#131, X17)' (#208) from worktree-ia-wave2-consolidation into main
This commit was merged in pull request #208.
This commit is contained in:
@@ -109,6 +109,11 @@ Fowler (Canonical Data Model) · SSOT (Single Source of Truth) | סטטוס: ver
|
|||||||
`ingest_internal_decision`) שמתפצלים — לדוגמה: המסלול החיצוני מתזמן חילוץ metadata
|
`ingest_internal_decision`) שמתפצלים — לדוגמה: המסלול החיצוני מתזמן חילוץ metadata
|
||||||
(`request_metadata_extraction`), והמסלול הפנימי לא — ולכן ערן סופר 8046/24 נקלטה בלי
|
(`request_metadata_extraction`), והמסלול הפנימי לא — ולכן ערן סופר 8046/24 נקלטה בלי
|
||||||
metadata → ממצא ל-[audit](../audit-report.md).
|
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
|
### INV-G3: ingest אחיד ו-idempotent
|
||||||
**כלל:** קליטה היא **אחידה ו-idempotent** — upsert על מפתח דטרמיניסטי. קליטה חוזרת של
|
**כלל:** קליטה היא **אחידה ו-idempotent** — upsert על מפתח דטרמיניסטי. קליטה חוזרת של
|
||||||
|
|||||||
@@ -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 (מדד-מרחק-סגנון).
|
`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
|
### 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 חדשים
|
### 0.5 Invariants חדשים
|
||||||
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`.
|
**INV-LRN4 (ניגוד-אמת → G10/G9):** למידת-קול מבוססת **pairing draft↔final ברמת-בלוק**, לא קריאת-final בלבד. כל החלטה אינה "סגורה" עד שהושוותה מול הסופי; כל סופי מנותח מול הטיוטה. נשמר פנקס-התאמה (`draft_final_pairs`) עם מצב-חיים `draft_done → final_received → analyzed → lessons_folded`.
|
||||||
|
|||||||
@@ -65,12 +65,12 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## דלתות-ספ (deltas — לאישור-יו"ר לפני יישום)
|
## דלתות-ספ (deltas — ✅ קודדו בגל-2 #131)
|
||||||
> כל שינוי-ספ דורש ≥3 מקורות (לעיל) + אישור-יו"ר. עד אז — המלצות.
|
> כל שינוי-ספ דורש ≥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).
|
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]]).
|
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).
|
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), כיוון-יעד פר-אשכול.
|
- [`../ia-audit-redesign.md`](../ia-audit-redesign.md) — מצב-קיים: 34 משטחים, 37 ממצאים (file:line), כיוון-יעד פר-אשכול.
|
||||||
|
|||||||
@@ -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)).
|
**הפרה ידועה:** [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
|
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ CREATE TABLE IF NOT EXISTS decision_lessons (
|
|||||||
lesson_text TEXT NOT NULL,
|
lesson_text TEXT NOT NULL,
|
||||||
category TEXT DEFAULT 'general', -- style / structure / lexicon / tabular / general
|
category TEXT DEFAULT 'general', -- style / structure / lexicon / tabular / general
|
||||||
source TEXT DEFAULT 'manual', -- manual / curator / chair / style_analyzer
|
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_by TEXT DEFAULT 'chaim',
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
updated_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]:
|
async def list_decision_lessons(corpus_id: UUID) -> list[dict]:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
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(
|
rows = await conn.fetch(
|
||||||
"SELECT id, style_corpus_id, lesson_text, category, source, "
|
"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 "
|
"FROM decision_lessons WHERE style_corpus_id = $1 "
|
||||||
"ORDER BY created_at DESC",
|
"ORDER BY created_at DESC",
|
||||||
corpus_id,
|
corpus_id,
|
||||||
@@ -2298,7 +2301,7 @@ async def add_decision_lesson(
|
|||||||
"(style_corpus_id, lesson_text, category, source, created_by, review_status) "
|
"(style_corpus_id, lesson_text, category, source, created_by, review_status) "
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6) "
|
"VALUES ($1, $2, $3, $4, $5, $6) "
|
||||||
"RETURNING id, style_corpus_id, lesson_text, category, source, "
|
"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,
|
corpus_id, lesson_text, category, source, created_by, review_status,
|
||||||
)
|
)
|
||||||
return dict(row) if row else {}
|
return dict(row) if row else {}
|
||||||
@@ -2309,16 +2312,15 @@ async def update_decision_lesson(
|
|||||||
*,
|
*,
|
||||||
lesson_text: str | None = None,
|
lesson_text: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
applied_to_skill: bool | None = None,
|
|
||||||
review_status: str | None = None,
|
review_status: str | None = None,
|
||||||
) -> dict:
|
) -> 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 = {}
|
sets: dict = {}
|
||||||
if lesson_text is not None:
|
if lesson_text is not None:
|
||||||
sets["lesson_text"] = lesson_text
|
sets["lesson_text"] = lesson_text
|
||||||
if category is not None:
|
if category is not None:
|
||||||
sets["category"] = category
|
sets["category"] = category
|
||||||
if applied_to_skill is not None:
|
|
||||||
sets["applied_to_skill"] = applied_to_skill
|
|
||||||
if review_status is not None:
|
if review_status is not None:
|
||||||
sets["review_status"] = review_status
|
sets["review_status"] = review_status
|
||||||
if not sets:
|
if not sets:
|
||||||
@@ -2333,7 +2335,7 @@ async def update_decision_lesson(
|
|||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
f"UPDATE decision_lessons SET {set_clause} WHERE id = $1 "
|
f"UPDATE decision_lessons SET {set_clause} WHERE id = $1 "
|
||||||
f"RETURNING id, style_corpus_id, lesson_text, category, source, "
|
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,
|
lesson_id, *values,
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
@@ -1,270 +1,8 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
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<string, string> = {
|
|
||||||
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 (
|
|
||||||
<li
|
|
||||||
className={`rounded border px-3 py-2 flex items-center gap-3 text-sm ${cls}`}
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-ink font-medium truncate" title={doc.title}>
|
|
||||||
{doc.title || "(ללא כותרת)"}
|
|
||||||
</div>
|
|
||||||
<div className="text-[0.72rem] text-ink-muted flex gap-3 mt-0.5">
|
|
||||||
{doc.case_number && (
|
|
||||||
<Link href={`/cases/${doc.case_number}`} className="hover:text-gold-deep">
|
|
||||||
ערר {doc.case_number}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<span>{formatRelativeTime(doc.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-[0.7rem]">{doc.status}</Badge>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// INV-IA4: /diagnostics was merged into /operations (one monitoring surface).
|
||||||
|
// The system-health cards now render inside /operations as <SystemHealthSection/>.
|
||||||
|
// This redirect keeps old links/bookmarks working.
|
||||||
export default function DiagnosticsPage() {
|
export default function DiagnosticsPage() {
|
||||||
const { data, isPending, error } = useDiagnostics();
|
redirect("/operations");
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell>
|
|
||||||
<section className="space-y-6">
|
|
||||||
<header>
|
|
||||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
|
||||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
|
||||||
<span aria-hidden> · </span>
|
|
||||||
<span className="text-navy">אבחון מערכת</span>
|
|
||||||
</nav>
|
|
||||||
<h1 className="text-navy mb-0">אבחון מערכת</h1>
|
|
||||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
|
||||||
מצב ה-DB, מסמכים שנכשלו או תקועים, ומשימות רקע פעילות. מתעדכן כל 10
|
|
||||||
שניות.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<Card className="bg-danger-bg border-danger/40">
|
|
||||||
<CardContent className="px-6 py-6 text-center text-danger">
|
|
||||||
{error.message}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* DB status + table counts */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-5 py-4 flex flex-col items-start gap-2">
|
|
||||||
<div className="flex items-center gap-2 text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
|
||||||
<Database className="w-3.5 h-3.5" />
|
|
||||||
מצב DB
|
|
||||||
</div>
|
|
||||||
{isPending ? (
|
|
||||||
<Skeleton className="h-6 w-24" />
|
|
||||||
) : data?.db_ok ? (
|
|
||||||
<div className="flex items-center gap-2 text-success font-semibold">
|
|
||||||
<CheckCircle2 className="w-5 h-5" />
|
|
||||||
<span>מחובר</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 text-danger font-semibold">
|
|
||||||
<AlertTriangle className="w-5 h-5" />
|
|
||||||
<span>מנותק</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-5 py-4">
|
|
||||||
<div className="text-ink-muted text-[0.72rem] uppercase tracking-wider mb-3">
|
|
||||||
ספירת טבלאות
|
|
||||||
</div>
|
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-5 gap-y-2 gap-x-4">
|
|
||||||
{Object.keys(TABLE_LABELS).map((key) => (
|
|
||||||
<div key={key} className="space-y-0.5">
|
|
||||||
<dt className="text-[0.72rem] text-ink-muted">
|
|
||||||
{TABLE_LABELS[key]}
|
|
||||||
</dt>
|
|
||||||
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
|
||||||
{isPending ? "—" : (data?.tables[key] ?? "—")}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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). */}
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-6 py-5">
|
|
||||||
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
תור אישור הלכות
|
|
||||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
|
||||||
{isPending ? "—" : (data?.halacha_backlog?.pending_review ?? 0)}
|
|
||||||
</Badge>
|
|
||||||
<Link
|
|
||||||
href="/approvals"
|
|
||||||
className="ms-auto text-[0.75rem] text-gold-deep hover:underline"
|
|
||||||
>
|
|
||||||
לאישור ←
|
|
||||||
</Link>
|
|
||||||
</h2>
|
|
||||||
{isPending ? (
|
|
||||||
<Skeleton className="h-12 w-full" />
|
|
||||||
) : (
|
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-y-2 gap-x-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<dt className="text-[0.72rem] text-ink-muted">ממתינים (נקי)</dt>
|
|
||||||
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
|
||||||
{data?.halacha_backlog?.pending_clean ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<dt className="text-[0.72rem] text-ink-muted">דורש תיקון</dt>
|
|
||||||
<dd className="font-display font-bold text-warn text-xl tabular-nums">
|
|
||||||
{data?.halacha_backlog?.pending_flagged ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<dt className="text-[0.72rem] text-ink-muted">אושרו</dt>
|
|
||||||
<dd className="font-display font-bold text-success text-xl tabular-nums">
|
|
||||||
{data?.halacha_backlog?.approved ?? 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<dt className="text-[0.72rem] text-ink-muted">הוותיק ביותר</dt>
|
|
||||||
<dd className="text-sm text-ink-soft">
|
|
||||||
{formatRelativeTime(data?.halacha_backlog?.oldest_pending_at ?? null)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Active tasks */}
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-6 py-5">
|
|
||||||
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
משימות רקע פעילות
|
|
||||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
|
||||||
{data?.active_tasks.length ?? 0}
|
|
||||||
</Badge>
|
|
||||||
</h2>
|
|
||||||
{isPending ? (
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
) : data?.active_tasks.length === 0 ? (
|
|
||||||
<p className="text-ink-muted text-sm">אין משימות פעילות</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{data?.active_tasks.map((t) => (
|
|
||||||
<li
|
|
||||||
key={t.task_id}
|
|
||||||
className="flex items-center gap-3 text-sm rounded bg-rule-soft/60 border border-rule px-3 py-2"
|
|
||||||
>
|
|
||||||
<span className="flex-1 text-ink truncate">
|
|
||||||
{t.filename || t.task_id}
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline" className="text-[0.7rem]">
|
|
||||||
{t.step || t.status}
|
|
||||||
</Badge>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Failed + stuck docs */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-6 py-5">
|
|
||||||
<h2 className="text-danger text-lg mb-3 flex items-center gap-2">
|
|
||||||
<AlertTriangle className="w-4 h-4" />
|
|
||||||
מסמכים שנכשלו
|
|
||||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
|
||||||
{data?.failed_documents.length ?? 0}
|
|
||||||
</Badge>
|
|
||||||
</h2>
|
|
||||||
{isPending ? (
|
|
||||||
<Skeleton className="h-20 w-full" />
|
|
||||||
) : data?.failed_documents.length === 0 ? (
|
|
||||||
<p className="text-ink-muted text-sm">אין כשלונות</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{data?.failed_documents.map((d) => (
|
|
||||||
<DocRow key={d.id} doc={d} tone="danger" />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
|
||||||
<CardContent className="px-6 py-5">
|
|
||||||
<h2 className="text-warn text-lg mb-3 flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
תקועים (> 10 דק׳)
|
|
||||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
|
||||||
{data?.stuck_documents.length ?? 0}
|
|
||||||
</Badge>
|
|
||||||
</h2>
|
|
||||||
{isPending ? (
|
|
||||||
<Skeleton className="h-20 w-full" />
|
|
||||||
) : data?.stuck_documents.length === 0 ? (
|
|
||||||
<p className="text-ink-muted text-sm">אין מסמכים תקועים</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{data?.stuck_documents.map((d) => (
|
|
||||||
<DocRow key={d.id} doc={d} tone="warn" />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { SystemHealthSection } from "@/components/operations/system-health-section";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -333,18 +334,34 @@ function PipelineCard({
|
|||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
children,
|
children,
|
||||||
|
href,
|
||||||
|
hrefLabel,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
children: React.ReactNode;
|
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 (
|
return (
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-5 py-4 space-y-2.5">
|
<CardContent className="px-5 py-4 space-y-2.5">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
<h3 className="text-navy text-sm font-semibold mb-0.5">{title}</h3>
|
<h3 className="text-navy text-sm font-semibold mb-0.5">{title}</h3>
|
||||||
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
|
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{href && (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="shrink-0 text-[0.72rem] text-gold-deep hover:underline whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{hrefLabel ?? "לטיפול ←"}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -619,7 +636,9 @@ export default function OperationsPage() {
|
|||||||
|
|
||||||
<PipelineCard
|
<PipelineCard
|
||||||
title="אישור הלכות (שער יו״ר)"
|
title="אישור הלכות (שער יו״ר)"
|
||||||
desc="הלכות שחולצו, ממתינות להכרעת דפנה ב-/approvals — שער-אנושי, לא תהליך"
|
desc="הלכות שחולצו, ממתינות להכרעת דפנה — שער-אנושי, לא תהליך"
|
||||||
|
href="/approvals"
|
||||||
|
hrefLabel="לתיבת-האישורים ←"
|
||||||
>
|
>
|
||||||
<StatusRow by={data.pipelines.halacha_review.by_status} />
|
<StatusRow by={data.pipelines.halacha_review.by_status} />
|
||||||
</PipelineCard>
|
</PipelineCard>
|
||||||
@@ -627,6 +646,8 @@ export default function OperationsPage() {
|
|||||||
<PipelineCard
|
<PipelineCard
|
||||||
title="פסיקה חסרה"
|
title="פסיקה חסרה"
|
||||||
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
|
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
|
||||||
|
href="/approvals"
|
||||||
|
hrefLabel="לתיבת-האישורים ←"
|
||||||
>
|
>
|
||||||
<StatusRow by={data.pipelines.missing_precedents.by_status} />
|
<StatusRow by={data.pipelines.missing_precedents.by_status} />
|
||||||
</PipelineCard>
|
</PipelineCard>
|
||||||
@@ -688,6 +709,16 @@ export default function OperationsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* INV-IA4: the former /diagnostics surface, folded in here — one
|
||||||
|
monitoring intent, one surface. /diagnostics now redirects here. */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<h2 className="text-navy text-lg mb-0">בריאות-מערכת</h2>
|
||||||
|
<p className="text-ink-muted text-sm">
|
||||||
|
מצב ה-DB, תורי-אישור, ומסמכים שנכשלו/תקועים. (אוחד מ״אבחון״.)
|
||||||
|
</p>
|
||||||
|
<SystemHealthSection />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -71,9 +71,10 @@ const KNOWLEDGE_MENUS: NavMenuDef[] = [
|
|||||||
|
|
||||||
const ADMIN_ITEMS: NavItem[] = [
|
const ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/skills", label: "מיומנויות" },
|
{ 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: "/operations", label: "תפעול" },
|
||||||
{ href: "/scripts", label: "סקריפטים" },
|
{ href: "/scripts", label: "סקריפטים" },
|
||||||
{ href: "/diagnostics", label: "אבחון" },
|
|
||||||
{ href: "/settings", label: "הגדרות" },
|
{ href: "/settings", label: "הגדרות" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
255
web-ui/src/components/operations/system-health-section.tsx
Normal file
255
web-ui/src/components/operations/system-health-section.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<li className={`rounded border px-3 py-2 flex items-center gap-3 text-sm ${cls}`}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-ink font-medium truncate" title={doc.title}>
|
||||||
|
{doc.title || "(ללא כותרת)"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted flex gap-3 mt-0.5">
|
||||||
|
{doc.case_number && (
|
||||||
|
<Link href={`/cases/${doc.case_number}`} className="hover:text-gold-deep">
|
||||||
|
ערר {doc.case_number}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span>{formatRelativeTime(doc.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">{doc.status}</Badge>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemHealthSection() {
|
||||||
|
const { data, isPending, error } = useDiagnostics();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-danger-bg border-danger/40">
|
||||||
|
<CardContent className="px-6 py-6 text-center text-danger">
|
||||||
|
{error.message}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* DB status + table counts */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 flex flex-col items-start gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||||
|
<Database className="w-3.5 h-3.5" />
|
||||||
|
מצב DB
|
||||||
|
</div>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-6 w-24" />
|
||||||
|
) : data?.db_ok ? (
|
||||||
|
<div className="flex items-center gap-2 text-success font-semibold">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
<span>מחובר</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-danger font-semibold">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
<span>מנותק</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4">
|
||||||
|
<div className="text-ink-muted text-[0.72rem] uppercase tracking-wider mb-3">
|
||||||
|
ספירת טבלאות
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-5 gap-y-2 gap-x-4">
|
||||||
|
{Object.keys(TABLE_LABELS).map((key) => (
|
||||||
|
<div key={key} className="space-y-0.5">
|
||||||
|
<dt className="text-[0.72rem] text-ink-muted">{TABLE_LABELS[key]}</dt>
|
||||||
|
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
||||||
|
{isPending ? "—" : (data?.tables[key] ?? "—")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Halacha review backlog (ADM-1, INV-IA5): render the human-gate counter
|
||||||
|
the backend returns; the action lives at /approvals (INV-IA1). */}
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h3 className="text-navy text-base mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
תור אישור הלכות
|
||||||
|
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||||
|
{isPending ? "—" : (data?.halacha_backlog?.pending_review ?? 0)}
|
||||||
|
</Badge>
|
||||||
|
<Link
|
||||||
|
href="/approvals"
|
||||||
|
className="ms-auto text-[0.75rem] text-gold-deep hover:underline"
|
||||||
|
>
|
||||||
|
לאישור ←
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-4 gap-y-2 gap-x-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<dt className="text-[0.72rem] text-ink-muted">ממתינים (נקי)</dt>
|
||||||
|
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
||||||
|
{data?.halacha_backlog?.pending_clean ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<dt className="text-[0.72rem] text-ink-muted">דורש תיקון</dt>
|
||||||
|
<dd className="font-display font-bold text-warn text-xl tabular-nums">
|
||||||
|
{data?.halacha_backlog?.pending_flagged ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<dt className="text-[0.72rem] text-ink-muted">אושרו</dt>
|
||||||
|
<dd className="font-display font-bold text-success text-xl tabular-nums">
|
||||||
|
{data?.halacha_backlog?.approved ?? 0}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<dt className="text-[0.72rem] text-ink-muted">הוותיק ביותר</dt>
|
||||||
|
<dd className="text-sm text-ink-soft">
|
||||||
|
{formatRelativeTime(data?.halacha_backlog?.oldest_pending_at ?? null)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active tasks */}
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h3 className="text-navy text-base mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
משימות רקע פעילות
|
||||||
|
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||||
|
{data?.active_tasks.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</h3>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
) : data?.active_tasks.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין משימות פעילות</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data?.active_tasks.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.task_id}
|
||||||
|
className="flex items-center gap-3 text-sm rounded bg-rule-soft/60 border border-rule px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-ink truncate">
|
||||||
|
{t.filename || t.task_id}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
|
{t.step || t.status}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Failed + stuck docs */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h3 className="text-danger text-base mb-3 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
מסמכים שנכשלו
|
||||||
|
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||||
|
{data?.failed_documents.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</h3>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
) : data?.failed_documents.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין כשלונות</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data?.failed_documents.map((d) => (
|
||||||
|
<DocRow key={d.id} doc={d} tone="danger" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<h3 className="text-warn text-base mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
תקועים (> 10 דק׳)
|
||||||
|
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||||
|
{data?.stuck_documents.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</h3>
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
) : data?.stuck_documents.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm">אין מסמכים תקועים</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data?.stuck_documents.map((d) => (
|
||||||
|
<DocRow key={d.id} doc={d} tone="warn" />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -149,11 +149,11 @@ function RecentFindings() {
|
|||||||
<span className="text-navy font-semibold tabular-nums">
|
<span className="text-navy font-semibold tabular-nums">
|
||||||
{f.decision_number || "—"}
|
{f.decision_number || "—"}
|
||||||
</span>
|
</span>
|
||||||
{f.applied_to_skill && (
|
{f.review_status === "approved" && (
|
||||||
<Badge variant="outline"
|
<Badge variant="outline"
|
||||||
className="bg-success-bg text-success border-success/40">
|
className="bg-success-bg text-success border-success/40">
|
||||||
<CheckCircle2 className="w-3 h-3 me-0.5" />
|
<CheckCircle2 className="w-3 h-3 me-0.5" />
|
||||||
אומץ
|
מאושר
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="grow text-ink-muted text-end">
|
<span className="grow text-ink-muted text-end">
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
* The chair can:
|
* The chair can:
|
||||||
* - Add a lesson typed manually (category = "general" by default)
|
* - Add a lesson typed manually (category = "general" by default)
|
||||||
* - Edit / delete existing lessons
|
* - Edit / delete existing lessons
|
||||||
* - Mark a lesson as "applied_to_skill" (informational — doesn't
|
* - Approve / reject a lesson (review_status — INV-LRN1/G10): only an
|
||||||
* auto-commit anything to SKILL.md; chair still curates that file
|
* approved lesson flows to the writer. This is the SINGLE writer gate;
|
||||||
* manually in git).
|
* 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
|
* Lessons from the curator arrive with source="curator" and are visually
|
||||||
* distinguished by a badge so the chair can audit auto-suggestions.
|
* distinguished by a badge so the chair can audit auto-suggestions.
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles, Check, X, Undo2,
|
Plus, Save, Trash2, Loader2, Check, X, Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
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 () => {
|
const onDelete = async () => {
|
||||||
if (!window.confirm("למחוק את הלקח?")) return;
|
if (!window.confirm("למחוק את הלקח?")) return;
|
||||||
try {
|
try {
|
||||||
@@ -226,13 +215,6 @@ function LessonItem({
|
|||||||
<Badge variant="outline" className={reviewBadge.cls}>
|
<Badge variant="outline" className={reviewBadge.cls}>
|
||||||
{reviewBadge.label}
|
{reviewBadge.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
{lesson.applied_to_skill && (
|
|
||||||
<Badge variant="outline"
|
|
||||||
className="bg-success-bg text-success border-success/40">
|
|
||||||
<CheckCircle2 className="w-3 h-3 me-1" />
|
|
||||||
אומץ
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span className="grow text-ink-muted tabular-nums">
|
<span className="grow text-ink-muted tabular-nums">
|
||||||
{new Date(lesson.created_at).toLocaleDateString("he-IL")}
|
{new Date(lesson.created_at).toLocaleDateString("he-IL")}
|
||||||
</span>
|
</span>
|
||||||
@@ -307,11 +289,6 @@ function LessonItem({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm" onClick={onToggleApplied}
|
|
||||||
disabled={patch.isPending}>
|
|
||||||
<Sparkles className="w-3 h-3 me-1" />
|
|
||||||
{lesson.applied_to_skill ? "בטל סימון 'אומץ'" : "סמן כ'אומץ ל-SKILL'"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||||
ערוך
|
ערוך
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -295,7 +295,8 @@ export type CuratorFinding = {
|
|||||||
id: string;
|
id: string;
|
||||||
lesson_text: string;
|
lesson_text: string;
|
||||||
category: 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_number: string;
|
||||||
decision_date: string;
|
decision_date: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -479,9 +480,9 @@ export type DecisionLesson = {
|
|||||||
// "panel:deepseek+gemini" — the two-judge style panel; (string) keeps it open
|
// "panel:deepseek+gemini" — the two-judge style panel; (string) keeps it open
|
||||||
// to future panel sources without a type break.
|
// to future panel sources without a type break.
|
||||||
source: "manual" | "curator" | "chair" | "style_analyzer" | (string & {});
|
source: "manual" | "curator" | "chair" | "style_analyzer" | (string & {});
|
||||||
applied_to_skill: boolean;
|
|
||||||
// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer.
|
// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer.
|
||||||
// Panel-written lessons start as "proposed" and wait for the chair here.
|
// 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";
|
review_status: "proposed" | "approved" | "rejected";
|
||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -499,7 +500,6 @@ export type LessonCreate = {
|
|||||||
export type LessonPatch = {
|
export type LessonPatch = {
|
||||||
lesson_text?: string;
|
lesson_text?: string;
|
||||||
category?: DecisionLesson["category"];
|
category?: DecisionLesson["category"];
|
||||||
applied_to_skill?: boolean;
|
|
||||||
review_status?: DecisionLesson["review_status"];
|
review_status?: DecisionLesson["review_status"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
25
web/app.py
25
web/app.py
@@ -1338,7 +1338,7 @@ async def get_curator_stats():
|
|||||||
# Last 10 curator findings — newest first
|
# Last 10 curator findings — newest first
|
||||||
recent_rows = await conn.fetch(
|
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,
|
dl.created_at,
|
||||||
sc.decision_number, sc.decision_date
|
sc.decision_number, sc.decision_date
|
||||||
FROM decision_lessons dl
|
FROM decision_lessons dl
|
||||||
@@ -1358,7 +1358,7 @@ async def get_curator_stats():
|
|||||||
"id": str(r["id"]),
|
"id": str(r["id"]),
|
||||||
"lesson_text": r["lesson_text"],
|
"lesson_text": r["lesson_text"],
|
||||||
"category": r["category"],
|
"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_number": r["decision_number"] or "",
|
||||||
"decision_date": str(r["decision_date"]) if r["decision_date"] else "",
|
"decision_date": str(r["decision_date"]) if r["decision_date"] else "",
|
||||||
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||||
@@ -1454,7 +1454,6 @@ class LessonCreate(BaseModel):
|
|||||||
class LessonPatch(BaseModel):
|
class LessonPatch(BaseModel):
|
||||||
lesson_text: str | None = None
|
lesson_text: str | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
applied_to_skill: bool | None = None
|
|
||||||
review_status: str | None = None # proposed | approved | rejected (INV-LRN1 gate)
|
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"],
|
"lesson_text": row["lesson_text"],
|
||||||
"category": row["category"],
|
"category": row["category"],
|
||||||
"source": row["source"],
|
"source": row["source"],
|
||||||
"applied_to_skill": bool(row["applied_to_skill"]),
|
|
||||||
# review gate (INV-LRN1/G10): only 'approved' flows to the writer.
|
# 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"),
|
"review_status": row.get("review_status", "proposed"),
|
||||||
"created_by": row.get("created_by", ""),
|
"created_by": row.get("created_by", ""),
|
||||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
|
"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,
|
lid,
|
||||||
lesson_text=body.lesson_text,
|
lesson_text=body.lesson_text,
|
||||||
category=body.category,
|
category=body.category,
|
||||||
applied_to_skill=body.applied_to_skill,
|
|
||||||
review_status=body.review_status,
|
review_status=body.review_status,
|
||||||
)
|
)
|
||||||
if not result.get("updated"):
|
if not result.get("updated"):
|
||||||
@@ -4657,11 +4655,20 @@ class PromoteLearningRequest(BaseModel):
|
|||||||
|
|
||||||
async def _append_methodology_override(category: str, key: str, items: list[str]) -> None:
|
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.
|
"""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()
|
pool = await db.get_pool()
|
||||||
row = await pool.fetchrow(
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
row = await conn.fetchrow(
|
||||||
"SELECT rule_value FROM appeal_type_rules "
|
"SELECT rule_value FROM appeal_type_rules "
|
||||||
"WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2",
|
"WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2 "
|
||||||
|
"FOR UPDATE",
|
||||||
category, key,
|
category, key,
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
@@ -4671,7 +4678,7 @@ async def _append_methodology_override(category: str, key: str, items: list[str]
|
|||||||
if not isinstance(current, list):
|
if not isinstance(current, list):
|
||||||
current = []
|
current = []
|
||||||
merged = current + [s for s in items if s and s not in current]
|
merged = current + [s for s in items if s and s not in current]
|
||||||
await pool.execute(
|
await conn.execute(
|
||||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
|
"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",
|
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
|
||||||
|
|||||||
Reference in New Issue
Block a user