feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s

Six-phase upgrade of /training from a read-only dashboard into a full
Style Studio for managing Daphna's style corpus.

- Upload Sheet on /training: file → proofread preview → commit (no more
  CLI-only `upload-training` skill).
- Rich corpus metadata: GET /api/training/corpus returns summary, outcome,
  key_principles, page_count, parties (regex), legal_citation, lessons_count.
  PATCH endpoint for chair edits. CorpusDetailDrawer with 4 tabs (details
  /content/lessons/patterns) replaces the bare table row.
- LLM metadata enrichment: style_metadata_extractor + MCP tools
  (style_corpus_enrich, style_corpus_pending_enrichment) fill summary
  /outcome/key_principles via claude_session (free, host-side).
- Per-decision lessons: new decision_lessons table + 4 REST endpoints +
  LessonsTab in drawer; hermes-curator now auto-posts findings as
  decision_lessons(source=curator).
- Curator Portrait tab: prompt rendered with link to Gitea, recent
  curator findings, style_analyzer training prompts, propose-change
  form that writes proposals to data/curator-proposals/ for manual
  chair review (no auto-mutation of the agent file).
- Style chat tab: SSE-streamed conversations with the style agent.
  New host-side pm2 service (legal-chat-service, port 8770) wraps
  claude CLI with stream-json + --resume continuation; FastAPI proxies
  via host.docker.internal. Zero API cost — uses chaim's claude.ai
  subscription. chat_conversations + chat_messages persist history.

Architecture: keeps the existing rule that claude_session only runs
on the host (not the container). The new legal-chat-service is the
canonical bridge between the container and the local CLI for the chat
feature; everything else (upload, metadata, lessons) stays within the
container's existing capabilities.

Audit script (scripts/audit_training_corpus.py) included for verifying
which corpus rows still need enrichment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 10:06:22 +00:00
parent 0629f19d5f
commit bb0cd7c6a2
23 changed files with 4568 additions and 75 deletions

View File

@@ -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())