From 8b23542dec09267b89df2a2fe760f3a5084fe37c Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 28 Jun 2026 22:06:58 +0000 Subject: [PATCH] feat(learning): chair-feedback style corrections auto-flow to the writer (P1 #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chair-feedback chain was DEAD: 27 feedback rows captured, 0 ever became a lesson the writer reads (no weekly-analysis job ever existed). The chair's own corrections — the most authoritative signal of all, and exactly "learn from every returned draft" — went nowhere. decision_lessons can't carry them: it's FK-coupled to a style_corpus row (a signed final), which a case-in-progress doesn't have. So chair STYLE feedback instead rides the discussion_rules channel that already reaches the writer for all blocks — the same path /training promote uses. - db.append_global_rule: the locked read-modify-write append, extracted from web `_append_methodology_override` into one shared impl (G2). The web function is now a thin wrapper that seeds defaults; chair-feedback calls it directly. - record_chair_feedback (MCP tool): a STYLE-category correction (style/wrong_tone/ wrong_structure) with a lesson_extracted flows immediately to discussion_rules. The chair IS the gate — no separate approval (INV-LRN1 graduated gate). SUBSTANCE feedback (missing_content/factual_error/other) is case-specific → recorded only. Invariants: INV-LRN1 (chair-authored style = highest authority, flows; substance not auto-flowed), G2 (single append impl shared by promote + feedback), INV-LRN5 (style channel only). Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/src/legal_mcp/services/db.py | 42 ++++++++++++++++++++++ mcp-server/src/legal_mcp/tools/workflow.py | 24 ++++++++++++- web/app.py | 37 ++++--------------- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index aa9afc5..0aae633 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -2875,6 +2875,48 @@ async def get_style_patterns(pattern_type: str | None = None) -> list[dict]: return [dict(r) for r in rows] +async def append_global_rule( + category: str, key: str, items: list[str], seed_if_missing: list | None = None, +) -> int: + """Append items to a _global appeal_type_rules list value (the writer-consumed + methodology channel), idempotently and under a row lock. Returns how many NEW + items were added (existing duplicates are skipped). Single locked read-modify- + write (MET-2/3) so concurrent appends can't drop items. Shared by the /training + promote gate (web `_append_methodology_override`) and chair-feedback auto-flow + (G2 — one append implementation).""" + pool = await get_pool() + 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 = row["rule_value"] + if isinstance(current, str): + try: + current = json.loads(current) + except (json.JSONDecodeError, TypeError): + current = [] + else: + current = list(seed_if_missing or []) + if not isinstance(current, list): + current = [] + added = [s for s in items if s and s not in current] + if not added and row: + return 0 + merged = current + added + 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), + ) + return len(added) + + async def get_methodology_overrides(category: str) -> dict: """Chair's /methodology edits for one category (golden_ratios / discussion_rules / content_checklists). Returns {rule_key: parsed_value}. These OVERRIDE the hardcoded diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index 4469b5d..96d1cfb 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -394,13 +394,35 @@ async def record_chair_feedback( lesson_extracted=lesson_extracted, ) + # Auto-flow chair-authored STYLE feedback to the writer (closes the dead + # chair_feedback→lesson chain — 27 feedback rows had produced 0 lessons). The + # chair is the highest authority, so a style correction she writes flows + # immediately — the chair IS the gate. It rides the SAME discussion_rules channel + # promote uses (db.append_global_rule, G2), reaching every block, without the + # style_corpus coupling decision_lessons require. SUBSTANCE feedback + # (missing_content/factual_error/other) is case-specific → recorded only. + # (INV-LRN1 graduated gate; 07-learning §1.2.) + _STYLE_FB = {"style", "wrong_tone", "wrong_structure"} + flowed = 0 + if lesson_extracted.strip() and category in _STYLE_FB: + try: + flowed = await db.append_global_rule( + "discussion_rules", "universal", [lesson_extracted.strip()], + ) + except Exception as e: + logger.warning("chair-feedback auto-flow failed for %s: %s", case_number, e) + + msg = f"הערה נרשמה בהצלחה. קטגוריה: {category}." + if flowed: + msg += " הלקח (סגנון) זרם אוטומטית לכותב." return ok({ "feedback_id": str(feedback_id), + "flowed_to_writer": bool(flowed), "next_steps": [ "כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback", "כדי לסמן כמטופל: resolve_chair_feedback", ], - }, message=f"הערה נרשמה בהצלחה. קטגוריה: {category}.") + }, message=msg) _CURATOR_FINDING_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"} diff --git a/web/app.py b/web/app.py index 42d5c23..728be50 100644 --- a/web/app.py +++ b/web/app.py @@ -4906,36 +4906,13 @@ 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. - - 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() - 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), - ) + """Thin wrapper over db.append_global_rule (the single locked append impl, G2) — + seeds from _METHODOLOGY_DEFAULTS when no override row exists yet. Shared by the + T14 promote gate; chair-feedback auto-flow calls db.append_global_rule directly.""" + await db.append_global_rule( + category, key, items, + seed_if_missing=list(_METHODOLOGY_DEFAULTS.get(category, {}).get(key, [])), + ) @app.post("/api/learning/pairs/{pair_id}/promote")