update for 1. 优化 2.中英切换

This commit is contained in:
2026-06-10 11:10:36 +08:00
parent e7963b267e
commit 9212747e1b
42 changed files with 7866 additions and 278 deletions

4
.env
View File

@@ -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

View File

@@ -31,5 +31,5 @@ POSTGRES_PASSWORD=postgresql123456
POSTGRES_DB=compliance_db
# ===== 文档元数据后端 =====
# 改为 postgres 以启用 PG 持久化structure_nodes + semantic_blocks 入库
# 改为 postgres 以启用合规分析历史记录Direction B和 Finding ChatDirection C
DOCUMENT_REPOSITORY_BACKEND=json

View File

@@ -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).

View File

@@ -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>并行子句处理(速度 35×、跨编码器重排序、置信度过滤、修复 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>📋 分析历史 &amp; 专业报告</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>📑 自定义规则 &amp; 模板</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>

View 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 并行子句处理(速度 35×、跨编码器重排序、置信度过滤、修复 highlight_terms 失效 Bug、减少 LLM 静默失败。\n \n 收益更快、更准确的分析消除当前 Bug\n 难度需要改造 pipeline.py","choice":"A","id":null,"timestamp":1780897986554}

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1780894411095}

View File

@@ -0,0 +1 @@
1055

View File

@@ -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"},
)

View File

@@ -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 14 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

View 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).
"""

View 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()

View 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=[],
)

View File

@@ -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]

View File

@@ -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(

View File

View 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

View 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))

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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

View 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

View File

@@ -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>
);
}

View File

@@ -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>
)}

View 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);
}

View File

@@ -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 ──────────────────────────────────────────────────────────

View File

@@ -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
View 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
View 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请检查后端服务。',
},
};

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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' }));
}

View File

@@ -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])}
>

View File

@@ -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>
);
}

View File

@@ -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 &amp; 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>
);

View File

@@ -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 => (

View File

@@ -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)} />}

View File

@@ -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",

View File

@@ -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