Merge pull request 'fix(learning): כרטיס-אוצֵר כן-3-ערוצים + לכידת ממצאי-אוצֵר (source='curator')' (#339) from worktree-curator-learning-surface into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s

This commit was merged in pull request #339.
This commit is contained in:
2026-06-28 21:28:53 +00:00
9 changed files with 392 additions and 58 deletions

View File

@@ -15,7 +15,7 @@ hermes-curator.md — מקור-האמת היחיד לפרומפט של סוכן
adapter: deepseek_local · model: deepseek-v4-pro adapter: deepseek_local · model: deepseek-v4-pro
profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx) profiles: CMP=curator-cmp (רישוי 1xxx) · CMPA=curator-cmpa (היטל 8xxx + פיצויים 9xxx)
role: Knowledge Curator — מנתח החלטות סופיות אחרי export, מציע עדכוני skills/lessons. 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}} placeholders זמינים: {{agentId}} {{agentName}} {{companyId}} {{companyName}} {{runId}}
{{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}} {{taskId}} {{taskTitle}} {{taskBody}} {{commentId}} {{wakeReason}} {{projectName}} {{paperclipApiUrl}}
@@ -129,6 +129,24 @@ curl -sS -X POST \
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` שדה `expected_outcome`**, **לא מקריאת הטקסט**. אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה. - אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`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 (חובה — רוב המקרים יש) ## 6. בחר interaction (חובה — רוב המקרים יש)
לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7. לפי הקונטקסט בחר **אחד** מ-3 הסוגים. אם **אין שום החלטה אנושית נדרשת** — דלג ישירות ל-§A.7.
@@ -261,7 +279,7 @@ curl -sS -X PATCH \
# כללים כלליים # כללים כלליים
- **idempotencyKey**: חובה ב-interaction. אם נעור פעמיים על אותו תיק — Paperclip לא יוצר כפילות. - **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 יוצר אם דפנה אישרה). - **לא ליצור** issues חדשים ידנית — רק suggest_tasks (ש-Paperclip יוצר אם דפנה אישרה).
- **לא להעיר** סוכנים אחרים. - **לא להעיר** סוכנים אחרים.
- **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף. - **בעיה?** אם MCP נכשל או מסמך חסר — comment קצר עם הסיבה + סגור (status=done). אל תזייף.

View File

@@ -96,10 +96,24 @@
(`hermes-curator.md:60-70`). (`hermes-curator.md:60-70`).
- מזהה **35 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` / - מזהה **35 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
`[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`). `[לקסיקון משפטי]` / `[טבלאי]` (`hermes-curator.md:99-108`).
- **מציע** — comment ב-Paperclip + רישום כל ממצא כ-`decision_lesson` דרך - **מציע** — comment + interaction בערוץ-הפלטפורמה, וגם **רושם כל ממצא מבנית** כ-`decision_lesson`
`POST /api/training/corpus/{corpus_id}/lessons` (`source:"curator"`) שמופיע ב-UI (`source='curator'`, `review_status='proposed'`) דרך כלי-ה-MCP `record_curator_findings`
תחת הטאב "מה למדנו" (`hermes-curator.md:73-96`). (`hermes-curator.md` §A.5b → `tools/workflow.py::record_curator_findings``db.add_decision_lesson`).
- **אינו מעדכן** קבצים בעצמו (skills/, lessons.py, DB) — רק מציע (`hermes-curator.md:125-130`). הממצאים מופיעים ב-`/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 → ניתוח שבועי → לקחים) ### 1.2 לולאת-פידבק-היו"ר (capture → ניתוח שבועי → לקחים)

View File

@@ -1176,6 +1176,15 @@ async def list_chair_feedback(
return await workflow.list_chair_feedback(case_number, category, unresolved_only, _clamp_limit(limit)) 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() @mcp.tool()
async def halacha_corroboration(halacha_id: str) -> dict: async def halacha_corroboration(halacha_id: str) -> dict:
"""החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only).""" """החזר את ה-corroboration של הלכה: הציטוטים שמתקפים אותה, הטיפול, וסיכום (X11, read-only)."""

View File

@@ -2675,6 +2675,22 @@ async def add_decision_lesson(
return dict(row) if row else {} 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( async def update_decision_lesson(
lesson_id: UUID, lesson_id: UUID,
*, *,
@@ -2950,6 +2966,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'). 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 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. 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() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -2964,6 +2986,20 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "")
LIMIT $1""", LIMIT $1""",
limit, practice_area, 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] return [dict(r) for r in rows]

View File

@@ -403,6 +403,78 @@ async def record_chair_feedback(
}, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.") }, 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( async def list_chair_feedback(
case_number: str = "", case_number: str = "",
category: str = "", category: str = "",

View File

@@ -45,6 +45,15 @@ export function CuratorPortraitPanel() {
<StatsCard /> <StatsCard />
<RecentFindings /> <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"> <Tabs defaultValue="curator-prompt" dir="rtl">
<TabsList className="bg-rule-soft/60"> <TabsList className="bg-rule-soft/60">
<TabsTrigger value="curator-prompt">פרומפט ה-Curator</TabsTrigger> <TabsTrigger value="curator-prompt">פרומפט ה-Curator</TabsTrigger>
@@ -67,38 +76,127 @@ export function CuratorPortraitPanel() {
// ── stats card ───────────────────────────────────────────────────── // ── 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() { function StatsCard() {
const { data, isPending } = useCuratorStats(); const { data, isPending } = useCuratorStats();
if (isPending) { if (isPending) {
return ( return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="space-y-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-20 w-full" />)} <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> </div>
); );
} }
if (!data) return null; if (!data) return null;
const { distillation: a, panel: b, curator: c } = data.channels;
return ( return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="space-y-5">
<Kpi label="ממצאי curator" value={data.total_findings} icon={<Sparkles className="w-4 h-4" />} /> <div className="bg-gold-wash border border-gold rounded-lg px-5 py-4 flex items-start gap-3">
<Kpi label="החלטות שנסקרו" value={`${data.decisions_with_findings}/${data.decisions_total}`} icon={<FileText className="w-4 h-4" />} /> <span className="text-gold-deep text-xl leading-tight"></span>
<Kpi label="ממצאים מאושרים (זורמים לכותב)" value={data.findings_approved} icon={<CheckCircle2 className="w-4 h-4" />} /> <p className="text-ink-soft text-sm leading-relaxed">
<Kpi label="ממוצע ממצאים להחלטה" הלמידה זורמת לכותב ב<b className="text-gold-deep font-semibold">שלושה ערוצים נפרדים</b>, כל אחד
value={ מופעל כשדפנה מסמנת החלטה כסופית. הכרטיס מראה כמה כל ערוץ תרם ומה כבר עובר בפועל לכותב
data.decisions_with_findings > 0 רק ידע ש<b className="text-gold-deep font-semibold">אושר בשער-היו״ר</b> משפיע על הטיוטות (INV-LRN1).
? (data.total_findings / data.decisions_with_findings).toFixed(1) </p>
: "—" </div>
}
icon={<Brain className="w-4 h-4" />} <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="35 דפוסים מתויגים להחלטה (סגנון/מבנה/לקסיקון/טבלאי) — נתפסים מבנית, לא רק כהערה."
/>
</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> </div>
); );
} }
function Kpi({ function Kpi({
label, value, icon, label, value, icon, tone,
}: { label: string; value: string | number; icon: React.ReactNode }) { }: { 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 ( return (
<Card className="bg-surface border-rule"> <Card className="bg-surface border-rule">
<CardContent className="px-4 py-3"> <CardContent className="px-4 py-3">
@@ -106,7 +204,7 @@ function Kpi({
{icon} {icon}
<span>{label}</span> <span>{label}</span>
</div> </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> </CardContent>
</Card> </Card>
); );
@@ -123,10 +221,10 @@ function RecentFindings() {
if (!data || data.recent_findings.length === 0) { if (!data || data.recent_findings.length === 0) {
return ( return (
<Card className="bg-rule-soft/40 border-rule"> <Card className="bg-rule-soft/40 border-rule">
<CardContent className="px-6 py-5 text-center text-ink-muted text-sm"> <CardContent className="px-6 py-5 text-center text-ink-muted text-sm leading-relaxed">
אין עדיין ממצאים של ה-Curator. הוא מופעל אוטומטית כאשר דפנה מסמנת אין עדיין ממצאי-אוצֵר. כשדפנה מסמנת החלטה כסופית, האוצֵר קורא את הסופי, מזהה
החלטה כסופית (mark-final), ושומר את ממצאיו כ-decision_lessons עם 35 דפוסי-סגנון, ושומר אותם כ-<code>decision_lessons</code> (source=&quot;curator&quot;)
source=&quot;curator&quot;. הממתינים לאישורה בנפרד מלקחי-הפאנל ומכללי-המתודולוגיה שלמעלה.
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -197,7 +197,7 @@ export function StyleReportPanel() {
<h3 className="text-navy text-lg m-0">אוצֵר ממצאי-סגנון</h3> <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"> <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"> <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> </span>
<div className="min-w-0"> <div className="min-w-0">
<b className="text-navy text-sm font-semibold block"> <b className="text-navy text-sm font-semibold block">
@@ -214,8 +214,8 @@ export function StyleReportPanel() {
{curator.data {curator.data
? Math.max( ? Math.max(
0, 0,
curator.data.total_findings - curator.data.channels.curator.total -
curator.data.findings_approved, curator.data.channels.curator.approved,
) )
: "—"} : "—"}
</div> </div>
@@ -223,7 +223,7 @@ export function StyleReportPanel() {
</div> </div>
<div className="flex-1 rounded-lg border border-rule bg-rule-soft px-3.5 py-3"> <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"> <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>
<div className="text-[0.78rem] text-ink-soft mt-1">סך ממצאים</div> <div className="text-[0.78rem] text-ink-soft mt-1">סך ממצאים</div>
</div> </div>

View File

@@ -302,14 +302,39 @@ export type CuratorFinding = {
created_at: string; 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 = { export type CuratorStats = {
total_findings: number;
decisions_with_findings: number;
decisions_total: number; decisions_total: number;
// LRN-5 (INV-IA5): the count of curator lessons with review_status='approved' channels: {
// (the real INV-LRN1 writer gate) — replaces the old findings_applied, which // A — Claude draft↔final distillation → methodology overrides; flows now.
// counted the informative-only applied_to_skill flag. distillation: {
findings_approved: number; 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[]; recent_findings: CuratorFinding[];
}; };

View File

@@ -1318,31 +1318,77 @@ async def get_style_analyzer_prompt():
@app.get("/api/training/curator/stats") @app.get("/api/training/curator/stats")
async def get_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 A. distillation — Claude draft↔final diff → methodology overrides
decisions". We deliberately keep this server-side and aggregate so the (appeal_type_rules['_global']). Folded items flow to the writer now.
UI can render a single card without fanning out N queries. 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() pool = await db.get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
total_lessons = await conn.fetchval( total_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus") or 0
"SELECT count(*) FROM decision_lessons WHERE source = 'curator'"
# ── 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 " "SELECT count(DISTINCT style_corpus_id) FROM decision_lessons "
"WHERE source = 'curator'" "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") panel_by_practice = {(r["practice_area"] or ""): r["n"] for r in pa_rows}
# 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 # Recent curator findings (channel C) — newest first
# 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
recent_rows = await conn.fetch( recent_rows = await conn.fetch(
""" """
SELECT dl.id, dl.lesson_text, dl.category, dl.review_status, SELECT dl.id, dl.lesson_text, dl.category, dl.review_status,
@@ -1355,11 +1401,27 @@ async def get_curator_stats():
LIMIT 10 LIMIT 10
""" """
) )
return { return {
"total_findings": total_lessons or 0, "decisions_total": total_corpus,
"decisions_with_findings": decisions_with_findings or 0, "channels": {
"decisions_total": total_corpus or 0, "distillation": {
"findings_approved": approved or 0, "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": [ "recent_findings": [
{ {
"id": str(r["id"]), "id": str(r["id"]),