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:
176
web/chat_proxy.py
Normal file
176
web/chat_proxy.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""FastAPI ↔ legal-chat-service streaming bridge.
|
||||
|
||||
The browser hits ``/api/training/chat/conversations/{id}/messages`` on
|
||||
the legal-ai container. The container is sealed off from the host's
|
||||
``claude`` CLI (intentional — see ``claude_session.py`` docstring), so
|
||||
we forward each request to the pm2-managed ``legal-chat-service`` over
|
||||
loopback (``host.docker.internal:8770``).
|
||||
|
||||
Responsibilities:
|
||||
- Save the user message to ``chat_messages`` before streaming starts.
|
||||
- Open an HTTP streaming connection to the host service.
|
||||
- Forward each SSE event to the browser as-is, accumulating the
|
||||
assistant text and any ``session_id`` so we can persist them once
|
||||
the stream closes.
|
||||
- Persist the assistant turn + the CLI's session_id at end-of-stream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import AsyncIterator
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from legal_mcp.services import db
|
||||
from web import chat_system_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# legal-chat-service lives on the host. In the container we reach it via
|
||||
# host.docker.internal — which requires ``extra_hosts: host.docker.internal:host-gateway``
|
||||
# in the Coolify service definition. Set ``CHAT_SERVICE_URL`` to override
|
||||
# (handy for local dev outside Docker).
|
||||
CHAT_SERVICE_URL = os.environ.get(
|
||||
"CHAT_SERVICE_URL",
|
||||
"http://host.docker.internal:8770",
|
||||
)
|
||||
CHAT_SERVICE_TIMEOUT_S = float(os.environ.get("CHAT_SERVICE_TIMEOUT_S", "3600"))
|
||||
|
||||
|
||||
_SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
|
||||
|
||||
async def stream_chat_message(
|
||||
conversation_id: UUID,
|
||||
user_message: str,
|
||||
) -> StreamingResponse:
|
||||
"""Open SSE stream, forward events, persist when done.
|
||||
|
||||
Returns a FastAPI StreamingResponse the route can return directly.
|
||||
"""
|
||||
conv = await db.get_chat_conversation(conversation_id)
|
||||
if not conv:
|
||||
raise HTTPException(404, "conversation not found")
|
||||
|
||||
# Persist the user turn immediately so a network drop doesn't lose it.
|
||||
await db.add_chat_message(
|
||||
conversation_id, role="user", content=user_message,
|
||||
)
|
||||
|
||||
is_first_turn = not conv.get("claude_session_id")
|
||||
system_block: str | None = None
|
||||
if is_first_turn:
|
||||
try:
|
||||
system_block = await chat_system_prompt.build_system_prompt(
|
||||
corpus_id=conv.get("style_corpus_id"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("system prompt build failed")
|
||||
raise HTTPException(500, f"system prompt failed: {e}")
|
||||
|
||||
payload = {
|
||||
"prompt": user_message,
|
||||
"system": system_block,
|
||||
"resume_session_id": conv.get("claude_session_id"),
|
||||
}
|
||||
|
||||
async def proxy_stream() -> AsyncIterator[bytes]:
|
||||
accumulated_text: list[str] = []
|
||||
events_log: list[dict] = []
|
||||
new_session_id: str | None = None
|
||||
|
||||
try:
|
||||
timeout_cfg = httpx.Timeout(
|
||||
CHAT_SERVICE_TIMEOUT_S,
|
||||
connect=10.0,
|
||||
read=CHAT_SERVICE_TIMEOUT_S,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout_cfg) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{CHAT_SERVICE_URL}/chat/start",
|
||||
json=payload,
|
||||
) as upstream:
|
||||
if upstream.status_code != 200:
|
||||
body = await upstream.aread()
|
||||
msg = body.decode("utf-8", errors="replace")[:300]
|
||||
err = {"type": "error",
|
||||
"message": f"chat-service {upstream.status_code}: {msg}"}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n".encode("utf-8")
|
||||
return
|
||||
|
||||
async for line in upstream.aiter_lines():
|
||||
if not line:
|
||||
yield b"\n"
|
||||
continue
|
||||
# Forward verbatim so the browser sees the same
|
||||
# SSE framing the host emits.
|
||||
out = line + "\n"
|
||||
yield out.encode("utf-8")
|
||||
# Mirror events: capture text + session_id for
|
||||
# persistence. The line starts with "data: <json>"
|
||||
# so we strip the prefix before parsing.
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
event = json.loads(line[len("data: "):])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
events_log.append(event)
|
||||
t = event.get("type")
|
||||
if t == "session_id" and event.get("value"):
|
||||
new_session_id = event["value"]
|
||||
elif t == "text_delta" and event.get("text"):
|
||||
accumulated_text.append(event["text"])
|
||||
elif t == "done" and event.get("text"):
|
||||
if not accumulated_text:
|
||||
accumulated_text.append(event["text"])
|
||||
|
||||
except httpx.ConnectError:
|
||||
err = {
|
||||
"type": "error",
|
||||
"message": (
|
||||
f"לא ניתן להגיע ל-legal-chat-service בכתובת {CHAT_SERVICE_URL}. "
|
||||
"ודא ש-pm2 מריץ אותו: `pm2 status legal-chat-service`."
|
||||
),
|
||||
}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n".encode("utf-8")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.exception("chat proxy failed")
|
||||
err = {"type": "error", "message": str(e)}
|
||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n".encode("utf-8")
|
||||
return
|
||||
|
||||
# End of stream — persist the assistant turn.
|
||||
try:
|
||||
full_text = "".join(accumulated_text).strip()
|
||||
if full_text:
|
||||
await db.add_chat_message(
|
||||
conversation_id,
|
||||
role="assistant",
|
||||
content=full_text,
|
||||
raw_events=events_log,
|
||||
)
|
||||
if new_session_id:
|
||||
await db.update_chat_conversation_session_id(
|
||||
conversation_id, new_session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("failed to persist assistant turn for conv=%s", conversation_id)
|
||||
|
||||
return StreamingResponse(
|
||||
proxy_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers=_SSE_HEADERS,
|
||||
)
|
||||
Reference in New Issue
Block a user