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

@@ -1421,10 +1421,12 @@ class LessonPatch(BaseModel):
lesson_text: str | None = None
category: str | None = None
applied_to_skill: bool | None = None
review_status: str | None = None # proposed | approved | rejected (INV-LRN1 gate)
_LESSON_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"}
_LESSON_SOURCES = {"manual", "curator", "chair", "style_analyzer"}
_LESSON_REVIEW_STATUSES = {"proposed", "approved", "rejected"}
def _lesson_to_json(row: dict) -> dict:
@@ -1435,6 +1437,8 @@ def _lesson_to_json(row: dict) -> dict:
"category": row["category"],
"source": row["source"],
"applied_to_skill": bool(row["applied_to_skill"]),
# review gate (INV-LRN1/G10): only 'approved' flows to the writer.
"review_status": row.get("review_status", "proposed"),
"created_by": row.get("created_by", ""),
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else "",
@@ -1464,8 +1468,11 @@ async def add_corpus_lesson(corpus_id: str, body: LessonCreate):
raise HTTPException(400, f"invalid category; allowed: {sorted(_LESSON_CATEGORIES)}")
if body.source not in _LESSON_SOURCES:
raise HTTPException(400, f"invalid source; allowed: {sorted(_LESSON_SOURCES)}")
# A lesson the chair authors directly here is approved by construction — it skips
# the proposal gate (which exists to vet machine-generated panel lessons).
row = await db.add_decision_lesson(
cid, lesson_text=text, category=body.category, source=body.source,
review_status="approved",
)
if not row:
raise HTTPException(500, "failed to insert lesson")
@@ -1480,11 +1487,14 @@ async def patch_corpus_lesson(lesson_id: str, body: LessonPatch):
raise HTTPException(400, "invalid lesson_id")
if body.category is not None and body.category not in _LESSON_CATEGORIES:
raise HTTPException(400, f"invalid category; allowed: {sorted(_LESSON_CATEGORIES)}")
if body.review_status is not None and body.review_status not in _LESSON_REVIEW_STATUSES:
raise HTTPException(400, f"invalid review_status; allowed: {sorted(_LESSON_REVIEW_STATUSES)}")
result = await db.update_decision_lesson(
lid,
lesson_text=body.lesson_text,
category=body.category,
applied_to_skill=body.applied_to_skill,
review_status=body.review_status,
)
if not result.get("updated"):
if result.get("reason") == "not found":