update for 1. 优化 2.中英切换
This commit is contained in:
4
.env
4
.env
@@ -48,8 +48,8 @@ CHUNK_OVERLAP=50
|
||||
MAX_FILE_SIZE_MB=100
|
||||
PARSER_BACKEND=aliyun
|
||||
CHUNK_BACKEND=aliyun
|
||||
# 文档元数据存储后端:json(默认)或 postgres
|
||||
DOCUMENT_REPOSITORY_BACKEND=json
|
||||
# 文档元数据存储后端:启用 postgres 以激活合规分析历史记录(Direction B)及 Finding Chat 持久化(Direction C)
|
||||
DOCUMENT_REPOSITORY_BACKEND=postgres
|
||||
# Set to true only when a Celery worker is actually running (./dev.sh start worker).
|
||||
# Default false: processing runs in FastAPI's threadpool — no external worker needed.
|
||||
USE_CELERY_WORKER=false
|
||||
|
||||
@@ -31,5 +31,5 @@ POSTGRES_PASSWORD=postgresql123456
|
||||
POSTGRES_DB=compliance_db
|
||||
|
||||
# ===== 文档元数据后端 =====
|
||||
# 改为 postgres 以启用 PG 持久化(structure_nodes + semantic_blocks 入库)
|
||||
# 改为 postgres 以启用合规分析历史记录(Direction B)和 Finding Chat(Direction C)
|
||||
DOCUMENT_REPOSITORY_BACKEND=json
|
||||
|
||||
@@ -49,7 +49,11 @@ MAX_FILE_SIZE_MB=100
|
||||
DOCUMENT_METADATA_PATH=backend/data/documents.json
|
||||
PARSER_BACKEND=aliyun
|
||||
CHUNK_BACKEND=aliyun
|
||||
# DOCUMENT_REPOSITORY_BACKEND=json(默认,无需数据库)或 postgres(启用 PG 持久化)
|
||||
# 文档元数据存储后端:json(默认,无需数据库)或 postgres(启用 PG 持久化)
|
||||
# ⚠ 以下功能需要 postgres(设为 json 时功能静默降级或报 500):
|
||||
# - Direction B: 合规分析历史记录 (/compliance/history/*)
|
||||
# - Direction B: DOCX 报告下载
|
||||
# - Direction C: Finding Chat 消息持久化
|
||||
DOCUMENT_REPOSITORY_BACKEND=json
|
||||
# Set to true only when a Celery worker is running (./dev.sh start worker).
|
||||
# Default false: document processing runs in FastAPI's threadpool (no external worker needed).
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<h2>Compliance Analysis — 哪个方向最值得优化?</h2>
|
||||
<p class="subtitle">基于代码深度分析,发现了 4 个有价值的改进方向。选择你最希望深入的那个。</p>
|
||||
|
||||
<div class="options">
|
||||
|
||||
<div class="option" data-choice="A" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>⚡ 分析质量提升</h3>
|
||||
<p>并行子句处理(速度 3–5×)、跨编码器重排序、置信度过滤、修复 highlight_terms 失效 Bug、减少 LLM 静默失败。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>更快、更准确的分析</li><li>消除当前 Bug</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需要改造 pipeline.py</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="B" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>📋 分析历史 & 专业报告</h3>
|
||||
<p>持久化分析记录(PostgreSQL)、历史对比、PDF/DOCX 专业报告导出、分析版本追踪。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>结果不再丢失</li><li>可交付给客户的报告</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需要新增数据库表</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="C" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>💬 深度 Chat 增强</h3>
|
||||
<p>每个 Finding 独立对话线程(持久化)、Chat 上下文绑定真实检索到的法规原文、多轮追问记忆、快捷建议问句生成。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>Finding 解读深度大幅提升</li><li>用户粘性强</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需重构 chat 端点</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="D" onclick="toggleSelect(this)">
|
||||
<div class="letter">D</div>
|
||||
<div class="content">
|
||||
<h3>📑 自定义规则 & 模板</h3>
|
||||
<p>用户自定义合规规则库、按行业预设模板(汽车/金融/医疗)、Prompt 版本管理、A/B 测试不同提示策略。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>适应不同行业场景</li><li>可配置,无需改代码</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需要规则管理 UI</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="subtitle" style="margin-top:20px">💡 也可以多选,或者在终端告诉我你有其他想法。</p>
|
||||
3
.superpowers/brainstorm/1055-1780892298/state/events
Normal file
3
.superpowers/brainstorm/1055-1780892298/state/events
Normal file
@@ -0,0 +1,3 @@
|
||||
{"type":"click","text":"C\n \n 💬 深度 Chat 增强\n 每个 Finding 独立对话线程(持久化)、Chat 上下文绑定真实检索到的法规原文、多轮追问记忆、快捷建议问句生成。\n \n 收益Finding 解读深度大幅提升用户粘性强\n 难度需重构 chat 端点","choice":"C","id":null,"timestamp":1780897984866}
|
||||
{"type":"click","text":"B\n \n 📋 分析历史 & 专业报告\n 持久化分析记录(PostgreSQL)、历史对比、PDF/DOCX 专业报告导出、分析版本追踪。\n \n 收益结果不再丢失可交付给客户的报告\n 难度需要新增数据库表","choice":"B","id":null,"timestamp":1780897985879}
|
||||
{"type":"click","text":"A\n \n ⚡ 分析质量提升\n 并行子句处理(速度 3–5×)、跨编码器重排序、置信度过滤、修复 highlight_terms 失效 Bug、减少 LLM 静默失败。\n \n 收益更快、更准确的分析消除当前 Bug\n 难度需要改造 pipeline.py","choice":"A","id":null,"timestamp":1780897986554}
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1780894411095}
|
||||
1
.superpowers/brainstorm/1055-1780892298/state/server.pid
Normal file
1
.superpowers/brainstorm/1055-1780892298/state/server.pid
Normal file
@@ -0,0 +1 @@
|
||||
1055
|
||||
@@ -85,10 +85,9 @@ async def analyze_stream(
|
||||
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,
|
||||
run_clauses_parallel,
|
||||
split_into_clauses,
|
||||
synthesize_conclusion,
|
||||
)
|
||||
@@ -136,22 +135,28 @@ async def analyze_stream(
|
||||
await asyncio.sleep(0)
|
||||
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
|
||||
|
||||
# ── Stage 3: retrieve + gap check per clause ──────────────────
|
||||
# ── Stage 3: retrieve + gap check (parallel across all clauses) ────────────
|
||||
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)
|
||||
yield _sse({
|
||||
"type": "stage",
|
||||
"stage": "analyzing",
|
||||
"label": f"Analyzing {len(clauses)} clauses in parallel…",
|
||||
})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
chunks = await asyncio.to_thread(
|
||||
retrieve_for_clause, clause, retrieval_service, 5, domains or None
|
||||
)
|
||||
clause_results = await run_clauses_parallel(
|
||||
clauses, retrieval_service, client,
|
||||
top_k=5,
|
||||
domains=domains or None,
|
||||
)
|
||||
|
||||
# Emit source events
|
||||
for res in clause_results:
|
||||
i = res["index"]
|
||||
chunks = res["chunks"]
|
||||
finding = res["finding"]
|
||||
|
||||
# Emit source events for this clause
|
||||
for chunk in chunks[:3]:
|
||||
yield _sse({
|
||||
"type": "source",
|
||||
@@ -161,12 +166,11 @@ async def analyze_stream(
|
||||
"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 ────────────────────────────
|
||||
@@ -178,6 +182,45 @@ async def analyze_stream(
|
||||
)
|
||||
yield _sse({"type": "done", **conclusion_data})
|
||||
|
||||
# Auto-save analysis to database
|
||||
try:
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from app.domain.compliance.ports import AnalysisRecord, FindingRecord
|
||||
from datetime import datetime
|
||||
|
||||
repo = get_compliance_repository()
|
||||
finding_records = [
|
||||
FindingRecord(
|
||||
id="",
|
||||
analysis_id="",
|
||||
seq=i,
|
||||
title=f.get("title", ""),
|
||||
description=f.get("desc", ""),
|
||||
status=f.get("status", "ok"),
|
||||
clause_ref=f.get("clause_ref"),
|
||||
)
|
||||
for i, f in enumerate(findings)
|
||||
]
|
||||
record = AnalysisRecord(
|
||||
id="",
|
||||
created_at=datetime.utcnow(),
|
||||
created_by=current_user.username if hasattr(current_user, "username") else None,
|
||||
doc_name=file_name or (title or "Pasted text"),
|
||||
standard_name=title or "",
|
||||
risk_score=conclusion_data.get("risk_score", 0),
|
||||
conclusion=conclusion_data.get("conclusion", ""),
|
||||
actions=conclusion_data.get("actions", []),
|
||||
para_text=conclusion_data.get("para_text", ""),
|
||||
highlight_terms=conclusion_data.get("highlight_terms", []),
|
||||
findings=finding_records,
|
||||
)
|
||||
analysis_id = await asyncio.to_thread(repo.save_analysis, record)
|
||||
yield _sse({"type": "saved", "analysis_id": analysis_id})
|
||||
except NotImplementedError:
|
||||
pass # No postgres backend configured — skip saving
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to auto-save compliance analysis: {}", exc)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("analyze-stream pipeline error")
|
||||
yield _sse({"type": "error", "text": str(exc)})
|
||||
@@ -225,3 +268,226 @@ async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def list_history(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return paginated list of saved compliance analyses (newest first)."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
try:
|
||||
repo = get_compliance_repository()
|
||||
records = await asyncio.to_thread(repo.list_analyses, limit, offset)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"created_at": r.created_at.isoformat(),
|
||||
"created_by": r.created_by,
|
||||
"doc_name": r.doc_name,
|
||||
"standard_name": r.standard_name,
|
||||
"risk_score": r.risk_score,
|
||||
"finding_count": len(r.findings),
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
except NotImplementedError:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/history/{analysis_id}")
|
||||
async def get_history_item(
|
||||
analysis_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return full analysis record including findings."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from fastapi import HTTPException
|
||||
repo = get_compliance_repository()
|
||||
record = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return {
|
||||
"id": record.id,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"created_by": record.created_by,
|
||||
"doc_name": record.doc_name,
|
||||
"standard_name": record.standard_name,
|
||||
"risk_score": record.risk_score,
|
||||
"conclusion": record.conclusion,
|
||||
"actions": record.actions,
|
||||
"para_text": record.para_text,
|
||||
"highlight_terms": record.highlight_terms,
|
||||
"findings": [
|
||||
{
|
||||
"id": f.id,
|
||||
"seq": f.seq,
|
||||
"title": f.title,
|
||||
"description": f.description,
|
||||
"status": f.status,
|
||||
"clause_ref": f.clause_ref,
|
||||
}
|
||||
for f in record.findings
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/history/{analysis_id}", status_code=204)
|
||||
async def delete_history_item(
|
||||
analysis_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a saved analysis (cascade removes findings and chat messages)."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
repo = get_compliance_repository()
|
||||
await asyncio.to_thread(repo.delete_analysis, analysis_id)
|
||||
|
||||
|
||||
@router.get("/history/{analysis_id}/download")
|
||||
async def download_history_docx(
|
||||
analysis_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return a DOCX compliance report for the given analysis."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from app.infrastructure.compliance.docx_export import generate_docx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
repo = get_compliance_repository()
|
||||
record = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
|
||||
docx_bytes = await asyncio.to_thread(generate_docx, record)
|
||||
safe_name = (record.doc_name or "report").replace(" ", "_")[:50]
|
||||
filename = f"compliance_{safe_name}_{record.created_at.strftime('%Y%m%d')}.docx"
|
||||
return Response(
|
||||
content=docx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analyses/{analysis_id}/findings/{finding_id}/chat")
|
||||
async def get_finding_chat_history(
|
||||
analysis_id: str,
|
||||
finding_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return persisted chat messages for a finding thread, oldest first."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
try:
|
||||
repo = get_compliance_repository()
|
||||
messages = await asyncio.to_thread(repo.get_messages, finding_id)
|
||||
return messages
|
||||
except NotImplementedError:
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/analyses/{analysis_id}/findings/{finding_id}/suggestions")
|
||||
async def get_finding_suggestions(
|
||||
analysis_id: str,
|
||||
finding_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Generate 3 LLM-powered follow-up question suggestions for a finding."""
|
||||
from app.application.compliance.pipeline import generate_suggestions
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from app.services.llm.llm_factory import get_llm_client
|
||||
from fastapi import HTTPException
|
||||
|
||||
repo = get_compliance_repository()
|
||||
analysis = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
|
||||
finding = next((f for f in analysis.findings if f.id == finding_id), None)
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
|
||||
client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
|
||||
questions = await asyncio.to_thread(generate_suggestions, finding, analysis, client)
|
||||
return {"questions": questions}
|
||||
|
||||
|
||||
@router.post("/analyses/{analysis_id}/findings/{finding_id}/chat")
|
||||
async def finding_chat(
|
||||
analysis_id: str,
|
||||
finding_id: str,
|
||||
request: ComplianceChatRequest,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Stream a grounded chat response for a specific finding.
|
||||
|
||||
Loads the finding and analysis from DB to build grounded context.
|
||||
Persists both user message and assistant response to finding_chat_messages.
|
||||
"""
|
||||
from app.application.compliance.pipeline import build_finding_context
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from fastapi import HTTPException
|
||||
|
||||
repo = get_compliance_repository()
|
||||
analysis = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
finding = next((f for f in analysis.findings if f.id == finding_id), None)
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
|
||||
# Persist user message
|
||||
await asyncio.to_thread(
|
||||
repo.save_message, analysis_id, finding_id, "user", request.query
|
||||
)
|
||||
|
||||
# Build message history (last 10 messages = 5 turns)
|
||||
history = await asyncio.to_thread(repo.get_messages, finding_id)
|
||||
history_messages = [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in history[-10:]
|
||||
]
|
||||
|
||||
# Build grounded system context
|
||||
system_context = build_finding_context(finding, analysis)
|
||||
full_query = f"[Compliance Finding Context]\n{system_context}\n\nUser question: {request.query}"
|
||||
|
||||
assistant_buffer: list[str] = []
|
||||
|
||||
async def generate() -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
_, event_stream = get_agent_conversation_service().stream_chat(
|
||||
query=full_query,
|
||||
top_k=5,
|
||||
prompt_template="compliance_qa",
|
||||
)
|
||||
for event in event_stream:
|
||||
event_type = event.get("event", "")
|
||||
if event_type == "content":
|
||||
text = event.get("data", "")
|
||||
if text:
|
||||
assistant_buffer.append(text)
|
||||
yield _sse({"type": "chunk", "text": text})
|
||||
elif event_type == "done":
|
||||
yield _sse({"type": "done"})
|
||||
await asyncio.sleep(0)
|
||||
except Exception as exc:
|
||||
logger.exception("finding_chat stream error")
|
||||
yield _sse({"type": "error", "text": str(exc)})
|
||||
finally:
|
||||
# Persist assistant response after stream completes
|
||||
full_response = "".join(assistant_buffer)
|
||||
if full_response:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
repo.save_message, analysis_id, finding_id, "assistant", full_response
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist assistant message: {}", exc)
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ All functions are synchronous — call them via asyncio.to_thread() in async SSE
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -12,10 +13,20 @@ import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
# Shared retry policy for LLM calls: 3 attempts, exponential back-off 1–4 s.
|
||||
_llm_retry = retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=4),
|
||||
retry=retry_if_exception_type((ValueError, TimeoutError, ConnectionError)),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.application.knowledge import KnowledgeRetrievalService
|
||||
from app.domain.retrieval import RetrievedChunk
|
||||
from app.domain.compliance.ports import AnalysisRecord, FindingRecord
|
||||
from app.services.llm.base_client import BaseLLMClient
|
||||
|
||||
|
||||
@@ -109,17 +120,67 @@ def retrieve_for_clause(
|
||||
return retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
|
||||
|
||||
|
||||
def process_single_clause(
|
||||
clause: str,
|
||||
index: int,
|
||||
retrieval_service: "KnowledgeRetrievalService",
|
||||
client: "BaseLLMClient",
|
||||
top_k: int = 5,
|
||||
domains: str | None = None,
|
||||
) -> dict:
|
||||
"""Process one clause: retrieve relevant regulations then check compliance.
|
||||
|
||||
Returns a dict with keys: index, chunks, finding (may be None on LLM failure).
|
||||
Designed to run inside asyncio.to_thread() for parallel execution.
|
||||
"""
|
||||
chunks = retrieve_for_clause(clause, retrieval_service, top_k, domains)
|
||||
finding = check_clause_compliance(clause, chunks, client)
|
||||
return {"index": index, "chunks": chunks, "finding": finding}
|
||||
|
||||
|
||||
async def run_clauses_parallel(
|
||||
clauses: list[str],
|
||||
retrieval_service: "KnowledgeRetrievalService",
|
||||
client: "BaseLLMClient",
|
||||
top_k: int = 5,
|
||||
domains: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Run all clauses through retrieve+gap-check in parallel.
|
||||
|
||||
Results are returned in the original clause order even though processing
|
||||
is concurrent. Exceptions in individual clauses are caught and returned as
|
||||
dicts with finding=None so the stream continues for remaining clauses.
|
||||
|
||||
Both retrieval_service and client must be thread-safe — they are shared
|
||||
across all asyncio.to_thread() calls without locking.
|
||||
"""
|
||||
tasks = [
|
||||
asyncio.to_thread(
|
||||
process_single_clause,
|
||||
clause, i, retrieval_service, client, top_k, domains,
|
||||
)
|
||||
for i, clause in enumerate(clauses)
|
||||
]
|
||||
raw = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = []
|
||||
for i, r in enumerate(raw):
|
||||
if isinstance(r, Exception):
|
||||
logger.warning("Clause {} processing failed: {}", i, r)
|
||||
results.append({"index": i, "chunks": [], "finding": None})
|
||||
else:
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
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])
|
||||
)
|
||||
) if chunks else "(no regulatory context retrieved)"
|
||||
prompt = (
|
||||
"You are a compliance expert. Judge whether the following business clause "
|
||||
"complies with the retrieved regulations.\n\n"
|
||||
@@ -135,9 +196,19 @@ def check_clause_compliance(
|
||||
"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:
|
||||
|
||||
def _do_check():
|
||||
resp = client.chat([{"role": "user", "content": prompt}], max_tokens=500)
|
||||
if not resp.is_success:
|
||||
raise ValueError("LLM returned non-success for gap check")
|
||||
return resp
|
||||
|
||||
try:
|
||||
response = _llm_retry(_do_check)()
|
||||
except Exception as exc:
|
||||
logger.warning("check_clause_compliance LLM call failed after retries: {}", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
result = _extract_json(response.content)
|
||||
if isinstance(result, dict) and "status" in result:
|
||||
@@ -182,12 +253,11 @@ def synthesize_conclusion(
|
||||
' {"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'
|
||||
' "highlight_terms": ["term1", "term2"], // up to 10 key technical/legal terms actually present in the text\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": [
|
||||
@@ -198,8 +268,19 @@ def synthesize_conclusion(
|
||||
"highlight_terms": [],
|
||||
"para_text": para_text[:800],
|
||||
}
|
||||
if not response.is_success:
|
||||
|
||||
def _do_synthesize():
|
||||
resp = client.chat([{"role": "user", "content": prompt}], max_tokens=1200)
|
||||
if not resp.is_success:
|
||||
raise ValueError("LLM returned non-success for synthesis")
|
||||
return resp
|
||||
|
||||
try:
|
||||
response = _llm_retry(_do_synthesize)()
|
||||
except Exception as exc:
|
||||
logger.warning("synthesize_conclusion LLM call failed after retries: {}", exc)
|
||||
return fallback
|
||||
|
||||
try:
|
||||
result = _extract_json(response.content)
|
||||
if isinstance(result, dict):
|
||||
@@ -213,3 +294,77 @@ def synthesize_conclusion(
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning("Conclusion synthesis JSON parse failed: {}", exc)
|
||||
return fallback
|
||||
|
||||
|
||||
_SUGGESTION_FOCUS = {
|
||||
"risk": "Focus on remediation steps, required certifications, and timeline to resolve.",
|
||||
"warn": "Focus on identifying the specific compliance gap and how to close it.",
|
||||
"ok": "Focus on maintaining compliance evidence and monitoring future changes.",
|
||||
}
|
||||
|
||||
_SUGGESTION_FALLBACK = {
|
||||
"risk": [
|
||||
"What specific certifications or documents are required to remediate this finding?",
|
||||
"What is the typical remediation timeline for this type of non-compliance?",
|
||||
"Which regulation clause defines the exact requirement?",
|
||||
],
|
||||
"warn": [
|
||||
"What is the exact gap between the current state and the requirement?",
|
||||
"What evidence would demonstrate partial compliance?",
|
||||
"Which regulation clause applies to this warning?",
|
||||
],
|
||||
"ok": [
|
||||
"What documentation should be maintained to evidence this compliance?",
|
||||
"How should this area be monitored as regulations evolve?",
|
||||
"Are there related clauses that may affect this compliant area?",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_finding_context(finding: "FindingRecord", analysis: "AnalysisRecord") -> str:
|
||||
"""Build a grounded system context string for a finding chat thread.
|
||||
|
||||
Combines finding details with analysis metadata so the LLM has full
|
||||
context without relying on the frontend to pass segment_context.
|
||||
"""
|
||||
return (
|
||||
f"Document: {analysis.doc_name}\n"
|
||||
f"Standard: {analysis.standard_name}\n"
|
||||
f"Finding [{finding.seq + 1}]: {finding.title}\n"
|
||||
f"Status: {finding.status}\n"
|
||||
f"Clause reference: {finding.clause_ref or 'N/A'}\n"
|
||||
f"Description: {finding.description}\n"
|
||||
f"Overall conclusion: {analysis.conclusion}\n"
|
||||
)
|
||||
|
||||
|
||||
def generate_suggestions(
|
||||
finding: "FindingRecord",
|
||||
analysis: "AnalysisRecord",
|
||||
client: "BaseLLMClient",
|
||||
) -> list[str]:
|
||||
"""Generate 3 context-aware follow-up questions for a finding chat thread.
|
||||
|
||||
Returns exactly 3 question strings. Falls back to static templates on error.
|
||||
"""
|
||||
fallback = _SUGGESTION_FALLBACK.get(finding.status, _SUGGESTION_FALLBACK["warn"])
|
||||
context = build_finding_context(finding, analysis)
|
||||
focus = _SUGGESTION_FOCUS.get(finding.status, _SUGGESTION_FOCUS["warn"])
|
||||
prompt = (
|
||||
f"{context}\n\n"
|
||||
f"Task: {focus}\n"
|
||||
"Generate exactly 3 concise follow-up questions a compliance analyst would ask.\n"
|
||||
'Return JSON: {"questions": ["question 1", "question 2", "question 3"]}\n'
|
||||
"Return ONLY the JSON object."
|
||||
)
|
||||
response = client.chat([{"role": "user", "content": prompt}], max_tokens=300)
|
||||
if not response.is_success:
|
||||
return fallback
|
||||
try:
|
||||
result = _extract_json(response.content)
|
||||
questions = result.get("questions", [])
|
||||
if isinstance(questions, list) and len(questions) >= 3:
|
||||
return [str(q) for q in questions[:3]]
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning("generate_suggestions JSON parse failed: {}", exc)
|
||||
return fallback
|
||||
|
||||
0
backend/app/domain/compliance/__init__.py
Normal file
0
backend/app/domain/compliance/__init__.py
Normal file
66
backend/app/domain/compliance/ports.py
Normal file
66
backend/app/domain/compliance/ports.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Domain ports for compliance history persistence."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class FindingRecord:
|
||||
"""Single finding row linked to an analysis."""
|
||||
id: str
|
||||
analysis_id: str
|
||||
seq: int
|
||||
title: str
|
||||
description: str
|
||||
status: str # "ok" | "warn" | "risk"
|
||||
clause_ref: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisRecord:
|
||||
"""Full compliance analysis record with nested findings."""
|
||||
id: str # UUID string; empty string means not yet persisted
|
||||
created_at: datetime
|
||||
created_by: Optional[str]
|
||||
doc_name: str
|
||||
standard_name: str
|
||||
risk_score: int
|
||||
conclusion: str
|
||||
actions: list # list[dict] — serialised action items
|
||||
para_text: str
|
||||
highlight_terms: list # list[str]
|
||||
findings: list[FindingRecord] = field(default_factory=list)
|
||||
|
||||
|
||||
class ComplianceRepository(ABC):
|
||||
"""Port for persisting and retrieving compliance analysis records."""
|
||||
|
||||
@abstractmethod
|
||||
def save_analysis(self, record: AnalysisRecord) -> str:
|
||||
"""Persist a new analysis record and return the assigned UUID string."""
|
||||
|
||||
@abstractmethod
|
||||
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]:
|
||||
"""Return analyses ordered by created_at DESC, without nested findings."""
|
||||
|
||||
@abstractmethod
|
||||
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]:
|
||||
"""Return a single analysis with all nested findings, or None."""
|
||||
|
||||
@abstractmethod
|
||||
def delete_analysis(self, analysis_id: str) -> None:
|
||||
"""Delete an analysis and all related findings and chat messages (cascade)."""
|
||||
|
||||
@abstractmethod
|
||||
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str:
|
||||
"""Persist a chat message and return its UUID string."""
|
||||
|
||||
@abstractmethod
|
||||
def get_messages(self, finding_id: str) -> list[dict]:
|
||||
"""Return chat messages for a finding ordered by created_at ASC.
|
||||
|
||||
Each dict has keys: id, role, content, created_at (ISO string).
|
||||
"""
|
||||
0
backend/app/infrastructure/compliance/__init__.py
Normal file
0
backend/app/infrastructure/compliance/__init__.py
Normal file
101
backend/app/infrastructure/compliance/docx_export.py
Normal file
101
backend/app/infrastructure/compliance/docx_export.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""DOCX report generator for compliance analysis results.
|
||||
|
||||
Uses python-docx (already in requirements.txt). Returns raw bytes so the
|
||||
caller can stream the response without writing to disk.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
from app.domain.compliance.ports import AnalysisRecord
|
||||
|
||||
_STATUS_LABEL = {"ok": "Compliant", "warn": "Warning", "risk": "Non-Compliant"}
|
||||
_STATUS_COLOR = {
|
||||
"ok": RGBColor(0x22, 0x8B, 0x22),
|
||||
"warn": RGBColor(0xFF, 0x8C, 0x00),
|
||||
"risk": RGBColor(0xDC, 0x14, 0x3C),
|
||||
}
|
||||
|
||||
|
||||
def generate_docx(record: AnalysisRecord) -> bytes:
|
||||
"""Generate a compliance report DOCX and return its raw bytes.
|
||||
|
||||
Structure:
|
||||
- Cover: document name, standard, date, risk score
|
||||
- Executive summary (conclusion)
|
||||
- Findings table
|
||||
- Recommended actions
|
||||
- Footer note
|
||||
"""
|
||||
doc = Document()
|
||||
|
||||
# ── Cover ──────────────────────────────────────────────────────────────────
|
||||
title_para = doc.add_heading("Compliance Analysis Report", level=0)
|
||||
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
doc.add_paragraph("")
|
||||
meta_table = doc.add_table(rows=4, cols=2)
|
||||
meta_table.style = "Table Grid"
|
||||
labels = ["Document", "Standard", "Date", "Risk Score"]
|
||||
values = [
|
||||
record.doc_name,
|
||||
record.standard_name,
|
||||
record.created_at.strftime("%Y-%m-%d %H:%M UTC") if record.created_at else "",
|
||||
f"{record.risk_score} / 100",
|
||||
]
|
||||
for i, (label, value) in enumerate(zip(labels, values)):
|
||||
meta_table.cell(i, 0).text = label
|
||||
meta_table.cell(i, 1).text = value
|
||||
|
||||
# ── Executive Summary ──────────────────────────────────────────────────────
|
||||
doc.add_heading("Executive Summary", level=1)
|
||||
doc.add_paragraph(record.conclusion)
|
||||
|
||||
# ── Findings ───────────────────────────────────────────────────────────────
|
||||
doc.add_heading("Findings", level=1)
|
||||
if record.findings:
|
||||
table = doc.add_table(rows=1, cols=4)
|
||||
table.style = "Table Grid"
|
||||
hdr = table.rows[0].cells
|
||||
for i, h in enumerate(["#", "Status", "Title", "Description / Clause"]):
|
||||
hdr[i].text = h
|
||||
for run in hdr[i].paragraphs[0].runs:
|
||||
run.bold = True
|
||||
|
||||
for f in record.findings:
|
||||
row = table.add_row().cells
|
||||
row[0].text = str(f.seq + 1)
|
||||
row[1].text = _STATUS_LABEL.get(f.status, f.status)
|
||||
row[2].text = f.title
|
||||
desc = f.description
|
||||
if f.clause_ref:
|
||||
desc += f"\n[{f.clause_ref}]"
|
||||
row[3].text = desc
|
||||
else:
|
||||
doc.add_paragraph("No findings recorded.")
|
||||
|
||||
# ── Recommended Actions ────────────────────────────────────────────────────
|
||||
doc.add_heading("Recommended Actions", level=1)
|
||||
for i, action in enumerate(record.actions, start=1):
|
||||
label = action.get("label", "Action")
|
||||
value = action.get("value", "")
|
||||
doc.add_paragraph(f"{i}. {label}: {value}", style="List Number")
|
||||
|
||||
# ── Footer note ────────────────────────────────────────────────────────────
|
||||
doc.add_paragraph("")
|
||||
footer = doc.add_paragraph(
|
||||
f"Generated by AI Regulation Analysis System — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
|
||||
)
|
||||
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
for run in footer.runs:
|
||||
run.font.size = Pt(8)
|
||||
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||||
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
280
backend/app/infrastructure/compliance/repository.py
Normal file
280
backend/app/infrastructure/compliance/repository.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# backend/app/infrastructure/compliance/repository.py
|
||||
"""PostgreSQL-backed compliance analysis repository.
|
||||
|
||||
Follows the same psycopg2 pattern as PostgresDocumentRepository:
|
||||
ThreadedConnectionPool + RealDictCursor for reads, _ensure_schema on init.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import psycopg2.pool
|
||||
from loguru import logger
|
||||
|
||||
from app.domain.compliance.ports import (
|
||||
AnalysisRecord,
|
||||
ComplianceRepository,
|
||||
FindingRecord,
|
||||
)
|
||||
|
||||
|
||||
class PostgresComplianceRepository(ComplianceRepository):
|
||||
"""Stores compliance analyses, findings, and finding chat messages in PostgreSQL."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
user: str,
|
||||
password: str,
|
||||
dbname: str,
|
||||
minconn: int = 1,
|
||||
maxconn: int = 5,
|
||||
) -> None:
|
||||
self._pool = psycopg2.pool.ThreadedConnectionPool(
|
||||
minconn=minconn,
|
||||
maxconn=maxconn,
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
dbname=dbname,
|
||||
)
|
||||
self._ensure_schema()
|
||||
|
||||
@contextmanager
|
||||
def _conn(self):
|
||||
conn = self._pool.getconn()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
self._pool.putconn(conn)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
"""Create tables if they do not exist."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS compliance_analyses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by VARCHAR(255),
|
||||
doc_name VARCHAR(500),
|
||||
standard_name VARCHAR(500),
|
||||
risk_score INTEGER,
|
||||
conclusion TEXT,
|
||||
actions JSONB,
|
||||
para_text TEXT,
|
||||
highlight_terms JSONB
|
||||
);
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS compliance_findings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
title VARCHAR(500),
|
||||
description TEXT,
|
||||
status VARCHAR(50),
|
||||
clause_ref VARCHAR(200)
|
||||
);
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS finding_chat_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||
finding_id UUID NOT NULL REFERENCES compliance_findings(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def save_analysis(self, record: AnalysisRecord) -> str:
|
||||
"""Insert analysis + findings; return the new analysis UUID."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO compliance_analyses
|
||||
(created_by, doc_name, standard_name, risk_score,
|
||||
conclusion, actions, para_text, highlight_terms)
|
||||
VALUES
|
||||
(%(created_by)s, %(doc_name)s, %(standard_name)s, %(risk_score)s,
|
||||
%(conclusion)s, %(actions)s, %(para_text)s, %(highlight_terms)s)
|
||||
RETURNING id
|
||||
""",
|
||||
{
|
||||
"created_by": record.created_by,
|
||||
"doc_name": record.doc_name,
|
||||
"standard_name": record.standard_name,
|
||||
"risk_score": record.risk_score,
|
||||
"conclusion": record.conclusion,
|
||||
"actions": json.dumps(record.actions, ensure_ascii=False),
|
||||
"para_text": record.para_text,
|
||||
"highlight_terms": json.dumps(record.highlight_terms, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
analysis_id = str(row["id"])
|
||||
|
||||
if record.findings:
|
||||
with conn.cursor() as cur:
|
||||
for f in record.findings:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO compliance_findings
|
||||
(analysis_id, seq, title, description, status, clause_ref)
|
||||
VALUES
|
||||
(%(analysis_id)s, %(seq)s, %(title)s, %(desc)s, %(status)s, %(clause_ref)s)
|
||||
""",
|
||||
{
|
||||
"analysis_id": analysis_id,
|
||||
"seq": f.seq,
|
||||
"title": f.title,
|
||||
"desc": f.description,
|
||||
"status": f.status,
|
||||
"clause_ref": f.clause_ref,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
return analysis_id
|
||||
|
||||
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]:
|
||||
"""Return analyses without nested findings, ordered newest first."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, created_at, created_by, doc_name, standard_name,
|
||||
risk_score, conclusion, actions, para_text, highlight_terms
|
||||
FROM compliance_analyses
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %(limit)s OFFSET %(offset)s
|
||||
""",
|
||||
{"limit": limit, "offset": offset},
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_record(dict(r)) for r in rows]
|
||||
|
||||
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]:
|
||||
"""Return analysis with nested findings list."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM compliance_analyses WHERE id = %(id)s",
|
||||
{"id": analysis_id},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
record = self._row_to_record(dict(row))
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, analysis_id, seq, title, description, status, clause_ref
|
||||
FROM compliance_findings
|
||||
WHERE analysis_id = %(id)s
|
||||
ORDER BY seq
|
||||
""",
|
||||
{"id": analysis_id},
|
||||
)
|
||||
findings = [
|
||||
FindingRecord(
|
||||
id=str(r["id"]),
|
||||
analysis_id=str(r["analysis_id"]),
|
||||
seq=r["seq"],
|
||||
title=r["title"] or "",
|
||||
description=r["description"] or "",
|
||||
status=r["status"] or "ok",
|
||||
clause_ref=r["clause_ref"],
|
||||
)
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
record.findings = findings
|
||||
return record
|
||||
|
||||
def delete_analysis(self, analysis_id: str) -> None:
|
||||
"""Delete analysis; findings and chat messages cascade automatically."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM compliance_analyses WHERE id = %(id)s",
|
||||
{"id": analysis_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str:
|
||||
"""Persist a chat message; return its UUID."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO finding_chat_messages
|
||||
(analysis_id, finding_id, role, content)
|
||||
VALUES
|
||||
(%(analysis_id)s, %(finding_id)s, %(role)s, %(content)s)
|
||||
RETURNING id
|
||||
""",
|
||||
{
|
||||
"analysis_id": analysis_id,
|
||||
"finding_id": finding_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return str(row["id"])
|
||||
|
||||
def get_messages(self, finding_id: str) -> list[dict]:
|
||||
"""Return messages for a finding, oldest first."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, role, content, created_at
|
||||
FROM finding_chat_messages
|
||||
WHERE finding_id = %(finding_id)s
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
{"finding_id": finding_id},
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"role": r["role"],
|
||||
"content": r["content"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def _row_to_record(self, row: dict) -> AnalysisRecord:
|
||||
"""Convert a RealDictCursor row to an AnalysisRecord (no findings)."""
|
||||
actions = row.get("actions") or []
|
||||
if isinstance(actions, str):
|
||||
actions = json.loads(actions)
|
||||
highlight_terms = row.get("highlight_terms") or []
|
||||
if isinstance(highlight_terms, str):
|
||||
highlight_terms = json.loads(highlight_terms)
|
||||
return AnalysisRecord(
|
||||
id=str(row["id"]),
|
||||
created_at=row["created_at"] if isinstance(row["created_at"], datetime) else datetime.utcnow(),
|
||||
created_by=row.get("created_by"),
|
||||
doc_name=row.get("doc_name") or "",
|
||||
standard_name=row.get("standard_name") or "",
|
||||
risk_score=int(row.get("risk_score") or 0),
|
||||
conclusion=row.get("conclusion") or "",
|
||||
actions=actions,
|
||||
para_text=row.get("para_text") or "",
|
||||
highlight_terms=highlight_terms,
|
||||
findings=[],
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""No-op reranker stub.
|
||||
|
||||
Returns the original candidate list sliced to top_k.
|
||||
Replace with CrossEncoderReranker when a local cross-encoder model is available.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.retrieval.models import RetrievedChunk
|
||||
from app.domain.retrieval.ports import Reranker
|
||||
|
||||
|
||||
class PassThroughReranker(Reranker):
|
||||
"""Pass-through reranker that preserves original retrieval order.
|
||||
|
||||
Acts as a placeholder for future cross-encoder reranking (e.g. ms-marco-MiniLM).
|
||||
Wire via bootstrap.get_compliance_reranker() when ready to swap.
|
||||
"""
|
||||
|
||||
def rerank(self, query: str, chunks: list[RetrievedChunk], top_k: int) -> list[RetrievedChunk]:
|
||||
"""Return the first top_k chunks without reordering."""
|
||||
return chunks[:top_k]
|
||||
@@ -40,6 +40,8 @@ from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatib
|
||||
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
||||
from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
|
||||
from app.services.llm.llm_factory import LLMFactory
|
||||
from app.domain.compliance.ports import ComplianceRepository
|
||||
from app.infrastructure.compliance.repository import PostgresComplianceRepository
|
||||
# Keep shared wiring centralized so dependency construction remains consistent.
|
||||
|
||||
|
||||
@@ -311,6 +313,28 @@ def get_event_store() -> BaseEventStore:
|
||||
return MockEventStore()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_compliance_repository() -> ComplianceRepository:
|
||||
"""Return the compliance analysis repository.
|
||||
|
||||
Requires document_repository_backend=postgres and valid postgres_* settings.
|
||||
Raises NotImplementedError for any other backend value.
|
||||
"""
|
||||
if settings.document_repository_backend != "postgres":
|
||||
raise NotImplementedError(
|
||||
f"ComplianceRepository requires document_repository_backend=postgres, "
|
||||
f"got '{settings.document_repository_backend}'. "
|
||||
"Set DOCUMENT_REPOSITORY_BACKEND=postgres in your .env file."
|
||||
)
|
||||
return PostgresComplianceRepository(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
dbname=settings.postgres_db,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_perception_service() -> PerceptionService:
|
||||
return PerceptionService(
|
||||
|
||||
0
backend/tests/compliance/__init__.py
Normal file
0
backend/tests/compliance/__init__.py
Normal file
140
backend/tests/compliance/test_pipeline.py
Normal file
140
backend/tests/compliance/test_pipeline.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from app.infrastructure.vectorstore.pass_through_reranker import PassThroughReranker
|
||||
from app.domain.retrieval.models import RetrievedChunk
|
||||
from app.domain.compliance.ports import AnalysisRecord, FindingRecord
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_chunk(score: float) -> RetrievedChunk:
|
||||
return RetrievedChunk(
|
||||
chunk_id="c1",
|
||||
doc_id="d1",
|
||||
doc_title="Test Doc",
|
||||
section_title="S1",
|
||||
text="some text",
|
||||
score=score,
|
||||
page_start=1,
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_client(content: str = '{"status":"ok","title":"T","desc":"D","clause_ref":"A1"}'):
|
||||
client = MagicMock()
|
||||
response = MagicMock()
|
||||
response.is_success = True
|
||||
response.content = content
|
||||
client.chat.return_value = response
|
||||
return client
|
||||
|
||||
|
||||
def _make_mock_retrieval():
|
||||
svc = MagicMock()
|
||||
svc.retrieve.return_value = []
|
||||
return svc
|
||||
|
||||
|
||||
# ── existing tests ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_pass_through_returns_top_k():
|
||||
reranker = PassThroughReranker()
|
||||
chunks = [_make_chunk(0.9), _make_chunk(0.8), _make_chunk(0.7)]
|
||||
result = reranker.rerank(query="test", chunks=chunks, top_k=2)
|
||||
assert len(result) == 2
|
||||
assert result[0].score == 0.9
|
||||
|
||||
|
||||
def test_pass_through_returns_all_when_top_k_exceeds():
|
||||
reranker = PassThroughReranker()
|
||||
chunks = [_make_chunk(0.5)]
|
||||
result = reranker.rerank(query="test", chunks=chunks, top_k=10)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ── new tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_process_single_clause_returns_finding():
|
||||
from app.application.compliance.pipeline import process_single_clause
|
||||
client = _make_mock_client()
|
||||
svc = _make_mock_retrieval()
|
||||
result = process_single_clause("test clause", 0, svc, client)
|
||||
assert result["finding"] is not None
|
||||
assert result["index"] == 0
|
||||
assert result["chunks"] == []
|
||||
|
||||
|
||||
def test_run_clauses_parallel_runs_all():
|
||||
from app.application.compliance.pipeline import run_clauses_parallel
|
||||
client = _make_mock_client()
|
||||
svc = _make_mock_retrieval()
|
||||
clauses = ["clause one", "clause two", "clause three"]
|
||||
results = asyncio.run(run_clauses_parallel(clauses, svc, client))
|
||||
assert len(results) == 3
|
||||
assert all(r["index"] == i for i, r in enumerate(results))
|
||||
|
||||
|
||||
def test_run_clauses_parallel_handles_clause_failure():
|
||||
from app.application.compliance.pipeline import run_clauses_parallel
|
||||
svc = _make_mock_retrieval()
|
||||
bad_client = MagicMock()
|
||||
bad_client.chat.side_effect = RuntimeError("LLM exploded")
|
||||
results = asyncio.run(run_clauses_parallel(
|
||||
["clause one", "clause two"], svc, bad_client
|
||||
))
|
||||
assert len(results) == 2
|
||||
assert all(r["finding"] is None for r in results)
|
||||
assert all(r["chunks"] == [] for r in results)
|
||||
|
||||
|
||||
# ── helpers for new tests ─────────────────────────────────────────────────────
|
||||
|
||||
def _sample_analysis() -> AnalysisRecord:
|
||||
return AnalysisRecord(
|
||||
id="a1", created_at=datetime(2026, 6, 8), created_by="u",
|
||||
doc_name="doc.pdf", standard_name="EU AI Act",
|
||||
risk_score=72, conclusion="Gaps found.", actions=[], para_text="para",
|
||||
highlight_terms=[], findings=[],
|
||||
)
|
||||
|
||||
|
||||
def _sample_finding(status: str = "risk") -> FindingRecord:
|
||||
return FindingRecord(
|
||||
id="f1", analysis_id="a1", seq=0,
|
||||
title="Missing CSMS", description="No CSMS certification.",
|
||||
status=status, clause_ref="Art.9.1",
|
||||
)
|
||||
|
||||
|
||||
# ── new tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_finding_context_contains_required_fields():
|
||||
from app.application.compliance.pipeline import build_finding_context
|
||||
ctx = build_finding_context(_sample_finding(), _sample_analysis())
|
||||
assert "doc.pdf" in ctx
|
||||
assert "EU AI Act" in ctx
|
||||
assert "Missing CSMS" in ctx
|
||||
assert "Art.9.1" in ctx
|
||||
|
||||
|
||||
def test_generate_suggestions_returns_three_questions():
|
||||
from app.application.compliance.pipeline import generate_suggestions
|
||||
client = _make_mock_client(
|
||||
'{"questions": ["Q1?", "Q2?", "Q3?"]}'
|
||||
)
|
||||
questions = generate_suggestions(_sample_finding("risk"), _sample_analysis(), client)
|
||||
assert len(questions) == 3
|
||||
assert all(isinstance(q, str) for q in questions)
|
||||
|
||||
|
||||
def test_generate_suggestions_falls_back_on_error():
|
||||
from app.application.compliance.pipeline import generate_suggestions
|
||||
bad_client = MagicMock()
|
||||
bad_resp = MagicMock()
|
||||
bad_resp.is_success = False
|
||||
bad_client.chat.return_value = bad_resp
|
||||
questions = generate_suggestions(_sample_finding(), _sample_analysis(), bad_client)
|
||||
assert len(questions) == 3 # fallback always returns 3
|
||||
98
backend/tests/compliance/test_repository.py
Normal file
98
backend/tests/compliance/test_repository.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime
|
||||
from app.domain.compliance.ports import (
|
||||
AnalysisRecord,
|
||||
FindingRecord,
|
||||
ComplianceRepository,
|
||||
)
|
||||
|
||||
|
||||
def _mock_pool():
|
||||
"""Return a mock psycopg2 ThreadedConnectionPool."""
|
||||
conn = MagicMock()
|
||||
cursor = MagicMock()
|
||||
cursor.__enter__ = MagicMock(return_value=cursor)
|
||||
cursor.__exit__ = MagicMock(return_value=False)
|
||||
conn.cursor.return_value = cursor
|
||||
pool = MagicMock()
|
||||
pool.getconn.return_value = conn
|
||||
return pool, conn, cursor
|
||||
|
||||
|
||||
@patch("app.infrastructure.compliance.repository.psycopg2.pool.ThreadedConnectionPool")
|
||||
def test_save_analysis_returns_uuid(mock_pool_cls):
|
||||
from app.infrastructure.compliance.repository import PostgresComplianceRepository
|
||||
pool, conn, cursor = _mock_pool()
|
||||
mock_pool_cls.return_value = pool
|
||||
cursor.fetchone.return_value = {"id": "abc-123"}
|
||||
|
||||
repo = PostgresComplianceRepository(
|
||||
host="localhost", port=5432, user="u", password="p", dbname="db"
|
||||
)
|
||||
record = AnalysisRecord(
|
||||
id="", created_at=datetime.utcnow(), created_by="user1",
|
||||
doc_name="doc.pdf", standard_name="EU AI Act",
|
||||
risk_score=50, conclusion="OK", actions=[], para_text="p",
|
||||
highlight_terms=[], findings=[],
|
||||
)
|
||||
result = repo.save_analysis(record)
|
||||
assert result == "abc-123"
|
||||
|
||||
|
||||
def test_analysis_record_construction():
|
||||
record = AnalysisRecord(
|
||||
id="",
|
||||
created_at=datetime.utcnow(),
|
||||
created_by="user1",
|
||||
doc_name="test.pdf",
|
||||
standard_name="EU AI Act",
|
||||
risk_score=72,
|
||||
conclusion="Several gaps found.",
|
||||
actions=[{"label": "Fix", "value": "Update docs"}],
|
||||
para_text="The system shall...",
|
||||
highlight_terms=["CSMS", "ISO 21434"],
|
||||
findings=[
|
||||
FindingRecord(
|
||||
id="",
|
||||
analysis_id="",
|
||||
seq=0,
|
||||
title="Missing CSMS",
|
||||
description="No CSMS certification found.",
|
||||
status="risk",
|
||||
clause_ref="Art.9.1",
|
||||
)
|
||||
],
|
||||
)
|
||||
assert record.doc_name == "test.pdf"
|
||||
assert len(record.findings) == 1
|
||||
assert record.findings[0].status == "risk"
|
||||
|
||||
|
||||
def test_compliance_repository_is_abstract():
|
||||
import inspect
|
||||
assert inspect.isabstract(ComplianceRepository)
|
||||
|
||||
|
||||
def test_generate_docx_returns_bytes():
|
||||
from app.infrastructure.compliance.docx_export import generate_docx
|
||||
record = AnalysisRecord(
|
||||
id="test-id", created_at=datetime(2026, 6, 8), created_by="user1",
|
||||
doc_name="test.pdf", standard_name="EU AI Act",
|
||||
risk_score=72, conclusion="Several gaps found.",
|
||||
actions=[{"label": "Fix", "value": "Update CSMS docs"}],
|
||||
para_text="The system shall implement CSMS.",
|
||||
highlight_terms=["CSMS"],
|
||||
findings=[
|
||||
FindingRecord(
|
||||
id="f1", analysis_id="test-id", seq=0,
|
||||
title="Missing CSMS", description="No CSMS cert.",
|
||||
status="risk", clause_ref="Art.9.1",
|
||||
)
|
||||
],
|
||||
)
|
||||
data = generate_docx(record)
|
||||
assert isinstance(data, bytes)
|
||||
assert len(data) > 1000 # DOCX is at minimum a ZIP with ~1 KB overhead
|
||||
# Verify it's a valid ZIP (DOCX = ZIP container)
|
||||
import zipfile, io
|
||||
assert zipfile.is_zipfile(io.BytesIO(data))
|
||||
@@ -58,7 +58,8 @@ services:
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# PostgreSQL数据库 (可选,启用 DOCUMENT_REPOSITORY_BACKEND=postgres 时使用)
|
||||
# PostgreSQL数据库 (启用 DOCUMENT_REPOSITORY_BACKEND=postgres 时使用;
|
||||
# 合规分析历史记录 Direction B、DOCX 报告下载及 Finding Chat 持久化 Direction C 均依赖此服务)
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: postgres
|
||||
|
||||
2292
docs/superpowers/plans/2026-06-08-compliance-enhancement.md
Normal file
2292
docs/superpowers/plans/2026-06-08-compliance-enhancement.md
Normal file
File diff suppressed because it is too large
Load Diff
1927
docs/superpowers/plans/2026-06-08-i18n.md
Normal file
1927
docs/superpowers/plans/2026-06-08-i18n.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,459 @@
|
||||
# Compliance Analysis Enhancement Design
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Directions:** A (Analysis Quality) + B (History & Reports) + C (Deep Chat)
|
||||
**Approach:** Three independent but coordinated feature sets sharing one DB schema (method one / structured tables).
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. **A — Analysis Quality:** Parallel clause processing (3-5× speed), fix `highlight_terms` bug (always returns empty), add LLM retry with tenacity, reserve `PassThroughReranker` for future cross-encoder work.
|
||||
2. **B — Analysis History & Reports:** Auto-save every completed analysis to PostgreSQL, history rail in UI, per-record DOCX export, delete with confirmation.
|
||||
3. **C — Deep Chat:** Per-finding persistent chat threads grounded in real retrieved text, LLM-generated suggestion questions, multi-turn memory.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Layering Rules (must not be violated)
|
||||
|
||||
```
|
||||
api/routes/ → thin HTTP handlers, SSE generators only
|
||||
application/ → orchestration logic (pipeline.py)
|
||||
domain/ports/ → ABCs, no implementation
|
||||
infrastructure/ → DB, docx, external calls
|
||||
shared/bootstrap.py → composition root, wires everything
|
||||
```
|
||||
|
||||
New business logic goes in `application/compliance/pipeline.py` and domain ports. Never in `services/*` or `workflows/*`.
|
||||
|
||||
### Shared Database Schema (B + C)
|
||||
|
||||
Three tables, created together so C's FK references are valid from day one:
|
||||
|
||||
```sql
|
||||
CREATE TABLE compliance_analyses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by VARCHAR(255),
|
||||
doc_name VARCHAR(500),
|
||||
standard_name VARCHAR(500),
|
||||
risk_score INTEGER,
|
||||
conclusion TEXT,
|
||||
actions JSONB,
|
||||
para_text TEXT,
|
||||
highlight_terms JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE compliance_findings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
title VARCHAR(500),
|
||||
description TEXT,
|
||||
status VARCHAR(50),
|
||||
clause_ref VARCHAR(200)
|
||||
);
|
||||
|
||||
CREATE TABLE finding_chat_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||
finding_id UUID NOT NULL REFERENCES compliance_findings(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL, -- 'user' | 'assistant'
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Direction A — Analysis Quality
|
||||
|
||||
### A1: Parallel Clause Processing
|
||||
|
||||
**Current:** Route handler has a sequential `for i, clause in enumerate(clauses)` loop. Each iteration calls `retrieve_for_clause()` then `check_clause_compliance()` synchronously via `asyncio.to_thread`.
|
||||
|
||||
**Change:** Extract a `process_single_clause(clause, idx, ...) -> dict` function in `pipeline.py`, then replace the loop with `asyncio.gather`:
|
||||
|
||||
```python
|
||||
async def run_clauses_parallel(clauses, retrieval_svc, llm_client, standard_name, para_text):
|
||||
tasks = [
|
||||
asyncio.to_thread(process_single_clause, clause, i, retrieval_svc, llm_client, standard_name, para_text)
|
||||
for i, clause in enumerate(clauses)
|
||||
]
|
||||
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||
```
|
||||
|
||||
Results are yielded to the SSE stream in original order. Exceptions from individual clauses are caught and emitted as `{type: "error", clause_index: i}` events rather than crashing the whole stream.
|
||||
|
||||
### A2: Fix highlight_terms
|
||||
|
||||
**Root cause:** `synthesize_conclusion()` passes the LLM response through `json.loads()` but the LLM often wraps output in markdown fences (` ```json ... ``` `), causing a parse failure and silent fallback to `[]`.
|
||||
|
||||
**Fix in `pipeline.py`:**
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
def _extract_json(text: str) -> dict:
|
||||
"""Strip markdown fences then parse JSON. Raises ValueError on failure."""
|
||||
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.MULTILINE)
|
||||
return json.loads(cleaned)
|
||||
```
|
||||
|
||||
Apply `_extract_json` in `synthesize_conclusion()` instead of bare `json.loads`. Wrap with `@retry` (see A3) so transient parse failures get a second attempt.
|
||||
|
||||
### A3: LLM Retry with tenacity
|
||||
|
||||
`tenacity` is already in `requirements.txt` but unused. Add to all LLM calls in `pipeline.py`:
|
||||
|
||||
```python
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=4),
|
||||
retry=retry_if_exception_type((httpx.HTTPError, ValueError)),
|
||||
reraise=True,
|
||||
)
|
||||
def _call_llm_with_retry(client, prompt: str) -> str:
|
||||
"""Call LLM and return raw text. Retries on HTTP errors and JSON parse failures."""
|
||||
...
|
||||
```
|
||||
|
||||
On final failure, the calling function catches and emits `{type: "error", text: "LLM call failed after 3 attempts"}` to the SSE stream.
|
||||
|
||||
### A4: PassThroughReranker (future-ready stub)
|
||||
|
||||
`domain/retrieval/ports.py` already defines a `Reranker` ABC. Add the no-op implementation:
|
||||
|
||||
**New file:** `backend/app/infrastructure/retrieval/reranker.py`
|
||||
|
||||
```python
|
||||
from app.domain.retrieval.ports import Reranker, RetrievedChunk
|
||||
|
||||
class PassThroughReranker(Reranker):
|
||||
"""No-op reranker. Replace with CrossEncoderReranker when a local model is available."""
|
||||
|
||||
def rerank(self, query: str, chunks: list[RetrievedChunk], top_k: int) -> list[RetrievedChunk]:
|
||||
return chunks[:top_k]
|
||||
```
|
||||
|
||||
Register in `shared/bootstrap.py` as the default `Reranker` implementation.
|
||||
|
||||
### A — Files Changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `backend/app/application/compliance/pipeline.py` | Add `process_single_clause`, `run_clauses_parallel`, `_extract_json`, `_call_llm_with_retry` |
|
||||
| `backend/app/api/routes/compliance.py` | Replace sequential loop with `await run_clauses_parallel(...)` |
|
||||
| `backend/app/infrastructure/retrieval/reranker.py` | New — `PassThroughReranker` |
|
||||
| `backend/app/shared/bootstrap.py` | Register `PassThroughReranker` |
|
||||
|
||||
---
|
||||
|
||||
## Direction B — History & Reports
|
||||
|
||||
### B1: Domain Port
|
||||
|
||||
**New file:** `backend/app/domain/compliance/ports.py`
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class FindingRecord:
|
||||
id: str
|
||||
analysis_id: str
|
||||
seq: int
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
clause_ref: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class AnalysisRecord:
|
||||
id: str
|
||||
created_at: datetime
|
||||
created_by: Optional[str]
|
||||
doc_name: str
|
||||
standard_name: str
|
||||
risk_score: int
|
||||
conclusion: str
|
||||
actions: list
|
||||
para_text: str
|
||||
highlight_terms: list
|
||||
findings: list[FindingRecord] = field(default_factory=list)
|
||||
|
||||
class ComplianceRepository(ABC):
|
||||
@abstractmethod
|
||||
def save_analysis(self, record: AnalysisRecord) -> str: ...
|
||||
@abstractmethod
|
||||
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]: ...
|
||||
@abstractmethod
|
||||
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]: ...
|
||||
@abstractmethod
|
||||
def delete_analysis(self, analysis_id: str) -> None: ...
|
||||
@abstractmethod
|
||||
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str: ...
|
||||
@abstractmethod
|
||||
def get_messages(self, finding_id: str) -> list[dict]: ...
|
||||
```
|
||||
|
||||
### B2: PostgresComplianceRepository
|
||||
|
||||
**New file:** `backend/app/infrastructure/compliance/repository.py`
|
||||
|
||||
Implements `ComplianceRepository` using `psycopg2` (already in requirements). Connection string from `settings.DATABASE_URL`. Key methods:
|
||||
|
||||
- `save_analysis`: INSERT into `compliance_analyses`, then bulk INSERT findings into `compliance_findings`, return `analysis_id` (UUID string).
|
||||
- `list_analyses`: SELECT with JOIN on findings count, ORDER BY `created_at DESC`, supports limit/offset.
|
||||
- `get_analysis`: SELECT analysis + all findings by `analysis_id`.
|
||||
- `delete_analysis`: DELETE cascades to findings and chat messages via FK.
|
||||
- `save_message` / `get_messages`: INSERT/SELECT on `finding_chat_messages`.
|
||||
|
||||
Uses a connection pool (simple `psycopg2.pool.ThreadedConnectionPool`, min=1, max=5).
|
||||
|
||||
### B3: Auto-save Hook
|
||||
|
||||
In the SSE generator in `compliance.py`, after the `done` event is assembled:
|
||||
|
||||
```python
|
||||
# After yielding the done event
|
||||
if repo is not None:
|
||||
record = AnalysisRecord(
|
||||
id="", # will be assigned by DB
|
||||
created_at=datetime.utcnow(),
|
||||
created_by=current_user,
|
||||
doc_name=doc_name,
|
||||
standard_name=standard_name,
|
||||
risk_score=done_payload["risk_score"],
|
||||
conclusion=done_payload["conclusion"],
|
||||
actions=done_payload["actions"],
|
||||
para_text=done_payload["para_text"],
|
||||
highlight_terms=done_payload["highlight_terms"],
|
||||
findings=[FindingRecord(...) for f in accumulated_findings],
|
||||
)
|
||||
analysis_id = await asyncio.to_thread(repo.save_analysis, record)
|
||||
# Emit an extra SSE event so frontend receives the analysis_id
|
||||
yield f"data: {json.dumps({'type': 'saved', 'analysis_id': analysis_id})}\n\n"
|
||||
```
|
||||
|
||||
### B4: New API Endpoints
|
||||
|
||||
Added to `backend/app/api/routes/compliance.py`:
|
||||
|
||||
```
|
||||
GET /api/v1/compliance/history
|
||||
Query params: limit=20&offset=0
|
||||
Response: [{id, created_at, doc_name, standard_name, risk_score, finding_count}]
|
||||
|
||||
GET /api/v1/compliance/history/{analysis_id}
|
||||
Response: full AnalysisRecord including findings list
|
||||
|
||||
DELETE /api/v1/compliance/history/{analysis_id}
|
||||
Response: 204 No Content
|
||||
|
||||
GET /api/v1/compliance/history/{analysis_id}/download
|
||||
Response: DOCX file (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||
```
|
||||
|
||||
### B5: DOCX Export
|
||||
|
||||
**New file:** `backend/app/infrastructure/compliance/docx_export.py`
|
||||
|
||||
Uses `python-docx` (already in requirements). Generates a structured report:
|
||||
|
||||
- Cover: document name, standard, date, risk score badge
|
||||
- Executive summary: conclusion paragraph
|
||||
- Findings table: seq / title / status / clause_ref / description
|
||||
- Action items: numbered list
|
||||
- Footer: generated by AI Regulation Analysis System
|
||||
|
||||
```python
|
||||
def generate_docx(record: AnalysisRecord) -> bytes:
|
||||
"""Generate a DOCX compliance report and return as bytes."""
|
||||
doc = Document()
|
||||
# ... build document ...
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
```
|
||||
|
||||
### B6: Frontend — History Rail
|
||||
|
||||
`CompliancePage.tsx` gains a left rail (same layout pattern as RagChat's `history-pane`):
|
||||
|
||||
```
|
||||
┌──────────────┬─────────────────────────────────┐
|
||||
│ History │ Main Analysis Area │
|
||||
│ ────────── │ │
|
||||
│ 2026-06-08 │ (current analysis or loaded │
|
||||
│ doc.pdf │ read-only historical record) │
|
||||
│ ⚠ 72 [↓][×]│ │
|
||||
│ ────────── │ │
|
||||
│ 2026-06-07 │ │
|
||||
│ csms.pdf │ │
|
||||
│ ✓ 15 [↓][×]│ │
|
||||
└──────────────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `[↓]` triggers `GET /history/{id}/download` and saves the DOCX file
|
||||
- `[×]` shows a confirmation dialog, then calls `DELETE /history/{id}`
|
||||
- Clicking a row loads that analysis into the main area in read-only mode
|
||||
- `PageStateContext.ComplianceState` gains `analysisId: string | null` and `isReadOnly: boolean`
|
||||
|
||||
On mount, the rail calls `GET /history?limit=20` to populate the list. The list re-fetches after delete or after a new analysis completes (triggered by the `saved` SSE event).
|
||||
|
||||
### B — Files Changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `backend/app/domain/compliance/ports.py` | New — `ComplianceRepository` ABC + data classes |
|
||||
| `backend/app/infrastructure/compliance/repository.py` | New — `PostgresComplianceRepository` |
|
||||
| `backend/app/infrastructure/compliance/docx_export.py` | New — `generate_docx()` |
|
||||
| `backend/app/api/routes/compliance.py` | Add history endpoints + auto-save hook |
|
||||
| `backend/app/shared/bootstrap.py` | Register `PostgresComplianceRepository` |
|
||||
| `frontend/src/pages/Compliance/CompliancePage.tsx` | Add History Rail |
|
||||
| `frontend/src/contexts/PageStateContext.tsx` | Add `analysisId`, `isReadOnly` to `ComplianceState` |
|
||||
|
||||
---
|
||||
|
||||
## Direction C — Deep Chat
|
||||
|
||||
### C1: New Chat Endpoints
|
||||
|
||||
Replace the existing `/compliance/chat/{segment_id}` (kept for backward compatibility but deprecated) with finding-scoped endpoints:
|
||||
|
||||
```
|
||||
POST /api/v1/compliance/analyses/{analysis_id}/findings/{finding_id}/chat
|
||||
Body: {query: string}
|
||||
Response: SSE stream — chunk / done / error events
|
||||
|
||||
GET /api/v1/compliance/analyses/{analysis_id}/findings/{finding_id}/chat
|
||||
Response: [{id, role, content, created_at}]
|
||||
|
||||
POST /api/v1/compliance/analyses/{analysis_id}/findings/{finding_id}/suggestions
|
||||
Response: {questions: [string, string, string]}
|
||||
```
|
||||
|
||||
### C2: Grounded Context Construction
|
||||
|
||||
New function in `pipeline.py`:
|
||||
|
||||
```python
|
||||
def build_finding_context(finding: FindingRecord, analysis: AnalysisRecord) -> str:
|
||||
"""
|
||||
Build a grounded system context string for a finding chat thread.
|
||||
Combines finding details with analysis metadata for LLM grounding.
|
||||
"""
|
||||
return (
|
||||
f"Document: {analysis.doc_name}\n"
|
||||
f"Standard: {analysis.standard_name}\n"
|
||||
f"Finding [{finding.seq}]: {finding.title}\n"
|
||||
f"Status: {finding.status}\n"
|
||||
f"Clause reference: {finding.clause_ref or 'N/A'}\n"
|
||||
f"Description: {finding.description}\n"
|
||||
f"Overall conclusion: {analysis.conclusion}\n"
|
||||
)
|
||||
```
|
||||
|
||||
This string is prepended to the system prompt for every chat call — replacing the fragile `segment_context` approach.
|
||||
|
||||
### C3: Multi-turn Context
|
||||
|
||||
Chat handler fetches existing messages from `finding_chat_messages` via `repo.get_messages(finding_id)` and prepends them to the LLM call as `[{"role": "user"/"assistant", "content": "..."}]` message history. Max history: 10 most recent messages (5 turns) to avoid token overflow.
|
||||
|
||||
After each LLM response, both the user message and assistant message are saved via `repo.save_message()`.
|
||||
|
||||
### C4: Suggestion Generation
|
||||
|
||||
New function in `pipeline.py`:
|
||||
|
||||
```python
|
||||
SUGGESTION_PROMPTS = {
|
||||
"non_compliant": "Generate 3 questions focused on remediation steps and timeline.",
|
||||
"partial": "Generate 3 questions focused on identifying the compliance gap.",
|
||||
"compliant": "Generate 3 questions focused on maintaining and evidencing compliance.",
|
||||
}
|
||||
|
||||
def generate_suggestions(finding: FindingRecord, analysis: AnalysisRecord, llm_client) -> list[str]:
|
||||
"""
|
||||
Generate 3 context-aware follow-up questions for a finding chat thread.
|
||||
Returns a list of 3 question strings. Falls back to generic questions on error.
|
||||
"""
|
||||
focus = SUGGESTION_PROMPTS.get(finding.status, SUGGESTION_PROMPTS["partial"])
|
||||
context = build_finding_context(finding, analysis)
|
||||
prompt = f"{context}\n\n{focus}\nReturn JSON: {{\"questions\": [\"...\", \"...\", \"...\"]}}"
|
||||
# ... call LLM, parse JSON, return list ...
|
||||
# Fallback on error:
|
||||
return ["What are the specific requirements?", "What is the remediation timeline?", "Which regulation clause applies?"]
|
||||
```
|
||||
|
||||
### C5: Frontend — Finding Chat Drawer
|
||||
|
||||
New component: `frontend/src/pages/Compliance/FindingChatDrawer.tsx`
|
||||
|
||||
Drawer slides in from the right (CSS: `position: fixed; right: 0; width: 420px`), reusing existing CSS variables (`--surface`, `--border`, `--accent`).
|
||||
|
||||
Structure:
|
||||
- Header: finding title + close button
|
||||
- Suggestions section: 3 chip buttons (only shown before first user message; hidden after)
|
||||
- Message list: scrollable, same bubble style as RagChat
|
||||
- Composer: textarea + send button, same pattern as RagChat composer
|
||||
|
||||
State managed in `PageStateContext.ComplianceState`:
|
||||
- `activeFindingId: string | null` — which finding's drawer is open
|
||||
- Drawer open/close controlled by `activeFindingId !== null`
|
||||
|
||||
On open:
|
||||
1. `GET /analyses/{id}/findings/{fid}/chat` → restore history
|
||||
2. If history is empty: `POST /findings/{fid}/suggestions` → show chips
|
||||
|
||||
Each finding card in `CompliancePage.tsx` gains a `💬 Chat` button that sets `activeFindingId`.
|
||||
|
||||
### C — Files Changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `backend/app/api/routes/compliance.py` | Add 3 new finding-chat endpoints |
|
||||
| `backend/app/application/compliance/pipeline.py` | Add `build_finding_context`, `generate_suggestions` |
|
||||
| `backend/app/infrastructure/compliance/repository.py` | Add `save_message`, `get_messages` (already in port) |
|
||||
| `frontend/src/pages/Compliance/FindingChatDrawer.tsx` | New component |
|
||||
| `frontend/src/pages/Compliance/CompliancePage.tsx` | Add Chat button to finding cards, render drawer |
|
||||
| `frontend/src/contexts/PageStateContext.tsx` | Add `activeFindingId` to `ComplianceState` |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Direction A must be completed first (parallel processing changes the route handler that B's auto-save hook attaches to). B must be completed before C (C's FK references require B's tables and repository).
|
||||
|
||||
```
|
||||
A (parallel + bug fixes + reranker stub)
|
||||
└→ B (schema migration + history + DOCX)
|
||||
└→ C (finding chat + suggestions)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- PDF export (DOCX only; users convert via Word/WPS)
|
||||
- Cross-encoder reranking (stub reserved, not implemented)
|
||||
- Scheduled/automatic crawling
|
||||
- User-level history isolation (all users share history — global visibility)
|
||||
- Prompt version management or A/B testing
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- Backend comments and docstrings: English only
|
||||
- No new top-level libraries beyond those already in `requirements.txt` (`tenacity`, `python-docx`, `psycopg2-binary` are all present)
|
||||
- `DOCUMENT_REPOSITORY_BACKEND=postgres` → `PostgresComplianceRepository`; any other value → raise `NotImplementedError` with a clear message (no mock fallback for compliance history)
|
||||
- Git commits are made by the user, never automated
|
||||
421
docs/superpowers/specs/2026-06-08-i18n-design.md
Normal file
421
docs/superpowers/specs/2026-06-08-i18n-design.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Internationalisation (i18n) Design — Frontend Chinese/English Toggle
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Scope:** UI framework strings only (nav labels, button labels, status messages, placeholders). Mock data, API-returned content, and domain regulation text are explicitly excluded.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
Add a language toggle button (EN ↔ 中) in the Sidebar footer, immediately left of the existing theme-toggle button, so users can switch the UI between English and Simplified Chinese. Default language is English on every page load; preference is not persisted across sessions.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Approach
|
||||
|
||||
Custom `LanguageContext` following the same pattern as the existing `ThemeContext`. No external library dependencies. Translation strings live in two TypeScript modules (`locales/en.ts` and `locales/zh.ts`) that export identical-shape objects.
|
||||
|
||||
### Layering
|
||||
|
||||
```
|
||||
src/
|
||||
├── contexts/
|
||||
│ └── LanguageContext.tsx # type Lang, LanguageProvider, useLanguage()
|
||||
└── locales/
|
||||
├── en.ts # English translations (default)
|
||||
└── zh.ts # Simplified Chinese translations
|
||||
```
|
||||
|
||||
`LanguageProvider` wraps the entire app in `App.tsx` — outermost provider so every component can consume it.
|
||||
|
||||
### Context interface
|
||||
|
||||
```ts
|
||||
type Lang = 'en' | 'zh';
|
||||
|
||||
interface LanguageContextValue {
|
||||
lang: Lang;
|
||||
t: Translations; // typed translation object
|
||||
toggleLang: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
`useState<Lang>('en')` — hardcoded default, no localStorage read on mount.
|
||||
|
||||
### Translation object shape (both files export `Translations`)
|
||||
|
||||
```ts
|
||||
export interface Translations {
|
||||
nav: {
|
||||
groupMain: string;
|
||||
groupWorkbench: string;
|
||||
groupChat: string;
|
||||
overview: string;
|
||||
signals: string;
|
||||
status: string;
|
||||
documents: string;
|
||||
compliance: string;
|
||||
chat: string;
|
||||
};
|
||||
sidebar: {
|
||||
toggleTheme: string;
|
||||
toggleLang: string;
|
||||
signOut: string;
|
||||
};
|
||||
overview: {
|
||||
eyebrow: string;
|
||||
heroTitle: string;
|
||||
heroDesc: string;
|
||||
openDashboard: string;
|
||||
jumpToChat: string;
|
||||
sectionHowItWorks: string;
|
||||
sectionScreens: string;
|
||||
stepUpload: string; stepUploadDesc: string;
|
||||
stepProcess: string; stepProcessDesc: string;
|
||||
stepMonitor: string; stepMonitorDesc: string;
|
||||
stepAnalyze: string; stepAnalyzeDesc: string;
|
||||
stepReview: string; stepReviewDesc: string;
|
||||
stepChat: string; stepChatDesc: string;
|
||||
statScreens: string;
|
||||
statFlows: string;
|
||||
statReviewPosture: string;
|
||||
navLiveHealth: string;
|
||||
navRegulatoryChanges: string;
|
||||
navUploadDocs: string;
|
||||
navComplianceWorkspace: string;
|
||||
navChatCited: string;
|
||||
navKPIs: string;
|
||||
};
|
||||
signals: {
|
||||
topbarTitle: string;
|
||||
topbarSub: string;
|
||||
searchPlaceholder: string;
|
||||
refreshBtn: string;
|
||||
crawlingBtn: string;
|
||||
statTotal: string;
|
||||
statHigh: string;
|
||||
statMedium: string;
|
||||
statLast90: string;
|
||||
badgeFinal: string;
|
||||
badgeDraft: string;
|
||||
badgeUrgent: string;
|
||||
badgePublished: string;
|
||||
emptySelectSignal: string;
|
||||
runAnalysis: string;
|
||||
stopBtn: string;
|
||||
sourceLink: string;
|
||||
tabOverview: string;
|
||||
tabObligations: string;
|
||||
tabImpact: string;
|
||||
tabChanges: string;
|
||||
cardScopeHeader: string;
|
||||
cardObligationsHeader: string;
|
||||
obligationsEmpty: string;
|
||||
colObligationDesc: string;
|
||||
colSubject: string;
|
||||
colType: string;
|
||||
colDeadline: string;
|
||||
deadlinePending: string;
|
||||
cardAffectedDocs: string;
|
||||
noAffectedDocs: string;
|
||||
cardAIImpact: string;
|
||||
footerText: string;
|
||||
statusConnecting: string;
|
||||
statusNoStream: string;
|
||||
statusCrawling: string;
|
||||
statusProcessing: string;
|
||||
statusComplete: string;
|
||||
statusUpdateComplete: string;
|
||||
statusError: string;
|
||||
statusConnFailed: string;
|
||||
};
|
||||
status: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
exportBtn: string;
|
||||
refreshBtn: string;
|
||||
newUploadBtn: string;
|
||||
statTotal: string;
|
||||
statIndexed: string;
|
||||
statFailed: string;
|
||||
statChunks: string;
|
||||
statCoverage: string;
|
||||
cardHealth: string;
|
||||
badgeOnline: string;
|
||||
badgeError: string;
|
||||
badgeDegraded: string;
|
||||
badgeUnknown: string;
|
||||
healthEndpointError: string;
|
||||
serviceEnabled: string;
|
||||
serviceDisabled: string;
|
||||
serviceNotLoaded: string;
|
||||
cardConfig: string;
|
||||
labelLLMProvider: string;
|
||||
labelLLMModel: string;
|
||||
labelEmbeddingModel: string;
|
||||
labelEmbeddingDim: string;
|
||||
labelMilvusCollection: string;
|
||||
labelParserBackend: string;
|
||||
labelChunkBackend: string;
|
||||
labelParserFailureMode: string;
|
||||
configLoadError: string;
|
||||
cardBreakdown: string;
|
||||
breakdownIndexed: string;
|
||||
breakdownProcessing: string;
|
||||
breakdownFailed: string;
|
||||
cardRuntime: string;
|
||||
labelActiveSessions: string;
|
||||
labelSessionCapacity: string;
|
||||
labelReranker: string;
|
||||
labelBM25: string;
|
||||
statusActive: string;
|
||||
statusUnavailable: string;
|
||||
footerAllOk: string;
|
||||
footerDegraded: string;
|
||||
footerChecking: string;
|
||||
};
|
||||
docs: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
refreshBtn: string;
|
||||
uploadBtn: string;
|
||||
confirmDeleteTitle: string;
|
||||
cancelBtn: string;
|
||||
deleteBtn: string;
|
||||
filterAll: string;
|
||||
filterReady: string;
|
||||
filterProcessing: string;
|
||||
filterFailed: string;
|
||||
filterPending: string;
|
||||
filterAllTypes: string;
|
||||
selectedCount: string; // '{n} document(s) selected' — use {n} placeholder
|
||||
deleteSelected: string;
|
||||
colName: string;
|
||||
colStatus: string;
|
||||
colUploaded: string;
|
||||
colChunks: string;
|
||||
colSize: string;
|
||||
colType: string;
|
||||
colActions: string;
|
||||
loading: string;
|
||||
emptyNoDocuments: string;
|
||||
emptyNoMatch: string;
|
||||
footerCount: string; // '{n} of {m} document(s)'
|
||||
titleDownload: string;
|
||||
titleRetry: string;
|
||||
titleDelete: string;
|
||||
confirmSingle: string; // '{name}' placeholder
|
||||
confirmBatch: string; // '{n}' placeholder
|
||||
};
|
||||
compliance: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
clearBtn: string;
|
||||
exportBtn: string;
|
||||
exportJSON: string;
|
||||
exportText: string;
|
||||
newAnalysisBtn: string;
|
||||
statusAnalyzing: string;
|
||||
statusComplete: string;
|
||||
statusError: string;
|
||||
emptyTitle: string;
|
||||
emptyDesc: string;
|
||||
colRetrieved: string; // 'Retrieved Regulations {count}'
|
||||
retrievingMsg: string;
|
||||
defaultRegulation: string;
|
||||
matchSuffix: string;
|
||||
colParagraph: string;
|
||||
extractingMsg: string;
|
||||
noTextExtracted: string;
|
||||
stagesHeader: string;
|
||||
stageExtraction: string;
|
||||
stageClauseSplit: string;
|
||||
stageRetrieval: string;
|
||||
stageSynthesis: string;
|
||||
colFindings: string; // 'Findings {count}'
|
||||
gapInProgress: string;
|
||||
askAIBtn: string;
|
||||
chatBtn: string;
|
||||
conclusionHeader: string;
|
||||
riskScoreTooltip: string;
|
||||
statusCovered: string;
|
||||
statusGap: string;
|
||||
statusCritical: string;
|
||||
statusInfo: string;
|
||||
sourceTypePasted: string;
|
||||
sourceTypeIndexed: string;
|
||||
sourceTypeUploaded: string;
|
||||
chatSidebarHeader: string;
|
||||
chatThinking: string;
|
||||
quickQ1: string;
|
||||
quickQ2: string;
|
||||
quickQ3: string;
|
||||
chatPlaceholder: string;
|
||||
sendBtn: string;
|
||||
analysisFailed: string;
|
||||
exportReportHeader: string;
|
||||
exportSectionParagraph: string;
|
||||
exportSectionFindings: string;
|
||||
exportSectionConclusion: string;
|
||||
exportSectionActions: string;
|
||||
historyHeader: string;
|
||||
downloadReport: string;
|
||||
historyEmpty: string;
|
||||
historyDeleteConfirm: string;
|
||||
drawerClose: string;
|
||||
drawerChatEmpty: string;
|
||||
drawerSuggestionsHeader: string;
|
||||
};
|
||||
ragchat: {
|
||||
topbarTitle: string;
|
||||
exportBtn: string;
|
||||
quickPromptsHeader: string;
|
||||
inputPlaceholder: string;
|
||||
citationsHeader: string; // 'Sources {count}'
|
||||
citationsEmpty: string;
|
||||
jumpToSource: string; // 'Jump to source [N]'
|
||||
apiError: string;
|
||||
quickPrompt1: string;
|
||||
quickPrompt2: string;
|
||||
quickPrompt3: string;
|
||||
quickPrompt4: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Language Toggle Button
|
||||
|
||||
Location: `Sidebar.tsx` footer `<div style={{ display: 'flex', gap: 4 }}>`.
|
||||
|
||||
Inserted **left of** the existing theme button:
|
||||
|
||||
```tsx
|
||||
<button className="theme-btn" onClick={toggleLang} title={t.sidebar.toggleLang}>
|
||||
{lang === 'en' ? 'EN' : '中'}
|
||||
</button>
|
||||
```
|
||||
|
||||
- Reuses existing `theme-btn` CSS class — no new styles needed.
|
||||
- Displays two-character label: `EN` or `中`.
|
||||
- `title` attribute (tooltip) translates with the rest of the UI.
|
||||
|
||||
---
|
||||
|
||||
## Translation Files (complete values)
|
||||
|
||||
### `locales/en.ts` (English — default)
|
||||
|
||||
Key values (representative; full file contains all keys above):
|
||||
|
||||
```ts
|
||||
nav: { groupMain: 'Main', groupWorkbench: 'Workbench', groupChat: 'Chat',
|
||||
overview: 'Overview', signals: 'Regulatory Signals', status: 'System Status',
|
||||
documents: 'Documents', compliance: 'Compliance Analysis', chat: 'Regulation Q&A' },
|
||||
sidebar: { toggleTheme: 'Toggle theme', toggleLang: 'Switch language', signOut: 'Sign out' },
|
||||
signals: { refreshBtn: 'Refresh Sources', crawlingBtn: 'Crawling...', ... },
|
||||
docs: { uploadBtn: 'Upload document', deleteBtn: 'Delete', cancelBtn: 'Cancel', ... },
|
||||
compliance: { newAnalysisBtn: 'New analysis', analyzeBtn: 'Analyze', sendBtn: 'Send', ... },
|
||||
ragchat: { exportBtn: 'Export chat', inputPlaceholder: 'Ask about your regulations…', ... },
|
||||
```
|
||||
|
||||
### `locales/zh.ts` (Simplified Chinese)
|
||||
|
||||
Key values:
|
||||
|
||||
```ts
|
||||
nav: { groupMain: '主菜单', groupWorkbench: '工作台', groupChat: '对话',
|
||||
overview: '概览', signals: '法规信号', status: '系统状态',
|
||||
documents: '文档管理', compliance: '合规分析', chat: '法规问答' },
|
||||
sidebar: { toggleTheme: '切换主题', toggleLang: '切换语言', signOut: '退出' },
|
||||
signals: { refreshBtn: '刷新数据源', crawlingBtn: '抓取中...', ... },
|
||||
docs: { uploadBtn: '上传文档', deleteBtn: '删除', cancelBtn: '取消', ... },
|
||||
compliance: { newAnalysisBtn: '新建分析', analyzeBtn: '开始分析', sendBtn: '发送', ... },
|
||||
ragchat: { exportBtn: '导出对话', inputPlaceholder: '请输入关于法规的问题…', ... },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## App.tsx Provider Wrapping
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PageStateProvider>
|
||||
<AppRouter />
|
||||
</PageStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
// After
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PageStateProvider>
|
||||
<AppRouter />
|
||||
</PageStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
```
|
||||
|
||||
`LanguageProvider` is outermost so it is available to all components including the theme toggle itself.
|
||||
|
||||
---
|
||||
|
||||
## Usage in Components
|
||||
|
||||
```tsx
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useLanguage();
|
||||
return <button>{t.docs.uploadBtn}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
No wrapping needed — `t` is always the correct object for the current language.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/contexts/LanguageContext.tsx` | New — `LanguageProvider`, `useLanguage()`, `Lang` type |
|
||||
| `src/locales/en.ts` | New — complete English `Translations` object |
|
||||
| `src/locales/zh.ts` | New — complete Chinese `Translations` object |
|
||||
| `src/App.tsx` | Add `<LanguageProvider>` wrapper |
|
||||
| `src/components/layout/Sidebar.tsx` | Add language toggle button; replace nav group titles and labels with `t.nav.*` |
|
||||
| `src/pages/Overview/OverviewPage.tsx` | Replace all UI strings with `t.overview.*` |
|
||||
| `src/pages/Perception/PerceptionPage.tsx` | Replace all UI strings with `t.signals.*` |
|
||||
| `src/pages/Status/StatusPage.tsx` | Replace all UI strings with `t.status.*` |
|
||||
| `src/pages/Docs/DocsPage.tsx` | Replace all UI strings with `t.docs.*` |
|
||||
| `src/pages/Compliance/CompliancePage.tsx` | Replace all UI strings with `t.compliance.*` |
|
||||
| `src/pages/RagChat/RagChatPage.tsx` | Replace all UI strings with `t.ragchat.*` |
|
||||
| `src/pages/Compliance/HistoryRail.tsx` | Replace UI strings with `t.compliance.*` |
|
||||
| `src/pages/Compliance/FindingChatDrawer.tsx` | Replace UI strings with `t.compliance.*` |
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Persistence across sessions (no localStorage for language preference)
|
||||
- More than two languages
|
||||
- RTL layout support
|
||||
- Pluralisation helpers (simple string substitution with `{n}` placeholders is sufficient — callers replace via `t.docs.selectedCount.replace('{n}', String(count))`)
|
||||
- Translation of API-returned content, mock data, regulation names, or document file names
|
||||
- Date/number formatting localisation
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- Zero new npm dependencies
|
||||
- Follow existing `ThemeContext` pattern exactly
|
||||
- Backend comments/docstrings: English only (no backend changes in this feature)
|
||||
- Git commits made by the user, never automated
|
||||
@@ -1,16 +1,18 @@
|
||||
import './styles/globals.css';
|
||||
import { ThemeProvider, AuthProvider, PageStateProvider } from './contexts';
|
||||
import { ThemeProvider, AuthProvider, PageStateProvider, LanguageProvider } from './contexts';
|
||||
import { AppRouter } from './router/AppRouter';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PageStateProvider>
|
||||
<AppRouter />
|
||||
</PageStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<PageStateProvider>
|
||||
<AppRouter />
|
||||
</PageStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
@@ -13,21 +14,6 @@ interface NavItem {
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ to: '/', icon: <LayoutDashboard size={16} />, label: 'Overview' },
|
||||
{ to: '/signals', icon: <Radio size={16} />, label: 'Regulatory Signals' },
|
||||
{ to: '/status', icon: <Monitor size={16} />, label: 'System Status' },
|
||||
];
|
||||
|
||||
const workbenchNav: NavItem[] = [
|
||||
{ to: '/documents', icon: <FileText size={16} />, label: 'Documents' },
|
||||
{ to: '/compliance', icon: <Shield size={16} />, label: 'Compliance Analysis' },
|
||||
];
|
||||
|
||||
const chatNav: NavItem[] = [
|
||||
{ to: '/chat', icon: <MessageSquare size={16} />, label: 'Regulation Q&A' },
|
||||
];
|
||||
|
||||
function NavGroup({ title, items }: { title: string; items: NavItem[] }) {
|
||||
return (
|
||||
<div className="nav-group">
|
||||
@@ -60,6 +46,22 @@ function initials(name: string): string {
|
||||
export function Sidebar() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { lang, t, toggleLang } = useLanguage();
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ to: '/', icon: <LayoutDashboard size={16} />, label: t.nav.overview },
|
||||
{ to: '/signals', icon: <Radio size={16} />, label: t.nav.signals },
|
||||
{ to: '/status', icon: <Monitor size={16} />, label: t.nav.status },
|
||||
];
|
||||
|
||||
const workbenchNav: NavItem[] = [
|
||||
{ to: '/documents', icon: <FileText size={16} />, label: t.nav.documents },
|
||||
{ to: '/compliance', icon: <Shield size={16} />, label: t.nav.compliance },
|
||||
];
|
||||
|
||||
const chatNav: NavItem[] = [
|
||||
{ to: '/chat', icon: <MessageSquare size={16} />, label: t.nav.chat },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
@@ -72,9 +74,9 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<NavGroup title="Main" items={mainNav} />
|
||||
<NavGroup title="Workbench" items={workbenchNav} />
|
||||
<NavGroup title="Chat" items={chatNav} />
|
||||
<NavGroup title={t.nav.groupMain} items={mainNav} />
|
||||
<NavGroup title={t.nav.groupWorkbench} items={workbenchNav} />
|
||||
<NavGroup title={t.nav.groupChat} items={chatNav} />
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
@@ -92,11 +94,19 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="theme-btn" onClick={toggleTheme} title="Toggle theme">
|
||||
<button
|
||||
className="theme-btn"
|
||||
onClick={toggleLang}
|
||||
title={t.sidebar.toggleLang}
|
||||
style={{ fontSize: 12, fontWeight: 600 }}
|
||||
>
|
||||
{lang === 'en' ? 'EN' : '中'}
|
||||
</button>
|
||||
<button className="theme-btn" onClick={toggleTheme} title={t.sidebar.toggleTheme}>
|
||||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
{user && (
|
||||
<button className="logout-btn" onClick={logout} title="Sign out">
|
||||
<button className="logout-btn" onClick={logout} title={t.sidebar.signOut}>
|
||||
<LogOut size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
36
frontend/src/contexts/LanguageContext.tsx
Normal file
36
frontend/src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { en } from '../locales/en';
|
||||
import type { Translations } from '../locales/en';
|
||||
import { zh } from '../locales/zh';
|
||||
|
||||
export type Lang = 'en' | 'zh';
|
||||
|
||||
interface LanguageContextValue {
|
||||
lang: Lang;
|
||||
t: Translations;
|
||||
toggleLang: () => void;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue>({
|
||||
lang: 'en',
|
||||
t: en,
|
||||
toggleLang: () => {},
|
||||
});
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLang] = useState<Lang>('en');
|
||||
|
||||
const toggleLang = () => setLang(l => (l === 'en' ? 'zh' : 'en'));
|
||||
|
||||
const t = lang === 'en' ? en : zh;
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ lang, t, toggleLang }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return useContext(LanguageContext);
|
||||
}
|
||||
@@ -99,6 +99,10 @@ export interface ComplianceState {
|
||||
findings: ComplianceFindingEvent[];
|
||||
done: ComplianceDonePayload | null;
|
||||
errorText: string;
|
||||
// Direction B additions:
|
||||
analysisId: string | null;
|
||||
isReadOnly: boolean;
|
||||
activeFindingId: string | null;
|
||||
}
|
||||
|
||||
const COMPLIANCE_INIT: ComplianceState = {
|
||||
@@ -110,6 +114,9 @@ const COMPLIANCE_INIT: ComplianceState = {
|
||||
findings: [],
|
||||
done: null,
|
||||
errorText: '',
|
||||
analysisId: null,
|
||||
isReadOnly: false,
|
||||
activeFindingId: null,
|
||||
};
|
||||
|
||||
// ── Perception types ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,8 @@ export { ThemeProvider, useTheme } from './ThemeContext';
|
||||
export { AuthProvider, useAuth } from './AuthContext';
|
||||
export type { AuthUser } from './AuthContext';
|
||||
export { PageStateProvider, usePageState } from './PageStateContext';
|
||||
export { LanguageProvider, useLanguage } from './LanguageContext';
|
||||
export type { Lang } from './LanguageContext';
|
||||
export type {
|
||||
RagChatState,
|
||||
RagMessage,
|
||||
|
||||
460
frontend/src/locales/en.ts
Normal file
460
frontend/src/locales/en.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
// English translations — default language
|
||||
export interface Translations {
|
||||
nav: {
|
||||
groupMain: string;
|
||||
groupWorkbench: string;
|
||||
groupChat: string;
|
||||
overview: string;
|
||||
signals: string;
|
||||
status: string;
|
||||
documents: string;
|
||||
compliance: string;
|
||||
chat: string;
|
||||
};
|
||||
sidebar: {
|
||||
toggleTheme: string;
|
||||
toggleLang: string;
|
||||
signOut: string;
|
||||
};
|
||||
overview: {
|
||||
eyebrow: string;
|
||||
heroTitle: string;
|
||||
heroDesc: string;
|
||||
openDashboard: string;
|
||||
jumpToChat: string;
|
||||
sectionHowItWorks: string;
|
||||
sectionScreens: string;
|
||||
statScreens: string;
|
||||
statFlows: string;
|
||||
statReviewPosture: string;
|
||||
stepUpload: string; stepUploadDesc: string;
|
||||
stepProcess: string; stepProcessDesc: string;
|
||||
stepMonitor: string; stepMonitorDesc: string;
|
||||
stepAnalyze: string; stepAnalyzeDesc: string;
|
||||
stepReview: string; stepReviewDesc: string;
|
||||
stepChat: string; stepChatDesc: string;
|
||||
screenStatus: string; screenStatusDesc: string;
|
||||
screenSignals: string; screenSignalsDesc: string;
|
||||
screenDocuments: string; screenDocumentsDesc: string;
|
||||
screenCompliance: string; screenComplianceDesc: string;
|
||||
screenChat: string; screenChatDesc: string;
|
||||
screenAnalytics: string; screenAnalyticsDesc: string;
|
||||
};
|
||||
signals: {
|
||||
topbarTitle: string;
|
||||
topbarSub: string;
|
||||
searchPlaceholder: string;
|
||||
refreshBtn: string;
|
||||
crawlingBtn: string;
|
||||
statTotal: string;
|
||||
statHigh: string;
|
||||
statMedium: string;
|
||||
statLast90: string;
|
||||
badgeFinal: string;
|
||||
badgeDraft: string;
|
||||
badgeUrgent: string;
|
||||
badgePublished: string;
|
||||
emptySelectSignal: string;
|
||||
runAnalysis: string;
|
||||
stopBtn: string;
|
||||
sourceLink: string;
|
||||
tabOverview: string;
|
||||
tabObligations: string;
|
||||
tabImpact: string;
|
||||
tabChanges: string;
|
||||
cardScopeHeader: string;
|
||||
cardObligationsHeader: string;
|
||||
obligationsEmpty: string;
|
||||
colObligationDesc: string;
|
||||
colSubject: string;
|
||||
colType: string;
|
||||
colDeadline: string;
|
||||
deadlinePending: string;
|
||||
cardAffectedDocs: string;
|
||||
noAffectedDocs: string;
|
||||
cardAIImpact: string;
|
||||
footerText: string;
|
||||
statusConnecting: string;
|
||||
statusNoStream: string;
|
||||
statusCrawling: string;
|
||||
statusProcessing: string;
|
||||
statusComplete: string;
|
||||
statusUpdateComplete: string;
|
||||
statusError: string;
|
||||
statusConnFailed: string;
|
||||
diffOld: string;
|
||||
diffNew: string;
|
||||
diffCardHeader: string;
|
||||
};
|
||||
status: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
exportBtn: string;
|
||||
refreshBtn: string;
|
||||
newUploadBtn: string;
|
||||
statTotal: string;
|
||||
statIndexed: string;
|
||||
statFailed: string;
|
||||
statChunks: string;
|
||||
statCoverage: string;
|
||||
cardHealth: string;
|
||||
badgeOnline: string;
|
||||
badgeError: string;
|
||||
badgeDegraded: string;
|
||||
badgeUnknown: string;
|
||||
healthEndpointError: string;
|
||||
serviceEnabled: string;
|
||||
serviceDisabled: string;
|
||||
serviceNotLoaded: string;
|
||||
cardConfig: string;
|
||||
labelLLMProvider: string;
|
||||
labelLLMModel: string;
|
||||
labelEmbeddingModel: string;
|
||||
labelEmbeddingDim: string;
|
||||
labelMilvusCollection: string;
|
||||
labelParserBackend: string;
|
||||
labelChunkBackend: string;
|
||||
labelParserFailureMode: string;
|
||||
configLoadError: string;
|
||||
cardBreakdown: string;
|
||||
breakdownIndexed: string;
|
||||
breakdownProcessing: string;
|
||||
breakdownFailed: string;
|
||||
cardRuntime: string;
|
||||
labelActiveSessions: string;
|
||||
labelSessionCapacity: string;
|
||||
labelReranker: string;
|
||||
labelBM25: string;
|
||||
statusActive: string;
|
||||
statusUnavailable: string;
|
||||
footerAllOk: string;
|
||||
footerDegraded: string;
|
||||
footerChecking: string;
|
||||
totalChunks: string;
|
||||
};
|
||||
docs: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
refreshBtn: string;
|
||||
uploadBtn: string;
|
||||
confirmDeleteTitle: string;
|
||||
cancelBtn: string;
|
||||
deleteBtn: string;
|
||||
filterAll: string;
|
||||
filterReady: string;
|
||||
filterProcessing: string;
|
||||
filterFailed: string;
|
||||
filterPending: string;
|
||||
filterAllTypes: string;
|
||||
deleteSelected: string;
|
||||
colName: string;
|
||||
colStatus: string;
|
||||
colUploaded: string;
|
||||
colChunks: string;
|
||||
colSize: string;
|
||||
colType: string;
|
||||
colActions: string;
|
||||
loading: string;
|
||||
emptyNoDocuments: string;
|
||||
emptyNoMatch: string;
|
||||
titleDownload: string;
|
||||
titleRetry: string;
|
||||
titleDelete: string;
|
||||
};
|
||||
compliance: {
|
||||
topbarTitle: string;
|
||||
searchPlaceholder: string;
|
||||
clearBtn: string;
|
||||
exportBtn: string;
|
||||
exportJSON: string;
|
||||
exportText: string;
|
||||
newAnalysisBtn: string;
|
||||
statusAnalyzing: string;
|
||||
statusComplete: string;
|
||||
statusError: string;
|
||||
emptyTitle: string;
|
||||
emptyDesc: string;
|
||||
retrievingMsg: string;
|
||||
defaultRegulation: string;
|
||||
matchSuffix: string;
|
||||
colParagraph: string;
|
||||
extractingMsg: string;
|
||||
noTextExtracted: string;
|
||||
stagesHeader: string;
|
||||
stageExtraction: string;
|
||||
stageClauseSplit: string;
|
||||
stageRetrieval: string;
|
||||
stageSynthesis: string;
|
||||
gapInProgress: string;
|
||||
askAIBtn: string;
|
||||
chatBtn: string;
|
||||
conclusionHeader: string;
|
||||
riskScoreTooltip: string;
|
||||
statusCovered: string;
|
||||
statusGap: string;
|
||||
statusCritical: string;
|
||||
statusInfo: string;
|
||||
sourceTypePasted: string;
|
||||
sourceTypeIndexed: string;
|
||||
sourceTypeUploaded: string;
|
||||
chatSidebarHeader: string;
|
||||
chatThinking: string;
|
||||
quickQ1: string;
|
||||
quickQ2: string;
|
||||
quickQ3: string;
|
||||
chatPlaceholder: string;
|
||||
sendBtn: string;
|
||||
analysisFailed: string;
|
||||
exportReportHeader: string;
|
||||
exportSectionParagraph: string;
|
||||
exportSectionFindings: string;
|
||||
exportSectionConclusion: string;
|
||||
exportSectionActions: string;
|
||||
historyHeader: string;
|
||||
downloadReport: string;
|
||||
historyEmpty: string;
|
||||
historyDeleteConfirm: string;
|
||||
drawerClose: string;
|
||||
drawerChatEmpty: string;
|
||||
drawerSuggestionsHeader: string;
|
||||
};
|
||||
ragchat: {
|
||||
topbarTitle: string;
|
||||
exportBtn: string;
|
||||
quickPromptsHeader: string;
|
||||
inputPlaceholder: string;
|
||||
citationsHeader: string;
|
||||
citationsEmpty: string;
|
||||
apiError: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const en: Translations = {
|
||||
nav: {
|
||||
groupMain: 'Main',
|
||||
groupWorkbench: 'Workbench',
|
||||
groupChat: 'Chat',
|
||||
overview: 'Overview',
|
||||
signals: 'Regulatory Signals',
|
||||
status: 'System Status',
|
||||
documents: 'Documents',
|
||||
compliance: 'Compliance Analysis',
|
||||
chat: 'Regulation Q&A',
|
||||
},
|
||||
sidebar: {
|
||||
toggleTheme: 'Toggle theme',
|
||||
toggleLang: 'Switch language',
|
||||
signOut: 'Sign out',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'T-Systems · AI Regulation Hub',
|
||||
heroTitle: 'AI Compliance,\nAutomated end-to-end',
|
||||
heroDesc: 'Monitor global AI regulations, analyze document compliance gaps, and get cited answers — all in one platform.',
|
||||
openDashboard: 'Open dashboard',
|
||||
jumpToChat: 'Jump to regulation chat',
|
||||
sectionHowItWorks: 'How it works',
|
||||
sectionScreens: 'Screens',
|
||||
statScreens: 'Screens',
|
||||
statFlows: 'Backend-aware flows',
|
||||
statReviewPosture: 'Review posture',
|
||||
stepUpload: 'Upload', stepUploadDesc: 'Ingest regulation documents',
|
||||
stepProcess: 'Process', stepProcessDesc: 'Embed and chunk via vector DB',
|
||||
stepMonitor: 'Monitor', stepMonitorDesc: 'Watch regulatory signal feed',
|
||||
stepAnalyze: 'Analyze', stepAnalyzeDesc: 'Run compliance gap analysis',
|
||||
stepReview: 'Review', stepReviewDesc: 'Inspect findings with AI assist',
|
||||
stepChat: 'Chat', stepChatDesc: 'Ask questions with cited answers',
|
||||
screenStatus: 'System Status', screenStatusDesc: 'Live health and workflow queue',
|
||||
screenSignals: 'Regulatory Signals', screenSignalsDesc: 'AI-detected regulatory changes',
|
||||
screenDocuments: 'Document Management', screenDocumentsDesc: 'Upload and inspect documents',
|
||||
screenCompliance: 'Compliance Analysis', screenComplianceDesc: 'Three-column compliance workspace',
|
||||
screenChat: 'Regulation Q&A', screenChatDesc: 'Chat with cited regulation sources',
|
||||
screenAnalytics: 'Analytics', screenAnalyticsDesc: 'KPIs and coverage metrics',
|
||||
},
|
||||
signals: {
|
||||
topbarTitle: 'Regulatory Signals',
|
||||
topbarSub: 'ai-powered · live feed',
|
||||
searchPlaceholder: 'Search signals...',
|
||||
refreshBtn: 'Refresh Sources',
|
||||
crawlingBtn: 'Crawling...',
|
||||
statTotal: 'Total signals',
|
||||
statHigh: 'High impact',
|
||||
statMedium: 'Medium impact',
|
||||
statLast90: 'Last 90 days',
|
||||
badgeFinal: 'Final',
|
||||
badgeDraft: 'Draft',
|
||||
badgeUrgent: 'Urgent',
|
||||
badgePublished: 'Published',
|
||||
emptySelectSignal: 'Select a signal to run impact analysis',
|
||||
runAnalysis: 'Run impact analysis',
|
||||
stopBtn: 'Stop',
|
||||
sourceLink: 'Source',
|
||||
tabOverview: 'Overview',
|
||||
tabObligations: 'Obligations',
|
||||
tabImpact: 'Impact Assessment',
|
||||
tabChanges: 'Change Comparison',
|
||||
cardScopeHeader: 'Scope & Summary',
|
||||
cardObligationsHeader: 'Obligations',
|
||||
obligationsEmpty: 'No structured data yet. Click "Run impact analysis" to extract.',
|
||||
colObligationDesc: 'Obligation',
|
||||
colSubject: 'Subject',
|
||||
colType: 'Type',
|
||||
colDeadline: 'Deadlines',
|
||||
deadlinePending: 'Pending',
|
||||
cardAffectedDocs: 'Affected documents',
|
||||
noAffectedDocs: 'No affected documents found.',
|
||||
cardAIImpact: 'AI Impact Analysis',
|
||||
footerText: 'Live feed · Regulation Hub',
|
||||
statusConnecting: 'Connecting to data sources...',
|
||||
statusNoStream: 'No stream',
|
||||
statusCrawling: 'Crawling...',
|
||||
statusProcessing: 'Processing {count} items...',
|
||||
statusComplete: 'Done +{count} items',
|
||||
statusUpdateComplete: 'Update complete — {new} added, {updated} updated',
|
||||
statusError: 'Error: {message}',
|
||||
statusConnFailed: 'Connection failed: {message}',
|
||||
diffOld: 'Previous',
|
||||
diffNew: 'Current',
|
||||
diffCardHeader: 'Change Comparison',
|
||||
},
|
||||
status: {
|
||||
topbarTitle: 'System Status',
|
||||
searchPlaceholder: 'Search...',
|
||||
exportBtn: 'Export',
|
||||
refreshBtn: 'Refresh',
|
||||
newUploadBtn: 'New upload',
|
||||
statTotal: 'Documents total',
|
||||
statIndexed: 'Indexed',
|
||||
statFailed: 'Failed',
|
||||
statChunks: 'Vector chunks',
|
||||
statCoverage: 'Index coverage',
|
||||
cardHealth: 'System health',
|
||||
badgeOnline: 'Online',
|
||||
badgeError: 'Error',
|
||||
badgeDegraded: 'Degraded',
|
||||
badgeUnknown: 'Unknown',
|
||||
healthEndpointError: 'Could not reach health endpoint',
|
||||
serviceEnabled: 'Enabled',
|
||||
serviceDisabled: 'Disabled',
|
||||
serviceNotLoaded: 'Not loaded',
|
||||
cardConfig: 'System configuration',
|
||||
labelLLMProvider: 'LLM provider',
|
||||
labelLLMModel: 'LLM model',
|
||||
labelEmbeddingModel: 'Embedding model',
|
||||
labelEmbeddingDim: 'Embedding dim',
|
||||
labelMilvusCollection: 'Milvus collection',
|
||||
labelParserBackend: 'Parser backend',
|
||||
labelChunkBackend: 'Chunk backend',
|
||||
labelParserFailureMode: 'Parser failure mode',
|
||||
configLoadError: 'Could not load config',
|
||||
cardBreakdown: 'Document breakdown',
|
||||
breakdownIndexed: 'Indexed',
|
||||
breakdownProcessing: 'Processing / Parsed',
|
||||
breakdownFailed: 'Failed',
|
||||
cardRuntime: 'Runtime info',
|
||||
labelActiveSessions: 'Active chat sessions',
|
||||
labelSessionCapacity: 'Session capacity',
|
||||
labelReranker: 'Cross-encoder reranker',
|
||||
labelBM25: 'BM25 hybrid retrieval',
|
||||
statusActive: 'Active',
|
||||
statusUnavailable: 'Unavailable',
|
||||
footerAllOk: 'All systems operational',
|
||||
footerDegraded: 'Degraded',
|
||||
footerChecking: 'Checking…',
|
||||
totalChunks: 'Total vector chunks',
|
||||
},
|
||||
docs: {
|
||||
topbarTitle: 'Document Management',
|
||||
searchPlaceholder: 'Search documents...',
|
||||
refreshBtn: 'Refresh',
|
||||
uploadBtn: 'Upload document',
|
||||
confirmDeleteTitle: 'Confirm deletion',
|
||||
cancelBtn: 'Cancel',
|
||||
deleteBtn: 'Delete',
|
||||
filterAll: 'All',
|
||||
filterReady: 'Ready',
|
||||
filterProcessing: 'Processing',
|
||||
filterFailed: 'Failed',
|
||||
filterPending: 'Pending',
|
||||
filterAllTypes: 'All types',
|
||||
deleteSelected: 'Delete selected',
|
||||
colName: 'Document name',
|
||||
colStatus: 'Status',
|
||||
colUploaded: 'Uploaded',
|
||||
colChunks: 'Chunks',
|
||||
colSize: 'Size',
|
||||
colType: 'Type',
|
||||
colActions: 'Actions',
|
||||
loading: 'Loading documents…',
|
||||
emptyNoDocuments: 'No documents yet. Upload a document to get started.',
|
||||
emptyNoMatch: 'No documents match the current filters.',
|
||||
titleDownload: 'Download original file',
|
||||
titleRetry: 'Retry processing',
|
||||
titleDelete: 'Delete document',
|
||||
},
|
||||
compliance: {
|
||||
topbarTitle: 'Compliance Analysis',
|
||||
searchPlaceholder: 'Search analyses...',
|
||||
clearBtn: 'Clear',
|
||||
exportBtn: 'Export',
|
||||
exportJSON: 'Export JSON',
|
||||
exportText: 'Export Text',
|
||||
newAnalysisBtn: 'New analysis',
|
||||
statusAnalyzing: 'Analyzing…',
|
||||
statusComplete: 'Analysis complete',
|
||||
statusError: 'Error',
|
||||
emptyTitle: 'No analysis running',
|
||||
emptyDesc: 'Click New analysis to start a compliance gap review against your indexed regulations.',
|
||||
retrievingMsg: 'Retrieving relevant regulations…',
|
||||
defaultRegulation: 'Regulation',
|
||||
matchSuffix: '% match',
|
||||
colParagraph: 'Paragraph Under Review',
|
||||
extractingMsg: 'Extracting and analyzing text…',
|
||||
noTextExtracted: 'No text extracted',
|
||||
stagesHeader: 'Analysis stages',
|
||||
stageExtraction: 'Text extraction',
|
||||
stageClauseSplit: 'Clause splitting',
|
||||
stageRetrieval: 'Regulation retrieval',
|
||||
stageSynthesis: 'Conclusion synthesis',
|
||||
gapInProgress: 'Gap analysis in progress…',
|
||||
askAIBtn: 'Ask AI',
|
||||
chatBtn: 'Chat',
|
||||
conclusionHeader: 'Conclusion',
|
||||
riskScoreTooltip: 'Risk score (0=safe, 100=critical)',
|
||||
statusCovered: 'Covered',
|
||||
statusGap: 'Gap',
|
||||
statusCritical: 'Critical',
|
||||
statusInfo: 'Info',
|
||||
sourceTypePasted: 'Pasted Text',
|
||||
sourceTypeIndexed: 'Indexed Document',
|
||||
sourceTypeUploaded: 'Uploaded File',
|
||||
chatSidebarHeader: 'AI Compliance Q&A',
|
||||
chatThinking: 'Thinking▋',
|
||||
quickQ1: 'What regulation applies?',
|
||||
quickQ2: 'How to remediate?',
|
||||
quickQ3: 'What is the risk?',
|
||||
chatPlaceholder: 'Ask about this finding…',
|
||||
sendBtn: 'Send',
|
||||
analysisFailed: 'Analysis failed',
|
||||
exportReportHeader: 'COMPLIANCE ANALYSIS REPORT',
|
||||
exportSectionParagraph: '── PARAGRAPH UNDER REVIEW ──',
|
||||
exportSectionFindings: '── FINDINGS ──',
|
||||
exportSectionConclusion: '── CONCLUSION ──',
|
||||
exportSectionActions: '── RECOMMENDED ACTIONS ──',
|
||||
historyHeader: 'History',
|
||||
downloadReport: 'Download report',
|
||||
historyEmpty: 'No analyses yet.',
|
||||
historyDeleteConfirm: 'Delete this analysis record? This cannot be undone.',
|
||||
drawerClose: 'Close',
|
||||
drawerChatEmpty: 'No messages yet. Ask a question below.',
|
||||
drawerSuggestionsHeader: 'Suggested questions',
|
||||
},
|
||||
ragchat: {
|
||||
topbarTitle: 'Regulation Q&A',
|
||||
exportBtn: 'Export chat',
|
||||
quickPromptsHeader: 'Quick prompts',
|
||||
inputPlaceholder: 'Ask about your regulations…',
|
||||
citationsHeader: 'Sources',
|
||||
citationsEmpty: 'Citations will appear here after a response is generated.',
|
||||
apiError: 'Could not reach the RAG API. Please check the backend.',
|
||||
},
|
||||
};
|
||||
231
frontend/src/locales/zh.ts
Normal file
231
frontend/src/locales/zh.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Translations } from './en';
|
||||
|
||||
export const zh: Translations = {
|
||||
nav: {
|
||||
groupMain: '主菜单',
|
||||
groupWorkbench: '工作台',
|
||||
groupChat: '对话',
|
||||
overview: '概览',
|
||||
signals: '法规信号',
|
||||
status: '系统状态',
|
||||
documents: '文档管理',
|
||||
compliance: '合规分析',
|
||||
chat: '法规问答',
|
||||
},
|
||||
sidebar: {
|
||||
toggleTheme: '切换主题',
|
||||
toggleLang: '切换语言',
|
||||
signOut: '退出',
|
||||
},
|
||||
overview: {
|
||||
eyebrow: 'T-Systems · AI 法规中心',
|
||||
heroTitle: 'AI 合规,\n端到端自动化',
|
||||
heroDesc: '监控全球 AI 法规,分析文档合规差距,获取有引用来源的回答——一站式平台。',
|
||||
openDashboard: '打开仪表盘',
|
||||
jumpToChat: '跳转到法规对话',
|
||||
sectionHowItWorks: '工作流程',
|
||||
sectionScreens: '功能页面',
|
||||
statScreens: '功能页面',
|
||||
statFlows: '后端感知流程',
|
||||
statReviewPosture: '审查状态',
|
||||
stepUpload: '上传', stepUploadDesc: '导入法规文档',
|
||||
stepProcess: '处理', stepProcessDesc: '向量化与分块',
|
||||
stepMonitor: '监控', stepMonitorDesc: '监控法规信号流',
|
||||
stepAnalyze: '分析', stepAnalyzeDesc: '运行合规差距分析',
|
||||
stepReview: '审查', stepReviewDesc: 'AI 辅助审查发现',
|
||||
stepChat: '对话', stepChatDesc: '带引用来源的问答',
|
||||
screenStatus: '系统状态', screenStatusDesc: '实时健康与任务队列',
|
||||
screenSignals: '法规信号', screenSignalsDesc: 'AI 检测法规变更',
|
||||
screenDocuments: '文档管理', screenDocumentsDesc: '上传与查阅文档',
|
||||
screenCompliance: '合规分析', screenComplianceDesc: '三栏合规工作台',
|
||||
screenChat: '法规问答', screenChatDesc: '带引用来源的法规对话',
|
||||
screenAnalytics: '数据分析', screenAnalyticsDesc: 'KPI 与覆盖指标',
|
||||
},
|
||||
signals: {
|
||||
topbarTitle: '法规信号',
|
||||
topbarSub: 'AI 驱动 · 实时订阅',
|
||||
searchPlaceholder: '搜索信号...',
|
||||
refreshBtn: '刷新数据源',
|
||||
crawlingBtn: '抓取中...',
|
||||
statTotal: '信号总数',
|
||||
statHigh: '高影响',
|
||||
statMedium: '中影响',
|
||||
statLast90: '近 90 天',
|
||||
badgeFinal: '已发布',
|
||||
badgeDraft: '草案',
|
||||
badgeUrgent: '紧急',
|
||||
badgePublished: '已发布',
|
||||
emptySelectSignal: '选择信号以运行影响分析',
|
||||
runAnalysis: '运行影响分析',
|
||||
stopBtn: '停止',
|
||||
sourceLink: '来源',
|
||||
tabOverview: '概览',
|
||||
tabObligations: '义务条款',
|
||||
tabImpact: '影响评估',
|
||||
tabChanges: '变更对比',
|
||||
cardScopeHeader: '范围与摘要',
|
||||
cardObligationsHeader: '义务条款',
|
||||
obligationsEmpty: '暂无结构化数据。点击"运行影响分析"触发提取。',
|
||||
colObligationDesc: '义务描述',
|
||||
colSubject: '主体',
|
||||
colType: '类型',
|
||||
colDeadline: '截止日期',
|
||||
deadlinePending: '待定',
|
||||
cardAffectedDocs: '受影响文档',
|
||||
noAffectedDocs: '未找到受影响文档。',
|
||||
cardAIImpact: 'AI 影响分析',
|
||||
footerText: '实时订阅 · 法规中心',
|
||||
statusConnecting: '正在连接数据源...',
|
||||
statusNoStream: '无数据流',
|
||||
statusCrawling: '抓取中...',
|
||||
statusProcessing: '处理 {count} 条...',
|
||||
statusComplete: '完成 +{count} 条',
|
||||
statusUpdateComplete: '更新完成 — 新增 {new} 条,更新 {updated} 条',
|
||||
statusError: '错误: {message}',
|
||||
statusConnFailed: '连接失败: {message}',
|
||||
diffOld: '旧版',
|
||||
diffNew: '新版',
|
||||
diffCardHeader: '变更对比',
|
||||
},
|
||||
status: {
|
||||
topbarTitle: '系统状态',
|
||||
searchPlaceholder: '搜索...',
|
||||
exportBtn: '导出',
|
||||
refreshBtn: '刷新',
|
||||
newUploadBtn: '上传文档',
|
||||
statTotal: '文档总数',
|
||||
statIndexed: '已索引',
|
||||
statFailed: '失败',
|
||||
statChunks: '向量分块数',
|
||||
statCoverage: '索引覆盖率',
|
||||
cardHealth: '系统健康',
|
||||
badgeOnline: '在线',
|
||||
badgeError: '错误',
|
||||
badgeDegraded: '降级',
|
||||
badgeUnknown: '未知',
|
||||
healthEndpointError: '无法访问健康检查端点',
|
||||
serviceEnabled: '已启用',
|
||||
serviceDisabled: '已禁用',
|
||||
serviceNotLoaded: '未加载',
|
||||
cardConfig: '系统配置',
|
||||
labelLLMProvider: 'LLM 提供商',
|
||||
labelLLMModel: 'LLM 模型',
|
||||
labelEmbeddingModel: '向量模型',
|
||||
labelEmbeddingDim: '向量维度',
|
||||
labelMilvusCollection: 'Milvus 集合',
|
||||
labelParserBackend: '解析后端',
|
||||
labelChunkBackend: '分块后端',
|
||||
labelParserFailureMode: '解析失败模式',
|
||||
configLoadError: '无法加载配置',
|
||||
cardBreakdown: '文档分布',
|
||||
breakdownIndexed: '已索引',
|
||||
breakdownProcessing: '处理中 / 已解析',
|
||||
breakdownFailed: '失败',
|
||||
cardRuntime: '运行时信息',
|
||||
labelActiveSessions: '活跃对话会话',
|
||||
labelSessionCapacity: '会话容量',
|
||||
labelReranker: '交叉编码器重排序',
|
||||
labelBM25: 'BM25 混合检索',
|
||||
statusActive: '活跃',
|
||||
statusUnavailable: '不可用',
|
||||
footerAllOk: '所有系统正常',
|
||||
footerDegraded: '降级运行',
|
||||
footerChecking: '检查中…',
|
||||
totalChunks: '向量分块总数',
|
||||
},
|
||||
docs: {
|
||||
topbarTitle: '文档管理',
|
||||
searchPlaceholder: '搜索文档...',
|
||||
refreshBtn: '刷新',
|
||||
uploadBtn: '上传文档',
|
||||
confirmDeleteTitle: '确认删除',
|
||||
cancelBtn: '取消',
|
||||
deleteBtn: '删除',
|
||||
filterAll: '全部',
|
||||
filterReady: '就绪',
|
||||
filterProcessing: '处理中',
|
||||
filterFailed: '失败',
|
||||
filterPending: '待处理',
|
||||
filterAllTypes: '所有类型',
|
||||
deleteSelected: '删除所选',
|
||||
colName: '文档名称',
|
||||
colStatus: '状态',
|
||||
colUploaded: '上传时间',
|
||||
colChunks: '分块数',
|
||||
colSize: '大小',
|
||||
colType: '类型',
|
||||
colActions: '操作',
|
||||
loading: '加载文档中…',
|
||||
emptyNoDocuments: '暂无文档。请上传文档以开始使用。',
|
||||
emptyNoMatch: '没有文档符合当前筛选条件。',
|
||||
titleDownload: '下载原始文件',
|
||||
titleRetry: '重试处理',
|
||||
titleDelete: '删除文档',
|
||||
},
|
||||
compliance: {
|
||||
topbarTitle: '合规分析',
|
||||
searchPlaceholder: '搜索分析记录...',
|
||||
clearBtn: '清除',
|
||||
exportBtn: '导出',
|
||||
exportJSON: '导出 JSON',
|
||||
exportText: '导出文本',
|
||||
newAnalysisBtn: '新建分析',
|
||||
statusAnalyzing: '分析中…',
|
||||
statusComplete: '分析完成',
|
||||
statusError: '错误',
|
||||
emptyTitle: '暂无分析任务',
|
||||
emptyDesc: '点击"新建分析"对已索引法规进行合规差距审查。',
|
||||
retrievingMsg: '正在检索相关法规…',
|
||||
defaultRegulation: '法规',
|
||||
matchSuffix: '% 匹配',
|
||||
colParagraph: '待审查段落',
|
||||
extractingMsg: '正在提取并分析文本…',
|
||||
noTextExtracted: '未提取到文本',
|
||||
stagesHeader: '分析阶段',
|
||||
stageExtraction: '文本提取',
|
||||
stageClauseSplit: '条款分割',
|
||||
stageRetrieval: '法规检索',
|
||||
stageSynthesis: '结论综合',
|
||||
gapInProgress: '差距分析进行中…',
|
||||
askAIBtn: '问 AI',
|
||||
chatBtn: '对话',
|
||||
conclusionHeader: '结论',
|
||||
riskScoreTooltip: '风险评分(0=安全,100=严重)',
|
||||
statusCovered: '已覆盖',
|
||||
statusGap: '存在差距',
|
||||
statusCritical: '严重',
|
||||
statusInfo: '信息',
|
||||
sourceTypePasted: '粘贴文本',
|
||||
sourceTypeIndexed: '已索引文档',
|
||||
sourceTypeUploaded: '上传文件',
|
||||
chatSidebarHeader: 'AI 合规问答',
|
||||
chatThinking: '思考中▋',
|
||||
quickQ1: '适用哪条法规?',
|
||||
quickQ2: '如何整改?',
|
||||
quickQ3: '风险等级如何?',
|
||||
chatPlaceholder: '针对此发现提问…',
|
||||
sendBtn: '发送',
|
||||
analysisFailed: '分析失败',
|
||||
exportReportHeader: '合规分析报告',
|
||||
exportSectionParagraph: '── 待审查段落 ──',
|
||||
exportSectionFindings: '── 发现 ──',
|
||||
exportSectionConclusion: '── 结论 ──',
|
||||
exportSectionActions: '── 建议行动 ──',
|
||||
historyHeader: '历史记录',
|
||||
downloadReport: '下载报告',
|
||||
historyEmpty: '暂无分析记录。',
|
||||
historyDeleteConfirm: '删除此分析记录?此操作不可撤销。',
|
||||
drawerClose: '关闭',
|
||||
drawerChatEmpty: '暂无消息。请在下方提问。',
|
||||
drawerSuggestionsHeader: '建议问题',
|
||||
},
|
||||
ragchat: {
|
||||
topbarTitle: '法规问答',
|
||||
exportBtn: '导出对话',
|
||||
quickPromptsHeader: '快捷问题',
|
||||
inputPlaceholder: '请输入关于法规的问题…',
|
||||
citationsHeader: '引用来源',
|
||||
citationsEmpty: '生成回答后,引用来源将显示在此处。',
|
||||
apiError: '无法连接到 RAG API,请检查后端服务。',
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Search, Plus, AlertTriangle, Download, MessageSquare, ChevronDown } from 'lucide-react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { NewAnalysisModal } from './NewAnalysisModal';
|
||||
import { useComplianceAnalysis } from './useComplianceAnalysis';
|
||||
import { usePageState } from '../../contexts';
|
||||
import { HistoryRail } from './HistoryRail';
|
||||
import { FindingChatDrawer } from './FindingChatDrawer';
|
||||
import type { FindingEvent, SourceEvent, AnalysisMeta } from './useComplianceAnalysis';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
@@ -11,9 +15,6 @@ function authHeader(): Record<string, string> {
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { ok: 'Covered', warn: 'Gap', risk: 'Critical', info: 'Info' };
|
||||
const SOURCE_TYPE_LABEL: Record<string, string> = { text: 'Pasted Text', doc: 'Indexed Document', upload: 'Uploaded File' };
|
||||
|
||||
function riskClass(score: number) {
|
||||
if (score >= 70) return 'high';
|
||||
if (score >= 40) return 'med';
|
||||
@@ -112,11 +113,93 @@ function useFindingChat() {
|
||||
return { open, findingIdx, messages, input, setInput, loading, openFor, close, send };
|
||||
}
|
||||
|
||||
function _FindingChatDrawerWrapper({
|
||||
analysisId,
|
||||
findingIndex,
|
||||
finding,
|
||||
onClose,
|
||||
}: {
|
||||
analysisId: string;
|
||||
findingIndex: number;
|
||||
finding: { title: string; desc: string; status: string; clause_ref?: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [findingId, setFindingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/compliance/history/${analysisId}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((data: { findings?: Array<{ seq: number; id: string }> }) => {
|
||||
const f = (data.findings ?? []).find(f => f.seq === findingIndex);
|
||||
if (f?.id) setFindingId(f.id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [analysisId, findingIndex]);
|
||||
|
||||
if (!findingId) return null;
|
||||
return (
|
||||
<FindingChatDrawer
|
||||
analysisId={analysisId}
|
||||
findingId={findingId}
|
||||
finding={finding}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompliancePage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const { state, run, reset } = useComplianceAnalysis();
|
||||
const chat = useFindingChat();
|
||||
const [drawerFindingIdx, setDrawerFindingIdx] = useState<number | null>(null);
|
||||
|
||||
const { setComplianceState } = usePageState();
|
||||
const { t } = useLanguage();
|
||||
const STATUS_LABEL: Record<string, string> = { ok: t.compliance.statusCovered, warn: t.compliance.statusGap, risk: t.compliance.statusCritical, info: t.compliance.statusInfo };
|
||||
const SOURCE_TYPE_LABEL: Record<string, string> = { text: t.compliance.sourceTypePasted, doc: t.compliance.sourceTypeIndexed, upload: t.compliance.sourceTypeUploaded };
|
||||
|
||||
const [historyRefresh, setHistoryRefresh] = useState(0);
|
||||
const prevAnalysisIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.analysisId && state.analysisId !== prevAnalysisIdRef.current) {
|
||||
prevAnalysisIdRef.current = state.analysisId;
|
||||
setHistoryRefresh(n => n + 1);
|
||||
}
|
||||
}, [state.analysisId]);
|
||||
|
||||
async function handleSelectHistory(id: string) {
|
||||
const res = await fetch(`/api/v1/compliance/history/${id}`, { headers: authHeader() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setComplianceState({
|
||||
status: 'done',
|
||||
stageLabel: 'Complete',
|
||||
stageKey: 'concluding',
|
||||
meta: { title: data.doc_name, sourceType: 'doc', startedAt: data.created_at },
|
||||
sources: [],
|
||||
findings: (data.findings || []).map((f: Record<string, unknown>) => ({
|
||||
title: String(f.title ?? ''),
|
||||
desc: String(f.description ?? ''),
|
||||
status: String(f.status ?? 'ok'),
|
||||
clause_ref: f.clause_ref ? String(f.clause_ref) : undefined,
|
||||
})),
|
||||
done: {
|
||||
conclusion: String(data.conclusion ?? ''),
|
||||
actions: data.actions ?? [],
|
||||
risk_score: Number(data.risk_score ?? 0),
|
||||
highlight_terms: data.highlight_terms ?? [],
|
||||
para_text: String(data.para_text ?? ''),
|
||||
},
|
||||
errorText: '',
|
||||
analysisId: data.id,
|
||||
isReadOnly: true,
|
||||
activeFindingId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const isIdle = state.status === 'idle';
|
||||
const isStreaming = state.status === 'streaming';
|
||||
@@ -147,24 +230,24 @@ export function CompliancePage() {
|
||||
|
||||
function exportText() {
|
||||
const lines: string[] = [
|
||||
`COMPLIANCE ANALYSIS REPORT`,
|
||||
t.compliance.exportReportHeader,
|
||||
`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 ──',
|
||||
t.compliance.exportSectionParagraph,
|
||||
state.done?.para_text ?? '',
|
||||
'',
|
||||
'── FINDINGS ──',
|
||||
t.compliance.exportSectionFindings,
|
||||
...state.findings.map((f, i) =>
|
||||
`[${i + 1}] [${f.status.toUpperCase()}] ${f.title}\n ${f.desc}${f.clause_ref ? `\n Ref: ${f.clause_ref}` : ''}`
|
||||
),
|
||||
'',
|
||||
'── CONCLUSION ──',
|
||||
t.compliance.exportSectionConclusion,
|
||||
state.done?.conclusion ?? '',
|
||||
'',
|
||||
'── RECOMMENDED ACTIONS ──',
|
||||
t.compliance.exportSectionActions,
|
||||
...(state.done?.actions ?? []).map(a => `• ${a.label}: ${a.value}`),
|
||||
];
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
|
||||
@@ -184,15 +267,15 @@ export function CompliancePage() {
|
||||
return (
|
||||
<div className="compliance-page" style={{ position: 'relative' }}>
|
||||
<Topbar
|
||||
title="Compliance Analysis"
|
||||
title={t.compliance.topbarTitle}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input placeholder="Search analyses..." />
|
||||
<input placeholder={t.compliance.searchPlaceholder} />
|
||||
</div>
|
||||
{isStreaming || isDone || isError ? (
|
||||
<button className="btn sm" onClick={reset}>Clear</button>
|
||||
<button className="btn sm" onClick={reset}>{t.compliance.clearBtn}</button>
|
||||
) : null}
|
||||
{isDone && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -200,7 +283,7 @@ export function CompliancePage() {
|
||||
className="btn sm"
|
||||
onClick={() => setShowExportMenu(v => !v)}
|
||||
>
|
||||
<Download size={13} />Export<ChevronDown size={11} />
|
||||
<Download size={13} />{t.compliance.exportBtn}<ChevronDown size={11} />
|
||||
</button>
|
||||
{showExportMenu && (
|
||||
<div style={{
|
||||
@@ -212,17 +295,17 @@ export function CompliancePage() {
|
||||
<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>
|
||||
>{t.compliance.exportJSON}</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>
|
||||
>{t.compliance.exportText}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn sm primary" onClick={() => setShowModal(true)}>
|
||||
<Plus size={13} />New analysis
|
||||
<Plus size={13} />{t.compliance.newAnalysisBtn}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -241,7 +324,7 @@ export function CompliancePage() {
|
||||
<div className={`compliance-status-bar ${state.status}`}>
|
||||
<div className="status-dot" />
|
||||
<span className="status-bar-label">
|
||||
{isStreaming ? 'Analyzing…' : isDone ? 'Analysis complete' : 'Error'}
|
||||
{isStreaming ? t.compliance.statusAnalyzing : isDone ? t.compliance.statusComplete : t.compliance.statusError}
|
||||
</span>
|
||||
<span className="status-bar-sub">{state.stageLabel}</span>
|
||||
</div>
|
||||
@@ -252,8 +335,8 @@ export function CompliancePage() {
|
||||
{isIdle && (
|
||||
<div className="analysis-empty">
|
||||
<div className="analysis-empty-icon"><Plus size={24} /></div>
|
||||
<h3>No analysis running</h3>
|
||||
<p>Click <strong>New analysis</strong> to start a compliance gap review against your indexed regulations.</p>
|
||||
<h3>{t.compliance.emptyTitle}</h3>
|
||||
<p>{t.compliance.emptyDesc}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -285,22 +368,29 @@ export function CompliancePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="compliance-workspace" style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<HistoryRail
|
||||
refreshTrigger={historyRefresh}
|
||||
onSelect={handleSelectHistory}
|
||||
selectedId={state.analysisId}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="compliance-workspace" style={{ position: 'relative' }}>
|
||||
|
||||
{/* Column 1: Retrieved Regulations */}
|
||||
<div className="comp-col source-col">
|
||||
<div className="col-header">
|
||||
Retrieved Regulations {state.sources.length > 0 && `(${state.sources.length})`}
|
||||
{t.compliance.stageRetrieval} {state.sources.length > 0 && `(${state.sources.length})`}
|
||||
</div>
|
||||
{state.sources.length === 0 && isStreaming && (
|
||||
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
|
||||
Retrieving relevant regulations…
|
||||
{t.compliance.retrievingMsg}
|
||||
</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="source-std">{s.standard || t.compliance.defaultRegulation}</span>
|
||||
<span className={`status ${s.status === 'retrieved' ? 'ok' : s.status}`}>
|
||||
{STATUS_LABEL[s.status] ?? 'Retrieved'}
|
||||
</span>
|
||||
@@ -309,7 +399,7 @@ export function CompliancePage() {
|
||||
{s.score > 0 && (
|
||||
<div className="source-scores">
|
||||
<span className="score-pill">
|
||||
{s.score <= 1 ? Math.round(s.score * 100) : Math.round(s.score)}% match
|
||||
{s.score <= 1 ? Math.round(s.score * 100) : Math.round(s.score)}{t.compliance.matchSuffix}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -324,7 +414,7 @@ export function CompliancePage() {
|
||||
|
||||
{/* Column 2: Paragraph Under Review + Stages */}
|
||||
<div className="comp-col review-col">
|
||||
<div className="col-header">Paragraph Under Review</div>
|
||||
<div className="col-header">{t.compliance.colParagraph}</div>
|
||||
|
||||
<div className="card para-card">
|
||||
{isDone && state.done?.para_text ? (
|
||||
@@ -333,16 +423,16 @@ export function CompliancePage() {
|
||||
</p>
|
||||
) : (
|
||||
<p className="para-text" style={{ color: 'var(--muted)' }}>
|
||||
{isStreaming ? 'Extracting and analyzing text…' : 'No text extracted'}
|
||||
{isStreaming ? t.compliance.extractingMsg : t.compliance.noTextExtracted}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card stages-card">
|
||||
<div className="card-header">Analysis stages</div>
|
||||
<div className="card-header">{t.compliance.stagesHeader}</div>
|
||||
{(() => {
|
||||
const STAGE_KEYS = ['extracting', 'splitting', 'analyzing', 'concluding'];
|
||||
const STAGE_LABELS = ['Text extraction', 'Clause splitting', 'Regulation retrieval', 'Conclusion synthesis'];
|
||||
const STAGE_LABELS = [t.compliance.stageExtraction, t.compliance.stageClauseSplit, t.compliance.stageRetrieval, t.compliance.stageSynthesis];
|
||||
const curIdx = STAGE_KEYS.indexOf(state.stageKey);
|
||||
return STAGE_KEYS.map((key, idx) => {
|
||||
const pct = isDone ? 100 : idx < curIdx ? 100 : idx === curIdx ? 60 : 0;
|
||||
@@ -371,7 +461,7 @@ export function CompliancePage() {
|
||||
|
||||
{state.findings.length === 0 && isStreaming && (
|
||||
<div style={{ padding: '20px 16px', color: 'var(--muted)', fontSize: 12 }}>
|
||||
Gap analysis in progress…
|
||||
{t.compliance.gapInProgress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -391,8 +481,17 @@ export function CompliancePage() {
|
||||
style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', gap: 4 }}
|
||||
onClick={() => chat.openFor(i, f)}
|
||||
>
|
||||
<MessageSquare size={11} />Ask AI
|
||||
<MessageSquare size={11} />{t.compliance.askAIBtn}
|
||||
</button>
|
||||
{state.analysisId && (
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={() => setDrawerFindingIdx(i)}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
💬 {t.compliance.chatBtn}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -401,10 +500,10 @@ export function CompliancePage() {
|
||||
{isDone && state.done && (
|
||||
<div className="card conclusion-box">
|
||||
<div className="card-header" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span>Conclusion</span>
|
||||
<span>{t.compliance.conclusionHeader}</span>
|
||||
<div
|
||||
className={`risk-score-badge ${riskClass(state.done.risk_score)}`}
|
||||
title="Risk score (0=safe, 100=critical)"
|
||||
title={t.compliance.riskScoreTooltip}
|
||||
>
|
||||
{state.done.risk_score}
|
||||
</div>
|
||||
@@ -431,12 +530,14 @@ export function CompliancePage() {
|
||||
{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
|
||||
<AlertTriangle size={14} /> {t.compliance.analysisFailed}
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>{state.errorText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Finding Chat Side Panel ────────────────────────────────── */}
|
||||
@@ -450,7 +551,7 @@ export function CompliancePage() {
|
||||
{/* 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: 13, fontWeight: 600 }}>{t.compliance.chatSidebarHeader}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
Finding #{(chat.findingIdx ?? 0) + 1} · {activeFinding?.title}
|
||||
</div>
|
||||
@@ -480,7 +581,7 @@ export function CompliancePage() {
|
||||
<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>
|
||||
{t.compliance.chatThinking}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -488,7 +589,7 @@ export function CompliancePage() {
|
||||
|
||||
{/* 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 => (
|
||||
{[t.compliance.quickQ1, t.compliance.quickQ2, t.compliance.quickQ3].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}
|
||||
@@ -502,7 +603,7 @@ export function CompliancePage() {
|
||||
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…"
|
||||
placeholder={t.compliance.chatPlaceholder}
|
||||
style={{ flex: 1, padding: '9px 12px', fontSize: 13, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, color: 'var(--fg)', outline: 'none' }}
|
||||
/>
|
||||
<button
|
||||
@@ -510,10 +611,23 @@ export function CompliancePage() {
|
||||
onClick={() => chat.send(chatContext)}
|
||||
disabled={!chat.input.trim() || chat.loading}
|
||||
style={{ padding: '9px 14px' }}
|
||||
>Send</button>
|
||||
>{t.compliance.sendBtn}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{drawerFindingIdx !== null && state.analysisId && (
|
||||
<_FindingChatDrawerWrapper
|
||||
analysisId={state.analysisId}
|
||||
findingIndex={drawerFindingIdx}
|
||||
finding={{
|
||||
title: state.findings[drawerFindingIdx]?.title ?? '',
|
||||
desc: state.findings[drawerFindingIdx]?.desc ?? '',
|
||||
status: state.findings[drawerFindingIdx]?.status ?? 'ok',
|
||||
clause_ref: state.findings[drawerFindingIdx]?.clause_ref,
|
||||
}}
|
||||
onClose={() => setDrawerFindingIdx(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
237
frontend/src/pages/Compliance/FindingChatDrawer.tsx
Normal file
237
frontend/src/pages/Compliance/FindingChatDrawer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
// frontend/src/pages/Compliance/FindingChatDrawer.tsx
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, Send } from 'lucide-react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface FindingInfo {
|
||||
title: string;
|
||||
desc: string;
|
||||
status: string;
|
||||
clause_ref?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
analysisId: string;
|
||||
findingId: string;
|
||||
finding: FindingInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FindingChatDrawer({ analysisId, findingId, finding, onClose }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = useState(true);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Load history + suggestions on open
|
||||
useEffect(() => {
|
||||
setLoadingHistory(true);
|
||||
fetch(`/api/v1/compliance/analyses/${analysisId}/findings/${findingId}/chat`, {
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((data: Message[]) => {
|
||||
setMessages(Array.isArray(data) ? data.map(m => ({ id: m.id, role: m.role, content: m.content })) : []);
|
||||
setLoadingHistory(false);
|
||||
if (!data.length) {
|
||||
fetch(
|
||||
`/api/v1/compliance/analyses/${analysisId}/findings/${findingId}/suggestions`,
|
||||
{ method: 'POST', headers: authHeader() }
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (Array.isArray(d?.questions)) setSuggestions(d.questions); })
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => setLoadingHistory(false));
|
||||
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, [analysisId, findingId]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
async function send(text?: string) {
|
||||
const q = (text ?? input).trim();
|
||||
if (!q || loading) return;
|
||||
setInput('');
|
||||
setSuggestions([]); // hide chips after first message
|
||||
|
||||
const assistantId = `ast-${Date.now()}`;
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ id: `usr-${Date.now()}`, role: 'user', content: q },
|
||||
{ id: assistantId, role: 'assistant', content: '' },
|
||||
]);
|
||||
setLoading(true);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/compliance/analyses/${analysisId}/findings/${findingId}/chat`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ query: q }),
|
||||
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(prev =>
|
||||
prev.map(m => m.id === assistantId ? { ...m, content: m.content + (j.text as string) } : m)
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error && e.name !== 'AbortError') {
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === assistantId ? { ...m, content: 'Error reaching server.' } : m)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
risk: 'var(--danger, #dc143c)',
|
||||
warn: 'var(--warning, #ff8c00)',
|
||||
ok: 'var(--success, #228b22)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', right: 0, top: 0, bottom: 0, width: 420,
|
||||
background: 'var(--surface)', borderLeft: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column', zIndex: 200,
|
||||
boxShadow: '-4px 0 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: STATUS_COLOR[finding.status] ?? 'var(--muted)', fontWeight: 600, marginBottom: 2 }}>
|
||||
{finding.status.toUpperCase()}
|
||||
{finding.clause_ref && (
|
||||
<span style={{ fontWeight: 400, marginLeft: 6, color: 'var(--muted)' }}>
|
||||
{finding.clause_ref}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{finding.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2, lineHeight: 1.4 }}>
|
||||
{finding.desc.length > 100 ? finding.desc.slice(0, 100) + '…' : finding.desc}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn icon-btn" onClick={onClose} style={{ flexShrink: 0 }} title={t.compliance.drawerClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestion chips */}
|
||||
{suggestions.length > 0 && (
|
||||
<div style={{ padding: '10px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 6 }}>{t.compliance.drawerSuggestionsHeader}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{suggestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="chip"
|
||||
style={{ textAlign: 'left', whiteSpace: 'normal', height: 'auto', padding: '6px 10px' }}
|
||||
onClick={() => send(q)}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{loadingHistory && (
|
||||
<p style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center' }}>Loading history…</p>
|
||||
)}
|
||||
{!loadingHistory && messages.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center' }}>{t.compliance.drawerChatEmpty}</p>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`message msg-${msg.role}`} style={{ maxWidth: '100%' }}>
|
||||
{msg.role === 'assistant' && <div className="msg-avatar">AI</div>}
|
||||
<div className="msg-bubble" style={{ fontSize: 13, whiteSpace: 'pre-wrap' }}>
|
||||
{msg.content || (loading ? '…' : '')}
|
||||
</div>
|
||||
{msg.role === 'user' && <div className="msg-avatar user-av">You</div>}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||||
<textarea
|
||||
className="composer-input"
|
||||
placeholder={t.compliance.chatPlaceholder}
|
||||
value={input}
|
||||
rows={2}
|
||||
style={{ flex: 1, fontSize: 13 }}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
|
||||
/>
|
||||
<button
|
||||
className="btn primary"
|
||||
disabled={!input.trim() || loading}
|
||||
onClick={() => void send()}
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
title={t.compliance.sendBtn}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend/src/pages/Compliance/HistoryRail.tsx
Normal file
142
frontend/src/pages/Compliance/HistoryRail.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// frontend/src/pages/Compliance/HistoryRail.tsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Download, Trash2 } from 'lucide-react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
created_at: string;
|
||||
doc_name: string;
|
||||
standard_name: string;
|
||||
risk_score: number;
|
||||
finding_count: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
refreshTrigger: number;
|
||||
onSelect: (id: string) => void;
|
||||
selectedId: string | null;
|
||||
}
|
||||
|
||||
function riskClass(score: number): string {
|
||||
if (score >= 70) return 'risk-high';
|
||||
if (score >= 40) return 'risk-medium';
|
||||
return 'risk-low';
|
||||
}
|
||||
|
||||
export function HistoryRail({ refreshTrigger, onSelect, selectedId }: Props) {
|
||||
const [items, setItems] = useState<HistoryItem[]>([]);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const { t } = useLanguage();
|
||||
|
||||
const fetchHistory = useCallback(() => {
|
||||
fetch('/api/v1/compliance/history?limit=30', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) setItems(data);
|
||||
})
|
||||
.catch(() => {/* backend may not have postgres configured */});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchHistory(); }, [fetchHistory, refreshTrigger]);
|
||||
|
||||
function handleDownload(e: React.MouseEvent, item: HistoryItem) {
|
||||
e.stopPropagation();
|
||||
fetch(`/api/v1/compliance/history/${item.id}/download`, { headers: authHeader() })
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = `compliance-${item.doc_name.slice(0, 30)}.docx`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(e: React.MouseEvent, item: HistoryItem) {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm(t.compliance.historyDeleteConfirm)) return;
|
||||
setDeletingId(item.id);
|
||||
fetch(`/api/v1/compliance/history/${item.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
})
|
||||
.then(() => {
|
||||
setItems(prev => prev.filter(i => i.id !== item.id));
|
||||
setDeletingId(null);
|
||||
})
|
||||
.catch(() => setDeletingId(null));
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="history-pane" style={{ minWidth: 200, maxWidth: 220 }}>
|
||||
<div className="history-header">{t.compliance.historyHeader}</div>
|
||||
<p style={{ padding: '12px 16px', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||
{t.compliance.historyEmpty}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="history-pane" style={{ minWidth: 200, maxWidth: 220, overflowY: 'auto' }}>
|
||||
<div className="history-header">{t.compliance.historyHeader}</div>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`quick-item${selectedId === item.id ? ' active' : ''}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 2 }}>
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.doc_name || 'Untitled'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className={`risk-badge ${riskClass(item.risk_score)}`} style={{ fontSize: 10 }}>
|
||||
{item.risk_score}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)', flex: 1 }}>
|
||||
{item.finding_count} finding{item.finding_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
className="btn icon-btn"
|
||||
title={t.compliance.downloadReport}
|
||||
onClick={e => handleDownload(e, item)}
|
||||
style={{ padding: '2px 4px' }}
|
||||
>
|
||||
<Download size={11} />
|
||||
</button>
|
||||
<button
|
||||
className="btn icon-btn danger"
|
||||
title="Delete"
|
||||
disabled={deletingId === item.id}
|
||||
onClick={e => handleDelete(e, item)}
|
||||
style={{ padding: '2px 4px' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ const INITIAL_STATE: ComplianceState = {
|
||||
findings: [],
|
||||
done: null,
|
||||
errorText: '',
|
||||
analysisId: null,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
export function useComplianceAnalysis() {
|
||||
@@ -116,6 +118,8 @@ export function useComplianceAnalysis() {
|
||||
para_text: j.para_text ?? '',
|
||||
};
|
||||
setState(s => ({ ...s, status: 'done', done: payload, stageKey: 'concluding', stageLabel: 'Complete' }));
|
||||
} else if (j.type === 'saved') {
|
||||
setState(s => ({ ...s, analysisId: j.analysis_id ?? null }));
|
||||
} else if (j.type === 'error') {
|
||||
setState(s => ({ ...s, status: 'error', errorText: j.text ?? 'Unknown error' }));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Upload, Search, Download, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { UploadModal } from './UploadModal';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -45,6 +46,7 @@ function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div
|
||||
@@ -53,13 +55,13 @@ function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||||
>
|
||||
<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>
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>{t.docs.confirmDeleteTitle}</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" onClick={onCancel}>{t.docs.cancelBtn}</button>
|
||||
<button className="btn sm" style={{ background: 'var(--danger)', color: '#fff', borderColor: 'var(--danger)' }} onClick={onConfirm}>
|
||||
Delete
|
||||
{t.docs.deleteBtn}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,6 +70,7 @@ function ConfirmDialog({ message, onConfirm, onCancel }: {
|
||||
}
|
||||
|
||||
export function DocsPage() {
|
||||
const { t } = useLanguage();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusF, setStatusF] = useState('All');
|
||||
const [typeF, setTypeF] = useState('All types');
|
||||
@@ -172,22 +175,22 @@ export function DocsPage() {
|
||||
return (
|
||||
<div className="docs-page">
|
||||
<Topbar
|
||||
title="Document Management"
|
||||
title={t.docs.topbarTitle}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input
|
||||
placeholder="Search documents..."
|
||||
placeholder={t.docs.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||
<RefreshCw size={13} />Refresh
|
||||
<RefreshCw size={13} />{t.docs.refreshBtn}
|
||||
</button>
|
||||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||||
<Upload size={13} />Upload document
|
||||
<Upload size={13} />{t.docs.uploadBtn}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -201,24 +204,37 @@ export function DocsPage() {
|
||||
key={f}
|
||||
className={`chip${statusF === f ? ' active' : ''}`}
|
||||
onClick={() => setStatusF(f)}
|
||||
>{f}</button>
|
||||
>
|
||||
{f === 'All' ? t.docs.filterAll
|
||||
: f === 'Ready' ? t.docs.filterReady
|
||||
: f === 'Processing' ? t.docs.filterProcessing
|
||||
: f === 'Failed' ? t.docs.filterFailed
|
||||
: t.docs.filterPending}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
|
||||
{typeOpts.map(o => <option key={o}>{o}</option>)}
|
||||
{typeOpts.map(o => (
|
||||
<option key={o} value={o}>{o === 'All types' ? t.docs.filterAllTypes : o}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Batch action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="batch-bar">
|
||||
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
|
||||
<span>
|
||||
{selected.size}{' '}
|
||||
{t.docs.colName === 'Document name'
|
||||
? `document${selected.size > 1 ? 's' : ''} selected`
|
||||
: '份文档已选择'}
|
||||
</span>
|
||||
<button
|
||||
className="btn sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'rgba(239,68,68,.4)' }}
|
||||
onClick={() => askDelete([...selected])}
|
||||
>
|
||||
<Trash2 size={12} />Delete selected
|
||||
<Trash2 size={12} />{t.docs.deleteSelected}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -231,22 +247,22 @@ export function DocsPage() {
|
||||
checked={selected.size === filtered.length && filtered.length > 0}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<span>Document name</span>
|
||||
<span>Status</span>
|
||||
<span>Uploaded</span>
|
||||
<span>Chunks</span>
|
||||
<span>Size</span>
|
||||
<span>Type</span>
|
||||
<span>Actions</span>
|
||||
<span>{t.docs.colName}</span>
|
||||
<span>{t.docs.colStatus}</span>
|
||||
<span>{t.docs.colUploaded}</span>
|
||||
<span>{t.docs.colChunks}</span>
|
||||
<span>{t.docs.colSize}</span>
|
||||
<span>{t.docs.colType}</span>
|
||||
<span>{t.docs.colActions}</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '32px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
|
||||
Loading documents…
|
||||
{t.docs.loading}
|
||||
</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.'}
|
||||
{docs.length === 0 ? t.docs.emptyNoDocuments : t.docs.emptyNoMatch}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(d => {
|
||||
@@ -276,7 +292,7 @@ export function DocsPage() {
|
||||
{/* Download */}
|
||||
<button
|
||||
className="text-link"
|
||||
title="Download original file"
|
||||
title={t.docs.titleDownload}
|
||||
onClick={() => downloadDoc(d.id, d.name)}
|
||||
>
|
||||
<Download size={12} />
|
||||
@@ -286,7 +302,7 @@ export function DocsPage() {
|
||||
{d.status === 'risk' && (
|
||||
<button
|
||||
className="text-link"
|
||||
title="Retry processing"
|
||||
title={t.docs.titleRetry}
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryDoc(d.id)}
|
||||
style={{ color: 'var(--warn)' }}
|
||||
@@ -298,7 +314,7 @@ export function DocsPage() {
|
||||
{/* Delete */}
|
||||
<button
|
||||
className="text-link danger-link"
|
||||
title="Delete document"
|
||||
title={t.docs.titleDelete}
|
||||
disabled={isDeleting}
|
||||
onClick={() => askDelete([d.id])}
|
||||
>
|
||||
|
||||
@@ -1,89 +1,93 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, BarChart2, Eye, FileText, Shield, MessageSquare, Monitor } from 'lucide-react';
|
||||
|
||||
const SCREENS = [
|
||||
{ id: 'status', label: 'System Status', icon: <Monitor size={20} />, to: '/status', desc: 'Live health and workflow queue' },
|
||||
{ id: 'signals', label: 'Regulatory Signals', icon: <Eye size={20} />, to: '/signals', desc: 'AI-detected regulatory changes' },
|
||||
{ id: 'documents', label: 'Document Management', icon: <FileText size={20} />, to: '/documents', desc: 'Upload and inspect documents' },
|
||||
{ id: 'compliance', label: 'Compliance Analysis', icon: <Shield size={20} />, to: '/compliance', desc: 'Three-column compliance workspace' },
|
||||
{ id: 'chat', label: 'Regulation Q&A', icon: <MessageSquare size={20} />, to: '/chat', desc: 'Chat with cited regulation sources' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: <BarChart2 size={20} />, to: '/status', desc: 'KPIs and coverage metrics' },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ num: '01', label: 'Upload', desc: 'Ingest regulation documents' },
|
||||
{ num: '02', label: 'Process', desc: 'Embed and chunk via vector DB' },
|
||||
{ num: '03', label: 'Monitor', desc: 'Watch regulatory signal feed' },
|
||||
{ num: '04', label: 'Analyze', desc: 'Run compliance gap analysis' },
|
||||
{ num: '05', label: 'Review', desc: 'Inspect findings with AI assist' },
|
||||
{ num: '06', label: 'Chat', desc: 'Ask questions with cited answers' },
|
||||
];
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
export function OverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const SCREENS = [
|
||||
{ id: 'status', label: t.overview.screenStatus, icon: <Monitor size={20} />, to: '/status', desc: t.overview.screenStatusDesc },
|
||||
{ id: 'signals', label: t.overview.screenSignals, icon: <Eye size={20} />, to: '/signals', desc: t.overview.screenSignalsDesc },
|
||||
{ id: 'documents', label: t.overview.screenDocuments, icon: <FileText size={20} />, to: '/documents', desc: t.overview.screenDocumentsDesc },
|
||||
{ id: 'compliance', label: t.overview.screenCompliance, icon: <Shield size={20} />, to: '/compliance', desc: t.overview.screenComplianceDesc },
|
||||
{ id: 'chat', label: t.overview.screenChat, icon: <MessageSquare size={20} />, to: '/chat', desc: t.overview.screenChatDesc },
|
||||
{ id: 'analytics', label: t.overview.screenAnalytics, icon: <BarChart2 size={20} />, to: '/status', desc: t.overview.screenAnalyticsDesc },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ num: '01', label: t.overview.stepUpload, desc: t.overview.stepUploadDesc },
|
||||
{ num: '02', label: t.overview.stepProcess, desc: t.overview.stepProcessDesc },
|
||||
{ num: '03', label: t.overview.stepMonitor, desc: t.overview.stepMonitorDesc },
|
||||
{ num: '04', label: t.overview.stepAnalyze, desc: t.overview.stepAnalyzeDesc },
|
||||
{ num: '05', label: t.overview.stepReview, desc: t.overview.stepReviewDesc },
|
||||
{ num: '06', label: t.overview.stepChat, desc: t.overview.stepChatDesc },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overview-scroll-wrapper">
|
||||
<div className="overview-page">
|
||||
<section className="overview-hero">
|
||||
<p className="hero-eyebrow">T-Systems · AI Regulation Hub</p>
|
||||
<h1 className="hero-title">AI Compliance,<br />Automated end-to-end</h1>
|
||||
<p className="hero-desc">
|
||||
Monitor global AI regulations, analyze document compliance gaps,
|
||||
and get cited answers — all in one platform.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn primary" onClick={() => navigate('/status')}>
|
||||
Open dashboard <ArrowRight size={14} />
|
||||
</button>
|
||||
<button className="btn" onClick={() => navigate('/chat')}>
|
||||
Jump to regulation chat
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overview-summary card">
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">6</span>
|
||||
<span className="summary-label">Screens</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">5</span>
|
||||
<span className="summary-label">Backend-aware flows</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">AI</span>
|
||||
<span className="summary-label">Review posture</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="overview-workflow">
|
||||
<h2 className="section-title">How it works</h2>
|
||||
<div className="workflow-steps">
|
||||
{STEPS.map(s => (
|
||||
<div key={s.num} className="workflow-step">
|
||||
<div className="step-num">{s.num}</div>
|
||||
<div className="step-label">{s.label}</div>
|
||||
<div className="step-desc">{s.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overview-screens">
|
||||
<h2 className="section-title">Screens</h2>
|
||||
<div className="screen-grid">
|
||||
{SCREENS.map(s => (
|
||||
<button key={s.id} className="screen-card card" onClick={() => navigate(s.to)}>
|
||||
<div className="screen-icon">{s.icon}</div>
|
||||
<div className="screen-label">{s.label}</div>
|
||||
<div className="screen-desc">{s.desc}</div>
|
||||
<section className="overview-hero">
|
||||
<p className="hero-eyebrow">{t.overview.eyebrow}</p>
|
||||
<h1 className="hero-title">
|
||||
{t.overview.heroTitle.split('\n').map((line, i) => (
|
||||
<span key={i}>{line}{i === 0 && <br />}</span>
|
||||
))}
|
||||
</h1>
|
||||
<p className="hero-desc">{t.overview.heroDesc}</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn primary" onClick={() => navigate('/status')}>
|
||||
{t.overview.openDashboard} <ArrowRight size={14} />
|
||||
</button>
|
||||
))}
|
||||
<button className="btn" onClick={() => navigate('/chat')}>
|
||||
{t.overview.jumpToChat}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overview-summary card">
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">6</span>
|
||||
<span className="summary-label">{t.overview.statScreens}</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">5</span>
|
||||
<span className="summary-label">{t.overview.statFlows}</span>
|
||||
</div>
|
||||
<div className="summary-divider" />
|
||||
<div className="summary-item">
|
||||
<span className="summary-num">AI</span>
|
||||
<span className="summary-label">{t.overview.statReviewPosture}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="overview-workflow">
|
||||
<h2 className="section-title">{t.overview.sectionHowItWorks}</h2>
|
||||
<div className="workflow-steps">
|
||||
{STEPS.map(s => (
|
||||
<div key={s.num} className="workflow-step">
|
||||
<div className="step-num">{s.num}</div>
|
||||
<div className="step-label">{s.label}</div>
|
||||
<div className="step-desc">{s.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overview-screens">
|
||||
<h2 className="section-title">{t.overview.sectionScreens}</h2>
|
||||
<div className="screen-grid">
|
||||
{SCREENS.map(s => (
|
||||
<button key={s.id} className="screen-card card" onClick={() => navigate(s.to)}>
|
||||
<div className="screen-icon">{s.icon}</div>
|
||||
<div className="screen-label">{s.label}</div>
|
||||
<div className="screen-desc">{s.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Topbar } from '../../components/layout/Topbar';
|
||||
import { RefreshCw, Play, Square, ExternalLink } from 'lucide-react';
|
||||
import { usePageState } from '../../contexts';
|
||||
import type { PerceptionSignal } from '../../contexts';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -62,6 +63,7 @@ const MOCK_SIGNALS: PerceptionSignal[] = [
|
||||
];
|
||||
|
||||
export function PerceptionPage() {
|
||||
const { t } = useLanguage();
|
||||
// Persistent state lives in PageStateContext — survives route changes
|
||||
const { perceptionState, setPerceptionState, perceptionAbortRef, perceptionCrawlAbortRef } = usePageState();
|
||||
const { signals, searchQuery, sourceFilter, impactFilter, selectedId, aiOutput, detailTab, crawlStatus } = perceptionState;
|
||||
@@ -177,7 +179,7 @@ export function PerceptionPage() {
|
||||
|
||||
async function runCrawl() {
|
||||
setCrawling(true);
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: '正在连接数据源...' }));
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusConnecting }));
|
||||
try {
|
||||
const res = await fetch('/api/v1/perception/crawl', {
|
||||
method: 'POST',
|
||||
@@ -209,10 +211,10 @@ export function PerceptionPage() {
|
||||
if (evtName === 'progress') {
|
||||
setPerceptionState(s => ({
|
||||
...s,
|
||||
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? '抓取中...' : d.stage === 'processing' ? `处理 ${d.fetched} 条...` : `完成 +${d.new} 条`}`,
|
||||
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? t.signals.statusCrawling : d.stage === 'processing' ? t.signals.statusProcessing.replace('{count}', String(d.fetched)) : t.signals.statusComplete.replace('{count}', String(d.new))}`,
|
||||
}));
|
||||
} else if (evtName === 'done') {
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: `更新完成 — 新增 ${d.total_new} 条,更新 ${d.total_updated} 条` }));
|
||||
setPerceptionState(s => ({ ...s, crawlStatus: t.signals.statusUpdateComplete.replace('{new}', String(d.total_new)).replace('{updated}', String(d.total_updated)) }));
|
||||
fetch('/api/v1/perception/events?limit=100', { headers: authHeader() })
|
||||
.then(r => r.json())
|
||||
.then(d2 => {
|
||||
@@ -223,7 +225,7 @@ export function PerceptionPage() {
|
||||
} else if (evtName === 'error') {
|
||||
setPerceptionState(s => ({
|
||||
...s,
|
||||
crawlStatus: `错误: ${typeof d === 'string' ? d : d.message}`,
|
||||
crawlStatus: t.signals.statusError.replace('{message}', typeof d === 'string' ? d : String(d.message)),
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -232,7 +234,7 @@ export function PerceptionPage() {
|
||||
} catch (e: unknown) {
|
||||
setPerceptionState(s => ({
|
||||
...s,
|
||||
crawlStatus: `连接失败: ${e instanceof Error ? e.message : String(e)}`,
|
||||
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e)),
|
||||
}));
|
||||
}
|
||||
setCrawling(false);
|
||||
@@ -253,20 +255,20 @@ export function PerceptionPage() {
|
||||
return (
|
||||
<div className="perception-page">
|
||||
<Topbar
|
||||
title="Regulatory Signals"
|
||||
subtitle="ai-powered · live feed"
|
||||
title={t.signals.topbarTitle}
|
||||
subtitle={t.signals.topbarSub}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<input
|
||||
placeholder="Search signals..."
|
||||
placeholder={t.signals.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={e => setPerceptionState(s => ({ ...s, searchQuery: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn sm primary" onClick={runCrawl} disabled={crawling}>
|
||||
<RefreshCw size={13} className={crawling ? 'spin' : ''} />
|
||||
{crawling ? '抓取中...' : '刷新数据源'}
|
||||
{crawling ? t.signals.crawlingBtn : t.signals.refreshBtn}
|
||||
</button>
|
||||
{crawlStatus && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', marginLeft: 8 }}>
|
||||
@@ -280,19 +282,19 @@ export function PerceptionPage() {
|
||||
<div className="stats-bar">
|
||||
<div className="sbar-cell">
|
||||
<span className="sbar-val">{stats?.total ?? '—'}</span>
|
||||
<span className="sbar-lbl">Total signals</span>
|
||||
<span className="sbar-lbl">{t.signals.statTotal}</span>
|
||||
</div>
|
||||
<div className="sbar-cell danger">
|
||||
<span className="sbar-val">{stats?.high_impact ?? '—'}</span>
|
||||
<span className="sbar-lbl">High impact</span>
|
||||
<span className="sbar-lbl">{t.signals.statHigh}</span>
|
||||
</div>
|
||||
<div className="sbar-cell warn">
|
||||
<span className="sbar-val">{stats?.medium_impact ?? '—'}</span>
|
||||
<span className="sbar-lbl">Medium impact</span>
|
||||
<span className="sbar-lbl">{t.signals.statMedium}</span>
|
||||
</div>
|
||||
<div className="sbar-cell accent">
|
||||
<span className="sbar-val">{stats?.last_90_days ?? '—'}</span>
|
||||
<span className="sbar-lbl">Last 90 days</span>
|
||||
<span className="sbar-lbl">{t.signals.statLast90}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -334,14 +336,14 @@ export function PerceptionPage() {
|
||||
<span className="source-tag">{sig.source}</span>
|
||||
<span className="ev-std">{sig.standard}</span>
|
||||
<span className={`status ${sig.status}`}>
|
||||
{sig.status === 'ok' ? 'Final' : sig.status === 'warn' ? 'Draft' : sig.status === 'risk' ? 'Urgent' : 'Published'}
|
||||
{sig.status === 'ok' ? t.signals.badgeFinal : sig.status === 'warn' ? t.signals.badgeDraft : sig.status === 'risk' ? t.signals.badgeUrgent : t.signals.badgePublished}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ev-title">{sig.title}</div>
|
||||
<div className="ev-summary">{sig.summary}</div>
|
||||
<div className="ev-bottom">
|
||||
<span className="ev-date">{sig.date}</span>
|
||||
<div className="ev-tags">{sig.tags.map(t => <span key={t} className="ev-tag">{t}</span>)}</div>
|
||||
<div className="ev-tags">{sig.tags.map(tag => <span key={tag} className="ev-tag">{tag}</span>)}</div>
|
||||
<span className={`impact-dot impact-${sig.impact.toLowerCase()}`}>{sig.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,7 +354,7 @@ export function PerceptionPage() {
|
||||
{!selected ? (
|
||||
<div className="analysis-empty">
|
||||
<div className="empty-ring" />
|
||||
<p>Select a signal to run impact analysis</p>
|
||||
<p>{t.signals.emptySelectSignal}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -361,7 +363,7 @@ export function PerceptionPage() {
|
||||
<span className="source-tag">{selected.source}</span>
|
||||
<span className="ev-std">{selected.standard}</span>
|
||||
<span className={`status ${selected.status}`}>
|
||||
{selected.status === 'risk' ? 'Urgent' : selected.status === 'warn' ? 'Draft' : 'Published'}
|
||||
{selected.status === 'risk' ? t.signals.badgeUrgent : selected.status === 'warn' ? t.signals.badgeDraft : t.signals.badgePublished}
|
||||
</span>
|
||||
{selectedFull?.change_summary && (
|
||||
<span className="status warn" style={{ marginLeft: 'auto' }}>CHANGED</span>
|
||||
@@ -371,8 +373,8 @@ export function PerceptionPage() {
|
||||
<p className="detail-summary">{selected.summary}</p>
|
||||
<div className="detail-actions">
|
||||
{!streaming
|
||||
? <button className="btn sm primary" onClick={runAnalysis}><Play size={12} />Run impact analysis</button>
|
||||
: <button className="btn sm" onClick={stopAnalysis}><Square size={12} />Stop</button>
|
||||
? <button className="btn sm primary" onClick={runAnalysis}><Play size={12} />{t.signals.runAnalysis}</button>
|
||||
: <button className="btn sm" onClick={stopAnalysis}><Square size={12} />{t.signals.stopBtn}</button>
|
||||
}
|
||||
{selected && (
|
||||
<a
|
||||
@@ -381,7 +383,7 @@ export function PerceptionPage() {
|
||||
rel="noopener noreferrer"
|
||||
className="btn sm"
|
||||
>
|
||||
<ExternalLink size={12} />Source
|
||||
<ExternalLink size={12} />{t.signals.sourceLink}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -398,14 +400,14 @@ export function PerceptionPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tab === 'overview' ? '概览' : tab === 'obligations' ? '义务条款' : tab === 'assessment' ? '影响评估' : '变更对比'}
|
||||
{tab === 'overview' ? t.signals.tabOverview : tab === 'obligations' ? t.signals.tabObligations : tab === 'assessment' ? t.signals.tabImpact : t.signals.tabChanges}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detailTab === 'overview' && (
|
||||
<div className="card">
|
||||
<div className="card-header">Scope & Summary</div>
|
||||
<div className="card-header">{t.signals.cardScopeHeader}</div>
|
||||
<p className="detail-summary" style={{ marginTop: 8 }}>
|
||||
{(selectedFull?.scope as string) || selected.summary}
|
||||
</p>
|
||||
@@ -419,21 +421,21 @@ export function PerceptionPage() {
|
||||
|
||||
{detailTab === 'obligations' && (
|
||||
<div className="card">
|
||||
<div className="card-header">义务条款</div>
|
||||
<div className="card-header">{t.signals.cardObligationsHeader}</div>
|
||||
{(() => {
|
||||
const obs = (selectedFull?.obligations as Array<Record<string, string>>) || [];
|
||||
const deadlines = (selectedFull?.deadlines as Array<Record<string, string>>) || [];
|
||||
return obs.length === 0 && deadlines.length === 0 ? (
|
||||
<p className="detail-summary" style={{ marginTop: 8 }}>暂无结构化数据。点击右上角"Run impact analysis"触发提取。</p>
|
||||
<p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.obligationsEmpty}</p>
|
||||
) : (
|
||||
<>
|
||||
{obs.length > 0 && (
|
||||
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse', marginTop: 8 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px' }}>义务描述</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}>主体</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}>类型</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px' }}>{t.signals.colObligationDesc}</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}>{t.signals.colSubject}</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}>{t.signals.colType}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -453,10 +455,10 @@ export function PerceptionPage() {
|
||||
)}
|
||||
{deadlines.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="card-header">截止日期</div>
|
||||
<div className="card-header">{t.signals.colDeadline}</div>
|
||||
{deadlines.map((d, i) => (
|
||||
<div key={i} style={{ fontSize: 13, padding: '4px 0', display: 'flex', gap: 12 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--danger)' }}>{d.date || '待定'}</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--danger)' }}>{d.date || t.signals.deadlinePending}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{d.description}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -470,12 +472,12 @@ export function PerceptionPage() {
|
||||
|
||||
{detailTab === 'assessment' && (
|
||||
<div className="card docs-card">
|
||||
<div className="card-header">Affected documents</div>
|
||||
<div className="card-header">{t.signals.cardAffectedDocs}</div>
|
||||
{(() => {
|
||||
const docs = (selectedFull?.affected_docs as Array<Record<string, unknown>>);
|
||||
const displayDocs = docs && docs.length > 0 ? docs : [];
|
||||
return displayDocs.length === 0
|
||||
? <p className="detail-summary" style={{ marginTop: 8 }}>No affected documents found.</p>
|
||||
? <p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.noAffectedDocs}</p>
|
||||
: displayDocs.map((d, i) => (
|
||||
<div key={i} className="doc-row">
|
||||
<span className="doc-score">{Math.round(Number(d.score ?? 0) * 100)}%</span>
|
||||
@@ -497,7 +499,7 @@ export function PerceptionPage() {
|
||||
|
||||
{detailTab === 'diff' && selectedFull?.change_summary && (
|
||||
<div className="card">
|
||||
<div className="card-header">变更对比</div>
|
||||
<div className="card-header">{t.signals.diffCardHeader}</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 8 }}>
|
||||
{selectedFull.change_summary as string}
|
||||
</p>
|
||||
@@ -513,11 +515,11 @@ export function PerceptionPage() {
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12 }}>
|
||||
<div style={{ background: 'var(--danger-bg)', padding: 8, borderRadius: 4 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>旧版</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffOld}</div>
|
||||
{String(s.old_text || '')}
|
||||
</div>
|
||||
<div style={{ background: 'var(--success-bg)', padding: 8, borderRadius: 4 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>新版</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffNew}</div>
|
||||
{String(s.new_text || '')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -530,7 +532,7 @@ export function PerceptionPage() {
|
||||
|
||||
{(aiOutput || streaming) && (
|
||||
<div className="card ai-card">
|
||||
<div className="card-header">AI Impact Analysis</div>
|
||||
<div className="card-header">{t.signals.cardAIImpact}</div>
|
||||
<div className="ai-output">
|
||||
{aiOutput}
|
||||
{streaming && <span className="blink-cursor">▋</span>}
|
||||
@@ -544,7 +546,7 @@ export function PerceptionPage() {
|
||||
|
||||
<footer className="page-footer">
|
||||
<div className="live-dot" />
|
||||
<span>Live feed · Regulation Hub</span>
|
||||
<span>{t.signals.footerText}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Send, Download } from 'lucide-react';
|
||||
import { usePageState } from '../../contexts';
|
||||
import type { RagCitation } from '../../contexts';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -59,6 +60,7 @@ const MOCK_QUICK = [
|
||||
export function RagChatPage() {
|
||||
// All persistent state lives in PageStateContext — survives route changes
|
||||
const { ragState, setRagState, ragStreamingRef, ragAbortRef } = usePageState();
|
||||
const { t } = useLanguage();
|
||||
const { messages, citations, sessionId, inputDraft } = ragState;
|
||||
|
||||
// Local-only UI state: highlighted citation and streaming indicator
|
||||
@@ -206,7 +208,7 @@ export function RagChatPage() {
|
||||
...s,
|
||||
messages: s.messages.map(msg =>
|
||||
msg.id === assistantId
|
||||
? { ...msg, text: 'Could not reach the RAG API. Please check the backend.' }
|
||||
? { ...msg, text: t.ragchat.apiError }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
@@ -222,7 +224,7 @@ export function RagChatPage() {
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<Topbar
|
||||
title="Regulation Q&A"
|
||||
title={t.ragchat.topbarTitle}
|
||||
actions={
|
||||
<button
|
||||
className="btn sm"
|
||||
@@ -234,7 +236,7 @@ export function RagChatPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
<Download size={13} />Export chat
|
||||
<Download size={13} />{t.ragchat.exportBtn}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
@@ -242,7 +244,7 @@ export function RagChatPage() {
|
||||
<div className="chat-body">
|
||||
{/* ── History pane ── */}
|
||||
<div className="history-pane">
|
||||
<div className="history-header">Quick prompts</div>
|
||||
<div className="history-header">{t.ragchat.quickPromptsHeader}</div>
|
||||
{quickPrompts.map(q => (
|
||||
<button key={q} className="quick-item" onClick={() => send(q)}>
|
||||
{q}
|
||||
@@ -282,7 +284,7 @@ export function RagChatPage() {
|
||||
<div className="composer-row">
|
||||
<textarea
|
||||
className="composer-input"
|
||||
placeholder="Ask about your regulations…"
|
||||
placeholder={t.ragchat.inputPlaceholder}
|
||||
value={inputDraft}
|
||||
onChange={e => setRagState(s => ({ ...s, inputDraft: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
@@ -302,11 +304,11 @@ export function RagChatPage() {
|
||||
{/* ── Citation rail ── */}
|
||||
<div className="citation-rail" ref={citRailRef}>
|
||||
<div className="citation-header">
|
||||
Sources {citations.length > 0 && `(${citations.length})`}
|
||||
{t.ragchat.citationsHeader}{citations.length > 0 && ` (${citations.length})`}
|
||||
</div>
|
||||
{citations.length === 0 && (
|
||||
<p style={{ padding: '12px 16px', fontSize: 12, color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||
Citations will appear here after a response is generated.
|
||||
{t.ragchat.citationsEmpty}
|
||||
</p>
|
||||
)}
|
||||
{citations.map(c => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { UploadModal } from '../Docs/UploadModal';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
function authHeader(): Record<string, string> {
|
||||
@@ -48,13 +49,14 @@ function StatusIcon({ status }: { status: 'ok' | 'error' | 'warn' | 'info' }) {
|
||||
}
|
||||
|
||||
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className="service-row">
|
||||
<StatusIcon status={status} />
|
||||
<span className="service-name" style={{ marginLeft: 8 }}>{name}</span>
|
||||
{detail && <span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>{detail}</span>}
|
||||
<span className={`status ${status}`} style={{ marginLeft: 'auto' }}>
|
||||
{status === 'ok' ? 'Online' : status === 'error' ? 'Error' : status === 'warn' ? 'Degraded' : 'Unknown'}
|
||||
{status === 'ok' ? t.status.badgeOnline : status === 'error' ? t.status.badgeError : status === 'warn' ? t.status.badgeDegraded : t.status.badgeUnknown}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -73,6 +75,7 @@ function ConfigRow({ label, value }: { label: string; value: string | number | n
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
export function StatusPage() {
|
||||
const { t } = useLanguage();
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
@@ -136,21 +139,21 @@ export function StatusPage() {
|
||||
return (
|
||||
<div className="status-page">
|
||||
<Topbar
|
||||
title="System Status"
|
||||
title={t.status.topbarTitle}
|
||||
actions={
|
||||
<>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input placeholder="Search..." />
|
||||
<input placeholder={t.status.searchPlaceholder} />
|
||||
</div>
|
||||
<button className="btn sm" onClick={handleExport}>
|
||||
<Download size={13} />Export
|
||||
<Download size={13} />{t.status.exportBtn}
|
||||
</button>
|
||||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||
<RefreshCw size={13} />Refresh
|
||||
<RefreshCw size={13} />{t.status.refreshBtn}
|
||||
</button>
|
||||
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
||||
<Upload size={13} />New upload
|
||||
<Upload size={13} />{t.status.newUploadBtn}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
@@ -162,26 +165,26 @@ export function StatusPage() {
|
||||
<div className="stats-grid">
|
||||
<div className="stat-cell">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
|
||||
<div className="stat-label">Documents total</div>
|
||||
<div className="stat-label">{t.status.statTotal}</div>
|
||||
</div>
|
||||
<div className="stat-cell">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_indexed ?? '—'}</div>}
|
||||
<div className="stat-label">Indexed</div>
|
||||
<div className="stat-label">{t.status.statIndexed}</div>
|
||||
</div>
|
||||
<div className="stat-cell danger">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_failed ?? '—'}</div>}
|
||||
<div className="stat-label">Failed</div>
|
||||
<div className="stat-label">{t.status.statFailed}</div>
|
||||
</div>
|
||||
<div className="stat-cell">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.chunks_total?.toLocaleString() ?? '—'}</div>}
|
||||
<div className="stat-label">Vector chunks</div>
|
||||
<div className="stat-label">{t.status.statChunks}</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>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{t.status.statCoverage}</span>
|
||||
<div style={{ flex: 1, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 3,
|
||||
@@ -203,7 +206,7 @@ export function StatusPage() {
|
||||
{/* System health */}
|
||||
<div className="card">
|
||||
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>System health</span>
|
||||
<span>{t.status.cardHealth}</span>
|
||||
{lastRefresh && (
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', fontWeight: 400 }}>
|
||||
Updated {lastRefresh.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
@@ -231,12 +234,12 @@ export function StatusPage() {
|
||||
<ServiceRow
|
||||
name="BM25 keyword retriever"
|
||||
status={health.bm25.available ? 'ok' : 'warn'}
|
||||
detail={health.bm25.available ? undefined : 'Not loaded'}
|
||||
detail={health.bm25.available ? undefined : t.status.serviceNotLoaded}
|
||||
/>
|
||||
<ServiceRow
|
||||
name={`Reranker${health.reranker.model ? ` (${health.reranker.model})` : ''}`}
|
||||
status={health.reranker.enabled ? 'ok' : 'info'}
|
||||
detail={health.reranker.enabled ? 'Enabled' : 'Disabled'}
|
||||
detail={health.reranker.enabled ? t.status.serviceEnabled : t.status.serviceDisabled}
|
||||
/>
|
||||
<ServiceRow
|
||||
name="Active sessions"
|
||||
@@ -246,7 +249,7 @@ export function StatusPage() {
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>
|
||||
Could not reach health endpoint
|
||||
{t.status.healthEndpointError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -257,7 +260,7 @@ export function StatusPage() {
|
||||
onClick={() => setConfigOpen(v => !v)}
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>System configuration</div>
|
||||
<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>{t.status.cardConfig}</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', transform: configOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>▾</span>
|
||||
</button>
|
||||
|
||||
@@ -265,17 +268,17 @@ export function StatusPage() {
|
||||
<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} />
|
||||
<ConfigRow label={t.status.labelLLMProvider} value={config.llm_provider} />
|
||||
<ConfigRow label={t.status.labelLLMModel} value={config.llm_model} />
|
||||
<ConfigRow label={t.status.labelEmbeddingModel} value={config.embedding_model} />
|
||||
<ConfigRow label={t.status.labelEmbeddingDim} value={config.embedding_dim} />
|
||||
<ConfigRow label={t.status.labelMilvusCollection} value={config.milvus_collection} />
|
||||
<ConfigRow label={t.status.labelParserBackend} value={config.parser_backend} />
|
||||
<ConfigRow label={t.status.labelChunkBackend} value={config.chunk_backend} />
|
||||
<ConfigRow label={t.status.labelParserFailureMode} value={config.parser_failure_mode} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)', fontSize: 13 }}>Could not load config</div>
|
||||
<div style={{ color: 'var(--muted)', fontSize: 13 }}>{t.status.configLoadError}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -286,7 +289,7 @@ export function StatusPage() {
|
||||
|
||||
{/* Document breakdown */}
|
||||
<div className="card">
|
||||
<div className="card-header">Document breakdown</div>
|
||||
<div className="card-header">{t.status.cardBreakdown}</div>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[1, 2, 3].map(i => <div key={i} className="loading-shimmer" style={{ height: 24, borderRadius: 4 }} />)}
|
||||
@@ -294,9 +297,9 @@ export function StatusPage() {
|
||||
) : 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)' },
|
||||
{ label: t.status.breakdownIndexed, value: stats.documents_indexed, total: stats.documents_total, color: 'var(--ok)' },
|
||||
{ label: t.status.breakdownProcessing, value: stats.documents_total - stats.documents_indexed - stats.documents_failed, total: stats.documents_total, color: 'var(--warn)' },
|
||||
{ label: t.status.breakdownFailed, 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 (
|
||||
@@ -312,7 +315,7 @@ export function StatusPage() {
|
||||
);
|
||||
})}
|
||||
<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={{ color: 'var(--muted)' }}>{t.status.totalChunks}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{stats.chunks_total.toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -322,26 +325,26 @@ export function StatusPage() {
|
||||
{/* Sessions & reranker quick facts */}
|
||||
{health && (
|
||||
<div className="card">
|
||||
<div className="card-header">Runtime info</div>
|
||||
<div className="card-header">{t.status.cardRuntime}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Active chat sessions</span>
|
||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelActiveSessions}</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={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</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={{ color: 'var(--muted)' }}>{t.status.labelReranker}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.reranker.enabled ? 'var(--ok)' : 'var(--muted)' }}>
|
||||
{health.reranker.enabled ? (health.reranker.model ?? 'Enabled') : 'Disabled'}
|
||||
{health.reranker.enabled ? (health.reranker.model ?? t.status.serviceEnabled) : t.status.serviceDisabled}
|
||||
</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={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
|
||||
{health.bm25.available ? 'Active' : 'Unavailable'}
|
||||
{health.bm25.available ? t.status.statusActive : t.status.statusUnavailable}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,7 +356,7 @@ export function StatusPage() {
|
||||
|
||||
<footer className="page-footer">
|
||||
<div className="live-dot" />
|
||||
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? 'All systems operational' : 'Degraded') : 'Checking…'}</span>
|
||||
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? t.status.footerAllOk : t.status.footerDegraded) : t.status.footerChecking}</span>
|
||||
</footer>
|
||||
|
||||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||
|
||||
@@ -17,7 +17,7 @@ dependencies = [
|
||||
"langchain>=0.1.0",
|
||||
"langchain-milvus>=0.1.0",
|
||||
"pymupdf>=1.24.0",
|
||||
"python-docx>=0.8.11",
|
||||
"python-docx>=1.1.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
|
||||
@@ -17,7 +17,7 @@ langchain-milvus>=0.1.0
|
||||
pymupdf>=1.24.0
|
||||
|
||||
# Word文档解析
|
||||
python-docx>=0.8.11
|
||||
python-docx>=1.1.0
|
||||
|
||||
# 阿里云文档解析
|
||||
alibabacloud-docmind-api20220711>=1.0.6
|
||||
|
||||
Reference in New Issue
Block a user