Merge pull request 'fix(learning): כרטיס-אוצֵר כן-3-ערוצים + לכידת ממצאי-אוצֵר (source='curator')' (#339) from worktree-curator-learning-surface into main
This commit was merged in pull request #339.
This commit is contained in:
@@ -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). אל תזייף.
|
||||||
|
|||||||
@@ -96,10 +96,24 @@
|
|||||||
(`hermes-curator.md:60-70`).
|
(`hermes-curator.md:60-70`).
|
||||||
- מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
|
- מזהה **3–5 דפוסים/פערים** חדשים, כל ממצא מתויג `[סגנון]` / `[מבנה]` /
|
||||||
`[לקסיקון משפטי]` / `[טבלאי]` (`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 → ניתוח שבועי → לקחים)
|
||||||
|
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "",
|
||||||
|
|||||||
@@ -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="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>
|
</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 עם
|
3–5 דפוסי-סגנון, ושומר אותם כ-<code>decision_lessons</code> (source="curator")
|
||||||
source="curator".
|
הממתינים לאישורה — בנפרד מלקחי-הפאנל ומכללי-המתודולוגיה שלמעלה.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
104
web/app.py
104
web/app.py
@@ -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"]),
|
||||||
|
|||||||
Reference in New Issue
Block a user