1 Commits

Author SHA1 Message Date
wangwei
06e0967128 add 2026-06-05 09:00:36 +08:00
13 changed files with 4560 additions and 239 deletions

View File

@@ -5,17 +5,19 @@ from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import AsyncGenerator
from typing import AsyncGenerator, Optional
from fastapi import APIRouter, File, UploadFile
from fastapi import APIRouter, File, Form, UploadFile
from fastapi.responses import StreamingResponse
from loguru import logger
from app.schemas.compliance import (
AnalyzeResponse,
ComplianceChatRequest,
)
from app.services.mock_data import generate_task_id, get_mock_compliance_result
from app.shared.bootstrap import get_agent_conversation_service
from app.shared.bootstrap import get_agent_conversation_service, get_retrieval_service
from app.config.settings import settings
router = APIRouter(prefix="/compliance", tags=["合规分析"])
@@ -62,6 +64,128 @@ async def get_result(task_id: str):
return task["result"]
def _sse(data: dict) -> str:
return f"event: message\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@router.post("/analyze-stream")
async def analyze_stream(
text: Optional[str] = Form(None),
doc_id: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
domains: Optional[str] = Form(None),
title: Optional[str] = Form(None),
):
"""Stream compliance analysis as SSE events.
Stages: clause_split → retrieval (per clause) → gap_check → conclusion
Events: stage | source | finding | done | error
"""
from app.application.compliance.pipeline import (
check_clause_compliance,
extract_text_from_doc_id,
extract_text_from_file,
retrieve_for_clause,
split_into_clauses,
synthesize_conclusion,
)
from app.services.llm.llm_factory import get_llm_client
from app.shared.bootstrap import get_retrieval_service
# Read file content eagerly (before async generator)
file_content: bytes | None = None
file_name: str | None = None
if file is not None:
file_content = await file.read()
file_name = file.filename
async def generate() -> AsyncGenerator[str, None]:
try:
client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
retrieval_service = get_retrieval_service()
# ── Stage 1: extract text ─────────────────────────────────────
yield _sse({"type": "stage", "stage": "extracting", "label": "Extracting text…"})
await asyncio.sleep(0)
if text:
para_text = text.strip()
elif doc_id:
try:
para_text = await asyncio.to_thread(extract_text_from_doc_id, doc_id)
except Exception as exc:
yield _sse({"type": "error", "text": f"Document not found: {exc}"})
return
elif file_content is not None:
para_text = await asyncio.to_thread(
extract_text_from_file, file_content, file_name or "upload"
)
else:
yield _sse({"type": "error", "text": "No input provided"})
return
if not para_text.strip():
yield _sse({"type": "error", "text": "Could not extract text from the provided input"})
return
# ── Stage 2: split into clauses ───────────────────────────────
yield _sse({"type": "stage", "stage": "splitting", "label": "Splitting into clauses…"})
await asyncio.sleep(0)
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
# ── Stage 3: retrieve + gap check per clause ──────────────────
findings: list[dict] = []
for i, clause in enumerate(clauses):
yield _sse({
"type": "stage",
"stage": "analyzing",
"label": f"Analyzing clause {i + 1}/{len(clauses)}",
})
await asyncio.sleep(0)
chunks = await asyncio.to_thread(
retrieve_for_clause, clause, retrieval_service, 5, domains or None
)
# Emit source events
for chunk in chunks[:3]:
yield _sse({
"type": "source",
"standard": getattr(chunk, "doc_title", "") or getattr(chunk, "doc_name", ""),
"clause": getattr(chunk, "section_title", "") or "",
"score": round(float(getattr(chunk, "score", 0)), 3),
"status": "retrieved",
"full_content": (getattr(chunk, "text", "") or "")[:300],
})
await asyncio.sleep(0)
finding = await asyncio.to_thread(check_clause_compliance, clause, chunks, client)
if finding:
findings.append(finding)
yield _sse({"type": "finding", **finding})
await asyncio.sleep(0)
# ── Stage 4: synthesize conclusion ────────────────────────────
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
await asyncio.sleep(0)
conclusion_data = await asyncio.to_thread(
synthesize_conclusion, para_text, findings, client
)
yield _sse({"type": "done", **conclusion_data})
except Exception as exc:
logger.exception("analyze-stream pipeline error")
yield _sse({"type": "error", "text": str(exc)})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@router.post("/chat/{segment_id}")
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
"""Stream compliance Q&A grounded in real vector retrieval."""

View File

@@ -0,0 +1 @@
"""Compliance application layer."""

View File

@@ -0,0 +1,215 @@
"""Compliance analysis pipeline helpers.
All functions are synchronous — call them via asyncio.to_thread() in async SSE generators.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
from app.application.knowledge import KnowledgeRetrievalService
from app.domain.retrieval import RetrievedChunk
from app.services.llm.base_client import BaseLLMClient
def _extract_json(text: str):
"""Extract JSON from LLM response, tolerating markdown wrappers."""
stripped = text.strip()
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", stripped)
if match:
stripped = match.group(1).strip()
try:
return json.loads(stripped)
except json.JSONDecodeError:
pass
for pattern in (r"(\[[\s\S]*\])", r"(\{[\s\S]*\})"):
m = re.search(pattern, stripped)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
continue
raise ValueError(f"No valid JSON found in LLM response: {text[:300]}")
def extract_text_from_doc_id(doc_id: str) -> str:
from app.shared.bootstrap import get_document_query_service, get_retrieval_service
doc = get_document_query_service().get(doc_id)
if not doc:
raise ValueError(f"Document '{doc_id}' not found")
service = get_retrieval_service()
chunks = service.retrieve(query=doc.doc_name, top_k=30)
doc_chunks = [c for c in chunks if c.doc_id == doc_id]
if not doc_chunks:
doc_chunks = chunks[:15]
return "\n\n".join(c.text for c in doc_chunks[:15])
def extract_text_from_file(content: bytes, filename: str) -> str:
from app.shared.bootstrap import get_document_command_service
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
tmp_path = ""
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
service = get_document_command_service()
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
if parsed.raw_text:
return parsed.raw_text[:4000]
return "\n".join(
b.get("text", "") for b in parsed.semantic_blocks[:30] if b.get("text")
)[:4000]
except Exception as exc:
logger.warning("File text extraction failed: {}", exc)
return ""
finally:
if tmp_path:
try: os.unlink(tmp_path)
except OSError: pass
def split_into_clauses(text: str, client: "BaseLLMClient") -> list[str]:
prompt = (
"You are a compliance analysis expert. Split the following text into 3-8 "
"semantically complete compliance clauses. Each clause should be an independent "
"compliance requirement or technical statement.\n"
"Return as JSON array of strings, e.g.:\n"
'["Clause one...", "Clause two..."]\n'
"Return ONLY the JSON array.\n\n"
f"Text:\n{text[:2000]}"
)
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1000)
if response.is_success:
try:
result = _extract_json(response.content)
if isinstance(result, list):
clauses = [str(c).strip() for c in result if str(c).strip()]
if clauses:
return clauses[:8]
except (ValueError, TypeError):
logger.warning("Clause split JSON parse failed, using fallback")
sentences = re.split(r"[.?!;\n]+", text)
return [s.strip() for s in sentences if len(s.strip()) > 20][:6]
def retrieve_for_clause(
clause: str,
retrieval_service: "KnowledgeRetrievalService",
top_k: int = 5,
domains: str | None = None,
) -> list["RetrievedChunk"]:
return retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
def check_clause_compliance(
clause: str,
chunks: list["RetrievedChunk"],
client: "BaseLLMClient",
) -> dict | None:
if not chunks:
return None
reg_context = "\n".join(
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
for i, c in enumerate(chunks[:5])
)
prompt = (
"You are a compliance expert. Judge whether the following business clause "
"complies with the retrieved regulations.\n\n"
f"Business clause:\n{clause}\n\n"
f"Retrieved regulations:\n{reg_context}\n\n"
"Return JSON:\n"
"{\n"
' "status": "ok" | "warn" | "risk",\n'
' "title": "Short finding title (max 30 chars)",\n'
' "desc": "Description (50-120 chars)",\n'
' "clause_ref": "Regulation clause reference e.g. Art.9.1 or Sec.3.1"\n'
"}\n"
"status: ok=compliant, warn=gap exists, risk=critical/missing\n"
"Return ONLY the JSON object."
)
response = client.chat([{"role": "user", "content": prompt}], max_tokens=500)
if not response.is_success:
return None
try:
result = _extract_json(response.content)
if isinstance(result, dict) and "status" in result:
return {
"title": str(result.get("title", "Compliance finding")),
"desc": str(result.get("desc", "")),
"status": result.get("status", "info"),
"clause_ref": result.get("clause_ref"),
}
except (ValueError, TypeError) as exc:
logger.warning("Gap check JSON parse failed: {}", exc)
return None
def synthesize_conclusion(
para_text: str,
findings: list[dict],
client: "BaseLLMClient",
) -> dict:
if not findings:
return {
"conclusion": "No significant compliance gaps found. Continue monitoring regulation updates.",
"actions": [{"label": "Next action", "value": "Monitor regulation updates"}],
"risk_score": 10,
"highlight_terms": [],
"para_text": para_text[:800],
}
findings_text = "\n".join(
f"- [{f['status'].upper()}] {f['title']}: {f['desc']}"
for f in findings
)
prompt = (
"You are a compliance analysis expert. Generate a summary report "
"based on the following compliance findings.\n\n"
f"Original text (first 600 chars):\n{para_text[:600]}\n\n"
f"Findings:\n{findings_text}\n\n"
"Return JSON:\n"
"{\n"
' "conclusion": "Overall compliance conclusion (100-200 chars)",\n'
' "actions": [\n'
' {"label": "Action label", "value": "Description"},\n'
' {"label": "Priority", "value": "High/Medium/Low", "risk": true}\n'
' ],\n'
' "risk_score": 0-100 (integer, higher=riskier),\n'
' "highlight_terms": ["Key terms to highlight, max 10 terms"],\n'
' "para_text": "Original text or summary (max 600 chars)"\n'
"}\n"
"Return ONLY the JSON object."
)
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1200)
fallback = {
"conclusion": "Compliance analysis complete. Review findings and create remediation plan.",
"actions": [
{"label": "Next action", "value": "Review critical findings"},
{"label": "Escalation", "value": "Legal review required", "risk": True},
],
"risk_score": 60,
"highlight_terms": [],
"para_text": para_text[:800],
}
if not response.is_success:
return fallback
try:
result = _extract_json(response.content)
if isinstance(result, dict):
return {
"conclusion": str(result.get("conclusion", fallback["conclusion"])),
"actions": result.get("actions", fallback["actions"]),
"risk_score": int(result.get("risk_score", 60)),
"highlight_terms": result.get("highlight_terms", []),
"para_text": str(result.get("para_text", para_text[:800])),
}
except (ValueError, TypeError) as exc:
logger.warning("Conclusion synthesis JSON parse failed: {}", exc)
return fallback

View File

@@ -81,3 +81,29 @@ class AnalyzeResponse(BaseModel):
"""Define the Analyze Response API model."""
task_id: str
status: str = "processing"
class AnalyzeStreamSource(BaseModel):
"""SSE source event payload for analyze-stream."""
standard: str
clause: str
score: float
status: str
full_content: str
class AnalyzeStreamFinding(BaseModel):
"""SSE finding event payload for analyze-stream."""
title: str
desc: str
status: str
clause_ref: Optional[str] = None
class AnalyzeStreamDone(BaseModel):
"""SSE done event payload for analyze-stream."""
conclusion: str
actions: list[dict]
risk_score: int
highlight_terms: list[str]
para_text: str

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,182 @@
import { useState, useRef } from 'react';
import { Search, Plus, AlertTriangle, Download, MessageSquare, ChevronDown } from 'lucide-react';
import { Topbar } from '../../components/layout/Topbar';
import { Search, Plus } from 'lucide-react';
import { NewAnalysisModal } from './NewAnalysisModal';
import { useComplianceAnalysis } from './useComplianceAnalysis';
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
const SOURCES = [
{ standard: 'EU AI Act', helper: 'Art. 9 — Risk management', scores: ['Art. 9.1', 'Art. 9.2'], status: 'risk' },
{ standard: 'MIIT Draft 2025-08', helper: '§3 — Training data provenance', scores: ['§3.1', '§3.4'], status: 'warn' },
{ standard: 'ISO/SAE 21434:2021', helper: 'Clause 9 — CSMS', scores: ['9.3', '9.4'], status: 'ok' },
];
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical', info: 'Info' };
const SOURCE_TYPE_LABEL: Record<string, string> = { text: 'Pasted Text', doc: 'Indexed Document', upload: 'Uploaded File' };
const STAGES = [
{ label: 'Clause retrieval', pct: 100, status: 'ok' },
{ label: 'Requirement extraction', pct: 100, status: 'ok' },
{ label: 'Gap analysis', pct: 78, status: 'warn' },
{ label: 'Recommendation synthesis', pct: 30, status: 'info' },
];
function riskClass(score: number) {
if (score >= 70) return 'high';
if (score >= 40) return 'med';
return 'low';
}
const FINDINGS = [
{ title: 'Missing risk management documentation', desc: 'No formal risk management system found for the described AI system scope under Art. 9.', status: 'risk' },
{ title: 'Training data lineage incomplete', desc: 'MIIT §3.1 requires traceable provenance for training datasets. Current documentation lacks data source registry.', status: 'warn' },
{ title: 'CSMS audit trail present', desc: 'ISO 21434 audit log requirements are met. Retention policy documented in Annex B.', status: 'ok' },
];
function highlightText(text: string, terms: string[]): React.ReactNode[] {
if (!terms.length) return [text];
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(${escaped.join('|')})`, 'i');
const patternGlobal = new RegExp(`(${escaped.join('|')})`, 'gi');
return text.split(patternGlobal).map((part, i) =>
pattern.test(part)
? <mark key={i} className="comp-highlight">{part}</mark>
: <span key={i}>{part}</span>
);
}
const PARA = `The AI system described in Section 4.2.1 of the Vehicle AI Safety Manual performs real-time classification of driving scenarios to support Level 3 automated driving decisions. The system ingests sensor fusion data from cameras, LIDAR, and radar arrays, processes it through a deep neural network trained on 2.4M annotated driving scenarios, and outputs driving mode recommendations with associated confidence scores. The model was trained using data collected between 2022 and 2024 across European and Chinese road environments.`;
function formatTs(iso: string) {
try {
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch { return iso; }
}
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical' };
// ── Chat state for a single finding ─────────────────────────────────────────
interface ChatMsg { id: number; role: 'user' | 'assistant'; content: string }
function useFindingChat() {
const [open, setOpen] = useState(false);
const [findingIdx, setFindingIdx] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
function openFor(idx: number, finding: FindingEvent) {
setFindingIdx(idx);
setOpen(true);
setMessages([{
id: 0,
role: 'assistant',
content: `I'm reviewing finding: **${finding.title}**\n\n${finding.desc}${finding.clause_ref ? `\n\nRef: ${finding.clause_ref}` : ''}\n\nHow can I help?`,
}]);
setInput('');
}
function close() { setOpen(false); abortRef.current?.abort(); }
async function send(segmentContext: string) {
if (!input.trim() || loading) return;
const q = input.trim();
setInput('');
const userMsg: ChatMsg = { id: Date.now(), role: 'user', content: q };
const assistantId = Date.now() + 1;
setMessages(m => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
setLoading(true);
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const res = await fetch(`/api/v1/compliance/chat/${findingIdx ?? 0}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: q, segment_context: segmentContext }),
signal: ctrl.signal,
});
if (!res.body) { setLoading(false); return; }
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const blocks = buf.split('\n\n');
buf = blocks.pop() ?? '';
for (const block of blocks) {
const dl = block.split('\n').find(l => l.startsWith('data: '));
if (!dl) continue;
try {
const j = JSON.parse(dl.slice(6));
if (j.type === 'chunk' && j.text) {
setMessages(m => m.map(msg => msg.id === assistantId ? { ...msg, content: msg.content + j.text } : msg));
}
} catch { /* skip */ }
}
}
} catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') return;
} finally {
setLoading(false);
}
}
return { open, findingIdx, messages, input, setInput, loading, openFor, close, send };
}
export function CompliancePage() {
const [showModal, setShowModal] = useState(false);
const [showExportMenu, setShowExportMenu] = useState(false);
const { state, run, reset } = useComplianceAnalysis();
const chat = useFindingChat();
const isIdle = state.status === 'idle';
const isStreaming = state.status === 'streaming';
const isDone = state.status === 'done';
const isError = state.status === 'error';
// ── Export helpers ────────────────────────────────────────────────────────
function exportJSON() {
const data = {
title: state.meta?.title,
sourceType: state.meta?.sourceType,
startedAt: state.meta?.startedAt,
sources: state.sources,
findings: state.findings,
conclusion: state.done?.conclusion,
actions: state.done?.actions,
risk_score: state.done?.risk_score,
highlight_terms: state.done?.highlight_terms,
para_text: state.done?.para_text,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `compliance-report-${Date.now()}.json`; a.click();
URL.revokeObjectURL(url);
setShowExportMenu(false);
}
function exportText() {
const lines: string[] = [
`COMPLIANCE ANALYSIS REPORT`,
`Title: ${state.meta?.title ?? 'Untitled'}`,
`Date: ${state.meta?.startedAt ? formatTs(state.meta.startedAt) : ''}`,
`Source: ${SOURCE_TYPE_LABEL[state.meta?.sourceType ?? 'text']}`,
`Risk Score: ${state.done?.risk_score ?? 'N/A'} / 100`,
'',
'── PARAGRAPH UNDER REVIEW ──',
state.done?.para_text ?? '',
'',
'── FINDINGS ──',
...state.findings.map((f, i) =>
`[${i + 1}] [${f.status.toUpperCase()}] ${f.title}\n ${f.desc}${f.clause_ref ? `\n Ref: ${f.clause_ref}` : ''}`
),
'',
'── CONCLUSION ──',
state.done?.conclusion ?? '',
'',
'── RECOMMENDED ACTIONS ──',
...(state.done?.actions ?? []).map(a => `${a.label}: ${a.value}`),
];
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `compliance-report-${Date.now()}.txt`; a.click();
URL.revokeObjectURL(url);
setShowExportMenu(false);
}
// ── Chat context (finding desc + clause_ref as segment context) ──────────
const activeFinding = chat.findingIdx !== null ? state.findings[chat.findingIdx] : null;
const chatContext = activeFinding
? `Finding: ${activeFinding.title}\n${activeFinding.desc}${activeFinding.clause_ref ? `\nRef: ${activeFinding.clause_ref}` : ''}`
: '';
return (
<div className="compliance-page">
<div className="compliance-page" style={{ position: 'relative' }}>
<Topbar
title="Compliance Analysis"
actions={
@@ -35,92 +185,331 @@ export function CompliancePage() {
<Search size={13} />
<input placeholder="Search analyses..." />
</div>
<button className="btn sm primary"><Plus size={13} />New analysis</button>
{isStreaming || isDone || isError ? (
<button className="btn sm" onClick={reset}>Clear</button>
) : null}
{isDone && (
<div style={{ position: 'relative' }}>
<button
className="btn sm"
onClick={() => setShowExportMenu(v => !v)}
>
<Download size={13} />Export<ChevronDown size={11} />
</button>
{showExportMenu && (
<div style={{
position: 'absolute', right: 0, top: '100%', marginTop: 4,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 6px 20px rgba(0,0,0,.15)',
zIndex: 50, minWidth: 140, overflow: 'hidden',
}}>
<button onClick={exportJSON} style={{ display: 'block', width: '100%', padding: '9px 14px', textAlign: 'left', fontSize: 13, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>Export JSON</button>
<button onClick={exportText} style={{ display: 'block', width: '100%', padding: '9px 14px', textAlign: 'left', fontSize: 13, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>Export Text</button>
</div>
)}
</div>
)}
<button className="btn sm primary" onClick={() => setShowModal(true)}>
<Plus size={13} />New analysis
</button>
</>
}
/>
<div className="compliance-hero">
<p className="hero-eyebrow">Compliance Workspace</p>
<h2 className="compliance-title">Document Paragraph Review</h2>
<p className="compliance-desc">
Three-column AI-assisted compliance gap analysis with regulation retrieval, paragraph review, and findings synthesis.
</p>
</div>
{showModal && (
<NewAnalysisModal
onClose={() => setShowModal(false)}
onSubmit={(fd, meta) => run(fd, meta)}
/>
)}
<div className="compliance-workspace">
<div className="comp-col source-col">
<div className="col-header">Retrieved Regulations</div>
{SOURCES.map(s => (
<div key={s.standard} className="source-item card">
<div className="source-top">
<span className="source-std">{s.standard}</span>
<span className={`status ${s.status}`}>{STATUS_LABEL[s.status]}</span>
</div>
<div className="source-helper">{s.helper}</div>
<div className="source-scores">
{s.scores.map(sc => <span key={sc} className="score-pill">{sc}</span>)}
</div>
</div>
))}
{/* Status bar */}
{(isStreaming || isDone || isError) && (
<div style={{ padding: '0 24px' }}>
<div className={`compliance-status-bar ${state.status}`}>
<div className="status-dot" />
<span className="status-bar-label">
{isStreaming ? 'Analyzing…' : isDone ? 'Analysis complete' : 'Error'}
</span>
<span className="status-bar-sub">{state.stageLabel}</span>
</div>
</div>
)}
<div className="comp-col review-col">
<div className="col-header">Paragraph Under Review</div>
<div className="card para-card">
<p className="para-text">
{PARA.split(/(AI system)/g).map((part, i) =>
part === 'AI system'
? <mark key={i}>{part}</mark>
: <span key={i}>{part}</span>
{/* Empty state */}
{isIdle && (
<div className="analysis-empty">
<div className="analysis-empty-icon"><Plus size={24} /></div>
<h3>No analysis running</h3>
<p>Click <strong>New analysis</strong> to start a compliance gap review against your indexed regulations.</p>
</div>
)}
{/* Workspace */}
{!isIdle && (
<>
{/* Analysis Header */}
{state.meta && (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 24px', borderBottom: '1px solid var(--border)',
fontSize: 13,
}}>
<span style={{ fontWeight: 600, color: 'var(--fg)' }}>{state.meta.title}</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>·</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>{SOURCE_TYPE_LABEL[state.meta.sourceType]}</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>·</span>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>{formatTs(state.meta.startedAt)}</span>
{isDone && state.done && (
<>
<span style={{ color: 'var(--muted)', fontSize: 11 }}>·</span>
<span className={`risk-score-badge ${riskClass(state.done.risk_score)}`}
style={{ width: 28, height: 28, fontSize: 11 }}
title="Risk score">
{state.done.risk_score}
</span>
</>
)}
</p>
</div>
<div className="card stages-card">
<div className="card-header">Analysis stages</div>
{STAGES.map(st => (
<div key={st.label} className="stage-row">
<div className="stage-label-row">
<span className="stage-label">{st.label}</span>
<span className="stage-pct">{st.pct}%</span>
</div>
<div className="stage-bar">
<div className={`stage-fill stage-${st.status}`} style={{ width: `${st.pct}%` }} />
</div>
</div>
))}
</div>
</div>
</div>
)}
<div className="comp-col findings-col">
<div className="col-header">Findings</div>
{FINDINGS.map(f => (
<div key={f.title} className="finding-item card">
<div className="finding-top">
<span className="finding-title">{f.title}</span>
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
<div className="compliance-workspace" style={{ position: 'relative' }}>
{/* Column 1: Retrieved Regulations */}
<div className="comp-col source-col">
<div className="col-header">
Retrieved Regulations {state.sources.length > 0 && `(${state.sources.length})`}
</div>
<p className="finding-desc">{f.desc}</p>
{state.sources.length === 0 && isStreaming && (
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
Retrieving relevant regulations
</div>
)}
{state.sources.map((s: SourceEvent, i: number) => (
<div key={i} className="source-item card">
<div className="source-top">
<span className="source-std">{s.standard || 'Regulation'}</span>
<span className={`status ${s.status === 'retrieved' ? 'ok' : s.status}`}>
{STATUS_LABEL[s.status] ?? 'Retrieved'}
</span>
</div>
{s.clause && <div className="source-helper">{s.clause}</div>}
{s.score > 0 && (
<div className="source-scores">
<span className="score-pill">
{s.score <= 1 ? Math.round(s.score * 100) : Math.round(s.score)}% match
</span>
</div>
)}
{s.full_content && (
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 6, lineHeight: 1.5 }}>
{s.full_content.slice(0, 120)}
</div>
)}
</div>
))}
</div>
))}
<div className="card conclusion-box">
<div className="card-header">Conclusion</div>
<p className="conclusion-text">
The document requires a formal risk management section documenting the AI system classification, risk identification methodology, and mitigation measures per EU AI Act Art. 9 before compliance can be certified.
</p>
<div className="action-items">
<div className="action-item">
<span className="action-label">Next action</span>
<span className="action-value">Draft risk management annex</span>
{/* Column 2: Paragraph Under Review + Stages */}
<div className="comp-col review-col">
<div className="col-header">Paragraph Under Review</div>
<div className="card para-card">
{isDone && state.done?.para_text ? (
<p className="para-text">
{highlightText(state.done.para_text, state.done.highlight_terms ?? [])}
</p>
) : (
<p className="para-text" style={{ color: 'var(--muted)' }}>
{isStreaming ? 'Extracting and analyzing text…' : 'No text extracted'}
</p>
)}
</div>
<div className="action-item">
<span className="action-label">Escalation</span>
<span className="action-value risk-text">Legal review required</span>
<div className="card stages-card">
<div className="card-header">Analysis stages</div>
{(() => {
const STAGE_KEYS = ['extracting', 'splitting', 'analyzing', 'concluding'];
const STAGE_LABELS = ['Text extraction', 'Clause splitting', 'Regulation retrieval', 'Conclusion synthesis'];
const curIdx = STAGE_KEYS.indexOf(state.stageKey);
return STAGE_KEYS.map((key, idx) => {
const pct = isDone ? 100 : idx < curIdx ? 100 : idx === curIdx ? 60 : 0;
const stStatus = pct === 100 ? 'ok' : pct > 0 ? 'running' : 'info';
return (
<div key={key} className={`stage-row${stStatus === 'running' ? ' stage-running' : ''}`}>
<div className="stage-label-row">
<span className="stage-label">{STAGE_LABELS[idx]}</span>
<span className="stage-pct">{pct}%</span>
</div>
<div className="stage-bar">
<div className={`stage-fill stage-${stStatus}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
});
})()}
</div>
</div>
{/* Column 3: Findings + Conclusion */}
<div className="comp-col findings-col">
<div className="col-header">
Findings {state.findings.length > 0 && `(${state.findings.length})`}
</div>
{state.findings.length === 0 && isStreaming && (
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
Gap analysis in progress
</div>
)}
{state.findings.map((f: FindingEvent, i: number) => (
<div key={i} className="finding-item card">
<div className="finding-top">
<span className="finding-title">{f.title}</span>
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
</div>
<p className="finding-desc">{f.desc}</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
{f.clause_ref && (
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Ref: {f.clause_ref}</div>
)}
<button
className="btn sm"
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
onClick={() => chat.openFor(i, f)}
>
<MessageSquare size={11} />Ask AI
</button>
</div>
</div>
))}
{/* Conclusion */}
{isDone && state.done && (
<div className="card conclusion-box">
<div className="card-header" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span>Conclusion</span>
<div
className={`risk-score-badge ${riskClass(state.done.risk_score)}`}
title="Risk score (0=safe, 100=critical)"
>
{state.done.risk_score}
</div>
</div>
<div className="risk-meter">
<span style={{ fontSize: 11, color: 'var(--muted)', width: 24 }}>0</span>
<div className="risk-bar-track">
<div className="risk-bar-fill" style={{ width: `${state.done.risk_score}%` }} />
</div>
<span style={{ fontSize: 11, color: 'var(--muted)', width: 24, textAlign: 'right' }}>100</span>
</div>
<p className="conclusion-text">{state.done.conclusion}</p>
<div className="action-items">
{state.done.actions.map((a, i) => (
<div key={i} className="action-item">
<span className="action-label">{a.label}</span>
<span className={`action-value${a.risk ? ' risk-text' : ''}`}>{a.value}</span>
</div>
))}
</div>
</div>
)}
{isError && (
<div className="card" style={{ borderColor: 'var(--danger)', padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontSize: 13, fontWeight: 600 }}>
<AlertTriangle size={14} /> Analysis failed
</div>
<p style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>{state.errorText}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
{chat.open && (
<div style={{
position: 'fixed', right: 0, top: 0, bottom: 0, width: 400,
background: 'var(--surface)', borderLeft: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', zIndex: 200,
boxShadow: '-8px 0 32px rgba(0,0,0,.12)',
}}>
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>AI Compliance Q&A</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
Finding #{(chat.findingIdx ?? 0) + 1} · {activeFinding?.title}
</div>
</div>
<button
onClick={chat.close}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', padding: 4 }}
></button>
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{chat.messages.map(msg => (
<div key={msg.id} style={{ display: 'flex', gap: 10, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
{msg.role === 'assistant' && (
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
)}
<div style={{
maxWidth: '82%', padding: '10px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap',
background: msg.role === 'user' ? 'var(--accent)' : 'var(--bg)',
color: msg.role === 'user' ? '#fff' : 'var(--fg)',
border: msg.role === 'assistant' ? '1px solid var(--border)' : 'none',
}}>{msg.content}</div>
</div>
))}
{chat.loading && (
<div style={{ display: 'flex', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: 8, background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 11, color: '#fff', fontWeight: 700 }}>AI</div>
<div style={{ padding: '10px 14px', borderRadius: 10, border: '1px solid var(--border)', background: 'var(--bg)', fontSize: 13, color: 'var(--muted)' }}>
Thinking<span className="blink-cursor"></span>
</div>
</div>
)}
</div>
{/* Quick questions */}
<div style={{ padding: '8px 20px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{['What regulation applies?', 'How to remediate?', 'What is the risk?'].map(q => (
<button key={q} onClick={() => chat.setInput(q)}
style={{ padding: '4px 10px', fontSize: 11, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', color: 'var(--muted)' }}>
{q}
</button>
))}
</div>
{/* Input */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
value={chat.input}
onChange={e => chat.setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chat.send(chatContext); } }}
placeholder="Ask about this finding…"
style={{ flex: 1, padding: '9px 12px', fontSize: 13, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--fg)', outline: 'none' }}
/>
<button
className="btn primary"
onClick={() => chat.send(chatContext)}
disabled={!chat.input.trim() || chat.loading}
style={{ padding: '9px 14px' }}
>Send</button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { useState, useRef, useEffect } from 'react';
import { X, Upload, FileText, Database } from 'lucide-react';
interface DocOption {
id: string;
name: string;
type?: string;
}
import type { AnalysisMeta } from './useComplianceAnalysis';
interface Props {
onClose: () => void;
onSubmit: (formData: FormData, meta: AnalysisMeta) => void;
}
const DOMAINS = ['EU AI Act', 'MIIT', 'ISO 21434', 'GDPR', 'NIST AI RMF', 'GB/T'];
export function NewAnalysisModal({ onClose, onSubmit }: Props) {
const [tab, setTab] = useState<'text' | 'doc' | 'upload'>('text');
const [text, setText] = useState('');
const [title, setTitle] = useState('');
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [docs, setDocs] = useState<DocOption[]>([]);
const [file, setFile] = useState<File | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
// Fetch indexed docs for "From Document" tab
useEffect(() => {
fetch('/api/v1/documents/management-list')
.then(r => r.json())
.then(d => {
const list: DocOption[] = (d?.documents ?? d ?? []).map((item: Record<string, unknown>) => ({
id: String(item.doc_id ?? item.id ?? ''),
name: String(item.doc_name ?? item.name ?? ''),
type: String(item.regulation_type ?? item.type ?? ''),
}));
setDocs(list);
})
.catch(() => setDocs([]));
}, []);
function toggleDomain(d: string) {
setSelectedDomains(prev =>
prev.includes(d) ? prev.filter(x => x !== d) : [...prev, d]
);
}
function handleFileChange(f: File | null) {
if (!f) return;
setFile(f);
if (!title) setTitle(f.name.replace(/\.[^.]+$/, ''));
}
function handleSubmit() {
const fd = new FormData();
if (title) fd.append('title', title);
if (selectedDomains.length) fd.append('domains', selectedDomains.join(','));
if (tab === 'text') {
if (!text.trim()) return;
fd.append('text', text.trim());
} else if (tab === 'doc') {
if (!selectedDocId) return;
fd.append('doc_id', selectedDocId);
} else {
if (!file) return;
fd.append('file', file);
}
const meta: AnalysisMeta = {
title: title || (tab === 'upload' && file ? file.name.replace(/\.[^.]+$/, '') : 'Untitled Analysis'),
sourceType: tab,
startedAt: new Date().toISOString(),
};
onSubmit(fd, meta);
onClose();
}
const canSubmit =
(tab === 'text' && text.trim().length > 0) ||
(tab === 'doc' && selectedDocId !== null) ||
(tab === 'upload' && file !== null);
return (
<div
className="modal-overlay"
ref={overlayRef}
onClick={e => { if (e.target === overlayRef.current) onClose(); }}
>
<div className="modal-dialog" style={{ maxWidth: 720, gridTemplateColumns: '1fr' }}>
<div className="modal-panel">
{/* Header */}
<div className="modal-header">
<span className="modal-title">New Compliance Analysis</span>
<button className="modal-close" onClick={onClose}><X size={16} /></button>
</div>
{/* Title field */}
<div className="upload-field" style={{ marginBottom: 16 }}>
<label>Analysis title (optional)</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. Section 4.2.1 AI System Review"
/>
</div>
{/* Tabs */}
<div className="modal-tabs">
<button className={`modal-tab${tab === 'text' ? ' active' : ''}`} onClick={() => setTab('text')}>
<FileText size={12} style={{ marginRight: 5, display: 'inline' }} />Paste Text
</button>
<button className={`modal-tab${tab === 'doc' ? ' active' : ''}`} onClick={() => setTab('doc')}>
<Database size={12} style={{ marginRight: 5, display: 'inline' }} />From Document
</button>
<button className={`modal-tab${tab === 'upload' ? ' active' : ''}`} onClick={() => setTab('upload')}>
<Upload size={12} style={{ marginRight: 5, display: 'inline' }} />Upload File
</button>
</div>
{/* Tab content */}
{tab === 'text' && (
<div className="upload-field full-width">
<label>Document text to analyze</label>
<textarea
style={{ minHeight: 240 }}
placeholder="Paste the document paragraph or clause text here…"
value={text}
onChange={e => setText(e.target.value)}
/>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{text.length} characters</span>
</div>
)}
{tab === 'doc' && (
<div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 6 }}>
Select an indexed document to analyze:
</div>
<div className="doc-select-list" style={{ maxHeight: 340 }}>
{docs.length === 0 && (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--muted)', fontSize: 13 }}>
No indexed documents found
</div>
)}
{docs.map(doc => (
<div
key={doc.id}
className={`doc-select-item${selectedDocId === doc.id ? ' selected' : ''}`}
onClick={() => setSelectedDocId(doc.id)}
>
<div className="doc-select-check">
{selectedDocId === doc.id && (
<svg width="8" height="8" viewBox="0 0 8 8" fill="white">
<path d="M1 4l2 2 4-4" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="doc-select-name">{doc.name}</div>
{doc.type && <div className="doc-select-meta">{doc.type}</div>}
</div>
</div>
))}
</div>
</div>
)}
{tab === 'upload' && (
<div
className={`dropzone${dragOver ? ' drag-over' : ''}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => {
e.preventDefault();
setDragOver(false);
handleFileChange(e.dataTransfer.files[0] ?? null);
}}
>
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
accept=".pdf,.docx,.txt,.md"
onChange={e => handleFileChange(e.target.files?.[0] ?? null)}
/>
<div className="drop-icon">PDF</div>
{file ? (
<div>
<div className="drop-label">{file.name}</div>
<div className="drop-hint">{(file.size / 1024).toFixed(0)} KB click to replace</div>
</div>
) : (
<div>
<div className="drop-label">Drop file here or click to browse</div>
<div className="drop-hint">PDF, DOCX, TXT, MD max 20 MB</div>
</div>
)}
</div>
)}
{/* Domain filter */}
<div style={{ marginTop: 18 }}>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 6 }}>
Filter by regulation domain (optional):
</div>
<div className="domain-chips">
{DOMAINS.map(d => (
<button
key={d}
className={`domain-chip${selectedDomains.includes(d) ? ' selected' : ''}`}
onClick={() => toggleDomain(d)}
>
{d}
</button>
))}
</div>
</div>
{/* Actions */}
<div className="modal-actions" style={{ marginTop: 24 }}>
<button className="btn" onClick={onClose}>Cancel</button>
<button
className="btn primary"
disabled={!canSubmit}
onClick={handleSubmit}
>
Start Analysis
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { useState, useCallback, useRef } from 'react';
export type AnalysisStatus = 'idle' | 'streaming' | 'done' | 'error';
export interface SourceEvent {
standard: string;
clause: string;
score: number;
status: string;
full_content: string;
}
export interface FindingEvent {
title: string;
desc: string;
status: 'ok' | 'warn' | 'risk';
clause_ref?: string;
}
export interface ActionItem {
label: string;
value: string;
risk?: boolean;
}
export interface DonePayload {
conclusion: string;
actions: ActionItem[];
risk_score: number;
highlight_terms: string[];
para_text: string;
}
export interface AnalysisMeta {
title: string;
sourceType: 'text' | 'doc' | 'upload';
startedAt: string; // ISO timestamp
}
export interface AnalysisState {
status: AnalysisStatus;
stageLabel: string;
stageKey: string;
meta: AnalysisMeta | null;
sources: SourceEvent[];
findings: FindingEvent[];
done: DonePayload | null;
errorText: string;
}
const INITIAL_STATE: AnalysisState = {
status: 'idle',
stageLabel: '',
stageKey: '',
meta: null,
sources: [],
findings: [],
done: null,
errorText: '',
};
export function useComplianceAnalysis() {
const [state, setState] = useState<AnalysisState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const reset = useCallback(() => {
abortRef.current?.abort();
setState(INITIAL_STATE);
}, []);
const run = useCallback(async (formData: FormData, meta: AnalysisMeta) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setState({ ...INITIAL_STATE, status: 'streaming', stageLabel: 'Starting…', meta });
try {
const res = await fetch('/api/v1/compliance/analyze-stream', {
method: 'POST',
body: formData,
signal: ctrl.signal,
});
if (!res.ok) {
const txt = await res.text();
setState(s => ({ ...s, status: 'error', errorText: `HTTP ${res.status}: ${txt}` }));
return;
}
if (!res.body) {
setState(s => ({ ...s, status: 'error', errorText: 'No response stream' }));
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const dataLine = block.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const raw = dataLine.slice(6).trim();
if (!raw) continue;
try {
const j = JSON.parse(raw);
if (j.type === 'stage') {
setState(s => ({ ...s, stageLabel: j.label ?? '', stageKey: j.stage ?? '' }));
} else if (j.type === 'source') {
const src: SourceEvent = {
standard: j.standard ?? '',
clause: j.clause ?? '',
score: j.score ?? 0,
status: j.status ?? 'retrieved',
full_content: j.full_content ?? '',
};
setState(s => ({ ...s, sources: [...s.sources, src] }));
} else if (j.type === 'finding') {
const finding: FindingEvent = {
title: j.title ?? '',
desc: j.desc ?? '',
status: j.status ?? 'info',
clause_ref: j.clause_ref,
};
setState(s => ({ ...s, findings: [...s.findings, finding] }));
} else if (j.type === 'done') {
const payload: DonePayload = {
conclusion: j.conclusion ?? '',
actions: j.actions ?? [],
risk_score: j.risk_score ?? 0,
highlight_terms: j.highlight_terms ?? [],
para_text: j.para_text ?? '',
};
setState(s => ({ ...s, status: 'done', done: payload, stageKey: 'concluding', stageLabel: 'Complete' }));
} else if (j.type === 'error') {
setState(s => ({ ...s, status: 'error', errorText: j.text ?? 'Unknown error' }));
}
} catch { /* skip malformed */ }
}
}
// Mark done if stream ended without explicit done event
setState(s => s.status === 'streaming' ? { ...s, status: 'done', stageKey: 'concluding', stageLabel: 'Complete' } : s);
} catch (e: unknown) {
if (e instanceof Error && e.name === 'AbortError') return;
setState(s => ({ ...s, status: 'error', errorText: String(e) }));
}
}, []);
return { state, run, reset };
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Upload, Search } from 'lucide-react';
import { Upload, Search, Download, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
import { UploadModal } from './UploadModal';
interface Doc {
@@ -10,30 +10,55 @@ interface Doc {
uploadedAt: string;
chunks: number;
type: string;
sizeBytes: number;
summary?: string;
version?: string;
}
const STATUS_FILTERS = ['All', 'Ready', 'Embedding', 'Failed', 'Pending'];
const TYPE_OPTS = ['All types', 'EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
const MOCK_DOCS: Doc[] = [
{ id: '1', name: 'EU AI Act — Full text (EN)', status: 'ok', uploadedAt: '2025-11-10', chunks: 842, type: 'EU Regulation' },
{ id: '2', name: 'MIIT Draft 2025-08 (ZH)', status: 'ok', uploadedAt: '2025-11-01', chunks: 320, type: 'National Draft' },
{ id: '3', name: 'ISO/SAE 21434:2021', status: 'ok', uploadedAt: '2025-10-15', chunks: 614, type: 'ISO Standard' },
{ id: '4', name: 'Vehicle AI Safety Manual v3.2', status: 'ok', uploadedAt: '2025-10-08', chunks: 198, type: 'Internal Policy' },
{ id: '5', name: 'ADAS System Requirements', status: 'warn', uploadedAt: '2025-09-22', chunks: 0, type: 'Internal Policy' },
{ id: '6', name: 'UNECE R155 Corrigendum', status: 'info', uploadedAt: '2025-09-12', chunks: 87, type: 'EU Regulation' },
{ id: '7', name: 'GB/T 42118-2022', status: 'risk', uploadedAt: '2025-08-30', chunks: 0, type: 'National Draft' },
];
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
const STATUS_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Processing', risk: 'Failed', info: 'Pending' };
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Embedding: 'warn', Failed: 'risk', Pending: 'info' };
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Processing: 'warn', Failed: 'risk', Pending: 'info' };
// Map backend DocumentStatus enum values to frontend display status
function backendStatus(s: string): Doc['status'] {
if (s === 'indexed') return 'ok';
if (s === 'failed') return 'risk';
if (s === 'parsed') return 'warn'; // chunked, awaiting embedding
return 'info'; // pending / stored
if (s === 'parsed') return 'warn';
return 'info';
}
function formatSize(bytes: number): string {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// ── Confirm dialog ─────────────────────────────────────────────────────────
function ConfirmDialog({ message, onConfirm, onCancel }: {
message: string;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', maxWidth: 400, width: '100%', boxShadow: '0 12px 40px rgba(0,0,0,.2)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<AlertTriangle size={18} color="var(--danger)" />
<span style={{ fontWeight: 600, fontSize: 15 }}>Confirm deletion</span>
</div>
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 20 }}>{message}</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button className="btn sm" onClick={onCancel}>Cancel</button>
<button className="btn sm" style={{ background: 'var(--danger)', color: '#fff', borderColor: 'var(--danger)' }} onClick={onConfirm}>
Delete
</button>
</div>
</div>
</div>
);
}
export function DocsPage() {
@@ -41,14 +66,23 @@ export function DocsPage() {
const [statusF, setStatusF] = useState('All');
const [typeF, setTypeF] = useState('All types');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [docs, setDocs] = useState<Doc[]>(MOCK_DOCS);
const [docs, setDocs] = useState<Doc[]>([]);
const [loading, setLoading] = useState(true);
const [showUpload, setShowUpload] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [retrying, setRetrying] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState<Set<string>>(new Set());
const [confirmDelete, setConfirmDelete] = useState<{ ids: string[]; names: string[] } | null>(null);
useEffect(() => {
// Dynamic type options derived from actual docs
const typeOpts = ['All types', ...Array.from(new Set(docs.map(d => d.type).filter(t => t && t !== '—')))];
const fetchDocs = useCallback(() => {
setLoading(true);
fetch('/api/v1/documents/management-list')
.then(r => r.json())
.then(d => {
if (!Array.isArray(d?.documents)) return;
if (!Array.isArray(d?.documents)) { setLoading(false); return; }
setDocs(d.documents.map((item: Record<string, unknown>) => ({
id: item.doc_id as string,
name: item.doc_name as string,
@@ -56,11 +90,18 @@ export function DocsPage() {
uploadedAt: ((item.updated_at as string) ?? '').slice(0, 10),
chunks: (item.chunk_count as number) ?? 0,
type: (item.regulation_type as string) || '—',
sizeBytes: (item.size_bytes as number) ?? 0,
summary: item.summary as string | undefined,
version: item.version as string | undefined,
})));
setLoading(false);
})
.catch(() => {});
.catch(() => setLoading(false));
}, []);
useEffect(() => { fetchDocs(); }, [fetchDocs, refreshKey]);
// ── Filtering ────────────────────────────────────────────────────────────
const filtered = docs.filter(d => {
const matchSearch = !search || d.name.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusF === 'All' || d.status === STATUS_MAP[statusF];
@@ -68,17 +109,60 @@ export function DocsPage() {
return matchSearch && matchStatus && matchType;
});
// ── Selection helpers ────────────────────────────────────────────────────
function toggleAll() {
if (selected.size === filtered.length) setSelected(new Set());
else setSelected(new Set(filtered.map(d => d.id)));
}
function toggleOne(id: string) {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
setSelected(s);
}
// ── Download ─────────────────────────────────────────────────────────────
function downloadDoc(id: string, name: string) {
const a = document.createElement('a');
a.href = `/api/v1/documents/download/${id}`;
a.download = name;
a.click();
}
// ── Retry (re-process failed doc) ────────────────────────────────────────
async function retryDoc(id: string) {
setRetrying(r => new Set([...r, id]));
try {
await fetch(`/api/v1/documents/${id}/retry`, { method: 'POST' });
setTimeout(() => {
setRetrying(r => { const s = new Set(r); s.delete(id); return s; });
setRefreshKey(k => k + 1);
}, 1500);
} catch {
setRetrying(r => { const s = new Set(r); s.delete(id); return s; });
}
}
// ── Delete (single or batch) ─────────────────────────────────────────────
function askDelete(ids: string[]) {
const names = ids.map(id => docs.find(d => d.id === id)?.name ?? id);
setConfirmDelete({ ids, names });
}
async function confirmDeleteDocs() {
if (!confirmDelete) return;
const { ids } = confirmDelete;
setConfirmDelete(null);
setDeleting(new Set(ids));
await Promise.allSettled(
ids.map(id => fetch(`/api/v1/documents/${id}`, { method: 'DELETE' }))
);
setDeleting(new Set());
setSelected(s => { const n = new Set(s); ids.forEach(id => n.delete(id)); return n; });
setRefreshKey(k => k + 1);
}
return (
<div className="docs-page">
<Topbar
@@ -93,12 +177,16 @@ export function DocsPage() {
onChange={e => setSearch(e.target.value)}
/>
</div>
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
<RefreshCw size={13} />Refresh
</button>
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
<Upload size={13} />Upload document
</button>
</>
}
/>
<div className="page-content">
<div className="docs-controls">
<div className="chip-group">
@@ -111,18 +199,25 @@ export function DocsPage() {
))}
</div>
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
{TYPE_OPTS.map(o => <option key={o}>{o}</option>)}
{typeOpts.map(o => <option key={o}>{o}</option>)}
</select>
</div>
{/* Batch action bar */}
{selected.size > 0 && (
<div className="batch-bar">
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
<button className="btn sm">Analyze selected</button>
<button className="btn sm risk-btn">Delete selected</button>
<button
className="btn sm"
style={{ color: 'var(--danger)', borderColor: 'rgba(239,68,68,.4)' }}
onClick={() => askDelete([...selected])}
>
<Trash2 size={12} />Delete selected
</button>
</div>
)}
{/* Table */}
<div className="docs-table">
<div className="table-header">
<input
@@ -134,32 +229,110 @@ export function DocsPage() {
<span>Status</span>
<span>Uploaded</span>
<span>Chunks</span>
<span>Size</span>
<span>Type</span>
<span>Actions</span>
</div>
{filtered.map(d => (
<div key={d.id} className={`table-row${selected.has(d.id) ? ' row-selected' : ''}`}>
<input
type="checkbox"
checked={selected.has(d.id)}
onChange={() => toggleOne(d.id)}
/>
<span className="doc-name-cell">{d.name}</span>
<span><span className={`status ${d.status}`}>{STATUS_LABEL[d.status]}</span></span>
<span className="cell-mono">{d.uploadedAt}</span>
<span className="cell-mono">{d.chunks || '—'}</span>
<span className="cell-muted">{d.type}</span>
<span className="row-actions">
<button className="text-link">Inspect</button>
<button className="text-link">Analyze</button>
{d.status === 'risk' && <button className="text-link danger-link">Resolve</button>}
</span>
{loading ? (
<div style={{ padding: '32px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
Loading documents
</div>
))}
) : filtered.length === 0 ? (
<div style={{ padding: '40px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
{docs.length === 0 ? 'No documents yet. Upload a document to get started.' : 'No documents match the current filters.'}
</div>
) : (
filtered.map(d => {
const isDeleting = deleting.has(d.id);
const isRetrying = retrying.has(d.id);
return (
<div
key={d.id}
className={`table-row${selected.has(d.id) ? ' row-selected' : ''}${isDeleting ? ' row-deleting' : ''}`}
>
<input
type="checkbox"
checked={selected.has(d.id)}
onChange={() => toggleOne(d.id)}
disabled={isDeleting}
/>
<span className="doc-name-cell" title={d.summary || d.name}>
{d.name}
{d.version && <span style={{ fontSize: 10, color: 'var(--muted)', marginLeft: 6 }}>v{d.version}</span>}
</span>
<span><span className={`status ${d.status}`}>{STATUS_LABEL[d.status]}</span></span>
<span className="cell-mono">{d.uploadedAt}</span>
<span className="cell-mono">{d.chunks || '—'}</span>
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
<span className="cell-muted">{d.type}</span>
<span className="row-actions">
{/* Download */}
<button
className="text-link"
title="Download original file"
onClick={() => downloadDoc(d.id, d.name)}
>
<Download size={12} />
</button>
{/* Retry for failed */}
{d.status === 'risk' && (
<button
className="text-link"
title="Retry processing"
disabled={isRetrying}
onClick={() => retryDoc(d.id)}
style={{ color: 'var(--warn)' }}
>
<RefreshCw size={12} style={{ animation: isRetrying ? 'spin 1s linear infinite' : 'none' }} />
</button>
)}
{/* Delete */}
<button
className="text-link danger-link"
title="Delete document"
disabled={isDeleting}
onClick={() => askDelete([d.id])}
>
<Trash2 size={12} />
</button>
</span>
</div>
);
})
)}
</div>
{/* Footer count */}
{!loading && (
<div style={{ padding: '10px 0', fontSize: 12, color: 'var(--muted)' }}>
{filtered.length} of {docs.length} document{docs.length !== 1 ? 's' : ''}
{selected.size > 0 && ` · ${selected.size} selected`}
</div>
)}
</div>
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
{/* Confirm delete dialog */}
{confirmDelete && (
<ConfirmDialog
message={
confirmDelete.ids.length === 1
? `Delete "${confirmDelete.names[0]}"? This will remove the document, all its chunks, and embeddings from the vector store. This action cannot be undone.`
: `Delete ${confirmDelete.ids.length} documents? This will remove them and all their chunks from the vector store. This action cannot be undone.`
}
onConfirm={confirmDeleteDocs}
onCancel={() => setConfirmDelete(null)}
/>
)}
{showUpload && (
<UploadModal
onClose={() => setShowUpload(false)}
onComplete={() => setRefreshKey(k => k + 1)}
/>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { X, Upload } from 'lucide-react';
interface Props {
onClose: () => void;
onComplete?: () => void; // called when all uploads finish (indexed)
}
const REG_TYPES = ['EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
@@ -48,7 +49,7 @@ function genDocId(): string {
return Math.random().toString(36).slice(2, 10);
}
export function UploadModal({ onClose }: Props) {
export function UploadModal({ onClose, onComplete }: Props) {
const [files, setFiles] = useState<File[]>([]);
const [regType, setRegType] = useState(REG_TYPES[0]);
const [version, setVersion] = useState('');
@@ -182,6 +183,7 @@ export function UploadModal({ onClose }: Props) {
setAllDone(true);
setDocStatus('idle');
setCurrentFileIdx(-1);
onComplete?.(); // notify parent to refresh doc list
}
// Compute queue stage display for the currently-processing file

View File

@@ -1,63 +1,132 @@
import { useState, useEffect } from 'react';
import { Topbar } from '../../components/layout/Topbar';
import { Search, Upload, Download, RefreshCw } from 'lucide-react';
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
import { UploadModal } from '../Docs/UploadModal';
// Backend /api/v1/status/stats returns:
// { documents_total, documents_indexed, documents_failed, chunks_total }
interface Stats { documents_total: number; documents_indexed: number; documents_failed: number; chunks_total: number; }
// ── API types ──────────────────────────────────────────────────────────────
interface Stats {
documents_total: number;
documents_indexed: number;
documents_failed: number;
chunks_total: number;
}
const TASKS = [
{ name: 'EU AI Act — Article 13 check', status: 'ok', progress: 88, cta: 'View report' },
{ name: 'GB/T 42118 compliance scan', status: 'warn', progress: 54, cta: 'Continue' },
{ name: 'MIIT Draft — automotive AI embedding', status: 'info', progress: 12, cta: 'Start' },
];
interface Health {
milvus: { status: string; connected?: boolean; collection_name?: string; num_entities?: number; error?: string };
minio: { status: string; connected: boolean };
bm25: { available: boolean };
reranker: { enabled: boolean; model: string | null };
sessions: { active: number; max: number };
}
const PROGRAMS = [
{ name: 'EU AI Act Readiness', status: 'ok', coverage: 88 },
{ name: 'China MIIT Compliance', status: 'warn', coverage: 54 },
{ name: 'ISO/SAE 21434 Audit', status: 'info', coverage: 32 },
];
interface Config {
embedding_model: string;
embedding_dim: number;
embedding_base_url: string;
milvus_collection: string;
parser_backend: string;
chunk_backend: string;
llm_provider: string;
llm_model: string;
parser_failure_mode: string;
artifact_prefix?: string;
document_metadata_path?: string;
}
const KPIS = [
{ label: 'Retrieval hit rate', value: 94, unit: '%' },
{ label: 'Evidence coverage', value: 78, unit: '%' },
{ label: 'Reviewer SLA', value: 91, unit: '%' },
];
// ── Small helpers ──────────────────────────────────────────────────────────
function StatusIcon({ status }: { status: 'ok' | 'error' | 'warn' | 'info' }) {
if (status === 'ok') return <CheckCircle size={14} color="var(--ok)" />;
if (status === 'error') return <XCircle size={14} color="var(--danger)" />;
if (status === 'warn') return <AlertTriangle size={14} color="var(--warn)" />;
return <Info size={14} color="var(--muted)" />;
}
const SERVICES = [
{ name: 'Vector store (Chroma)', status: 'ok' },
{ name: 'LLM gateway (Claude)', status: 'ok' },
{ name: 'Document parser', status: 'ok' },
{ name: 'SSE stream endpoint', status: 'ok' },
{ name: 'Regulation feed sync', status: 'warn' },
];
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
return (
<div className="service-row">
<StatusIcon status={status} />
<span className="service-name" style={{ marginLeft: 8 }}>{name}</span>
{detail && <span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>{detail}</span>}
<span className={`status ${status}`} style={{ marginLeft: 'auto' }}>
{status === 'ok' ? 'Online' : status === 'error' ? 'Error' : status === 'warn' ? 'Degraded' : 'Unknown'}
</span>
</div>
);
}
const EVENTS = [
{ date: '2025-11-18', title: 'EU AI Act — Delegated acts published', summary: 'European Commission releases implementing rules for high-risk AI classification under Annex III.' },
{ date: '2025-10-30', title: 'MIIT Draft — automotive AI', summary: 'New draft regulation covers in-vehicle AI training data provenance and OTA update governance.' },
{ date: '2025-10-05', title: 'ISO/SAE 21434 amendment', summary: 'Amendment 1 clarifies cybersecurity management system scope for software-only updates.' },
];
const STATUS_LABEL: Record<string, string> = { ok: 'Complete', warn: 'In progress', info: 'Pending' };
function ConfigRow({ label, value }: { label: string; value: string | number | null | undefined }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 0', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
<span style={{ color: 'var(--muted)' }}>{label}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--fg)', fontSize: 11, maxWidth: '60%', textAlign: 'right', wordBreak: 'break-all' }}>
{value ?? '—'}
</span>
</div>
);
}
// ── Main component ─────────────────────────────────────────────────────────
export function StatusPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [health, setHealth] = useState<Health | null>(null);
const [config, setConfig] = useState<Config | null>(null);
const [loading, setLoading] = useState(true);
const [healthLoading, setHealthLoading] = useState(true);
const [configOpen, setConfigOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [showUpload, setShowUpload] = useState(false);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
useEffect(() => {
setLoading(true);
fetch('/api/v1/status/stats')
.then(r => r.json())
.then(d => { setStats(d); setLoading(false); })
.catch(() => {
setStats({ documents_total: 42, documents_indexed: 38, documents_failed: 1, chunks_total: 3841 });
setLoading(false);
});
setHealthLoading(true);
// Fetch all three endpoints in parallel
Promise.allSettled([
fetch('/api/v1/status/stats').then(r => r.json()),
fetch('/api/v1/status/health').then(r => r.json()),
fetch('/api/v1/status/config').then(r => r.json()),
]).then(([statsRes, healthRes, configRes]) => {
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
if (configRes.status === 'fulfilled') setConfig(configRes.value);
setLoading(false);
setHealthLoading(false);
setLastRefresh(new Date());
});
}, [refreshKey]);
// ── Derived values ───────────────────────────────────────────────────────
const indexedPct = stats && stats.documents_total > 0
? Math.round((stats.documents_indexed / stats.documents_total) * 100)
: 0;
function milvusStatus(): 'ok' | 'error' | 'warn' | 'info' {
if (!health) return 'info';
return health.milvus.status === 'ok' ? 'ok' : 'error';
}
function milvusDetail() {
if (!health) return undefined;
if (health.milvus.error) return health.milvus.error.slice(0, 60);
const parts: string[] = [];
if (health.milvus.collection_name) parts.push(health.milvus.collection_name);
if (health.milvus.num_entities !== undefined) parts.push(`${health.milvus.num_entities.toLocaleString()} entities`);
return parts.join(' · ') || undefined;
}
// ── Export ───────────────────────────────────────────────────────────────
function handleExport() {
const data = { stats, health, config, exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `regulation-hub-status-${Date.now()}.json`; a.click();
URL.revokeObjectURL(url);
}
return (
<div className="status-page">
<Topbar
@@ -68,17 +137,8 @@ export function StatusPage() {
<Search size={13} />
<input placeholder="Search..." />
</div>
<button
className="btn sm"
onClick={() => {
const blob = new Blob([JSON.stringify(stats, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'regulation-hub-status.json'; a.click();
URL.revokeObjectURL(url);
}}
>
<Download size={13} />Export status
<button className="btn sm" onClick={handleExport}>
<Download size={13} />Export
</button>
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
<RefreshCw size={13} />Refresh
@@ -89,7 +149,10 @@ export function StatusPage() {
</>
}
/>
<div className="page-content">
{/* ── Stats grid ────────────────────────────────────────────────── */}
<div className="stats-grid">
<div className="stat-cell">
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
@@ -109,72 +172,182 @@ export function StatusPage() {
</div>
</div>
{/* Indexed progress bar */}
{!loading && stats && stats.documents_total > 0 && (
<div style={{ padding: '0 0 20px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>Index coverage</span>
<div style={{ flex: 1, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3,
width: `${indexedPct}%`,
background: indexedPct === 100 ? 'var(--ok)' : indexedPct > 60 ? 'var(--accent)' : 'var(--warn)',
transition: 'width 0.6s ease',
}} />
</div>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--fg)', whiteSpace: 'nowrap' }}>
{indexedPct}% ({stats.documents_indexed}/{stats.documents_total})
</span>
</div>
)}
{/* ── Main panel grid ───────────────────────────────────────────── */}
<div className="panel-grid">
<div className="panel-left">
{/* System health */}
<div className="card">
<div className="card-header">Workflow queue</div>
{TASKS.map(t => (
<div key={t.name} className="task-row">
<div className="task-info">
<div className="task-name">{t.name}</div>
<div className="task-progress-bar">
<div className="task-progress-fill" style={{ width: `${t.progress}%` }} />
</div>
</div>
<span className={`status ${t.status}`}>{STATUS_LABEL[t.status]}</span>
<button className="btn sm">{t.cta}</button>
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>System health</span>
{lastRefresh && (
<span style={{ fontSize: 11, color: 'var(--muted)', fontWeight: 400 }}>
Updated {lastRefresh.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
</div>
{healthLoading ? (
<div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="loading-shimmer" style={{ height: 28, borderRadius: 6 }} />
))}
</div>
))}
) : health ? (
<>
<ServiceRow
name="Milvus vector store"
status={milvusStatus()}
detail={milvusDetail()}
/>
<ServiceRow
name="MinIO object storage"
status={health.minio.connected ? 'ok' : 'error'}
/>
<ServiceRow
name="BM25 keyword retriever"
status={health.bm25.available ? 'ok' : 'warn'}
detail={health.bm25.available ? undefined : 'Not loaded'}
/>
<ServiceRow
name={`Reranker${health.reranker.model ? ` (${health.reranker.model})` : ''}`}
status={health.reranker.enabled ? 'ok' : 'info'}
detail={health.reranker.enabled ? 'Enabled' : 'Disabled'}
/>
<ServiceRow
name="Active sessions"
status={health.sessions.active < health.sessions.max ? 'ok' : 'warn'}
detail={`${health.sessions.active} / ${health.sessions.max} max`}
/>
</>
) : (
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>
Could not reach health endpoint
</div>
)}
</div>
{/* System config (collapsible) */}
<div className="card">
<div className="card-header">Active compliance programs</div>
{PROGRAMS.map(p => (
<div key={p.name} className="program-row">
<span className={`status ${p.status}`} style={{ marginRight: 'auto' }}>{p.name}</span>
<span className="program-pct">{p.coverage}%</span>
<button
onClick={() => setConfigOpen(v => !v)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>System configuration</div>
<span style={{ fontSize: 11, color: 'var(--muted)', transform: configOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}></span>
</button>
{configOpen && (
<div style={{ marginTop: 12 }}>
{config ? (
<>
<ConfigRow label="LLM provider" value={config.llm_provider} />
<ConfigRow label="LLM model" value={config.llm_model} />
<ConfigRow label="Embedding model" value={config.embedding_model} />
<ConfigRow label="Embedding dim" value={config.embedding_dim} />
<ConfigRow label="Milvus collection" value={config.milvus_collection} />
<ConfigRow label="Parser backend" value={config.parser_backend} />
<ConfigRow label="Chunk backend" value={config.chunk_backend} />
<ConfigRow label="Parser failure mode" value={config.parser_failure_mode} />
</>
) : (
<div style={{ color: 'var(--muted)', fontSize: 13 }}>Could not load config</div>
)}
</div>
))}
<div className="kpi-strip">
{KPIS.map(k => (
<div key={k.label} className="kpi-item">
<div className="kpi-label">{k.label}</div>
<div className="kpi-bar"><div className="kpi-fill" style={{ width: `${k.value}%` }} /></div>
<div className="kpi-value">{k.value}{k.unit}</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="panel-right">
{/* Document breakdown */}
<div className="card">
<div className="card-header">System health</div>
{SERVICES.map(s => (
<div key={s.name} className="service-row">
<span className="service-name">{s.name}</span>
<span className={`status ${s.status}`}>{s.status === 'ok' ? 'Online' : 'Degraded'}</span>
<div className="card-header">Document breakdown</div>
{loading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[1, 2, 3].map(i => <div key={i} className="loading-shimmer" style={{ height: 24, borderRadius: 4 }} />)}
</div>
))}
) : stats ? (
<>
{[
{ label: 'Indexed', value: stats.documents_indexed, total: stats.documents_total, color: 'var(--ok)' },
{ label: 'Processing / Parsed', value: stats.documents_total - stats.documents_indexed - stats.documents_failed, total: stats.documents_total, color: 'var(--warn)' },
{ label: 'Failed', value: stats.documents_failed, total: stats.documents_total, color: 'var(--danger)' },
].map(row => {
const pct = stats.documents_total > 0 ? Math.round((Math.max(0, row.value) / stats.documents_total) * 100) : 0;
return (
<div key={row.label} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span style={{ color: 'var(--muted)' }}>{row.label}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--fg)' }}>{Math.max(0, row.value)} ({pct}%)</span>
</div>
<div style={{ height: 5, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: row.color, borderRadius: 2, transition: 'width 0.5s' }} />
</div>
</div>
);
})}
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span style={{ color: 'var(--muted)' }}>Total vector chunks</span>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{stats.chunks_total.toLocaleString()}</span>
</div>
</>
) : null}
</div>
<div className="card">
<div className="card-header">Regulatory watch</div>
{EVENTS.map(e => (
<div key={e.title} className="event-row">
<div className="event-date">{e.date}</div>
<div className="event-title">{e.title}</div>
<div className="event-summary">{e.summary}</div>
{/* Sessions & reranker quick facts */}
{health && (
<div className="card">
<div className="card-header">Runtime info</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
<span style={{ color: 'var(--muted)' }}>Active chat sessions</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.active}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
<span style={{ color: 'var(--muted)' }}>Session capacity</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
<span style={{ color: 'var(--muted)' }}>Cross-encoder reranker</span>
<span style={{ fontFamily: 'var(--font-mono)', color: health.reranker.enabled ? 'var(--ok)' : 'var(--muted)' }}>
{health.reranker.enabled ? (health.reranker.model ?? 'Enabled') : 'Disabled'}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
<span style={{ color: 'var(--muted)' }}>BM25 hybrid retrieval</span>
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
{health.bm25.available ? 'Active' : 'Unavailable'}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
<footer className="page-footer">
<div className="live-dot" />
<span>Regulation Hub · T-Systems AI · Online</span>
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? 'All systems operational' : 'Degraded') : 'Checking…'}</span>
</footer>
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}

View File

@@ -324,6 +324,7 @@ body {
.page-footer { padding: 12px 22px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); background: var(--surface); flex-shrink: 0; }
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 2px var(--success-bg); animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* ── Overview Page ──────────────────────────────── */
.overview-page { padding: 32px; max-width: 900px; display: flex; flex-direction: column; gap: 32px; }
@@ -473,7 +474,7 @@ body {
.docs-table { background: var(--surface); border-radius: var(--radius-md); box-shadow: var(--shadow-card); overflow: hidden; }
.table-header {
display: grid;
grid-template-columns: 28px 1.4fr 0.8fr 0.85fr 0.85fr 0.75fr 0.75fr;
grid-template-columns: 28px 1fr 90px 100px 70px 100px 90px 120px;
gap: 12px;
padding: 10px 14px;
background: var(--bg);
@@ -483,17 +484,18 @@ body {
}
.table-row {
display: grid;
grid-template-columns: 28px 1.4fr 0.8fr 0.85fr 0.85fr 0.75fr 0.75fr;
grid-template-columns: 28px 1fr 90px 100px 70px 100px 90px 120px;
gap: 12px;
padding: 11px 14px;
border-bottom: 1px solid var(--border);
font-size: 13px;
align-items: center;
transition: background 0.1s;
transition: background 0.1s, opacity 0.3s;
}
.table-row:last-child { border-bottom: none; }
.table-row:hover { background: var(--bg); }
.table-row.row-selected { background: var(--accent-dim); }
.table-row.row-deleting { opacity: 0.4; pointer-events: none; }
.doc-name-cell { font-weight: 500; }
.cell-mono { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.cell-muted { font-size: 12px; color: var(--muted); }
@@ -776,3 +778,201 @@ body {
}
.summary-card-sm strong { font-size: 13px; font-family: var(--font-mono); }
.summary-card-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
/* ═══════════════════════════════════════════════
COMPLIANCE ANALYSIS — NEW ANALYSIS FEATURE
═══════════════════════════════════════════════ */
/* Modal tabs */
.modal-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.modal-tab {
padding: 10px 18px;
font-size: 13px;
font-weight: 500;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.12s, border-color 0.12s;
font-family: var(--font-body);
}
.modal-tab:hover { color: var(--fg); }
.modal-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* Domain chips */
.domain-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.domain-chip {
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
cursor: pointer;
transition: all 0.12s;
}
.domain-chip.selected {
background: rgba(226,0,116,.12);
border-color: rgba(226,0,116,.5);
color: var(--accent);
}
/* Doc select list */
.doc-select-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
margin-top: 8px;
}
.doc-select-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.doc-select-item:hover { border-color: rgba(226,0,116,.4); }
.doc-select-item.selected {
border-color: var(--accent);
background: rgba(226,0,116,.06);
}
.doc-select-check {
width: 16px; height: 16px;
border-radius: 50%;
border: 2px solid var(--border);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.12s;
}
.doc-select-item.selected .doc-select-check {
background: var(--accent);
border-color: var(--accent);
}
.doc-select-name { font-size: 13px; font-weight: 500; flex: 1; }
.doc-select-meta { font-size: 11px; color: var(--muted); }
/* Stage running animation */
@keyframes stage-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.stage-running .stage-fill {
animation: stage-pulse 1.2s ease-in-out infinite;
}
.stage-fill.stage-running {
background: var(--accent);
animation: stage-pulse 1.2s ease-in-out infinite;
}
/* Compliance status bar */
.compliance-status-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: var(--radius-sm);
background: var(--surface);
border: 1px solid var(--border);
margin-bottom: 16px;
font-size: 12px;
}
.compliance-status-bar .status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
}
.compliance-status-bar.streaming .status-dot {
background: var(--accent);
animation: stage-pulse 1s ease-in-out infinite;
}
.compliance-status-bar.done .status-dot { background: var(--ok); }
.compliance-status-bar.error .status-dot { background: var(--danger); }
.status-bar-label { color: var(--fg); font-weight: 500; }
.status-bar-sub { color: var(--muted); margin-left: 4px; }
/* Risk score badge */
.risk-score-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px; height: 42px;
border-radius: 50%;
font-size: 15px;
font-weight: 700;
font-family: var(--font-mono);
flex-shrink: 0;
}
.risk-score-badge.low { background: rgba(34,197,94,.15); color: #22c55e; }
.risk-score-badge.med { background: rgba(234,179,8,.15); color: #eab308; }
.risk-score-badge.high { background: rgba(239,68,68,.15); color: #ef4444; }
/* Analysis empty state */
.analysis-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 24px;
text-align: center;
color: var(--muted);
}
.analysis-empty-icon {
width: 56px; height: 56px;
border-radius: 16px;
border: 1px dashed rgba(226,0,116,.4);
display: flex; align-items: center; justify-content: center;
color: var(--accent);
opacity: 0.7;
}
.analysis-empty h3 { font-size: 15px; font-weight: 600; color: var(--fg); margin: 0; }
.analysis-empty p { font-size: 13px; max-width: 280px; line-height: 1.6; margin: 0; }
/* Highlight terms in paragraph */
mark.comp-highlight {
background: rgba(226,0,116,.18);
color: inherit;
border-radius: 2px;
padding: 0 2px;
}
/* Conclusion box enhancements */
.risk-meter {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0;
}
.risk-bar-track {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.risk-bar-fill {
height: 100%;
border-radius: 3px;
background: linear-gradient(to right, #22c55e 0%, #eab308 50%, #ef4444 100%);
transition: width 0.6s ease;
}