feat(learning): שער-אישור ל-decision_lessons — רק לקח מאושר זורם לכותב (INV-LRN1, #126)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 12s

אודיט #122 חשף שלקחי-הפאנל (decision_lessons) זרמו לכותב אוטומטית
(block_writer → get_recent_decision_lessons) ללא סינון-אישור — הפאנל כתב,
והכותב צרך מיד, בעקיפת שער-היו"ר (INV-LRN1/G10). מנגד, מה שהיו"ר אישר ב-promote
הלך לערוץ נפרד (appeal_type_rules). תוצאה: דליפה — תוכן לא-מאושר השפיע על הכתיבה.

התיקון — שער-אישור מפורש:
- עמודת review_status (proposed|approved|rejected) ל-decision_lessons (SCHEMA_V34).
- get_recent_decision_lessons (צרכן-הכותב) מחזיר רק review_status='approved'.
- הפאנל (style_lesson_panel) כותב 'proposed' (ברירת-מחדל) → לא זורם עד אישור.
- לקח שהיו"ר מקליד ידנית ב-/training = 'approved' מיידית (מדלג על שער-ההצעה).
- UI (lessons-tab, טאב "קורפוס" ב-/training): תג-סטטוס + כפתורי אשר/דחה/בטל-אישור.

הכרעת-יו"ר (2026-06-11): כל הלקחים שקדמו לשער (41) מתאפסים ל-'proposed' —
שום לקח לא זורם עד אישור מפורש (ברירת-המחדל של העמודה מיישמת זאת על הקיימים).

Invariants:
- INV-LRN1 / G10 (מקיים) — עדכון-ידע לערוץ-הכותב דורש אישור-יו"ר מפורש; אין auto-commit.
- INV-LRN5 (נשמר) — substance ממילא מסונן בפאנל; השער הוא על style_method בלבד.
- G1 (מקיים) — סינון-במקור (get_recent) ולא תיקון-בקריאה אצל הכותב.
- G2 (מקיים) — אותו פנקס decision_lessons; אין מסלול מקביל.

api:types: להריץ npm run api:types אחרי deploy (review_status נוסף ל-payload;
הטיפוסים הידניים ב-training.ts כבר מעודכנים, tsc עובר).

ref: #122 · #126 · data/audit/learning-loop-activity-20260611.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 18:13:59 +00:00
parent af5875453d
commit 4b01283e3b
4 changed files with 110 additions and 9 deletions

View File

@@ -1442,6 +1442,19 @@ CREATE TABLE IF NOT EXISTS drain_controls (
);
"""
SCHEMA_V34_SQL = """
-- decision_lessons review gate (INV-LRN1/G10): a lesson must be chair-APPROVED
-- before it flows to the writer. The style panel writes proposals
-- (review_status='proposed'); get_recent_decision_lessons (writer-consumed) returns
-- only 'approved'. The chair approves/rejects in /training. Existing rows take the
-- column default — per chair decision (2026-06-11) all pre-gate lessons reset to
-- 'proposed' (nothing flows until re-approved).
ALTER TABLE decision_lessons
ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'proposed';
CREATE INDEX IF NOT EXISTS idx_decision_lessons_review
ON decision_lessons(review_status);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
@@ -1479,6 +1492,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V31_SQL)
await conn.execute(SCHEMA_V32_SQL)
await conn.execute(SCHEMA_V33_SQL)
await conn.execute(SCHEMA_V34_SQL)
logger.info("Database schema initialized (v1-v33)")
@@ -2257,7 +2271,7 @@ async def list_decision_lessons(corpus_id: UUID) -> list[dict]:
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, style_corpus_id, lesson_text, category, source, "
" applied_to_skill, created_by, created_at, updated_at "
" applied_to_skill, review_status, created_by, created_at, updated_at "
"FROM decision_lessons WHERE style_corpus_id = $1 "
"ORDER BY created_at DESC",
corpus_id,
@@ -2272,16 +2286,20 @@ async def add_decision_lesson(
category: str = "general",
source: str = "manual",
created_by: str = "chaim",
review_status: str = "proposed",
) -> dict:
# review_status gate (INV-LRN1/G10): panel-written lessons default to 'proposed'
# and do NOT flow to the writer until the chair approves. A lesson the chair
# authors directly is passed review_status='approved' by the caller.
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"INSERT INTO decision_lessons "
"(style_corpus_id, lesson_text, category, source, created_by) "
"VALUES ($1, $2, $3, $4, $5) "
"(style_corpus_id, lesson_text, category, source, created_by, review_status) "
"VALUES ($1, $2, $3, $4, $5, $6) "
"RETURNING id, style_corpus_id, lesson_text, category, source, "
" applied_to_skill, created_by, created_at, updated_at",
corpus_id, lesson_text, category, source, created_by,
" applied_to_skill, review_status, created_by, created_at, updated_at",
corpus_id, lesson_text, category, source, created_by, review_status,
)
return dict(row) if row else {}
@@ -2292,6 +2310,7 @@ async def update_decision_lesson(
lesson_text: str | None = None,
category: str | None = None,
applied_to_skill: bool | None = None,
review_status: str | None = None,
) -> dict:
sets: dict = {}
if lesson_text is not None:
@@ -2300,6 +2319,8 @@ async def update_decision_lesson(
sets["category"] = category
if applied_to_skill is not None:
sets["applied_to_skill"] = applied_to_skill
if review_status is not None:
sets["review_status"] = review_status
if not sets:
return {"updated": False, "reason": "nothing to update"}
sets["updated_at"] = "now()" # sentinel — replaced inline below
@@ -2312,7 +2333,7 @@ async def update_decision_lesson(
row = await conn.fetchrow(
f"UPDATE decision_lessons SET {set_clause} WHERE id = $1 "
f"RETURNING id, style_corpus_id, lesson_text, category, source, "
f" applied_to_skill, updated_at",
f" applied_to_skill, review_status, updated_at",
lesson_id, *values,
)
if not row:
@@ -2494,7 +2515,12 @@ async def get_methodology_overrides(category: str) -> dict:
async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") -> list[dict]:
"""Per-decision learnings the chair/curator attached in /training (decision_lessons),
so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first."""
so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first.
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.
"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
@@ -2502,7 +2528,8 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "")
sc.decision_number, sc.practice_area
FROM decision_lessons dl
JOIN style_corpus sc ON sc.id = dl.style_corpus_id
WHERE ($2 = '' OR sc.practice_area = $2)
WHERE dl.review_status = 'approved'
AND ($2 = '' OR sc.practice_area = $2)
ORDER BY dl.created_at DESC
LIMIT $1""",
limit, practice_area,