Compare commits
1 Commits
746513cc54
...
feature/fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e0967128 |
@@ -5,17 +5,19 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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 fastapi.responses import StreamingResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from app.schemas.compliance import (
|
from app.schemas.compliance import (
|
||||||
AnalyzeResponse,
|
AnalyzeResponse,
|
||||||
ComplianceChatRequest,
|
ComplianceChatRequest,
|
||||||
)
|
)
|
||||||
from app.services.mock_data import generate_task_id, get_mock_compliance_result
|
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=["合规分析"])
|
router = APIRouter(prefix="/compliance", tags=["合规分析"])
|
||||||
@@ -62,6 +64,128 @@ async def get_result(task_id: str):
|
|||||||
return task["result"]
|
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}")
|
@router.post("/chat/{segment_id}")
|
||||||
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
||||||
"""Stream compliance Q&A grounded in real vector retrieval."""
|
"""Stream compliance Q&A grounded in real vector retrieval."""
|
||||||
|
|||||||
1
backend/app/application/compliance/__init__.py
Normal file
1
backend/app/application/compliance/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Compliance application layer."""
|
||||||
215
backend/app/application/compliance/pipeline.py
Normal file
215
backend/app/application/compliance/pipeline.py
Normal 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
|
||||||
@@ -80,4 +80,30 @@ class ComplianceChatRequest(BaseModel):
|
|||||||
class AnalyzeResponse(BaseModel):
|
class AnalyzeResponse(BaseModel):
|
||||||
"""Define the Analyze Response API model."""
|
"""Define the Analyze Response API model."""
|
||||||
task_id: str
|
task_id: str
|
||||||
status: str = "processing"
|
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
|
||||||
1489
docs/superpowers/plans/2026-06-04-compliance-new-analysis.md
Normal file
1489
docs/superpowers/plans/2026-06-04-compliance-new-analysis.md
Normal file
File diff suppressed because it is too large
Load Diff
1127
docs/superpowers/plans/2026-06-04-frontend-optimization.md
Normal file
1127
docs/superpowers/plans/2026-06-04-frontend-optimization.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 { 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 = [
|
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical', info: 'Info' };
|
||||||
{ standard: 'EU AI Act', helper: 'Art. 9 — Risk management', scores: ['Art. 9.1', 'Art. 9.2'], status: 'risk' },
|
const SOURCE_TYPE_LABEL: Record<string, string> = { text: 'Pasted Text', doc: 'Indexed Document', upload: 'Uploaded File' };
|
||||||
{ 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 STAGES = [
|
function riskClass(score: number) {
|
||||||
{ label: 'Clause retrieval', pct: 100, status: 'ok' },
|
if (score >= 70) return 'high';
|
||||||
{ label: 'Requirement extraction', pct: 100, status: 'ok' },
|
if (score >= 40) return 'med';
|
||||||
{ label: 'Gap analysis', pct: 78, status: 'warn' },
|
return 'low';
|
||||||
{ label: 'Recommendation synthesis', pct: 30, status: 'info' },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
const FINDINGS = [
|
function highlightText(text: string, terms: string[]): React.ReactNode[] {
|
||||||
{ title: 'Missing risk management documentation', desc: 'No formal risk management system found for the described AI system scope under Art. 9.', status: 'risk' },
|
if (!terms.length) return [text];
|
||||||
{ title: 'Training data lineage incomplete', desc: 'MIIT §3.1 requires traceable provenance for training datasets. Current documentation lacks data source registry.', status: 'warn' },
|
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
{ title: 'CSMS audit trail present', desc: 'ISO 21434 audit log requirements are met. Retention policy documented in Annex B.', status: 'ok' },
|
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() {
|
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 (
|
return (
|
||||||
<div className="compliance-page">
|
<div className="compliance-page" style={{ position: 'relative' }}>
|
||||||
<Topbar
|
<Topbar
|
||||||
title="Compliance Analysis"
|
title="Compliance Analysis"
|
||||||
actions={
|
actions={
|
||||||
@@ -35,92 +185,331 @@ export function CompliancePage() {
|
|||||||
<Search size={13} />
|
<Search size={13} />
|
||||||
<input placeholder="Search analyses..." />
|
<input placeholder="Search analyses..." />
|
||||||
</div>
|
</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">
|
{showModal && (
|
||||||
<p className="hero-eyebrow">Compliance Workspace</p>
|
<NewAnalysisModal
|
||||||
<h2 className="compliance-title">Document Paragraph Review</h2>
|
onClose={() => setShowModal(false)}
|
||||||
<p className="compliance-desc">
|
onSubmit={(fd, meta) => run(fd, meta)}
|
||||||
Three-column AI-assisted compliance gap analysis with regulation retrieval, paragraph review, and findings synthesis.
|
/>
|
||||||
</p>
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="compliance-workspace">
|
{/* Status bar */}
|
||||||
<div className="comp-col source-col">
|
{(isStreaming || isDone || isError) && (
|
||||||
<div className="col-header">Retrieved Regulations</div>
|
<div style={{ padding: '0 24px' }}>
|
||||||
{SOURCES.map(s => (
|
<div className={`compliance-status-bar ${state.status}`}>
|
||||||
<div key={s.standard} className="source-item card">
|
<div className="status-dot" />
|
||||||
<div className="source-top">
|
<span className="status-bar-label">
|
||||||
<span className="source-std">{s.standard}</span>
|
{isStreaming ? 'Analyzing…' : isDone ? 'Analysis complete' : 'Error'}
|
||||||
<span className={`status ${s.status}`}>{STATUS_LABEL[s.status]}</span>
|
</span>
|
||||||
</div>
|
<span className="status-bar-sub">{state.stageLabel}</span>
|
||||||
<div className="source-helper">{s.helper}</div>
|
</div>
|
||||||
<div className="source-scores">
|
|
||||||
{s.scores.map(sc => <span key={sc} className="score-pill">{sc}</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="comp-col review-col">
|
{/* Empty state */}
|
||||||
<div className="col-header">Paragraph Under Review</div>
|
{isIdle && (
|
||||||
<div className="card para-card">
|
<div className="analysis-empty">
|
||||||
<p className="para-text">
|
<div className="analysis-empty-icon"><Plus size={24} /></div>
|
||||||
{PARA.split(/(AI system)/g).map((part, i) =>
|
<h3>No analysis running</h3>
|
||||||
part === 'AI system'
|
<p>Click <strong>New analysis</strong> to start a compliance gap review against your indexed regulations.</p>
|
||||||
? <mark key={i}>{part}</mark>
|
</div>
|
||||||
: <span key={i}>{part}</span>
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
)}
|
||||||
<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 className="comp-col findings-col">
|
<div className="compliance-workspace" style={{ position: 'relative' }}>
|
||||||
<div className="col-header">Findings</div>
|
|
||||||
{FINDINGS.map(f => (
|
{/* Column 1: Retrieved Regulations */}
|
||||||
<div key={f.title} className="finding-item card">
|
<div className="comp-col source-col">
|
||||||
<div className="finding-top">
|
<div className="col-header">
|
||||||
<span className="finding-title">{f.title}</span>
|
Retrieved Regulations {state.sources.length > 0 && `(${state.sources.length})`}
|
||||||
<span className={`status ${f.status}`}>{STATUS_LABEL[f.status] ?? f.status}</span>
|
|
||||||
</div>
|
</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>
|
||||||
))}
|
|
||||||
<div className="card conclusion-box">
|
{/* Column 2: Paragraph Under Review + Stages */}
|
||||||
<div className="card-header">Conclusion</div>
|
<div className="comp-col review-col">
|
||||||
<p className="conclusion-text">
|
<div className="col-header">Paragraph Under Review</div>
|
||||||
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="card para-card">
|
||||||
<div className="action-items">
|
{isDone && state.done?.para_text ? (
|
||||||
<div className="action-item">
|
<p className="para-text">
|
||||||
<span className="action-label">Next action</span>
|
{highlightText(state.done.para_text, state.done.highlight_terms ?? [])}
|
||||||
<span className="action-value">Draft risk management annex</span>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="para-text" style={{ color: 'var(--muted)' }}>
|
||||||
|
{isStreaming ? 'Extracting and analyzing text…' : 'No text extracted'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="action-item">
|
|
||||||
<span className="action-label">Escalation</span>
|
<div className="card stages-card">
|
||||||
<span className="action-value risk-text">Legal review required</span>
|
<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>
|
||||||
</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>
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
241
frontend/src/pages/Compliance/NewAnalysisModal.tsx
Normal file
241
frontend/src/pages/Compliance/NewAnalysisModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
frontend/src/pages/Compliance/useComplianceAnalysis.ts
Normal file
161
frontend/src/pages/Compliance/useComplianceAnalysis.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Topbar } from '../../components/layout/Topbar';
|
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';
|
import { UploadModal } from './UploadModal';
|
||||||
|
|
||||||
interface Doc {
|
interface Doc {
|
||||||
@@ -10,30 +10,55 @@ interface Doc {
|
|||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
chunks: number;
|
chunks: number;
|
||||||
type: string;
|
type: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
summary?: string;
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_FILTERS = ['All', 'Ready', 'Embedding', 'Failed', 'Pending'];
|
const STATUS_FILTERS = ['All', 'Ready', 'Processing', '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_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Processing', risk: 'Failed', info: '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'] {
|
function backendStatus(s: string): Doc['status'] {
|
||||||
if (s === 'indexed') return 'ok';
|
if (s === 'indexed') return 'ok';
|
||||||
if (s === 'failed') return 'risk';
|
if (s === 'failed') return 'risk';
|
||||||
if (s === 'parsed') return 'warn'; // chunked, awaiting embedding
|
if (s === 'parsed') return 'warn';
|
||||||
return 'info'; // pending / stored
|
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() {
|
export function DocsPage() {
|
||||||
@@ -41,14 +66,23 @@ export function DocsPage() {
|
|||||||
const [statusF, setStatusF] = useState('All');
|
const [statusF, setStatusF] = useState('All');
|
||||||
const [typeF, setTypeF] = useState('All types');
|
const [typeF, setTypeF] = useState('All types');
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
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 [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')
|
fetch('/api/v1/documents/management-list')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
if (!Array.isArray(d?.documents)) return;
|
if (!Array.isArray(d?.documents)) { setLoading(false); return; }
|
||||||
setDocs(d.documents.map((item: Record<string, unknown>) => ({
|
setDocs(d.documents.map((item: Record<string, unknown>) => ({
|
||||||
id: item.doc_id as string,
|
id: item.doc_id as string,
|
||||||
name: item.doc_name as string,
|
name: item.doc_name as string,
|
||||||
@@ -56,11 +90,18 @@ export function DocsPage() {
|
|||||||
uploadedAt: ((item.updated_at as string) ?? '').slice(0, 10),
|
uploadedAt: ((item.updated_at as string) ?? '').slice(0, 10),
|
||||||
chunks: (item.chunk_count as number) ?? 0,
|
chunks: (item.chunk_count as number) ?? 0,
|
||||||
type: (item.regulation_type as string) || '—',
|
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 filtered = docs.filter(d => {
|
||||||
const matchSearch = !search || d.name.toLowerCase().includes(search.toLowerCase());
|
const matchSearch = !search || d.name.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchStatus = statusF === 'All' || d.status === STATUS_MAP[statusF];
|
const matchStatus = statusF === 'All' || d.status === STATUS_MAP[statusF];
|
||||||
@@ -68,17 +109,60 @@ export function DocsPage() {
|
|||||||
return matchSearch && matchStatus && matchType;
|
return matchSearch && matchStatus && matchType;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Selection helpers ────────────────────────────────────────────────────
|
||||||
function toggleAll() {
|
function toggleAll() {
|
||||||
if (selected.size === filtered.length) setSelected(new Set());
|
if (selected.size === filtered.length) setSelected(new Set());
|
||||||
else setSelected(new Set(filtered.map(d => d.id)));
|
else setSelected(new Set(filtered.map(d => d.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOne(id: string) {
|
function toggleOne(id: string) {
|
||||||
const s = new Set(selected);
|
const s = new Set(selected);
|
||||||
s.has(id) ? s.delete(id) : s.add(id);
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
setSelected(s);
|
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 (
|
return (
|
||||||
<div className="docs-page">
|
<div className="docs-page">
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -93,12 +177,16 @@ export function DocsPage() {
|
|||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||||
|
<RefreshCw size={13} />Refresh
|
||||||
|
</button>
|
||||||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||||||
<Upload size={13} />Upload document
|
<Upload size={13} />Upload document
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
<div className="docs-controls">
|
<div className="docs-controls">
|
||||||
<div className="chip-group">
|
<div className="chip-group">
|
||||||
@@ -111,18 +199,25 @@ export function DocsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Batch action bar */}
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="batch-bar">
|
<div className="batch-bar">
|
||||||
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
|
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
|
||||||
<button className="btn sm">Analyze selected</button>
|
<button
|
||||||
<button className="btn sm risk-btn">Delete selected</button>
|
className="btn sm"
|
||||||
|
style={{ color: 'var(--danger)', borderColor: 'rgba(239,68,68,.4)' }}
|
||||||
|
onClick={() => askDelete([...selected])}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />Delete selected
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
<div className="docs-table">
|
<div className="docs-table">
|
||||||
<div className="table-header">
|
<div className="table-header">
|
||||||
<input
|
<input
|
||||||
@@ -134,32 +229,110 @@ export function DocsPage() {
|
|||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<span>Uploaded</span>
|
<span>Uploaded</span>
|
||||||
<span>Chunks</span>
|
<span>Chunks</span>
|
||||||
|
<span>Size</span>
|
||||||
<span>Type</span>
|
<span>Type</span>
|
||||||
<span>Actions</span>
|
<span>Actions</span>
|
||||||
</div>
|
</div>
|
||||||
{filtered.map(d => (
|
|
||||||
<div key={d.id} className={`table-row${selected.has(d.id) ? ' row-selected' : ''}`}>
|
{loading ? (
|
||||||
<input
|
<div style={{ padding: '32px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
|
||||||
type="checkbox"
|
Loading documents…
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { X, Upload } from 'lucide-react';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onComplete?: () => void; // called when all uploads finish (indexed)
|
||||||
}
|
}
|
||||||
|
|
||||||
const REG_TYPES = ['EU Regulation', 'ISO Standard', 'National Draft', 'Internal Policy'];
|
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);
|
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 [files, setFiles] = useState<File[]>([]);
|
||||||
const [regType, setRegType] = useState(REG_TYPES[0]);
|
const [regType, setRegType] = useState(REG_TYPES[0]);
|
||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
@@ -182,6 +183,7 @@ export function UploadModal({ onClose }: Props) {
|
|||||||
setAllDone(true);
|
setAllDone(true);
|
||||||
setDocStatus('idle');
|
setDocStatus('idle');
|
||||||
setCurrentFileIdx(-1);
|
setCurrentFileIdx(-1);
|
||||||
|
onComplete?.(); // notify parent to refresh doc list
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute queue stage display for the currently-processing file
|
// Compute queue stage display for the currently-processing file
|
||||||
|
|||||||
@@ -1,63 +1,132 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Topbar } from '../../components/layout/Topbar';
|
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';
|
import { UploadModal } from '../Docs/UploadModal';
|
||||||
|
|
||||||
// Backend /api/v1/status/stats returns:
|
// ── API types ──────────────────────────────────────────────────────────────
|
||||||
// { documents_total, documents_indexed, documents_failed, chunks_total }
|
interface Stats {
|
||||||
interface Stats { documents_total: number; documents_indexed: number; documents_failed: number; chunks_total: number; }
|
documents_total: number;
|
||||||
|
documents_indexed: number;
|
||||||
|
documents_failed: number;
|
||||||
|
chunks_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
const TASKS = [
|
interface Health {
|
||||||
{ name: 'EU AI Act — Article 13 check', status: 'ok', progress: 88, cta: 'View report' },
|
milvus: { status: string; connected?: boolean; collection_name?: string; num_entities?: number; error?: string };
|
||||||
{ name: 'GB/T 42118 compliance scan', status: 'warn', progress: 54, cta: 'Continue' },
|
minio: { status: string; connected: boolean };
|
||||||
{ name: 'MIIT Draft — automotive AI embedding', status: 'info', progress: 12, cta: 'Start' },
|
bm25: { available: boolean };
|
||||||
];
|
reranker: { enabled: boolean; model: string | null };
|
||||||
|
sessions: { active: number; max: number };
|
||||||
|
}
|
||||||
|
|
||||||
const PROGRAMS = [
|
interface Config {
|
||||||
{ name: 'EU AI Act Readiness', status: 'ok', coverage: 88 },
|
embedding_model: string;
|
||||||
{ name: 'China MIIT Compliance', status: 'warn', coverage: 54 },
|
embedding_dim: number;
|
||||||
{ name: 'ISO/SAE 21434 Audit', status: 'info', coverage: 32 },
|
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 = [
|
// ── Small helpers ──────────────────────────────────────────────────────────
|
||||||
{ label: 'Retrieval hit rate', value: 94, unit: '%' },
|
function StatusIcon({ status }: { status: 'ok' | 'error' | 'warn' | 'info' }) {
|
||||||
{ label: 'Evidence coverage', value: 78, unit: '%' },
|
if (status === 'ok') return <CheckCircle size={14} color="var(--ok)" />;
|
||||||
{ label: 'Reviewer SLA', value: 91, unit: '%' },
|
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 = [
|
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
|
||||||
{ name: 'Vector store (Chroma)', status: 'ok' },
|
return (
|
||||||
{ name: 'LLM gateway (Claude)', status: 'ok' },
|
<div className="service-row">
|
||||||
{ name: 'Document parser', status: 'ok' },
|
<StatusIcon status={status} />
|
||||||
{ name: 'SSE stream endpoint', status: 'ok' },
|
<span className="service-name" style={{ marginLeft: 8 }}>{name}</span>
|
||||||
{ name: 'Regulation feed sync', status: 'warn' },
|
{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 = [
|
function ConfigRow({ label, value }: { label: string; value: string | number | null | undefined }) {
|
||||||
{ 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.' },
|
return (
|
||||||
{ date: '2025-10-30', title: 'MIIT Draft — automotive AI', summary: 'New draft regulation covers in-vehicle AI training data provenance and OTA update governance.' },
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 0', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
|
||||||
{ date: '2025-10-05', title: 'ISO/SAE 21434 amendment', summary: 'Amendment 1 clarifies cybersecurity management system scope for software-only updates.' },
|
<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 ?? '—'}
|
||||||
const STATUS_LABEL: Record<string, string> = { ok: 'Complete', warn: 'In progress', info: 'Pending' };
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
export function StatusPage() {
|
export function StatusPage() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
|
const [healthLoading, setHealthLoading] = useState(true);
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch('/api/v1/status/stats')
|
setHealthLoading(true);
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => { setStats(d); setLoading(false); })
|
// Fetch all three endpoints in parallel
|
||||||
.catch(() => {
|
Promise.allSettled([
|
||||||
setStats({ documents_total: 42, documents_indexed: 38, documents_failed: 1, chunks_total: 3841 });
|
fetch('/api/v1/status/stats').then(r => r.json()),
|
||||||
setLoading(false);
|
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]);
|
}, [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 (
|
return (
|
||||||
<div className="status-page">
|
<div className="status-page">
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -68,17 +137,8 @@ export function StatusPage() {
|
|||||||
<Search size={13} />
|
<Search size={13} />
|
||||||
<input placeholder="Search..." />
|
<input placeholder="Search..." />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button className="btn sm" onClick={handleExport}>
|
||||||
className="btn sm"
|
<Download size={13} />Export
|
||||||
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>
|
</button>
|
||||||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||||
<RefreshCw size={13} />Refresh
|
<RefreshCw size={13} />Refresh
|
||||||
@@ -89,7 +149,10 @@ export function StatusPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
|
|
||||||
|
{/* ── Stats grid ────────────────────────────────────────────────── */}
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<div className="stat-cell">
|
<div className="stat-cell">
|
||||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
|
{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>
|
||||||
</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-grid">
|
||||||
<div className="panel-left">
|
<div className="panel-left">
|
||||||
|
|
||||||
|
{/* System health */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">Workflow queue</div>
|
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
{TASKS.map(t => (
|
<span>System health</span>
|
||||||
<div key={t.name} className="task-row">
|
{lastRefresh && (
|
||||||
<div className="task-info">
|
<span style={{ fontSize: 11, color: 'var(--muted)', fontWeight: 400 }}>
|
||||||
<div className="task-name">{t.name}</div>
|
Updated {lastRefresh.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
<div className="task-progress-bar">
|
</span>
|
||||||
<div className="task-progress-fill" style={{ width: `${t.progress}%` }} />
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<span className={`status ${t.status}`}>{STATUS_LABEL[t.status]}</span>
|
{healthLoading ? (
|
||||||
<button className="btn sm">{t.cta}</button>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* System config (collapsible) */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">Active compliance programs</div>
|
<button
|
||||||
{PROGRAMS.map(p => (
|
onClick={() => setConfigOpen(v => !v)}
|
||||||
<div key={p.name} className="program-row">
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||||
<span className={`status ${p.status}`} style={{ marginRight: 'auto' }}>{p.name}</span>
|
>
|
||||||
<span className="program-pct">{p.coverage}%</span>
|
<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>
|
||||||
))}
|
)}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="panel-right">
|
<div className="panel-right">
|
||||||
|
|
||||||
|
{/* Document breakdown */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">System health</div>
|
<div className="card-header">Document breakdown</div>
|
||||||
{SERVICES.map(s => (
|
{loading ? (
|
||||||
<div key={s.name} className="service-row">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<span className="service-name">{s.name}</span>
|
{[1, 2, 3].map(i => <div key={i} className="loading-shimmer" style={{ height: 24, borderRadius: 4 }} />)}
|
||||||
<span className={`status ${s.status}`}>{s.status === 'ok' ? 'Online' : 'Degraded'}</span>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="card">
|
{/* Sessions & reranker quick facts */}
|
||||||
<div className="card-header">Regulatory watch</div>
|
{health && (
|
||||||
{EVENTS.map(e => (
|
<div className="card">
|
||||||
<div key={e.title} className="event-row">
|
<div className="card-header">Runtime info</div>
|
||||||
<div className="event-date">{e.date}</div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div className="event-title">{e.title}</div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||||
<div className="event-summary">{e.summary}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="page-footer">
|
<footer className="page-footer">
|
||||||
<div className="live-dot" />
|
<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>
|
</footer>
|
||||||
|
|
||||||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||||
|
|||||||
@@ -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; }
|
.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; }
|
.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 pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* ── Overview Page ──────────────────────────────── */
|
/* ── Overview Page ──────────────────────────────── */
|
||||||
.overview-page { padding: 32px; max-width: 900px; display: flex; flex-direction: column; gap: 32px; }
|
.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; }
|
.docs-table { background: var(--surface); border-radius: var(--radius-md); box-shadow: var(--shadow-card); overflow: hidden; }
|
||||||
.table-header {
|
.table-header {
|
||||||
display: grid;
|
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;
|
gap: 12px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -483,17 +484,18 @@ body {
|
|||||||
}
|
}
|
||||||
.table-row {
|
.table-row {
|
||||||
display: grid;
|
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;
|
gap: 12px;
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s, opacity 0.3s;
|
||||||
}
|
}
|
||||||
.table-row:last-child { border-bottom: none; }
|
.table-row:last-child { border-bottom: none; }
|
||||||
.table-row:hover { background: var(--bg); }
|
.table-row:hover { background: var(--bg); }
|
||||||
.table-row.row-selected { background: var(--accent-dim); }
|
.table-row.row-selected { background: var(--accent-dim); }
|
||||||
|
.table-row.row-deleting { opacity: 0.4; pointer-events: none; }
|
||||||
.doc-name-cell { font-weight: 500; }
|
.doc-name-cell { font-weight: 500; }
|
||||||
.cell-mono { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
|
.cell-mono { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
|
||||||
.cell-muted { font-size: 12px; 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-sm strong { font-size: 13px; font-family: var(--font-mono); }
|
||||||
.summary-card-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user