fix(learning): כרטיס-אוצֵר כן-3-ערוצים + לכידת ממצאי-אוצֵר (source='curator') #339
@@ -15,7 +15,7 @@ hermes-curator.md — מקור-האמת היחיד לפרומפט של סוכן
|
||||
adapter: deepseek_local · model: deepseek-v4-pro
|
||||
profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx)
|
||||
role: Knowledge Curator — מנתח החלטות סופיות אחרי export, מציע עדכוני skills/lessons.
|
||||
read-only על תוכן; write רק על comments / interactions (G10).
|
||||
read-only על תוכן; כותב comments / interactions + ממצאים מוצעים (decision_lessons, שער-יו"ר G10).
|
||||
|
||||
placeholders זמינים: {{agentId}} {{agentName}} {{companyId}} {{companyName}} {{runId}}
|
||||
{{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}}
|
||||
@@ -129,6 +129,24 @@ curl -sS -X POST \
|
||||
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` שדה `expected_outcome`**, **לא מקריאת הטקסט**. אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
||||
- אל תפרש משפטית את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
|
||||
|
||||
## 5b. שמור את הממצאים מבנית (חובה — INV-LRN3)
|
||||
ה-comment הוא ארעי ולא-נסקר. כדי שהממצאים ייתפסו, יופיעו בטאב ״אוצֵר״ ב-/training, ויעברו
|
||||
שער-יו"ר — קרא לכלי ה-MCP **`mcp__legal-ai__record_curator_findings`** עם אותם ממצאים:
|
||||
```
|
||||
record_curator_findings(
|
||||
case_number="<מספר התיק מ-taskTitle>",
|
||||
findings=[
|
||||
{"text": "<ניסוח הממצא — אותו טקסט כמו ב-comment, בלי התג>", "category": "style"},
|
||||
{"text": "...", "category": "structure"},
|
||||
...
|
||||
]
|
||||
)
|
||||
```
|
||||
מיפוי תג→category: `[סגנון]`→`style` · `[מבנה]`→`structure` · `[לקסיקון משפטי]`→`lexicon` · `[טבלאי]`→`tabular`.
|
||||
הכלי כותב כל ממצא כ-`decision_lesson` (`source='curator'`, `review_status='proposed'`) ומדלג על כפילויות.
|
||||
אם הוא מחזיר שגיאת "לא נמצאה רשומת style_corpus" — הסופי טרם נקלט לקורפוס-הסגנון; ציין זאת ב-comment והמשך (אל תיכשל).
|
||||
**זו הצעה הממתינה לאישור דפנה — לא שינוי-קול. אתה עדיין read-only על התוכן ולא נוגע ב-skills/קבצים.**
|
||||
|
||||
## 6. בחר interaction (חובה — רוב המקרים יש)
|
||||
לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7.
|
||||
|
||||
@@ -261,7 +279,7 @@ curl -sS -X PATCH \
|
||||
# כללים כלליים
|
||||
|
||||
- **idempotencyKey**: חובה ב-interaction. אם נעור פעמיים על אותו תיק — Paperclip לא יוצר כפילות.
|
||||
- **לא לעדכן** קבצים (skills/, lessons.py, DB) בעצמך. רק לכתוב comments / interactions.
|
||||
- **לא לעדכן** קבצים (skills/, lessons.py) או שכבת-הקול בעצמך. הפלט שלך: comments + interactions + רישום ממצאים כ-`decision_lesson` *מוצע* דרך `record_curator_findings` (שער-יו"ר, INV-LRN3). אתה לא מאשר ולא מטמיע — רק מציע.
|
||||
- **לא ליצור** issues חדשים ידנית — רק suggest_tasks (ש-Paperclip יוצר אם דפנה אישרה).
|
||||
- **לא להעיר** סוכנים אחרים.
|
||||
- **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף.
|
||||
|
||||
@@ -87,10 +87,24 @@
|
||||
(`hermes-curator.md:60-70`).
|
||||
- מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
|
||||
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
|
||||
- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך
|
||||
`POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI
|
||||
תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`).
|
||||
- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`).
|
||||
- **מציע** — comment + interaction בערוץ-הפלטפורמה, וגם **רושם כל ממצא מבנית** כ-`decision_lesson`
|
||||
(`source='curator'`, `review_status='proposed'`) דרך כלי-ה-MCP `record_curator_findings`
|
||||
(`hermes-curator.md` §A.5b → `tools/workflow.py::record_curator_findings` → `db.add_decision_lesson`).
|
||||
הממצאים מופיעים ב-`/training` טאב "אוצֵר" (ערוץ ג׳) ועוברים את שער-היו"ר (INV-LRN1).
|
||||
> **דריפט-מימוש שנסגר (2026-06-28):** עד גל זה הסוכן כתב comment בלבד והספ תיאר endpoint שלא נקרא —
|
||||
> הממצאים האיכותיים אבדו, וכרטיס-האוצֵר (`get_curator_stats`) ספר `source='curator'` שאיש לא כתב → 0.
|
||||
> `record_curator_findings` מממש את INV-LRN3 בפועל.
|
||||
- **אינו מאשר ואינו מטמיע** — read-only על התוכן; רישום-הממצא הוא *הצעה* מגודרת-שער, לא שינוי-קול.
|
||||
אינו עורך `skills/` / `lessons.py` / שכבת-הקול בעצמו (`hermes-curator.md` §"כללים כלליים").
|
||||
- **שני ערוצי-למידה נפרדים על אותו סופי:** ערוץ-האוצֵר (כאן, qualitative, `source='curator'`) ו**פאנל-הסגנון**
|
||||
הדו-סוכני שהצינור מפעיל (§0.6 — DeepSeek+Gemini, `source='panel:deepseek+gemini'`). שניהם
|
||||
`decision_lessons` ממתיני-שער; ערוץ הדיסטילציה (`appeal_type_rules`, §0.4) הוא השלישי.
|
||||
|
||||
> **פער-מימוש פתוח (TaskMaster #157):** ה-`PIPELINE-WAKE BRANCH` (`hermes-curator.md:24-43`) מפעיל את
|
||||
> הצינור על `final_learning_*` ו**מסיים מיד** — ניתוח-§A האיכותי (וממילא `record_curator_findings`)
|
||||
> **אינו רץ אוטומטית על mark-final**, אלא רק ביקיצת-תגובה/ידנית. כדי שממצאי-האוצֵר ייתפסו על כל סופי
|
||||
> יש להכריע: שהסוכן ימשיך ל-§A אחרי הפעלת-הצינור (לטפל במירוץ enroll_style_corpus), או שהצינור עצמו
|
||||
> יפלוט `source='curator'`. נדחה לתכנון נפרד.
|
||||
|
||||
### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים)
|
||||
|
||||
|
||||
@@ -1176,6 +1176,15 @@ async def list_chair_feedback(
|
||||
return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_curator_findings(case_number: str, findings: list[dict]) -> str:
|
||||
"""לכידת ממצאי-האוצֵר על החלטה סופית כ-decision_lessons מובְנים (source='curator',
|
||||
ממתינים לשער-יו"ר) — INV-LRN3. כל ממצא: {"text": "...", "category": style/structure/
|
||||
lexicon/tabular} (או "tag" עברי). מחזיר כמה נרשמו וכמה כפילויות דולגו. read-only על
|
||||
התוכן — הרישום הצעה הממתינה לאישור דפנה ב-/training, לא שינוי-קול ישיר (G10)."""
|
||||
return await workflow.record_curator_findings(case_number, findings)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def halacha_corroboration(halacha_id: str) -> dict:
|
||||
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""
|
||||
|
||||
@@ -2651,6 +2651,22 @@ async def add_decision_lesson(
|
||||
return dict(row) if row else {}
|
||||
|
||||
|
||||
async def get_style_corpus_id_by_decision(decision_number: str) -> UUID | None:
|
||||
"""Resolve the style_corpus row id for a final decision by its number.
|
||||
|
||||
The curator knows the case_number; lessons attach to the corpus row the
|
||||
learning pipeline enrolled (enroll_style_corpus). Returns None if the final
|
||||
has not been enrolled yet (caller surfaces it — no silent attach).
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id FROM style_corpus WHERE decision_number = $1 LIMIT 1",
|
||||
decision_number,
|
||||
)
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
async def update_decision_lesson(
|
||||
lesson_id: UUID,
|
||||
*,
|
||||
@@ -2866,6 +2882,12 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "")
|
||||
Gate (INV-LRN1/G10): returns only CHAIR-APPROVED lessons (review_status='approved').
|
||||
Panel-written proposals stay out of the writer's context until the chair approves
|
||||
them in /training — the chair's review is the gate, not the panel's 2/2 vote.
|
||||
|
||||
No silent cap (חוקה §6): when more approved lessons match than `limit`, the
|
||||
overflow is dropped by ORDER BY created_at DESC — log it so coverage loss is
|
||||
observable, not invisible. The real fix is lesson synthesis (TaskMaster #158):
|
||||
consolidate overlapping lessons into fewer, richer ones so the set fits without
|
||||
truncation. Until then this WARN is the audit trail.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
@@ -2880,6 +2902,20 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "")
|
||||
LIMIT $1""",
|
||||
limit, practice_area,
|
||||
)
|
||||
if len(rows) >= limit:
|
||||
total = await conn.fetchval(
|
||||
"""SELECT count(*) FROM decision_lessons dl
|
||||
JOIN style_corpus sc ON sc.id = dl.style_corpus_id
|
||||
WHERE dl.review_status = 'approved'
|
||||
AND ($1 = '' OR sc.practice_area = $1)""",
|
||||
practice_area,
|
||||
) or 0
|
||||
if total > limit:
|
||||
logger.warning(
|
||||
"get_recent_decision_lessons: capped %d→%d approved lessons "
|
||||
"(practice_area=%r) — %d not reaching the writer. See TaskMaster #158 (synthesis).",
|
||||
total, limit, practice_area or "*", total - limit,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
@@ -403,6 +403,78 @@ async def record_chair_feedback(
|
||||
}, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.")
|
||||
|
||||
|
||||
_CURATOR_FINDING_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"}
|
||||
# Agent tags ([סגנון]/[מבנה]/[לקסיקון משפטי]/[טבלאי]) → decision_lessons.category
|
||||
_CURATOR_TAG_TO_CATEGORY = {
|
||||
"סגנון": "style", "מבנה": "structure",
|
||||
"לקסיקון משפטי": "lexicon", "לקסיקון": "lexicon", "טבלאי": "tabular",
|
||||
}
|
||||
|
||||
|
||||
async def record_curator_findings(case_number: str, findings: list[dict]) -> str:
|
||||
"""לכידת ממצאי-האוצֵר כ-decision_lessons מובְנים (source='curator', proposed) — INV-LRN3.
|
||||
|
||||
האוצֵר מזהה דפוסי-סגנון בקריאת הסופי; עד כה הם חיו רק כהערה ארעית בערוץ-התגובות
|
||||
(אובד, לא נסקר). כאן הם נתפסים מבנית כך שיופיעו בטאב ״אוצֵר״ ויעברו שער-יו"ר (INV-LRN1/G10).
|
||||
האוצֵר נשאר read-only על התוכן — הרישום הוא הצעה הממתינה לאישור, לא שינוי-קול.
|
||||
|
||||
Args:
|
||||
case_number: מספר התיק הסופי (= decision_number בקורפוס-הסגנון).
|
||||
findings: רשימת ממצאים, כל אחד {"text": "...", "category"/"tag": "..."}.
|
||||
category ∈ style/structure/lexicon/tabular/general (או tag עברי).
|
||||
"""
|
||||
if not findings:
|
||||
return err("findings ריק — אין ממצאים לרשום.")
|
||||
corpus_id = await db.get_style_corpus_id_by_decision(case_number)
|
||||
if not corpus_id:
|
||||
return err(
|
||||
f"לא נמצאה רשומת style_corpus ל-{case_number} — ודא שהסופי נקלט לקורפוס-הסגנון "
|
||||
"(enroll_style_corpus) לפני רישום ממצאים."
|
||||
)
|
||||
# Dedup against lessons already on this corpus (any source) — re-running §A
|
||||
# must not pile duplicates (INV-LRN3 reliability).
|
||||
existing = {(_norm(r["lesson_text"])) for r in await db.list_decision_lessons(corpus_id)}
|
||||
written, skipped_dup, skipped_empty = [], 0, 0
|
||||
for f in findings:
|
||||
text = (f.get("text") or "").strip()
|
||||
if not text:
|
||||
skipped_empty += 1
|
||||
continue
|
||||
if _norm(text) in existing:
|
||||
skipped_dup += 1
|
||||
continue
|
||||
raw_cat = (f.get("category") or f.get("tag") or "general").strip()
|
||||
category = _CURATOR_TAG_TO_CATEGORY.get(raw_cat, raw_cat)
|
||||
if category not in _CURATOR_FINDING_CATEGORIES:
|
||||
category = "general"
|
||||
row = await db.add_decision_lesson(
|
||||
corpus_id,
|
||||
lesson_text=text,
|
||||
category=category,
|
||||
source="curator",
|
||||
created_by="curator",
|
||||
review_status="proposed",
|
||||
)
|
||||
if row:
|
||||
written.append(str(row["id"]))
|
||||
existing.add(_norm(text))
|
||||
return ok({
|
||||
"corpus_id": str(corpus_id),
|
||||
"written": len(written),
|
||||
"skipped_duplicate": skipped_dup,
|
||||
"skipped_empty": skipped_empty,
|
||||
"lesson_ids": written,
|
||||
}, message=(
|
||||
f"נרשמו {len(written)} ממצאי-אוצֵר (source=curator, ממתינים לשער-יו\"ר ב-/training). "
|
||||
f"{skipped_dup} כפילויות דולגו."
|
||||
))
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""Normalize lesson text for dedup — collapse whitespace, strip."""
|
||||
return " ".join((s or "").split())
|
||||
|
||||
|
||||
async def list_chair_feedback(
|
||||
case_number: str = "",
|
||||
category: str = "",
|
||||
|
||||
@@ -45,6 +45,15 @@ export function CuratorPortraitPanel() {
|
||||
<StatsCard />
|
||||
<RecentFindings />
|
||||
|
||||
<div className="bg-info-bg border border-info/40 rounded-lg px-4 py-3 flex items-start gap-2.5 text-[0.78rem] text-ink-soft leading-relaxed">
|
||||
<span>🔒</span>
|
||||
<div>
|
||||
<b className="text-navy">שער-היו״ר (INV-LRN1 / G10):</b> כל הערוצים <b>מציעים</b> בלבד.
|
||||
ממצא או לקח משפיע על הכתיבה רק אחרי שדפנה אישרה אותו. האוצֵר עצמו read-only על התוכן —
|
||||
מזהה דפוסים ומגיש, לעולם לא משנה קבצים או סגנון בעצמו.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="curator-prompt" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="curator-prompt">פרומפט ה-Curator</TabsTrigger>
|
||||
@@ -67,38 +76,127 @@ export function CuratorPortraitPanel() {
|
||||
|
||||
// ── stats card ─────────────────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-bold mb-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One of the three learning channels — engine, backing store, count, flow gate. */
|
||||
function ChannelCard({
|
||||
letter, title, engine, target, store, value, unit, flows, flowLabel, meta,
|
||||
}: {
|
||||
letter: string; title: string; engine: string; target: string; store: string;
|
||||
value: number; unit: string; flows: boolean; flowLabel: string; meta: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-[18px] py-4 flex flex-col gap-2.5">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-[22px] h-[22px] rounded-md flex items-center justify-center font-bold text-xs text-parchment shrink-0 bg-navy">
|
||||
{letter}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-[0.9rem] text-navy font-semibold leading-tight">{title}</h3>
|
||||
<div className="text-[0.72rem] text-ink-muted">{engine}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-soft bg-rule-soft rounded-md px-2.5 py-1.5">
|
||||
← {target} <code className="text-[0.68rem] text-gold-deep">{store}</code>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[1.9rem] leading-none font-bold text-navy tabular-nums">{value}</span>
|
||||
<span className="text-[0.78rem] text-ink-muted">{unit}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full text-[0.72rem] font-semibold px-2.5 py-0.5 w-fit border ${
|
||||
flows
|
||||
? "bg-success-bg text-success border-success/40"
|
||||
: "bg-gold-wash text-gold-deep border-gold/40"
|
||||
}`}
|
||||
>
|
||||
<span className="text-[0.5rem]">●</span>{flowLabel}
|
||||
</span>
|
||||
<div className="text-[0.72rem] text-ink-muted leading-relaxed">{meta}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsCard() {
|
||||
const { data, isPending } = useCuratorStats();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-44 w-full" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const { distillation: a, panel: b, curator: c } = data.channels;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Kpi label="ממצאי curator" value={data.total_findings} icon={<Sparkles className="w-4 h-4" />} />
|
||||
<Kpi label="החלטות שנסקרו" value={`${data.decisions_with_findings}/${data.decisions_total}`} icon={<FileText className="w-4 h-4" />} />
|
||||
<Kpi label="ממצאים מאושרים (זורמים לכותב)" value={data.findings_approved} icon={<CheckCircle2 className="w-4 h-4" />} />
|
||||
<Kpi label="ממוצע ממצאים להחלטה"
|
||||
value={
|
||||
data.decisions_with_findings > 0
|
||||
? (data.total_findings / data.decisions_with_findings).toFixed(1)
|
||||
: "—"
|
||||
}
|
||||
icon={<Brain className="w-4 h-4" />}
|
||||
<div className="space-y-5">
|
||||
<div className="bg-gold-wash border border-gold rounded-lg px-5 py-4 flex items-start gap-3">
|
||||
<span className="text-gold-deep text-xl leading-tight">★</span>
|
||||
<p className="text-ink-soft text-sm leading-relaxed">
|
||||
הלמידה זורמת לכותב ב<b className="text-gold-deep font-semibold">שלושה ערוצים נפרדים</b>, כל אחד
|
||||
מופעל כשדפנה מסמנת החלטה כסופית. הכרטיס מראה כמה כל ערוץ תרם ומה כבר עובר בפועל לכותב —
|
||||
רק ידע ש<b className="text-gold-deep font-semibold">אושר בשער-היו״ר</b> משפיע על הטיוטות (INV-LRN1).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionLabel>שלושת ערוצי-ההזנה לכותב</SectionLabel>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<ChannelCard
|
||||
letter="א" title="דיסטילציה" engine="Claude · השוואת טיוטה↔סופי"
|
||||
target="כללי-מתודולוגיה" store="appeal_type_rules"
|
||||
value={a.items_total} unit="פריטים זורמים"
|
||||
flows={a.items_total > 0} flowLabel="זורם לכותב"
|
||||
meta={`${a.discussion_rules} כללי-דיון + ${a.transition_phrases} ביטויי-מעבר · אושרו ב-${a.pairs_folded} זוגות דרך טאב ״למידה״.`}
|
||||
/>
|
||||
<ChannelCard
|
||||
letter="ב" title="פאנל דו-סוכני" engine="DeepSeek + Gemini · הצבעה 2/2"
|
||||
target="לקחי-סגנון" store="decision_lessons"
|
||||
value={b.total} unit="לקחים"
|
||||
flows={b.approved > 0} flowLabel={`${b.approved} אושרו · ${b.proposed} ממתינים`}
|
||||
meta={`על פני ${b.decisions} החלטות · אישור per-החלטה בטאב הקורפוס.`}
|
||||
/>
|
||||
<ChannelCard
|
||||
letter="ג" title="ממצאי-אוצֵר" engine="האוצֵר · קריאת הסופי + דפוסים"
|
||||
target="ממצאי-סגנון" store="source=curator"
|
||||
value={c.total} unit="ממצאים"
|
||||
flows={c.approved > 0} flowLabel={`${c.approved} אושרו · ${c.proposed} ממתינים`}
|
||||
meta="3–5 דפוסים מתויגים להחלטה (סגנון/מבנה/לקסיקון/טבלאי) — נתפסים מבנית, לא רק כהערה."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionLabel>ערוץ האוצֵר — מבט מקרוב</SectionLabel>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Kpi label="סך ממצאי-אוצֵר" value={c.total} icon={<Sparkles className="w-4 h-4" />} />
|
||||
<Kpi label="החלטות שנסקרו" value={`${c.decisions_reviewed}/${data.decisions_total}`} icon={<FileText className="w-4 h-4" />} />
|
||||
<Kpi label="מאושרים · זורמים לכותב" value={c.approved} tone="success" icon={<CheckCircle2 className="w-4 h-4" />} />
|
||||
<Kpi label="ממתינים לאישור" value={c.proposed} tone="gold" icon={<Brain className="w-4 h-4" />} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
label, value, icon,
|
||||
}: { label: string; value: string | number; icon: React.ReactNode }) {
|
||||
label, value, icon, tone,
|
||||
}: { label: string; value: string | number; icon: React.ReactNode; tone?: "success" | "gold" }) {
|
||||
const valueCls = tone === "success" ? "text-success" : tone === "gold" ? "text-gold-deep" : "text-navy";
|
||||
return (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-4 py-3">
|
||||
@@ -106,7 +204,7 @@ function Kpi({
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="text-2xl text-navy font-semibold tabular-nums mt-1">{value}</p>
|
||||
<p className={`text-2xl font-semibold tabular-nums mt-1 ${valueCls}`}>{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -123,10 +221,10 @@ function RecentFindings() {
|
||||
if (!data || data.recent_findings.length === 0) {
|
||||
return (
|
||||
<Card className="bg-rule-soft/40 border-rule">
|
||||
<CardContent className="px-6 py-5 text-center text-ink-muted text-sm">
|
||||
אין עדיין ממצאים של ה-Curator. הוא מופעל אוטומטית כאשר דפנה מסמנת
|
||||
החלטה כסופית (mark-final), ושומר את ממצאיו כ-decision_lessons עם
|
||||
source="curator".
|
||||
<CardContent className="px-6 py-5 text-center text-ink-muted text-sm leading-relaxed">
|
||||
אין עדיין ממצאי-אוצֵר. כשדפנה מסמנת החלטה כסופית, האוצֵר קורא את הסופי, מזהה
|
||||
3–5 דפוסי-סגנון, ושומר אותם כ-<code>decision_lessons</code> (source="curator")
|
||||
הממתינים לאישורה — בנפרד מלקחי-הפאנל ומכללי-המתודולוגיה שלמעלה.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -197,7 +197,7 @@ export function StyleReportPanel() {
|
||||
<h3 className="text-navy text-lg m-0">אוצֵר — ממצאי-סגנון</h3>
|
||||
<div className="flex items-center gap-4 rounded-lg border border-success bg-success-bg px-[18px] py-3.5">
|
||||
<span className="text-success font-bold text-[1.75rem] leading-none tabular-nums">
|
||||
{curator.data ? curator.data.findings_approved : "—"}
|
||||
{curator.data ? curator.data.channels.curator.approved : "—"}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<b className="text-navy text-sm font-semibold block">
|
||||
@@ -214,8 +214,8 @@ export function StyleReportPanel() {
|
||||
{curator.data
|
||||
? Math.max(
|
||||
0,
|
||||
curator.data.total_findings -
|
||||
curator.data.findings_approved,
|
||||
curator.data.channels.curator.total -
|
||||
curator.data.channels.curator.approved,
|
||||
)
|
||||
: "—"}
|
||||
</div>
|
||||
@@ -223,7 +223,7 @@ export function StyleReportPanel() {
|
||||
</div>
|
||||
<div className="flex-1 rounded-lg border border-rule bg-rule-soft px-3.5 py-3">
|
||||
<div className="text-ink-muted font-bold text-[1.35rem] tabular-nums leading-none">
|
||||
{curator.data ? curator.data.total_findings : "—"}
|
||||
{curator.data ? curator.data.channels.curator.total : "—"}
|
||||
</div>
|
||||
<div className="text-[0.78rem] text-ink-soft mt-1">סך ממצאים</div>
|
||||
</div>
|
||||
|
||||
@@ -302,14 +302,39 @@ export type CuratorFinding = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// One decision_lessons channel split by the INV-LRN1 review gate.
|
||||
export type LessonChannelStats = {
|
||||
total: number;
|
||||
approved: number;
|
||||
proposed: number;
|
||||
rejected: number;
|
||||
};
|
||||
|
||||
// The three honest learning channels that feed the writer (INV-IA2/IA5).
|
||||
// Earlier this was a single flat count of source='curator' — which nothing
|
||||
// wrote, so it always read 0 while the panel/methodology channels carried all
|
||||
// the real learning. See get_curator_stats (web/app.py).
|
||||
export type CuratorStats = {
|
||||
total_findings: number;
|
||||
decisions_with_findings: number;
|
||||
decisions_total: number;
|
||||
// LRN-5 (INV-IA5): the count of curator lessons with review_status='approved'
|
||||
// (the real INV-LRN1 writer gate) — replaces the old findings_applied, which
|
||||
// counted the informative-only applied_to_skill flag.
|
||||
findings_approved: number;
|
||||
channels: {
|
||||
// A — Claude draft↔final distillation → methodology overrides; flows now.
|
||||
distillation: {
|
||||
discussion_rules: number;
|
||||
transition_phrases: number;
|
||||
items_total: number;
|
||||
pairs_folded: number;
|
||||
pairs_total: number;
|
||||
};
|
||||
// B — DeepSeek+Gemini panel → decision_lessons; flows when chair-approved.
|
||||
panel: LessonChannelStats & {
|
||||
decisions: number;
|
||||
by_practice_area: Record<string, number>;
|
||||
};
|
||||
// C — curator agent's qualitative findings → decision_lessons (source='curator').
|
||||
curator: LessonChannelStats & {
|
||||
decisions_reviewed: number;
|
||||
};
|
||||
};
|
||||
recent_findings: CuratorFinding[];
|
||||
};
|
||||
|
||||
|
||||
104
web/app.py
104
web/app.py
@@ -1318,31 +1318,77 @@ async def get_style_analyzer_prompt():
|
||||
|
||||
@app.get("/api/training/curator/stats")
|
||||
async def get_curator_stats():
|
||||
"""Cheap aggregate stats over decision_lessons + style_corpus.
|
||||
"""Aggregate over the THREE learning channels that feed the writer, for the
|
||||
/training "אוצֵר" tab. Each channel is surfaced honestly with its real
|
||||
backing store and how much of it actually reaches the writer:
|
||||
|
||||
Used by the Curator-Portrait tab to show "10 curator findings across 24
|
||||
decisions". We deliberately keep this server-side and aggregate so the
|
||||
UI can render a single card without fanning out N queries.
|
||||
A. distillation — Claude draft↔final diff → methodology overrides
|
||||
(appeal_type_rules['_global']). Folded items flow to the writer now.
|
||||
B. panel — DeepSeek+Gemini 2/2 vote → decision_lessons
|
||||
(source='panel:deepseek+gemini'). Flow to the writer only once the
|
||||
chair approves (review_status='approved', INV-LRN1/G10).
|
||||
C. curator — the agent's Opus-read qualitative findings → decision_lessons
|
||||
(source='curator'). Same chair gate as the panel.
|
||||
|
||||
Earlier this endpoint counted ONLY source='curator' and reported it as the
|
||||
whole story, so it read 0 while the panel/methodology channels carried all
|
||||
the real learning. The card was measuring a column nothing wrote (the agent
|
||||
posted findings as Paperclip comments, never as decision_lessons — a
|
||||
spec↔impl drift since closed). It now reports all three (INV-IA2/IA5).
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
total_lessons = await conn.fetchval(
|
||||
"SELECT count(*) FROM decision_lessons WHERE source = 'curator'"
|
||||
total_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus") or 0
|
||||
|
||||
# ── Channel A — distillation → methodology overrides ──────────────
|
||||
meth_rows = await conn.fetch(
|
||||
"SELECT rule_category, coalesce(jsonb_array_length(rule_value), 0) AS n "
|
||||
"FROM appeal_type_rules "
|
||||
"WHERE appeal_type = '_global' "
|
||||
" AND rule_category IN ('discussion_rules', 'transition_phrases')"
|
||||
)
|
||||
decisions_with_findings = await conn.fetchval(
|
||||
meth = {r["rule_category"]: r["n"] for r in meth_rows}
|
||||
discussion_rules = meth.get("discussion_rules", 0)
|
||||
transition_phrases = meth.get("transition_phrases", 0)
|
||||
pairs_folded = await conn.fetchval(
|
||||
"SELECT count(*) FROM draft_final_pairs WHERE status = 'lessons_folded'"
|
||||
) or 0
|
||||
pairs_total = await conn.fetchval("SELECT count(*) FROM draft_final_pairs") or 0
|
||||
|
||||
# ── Channels B & C — decision_lessons by source × review_status ───
|
||||
ls_rows = await conn.fetch(
|
||||
"SELECT source, review_status, count(*) AS n FROM decision_lessons "
|
||||
"GROUP BY source, review_status"
|
||||
)
|
||||
|
||||
def _channel(src_predicate) -> dict:
|
||||
buckets = {"approved": 0, "proposed": 0, "rejected": 0}
|
||||
for r in ls_rows:
|
||||
if src_predicate(r["source"] or ""):
|
||||
st = r["review_status"] or "proposed"
|
||||
buckets[st] = buckets.get(st, 0) + r["n"]
|
||||
buckets["total"] = sum(buckets.values())
|
||||
return buckets
|
||||
|
||||
panel = _channel(lambda s: s.startswith("panel:"))
|
||||
curator = _channel(lambda s: s == "curator")
|
||||
|
||||
panel_decisions = await conn.fetchval(
|
||||
"SELECT count(DISTINCT style_corpus_id) FROM decision_lessons "
|
||||
"WHERE source LIKE 'panel:%'"
|
||||
) or 0
|
||||
curator_decisions = await conn.fetchval(
|
||||
"SELECT count(DISTINCT style_corpus_id) FROM decision_lessons "
|
||||
"WHERE source = 'curator'"
|
||||
) or 0
|
||||
pa_rows = await conn.fetch(
|
||||
"SELECT sc.practice_area, count(*) AS n "
|
||||
"FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
|
||||
"WHERE dl.source LIKE 'panel:%' GROUP BY sc.practice_area"
|
||||
)
|
||||
total_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus")
|
||||
# LRN-5 (INV-IA5): count the *real* consumer-mapped gate — review_status
|
||||
# 'approved' is what flows to the writer (INV-LRN1, #126). The old count
|
||||
# of applied_to_skill was a KPI over an informative-only flag (LRN-1)
|
||||
# that writes nowhere, so it reported adoption that never happened.
|
||||
approved = await conn.fetchval(
|
||||
"SELECT count(*) FROM decision_lessons "
|
||||
"WHERE source = 'curator' AND review_status = 'approved'"
|
||||
)
|
||||
# Last 10 curator findings — newest first
|
||||
panel_by_practice = {(r["practice_area"] or "—"): r["n"] for r in pa_rows}
|
||||
|
||||
# Recent curator findings (channel C) — newest first
|
||||
recent_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT dl.id, dl.lesson_text, dl.category, dl.review_status,
|
||||
@@ -1355,11 +1401,27 @@ async def get_curator_stats():
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
|
||||
return {
|
||||
"total_findings": total_lessons or 0,
|
||||
"decisions_with_findings": decisions_with_findings or 0,
|
||||
"decisions_total": total_corpus or 0,
|
||||
"findings_approved": approved or 0,
|
||||
"decisions_total": total_corpus,
|
||||
"channels": {
|
||||
"distillation": {
|
||||
"discussion_rules": discussion_rules,
|
||||
"transition_phrases": transition_phrases,
|
||||
"items_total": discussion_rules + transition_phrases,
|
||||
"pairs_folded": pairs_folded,
|
||||
"pairs_total": pairs_total,
|
||||
},
|
||||
"panel": {
|
||||
**panel,
|
||||
"decisions": panel_decisions,
|
||||
"by_practice_area": panel_by_practice,
|
||||
},
|
||||
"curator": {
|
||||
**curator,
|
||||
"decisions_reviewed": curator_decisions,
|
||||
},
|
||||
},
|
||||
"recent_findings": [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
|
||||
Reference in New Issue
Block a user