diff --git a/.claude/agents/hermes-curator.md b/.claude/agents/hermes-curator.md index 6340f61..4a59fc8 100644 --- a/.claude/agents/hermes-curator.md +++ b/.claude/agents/hermes-curator.md @@ -76,6 +76,24 @@ profiles: Authorization: Bearer $PAPERCLIP_API_KEY { "body": "" } ``` +5b. **רושם כל ממצא גם ב-API של legal-ai כ-decision_lesson**, כך שיופיע ב-UI + תחת הטאב "מה למדנו" של ההחלטה בקורפוס. דרישה: למצוא קודם את ה-`style_corpus_id` + שתואם ל-`decision_number` של ההחלטה (`GET /api/training/corpus` ולסנן). + לכל ממצא: + ``` + POST https://legal-ai.nautilus.marcusgroup.org/api/training/corpus/{corpus_id}/lessons + Content-Type: application/json + { + "lesson_text": "<התקציר של הממצא — מה ראיתי + הצעה — שורה אחת>", + "category": "", + "source": "curator" + } + ``` + מיפוי תגי-ממצא ל-`category`: + - `[סגנון]` → `style` + - `[מבנה]` → `structure` + - `[לקסיקון משפטי]` → `lexicon` + - `[טבלאי]` → `tabular` 6. סוגר את ה-issue (status=done) אחרי שכתבתי את ה-comment ## פורמט ה-comment diff --git a/CLAUDE.md b/CLAUDE.md index c525373..ea76897 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,16 @@ - שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip` - **אין צורך ב-Docker או Coolify** +**legal-chat-service** — רץ **מקומית דרך pm2** (חדש, מאפריל 2026): +- פורט: `localhost:8770` (loopback בלבד) +- שירות aiohttp קצר שעוטף את `claude` CLI ב-streaming + session continuation, ומשרת את הטאב "שיחה" בדף `/training`. הקונטיינר משדל אליו proxy דרך `host.docker.internal:8770`. +- קוד: [mcp-server/src/legal_mcp/chat_service/](mcp-server/src/legal_mcp/chat_service/) +- התקנה: `pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs && pm2 save` +- בריאות: `curl http://127.0.0.1:8770/health` → `{"ok":true,...}` +- שינויי קוד: `pm2 restart legal-chat-service` +- **אפס עלות API** — claude CLI משתמש ב-claude.ai subscription של chaim. הנחת היסוד של `claude_session.py` (claude CLI מקומי בלבד) נשמרת — השירות הזה הוא הגשר הרשמי בין הקונטיינר לחוץ. +- Coolify dependency: ה-Service Definition של legal-ai חייב להכיל `extra_hosts: host.docker.internal:host-gateway` (אחרת ה-proxy יקבל ConnectError). + --- ## מבנה תיקיות diff --git a/mcp-server/src/legal_mcp/chat_service/__init__.py b/mcp-server/src/legal_mcp/chat_service/__init__.py new file mode 100644 index 0000000..36bb728 --- /dev/null +++ b/mcp-server/src/legal_mcp/chat_service/__init__.py @@ -0,0 +1,13 @@ +"""legal-chat-service — host-side SSE bridge to ``claude`` CLI. + +Runs as a pm2-managed process on the host (port 127.0.0.1:8770 by default). +The legal-ai FastAPI container proxies chat requests to it via +``host.docker.internal:8770``. + +Why a separate service: + The chat needs real-time streaming + multi-turn session continuation + (``claude --resume ``). The container can't run the + claude CLI (no binary, no claude.ai credentials). Splitting this out + keeps the architectural rule of ``claude_session.py`` intact while + enabling the new chat feature for free (no API key). +""" diff --git a/mcp-server/src/legal_mcp/chat_service/server.py b/mcp-server/src/legal_mcp/chat_service/server.py new file mode 100644 index 0000000..0f33987 --- /dev/null +++ b/mcp-server/src/legal_mcp/chat_service/server.py @@ -0,0 +1,144 @@ +"""HTTP+SSE bridge from FastAPI (in container) to local claude CLI. + +Endpoints: + POST /chat/start — body: {prompt, system?, resume_session_id?} + returns SSE stream of events from + ``claude_session.query_streaming``. + GET /health — liveness probe. + +Run with pm2: + pm2 start ecosystem.config.cjs --only legal-chat-service + +Standalone for dev: + cd ~/legal-ai/mcp-server + .venv/bin/python -m legal_mcp.chat_service.server --port 8770 + +We intentionally bind to 127.0.0.1 only — the FastAPI container reaches +us via ``host.docker.internal``, and exposing the bridge publicly would +let anyone run claude CLI commands against Daphna's session. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import sys +from typing import Any + +from aiohttp import web + +# Run-via-CLI bootstrap so ``python -m legal_mcp.chat_service.server`` +# works even when the package isn't installed (it is in the venv, but +# this safeguard keeps the entrypoint robust). +_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +if _pkg_root not in sys.path: + sys.path.insert(0, _pkg_root) + +from legal_mcp.services import claude_session # noqa: E402 + +logger = logging.getLogger("legal_chat_service") + + +async def health(request: web.Request) -> web.Response: + return web.json_response({"ok": True, "service": "legal-chat-service"}) + + +async def chat_start(request: web.Request) -> web.StreamResponse: + """Drive ``claude_session.query_streaming`` and forward events as SSE. + + Request body (JSON): + prompt: str — required, user message + system: str | None — system instructions (ignored if resuming) + resume_session_id: str | None — continue a prior CLI session + timeout: int = 3600 — hard timeout for the subprocess + """ + try: + body = await request.json() + except json.JSONDecodeError: + return web.json_response({"error": "invalid JSON body"}, status=400) + + prompt = body.get("prompt") or "" + if not prompt.strip(): + return web.json_response({"error": "prompt is required"}, status=400) + system = body.get("system") + resume_session_id = body.get("resume_session_id") + timeout = int(body.get("timeout") or 3600) + + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + # X-Accel-Buffering=no defeats nginx/traefik buffering — the + # FastAPI container proxies via httpx and forwards bytes as + # they arrive, but the inner header is harmless and makes + # browser-direct testing easier. + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + async def send_event(payload: dict[str, Any]) -> None: + line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + await response.write(line.encode("utf-8")) + + try: + async for event in claude_session.query_streaming( + prompt, + system=system, + resume_session_id=resume_session_id, + timeout=timeout, + ): + await send_event(event) + if event.get("type") == "done" or event.get("type") == "error": + break + except asyncio.CancelledError: + # Client disconnected — bail cleanly. + logger.info("chat_start: client disconnected") + except Exception as e: + logger.exception("chat_start: streaming failed") + try: + await send_event({"type": "error", "message": str(e)}) + except ConnectionResetError: + pass + + try: + await response.write_eof() + except ConnectionResetError: + pass + return response + + +def build_app() -> web.Application: + app = web.Application() + app.router.add_get("/health", health) + app.router.add_post("/chat/start", chat_start) + return app + + +def main() -> int: + parser = argparse.ArgumentParser(description="legal-chat-service") + parser.add_argument("--port", type=int, default=8770) + parser.add_argument("--host", default="127.0.0.1", + help="bind address; 127.0.0.1 keeps the service " + "loopback-only — leave it alone in production") + parser.add_argument("--log-level", default="INFO") + args = parser.parse_args() + + logging.basicConfig( + level=args.log_level.upper(), + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + app = build_app() + web.run_app(app, host=args.host, port=args.port, print=lambda _msg: None) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 7c67bd1..a14d85b 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -57,6 +57,7 @@ from legal_mcp.tools import ( # noqa: E402 legal_arguments as la_tools, missing_precedents as mp_tools, citations as cit_tools, + training_enrichment as train_tools, ) @@ -248,6 +249,18 @@ async def precedent_extract_metadata(case_law_id: str) -> str: return await plib.precedent_extract_metadata(case_law_id) +@mcp.tool() +async def style_corpus_enrich(corpus_id: str, overwrite: bool = False) -> str: + """חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון של דפנה. ברירת מחדל: ממלא רק שדות ריקים. שלח `overwrite=true` כדי לרענן.""" + return await train_tools.extract_decision_metadata(corpus_id, overwrite=overwrite) + + +@mcp.tool() +async def style_corpus_pending_enrichment(limit: int = 50) -> str: + """רשימת החלטות בקורפוס הסגנון שעדיין חסרות summary/outcome/key_principles — מועמדות לחילוץ.""" + return await train_tools.list_corpus_pending_enrichment(limit) + + @mcp.tool() async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str: """ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה.""" diff --git a/mcp-server/src/legal_mcp/services/claude_session.py b/mcp-server/src/legal_mcp/services/claude_session.py index 8db82f2..ced3ccc 100644 --- a/mcp-server/src/legal_mcp/services/claude_session.py +++ b/mcp-server/src/legal_mcp/services/claude_session.py @@ -142,3 +142,175 @@ async def query_json( """ raw = await query(prompt, timeout=timeout, system=system) return parse_llm_json(raw) + + +# ── Streaming + session continuation ──────────────────────────────── + + +async def query_streaming( + prompt: str, + *, + system: str | None = None, + resume_session_id: str | None = None, + timeout: int = LONG_TIMEOUT, + cwd: str | None = None, +): + """Stream Claude's response as an async iterator of events. + + Wraps `claude -p --output-format=stream-json` (newline-delimited JSON + objects from the CLI) and translates each line into a small, stable + shape that the chat service / SSE proxy can forward without leaking + CLI internals to the browser. + + Event shapes yielded: + {"type": "session_id", "value": ""} # first event, used for resume + {"type": "text_delta", "text": ""} # incremental assistant text + {"type": "tool_use", "name": "...", "input": {...}} + {"type": "error", "message": "..."} + {"type": "done", "text": ""} + + The CLI emits a richer stream; we project to this minimal set so the + front-end can stay stable across CLI upgrades. + + Args: + prompt: The user message to send. + system: Optional system instructions (used only when starting a + fresh conversation — when resume_session_id is set, the + session already carries its system prompt). + resume_session_id: Continue a prior conversation. When given, + we don't re-send the system prompt; the CLI loads the + entire conversation history from disk. + timeout: Hard ceiling on the subprocess. + cwd: Working directory for the subprocess — defaults to the + host's HOME so claude.ai credentials resolve correctly. + """ + if resume_session_id: + # When resuming, system is already baked into the on-disk session + # — sending it again would be a no-op at best and confuse the + # conversation at worst. + full_prompt = prompt + cmd = [ + "claude", "-p", + "--output-format", "stream-json", + "--verbose", + "--resume", resume_session_id, + ] + else: + full_prompt = f"{system}\n\n{prompt}" if system else prompt + cmd = [ + "claude", "-p", + "--output-format", "stream-json", + "--verbose", + ] + + if len(full_prompt) > 200_000: + logger.warning( + "Streaming: large prompt (%d chars) — may hit CLI input limits", + len(full_prompt), + ) + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + except FileNotFoundError: + yield { + "type": "error", + "message": ( + "Claude CLI not found on host — legal-chat-service must " + "run where the `claude` binary is installed (Daphna's host, " + "not the legal-ai container)." + ), + } + return + + assert proc.stdin is not None # for type checkers + assert proc.stdout is not None + + # Send the prompt and close stdin so the CLI knows the user message + # is complete. + try: + proc.stdin.write(full_prompt.encode("utf-8")) + await proc.stdin.drain() + proc.stdin.close() + except BrokenPipeError: + # CLI exited before reading the prompt — drain stderr and bail. + stderr_b = await proc.stderr.read() if proc.stderr else b"" + yield { + "type": "error", + "message": f"Claude CLI closed stdin early: {stderr_b.decode('utf-8', errors='replace')[:300]}", + } + return + + accumulated_text: list[str] = [] + session_id_emitted = False + deadline = asyncio.get_event_loop().time() + timeout + try: + while True: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + yield {"type": "error", "message": f"timed out after {timeout}s"} + break + try: + line_b = await asyncio.wait_for(proc.stdout.readline(), timeout=remaining) + except asyncio.TimeoutError: + yield {"type": "error", "message": f"stream timed out after {timeout}s"} + break + if not line_b: + break + line = line_b.decode("utf-8", errors="replace").strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + # Stray non-JSON line from CLI — surface a snippet for debug. + logger.debug("non-JSON stream line: %s", line[:120]) + continue + + # The CLI's stream-json emits several event types. We only + # care about the ones the chat service forwards. + t = event.get("type") + if not session_id_emitted: + sid = event.get("session_id") + if sid: + session_id_emitted = True + yield {"type": "session_id", "value": sid} + + if t == "assistant": + # event["message"]["content"] is a list of blocks; we extract + # text blocks and tool_use blocks. + msg = event.get("message") or {} + for block in msg.get("content") or []: + btype = block.get("type") + if btype == "text": + text = block.get("text") or "" + if text: + accumulated_text.append(text) + yield {"type": "text_delta", "text": text} + elif btype == "tool_use": + yield { + "type": "tool_use", + "name": block.get("name") or "", + "input": block.get("input") or {}, + } + elif t == "result": + # Final synthesized result line from the CLI — we already + # delivered the deltas, so just stop here. + break + finally: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + try: + await proc.wait() + except Exception: + pass + + yield {"type": "done", "text": "".join(accumulated_text)} diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 5e8f214..8416424 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -194,6 +194,55 @@ ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '' -- הרחבת style_patterns עם appeal_subtype לניתוח סגנון נפרד לכל סוג ערר ALTER TABLE style_patterns ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT ''; +-- decision_lessons: per-decision learnings the chair / curator / style_analyzer +-- attaches to a corpus row. The generic legal-decision-lessons.md file stays +-- as the source of truth for cross-corpus patterns; this table stores the +-- granular "what we learned from THIS decision" notes that drive the writer's +-- future drafts and let the curator look up prior observations on the same row. +CREATE TABLE IF NOT EXISTS decision_lessons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + style_corpus_id UUID NOT NULL REFERENCES style_corpus(id) ON DELETE CASCADE, + lesson_text TEXT NOT NULL, + category TEXT DEFAULT 'general', -- style / structure / lexicon / tabular / general + source TEXT DEFAULT 'manual', -- manual / curator / chair / style_analyzer + applied_to_skill BOOLEAN DEFAULT false, -- has this been promoted into SKILL.md? + created_by TEXT DEFAULT 'chaim', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_decision_lessons_corpus ON decision_lessons(style_corpus_id); +CREATE INDEX IF NOT EXISTS idx_decision_lessons_applied ON decision_lessons(applied_to_skill); + +-- chat_conversations / chat_messages: persistent history for the +-- "שיחה עם הסוכן" tab on /training. Each conversation can optionally be +-- scoped to a single style_corpus row (when the chair starts a chat +-- "about decision X"). claude_session_id is the value the local claude +-- CLI returns in stream-json — we pass it back via `--resume` on the +-- next message so the model continues the same conversation without +-- re-loading the system prompt every time. +CREATE TABLE IF NOT EXISTS chat_conversations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title TEXT NOT NULL DEFAULT 'שיחה חדשה', + style_corpus_id UUID REFERENCES style_corpus(id) ON DELETE SET NULL, + claude_session_id TEXT, + system_prompt_version TEXT DEFAULT 'v1', + created_at TIMESTAMPTZ DEFAULT now(), + last_message_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + conversation_id UUID NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE, + role TEXT NOT NULL, -- 'user' | 'assistant' + content TEXT NOT NULL, + raw_events JSONB DEFAULT '[]', -- stream-json events for the assistant turn (optional, for debug) + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chat_messages_conv ON chat_messages(conversation_id, created_at); +CREATE INDEX IF NOT EXISTS idx_chat_conv_corpus ON chat_conversations(style_corpus_id); +CREATE INDEX IF NOT EXISTS idx_chat_conv_last ON chat_conversations(last_message_at DESC); + -- טבלת qa_results CREATE TABLE IF NOT EXISTS qa_results ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -1609,6 +1658,284 @@ async def delete_from_style_corpus(corpus_id: UUID) -> dict: } +async def get_style_corpus_row(corpus_id: UUID) -> dict | None: + """Return a single style_corpus row by id, or None if missing.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, document_id, decision_number, decision_date, + subject_categories, full_text, summary, outcome, + key_principles, practice_area, appeal_subtype, created_at + FROM style_corpus WHERE id = $1 + """, + corpus_id, + ) + return dict(row) if row else None + + +async def update_style_corpus_metadata( + corpus_id: UUID, + *, + summary: str | None = None, + outcome: str | None = None, + key_principles: list[str] | None = None, + appeal_subtype: str | None = None, + practice_area: str | None = None, + overwrite: bool = False, +) -> dict: + """Patch the enriched-metadata columns of a style_corpus row. + + By default, only empty columns are filled — passing ``overwrite=True`` + is the caller's signal that they intentionally want to replace existing + values (used by the re-extract flow when the chair runs it manually). + """ + pool = await get_pool() + async with pool.acquire() as conn: + existing = await conn.fetchrow( + "SELECT summary, outcome, key_principles, appeal_subtype, practice_area " + "FROM style_corpus WHERE id = $1", + corpus_id, + ) + if not existing: + return {"updated": False, "reason": "not found"} + + sets: dict = {} + if summary is not None and (overwrite or not (existing["summary"] or "").strip()): + sets["summary"] = summary + if outcome is not None and (overwrite or not (existing["outcome"] or "").strip()): + sets["outcome"] = outcome + if key_principles is not None: + current = existing["key_principles"] + if isinstance(current, str): + try: + current = json.loads(current) + except json.JSONDecodeError: + current = [] + if overwrite or not (current or []): + sets["key_principles"] = json.dumps(key_principles) + if appeal_subtype is not None and (overwrite or not (existing["appeal_subtype"] or "").strip()): + sets["appeal_subtype"] = appeal_subtype + if practice_area is not None and (overwrite or not (existing["practice_area"] or "").strip()): + sets["practice_area"] = practice_area + + if not sets: + return {"updated": False, "reason": "nothing to update", "fields": []} + + cols = list(sets.keys()) + set_clause = ", ".join(f"{c} = ${i + 2}" for i, c in enumerate(cols)) + values = [sets[c] for c in cols] + await conn.execute( + f"UPDATE style_corpus SET {set_clause} WHERE id = $1", + corpus_id, *values, + ) + return {"updated": True, "fields": cols} + + +# ── decision_lessons (per-corpus row notes) ──────────────────────── + + +async def list_decision_lessons(corpus_id: UUID) -> list[dict]: + pool = await get_pool() + 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 " + "FROM decision_lessons WHERE style_corpus_id = $1 " + "ORDER BY created_at DESC", + corpus_id, + ) + return [dict(r) for r in rows] + + +async def add_decision_lesson( + corpus_id: UUID, + *, + lesson_text: str, + category: str = "general", + source: str = "manual", + created_by: str = "chaim", +) -> dict: + 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) " + "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, + ) + return dict(row) if row else {} + + +async def update_decision_lesson( + lesson_id: UUID, + *, + lesson_text: str | None = None, + category: str | None = None, + applied_to_skill: bool | None = None, +) -> dict: + sets: dict = {} + if lesson_text is not None: + sets["lesson_text"] = lesson_text + if category is not None: + sets["category"] = category + if applied_to_skill is not None: + sets["applied_to_skill"] = applied_to_skill + if not sets: + return {"updated": False, "reason": "nothing to update"} + sets["updated_at"] = "now()" # sentinel — replaced inline below + cols = [c for c in sets if c != "updated_at"] + set_clause = ", ".join(f"{c} = ${i + 2}" for i, c in enumerate(cols)) + set_clause += ", updated_at = now()" + values = [sets[c] for c in cols] + pool = await get_pool() + async with pool.acquire() as conn: + 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", + lesson_id, *values, + ) + if not row: + return {"updated": False, "reason": "not found"} + return {"updated": True, **dict(row)} + + +async def delete_decision_lesson(lesson_id: UUID) -> dict: + pool = await get_pool() + async with pool.acquire() as conn: + result = await conn.execute( + "DELETE FROM decision_lessons WHERE id = $1", lesson_id, + ) + # asyncpg returns "DELETE n" + deleted = result.split(" ", 1)[1].strip() if " " in result else "0" + return {"deleted": deleted != "0"} + + +async def count_decision_lessons_per_corpus() -> dict[str, int]: + """Map style_corpus.id (str) → lesson count, for badge display in the list.""" + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT style_corpus_id, count(*) AS n " + "FROM decision_lessons GROUP BY style_corpus_id" + ) + return {str(r["style_corpus_id"]): r["n"] for r in rows} + + +# ── chat (style agent conversations) ─────────────────────────────── + + +async def create_chat_conversation( + *, + title: str = "שיחה חדשה", + style_corpus_id: UUID | None = None, + system_prompt_version: str = "v1", +) -> dict: + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "INSERT INTO chat_conversations " + "(title, style_corpus_id, system_prompt_version) " + "VALUES ($1, $2, $3) " + "RETURNING id, title, style_corpus_id, claude_session_id, " + " system_prompt_version, created_at, last_message_at", + title, style_corpus_id, system_prompt_version, + ) + return dict(row) if row else {} + + +async def list_chat_conversations(limit: int = 50) -> list[dict]: + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT c.id, c.title, c.style_corpus_id, c.claude_session_id, + c.created_at, c.last_message_at, + sc.decision_number, + (SELECT count(*) FROM chat_messages m WHERE m.conversation_id = c.id) AS message_count + FROM chat_conversations c + LEFT JOIN style_corpus sc ON sc.id = c.style_corpus_id + ORDER BY c.last_message_at DESC NULLS LAST + LIMIT $1 + """, + limit, + ) + return [dict(r) for r in rows] + + +async def get_chat_conversation(conv_id: UUID) -> dict | None: + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, title, style_corpus_id, claude_session_id, " + " system_prompt_version, created_at, last_message_at " + "FROM chat_conversations WHERE id = $1", + conv_id, + ) + return dict(row) if row else None + + +async def delete_chat_conversation(conv_id: UUID) -> dict: + pool = await get_pool() + async with pool.acquire() as conn: + result = await conn.execute( + "DELETE FROM chat_conversations WHERE id = $1", conv_id, + ) + deleted = result.split(" ", 1)[1].strip() if " " in result else "0" + return {"deleted": deleted != "0"} + + +async def update_chat_conversation_session_id( + conv_id: UUID, claude_session_id: str, +) -> None: + pool = await get_pool() + async with pool.acquire() as conn: + await conn.execute( + "UPDATE chat_conversations SET claude_session_id = $1, " + " last_message_at = now() " + "WHERE id = $2", + claude_session_id, conv_id, + ) + + +async def add_chat_message( + conv_id: UUID, + *, + role: str, + content: str, + raw_events: list | None = None, +) -> dict: + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "INSERT INTO chat_messages " + "(conversation_id, role, content, raw_events) " + "VALUES ($1, $2, $3, $4) " + "RETURNING id, conversation_id, role, content, created_at", + conv_id, role, content, json.dumps(raw_events or []), + ) + await conn.execute( + "UPDATE chat_conversations SET last_message_at = now() WHERE id = $1", + conv_id, + ) + return dict(row) if row else {} + + +async def list_chat_messages(conv_id: UUID) -> list[dict]: + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT id, role, content, created_at " + "FROM chat_messages WHERE conversation_id = $1 " + "ORDER BY created_at ASC", + conv_id, + ) + return [dict(r) for r in rows] + + async def get_style_patterns(pattern_type: str | None = None) -> list[dict]: pool = await get_pool() async with pool.acquire() as conn: diff --git a/mcp-server/src/legal_mcp/services/style_metadata_extractor.py b/mcp-server/src/legal_mcp/services/style_metadata_extractor.py new file mode 100644 index 0000000..0f5d466 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/style_metadata_extractor.py @@ -0,0 +1,195 @@ +"""Auto-extract per-decision metadata for a style_corpus row. + +Populates the fields that the upload flow leaves empty — summary, outcome, +key_principles, appeal_subtype, practice_area — by asking Claude (via the +local CLI session) to read the proofread full_text and return a structured +JSON blob. + +Caller policy (``apply_to_corpus``): by default we **only fill empty +columns**, so chair-edited values are preserved across re-runs. The chair +can force a refresh by passing ``overwrite=True``. + +Why this is a separate module from ``precedent_metadata_extractor``: +that one fills the *external* case_law corpus (court rulings, third-party +committee decisions). This one fills the *style* corpus — Daphna's own +decisions used to teach the writer the in-house voice. The two corpora +have different schemas, different prompts, and different downstream +consumers, so coupling them would have been the wrong shortcut. +""" + +from __future__ import annotations + +import logging +from uuid import UUID + +from legal_mcp.services import claude_session, db + +logger = logging.getLogger(__name__) + + +# A single decision typically runs 200K-650K chars. We sample the head +# (where outcome + parties + framing live) and the tail (where the +# operative ruling sits). Picking from both edges keeps the prompt under +# 60K chars — comfortable for any Claude tier. +_HEAD_CHARS = 25_000 +_TAIL_CHARS = 15_000 + + +def _build_text_window(full_text: str) -> str: + if len(full_text) <= _HEAD_CHARS + _TAIL_CHARS: + return full_text + head = full_text[:_HEAD_CHARS] + tail = full_text[-_TAIL_CHARS:] + return ( + f"{head}\n\n" + f"[... חתך: {len(full_text) - _HEAD_CHARS - _TAIL_CHARS:,} תווים מהאמצע " + f"הושמטו — שמרנו על ההתחלה (טענות + רקע) ועל הסוף (הכרעה + הוצאות) ...]" + f"\n\n{tail}" + ) + + +# Static instructions — go via ``system`` so the SDK path can cache them +# across batch enrichment runs (24+ decisions in one pass). +METADATA_PROMPT = """אתה מסייע משפטי שמקטלג את הקורפוס הסגנוני של דפנה תמיר (יו"ר ועדת ערר). + +תפקידך: לקרוא החלטה אחת ולחלץ מטא-דאטה ל-style_corpus — שדות שהמשתמש לא הזין בעת ההעלאה. + +**אל תמציא**. אם המידע לא מופיע בטקסט, השאר מחרוזת ריקה או מערך ריק. אסור להסיק עובדות שלא כתובות. + +## פלט נדרש + +החזר JSON אחד (object אחד — לא array, לא markdown, לא הסברים): + +{ + "summary": "תקציר עניני ב-2-3 משפטים: מי העורר, מה דרש, מה הוכרע. סגנון יבש, ניטרלי, ללא שיפוט. דוגמה: 'ערר על דחיית בקשה להיתר לתוספת מרפסת בקומה ג׳. דפנה קיבלה את הערר חלקית — אישרה את המרפסת בהקטנה ל-12 מ״ר.'", + + "outcome": "התוצאה התמציתית. אחד מאלה (או צירוף קצר): 'קבלה' / 'קבלה חלקית' / 'דחייה' / 'הסתלקות' / 'החזרה לוועדה המקומית'. אם זה לא ברור — מחרוזת ריקה.", + + "key_principles": [ + "עיקרון משפטי 1 שעולה מההחלטה — משפט אחד, ניסוח מופשט. למשל 'שיקול דעת מוגבל לחריגות בנייה קטנות'.", + "עיקרון 2", + "..." + ], + + "appeal_subtype": "תת-סוג ערר. ערכים מותרים: 'building_permit' (היתר בנייה / רישוי), 'betterment_levy' (היטל השבחה), 'compensation_197' (פיצויים ס׳ 197), 'use_change' (שימוש חורג), 'tama_38' (תמ\\"א 38), או מחרוזת ריקה אם לא ברור.", + + "practice_area": "תחום משפט גנרי. ברירת מחדל: 'appeals_committee'. אם זה במובהק 'planning_law' — סמן.", + + "parties_appellant": "שם העורר/ים המרכזיים בהחלטה (אחד או כמה, מופרדים בפסיק). אם זו החלטה מאוחדת — שם הצד המוביל. השאר ריק אם לא ניתן לזהות במדויק.", + + "parties_respondent": "שם המשיב/ים. ברירת מחדל לעררי 1xxx ו-8xxx: 'הוועדה המקומית לתכנון ובניה ירושלים' או דומה. השאר ריק אם לא ברור." +} + +## כללי איכות + +1. **summary** — חייב להזכיר את התוצאה. בלי 'בית המשפט קבע ש...' (אנחנו לא בית משפט). בלי הערכת אישית. +2. **outcome** — קבלה / קבלה חלקית / דחייה / הסתלקות / החזרה לוועדה המקומית. אם דפנה הכריעה חלקית — 'קבלה חלקית'. אסור 'התקבל' או 'נדחה' בלשון פעולה — רק שם פעולה. +3. **key_principles** — 2-5 עקרונות מקסימום. כל אחד משפט אחד. לא ציטוטים מילוליים, אלא תמצות העיקרון. +4. **appeal_subtype** — תמיד פעולה אחת. אם החלטה מערבת כמה תת-סוגים — בחר את העיקרי. +5. **parties_appellant / parties_respondent** — שם בלבד, בלי 'נ׳' או 'נגד'. + +החזר רק את ה-JSON. אל תכתוב שום דבר לפניו או אחריו. +""" + + +async def extract_decision_metadata(corpus_id: UUID | str) -> dict: + """Run Claude over the row's full_text and return suggested fields. + + Does NOT touch the DB. The caller decides what to apply. + """ + if isinstance(corpus_id, str): + corpus_id = UUID(corpus_id) + row = await db.get_style_corpus_row(corpus_id) + if not row: + return {} + full_text = (row.get("full_text") or "").strip() + if not full_text: + return {} + + context = ( + f"מספר החלטה: {row.get('decision_number') or '—'}\n" + f"תאריך: {row.get('decision_date') or '—'}\n" + f"תת-סוג נוכחי: {row.get('appeal_subtype') or '—'}\n" + f"נושאים מתויגים: {row.get('subject_categories') or '—'}" + ) + window = _build_text_window(full_text) + user_msg = ( + f"## הקלט\n{context}\n\n" + f"--- תחילת ההחלטה ---\n{window}\n--- סוף ההחלטה ---" + ) + + try: + result = await claude_session.query_json(user_msg, system=METADATA_PROMPT) + except Exception as e: + logger.warning("style_metadata_extractor: query failed: %s", e) + return {} + + if not isinstance(result, dict): + logger.warning( + "style_metadata_extractor: expected JSON object, got %s", + type(result).__name__, + ) + return {} + + out: dict = {} + if isinstance(result.get("summary"), str): + out["summary"] = result["summary"].strip() + if isinstance(result.get("outcome"), str): + out["outcome"] = result["outcome"].strip() + kp = result.get("key_principles") or [] + if isinstance(kp, list): + out["key_principles"] = [str(p).strip() for p in kp if str(p).strip()] + if isinstance(result.get("appeal_subtype"), str): + st = result["appeal_subtype"].strip() + # Open enum — but log values outside the documented list so we can + # tighten the prompt later if needed. + known = { + "building_permit", "betterment_levy", "compensation_197", + "use_change", "tama_38", "", + } + if st not in known: + logger.info("style_metadata: unknown appeal_subtype=%r (kept)", st) + out["appeal_subtype"] = st + if isinstance(result.get("practice_area"), str): + out["practice_area"] = result["practice_area"].strip() + # Parties: not stored in the schema today, but worth surfacing in the + # extractor's return value so callers (and the UI's drawer) can display + # them. The list endpoint extracts via regex; LLM output is the + # higher-quality fallback when regex fails. + if isinstance(result.get("parties_appellant"), str): + out["parties_appellant"] = result["parties_appellant"].strip() + if isinstance(result.get("parties_respondent"), str): + out["parties_respondent"] = result["parties_respondent"].strip() + return out + + +async def extract_and_apply( + corpus_id: UUID | str, *, overwrite: bool = False, +) -> dict: + """Convenience: extract → apply → return summary of what changed. + + Idempotent under default ``overwrite=False`` — re-runs only fill empty + fields. Use ``overwrite=True`` to refresh values the chair (or a prior + extraction) already wrote. + """ + if isinstance(corpus_id, str): + corpus_id = UUID(corpus_id) + suggested = await extract_decision_metadata(corpus_id) + if not suggested: + return {"extracted": False, "applied": False, "reason": "no suggestion"} + + update_result = await db.update_style_corpus_metadata( + corpus_id, + summary=suggested.get("summary"), + outcome=suggested.get("outcome"), + key_principles=suggested.get("key_principles"), + appeal_subtype=suggested.get("appeal_subtype"), + practice_area=suggested.get("practice_area"), + overwrite=overwrite, + ) + return { + "extracted": True, + "applied": update_result.get("updated", False), + "fields_set": update_result.get("fields", []), + "suggested": suggested, + } diff --git a/mcp-server/src/legal_mcp/tools/training_enrichment.py b/mcp-server/src/legal_mcp/tools/training_enrichment.py new file mode 100644 index 0000000..016a625 --- /dev/null +++ b/mcp-server/src/legal_mcp/tools/training_enrichment.py @@ -0,0 +1,85 @@ +"""MCP tool wrappers for the style_corpus metadata-enrichment flow. + +The actual extractor lives in +``legal_mcp.services.style_metadata_extractor``; this module just exposes +it as MCP tools that the chair (or a future automation) can call from +Claude Code. + +Why these tools matter: the upload pipeline (`/api/training/upload` → +`_process_proofread_training`) inserts a style_corpus row with +``summary=''``, ``outcome=''``, ``key_principles=[]`` because LLM +extraction can't run from the FastAPI container (no claude CLI there). +This module fills that gap — call it from the host, where ``claude`` +CLI is available, and the row gets enriched. +""" + +from __future__ import annotations + +import json +from uuid import UUID + +from legal_mcp.services import db, style_metadata_extractor + + +def _ok(payload) -> str: + return json.dumps({"ok": True, **payload}, ensure_ascii=False, default=str) + + +def _err(msg: str) -> str: + return json.dumps({"ok": False, "error": msg}, ensure_ascii=False) + + +async def extract_decision_metadata(corpus_id: str, overwrite: bool = False) -> str: + """חילוץ מטא-דאטה (summary, outcome, key_principles, appeal_subtype) להחלטה בקורפוס הסגנון. + + ברירת מחדל ``overwrite=False`` ממלא רק שדות ריקים. הזן ``overwrite=true`` + כדי לרענן ערכים שכבר נכתבו. + """ + try: + cid = UUID(corpus_id) + except ValueError: + return _err("corpus_id לא תקין") + try: + result = await style_metadata_extractor.extract_and_apply(cid, overwrite=overwrite) + except Exception as e: + return _err(str(e)) + return _ok(result) + + +async def list_corpus_pending_enrichment(limit: int = 50) -> str: + """רשימת רשומות style_corpus שחסר להן summary/outcome/key_principles — מועמדות להעשרה.""" + pool = await db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, decision_number, decision_date, + length(full_text) AS chars, + coalesce(summary, '') = '' AS missing_summary, + coalesce(outcome, '') = '' AS missing_outcome, + coalesce(jsonb_array_length(key_principles), 0) = 0 AS missing_principles + FROM style_corpus + WHERE coalesce(summary, '') = '' + OR coalesce(outcome, '') = '' + OR coalesce(jsonb_array_length(key_principles), 0) = 0 + ORDER BY decision_date NULLS LAST + LIMIT $1 + """, + limit, + ) + items = [ + { + "corpus_id": str(r["id"]), + "decision_number": r["decision_number"] or "", + "decision_date": str(r["decision_date"]) if r["decision_date"] else "", + "chars": r["chars"], + "missing": [ + f for f, v in ( + ("summary", r["missing_summary"]), + ("outcome", r["missing_outcome"]), + ("key_principles", r["missing_principles"]), + ) if v + ], + } + for r in rows + ] + return _ok({"count": len(items), "items": items}) diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 94baf6b..d7e99a8 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -35,6 +35,7 @@ | `compute_ndcg.py` | python | חישוב nDCG@10 על `search_relevance_feedback` (TaskMaster #50, Stage C). aggregation לפי `search_type` ולפי שבוע, כולל top-cited case_law ו-coverage %. דגלים: `--k 10`, `--weeks 12`, `--pretty`. read-only, פלט JSON. משמש גם את `GET /api/admin/rag-metrics` (מיובא inline) — שינוי חתימה ב-`compute()` ישבור את ה-endpoint | ידני / cron עתידי לדיווח שבועי | | `backfill_multimodal_precedents.py` | python | Backfill voyage-multimodal-3 page embeddings על רשומות `case_law` (external_upload + internal_committee) שחסרות `precedent_image_embeddings`. בונה אינדקס קבצים מ-`data/precedent-library/` ו-`data/internal-decisions/`, מנסה התאמה לפי tokens של מספרי תיק (כולל parts-match לפורמטים שונים של Nevo doc-id). מדלג על רשומות בלי קובץ-מקור או עם MD בלבד (PyMuPDF לא מרנדר MD). תומך `--dry-run` (default) / `--apply` / `--only external_upload\|internal_committee` / `--limit N`. רץ בקונטיינר (יש `/data` + Voyage env). **הופעל 2026-05-26**: 70 חסרים → 26 backfilled (503 pages, ~$0.21 voyage tokens), 44 אין-קובץ-מקור. ניתן להריץ שוב אחרי שיועלו עוד PDF/DOCX לספרייה | ידני | | `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) | +| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה | ## תיקיית `.archive/` — סקריפטים שהושלמו diff --git a/scripts/audit_training_corpus.py b/scripts/audit_training_corpus.py new file mode 100755 index 0000000..d1bd401 --- /dev/null +++ b/scripts/audit_training_corpus.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +"""Audit the style_corpus table — list each decision with what's populated and what's missing. + +Produces a JSON report at data/audit/corpus-YYYY-MM-DD.json so we can see at a glance +which corpus entries lack summary/outcome/key_principles/appeal_subtype/chunks/embeddings. + +Run with the mcp-server venv (has asyncpg): + POSTGRES_URL=postgres://... ./mcp-server/.venv/bin/python scripts/audit_training_corpus.py + +Without POSTGRES_URL, falls back to the per-field env vars used by web/mcp-server config. +""" +from __future__ import annotations + +import asyncio +import json +import os +import re +import sys +from datetime import UTC, date, datetime +from pathlib import Path + +import asyncpg + + +def _build_dsn() -> str: + if url := os.environ.get("POSTGRES_URL"): + return url + return ( + f"postgres://{os.environ.get('POSTGRES_USER', 'legal_ai')}:" + f"{os.environ.get('POSTGRES_PASSWORD', '')}@" + f"{os.environ.get('POSTGRES_HOST', '127.0.0.1')}:" + f"{os.environ.get('POSTGRES_PORT', '5433')}/" + f"{os.environ.get('POSTGRES_DB', 'legal_ai')}" + ) + + +async def audit() -> dict: + dsn = _build_dsn() + conn = await asyncpg.connect(dsn) + try: + rows = await conn.fetch( + """ + SELECT id, decision_number, decision_date, subject_categories, + length(full_text) AS chars, + summary, + outcome, + key_principles, + practice_area, + appeal_subtype, + document_id, + created_at + FROM style_corpus + ORDER BY decision_date NULLS LAST, decision_number + """ + ) + + # Chunk + embedding counts for each related document — by direct FK first, + # then by title-match for legacy rows where style_corpus.document_id is NULL. + chunk_counts = await conn.fetch( + """ + SELECT d.id AS doc_id, d.title, + count(c.id) AS chunks, + count(c.embedding) FILTER (WHERE c.embedding IS NOT NULL) AS chunks_with_emb + FROM documents d + LEFT JOIN document_chunks c ON c.document_id = d.id + WHERE d.title LIKE '[קורפוס]%' OR d.id IN (SELECT document_id FROM style_corpus WHERE document_id IS NOT NULL) + GROUP BY d.id, d.title + """ + ) + + finally: + await conn.close() + + by_doc_id = {r["doc_id"]: r for r in chunk_counts} + + # Index corpus documents by every digit cluster in their title so we can + # match against style_corpus.decision_number regardless of formatting + # (e.g. style_corpus has "1109-25" but title may say "ARAR-25-1109" or + # "ערר 1009-25"). Each digit run >=3 chars becomes a key. + by_digit: dict[str, dict] = {} + for r in chunk_counts: + title = r["title"] or "" + for tok in re.findall(r"\d{3,}", title): + by_digit.setdefault(tok, r) + + decisions = [] + gaps_total = { + "summary": 0, "outcome": 0, "key_principles": 0, + "appeal_subtype": 0, "subject_categories": 0, + "chunks": 0, "embeddings": 0, "document_id": 0, + } + + for row in rows: + cats = row["subject_categories"] + if isinstance(cats, str): + try: + cats = json.loads(cats) + except json.JSONDecodeError: + cats = [] + cats = cats or [] + + kp = row["key_principles"] + if isinstance(kp, str): + try: + kp = json.loads(kp) + except json.JSONDecodeError: + kp = [] + kp = kp or [] + + # Resolve chunks: prefer FK, fall back to digit-cluster match on decision_number. + chunks = 0 + chunks_with_emb = 0 + if row["document_id"] and row["document_id"] in by_doc_id: + r = by_doc_id[row["document_id"]] + chunks = r["chunks"] + chunks_with_emb = r["chunks_with_emb"] + elif row["decision_number"]: + for tok in re.findall(r"\d{3,}", row["decision_number"]): + if tok in by_digit: + r = by_digit[tok] + chunks = r["chunks"] + chunks_with_emb = r["chunks_with_emb"] + break + + missing = [] + if not row["summary"]: + missing.append("summary") + gaps_total["summary"] += 1 + if not row["outcome"]: + missing.append("outcome") + gaps_total["outcome"] += 1 + if not kp: + missing.append("key_principles") + gaps_total["key_principles"] += 1 + if not row["appeal_subtype"]: + missing.append("appeal_subtype") + gaps_total["appeal_subtype"] += 1 + if not cats: + missing.append("subject_categories") + gaps_total["subject_categories"] += 1 + if chunks == 0: + missing.append("chunks") + gaps_total["chunks"] += 1 + elif chunks_with_emb < chunks: + missing.append(f"embeddings({chunks_with_emb}/{chunks})") + gaps_total["embeddings"] += 1 + if row["document_id"] is None: + missing.append("document_id") + gaps_total["document_id"] += 1 + + decisions.append({ + "id": str(row["id"]), + "decision_number": row["decision_number"] or "", + "decision_date": row["decision_date"].isoformat() if row["decision_date"] else None, + "chars": row["chars"], + "subject_categories": cats, + "practice_area": row["practice_area"] or "", + "appeal_subtype": row["appeal_subtype"] or "", + "summary_len": len(row["summary"] or ""), + "outcome_len": len(row["outcome"] or ""), + "key_principles_count": len(kp), + "chunks": chunks, + "chunks_with_embeddings": chunks_with_emb, + "document_id": str(row["document_id"]) if row["document_id"] else None, + "missing": missing, + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + }) + + return { + "generated_at": datetime.now(UTC).isoformat(), + "total_decisions": len(decisions), + "gaps_total": gaps_total, + "decisions": decisions, + } + + +async def main() -> int: + report = await audit() + out_dir = Path(__file__).resolve().parents[1] / "data" / "audit" + out_dir.mkdir(parents=True, exist_ok=True) + today = date.today().isoformat() + out_file = out_dir / f"corpus-{today}.json" + out_file.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + # Console summary + print(f"Total decisions: {report['total_decisions']}") + print("Gaps by field (count of decisions missing it):") + for field, n in report["gaps_total"].items(): + bar = "█" * min(n, 60) + print(f" {field:25s} {n:3d} {bar}") + print(f"\nReport written to {out_file}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/scripts/legal-chat-service.config.cjs b/scripts/legal-chat-service.config.cjs new file mode 100644 index 0000000..c399d72 --- /dev/null +++ b/scripts/legal-chat-service.config.cjs @@ -0,0 +1,48 @@ +/** + * pm2 ecosystem entry for legal-chat-service — the host-side SSE bridge + * to ``claude`` CLI that powers the /training chat tab. + * + * Why pm2: + * - Auto-restart if the process dies (claude CLI subprocess failures + * should never leave the service in a half-dead state). + * - Log rotation matches paperclip's behavior so the chair sees + * consistent log paths under ~/.pm2/logs/. + * + * Install (once): + * pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs + * pm2 save + * + * Smoke test: + * curl http://127.0.0.1:8770/health + * # → {"ok":true,"service":"legal-chat-service"} + * + * Update: + * pm2 restart legal-chat-service + * + * Stop: + * pm2 stop legal-chat-service + */ + +module.exports = { + apps: [ + { + name: "legal-chat-service", + cwd: "/home/chaim/legal-ai/mcp-server", + // Run the in-package server via the venv interpreter so all + // imports (claude_session, etc) resolve. + script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python", + args: "-m legal_mcp.chat_service.server --port 8770", + // claude CLI looks up credentials under HOME — make sure it + // sees Daphna's session, not an empty container HOME. + env: { + HOME: "/home/chaim", + PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin", + PYTHONUNBUFFERED: "1", + }, + restart_delay: 5000, + max_restarts: 10, + autorestart: true, + max_memory_restart: "500M", + }, + ], +}; diff --git a/web-ui/src/app/training/page.tsx b/web-ui/src/app/training/page.tsx index b2efa52..44f1f08 100644 --- a/web-ui/src/app/training/page.tsx +++ b/web-ui/src/app/training/page.tsx @@ -1,30 +1,49 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; +import { Upload } from "lucide-react"; import { AppShell } from "@/components/app-shell"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { StyleReportPanel } from "@/components/training/style-report-panel"; import { CorpusPanel } from "@/components/training/corpus-panel"; import { ComparePanel } from "@/components/training/compare-panel"; +import { CuratorPortraitPanel } from "@/components/training/curator-portrait-panel"; +import { ChatPanel } from "@/components/training/chat-panel"; +import { TrainingUploadDialog } from "@/components/training/upload-dialog"; export default function TrainingPage() { + const [uploadOpen, setUploadOpen] = useState(false); + return (
-
- -

הפורטרט הסגנוני של דפנה

-

- לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת, - ביטויי חתימה, וכלי השוואה בין שתי החלטות. -

+
+
+ +

הפורטרט הסגנוני של דפנה

+

+ לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת, + ביטויי חתימה, וכלי השוואה בין שתי החלטות. +

+
+
+ +
@@ -34,6 +53,8 @@ export default function TrainingPage() { פורטרט סגנון קורפוס השוואה + הסוכן + שיחה @@ -47,6 +68,14 @@ export default function TrainingPage() { + + + + + + + + diff --git a/web-ui/src/components/training/chat-panel.tsx b/web-ui/src/components/training/chat-panel.tsx new file mode 100644 index 0000000..07c7903 --- /dev/null +++ b/web-ui/src/components/training/chat-panel.tsx @@ -0,0 +1,434 @@ +"use client"; + +/* + * Style-agent chat panel — the new "שיחה" tab on /training. + * + * Layout: two columns. + * - Sidebar: list of conversations + "+ שיחה חדשה" button + * - Main: thread of messages + composer with SSE streaming + * + * Each message is persisted to the legal-ai DB; the LLM call goes + * out via FastAPI → host's legal-chat-service → claude CLI. There + * is no API cost — the claude CLI uses Daphna's claude.ai + * subscription via the host's auth. + * + * Health gate: if /api/training/chat/health reports the host service + * is unreachable, the composer is replaced by a setup notice telling + * the chair to start the pm2 service. + */ + +import { useEffect, useRef, useState } from "react"; +import { + Send, Plus, Trash2, Loader2, MessageSquare, Sparkles, AlertTriangle, +} from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + chatKeys, + useChatConversation, + useChatConversations, + useChatHealth, + useCorpus, + useCreateChat, + useDeleteChat, + type ChatMessage, +} from "@/lib/api/training"; +import { useQueryClient } from "@tanstack/react-query"; + +export function ChatPanel() { + const [activeId, setActiveId] = useState(null); + const health = useChatHealth(); + + return ( +
+ +
+ {health.data && !health.data.reachable && ( + + )} + {activeId ? ( + + ) : ( + + + +

בחר שיחה קיימת או פתח חדשה כדי להתחיל לדבר עם סוכן הסגנון.

+

+ הסוכן רץ על claude CLI מקומי דרך legal-chat-service. אין עלות API. +

+
+
+ )} +
+
+ ); +} + +// ── Sidebar: list + new ──────────────────────────────────────────── + +function ConversationsSidebar({ + activeId, onSelect, +}: { + activeId: string | null; + onSelect: (id: string | null) => void; +}) { + const { data: convs, isPending } = useChatConversations(); + const { data: corpus } = useCorpus(); + const create = useCreateChat(); + const del = useDeleteChat(); + const [creating, setCreating] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newCorpusId, setNewCorpusId] = useState("__none__"); + + const onCreate = async () => { + try { + const conv = await create.mutateAsync({ + title: newTitle.trim() || "שיחה חדשה", + style_corpus_id: newCorpusId === "__none__" ? null : newCorpusId, + }); + onSelect(conv.id); + setCreating(false); + setNewTitle(""); + setNewCorpusId("__none__"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "כשל ביצירת שיחה"); + } + }; + + const onDelete = async (id: string) => { + if (!window.confirm("למחוק את השיחה? פעולה זו לא ניתנת לביטול.")) return; + try { + await del.mutateAsync(id); + if (activeId === id) onSelect(null); + toast.success("השיחה נמחקה"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "כשל במחיקה"); + } + }; + + return ( + + + {!creating ? ( + + ) : ( +
+