feat(ia): IA גל-2 — איחוד-משטחים: ערוץ-למידה אחד · /operations⊇/diagnostics · MET-2/3 (#131, X17)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 9s

גל-2 מבקלוג #127 — איחוד-משטחים לפי משטח-היעד של X17. מקיים INV-IA1/IA3/IA4 +
דלתות-הספ (X6 INV-UI7/8, 07-learning §0.4, 00-constitution G2). שומר G10/INV-LRN1
(לא הוסר שום שער-אנושי — רק שער/דגל כפול).

א) תיבת-אישור אחת (INV-IA1): כרטיסי "אישור הלכות"+"פסיקה חסרה" ב-/operations
   מצביעים ל-/approvals (לתיבת-האישורים ←) — /operations מנטר, /approvals מחליט.

ב) ערוץ-למידה אחד (INV-IA3): הוסר applied_to_skill end-to-end —
   - UI: כפתור "סמן כ'אומץ'" + badge "אומץ" ב-lessons-tab; badge ב-curator-portrait.
   - API: LessonPatch, _lesson_to_json, patch call, curator recent_findings (→review_status).
   - db.py: list/add/update_decision_lesson לא בוחרים/כותבים applied_to_skill;
     הפרמטר הוסר. העמודה+אינדקס נשמרים (back-compat, ללא migration), מסומנים DEPRECATED.
   - types: DecisionLesson/LessonPatch/CuratorFinding.
   review_status='approved' = הסטטוס היחיד "זורם-לכותב" (INV-LRN1, #126).

ג) MET-2/3 lost-update (INV-IA3): _append_methodology_override רץ עכשיו בטרנזקציה
   אחת עם SELECT ... FOR UPDATE — אין read-modify-write מתפצל מול עורך-המתודולוגיה
   או promote מקביל. /methodology = העורך-הקנוני; promote מבטל את ה-cache (גל-1 MET-1).

ד) /operations⊇/diagnostics (INV-IA4): גוף /diagnostics חולץ ל-<SystemHealthSection/>
   ומורנדר ב-/operations תחת "בריאות-מערכת". /diagnostics → redirect ל-/operations.
   /diagnostics הוסר מהניווט. משטח-ניטור יחיד.

ה) דלתות-ספ (≥3 מקורות ב-X17, אושר ע"י חיים /goal):
   - X6: INV-UI7 (aggregate=SSoT, mutation מבטל queryKey) + INV-UI8 (render-or-remove, חלקיות).
   - 07-learning §0.4: שער-אחד + טרנזקציה-אחת + applied_to_skill מוסר.
   - 00-constitution G2: תאום-המתודולוגיה כהפרה-ידועה-ממותנת.
   - X17 דלתות-ספ סומנו  קודדו.

בדיקות: py_compile app.py + db.py ✓ · tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109
קיים-מראש). next build נכשל ב-worktree רק בגלל symlink (Turbopack) — Docker/CI תקין.
api:types יתרענן בדפלוי (curator/lessons אינם response-modeled; הטיפוסים יד-כתובים עודכנו).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:04:57 +00:00
parent fc2de64700
commit 6e69c1dc38
13 changed files with 378 additions and 341 deletions

View File

@@ -1338,7 +1338,7 @@ async def get_curator_stats():
# Last 10 curator findings — newest first
recent_rows = await conn.fetch(
"""
SELECT dl.id, dl.lesson_text, dl.category, dl.applied_to_skill,
SELECT dl.id, dl.lesson_text, dl.category, dl.review_status,
dl.created_at,
sc.decision_number, sc.decision_date
FROM decision_lessons dl
@@ -1358,7 +1358,7 @@ async def get_curator_stats():
"id": str(r["id"]),
"lesson_text": r["lesson_text"],
"category": r["category"],
"applied_to_skill": bool(r["applied_to_skill"]),
"review_status": r["review_status"] or "proposed",
"decision_number": r["decision_number"] or "",
"decision_date": str(r["decision_date"]) if r["decision_date"] else "",
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
@@ -1454,7 +1454,6 @@ class LessonCreate(BaseModel):
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)
@@ -1470,8 +1469,8 @@ def _lesson_to_json(row: dict) -> dict:
"lesson_text": row["lesson_text"],
"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.
# (applied_to_skill removed — LRN-1/INV-IA3, was informative-only.)
"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 "",
@@ -1527,7 +1526,6 @@ async def patch_corpus_lesson(lesson_id: str, body: LessonPatch):
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"):
@@ -4657,26 +4655,35 @@ class PromoteLearningRequest(BaseModel):
async def _append_methodology_override(category: str, key: str, items: list[str]) -> None:
"""Read current (override-or-default) list value, append new items, upsert override.
Shared by the T14 approval gate to fold approved learnings into writer-consumed channels."""
Shared by the T14 approval gate to fold approved learnings into writer-consumed channels.
MET-2/3 (INV-IA3): the read-modify-write runs inside ONE transaction with the
existing override row locked FOR UPDATE, so a concurrent promote (or a methodology
PUT) can't interleave between the read and the write and silently drop items.
The methodology PUT overwrites the same row; the MET-1 invalidation (גל-1) makes
the /methodology editor refetch on promote so it edits post-append state."""
pool = await db.get_pool()
row = await pool.fetchrow(
"SELECT rule_value FROM appeal_type_rules "
"WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2",
category, key,
)
if row:
current = _coerce_json(row["rule_value"]) or []
else:
current = list(_METHODOLOGY_DEFAULTS.get(category, {}).get(key, []))
if not isinstance(current, list):
current = []
merged = current + [s for s in items if s and s not in current]
await pool.execute(
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
category, key, json.dumps(merged, ensure_ascii=False),
)
async with pool.acquire() as conn:
async with conn.transaction():
row = await conn.fetchrow(
"SELECT rule_value FROM appeal_type_rules "
"WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2 "
"FOR UPDATE",
category, key,
)
if row:
current = _coerce_json(row["rule_value"]) or []
else:
current = list(_METHODOLOGY_DEFAULTS.get(category, {}).get(key, []))
if not isinstance(current, list):
current = []
merged = current + [s for s in items if s and s not in current]
await conn.execute(
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
category, key, json.dumps(merged, ensure_ascii=False),
)
@app.post("/api/learning/pairs/{pair_id}/promote")