feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
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:
144
mcp-server/src/legal_mcp/chat_service/server.py
Normal file
144
mcp-server/src/legal_mcp/chat_service/server.py
Normal 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())
|
||||
Reference in New Issue
Block a user