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

@@ -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)."""

View File

@@ -2675,6 +2675,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,
*,
@@ -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').
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:
@@ -2964,6 +2986,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]

View File

@@ -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 = "",