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
|
||||
(`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 על מפתח דטרמיניסטי. קליטה חוזרת של
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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), כיוון-יעד פר-אשכול.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// 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() {
|
||||
const { data, isPending, error } = useDiagnostics();
|
||||
|
||||
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>
|
||||
);
|
||||
redirect("/operations");
|
||||
}
|
||||
|
||||
@@ -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,18 +334,34 @@ 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 (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<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>
|
||||
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
|
||||
</div>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="shrink-0 text-[0.72rem] text-gold-deep hover:underline whitespace-nowrap"
|
||||
>
|
||||
{hrefLabel ?? "לטיפול ←"}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -619,7 +636,9 @@ export default function OperationsPage() {
|
||||
|
||||
<PipelineCard
|
||||
title="אישור הלכות (שער יו״ר)"
|
||||
desc="הלכות שחולצו, ממתינות להכרעת דפנה ב-/approvals — שער-אנושי, לא תהליך"
|
||||
desc="הלכות שחולצו, ממתינות להכרעת דפנה — שער-אנושי, לא תהליך"
|
||||
href="/approvals"
|
||||
hrefLabel="לתיבת-האישורים ←"
|
||||
>
|
||||
<StatusRow by={data.pipelines.halacha_review.by_status} />
|
||||
</PipelineCard>
|
||||
@@ -627,6 +646,8 @@ export default function OperationsPage() {
|
||||
<PipelineCard
|
||||
title="פסיקה חסרה"
|
||||
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
|
||||
href="/approvals"
|
||||
hrefLabel="לתיבת-האישורים ←"
|
||||
>
|
||||
<StatusRow by={data.pipelines.missing_precedents.by_status} />
|
||||
</PipelineCard>
|
||||
@@ -688,6 +709,16 @@ export default function OperationsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
@@ -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: "הגדרות" },
|
||||
];
|
||||
|
||||
|
||||
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">
|
||||
{f.decision_number || "—"}
|
||||
</span>
|
||||
{f.applied_to_skill && (
|
||||
{f.review_status === "approved" && (
|
||||
<Badge variant="outline"
|
||||
className="bg-success-bg text-success border-success/40">
|
||||
<CheckCircle2 className="w-3 h-3 me-0.5" />
|
||||
אומץ
|
||||
מאושר
|
||||
</Badge>
|
||||
)}
|
||||
<span className="grow text-ink-muted text-end">
|
||||
|
||||
@@ -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({
|
||||
<Badge variant="outline" className={reviewBadge.cls}>
|
||||
{reviewBadge.label}
|
||||
</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">
|
||||
{new Date(lesson.created_at).toLocaleDateString("he-IL")}
|
||||
</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>
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
|
||||
|
||||
25
web/app.py
25
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,11 +4655,20 @@ 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(
|
||||
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",
|
||||
"WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2 "
|
||||
"FOR UPDATE",
|
||||
category, key,
|
||||
)
|
||||
if row:
|
||||
@@ -4671,7 +4678,7 @@ async def _append_methodology_override(category: str, key: str, items: list[str]
|
||||
if not isinstance(current, list):
|
||||
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) "
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user