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")