bardd's picture
Deploy Mike backend-only Space
06671aa verified
Raw History Blame Contribute Delete
17.4 kB
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Any, Optional
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
from pydantic import BaseModel
from auth import UserSession, current_user
from docx_comments import apply_docx_comments
from docx_text import extract_docx_paragraphs, render_review_text
from tabular_agent import run_chat, run_review, _new_trace, _end_trace
from inference.config import manifest_sha256, resolve_manifest_path
from models import ChatConversationDetail, PlaybookDetail, ReviewFinding
from playbooks import chat_role_brief, get_playbook, list_playbooks, update_playbook
from logger import setup_logger, current_trace_run
logger = setup_logger(__name__)
from storage import (
DATA_DIR,
get_conversation,
get_commented_docx_path,
get_run,
init_db,
list_conversations,
list_runs,
new_id,
reviewed_docx_name,
run_to_markdown,
save_chat_message,
save_workflow_run,
)
ROOT_DIR = Path(__file__).resolve().parent
UPLOAD_DIR = DATA_DIR / "uploads"
ARTIFACT_DIR = DATA_DIR / "artifacts"
LANGSMITH_PROJECT = os.environ.get("LANGSMITH_PROJECT", "legal-mike-oss")
COMMENTER_PROJECT = (
ROOT_DIR.parent / "tools" / "docx-commenter" / "docx-commenter.csproj"
)
app = FastAPI(title="Tabularrr Playbook Review Service")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class PlaybookUpdate(BaseModel):
yaml_text: str
class ReviewRunRequest(BaseModel):
document_bytes_b64: str
document_name: str
playbook_id: str = "buyer"
max_findings: int = 30
class ApplyCommentsRequest(BaseModel):
document_bytes_b64: str
document_name: str
findings: list[ReviewFinding]
track_changes: bool = False
class ChatRequest(BaseModel):
message: str
conversation_id: Optional[str] = None
playbook_id: str = "buyer"
model_id: Optional[str] = None
document_name: Optional[str] = None
document_text: Optional[str] = None
@app.on_event("startup")
def startup() -> None:
init_db()
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
def _sse(event: str, data: Any) -> str:
return f"data: {json.dumps({'event': event, 'data': data}, ensure_ascii=False)}\n\n"
def _safe_filename(name: str) -> str:
clean = "".join(ch if ch.isalnum() or ch in "._- " else "_" for ch in name).strip()
return clean or "document.docx"
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/inference-manifest")
def inference_manifest() -> dict[str, str]:
path = resolve_manifest_path()
if not path.exists():
raise HTTPException(status_code=500, detail="Inference manifest is unavailable.")
return {
"path": str(path),
"sha256": manifest_sha256(path),
}
@app.get("/api/playbooks")
def api_list_playbooks(user: UserSession = Depends(current_user)) -> dict[str, Any]:
return {"playbooks": [item.model_dump() for item in list_playbooks()]}
@app.get("/api/playbooks/{playbook_id}")
def api_get_playbook(
playbook_id: str, user: UserSession = Depends(current_user)
) -> PlaybookDetail:
return get_playbook(playbook_id)
@app.put("/api/playbooks/{playbook_id}")
def api_update_playbook(
playbook_id: str,
payload: PlaybookUpdate,
user: UserSession = Depends(current_user),
) -> PlaybookDetail:
return update_playbook(playbook_id, payload.yaml_text)
@app.get("/api/workflow-runs")
def api_list_runs(
limit: int = 50,
playbook_id: Optional[str] = None,
user: UserSession = Depends(current_user),
) -> dict[str, Any]:
return {
"runs": [
item.model_dump()
for item in list_runs(user.username, limit=limit, playbook_id=playbook_id)
]
}
@app.get("/api/workflow-runs/{run_id}")
def api_get_run(
run_id: str, user: UserSession = Depends(current_user)
) -> dict[str, Any]:
return get_run(run_id, user.username).model_dump()
@app.get("/api/workflow-runs/{run_id}/commented-docx")
def api_download_commented_docx(
run_id: str, user: UserSession = Depends(current_user)
) -> FileResponse:
detail = get_run(run_id, user.username)
path = get_commented_docx_path(run_id, user.username)
return FileResponse(
path,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
filename=detail.commented_docx_name or reviewed_docx_name(detail.document_name),
)
@app.get("/api/workflow-runs/{run_id}/markdown")
def api_download_markdown(
run_id: str, user: UserSession = Depends(current_user)
) -> PlainTextResponse:
return PlainTextResponse(
run_to_markdown(run_id, user.username),
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{run_id}.md"'},
)
@app.post("/api/workflow-runs")
async def api_create_workflow_run(
request: Request,
payload: ReviewRunRequest,
user: UserSession = Depends(current_user),
) -> StreamingResponse:
import base64
run_id = new_id("run")
run_dir = ARTIFACT_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
upload_dir = UPLOAD_DIR / run_id
upload_dir.mkdir(parents=True, exist_ok=True)
document_name = _safe_filename(payload.document_name)
upload_path = upload_dir / document_name
logger.info(f"Starting workflow run {run_id} for document '{document_name}' with playbook '{payload.playbook_id}'")
docx_bytes = base64.b64decode(payload.document_bytes_b64)
upload_path.write_bytes(docx_bytes)
playbook_id = payload.playbook_id
max_findings = max(1, min(100, int(payload.max_findings)))
async def generate():
trace = _new_trace(
name="workflow_run",
run_type="chain",
inputs={"document_name": document_name, "playbook_id": playbook_id, "max_findings": max_findings},
tags=["workflow", "api"],
trace_headers=request.headers,
)
token = current_trace_run.set(trace)
try:
yield _sse("status", "Reading DOCX and extracting non-table contract text.")
paragraphs = extract_docx_paragraphs(upload_path)
if not paragraphs:
raise HTTPException(
status_code=400,
detail="No non-table paragraph text was found in this DOCX.",
)
review_text = render_review_text(paragraphs)
document_text = "\n\n".join(para.text for para in paragraphs)
playbook = get_playbook(playbook_id)
yield _sse("status", f"Loaded {playbook.name}.")
yield _sse(
"status",
f"Sending {len(document_text):,} non-table characters to LLM for review.",
)
result_key = payload.playbook_id
result, usage, _ = await run_review(
review_text=review_text,
document_text=document_text,
document_name=document_name,
playbook_id=playbook.id,
playbook_name=playbook.name,
playbook_text=playbook.yaml_text,
max_findings=max_findings,
trace_headers=request.headers,
)
yield _sse(
"status", f"Validated {len(result.findings)} structured findings."
)
output_docx = run_dir / reviewed_docx_name(document_name)
yield _sse(
"status", "Inserting native Word comments into matched DOCX text."
)
apply_report = apply_docx_comments(
input_docx=upload_path,
output_docx=output_docx,
findings=result.findings,
work_dir=run_dir,
)
applied_count = sum(1 for item in apply_report if item.status == "applied")
yield _sse("status", f"Applied {applied_count} native Word comments.")
save_workflow_run(
run_id=run_id,
owner_username=user.username,
document_name=document_name,
original_docx_path=upload_path,
commented_docx_path=output_docx if output_docx.exists() else None,
document_text=document_text,
playbook_id=playbook.id,
playbook_name=playbook.name,
playbook_text=playbook.yaml_text,
max_findings=max_findings,
status="completed",
result=result,
apply_report=apply_report,
model=result.model,
usage=usage,
)
logger.info(f"Workflow run {run_id} completed successfully.")
_end_trace(trace, outputs={"run_id": run_id, "applied_count": applied_count})
yield _sse("complete", get_run(run_id, user.username).model_dump())
except HTTPException as exc:
logger.warning(f"HTTPException in workflow run {run_id}: {exc.detail}")
_end_trace(trace, error=str(exc.detail))
yield _sse("error", exc.detail)
except Exception as exc:
logger.error(f"Exception in workflow run {run_id}: {exc}", exc_info=True)
_end_trace(trace, error=str(exc))
yield _sse("error", str(exc))
finally:
current_trace_run.reset(token)
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/api/apply-comments")
async def api_apply_comments(
request: Request,
payload: ApplyCommentsRequest,
user: UserSession = Depends(current_user),
) -> dict[str, Any]:
import base64
run_id = new_id("comment")
run_dir = ARTIFACT_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
document_name = _safe_filename(payload.document_name)
logger.info(f"Applying comments to '{document_name}' in run {run_id}. Number of findings: {len(payload.findings)}")
trace = _new_trace(
name="apply_comments",
run_type="chain",
inputs={"document_name": document_name, "findings_count": len(payload.findings)},
tags=["apply", "api"],
trace_headers=request.headers,
)
token = current_trace_run.set(trace)
input_path = run_dir / f"input_{document_name}"
output_docx = run_dir / reviewed_docx_name(document_name)
docx_bytes = base64.b64decode(payload.document_bytes_b64)
input_path.write_bytes(docx_bytes)
try:
# Build sanitized inputs for LangSmith — no binary bytes, no credentials
safe_findings = []
for f in payload.findings:
d = f.model_dump() if hasattr(f, "model_dump") else dict(f)
safe = {
"id": d.get("id"),
"comment_key": d.get("comment_key"),
"model_block_id": d.get("model_block_id"),
"risk_level": d.get("risk_level"),
"clause_type": d.get("clause_type"),
"comment": d.get("comment"),
"target_text": d.get("target_text"),
"anchor_mode": d.get("anchor_mode"),
"start_paragraph_id": d.get("start_paragraph_id"),
"end_paragraph_id": d.get("end_paragraph_id"),
"stable_id": d.get("stable_id"),
"native_para_id": d.get("native_para_id"),
"text_hash": d.get("text_hash"),
"xml_path": d.get("xml_path"),
"op_type": d.get("op_type"),
"bookmark_name": d.get("bookmark_name"),
"section_heading": d.get("section_heading"),
}
safe_findings.append(safe)
apply_report = apply_docx_comments(
input_docx=input_path,
output_docx=output_docx,
findings=payload.findings,
work_dir=run_dir,
track_changes=payload.track_changes,
)
# Return the output bytes as B64 so the TS side can handle storage
output_bytes = output_docx.read_bytes() if output_docx.exists() else b""
out_b64 = base64.b64encode(output_bytes).decode("ascii")
applied = sum(1 for r in apply_report if r.status == "applied")
not_applied = sum(1 for r in apply_report if r.status != "applied")
failure_counts = {}
for r in apply_report:
if r.status != "applied" and r.failure_code:
failure_counts[r.failure_code] = failure_counts.get(r.failure_code, 0) + 1
finding_lookup = {d.get("id"): d for d in safe_findings}
enriched_apply_report = []
for item in apply_report:
entry = item.model_dump()
src = finding_lookup.get(item.id, {})
entry["op_type"] = entry.get("op_type") or src.get("op_type")
entry["bookmark_name"] = entry.get("bookmark_name") or src.get("bookmark_name")
entry["section_heading"] = entry.get("section_heading") or src.get("section_heading")
enriched_apply_report.append(entry)
_end_trace(trace, outputs={
"document_name": document_name,
"findings_count": len(payload.findings),
"applied": applied,
"not_applied": not_applied,
"failure_code_counts": failure_counts,
"apply_report": enriched_apply_report,
"sanitized_findings": safe_findings,
})
return {
"document_name": document_name,
"document_bytes_b64": out_b64,
"commented_bytes_b64": out_b64,
"apply_report": [item.model_dump() for item in apply_report],
}
except Exception as exc:
logger.error(f"Error applying comments: {exc}", exc_info=True)
_end_trace(trace, error=str(exc))
raise HTTPException(status_code=500, detail=str(exc))
finally:
current_trace_run.reset(token)
@app.post("/api/chat")
async def api_chat(
request: Request,
payload: ChatRequest,
user: UserSession = Depends(current_user),
) -> StreamingResponse:
document_name = payload.document_name
document_text = payload.document_text
playbook = get_playbook(payload.playbook_id)
logger.info(f"Starting chat. Conversation ID: {payload.conversation_id}, Playbook: {payload.playbook_id}, Document: {document_name}")
conv_id = save_chat_message(
owner_username=user.username,
conversation_id=payload.conversation_id,
role="user",
content=payload.message,
document_name=document_name,
)
async def generate():
trace = _new_trace(
name="chat_api",
run_type="chain",
inputs={"message": payload.message, "document_name": document_name, "playbook_id": payload.playbook_id},
tags=["chat", "api"],
trace_headers=request.headers,
)
token = current_trace_run.set(trace)
try:
if document_name:
yield _sse("status", f"Attached {document_name} to this chat.")
answer, _usage, _ = await run_chat(
message=payload.message,
document_text=document_text,
document_name=document_name,
playbook_id=playbook.id,
playbook_name=playbook.name,
role_brief=chat_role_brief(playbook.id),
model_id=payload.model_id,
trace_headers=request.headers,
)
words = answer.split(" ")
for index in range(0, len(words), 18):
yield _sse(
"delta",
" ".join(words[index : index + 18])
+ (" " if index + 18 < len(words) else ""),
)
save_chat_message(
owner_username=user.username,
conversation_id=conv_id,
role="assistant",
content=answer,
document_name=None,
)
logger.info("Chat completed successfully.")
_end_trace(trace, outputs={"answer_length": len(answer)})
yield _sse("complete", answer)
except Exception as exc:
logger.error(f"Chat error: {exc}", exc_info=True)
_end_trace(trace, error=str(exc))
yield _sse("error", str(exc))
finally:
current_trace_run.reset(token)
return StreamingResponse(generate(), media_type="text/event-stream")
@app.get("/api/chat/conversations")
def api_list_conversations(
limit: int = 20, user: UserSession = Depends(current_user)
) -> dict[str, Any]:
return {
"conversations": [
item.model_dump() for item in list_conversations(user.username, limit=limit)
]
}
@app.get("/api/chat/conversations/{conversation_id}")
def api_get_conversation(
conversation_id: str,
user: UserSession = Depends(current_user),
) -> ChatConversationDetail:
return get_conversation(conversation_id, user.username).model_dump()