feat(learning): lesson synthesis — merge overlapping lessons into richer super-lessons (#158 / INV-LRN8)
הפתרון-האמיתי לחיתוך-השקט limit=15 (#157): במקום ערימת לקחים גולמיים חופפים, ממזגים לקחי-סגנון דומים ל"לקח-על" אחד עשיר ומוכלל — הסט שזורם לכותב קטֵן ומשתבח (Authorial Style Profiling). מחקה את סינתזת-הקנוני (V41/INV-LRN6) על אותה טבלה (G2, אין מאגר-מקביל). מנגנון (services/lesson_synthesis.py, מסלול-יחיד): - אשכול greedy לפי cosine (LESSON_SYNTH_CLUSTER_THRESHOLD) בתוך shard practice_area+category. - מיזוג ע"י claude_session מעוגן-מקור (INV-AH, סגנון-בלבד INV-LRN5, abstain) + שער-drift (cosine מול centroid ≥ LESSON_SYNTH_DRIFT_FLOOR). - לקח-על נכתב source='synthesis' + synthesized_from; המקורות→review_status='superseded'. - שער מדורג-הפיך (הכרעת-יו"ר): מקורות approved → לקח-על approved (זורם), veto-יו"ר משחזר את המקורות (db.revert_lesson_synthesis, מחובר ל-PATCH lessons). - idempotency: lookup-cosine מול synthesis קיים לפני INSERT. נגזרות: SCHEMA_V46 (embedding vector(1024) + synthesized_from + ivfflat); כלי-MCP lesson_synthesize_pending; scripts/backfill_lesson_synthesis.py (--dry-run/--apply, audit CSV); config LESSON_SYNTH_*; spec INV-LRN8; SCRIPTS.md. get_recent_decision_lessons ללא שינוי — superseded יוצא (מסנן approved), synthesis נכנס. UI badges (synthesis/superseded) נדחים לשער-העיצוב (מוגנים ב-fallback, ללא קריסה). בדיקות: py_compile ✓ · leak-guard G12 ✓ · smoke-test טהור לאשכול/cosine/centroid ✓. אימות functional מלא (dry-run מול DB+voyage+claude CLI) — בהוסט אחרי-deploy, כמו V41. Invariants: G2 (מסלול-יחיד, אותה טבלה), INV-AH (עיגון+drift), INV-LRN1/G10 (שער מדורג-הפיך), INV-LRN5 (סגנון-בלבד), INV-LRN8 (חדש). depends-on #157/#159. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1770,6 +1770,18 @@ CREATE TABLE IF NOT EXISTS style_distance_history (
|
||||
CREATE INDEX IF NOT EXISTS idx_style_distance_history_measured ON style_distance_history(measured_at);
|
||||
"""
|
||||
|
||||
SCHEMA_V46_SQL = """
|
||||
-- decision_lessons synthesis (#158 / INV-LRN8): consolidate overlapping lessons into
|
||||
-- one richer "super-lesson" (source='synthesis', synthesized_from=[merged ids]); the
|
||||
-- merged sources flip review_status='superseded' (kept as provenance, no longer fed to
|
||||
-- the writer). embedding powers cosine clustering + the drift guard. Mirrors the
|
||||
-- canonical-halacha synthesis (V41) ON THE SAME TABLE — no parallel store (G2).
|
||||
ALTER TABLE decision_lessons ADD COLUMN IF NOT EXISTS embedding vector(1024);
|
||||
ALTER TABLE decision_lessons ADD COLUMN IF NOT EXISTS synthesized_from UUID[] NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
|
||||
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
|
||||
"""
|
||||
|
||||
|
||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||
@@ -1838,6 +1850,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(SCHEMA_V43_SQL)
|
||||
await conn.execute(SCHEMA_V44_SQL)
|
||||
await conn.execute(SCHEMA_V45_SQL)
|
||||
await conn.execute(SCHEMA_V46_SQL)
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -2691,6 +2704,139 @@ async def get_style_corpus_id_by_decision(decision_number: str) -> UUID | None:
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
# ── decision_lessons synthesis (#158 / INV-LRN8) ───────────────────
|
||||
# Consolidate overlapping lessons into one richer 'synthesis' row; sources are
|
||||
# marked 'superseded' (provenance, not writer-fed). Mirrors V41 on the same table.
|
||||
|
||||
async def fetch_synthesis_candidates(practice_area: str, category: str) -> list[dict]:
|
||||
"""Lessons eligible for synthesis in one (practice_area, category) shard.
|
||||
|
||||
Only ``review_status='approved'`` lessons that are themselves NOT a synthesis
|
||||
output and NOT already superseded — i.e. live, writer-fed lessons. Returns id,
|
||||
lesson_text, review_status, source, style_corpus_id and the stored embedding
|
||||
(python list or None; the caller lazily backfills NULLs via set_lesson_embedding).
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT dl.id, dl.lesson_text, dl.review_status, dl.source, "
|
||||
" dl.style_corpus_id, dl.embedding "
|
||||
"FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
|
||||
"WHERE dl.review_status = 'approved' "
|
||||
" AND dl.source <> 'synthesis' "
|
||||
" AND dl.category = $1 "
|
||||
" AND ($2 = '' OR sc.practice_area = $2) "
|
||||
"ORDER BY dl.created_at",
|
||||
category, practice_area,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def synthesis_shards(min_size: int = 2) -> list[dict]:
|
||||
"""(practice_area, category) shards that have ≥min_size live approved lessons —
|
||||
the candidate shards a synthesis pass should consider. Largest first."""
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT sc.practice_area AS practice_area, dl.category AS category, count(*) AS n "
|
||||
"FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
|
||||
"WHERE dl.review_status = 'approved' AND dl.source <> 'synthesis' "
|
||||
"GROUP BY 1, 2 HAVING count(*) >= $1 ORDER BY count(*) DESC",
|
||||
min_size,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def set_lesson_embedding(lesson_id: UUID, embedding: list[float]) -> None:
|
||||
"""Lazy backfill: store a lesson's embedding so clustering/drift don't re-embed."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE decision_lessons SET embedding = $2 WHERE id = $1",
|
||||
lesson_id, embedding,
|
||||
)
|
||||
|
||||
|
||||
async def nearest_synthesis_lesson(
|
||||
vec: list[float], category: str, threshold: float,
|
||||
) -> "tuple[str, float] | None":
|
||||
"""Nearest existing synthesis lesson (same category) by cosine, for idempotency —
|
||||
so a re-run doesn't create a near-duplicate super-lesson. None if below threshold."""
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT id::text AS id, 1 - (embedding <=> $1) AS sim "
|
||||
"FROM decision_lessons "
|
||||
"WHERE source = 'synthesis' AND category = $2 AND embedding IS NOT NULL "
|
||||
" AND review_status <> 'rejected' "
|
||||
"ORDER BY embedding <=> $1 LIMIT 1",
|
||||
vec, category,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
sim = float(row["sim"])
|
||||
return (row["id"], sim) if sim >= threshold else None
|
||||
|
||||
|
||||
async def apply_lesson_synthesis(
|
||||
*,
|
||||
corpus_id: UUID,
|
||||
lesson_text: str,
|
||||
category: str,
|
||||
embedding: list[float],
|
||||
source_ids: list[UUID],
|
||||
review_status: str,
|
||||
) -> dict:
|
||||
"""Atomically commit a synthesis: insert the super-lesson (source='synthesis')
|
||||
and flip its source lessons to 'superseded' (provenance, no longer writer-fed).
|
||||
Returns the new row. INV-LRN8 / G2 — single write path."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO decision_lessons "
|
||||
"(style_corpus_id, lesson_text, category, source, created_by, "
|
||||
" review_status, embedding, synthesized_from) "
|
||||
"VALUES ($1, $2, $3, 'synthesis', 'synthesis', $4, $5, $6) "
|
||||
"RETURNING id, style_corpus_id, lesson_text, category, source, "
|
||||
" review_status, created_by, created_at, updated_at",
|
||||
corpus_id, lesson_text, category, review_status, embedding, source_ids,
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE decision_lessons SET review_status = 'superseded', updated_at = now() "
|
||||
"WHERE id = ANY($1::uuid[])",
|
||||
source_ids,
|
||||
)
|
||||
return dict(row) if row else {}
|
||||
|
||||
|
||||
async def revert_lesson_synthesis(synthesis_id: UUID) -> dict:
|
||||
"""Chair veto of a super-lesson: mark it 'rejected' and restore its source lessons
|
||||
to 'approved' (so they flow to the writer again). Idempotent; returns counts."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
"SELECT synthesized_from, source FROM decision_lessons WHERE id = $1",
|
||||
synthesis_id,
|
||||
)
|
||||
if not row or row["source"] != "synthesis":
|
||||
return {"reverted": False, "reason": "not a synthesis lesson"}
|
||||
source_ids = list(row["synthesized_from"] or [])
|
||||
await conn.execute(
|
||||
"UPDATE decision_lessons SET review_status = 'rejected', updated_at = now() "
|
||||
"WHERE id = $1",
|
||||
synthesis_id,
|
||||
)
|
||||
restored = 0
|
||||
if source_ids:
|
||||
res = await conn.execute(
|
||||
"UPDATE decision_lessons SET review_status = 'approved', updated_at = now() "
|
||||
"WHERE id = ANY($1::uuid[]) AND review_status = 'superseded'",
|
||||
source_ids,
|
||||
)
|
||||
restored = int(res.split()[-1]) if res.split()[-1].isdigit() else 0
|
||||
return {"reverted": True, "restored_sources": restored}
|
||||
|
||||
|
||||
async def update_decision_lesson(
|
||||
lesson_id: UUID,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user