feat(learning): שער-אישור ל-decision_lessons — רק לקח מאושר זורם לכותב (INV-LRN1, #126)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 12s
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user