18 Commits

Author SHA1 Message Date
wangwei
9212747e1b update for 1. 优化 2.中英切换 2026-06-10 11:10:36 +08:00
wangwei
e7963b267e fix somethings 2026-06-08 11:16:28 +08:00
wangwei
9fea9c6a53 1. Add 登陆功能
2. 调整字体大小
3. 新增部分功能
2026-06-05 18:00:31 +08:00
wangwei
06e0967128 add 2026-06-05 09:00:36 +08:00
wangwei
746513cc54 fix 2026-06-04 15:43:44 +08:00
wangwei
ac490d851a chore: delete dead code, fix tailwind dark mode, fix title and start.sh port
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:29:28 +08:00
wangwei
bc8ccc1143 feat: complete frontend redesign — all 6 pages implemented per prototype specs
Fix UI components (Badge, Button, Card, Input, ProgressBar, ScoreBar,
ChatPanel) to use CSS variables instead of old theme object pattern.
Clean up barrel exports (common/index.ts, layout/index.ts) and remove
stale router/tabs import from shell-config.ts. Build: tsc zero errors,
Vite production build succeeds (1760 modules, 270 kB JS, 22 kB CSS).
2026-06-03 18:42:42 +08:00
wangwei
6414d67b3b feat: implement Regulation Q&A chat page with history pane, streaming, citation rail 2026-06-03 17:58:38 +08:00
wangwei
9f15e40bbb feat: implement Compliance Analysis three-column workspace with findings and stages 2026-06-03 17:50:55 +08:00
wangwei
65ba1b214d feat: implement Document Management page with filterable table and batch actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:45:14 +08:00
wangwei
7cd7a10bea feat: implement Regulatory Signals page with two-pane split and SSE streaming
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:35:37 +08:00
wangwei
235de65975 feat: implement System Status page with stats grid, panel grid, KPI strip 2026-06-03 17:26:22 +08:00
wangwei
07ccf055ab feat: implement Overview launcher page with hero, workflow steps, screen grid 2026-06-03 17:22:25 +08:00
wangwei
3dc12b0bfe feat: add AppShell + Topbar + 6-route AppRouter with stub pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:16:00 +08:00
wangwei
08461215b0 feat: add Sidebar component with nav groups, badges, and theme toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 17:05:05 +08:00
wangwei
dcda7e0423 @
chore: delete old layout/common/tabs components before redesign
@
2026-06-03 16:58:35 +08:00
wangwei
f3dbdc7e3f feat: update ThemeContext to use data-theme attribute, simplify to light/dark
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:39:33 +08:00
wangwei
932e2c7792 feat: rewrite globals.css with prototype design tokens, layout, and all page CSS 2026-06-03 16:14:47 +08:00
187 changed files with 48613 additions and 6646 deletions

32
.env
View File

@@ -48,8 +48,16 @@ CHUNK_OVERLAP=50
MAX_FILE_SIZE_MB=100 MAX_FILE_SIZE_MB=100
PARSER_BACKEND=aliyun PARSER_BACKEND=aliyun
CHUNK_BACKEND=aliyun CHUNK_BACKEND=aliyun
# 文档元数据存储后端:json默认或 postgres # 文档元数据存储后端:启用 postgres 以激活合规分析历史记录Direction B及 Finding Chat 持久化Direction C
DOCUMENT_REPOSITORY_BACKEND=json 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
# ===== 法规感知爬取配置 =====
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
# ===== API配置 ===== # ===== API配置 =====
API_HOST=0.0.0.0 API_HOST=0.0.0.0
@@ -92,3 +100,23 @@ ALIYUN_LLM_ENHANCEMENT=true
ALIYUN_ENHANCEMENT_MODE=VLM ALIYUN_ENHANCEMENT_MODE=VLM
DOCUMENT_PARSE_ARTIFACT_PREFIX=artifacts DOCUMENT_PARSE_ARTIFACT_PREFIX=artifacts
PARSER_FAILURE_MODE=fail PARSER_FAILURE_MODE=fail
# ===== Reranker 配置 =====
RERANKER_ENABLED=true
RERANKER_BASE_URL=http://6.86.80.4:30080/v1
RERANKER_MODEL=BAAI/bge-reranker-v2-m3
RERANKER_API_KEY=
RERANKER_TOP_K=5
# ===== 会话持久化 =====
SESSION_BACKEND=redis
# ===== 认证配置 =====
# 生产环境请修改为强随机密钥: python -c "import secrets; print(secrets.token_hex(32))"
AUTH_SECRET_KEY=ai-compliance-hub-jwt-secret-2026-tsystems
AUTH_ALGORITHM=HS256
AUTH_TOKEN_EXPIRE_MINUTES=480
AUTH_ENABLED=true
# ===== CORS =====
CORS_ALLOW_ORIGINS=http://localhost:5173

View File

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

View File

@@ -50,7 +50,19 @@ DOCUMENT_METADATA_PATH=backend/data/documents.json
PARSER_BACKEND=aliyun PARSER_BACKEND=aliyun
CHUNK_BACKEND=aliyun CHUNK_BACKEND=aliyun
# 文档元数据存储后端json默认无需数据库或 postgres启用 PG 持久化) # 文档元数据存储后端json默认无需数据库或 postgres启用 PG 持久化)
# ⚠ 以下功能需要 postgres设为 json 时功能静默降级或报 500
# - Direction B: 合规分析历史记录 (/compliance/history/*)
# - Direction B: DOCX 报告下载
# - Direction C: Finding Chat 消息持久化
DOCUMENT_REPOSITORY_BACKEND=json 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).
USE_CELERY_WORKER=false
# ===== 法规感知爬取配置 =====
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
# ===== 阿里云文档解析 ===== # ===== 阿里云文档解析 =====
ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id
@@ -96,11 +108,15 @@ RAG_TOP_K=10
RAG_RETRIEVAL_TOP_K=20 RAG_RETRIEVAL_TOP_K=20
RAG_MAX_CONTEXT_TOKENS=4000 RAG_MAX_CONTEXT_TOKENS=4000
RAG_SUMMARY_MAX_TOKENS=1024 RAG_SUMMARY_MAX_TOKENS=1024
RAG_SKILLS_MAX_TOKENS=2048
# ===== Reranker配置(Cross-Encoder精排,默认关闭)===== # ── Reranker (Cross-Encoder) ──────────────────────────────────────────────────
# 设置 RERANKER_ENABLED=true 并配置 RERANKER_BASE_URL 以启用精排 # Set RERANKER_ENABLED=true and point to a TEI or Cohere-compatible rerank API.
RERANKER_ENABLED=false # Recommended model: BAAI/bge-reranker-v2.5-gemma2-lightweight (lighter) or
RERANKER_BASE_URL= # BAAI/bge-reranker-v2-m3 (heavier, higher quality).
# The endpoint must expose POST /rerank (TEI style) or POST /v1/rerank (Cohere style).
RERANKER_ENABLED=true
RERANKER_BASE_URL=http://6.86.80.4:30080/v1
RERANKER_MODEL=BAAI/bge-reranker-v2-m3 RERANKER_MODEL=BAAI/bge-reranker-v2-m3
RERANKER_API_KEY= RERANKER_API_KEY=
RERANKER_TOP_K=5 RERANKER_TOP_K=5
@@ -108,3 +124,20 @@ RERANKER_TOP_K=5
# ===== 会话配置 ===== # ===== 会话配置 =====
SESSION_MAX_SESSIONS=100 SESSION_MAX_SESSIONS=100
SESSION_TIMEOUT_MINUTES=30 SESSION_TIMEOUT_MINUTES=30
# SESSION_BACKEND=redis 启用 Redis 持久化会话(需要 Redis 可用,推荐生产环境)
# SESSION_BACKEND=memory 使用内存会话(重启丢失,适合本地开发)
SESSION_BACKEND=memory
# ===== 认证配置 (Auth) =====
# 生产环境必须替换为强随机密钥:
# python -c "import secrets; print(secrets.token_hex(32))"
AUTH_SECRET_KEY=change-me-in-production-must-be-32-or-more-characters-long
AUTH_ALGORITHM=HS256
# Token 有效期(分钟),默认 8 小时
AUTH_TOKEN_EXPIRE_MINUTES=480
# 设为 false 可跳过认证(仅限本地开发调试,生产必须 true
AUTH_ENABLED=true
# ===== CORS =====
# 逗号分隔的允许跨域来源列表,生产环境绝不能使用 *
CORS_ALLOW_ORIGINS=http://localhost:5173

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

@@ -0,0 +1,132 @@
<h2>A 方案 · 浅色背景版</h2>
<p class="subtitle">Magenta 主色 + 全浅色背景 — 对比两种浅色处理方式</p>
<style>
.slide-preview {
border-radius: 6px;
overflow: hidden;
aspect-ratio: 16/9;
position: relative;
font-family: 'Calibri', 'Segoe UI', sans-serif;
border: 1px solid #ddd;
margin-bottom: 8px;
}
.tag-row { display:flex; gap:6px; margin-top:8px; flex-wrap:wrap; }
.tag { padding:3px 8px; border-radius:4px; font-size:11px; font-weight:600; letter-spacing:0.5px; }
.mini-card { border-radius:4px; padding:6px 10px; margin-top:4px; font-size:11px; }
</style>
<div class="split">
<!-- Option A1: White/Light Gray (Sherlock style) -->
<div>
<div class="mockup">
<div class="mockup-header">A1 · 纯白 + 灰色背景Sherlock 风格)</div>
<div class="mockup-body" style="background:#f5f5f5; padding:12px;">
<!-- Title slide -->
<div class="slide-preview" style="background:#fff;">
<div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,#e20074,#be0060);"></div>
<div style="padding:18px 22px 14px;">
<div style="font-size:7px;color:#e20074;letter-spacing:2px;font-weight:700;text-transform:uppercase;margin-bottom:8px;">INTERNAL · AI 合规项目组 · 2026.05</div>
<div style="font-size:17px;font-weight:900;color:#1a1a2e;line-height:1.2;margin-bottom:6px;">AI + 合规智能中枢<br><span style="color:#e20074;">阶段性进展汇报</span></div>
<div style="font-size:8px;color:#666;margin-bottom:12px;">基于 Agent 协同的多模块法规合规智能平台</div>
<div style="display:flex;gap:8px;">
<div style="background:#fff0f7;border:1px solid rgba(226,0,116,0.3);border-radius:4px;padding:3px 10px;font-size:8px;color:#e20074;font-weight:700;">5 功能模块</div>
<div style="background:#f0fdf8;border:1px solid rgba(0,137,106,0.3);border-radius:4px;padding:3px 10px;font-size:8px;color:#00896a;font-weight:700;">17+ API</div>
<div style="background:#fff8f0;border:1px solid rgba(204,98,0,0.3);border-radius:4px;padding:3px 10px;font-size:8px;color:#cc6200;font-weight:700;">6+ 法规源</div>
</div>
</div>
<div style="position:absolute;bottom:0;left:0;right:0;height:2px;background:#f0f0f0;"></div>
<div style="position:absolute;bottom:6px;right:14px;font-size:7px;color:#bbb;">T-Systems · Internal · Confidential</div>
</div>
<!-- Content slide -->
<div class="slide-preview" style="background:#fafafa;">
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:#e20074;"></div>
<div style="padding:14px 18px;">
<div style="font-size:7px;color:#e20074;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:3px;">阶段成果</div>
<div style="font-size:11px;font-weight:800;color:#1a1a2e;margin-bottom:8px;">已完成的工作</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;">
<div style="background:#fff;border:1px solid #e8e8f0;border-left:3px solid #e20074;border-radius:3px;padding:5px 8px;">
<div style="font-size:8px;font-weight:700;color:#1a1a2e;margin-bottom:1px;">📡 法规感知</div>
<div style="font-size:7px;color:#666;">六大法规源实时事件流</div>
</div>
<div style="background:#fff;border:1px solid #e8e8f0;border-left:3px solid #00896a;border-radius:3px;padding:5px 8px;">
<div style="font-size:8px;font-weight:700;color:#1a1a2e;margin-bottom:1px;">💬 Agent 对话</div>
<div style="font-size:7px;color:#666;">核心链路全通 ✓</div>
</div>
<div style="background:#fff;border:1px solid #e8e8f0;border-left:3px solid #2a68c8;border-radius:3px;padding:5px 8px;">
<div style="font-size:8px;font-weight:700;color:#1a1a2e;margin-bottom:1px;">📚 知识库</div>
<div style="font-size:7px;color:#666;">5步 Pipeline 可视化</div>
</div>
<div style="background:#fff;border:1px solid #e8e8f0;border-left:3px solid #cc6200;border-radius:3px;padding:5px 8px;">
<div style="font-size:8px;font-weight:700;color:#1a1a2e;margin-bottom:1px;">🔍 合规分析</div>
<div style="font-size:7px;color:#666;">风险评分仪表盘</div>
</div>
</div>
</div>
</div>
</div>
</div>
<p style="font-size:13px;color:var(--text-secondary);margin-top:8px;">✦ 纯白底 + 极浅灰区分层次。顶部洋红横线代替渐变块。内容卡片左侧彩色边线区分模块。<br>与 Sherlock PPTX 的白底风格一致。</p>
</div>
<!-- Option A2: Light Beige/Blue (Bosch light style) -->
<div>
<div class="mockup">
<div class="mockup-header">A2 · 浅蓝灰 + 白卡片Bosch 浅色风格)</div>
<div class="mockup-body" style="background:#e8edf5; padding:12px;">
<!-- Title slide -->
<div class="slide-preview" style="background:#f0f4fb;">
<div style="position:absolute;left:0;top:0;bottom:0;width:5px;background:linear-gradient(to bottom,#e20074,#be0060);"></div>
<div style="padding:18px 22px 14px 28px;">
<div style="font-size:7px;color:#e20074;letter-spacing:2px;font-weight:700;text-transform:uppercase;margin-bottom:8px;">■ INTERNAL · AI 合规项目组 · 2026.05</div>
<div style="font-size:17px;font-weight:900;color:#1a1a2e;line-height:1.2;margin-bottom:5px;">AI + 合规智能中枢</div>
<div style="font-size:13px;color:#e20074;font-weight:700;margin-bottom:10px;">阶段性进展汇报</div>
<div style="display:flex;gap:8px;">
<div style="background:rgba(226,0,116,0.1);border:1px solid rgba(226,0,116,0.25);border-radius:20px;padding:2px 10px;font-size:8px;color:#e20074;font-weight:700;">5 功能模块</div>
<div style="background:rgba(0,137,106,0.1);border:1px solid rgba(0,137,106,0.25);border-radius:20px;padding:2px 10px;font-size:8px;color:#00896a;font-weight:700;">17+ API</div>
<div style="background:rgba(42,104,200,0.1);border:1px solid rgba(42,104,200,0.25);border-radius:20px;padding:2px 10px;font-size:8px;color:#2a68c8;font-weight:700;">6+ 法规源</div>
</div>
</div>
<div style="position:absolute;bottom:6px;right:14px;font-size:7px;color:#aab;font-style:italic;">T-Systems · Internal · Confidential</div>
</div>
<!-- Content slide -->
<div class="slide-preview" style="background:#edf1f8;">
<div style="position:absolute;left:0;top:0;bottom:0;width:4px;background:#e20074;"></div>
<div style="padding:12px 16px 12px 20px;">
<div style="font-size:7px;color:#e20074;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:2px;">阶段成果</div>
<div style="font-size:11px;font-weight:800;color:#1a1a2e;margin-bottom:8px;">已完成的工作</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;">
<div style="background:#fff;border-radius:4px;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,0.06);">
<div style="font-size:8px;font-weight:700;color:#e20074;margin-bottom:2px;">📡 法规感知</div>
<div style="font-size:7px;color:#555;">六大法规源实时事件流</div>
</div>
<div style="background:#fff;border-radius:4px;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,0.06);">
<div style="font-size:8px;font-weight:700;color:#00896a;margin-bottom:2px;">💬 Agent 对话</div>
<div style="font-size:7px;color:#555;">核心链路全通 ✓</div>
</div>
<div style="background:#fff;border-radius:4px;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,0.06);">
<div style="font-size:8px;font-weight:700;color:#2a68c8;margin-bottom:2px;">📚 知识库</div>
<div style="font-size:7px;color:#555;">5步 Pipeline 可视化</div>
</div>
<div style="background:#fff;border-radius:4px;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,0.06);">
<div style="font-size:8px;font-weight:700;color:#cc6200;margin-bottom:2px;">🔍 合规分析</div>
<div style="font-size:7px;color:#555;">风险评分仪表盘</div>
</div>
</div>
</div>
</div>
</div>
</div>
<p style="font-size:13px;color:var(--text-secondary);margin-top:8px;">✦ 浅蓝灰底色 + 白色卡片浮起。左侧竖向洋红线作为品牌标识。圆角胶囊标签替代方形标签。<br>柔和、现代感更强,参考 Bosch 浅色内页风格。</p>
</div>
</div>
<p class="subtitle" style="margin-top:16px;">两种方案均使用 Magenta #E20074 主色 + 浅色全背景。请选择你更喜欢的处理方式,或告诉我想要调整的细节。</p>

View File

@@ -0,0 +1,198 @@
<h2>PPT 整体视觉风格</h2>
<p class="subtitle">三种风格方案 — 基于 Sherlock PPTX 和 Bosch PDF 的参考,结合项目品牌色</p>
<style>
.slide-preview {
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
aspect-ratio: 16/9;
position: relative;
font-family: 'Calibri', 'Segoe UI', sans-serif;
}
.slide-title-bar {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding: 20px 28px;
}
.slide-content-bar {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px 20px;
}
.tag-row {
display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap;
}
.tag {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
}
.mini-card {
border-radius: 4px;
padding: 6px 10px;
margin-top: 6px;
font-size: 11px;
}
</style>
<div class="cards">
<!-- Option A: T-Systems Magenta Dark -->
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="background:#0f0f1a; padding:16px;">
<!-- Title slide preview -->
<div class="slide-preview" style="background:linear-gradient(135deg,#1a0030 0%,#0f0f1a 60%); border:1px solid #e2007430; margin-bottom:8px;">
<div class="slide-title-bar">
<div style="width:3px;height:36px;background:#e20074;display:inline-block;margin-right:12px;border-radius:2px;float:left;margin-top:4px;"></div>
<div style="overflow:hidden;">
<div style="font-size:9px;color:#e20074;letter-spacing:2px;font-weight:700;text-transform:uppercase;margin-bottom:4px;">INTERNAL · AI 合规项目组 · 2026.05</div>
<div style="font-size:16px;font-weight:900;color:#fff;line-height:1.2;">AI + 合规智能中枢<br><span style="color:#e20074;">阶段性进展汇报</span></div>
<div style="font-size:8px;color:#888;margin-top:6px;">基于 Agent 协同的多模块法规合规智能平台</div>
</div>
<div style="position:absolute;right:14px;bottom:10px;display:flex;gap:6px;">
<div style="background:rgba(226,0,116,0.15);border:1px solid rgba(226,0,116,0.4);border-radius:4px;padding:3px 8px;font-size:9px;color:#e20074;font-weight:700;">5 模块</div>
<div style="background:rgba(0,137,106,0.15);border:1px solid rgba(0,137,106,0.4);border-radius:4px;padding:3px 8px;font-size:9px;color:#00896a;font-weight:700;">17+ API</div>
</div>
</div>
</div>
<!-- Content slide preview -->
<div class="slide-preview" style="background:#fff;border:1px solid #eee;">
<div class="slide-content-bar">
<div style="width:100%;height:3px;background:linear-gradient(90deg,#e20074,#be0060);border-radius:2px;margin-bottom:8px;"></div>
<div style="font-size:8px;color:#e20074;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:3px;">阶段成果</div>
<div style="font-size:12px;font-weight:700;color:#1a1a2e;margin-bottom:8px;">已完成的工作</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
<div class="mini-card" style="background:rgba(226,0,116,0.07);border-left:3px solid #e20074;">
<div style="font-size:9px;font-weight:700;color:#1a1a2e;">📡 法规感知</div>
<div style="font-size:8px;color:#666;margin-top:2px;">六大法规源实时事件流</div>
</div>
<div class="mini-card" style="background:rgba(0,137,106,0.07);border-left:3px solid #00896a;">
<div style="font-size:9px;font-weight:700;color:#1a1a2e;">💬 Agent 对话</div>
<div style="font-size:8px;color:#666;margin-top:2px;">核心链路全通</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A · T-Systems 风格</h3>
<p>深色标题页 + 浅色内容页的"三明治"结构Magenta 主色调,参考 Sherlock Deep Dive 原版风格。专业感强,与现有品牌高度一致。</p>
<div class="tag-row">
<span class="tag" style="background:#e200741a;color:#e20074;">Magenta #E20074</span>
<span class="tag" style="background:#1a1a2e1a;color:#1a1a2e;">Navy #1A1A2E</span>
<span class="tag" style="background:#00896a1a;color:#00896a;">Teal #00896A</span>
</div>
</div>
</div>
<!-- Option B: Bosch Dark Enterprise -->
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="background:#1a1a1a; padding:16px;">
<div class="slide-preview" style="background:linear-gradient(145deg,#1a1a1a 0%,#2d0a0a 100%); border:1px solid #cc000030; margin-bottom:8px;">
<div class="slide-title-bar">
<div style="font-size:8px;color:#cc0000;letter-spacing:2px;font-weight:700;text-transform:uppercase;margin-bottom:6px;">■ AI COMPLIANCE INTELLIGENCE HUB</div>
<div style="font-size:16px;font-weight:900;color:#fff;line-height:1.2;margin-bottom:6px;">团队阶段性汇报<br><span style="color:#ff6b35;">Q2 · 2026</span></div>
<div style="display:flex;gap:8px;margin-top:8px;">
<div style="border:1px solid rgba(204,0,0,0.5);border-radius:2px;padding:3px 8px;font-size:8px;color:#ff4444;font-weight:600;">5 模块上线</div>
<div style="border:1px solid rgba(255,107,53,0.5);border-radius:2px;padding:3px 8px;font-size:8px;color:#ff6b35;font-weight:600;">17+ API</div>
</div>
</div>
</div>
<div class="slide-preview" style="background:#f4f4f4; border:1px solid #ddd;">
<div class="slide-content-bar">
<div style="background:#cc0000;height:4px;margin:-16px -20px 10px;"></div>
<div style="font-size:8px;color:#cc0000;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:3px;">PROGRESS</div>
<div style="font-size:12px;font-weight:700;color:#1a1a1a;margin-bottom:8px;">核心链路打通情况</div>
<div style="display:flex;flex-direction:column;gap:5px;">
<div style="display:flex;align-items:center;gap:6px;">
<div style="width:8px;height:8px;border-radius:50%;background:#00a651;flex-shrink:0;"></div>
<div style="font-size:9px;color:#333;">Agent 对话全链路 · 已完成</div>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<div style="width:8px;height:8px;border-radius:50%;background:#ff8c00;flex-shrink:0;"></div>
<div style="font-size:9px;color:#333;">法规感知模块 · 进行中</div>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<div style="width:8px;height:8px;border-radius:50%;background:#cc0000;flex-shrink:0;"></div>
<div style="font-size:9px;color:#333;">合规分析 · 进行中</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B · Bosch 企业风格</h3>
<p>全深色背景,红色主调,强调数据与进展状态。参考 Bosch Workshop PDF 的工程师汇报风格,适合技术向观众,视觉冲击力强。</p>
<div class="tag-row">
<span class="tag" style="background:#cc00001a;color:#cc0000;">Bosch Red #CC0000</span>
<span class="tag" style="background:#1a1a1a1a;color:#333;">Charcoal #1A1A1A</span>
<span class="tag" style="background:#ff6b351a;color:#ff6b35;">Orange #FF6B35</span>
</div>
</div>
</div>
<!-- Option C: Hybrid Elegant -->
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="background:#0d1b3e; padding:16px;">
<div class="slide-preview" style="background:linear-gradient(160deg,#0d1b3e 0%,#1a2b5e 100%); border:1px solid #4a7fc130; margin-bottom:8px;">
<div class="slide-title-bar">
<div style="font-size:8px;color:#7eb8f7;letter-spacing:2px;text-transform:uppercase;margin-bottom:6px;">AI COMPLIANCE · INTERNAL REPORT · 2026.05</div>
<div style="font-size:16px;font-weight:900;color:#fff;line-height:1.2;margin-bottom:4px;">AI + 合规智能中枢</div>
<div style="font-size:11px;color:#e20074;font-weight:700;margin-bottom:10px;">阶段性进展汇报</div>
<div style="display:flex;gap:8px;">
<div style="background:rgba(226,0,116,0.2);border:1px solid rgba(226,0,116,0.5);border-radius:20px;padding:2px 10px;font-size:8px;color:#e20074;font-weight:700;">5 功能模块</div>
<div style="background:rgba(0,212,170,0.2);border:1px solid rgba(0,212,170,0.5);border-radius:20px;padding:2px 10px;font-size:8px;color:#00d4aa;font-weight:700;">17+ API 接口</div>
<div style="background:rgba(255,180,50,0.2);border:1px solid rgba(255,180,50,0.5);border-radius:20px;padding:2px 10px;font-size:8px;color:#ffb432;font-weight:700;">6+ 法规源</div>
</div>
</div>
</div>
<div class="slide-preview" style="background:#fff; border:1px solid #dde3f0;">
<div class="slide-content-bar">
<div style="font-size:8px;color:#2a5aad;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-bottom:3px;">■ 阶段成果</div>
<div style="font-size:12px;font-weight:700;color:#0d1b3e;margin-bottom:8px;">已完成的工作</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;">
<div style="background:#f0f5ff;border:1px solid #c5d5f5;border-radius:4px;padding:5px 8px;">
<div style="font-size:8px;color:#e20074;font-weight:700;margin-bottom:2px;">● 核心链路</div>
<div style="font-size:9px;color:#0d1b3e;font-weight:600;">Agent 对话全通</div>
</div>
<div style="background:#f0fff8;border:1px solid #b8f0dc;border-radius:4px;padding:5px 8px;">
<div style="font-size:8px;color:#00896a;font-weight:700;margin-bottom:2px;">✓ 已完成</div>
<div style="font-size:9px;color:#0d1b3e;font-weight:600;">系统监控</div>
</div>
<div style="background:#fff8f0;border:1px solid #f5d5b5;border-radius:4px;padding:5px 8px;">
<div style="font-size:8px;color:#cc6200;font-weight:700;margin-bottom:2px;">⟳ 进行中</div>
<div style="font-size:9px;color:#0d1b3e;font-weight:600;">法规感知</div>
</div>
<div style="background:#fff0f8;border:1px solid #f5c0dc;border-radius:4px;padding:5px 8px;">
<div style="font-size:8px;color:#e20074;font-weight:700;margin-bottom:2px;">⟳ 进行中</div>
<div style="font-size:9px;color:#0d1b3e;font-weight:600;">合规分析</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C · 海军蓝 + 混合强调色</h3>
<p>深蓝封面页 + 白色内容页,融合 Sherlock 的专业感与 boss-report 的多彩状态系统。用色丰富但有序,适合展示多模块进展。</p>
<div class="tag-row">
<span class="tag" style="background:#0d1b3e1a;color:#0d1b3e;">Navy #0D1B3E</span>
<span class="tag" style="background:#e200741a;color:#e20074;">Magenta</span>
<span class="tag" style="background:#00d4aa1a;color:#009a7a;">Teal</span>
<span class="tag" style="background:#ffb4321a;color:#cc8800;">Gold</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,100 @@
<h2>PPT 幻灯片结构方案</h2>
<p class="subtitle">基于 boss-report.html 的内容,三种详细程度的结构 — 选择最适合你汇报场景的版本</p>
<style>
.deck { display:flex; flex-direction:column; gap:6px; }
.slide-row {
display: flex; align-items: stretch; gap: 8px;
background: #fff; border: 1px solid #e8e8f0; border-radius: 6px; overflow: hidden;
}
.slide-num {
background: #e20074; color: #fff; font-weight: 800;
font-size: 11px; padding: 0 10px;
display: flex; align-items: center; justify-content: center;
min-width: 32px; writing-mode: horizontal-tb;
}
.slide-info { padding: 8px 12px; flex: 1; }
.slide-title { font-size: 13px; font-weight: 700; color: #1a1a2e; margin-bottom: 2px; }
.slide-sub { font-size: 11px; color: #666; }
.slide-type {
font-size: 9px; padding: 2px 7px; border-radius: 10px; font-weight: 700;
align-self: center; white-space: nowrap; margin-right: 10px;
letter-spacing: 0.5px;
}
.t-cover { background:#fff0f7; color:#e20074; border:1px solid rgba(226,0,116,0.2); }
.t-section { background:#f0f4ff; color:#2a68c8; border:1px solid rgba(42,104,200,0.2); }
.t-content { background:#f5f5f5; color:#555; border:1px solid #ddd; }
.t-kpi { background:#f0fdf8; color:#00896a; border:1px solid rgba(0,137,106,0.2); }
.t-arch { background:#fff8f0; color:#cc6200; border:1px solid rgba(204,98,0,0.2); }
.t-close { background:#1a1a2e; color:#fff; border:1px solid #1a1a2e; }
</style>
<div class="options">
<!-- Option A: Compact 8 slides -->
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>精简版 · 8 页</h3>
<p>适合 1015 分钟快速汇报,老板/领导层受众,重点突出进展和价值</p>
<div class="deck" style="margin-top:10px;">
<div class="slide-row"><div class="slide-num">01</div><div class="slide-info"><div class="slide-title">封面 — AI+合规智能中枢 阶段性汇报</div><div class="slide-sub">项目名 · 日期 · 核心数字5模块 / 17+ API / 6+法规源)</div></div><span class="slide-type t-cover">封面</span></div>
<div class="slide-row"><div class="slide-num">02</div><div class="slide-info"><div class="slide-title">项目背景 — 为什么做这个系统?</div><div class="slide-sub">3 大痛点:法规碎片化 · 响应周期长 · 人工成本高</div></div><span class="slide-type t-content">内容</span></div>
<div class="slide-row"><div class="slide-num">03</div><div class="slide-info"><div class="slide-title">阶段成果总览 — 已完成的工作</div><div class="slide-sub">5 个模块状态(进行中/已完成),核心链路打通</div></div><span class="slide-type t-kpi">进展</span></div>
<div class="slide-row"><div class="slide-num">04</div><div class="slide-info"><div class="slide-title">系统架构 — 5 层技术栈</div><div class="slide-sub">前端→API→AI引擎→基础设施分层示意图</div></div><span class="slide-type t-arch">架构</span></div>
<div class="slide-row"><div class="slide-num">05</div><div class="slide-info"><div class="slide-title">业务价值 — 有与没有的差距</div><div class="slide-sub">70% 重复工作减少 / 分钟级响应 / 6+ 法规源统一</div></div><span class="slide-type t-kpi">价值</span></div>
<div class="slide-row"><div class="slide-num">06</div><div class="slide-info"><div class="slide-title">四阶段路线图</div><div class="slide-sub">当前处于阶段二 Demo 打磨,阶段三生产部署计划</div></div><span class="slide-type t-content">路线图</span></div>
<div class="slide-row"><div class="slide-num">07</div><div class="slide-info"><div class="slide-title">近期行动项 — 需要决策/资源</div><div class="slide-sub">Demo收尾 · 业务对齐 · 技术债 · 资源申请</div></div><span class="slide-type t-content">行动</span></div>
<div class="slide-row"><div class="slide-num">08</div><div class="slide-info"><div class="slide-title">封底 — Q&A</div><div class="slide-sub">联系方式 · 内部机密声明</div></div><span class="slide-type t-close">封底</span></div>
</div>
</div>
</div>
<!-- Option B: Standard 11 slides -->
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>标准版 · 11 页</h3>
<p>适合 2030 分钟完整汇报,技术+管理混合受众,各模块有独立页面</p>
<div class="deck" style="margin-top:10px;">
<div class="slide-row"><div class="slide-num">01</div><div class="slide-info"><div class="slide-title">封面</div><div class="slide-sub">项目名 · 核心 KPI 数字 · 日期</div></div><span class="slide-type t-cover">封面</span></div>
<div class="slide-row"><div class="slide-num">02</div><div class="slide-info"><div class="slide-title">目录</div><div class="slide-sub">背景 · 成果 · 架构 · 功能演示 · 价值 · 路线图 · 行动项</div></div><span class="slide-type t-section">目录</span></div>
<div class="slide-row"><div class="slide-num">03</div><div class="slide-info"><div class="slide-title">项目背景 — 3 大核心痛点</div><div class="slide-sub">法规碎片化 / 响应周期长 / 人工成本高3列卡片</div></div><span class="slide-type t-content">内容</span></div>
<div class="slide-row"><div class="slide-num">04</div><div class="slide-info"><div class="slide-title">阶段成果 — 5 个功能模块进展</div><div class="slide-sub">每个模块状态 + 关键功能点(进行中/已完成)</div></div><span class="slide-type t-kpi">进展</span></div>
<div class="slide-row"><div class="slide-num">05</div><div class="slide-info"><div class="slide-title">核心亮点 — Agent 对话链路打通</div><div class="slide-sub">Milvus 检索 → LLM 推理 → SSE 流式输出,全链路贯通</div></div><span class="slide-type t-kpi">亮点</span></div>
<div class="slide-row"><div class="slide-num">06</div><div class="slide-info"><div class="slide-title">系统架构 — 清洁架构 5 层结构</div><div class="slide-sub">分层示意图用户→前端→API→AI引擎→基础设施</div></div><span class="slide-type t-arch">架构</span></div>
<div class="slide-row"><div class="slide-num">07</div><div class="slide-info"><div class="slide-title">技术栈全景</div><div class="slide-sub">React 19 / FastAPI / Milvus / Qwen · DeepSeek / Aliyun DocMind</div></div><span class="slide-type t-arch">技术</span></div>
<div class="slide-row"><div class="slide-num">08</div><div class="slide-info"><div class="slide-title">业务价值 — 量化对比</div><div class="slide-sub">传统方式 vs AI中枢4 大指标数字70%+/分钟级/6+/5</div></div><span class="slide-type t-kpi">价值</span></div>
<div class="slide-row"><div class="slide-num">09</div><div class="slide-info"><div class="slide-title">四阶段路线图</div><div class="slide-sub">时间线POC已完成 → Demo进行中 → 生产部署 → 规模推广</div></div><span class="slide-type t-content">路线图</span></div>
<div class="slide-row"><div class="slide-num">10</div><div class="slide-info"><div class="slide-title">近期行动项 — 4 大类任务</div><div class="slide-sub">Demo收尾 / 业务对齐 / 技术债清理 / 资源&决策2×2 卡片)</div></div><span class="slide-type t-content">行动</span></div>
<div class="slide-row"><div class="slide-num">11</div><div class="slide-info"><div class="slide-title">封底 — Q&A</div><div class="slide-sub"></div></div><span class="slide-type t-close">封底</span></div>
</div>
</div>
</div>
<!-- Option C: Full 14 slides -->
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>完整版 · 14 页</h3>
<p>适合正式汇报 + 存档,含各模块独立功能页,技术细节充分展示</p>
<div class="deck" style="margin-top:10px;">
<div class="slide-row"><div class="slide-num">01</div><div class="slide-info"><div class="slide-title">封面</div></div><span class="slide-type t-cover">封面</span></div>
<div class="slide-row"><div class="slide-num">02</div><div class="slide-info"><div class="slide-title">目录</div></div><span class="slide-type t-section">目录</span></div>
<div class="slide-row"><div class="slide-num">03</div><div class="slide-info"><div class="slide-title">项目背景 — 3 大痛点</div></div><span class="slide-type t-content">内容</span></div>
<div class="slide-row"><div class="slide-num">04</div><div class="slide-info"><div class="slide-title">【分节页】— 阶段成果</div></div><span class="slide-type t-section">分节</span></div>
<div class="slide-row"><div class="slide-num">05</div><div class="slide-info"><div class="slide-title">5 模块进展总览</div></div><span class="slide-type t-kpi">进展</span></div>
<div class="slide-row"><div class="slide-num">06</div><div class="slide-info"><div class="slide-title">核心亮点 — Agent 链路</div></div><span class="slide-type t-kpi">亮点</span></div>
<div class="slide-row"><div class="slide-num">07</div><div class="slide-info"><div class="slide-title">文档处理 Pipeline5步</div></div><span class="slide-type t-arch">流程</span></div>
<div class="slide-row"><div class="slide-num">08</div><div class="slide-info"><div class="slide-title">【分节页】— 技术架构</div></div><span class="slide-type t-section">分节</span></div>
<div class="slide-row"><div class="slide-num">09</div><div class="slide-info"><div class="slide-title">系统分层架构图</div></div><span class="slide-type t-arch">架构</span></div>
<div class="slide-row"><div class="slide-num">10</div><div class="slide-info"><div class="slide-title">技术栈全景</div></div><span class="slide-type t-arch">技术</span></div>
<div class="slide-row"><div class="slide-num">11</div><div class="slide-info"><div class="slide-title">业务价值量化对比</div></div><span class="slide-type t-kpi">价值</span></div>
<div class="slide-row"><div class="slide-num">12</div><div class="slide-info"><div class="slide-title">四阶段路线图</div></div><span class="slide-type t-content">路线图</span></div>
<div class="slide-row"><div class="slide-num">13</div><div class="slide-info"><div class="slide-title">近期行动项4 类)</div></div><span class="slide-type t-content">行动</span></div>
<div class="slide-row"><div class="slide-num">14</div><div class="slide-info"><div class="slide-title">封底 — Q&A</div></div><span class="slide-type t-close">封底</span></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1 @@
{"type":"server-started","port":56940,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:56940","screen_dir":"C:\\Projects\\AIProjects\\AIRegulations\\AIRegulation-DocAnalysis-Demo\\.superpowers\\brainstorm\\1652-1779893150\\content","state_dir":"C:\\Projects\\AIProjects\\AIRegulations\\AIRegulation-DocAnalysis-Demo\\.superpowers\\brainstorm\\1652-1779893150\\state"}

View File

@@ -0,0 +1 @@
1652

View File

@@ -0,0 +1,88 @@
<h2>法规对话模块优化方案</h2>
<p class="subtitle">选择你偏好的整体策略,我会据此展开详细设计</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>分层优先(推荐)</h3>
<p>按依赖关系分4个阶段逐步落地每阶段可独立上线。</p>
<div style="margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#7dd3fc">Phase 1 · 第1周</div>
<strong style="font-size:13px">接入真实服务</strong>
<p style="font-size:12px;margin:4px 0 0;opacity:0.7">消灭 rag.py / compliance.py 中的 Mock 数据,让系统真正可用</p>
</div>
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#86efac">Phase 2 · 第2-3周</div>
<strong style="font-size:13px">混合检索 + Reranking</strong>
<p style="font-size:12px;margin:4px 0 0;opacity:0.7">Milvus sparse BM25 + dense RRF 融合 + Cross-encoder reranker</p>
</div>
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#fcd34d">Phase 3 · 第4周</div>
<strong style="font-size:13px">引用溯源 + 筛选 UI</strong>
<p style="font-size:12px;margin:4px 0 0;opacity:0.7">答案内联 [1][2] 跳转原文片段,法规类型/版本筛选栏</p>
</div>
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#f9a8d4">Phase 4 · 第5周</div>
<strong style="font-size:13px">会话持久化 + 压缩</strong>
<p style="font-size:12px;margin:4px 0 0;opacity:0.7">PostgreSQL 存储会话,长对话上下文压缩,快问后端化</p>
</div>
</div>
<div class="pros-cons" style="margin-top:12px">
<div class="pros"><h4>优势</h4><ul><li>每阶段可独立验证</li><li>Phase 1 即可见效</li><li>风险最低</li></ul></div>
<div class="cons"><h4>劣势</h4><ul><li>完整交付需 5 周</li></ul></div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>检索优先</h3>
<p>先升级检索质量(最有技术价值),再接入服务,最后做 UX。</p>
<div style="margin-top:12px;display:flex;flex-direction:column;gap:8px;">
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#86efac">Step 1</div>
<strong style="font-size:13px">Milvus sparse + dense 混合索引</strong>
<p style="font-size:12px;margin:4px 0 0;opacity:0.7">先在 Mock 环境验证检索效果,技术风险前移</p>
</div>
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#7dd3fc">Step 2</div>
<strong style="font-size:13px">接入真实服务 + 端到端测试</strong>
</div>
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#fcd34d">Step 3</div>
<strong style="font-size:13px">引用 + UX + 会话持久化</strong>
</div>
</div>
<div class="pros-cons" style="margin-top:12px">
<div class="pros"><h4>优势</h4><ul><li>技术风险前移验证</li></ul></div>
<div class="cons"><h4>劣势</h4><ul><li>Mock 上测检索效果失真</li><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>最小可行改进</h3>
<p>只做最小必要改动,跳过 BM25/Reranking快速交付可用版本。</p>
<div style="margin-top:12px;display:flex;flex-direction:column;gap:8px;">
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#7dd3fc">Step 1</div>
<strong style="font-size:13px">接入真实服务(消灭 Mock</strong>
</div>
<div style="background:rgba(255,255,255,0.07);border-radius:6px;padding:10px;">
<div class="label" style="color:#fcd34d">Step 2</div>
<strong style="font-size:13px">引用溯源 + 筛选 UI</strong>
<p style="font-size:12px;margin:4px 0 0;opacity:0.7">跳过混合检索和会话持久化</p>
</div>
</div>
<div class="pros-cons" style="margin-top:12px">
<div class="pros"><h4>优势</h4><ul><li>2周内完成</li><li>最低风险</li></ul></div>
<div class="cons"><h4>劣势</h4><ul><li>检索质量无提升</li><li>会话仍会丢失</li></ul></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,127 @@
<h2>设计概览:法规对话模块优化路线</h2>
<p class="subtitle">Section 1 of 4 — 架构演进全图</p>
<style>
.arch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 16px; }
.arch-box { background: rgba(255,255,255,0.05); border-radius: 10px; padding: 16px; }
.arch-box h3 { margin: 0 0 12px; font-size: 15px; }
.pipe { display: flex; flex-direction: column; gap: 6px; }
.node { border-radius: 6px; padding: 8px 12px; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.node-ok { background: rgba(134,239,172,0.15); border: 1px solid rgba(134,239,172,0.4); }
.node-mock { background: rgba(248,113,113,0.15); border: 1px solid rgba(248,113,113,0.4); }
.node-new { background: rgba(125,211,252,0.15); border: 1px solid rgba(125,211,252,0.5); }
.node-upgrade { background: rgba(253,224,71,0.15); border: 1px solid rgba(253,224,71,0.4); }
.arrow { text-align: center; font-size: 18px; opacity: 0.5; line-height: 1; }
.badge { font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: 600; margin-left: auto; white-space: nowrap; }
.badge-mock { background: rgba(248,113,113,0.3); color: #fca5a5; }
.badge-ok { background: rgba(134,239,172,0.3); color: #86efac; }
.badge-p1 { background: rgba(125,211,252,0.3); color: #7dd3fc; }
.badge-p2 { background: rgba(134,239,172,0.3); color: #86efac; }
.badge-p3 { background: rgba(253,224,71,0.3); color: #fde047; }
.badge-p4 { background: rgba(249,168,212,0.3); color: #f9a8d4; }
.legend { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; font-size: 12px; }
.leg { display: flex; align-items: center; gap: 6px; }
.leg-dot { width: 10px; height: 10px; border-radius: 3px; }
</style>
<div class="arch-grid">
<!-- LEFT: Current State -->
<div class="arch-box">
<h3>📍 当前状态</h3>
<div class="pipe">
<div class="node node-ok">用户提问 (RagChatPage / ChatPanel)</div>
<div class="arrow"></div>
<div class="node node-ok">
<span>POST /agent/chat/stream</span>
<span class="badge badge-ok">真实</span>
</div>
<div class="arrow">↓ ↗</div>
<div class="node node-mock">
<span>POST /rag/chat &amp; /compliance/chat/{id}</span>
<span class="badge badge-mock">Mock 数据</span>
</div>
<div class="arrow"></div>
<div class="node node-ok">
<span>Dense 向量检索COSINE</span>
<span class="badge badge-ok">可用</span>
</div>
<div class="arrow"></div>
<div class="node node-ok">
<span>LLM 生成(输出含 [1][2] 引用)</span>
<span class="badge badge-ok">可用</span>
</div>
<div class="arrow"></div>
<div class="node node-mock">
<span>前端显示来源面板,[1][2] 未解析</span>
<span class="badge badge-mock">未联动</span>
</div>
<div class="arrow"></div>
<div class="node node-mock">
<span>会话存内存30min过期max 100</span>
<span class="badge badge-mock">易丢失</span>
</div>
</div>
</div>
<!-- RIGHT: Target State -->
<div class="arch-box">
<h3>🎯 目标状态4个阶段后</h3>
<div class="pipe">
<div class="node node-ok">用户提问 + 法规类型/版本筛选器 (P3)</div>
<div class="arrow"></div>
<div class="node node-new">
<span>/compliance/chat → 真实 AgentService + Segment 上下文</span>
<span class="badge badge-p1">Phase 1</span>
</div>
<div class="arrow"></div>
<div class="node node-upgrade">
<span>Hybrid 检索Dense + Sparse BM25Milvus→ RRF 融合</span>
<span class="badge badge-p2">Phase 2</span>
</div>
<div class="arrow"></div>
<div class="node node-upgrade">
<span>Cross-Encoder RerankerTop-K 精排)</span>
<span class="badge badge-p2">Phase 2</span>
</div>
<div class="arrow"></div>
<div class="node node-ok">LLM 生成(含 [1][2] 引用编号)</div>
<div class="arrow"></div>
<div class="node node-new">
<span>前端内联引用解析:[1] → 高亮原文跳转</span>
<span class="badge badge-p3">Phase 3</span>
</div>
<div class="arrow"></div>
<div class="node node-new">
<span>会话持久化PostgreSQL+ 上下文压缩</span>
<span class="badge badge-p4">Phase 4</span>
</div>
</div>
</div>
</div>
<div class="legend">
<div class="leg"><div class="leg-dot" style="background:#86efac"></div>现有功能正常</div>
<div class="leg"><div class="leg-dot" style="background:#f87171"></div>当前有问题</div>
<div class="leg"><div class="leg-dot" style="background:#7dd3fc"></div>Phase 1 新增/修复</div>
<div class="leg"><div class="leg-dot" style="background:#86efac;opacity:0.6"></div>Phase 2 升级</div>
<div class="leg"><div class="leg-dot" style="background:#fde047"></div>Phase 3 新增</div>
<div class="leg"><div class="leg-dot" style="background:#f9a8d4"></div>Phase 4 新增</div>
</div>
<div style="margin-top:20px;padding:14px;background:rgba(255,255,255,0.05);border-radius:8px;font-size:13px;">
<strong>关键发现:</strong> RagChatPage 已通过 <code>/agent/chat/stream</code> 使用真实服务。
最需要修复的是 <strong>CompliancePage 的 ChatPanel</strong>(合规对话面板仍是 Mock以及前端快速问题硬编码问题。
Phase 2 的 BM25 稀疏向量需要重建 Milvus Collection或添加新 field
</div>

View File

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

View File

@@ -0,0 +1 @@
1946

View File

@@ -0,0 +1,51 @@
<h2>团队阶段性汇报 PPT — 定位选择</h2>
<p class="subtitle">这次汇报面向谁?确定受众和基调,才能决定内容侧重</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>🎯 项目组内部复盘</h3>
<p>受众:本团队成员(开发 + 产品 + 架构)</p>
<p>基调:<strong>坦诚、细节、协作</strong></p>
<ul style="margin-top:8px;padding-left:16px;font-size:13px;color:#888;line-height:2">
<li>每个模块做了什么 / 谁负责</li>
<li>哪些卡点 / 技术债</li>
<li>下阶段分工与优先级</li>
<li>风格:简洁实用,不必精致</li>
</ul>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>📊 跨团队 / 中层管理汇报</h3>
<p>受众:其他部门 Leader、项目经理、IT 管理层</p>
<p>基调:<strong>成果导向、清晰、有说服力</strong></p>
<ul style="margin-top:8px;padding-left:16px;font-size:13px;color:#888;line-height:2">
<li>我们做了什么 / 进度如何</li>
<li>系统架构概览(不过深)</li>
<li>下阶段计划 + 需要的支持</li>
<li>风格:专业、品牌感、图文并茂</li>
</ul>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>🔧 技术分享 / 工程师视角</h3>
<p>受众:技术团队、架构师、其他工程师</p>
<p>基调:<strong>技术深度、方案决策、经验分享</strong></p>
<ul style="margin-top:8px;padding-left:16px;font-size:13px;color:#888;line-height:2">
<li>架构设计思路(清洁架构 / Ports&amp;Adapters</li>
<li>Agent 协同实现方式</li>
<li>踩坑与解决方案</li>
<li>风格:代码 + 图表,技术感强</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<h2>PPT 内容结构 — 选一个框架</h2>
<p class="subtitle">boss-report 已有 10 张幻灯片,团队汇报可以更精简。哪种结构更符合你的预期?</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>精简版 · 8 张</h3>
<p>快速汇报30 分钟内讲完</p>
<ol style="margin-top:10px;padding-left:18px;font-size:13px;color:#888;line-height:2.2">
<li>封面(项目名 + 团队 + 日期)</li>
<li>背景与目标(为什么做)</li>
<li>阶段成果总览(已完成/进行中/未开始)</li>
<li>核心模块展示5 个功能,每个 2-3 句)</li>
<li>系统架构5 层图,一张)</li>
<li>业务价值(对比 + 4 个 KPI</li>
<li>四阶段路线图(当前位置高亮)</li>
<li>近期行动项 + 结尾</li>
</ol>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>标准版 · 12 张</h3>
<p>完整汇报适合正式会议4560 分钟</p>
<ol style="margin-top:10px;padding-left:18px;font-size:13px;color:#888;line-height:2.2">
<li>封面</li>
<li>背景与痛点3 张卡片)</li>
<li>项目目标 &amp; 范围</li>
<li>阶段成果总览</li>
<li>模块详情 1/2感知 / 文档 / 合规)</li>
<li>模块详情 2/2Agent 对话 / 监控)</li>
<li>系统架构</li>
<li>技术亮点Agent 协同 / SSE / 双引擎)</li>
<li>业务价值 &amp; KPI</li>
<li>四阶段路线图</li>
<li>近期行动项 &amp; 资源需求</li>
<li>总结 &amp; 致谢</li>
</ol>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>重点突出版 · 10 张(推荐)</h3>
<p>与 boss-report 同等体量,但内容侧重<strong>团队视角</strong></p>
<ol style="margin-top:10px;padding-left:18px;font-size:13px;color:#888;line-height:2.2">
<li>封面(团队 + 汇报人 + 日期)</li>
<li>项目背景 &amp; 我们的目标</li>
<li>本阶段工作总览(模块 + 状态 + 负责人)</li>
<li>核心功能演示 1/2</li>
<li>核心功能演示 2/2</li>
<li>系统架构 &amp; 技术选型</li>
<li>业务价值量化</li>
<li>四阶段路线图</li>
<li>下阶段重点 &amp; 分工</li>
<li>结语 &amp; Q&amp;A</li>
</ol>
</div>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<h2>视觉风格选择</h2>
<p class="subtitle">团队汇报的整体视觉基调——哪种感觉最符合你们的场合?</p>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image" style="background:linear-gradient(135deg,#0a0f2e 0%,#1a1040 100%);height:160px;display:flex;align-items:center;justify-content:center;gap:16px;flex-direction:column;">
<div style="display:flex;gap:8px;align-items:center;">
<div style="width:4px;height:40px;background:#e20074;border-radius:2px;"></div>
<div>
<div style="font-size:15px;font-weight:800;color:#fff;letter-spacing:1px;">AI + 合规智能中枢</div>
<div style="font-size:11px;color:#e20074;margin-top:4px;letter-spacing:2px;">TEAM PROGRESS REPORT</div>
</div>
</div>
<div style="display:flex;gap:8px;">
<div style="background:rgba(226,0,116,0.15);border:1px solid rgba(226,0,116,0.4);border-radius:4px;padding:4px 10px;font-size:10px;color:#e20074;">5 模块</div>
<div style="background:rgba(0,212,170,0.1);border:1px solid rgba(0,212,170,0.3);border-radius:4px;padding:4px 10px;font-size:10px;color:#00d4aa;">17+ API</div>
</div>
</div>
<div class="card-body">
<h3>A · 深色科技风(沿用 boss-report 风格)</h3>
<p>深海蓝 + 品牌洋红,科技感强,与现有 boss-report.pptx 视觉一致。适合 IT 氛围浓的场合。</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image" style="background:linear-gradient(135deg,#f7f7fa 0%,#f0eef8 100%);height:160px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;border-bottom:1px solid #e0e0ea;">
<div style="display:flex;gap:8px;align-items:center;">
<div style="width:4px;height:40px;background:#e20074;border-radius:2px;"></div>
<div>
<div style="font-size:15px;font-weight:800;color:#1a1a2e;letter-spacing:0.5px;">AI + 合规智能中枢</div>
<div style="font-size:11px;color:#e20074;margin-top:4px;letter-spacing:2px;">团队阶段性汇报</div>
</div>
</div>
<div style="display:flex;gap:6px;">
<div style="background:rgba(226,0,116,0.1);border:1px solid rgba(226,0,116,0.25);border-radius:4px;padding:3px 10px;font-size:10px;color:#be0060;">5 功能模块</div>
<div style="background:rgba(0,137,106,0.08);border:1px solid rgba(0,137,106,0.2);border-radius:4px;padding:3px 10px;font-size:10px;color:#00896a;">核心链路已通</div>
</div>
</div>
<div class="card-body">
<h3>B · 浅色专业风(与 HTML 报告一致)</h3>
<p>白底 + 洋红 + 深色文字,与 boss-report.html 视觉一致。阅读友好,投影仪效果好,商务感强。</p>
</div>
</div>
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image" style="background:linear-gradient(135deg,#1e3a5f 0%,#2d5986 100%);height:160px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;">
<div style="display:flex;gap:8px;align-items:center;">
<div style="width:4px;height:40px;background:#4fc3f7;border-radius:2px;"></div>
<div>
<div style="font-size:15px;font-weight:800;color:#fff;letter-spacing:0.5px;">AI + 合规智能中枢</div>
<div style="font-size:11px;color:#4fc3f7;margin-top:4px;letter-spacing:2px;">TEAM REPORT · 2026.05</div>
</div>
</div>
<div style="display:flex;gap:6px;">
<div style="background:rgba(79,195,247,0.15);border:1px solid rgba(79,195,247,0.35);border-radius:4px;padding:3px 10px;font-size:10px;color:#4fc3f7;">T-Systems Blue</div>
<div style="background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);border-radius:4px;padding:3px 10px;font-size:10px;color:#fff;">专业蓝</div>
</div>
</div>
<div class="card-body">
<h3>C · 企业蓝风格T-Systems 主色调)</h3>
<p>深蓝 + 天蓝点缀,更接近 T-Systems 官方企业蓝调。适合对外正式会议或集团内部跨团队场合。</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

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

View File

@@ -0,0 +1 @@
1959

259
01_Architecture.html Normal file
View File

@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI+合规智能中枢 — 分层次技术架构图</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
background: linear-gradient(160deg, #fef5f9 0%, #f8eaf0 100%);
min-height: 100vh;
padding: 50px 24px;
}
.container { max-width: 1300px; margin: 0 auto; }
/* ── 品牌色系 (T-systems PPT) ── */
:root {
--pink: #E20074;
--pink-dark: #B0005A;
--pink-light: #E20074;
--pink-bg: #FDF2F7;
--navy: #000E5E;
--navy-mid: #1A2B6B;
--blue-light: #D3E7F3;
--blue-pale: #EAF4FA;
--teal: #32B9AF;
--teal-dark: #1A8A82;
--teal-bg: #E0F7F5;
--orange: #F26B43;
--orange-dark:#D4532B;
--orange-bg: #FEF0EB;
--gray-text: #4B4B4B;
--gray-mid: #7B7B7B;
--gray-light: #B0B0B0;
--gray-bg: #F0F0F0;
}
/* ── 标题 ── */
.header { text-align:center; margin-bottom:44px; }
.header h1 { font-size:34px; font-weight:900; color:var(--navy); letter-spacing:2px; }
.header .sub { font-size:16px; color:var(--gray-text); font-weight:500; margin-top:6px; }
.header .tag { display:inline-block; background:var(--pink); color:#fff; padding:4px 18px; border-radius:20px; font-size:12px; margin-top:10px; }
/* ── 层 ── */
.layer {
border-radius:14px; border:2px solid; overflow:hidden;
transition: transform .2s, box-shadow .2s;
}
.layer:hover { transform:translateY(-2px); box-shadow:0 6px 24px rgba(226,0,116,.1); }
.lh {
padding:12px 22px; color:#fff; font-size:17px; font-weight:700;
display:flex; align-items:center; gap:10px;
}
.lh .en { font-size:12px; font-weight:400; opacity:.75; }
.lh .ico { font-size:20px; }
.lb { padding:18px 22px; display:grid; gap:10px; }
.g5 { grid-template-columns:repeat(5,1fr); }
.lb.g5 { display:flex; justify-content:space-between; gap:0; }
.lb.g5 > .m { flex:0 0 18%; }
.m {
background:#fff; border-radius:9px; padding:12px 14px; border:1.5px solid;
transition: transform .12s, box-shadow .12s; cursor:default;
}
.m:hover { transform:translateY(-1px); box-shadow:0 3px 10px rgba(226,0,116,.08); }
.m .n { font-size:14px; font-weight:700; margin-bottom:3px; }
.m .d { font-size:11.5px; color:var(--gray-mid); line-height:1.55; }
/* ── 箭头 ── */
.arr-row { display:flex; justify-content:center; gap:180px; padding:5px 0; }
.arr { width:2px; height:18px; background:var(--pink-light); position:relative; }
.arr::after { content:''; position:absolute; bottom:-5px; left:-4px;
border-left:5px solid transparent; border-right:5px solid transparent; border-top:6px solid var(--pink-light); }
/* ── 颜色主题 ── */
.c1 { border-color:var(--pink); background:var(--pink-bg); }
.c1 .lh { background:linear-gradient(135deg,var(--pink-dark),var(--pink)); }
.c1 .m { border-color:var(--pink-light); }
.c1 .m .n { color:var(--pink-dark); }
.c2 { border-color:var(--navy); background:var(--blue-pale); }
.c2 .lh { background:linear-gradient(135deg,var(--navy),var(--navy-mid)); }
.c2 .m { border-color:var(--blue-light); }
.c2 .m .n { color:var(--navy); }
.c3 { border-color:var(--pink); background:#FCEEF4; }
.c3 .lh { background:linear-gradient(135deg,#C40068,#E20074); }
.c3 .m { border-color:var(--pink-light); }
.c3 .m .n { color:#C40068; }
.c4 { border-color:var(--teal); background:var(--teal-bg); }
.c4 .lh { background:linear-gradient(135deg,var(--teal-dark),var(--teal)); }
.c4 .m { border-color:#A0E0DB; }
.c4 .m .n { color:var(--teal-dark); }
.c5 { border-color:var(--gray-text); background:var(--gray-bg); }
.c5 .lh { background:linear-gradient(135deg,#2C2C2C,var(--gray-text)); }
.c5 .m { border-color:#C8C8C8; }
.c5 .m .n { color:#2C2C2C; }
/* ── 图例 ── */
.legend {
margin-top:36px; padding:18px 24px; background:#fff;
border-radius:12px; border:1px solid #E0D0D8;
}
.legend h3 { font-size:15px; color:var(--navy); margin-bottom:10px; }
.lg { display:flex; flex-wrap:wrap; gap:14px; }
.li { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--gray-text); }
.ld { width:11px; height:11px; border-radius:50%; }
.footer { text-align:center; margin-top:24px; font-size:12px; color:var(--gray-light); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>AI+合规智能中枢 — 分层技术架构</h1>
<div class="sub">面向车企与工厂 | 全链路合规智能平台</div>
<div class="tag">T-systems AI Regulations Team &nbsp;|&nbsp; 2026.04</div>
</div>
<!-- ═══ L1 应用接入层 ═══ -->
<div class="layer c1">
<div class="lh"><span class="ico">🌐</span> 应用接入层 <span class="en">Application &amp; Access Layer</span></div>
<div class="lb g5">
<div class="m"><div class="n">Web管理门户</div><div class="d">知识库问答 / 文档审查 / EHS管理</div></div>
<div class="m"><div class="n">移动端 &amp; 企业Bot</div><div class="d">飞书 / 钉钉 / Teams / 巡检App</div></div>
<div class="m"><div class="n">企业系统集成</div><div class="d">风控中台 · PLM/ERP· Webhook</div></div>
<div class="m"><div class="n">API Gateway</div><div class="d">Nginx · 限流 · TLS · 路由</div></div>
<div class="m"><div class="n">RBAC权限网关</div><div class="d">四角色鉴权:研发 / 生产 / 采购 / 法务</div></div>
</div>
</div>
<div class="arr-row"><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- ═══ L2 业务能力层 ═══ -->
<div class="layer c2">
<div class="lh"><span class="ico">💼</span> 业务能力层 <span class="en">Business Capability Layer</span></div>
<div class="lb g5">
<div class="m"><div class="n">合规知识库问答</div><div class="d">混合检索 · 中英双语 · 引文溯源</div></div>
<div class="m"><div class="n">智能文档审查</div><div class="d">上传审查 · 条款比对 · 风险标注</div></div>
<div class="m"><div class="n">EHS隐患识别 &amp; 体系审计</div><div class="d">SIF预测 · 四维根因 · ISO 45001扫描</div></div>
<div class="m"><div class="n">法规变更监控 &amp; 推送</div><div class="d">自动检测 · 增量索引 · 精准推送</div></div>
<div class="m"><div class="n">个性化推荐 &amp; 报告</div><div class="d">角色画像 · 风险等级 · 整改建议</div></div>
</div>
</div>
<div class="arr-row"><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- ═══ L3 法规感知 &amp; 知识自动更新闭环 ═══ -->
<div class="layer" style="border-color:var(--orange);background:var(--orange-bg);">
<div class="lh" style="background:linear-gradient(135deg,var(--orange-dark),var(--orange));"><span class="ico">📡</span> 法规感知 &amp; 知识自动更新闭环 <span class="en">Regulation Awareness &amp; Auto-Update Loop</span></div>
<div class="lb" style="grid-template-columns:1fr; gap:0;">
<div style="display:flex;align-items:stretch;gap:0;background:#fff;border-radius:10px;border:1.5px solid #F5CCE0;padding:0;overflow:hidden;">
<div style="flex:1;padding:14px 18px;border-right:2px solid var(--pink-light);text-align:center;">
<div style="font-size:14px;font-weight:700;color:var(--pink-dark);margin-bottom:4px;">① 法规源监控</div>
<div style="font-size:11px;color:var(--gray-mid);">定时爬取国标网 · 工信部 · UN-ECE<br>EUR-Lex · 碳交易平台 · 行业通报</div>
</div>
<div style="flex:0 0 30px;display:flex;align-items:center;justify-content:center;color:var(--pink);font-size:18px;"></div>
<div style="flex:1;padding:14px 18px;border-right:2px solid var(--pink-light);text-align:center;">
<div style="font-size:14px;font-weight:700;color:var(--pink-dark);margin-bottom:4px;">② 智能变更感知</div>
<div style="font-size:11px;color:var(--gray-mid);">NLP比对新旧版本 · 版本Diff提取<br>自动识别新增/修订/废止条款</div>
</div>
<div style="flex:0 0 30px;display:flex;align-items:center;justify-content:center;color:var(--pink);font-size:18px;"></div>
<div style="flex:1;padding:14px 18px;border-right:2px solid var(--pink-light);text-align:center;">
<div style="font-size:14px;font-weight:700;color:var(--pink-dark);margin-bottom:4px;">③ 自动解析入库</div>
<div style="font-size:11px;color:var(--gray-mid);">MinerU/OCR解析 · 条款级分块<br>BGE-M3嵌入 · Milvus+PostgreSQL写入</div>
</div>
<div style="flex:0 0 30px;display:flex;align-items:center;justify-content:center;color:var(--pink);font-size:18px;"></div>
<div style="flex:1;padding:14px 18px;border-right:2px solid var(--pink-light);text-align:center;">
<div style="font-size:14px;font-weight:700;color:var(--pink-dark);margin-bottom:4px;">④ 知识图谱更新</div>
<div style="font-size:11px;color:var(--gray-mid);">Neo4j关系同步 · 条款义务映射<br>影响范围分析 · 关联企业制度</div>
</div>
<div style="flex:0 0 30px;display:flex;align-items:center;justify-content:center;color:var(--pink);font-size:18px;"></div>
<div style="flex:1;padding:14px 18px;border-right:2px solid var(--pink-light);text-align:center;">
<div style="font-size:14px;font-weight:700;color:var(--pink-dark);margin-bottom:4px;">⑤ 差距分析 &amp; 推送</div>
<div style="font-size:11px;color:var(--gray-mid);">AI对比企业现状与新法差距<br>按角色/业务域精准推送变更摘要</div>
</div>
<div style="flex:0 0 30px;display:flex;align-items:center;justify-content:center;color:var(--pink);font-size:18px;"></div>
<div style="flex:1;padding:14px 18px;text-align:center;">
<div style="font-size:14px;font-weight:700;color:var(--pink-dark);margin-bottom:4px;">⑥ 触发整改闭环</div>
<div style="font-size:11px;color:var(--gray-mid);">自动生成整改任务 · 关联责任人<br>整改进度跟踪 → 复审归档 ↺ 回到①</div>
</div>
</div>
</div>
</div>
<div class="arr-row"><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- ═══ L4 AI引擎层 ═══ -->
<div class="layer c3">
<div class="lh"><span class="ico">🧠</span> AI引擎层 <span class="en">AI Engine Layer</span></div>
<div class="lb g5">
<div class="m"><div class="n">RAG检索引擎</div><div class="d">BM25 + 向量双路召回 · Cross-Encoder精排</div></div>
<div class="m"><div class="n">LLM问答生成</div><div class="d">DeepSeek / Qwen2.5 · 引文锚定输出 · 引文置信度</div></div>
<div class="m"><div class="n">文档解析 &amp; OCR</div><div class="d">MinerU · 阿里云解析 · 版面感知 109语言</div></div>
<div class="m"><div class="n">知识图谱推理</div><div class="d">Neo4j · 法规-条款-义务关系 · 多跳推理</div></div>
<div class="m"><div class="n">NLP &amp; 合规比对</div><div class="d">实体识别 · 条款级语义对比 · 风险评分</div></div>
</div>
</div>
<div class="arr-row"><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- ═══ L5 数据 &amp; 知识层 ═══ -->
<div class="layer c4">
<div class="lh"><span class="ico">💾</span> 数据 &amp; 知识层 <span class="en">Data &amp; Knowledge Layer</span></div>
<div class="lb g5">
<div class="m"><div class="n">Milvus 向量库</div><div class="d">Dense + Sparse + Hybrid 语义检索</div></div>
<div class="m"><div class="n">PostgreSQL</div><div class="d">元数据 · 权限 · 任务状态 · 法规版本</div></div>
<div class="m"><div class="n">Neo4j + S3/MinIO</div><div class="d">知识图谱存储 · 原始文件与解析产物</div></div>
<div class="m"><div class="n">消息队列 &amp; 缓存</div><div class="d">RabbitMQ/Kafka 任务分发 · Redis 热数据</div></div>
<div class="m"><div class="n">法规知识库</div><div class="d">车辆安全 · 数据安全 · EHS · 碳排放 · 质量体系 · 案例库 · 术语库</div></div>
</div>
</div>
<div class="arr-row"><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- ═══ L6 基础设施层 ═══ -->
<div class="layer c5">
<div class="lh"><span class="ico">🏗️</span> 基础设施层 <span class="en">Infrastructure Layer</span></div>
<div class="lb g5">
<div class="m"><div class="n">安全与治理</div><div class="d">JWT/OAuth2 · RBAC · 数据脱敏 · 审计日志 · 私有化部署</div></div>
<div class="m"><div class="n">容器编排</div><div class="d">Docker · 弹性伸缩 · GPU集群</div></div>
<div class="m"><div class="n">运维观测</div><div class="d">Prometheus · Grafana · ELK · 链路追踪 · 告警</div></div>
<div class="m"><div class="n">CI/CD &amp; 网络</div><div class="d">GitLab CI · VPC隔离 · VPN · 备份灾备</div></div>
<div class="m"><div class="n">多语言嵌入模型</div><div class="d">BGE-M3 · 中英双语 · 8192 tokens 上下文</div></div>
</div>
</div>
<!-- ═══ 图例 ═══ -->
<div class="legend">
<h3>核心技术选型</h3>
<div class="lg">
<div class="li"><div class="ld" style="background:#E20074"></div>API: FastAPI</div>
<div class="li"><div class="ld" style="background:#000E5E"></div>RAG: LangChain / LlamaIndex</div>
<div class="li"><div class="ld" style="background:#E20074"></div>嵌入: BGE-M3</div>
<div class="li"><div class="ld" style="background:#32B9AF"></div>向量库: Milvus</div>
<div class="li"><div class="ld" style="background:#E20074"></div>解析: MinerU + 阿里云</div>
<div class="li"><div class="ld" style="background:#000E5E"></div>图谱: Neo4j</div>
<div class="li"><div class="ld" style="background:#E20074"></div>LLM: DeepSeek / Qwen2.5</div>
<div class="li"><div class="ld" style="background:#4B4B4B"></div>队列: Kafka + RabbitMQ</div>
<div class="li"><div class="ld" style="background:#32B9AF"></div>关系库: PostgreSQL</div>
<div class="li"><div class="ld" style="background:#F26B43"></div>对象存储: S3 / MinIO</div>
<div class="li"><div class="ld" style="background:#4B4B4B"></div>缓存: Redis</div>
</div>
</div>
<div class="footer">AI+合规智能中枢 v1.0 &nbsp;|&nbsp; T-systems AI Regulations Team &nbsp;|&nbsp; 2026.04</div>
</div>
</body>
</html>

567
02_Architecture_Detail.html Normal file
View File

@@ -0,0 +1,567 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI+合规智能中枢 — 详细技术架构图</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
background: linear-gradient(160deg, #fef5f9 0%, #f8eaf0 100%);
padding: 30px 20px;
}
.page { max-width: 1800px; margin: 0 auto; }
.header { text-align:center; margin-bottom:30px; }
.header h1 { font-size:32px; font-weight:900; color:#000E5E; }
.header .sub { font-size:16px; color:#4B4B4B; margin-top:4px; }
/* ── 总体布局:左主区 + 右侧栏 ── */
.layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 20px;
}
/* ── 主区域 ── */
.main { display:flex; flex-direction:column; gap:16px; }
/* ── 通用框 ── */
.box {
border-radius: 12px;
border: 2px solid;
overflow: hidden;
background: white;
}
.box-header {
padding: 10px 18px;
color: white;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.box-header .en { font-size: 11px; opacity: 0.75; font-weight: 400; }
.box-body { padding: 14px 16px; }
.services-grid .box-header { min-height: 56px; }
/* ── 小模块 ── */
.mod {
background: #FAFBFC;
border: 1.5px solid #DDE1E6;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 6px;
transition: background 0.15s;
}
.mod:hover { background: #F0F4F8; }
.mod-title { font-size: 13px; font-weight: 700; color: #000E5E; }
.mod-desc { font-size: 11px; color: #7F8C8D; margin-top: 2px; }
.mod-api { font-size: 10px; color: #2980B9; font-family: 'Consolas', monospace; margin-top: 2px; }
/* ── 用户行 ── */
.user-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.user-card {
background: linear-gradient(135deg, #B0005A, #E20074);
color: white;
border-radius: 10px;
padding: 12px 14px;
text-align: center;
}
.user-card .name { font-size: 14px; font-weight: 700; }
.user-card .role { font-size: 11px; opacity: 0.8; margin-top: 2px; }
/* ── 网关横条 ── */
.gw-bar {
background: linear-gradient(135deg, #B0005A, #E20074);
color: white;
border-radius: 8px;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.gw-bar .title { font-size: 14px; font-weight: 700; }
.gw-bar .items { font-size: 11px; opacity: 0.85; }
/* ── 服务列 ── */
.services-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
}
/* ── 数据流标注 ── */
.flow-section {
background: white;
border-radius: 12px;
border: 1.5px solid #DDE1E6;
padding: 16px 20px;
}
.flow-title {
font-size: 14px;
font-weight: 700;
color: #000E5E;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #EEF0F2;
}
.flow-item {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: #5D6D7E;
}
.flow-num {
background: #E20074;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
/* ── 中间件行 ── */
.mid-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
.mid-card {
border-radius: 8px;
border: 1.5px solid;
padding: 10px 12px;
text-align: center;
}
.mid-card .name { font-size: 14px; font-weight: 700; }
.mid-card .desc { font-size: 10px; margin-top: 4px; }
/* ── AI模型行 ── */
.ai-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
/* ── 数据源行 ── */
.src-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
/* ── 右侧栏 ── */
.sidebar { display:flex; flex-direction:column; gap:16px; }
.side-box {
border-radius: 12px;
border: 2px solid;
overflow: hidden;
background: white;
}
.side-header {
padding: 8px 14px;
color: white;
font-size: 13px;
font-weight: 700;
}
.side-body { padding: 10px 14px; }
.side-item {
font-size: 11px;
color: #4B4B4B;
padding: 3px 0;
padding-left: 14px;
position: relative;
}
.side-item::before {
content: '▸';
position: absolute;
left: 0;
color: #E20074;
}
/* ── 箭头 ── */
.arrows {
display: flex;
justify-content: center;
gap: 180px;
padding: 5px 0;
}
.arr { width: 2px; height: 18px; background: #E20074; position: relative; }
.arr::after {
content: '';
position: absolute;
bottom: -5px; left: -4px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #E20074;
}
/* ── 颜色 ── */
.c-teal { border-color: #148F77; }
.c-teal .box-header, .c-teal .side-header { background: linear-gradient(135deg, #0E6655, #148F77); }
.c-teal .mid-card { border-color: #A0D8C8; background: #E0F7F3; }
.c-teal .mid-card .name { color: #0E6655; }
.c-teal .mid-card .desc { color: #4B4B4B; }
.c-purple { border-color: #7D3CB5; }
.c-purple .box-header, .c-purple .side-header { background: linear-gradient(135deg, #5B2C8B, #7D3CB5); }
.c-purple .mid-card { border-color: #C8A8E0; background: #F0E6F6; }
.c-purple .mid-card .name { color: #5B2C8B; }
.c-purple .mid-card .desc { color: #4B4B4B; }
.c-green { border-color: #2D8B57; }
.c-green .box-header, .c-green .side-header { background: linear-gradient(135deg, #1B5E3B, #2D8B57); }
.c-green .mid-card { border-color: #A8D8B8; background: #E8F6EF; }
.c-green .mid-card .name { color: #1B5E3B; }
.c-green .mid-card .desc { color: #4B4B4B; }
.c-blue { border-color: #000E5E; }
.c-blue .box-header, .c-blue .side-header { background: linear-gradient(135deg, #000E5E, #1A2B6B); }
.c-blue .mid-card { border-color: #B0C4E8; background: #D3E7F3; }
.c-blue .mid-card .name { color: #000E5E; }
.c-blue .mid-card .desc { color: #4B4B4B; }
.c-orange { border-color: #F26B43; }
.c-orange .box-header, .c-orange .side-header { background: linear-gradient(135deg, #D4532B, #F26B43); }
.c-orange .mid-card { border-color: #E0B888; background: #FEF0EB; }
.c-orange .mid-card .name { color: #D4532B; }
.c-orange .mid-card .desc { color: #4B4B4B; }
.c-red { border-color: #E20074; }
.c-red .box-header, .c-red .side-header { background: linear-gradient(135deg, #B0005A, #E20074); }
.c-gray { border-color: #4B4B4B; }
.c-gray .side-header { background: linear-gradient(135deg, #2C2C2C, #4B4B4B); }
.c-dark { border-color: #000E5E; }
.c-dark .side-header { background: linear-gradient(135deg, #000840, #000E5E); }
.footer { text-align:center; margin-top:30px; font-size:12px; color:#B0B0B0; }
/* 连接线标签 */
.conn-label {
font-size: 10px;
color: #95A5A6;
text-align: center;
padding: 2px 0;
}
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1>AI+合规智能中枢 — 详细技术架构图</h1>
<div class="sub">面向车企与工厂 | 全链路合规智能平台 | Detailed Architecture</div>
</div>
<div class="layout">
<!-- ════════════════════ 主区域 ════════════════════ -->
<div class="main">
<!-- 用户行 -->
<div class="user-row">
<div class="user-card"><div class="name">车企研发/法务</div><div class="role">Web门户 + API调用</div></div>
<div class="user-card"><div class="name">工厂EHS工程师</div><div class="role">移动端 + Bot通知</div></div>
<div class="user-card"><div class="name">采购/供应链</div><div class="role">PLM/ERP集成</div></div>
<div class="user-card"><div class="name">管理层/审计</div><div class="role">Dashboard + 报表</div></div>
<div class="user-card"><div class="name">外部供应商</div><div class="role">合规声明上传</div></div>
</div>
<div class="arrows"><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- API网关 -->
<div class="gw-bar">
<div class="title">API Gateway / nginx / Traefik</div>
<div class="items">TLS终止 | 限流熔断 | 路由分发 | 负载均衡 | JWT校验 | 请求日志</div>
</div>
<div class="arrows"><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- 六大核心服务 -->
<div class="services-grid" style="grid-template-columns:repeat(6,1fr);">
<!-- kbmp-service -->
<div class="box c-teal">
<div class="box-header">kbmp-service <span class="en">知识库公开接口</span></div>
<div class="box-body">
<div class="mod"><div class="mod-title">知识库CRUD</div><div class="mod-api">POST /workspace/create</div><div class="mod-desc">创建知识库空间</div></div>
<div class="mod"><div class="mod-title">文件上传</div><div class="mod-api">POST /files/upload</div><div class="mod-desc">文件登记 + 任务投递</div></div>
<div class="mod"><div class="mod-title">检索编排</div><div class="mod-api">POST /knowledge/retrieval</div><div class="mod-desc">意图识别→召回→重排→生成</div></div>
<div class="mod"><div class="mod-title">Chunk召回</div><div class="mod-api">POST /chunks/recall</div><div class="mod-desc">向量+关键词混合召回</div></div>
<div class="mod"><div class="mod-title">任务投递</div><div class="mod-desc">解析/索引任务→消息队列</div></div>
<div class="mod"><div class="mod-title">Worker入口</div><div class="mod-desc">worker启动/心跳/状态上报</div></div>
</div>
</div>
<!-- mcp-server -->
<div class="box c-purple">
<div class="box-header">mcp-server <span class="en">文档解析服务</span></div>
<div class="box-body">
<div class="mod"><div class="mod-title">阿里云解析</div><div class="mod-api">POST /parse-document</div><div class="mod-desc">云端高精度解析</div></div>
<div class="mod"><div class="mod-title">MinerU解析</div><div class="mod-api">POST /mineru-parse</div><div class="mod-desc">本地多模态解析引擎</div></div>
<div class="mod"><div class="mod-title">OCR引擎</div><div class="mod-desc">版面感知 109语言支持</div></div>
<div class="mod"><div class="mod-title">Markdown生成</div><div class="mod-desc">结构化文本输出</div></div>
<div class="mod"><div class="mod-title">表格/图片提取</div><div class="mod-desc">PDF/Word/Excel多格式</div></div>
<div class="mod"><div class="mod-title">解析回退策略</div><div class="mod-desc">阿里云→MinerU→本地Fallback</div></div>
</div>
</div>
<!-- 合规业务后端 -->
<div class="box c-green">
<div class="box-header">合规业务后端 <span class="en">法规 + 审查 + 推送</span></div>
<div class="box-body">
<div class="mod"><div class="mod-title">法规下载</div><div class="mod-api">POST /compliance/regulations/download</div><div class="mod-desc">从互联网下载法规文档</div></div>
<div class="mod"><div class="mod-title">法规更新/同步</div><div class="mod-api">POST /compliance/regulations/update</div><div class="mod-desc">版本管理+增量索引同步</div></div>
<div class="mod"><div class="mod-title">权限分级管理</div><div class="mod-api">POST /compliance/access-control</div><div class="mod-desc">研发/生产/采购/法务四角色</div></div>
<div class="mod"><div class="mod-title">智能合规审查</div><div class="mod-api">POST /compliance/check</div><div class="mod-desc">条款级比对+风险评分</div></div>
<div class="mod"><div class="mod-title">合规结果查询</div><div class="mod-api">GET /compliance/query</div><div class="mod-desc">审查结果+风险项+整改建议</div></div>
<div class="mod"><div class="mod-title">事件订阅推送</div><div class="mod-api">POST /compliance/subscribe</div><div class="mod-desc">Webhook+多渠道推送</div></div>
</div>
</div>
<!-- 法规感知引擎 (新增) -->
<div class="box c-red">
<div class="box-header">法规感知引擎 <span class="en">Regulation Awareness Engine</span></div>
<div class="box-body">
<div class="mod"><div class="mod-title">法规源监控</div><div class="mod-desc">定时爬取国标网/工信部/UN-ECE<br>EUR-Lex/碳交易/行业通报</div></div>
<div class="mod"><div class="mod-title">智能变更感知</div><div class="mod-desc">NLP比对新旧版本Diff<br>自动识别新增/修订/废止条款</div></div>
<div class="mod"><div class="mod-title">自动解析入库</div><div class="mod-desc">触发MinerU解析→条款分块<br>→BGE-M3嵌入→Milvus+PG写入</div></div>
<div class="mod"><div class="mod-title">知识图谱同步</div><div class="mod-desc">Neo4j关系更新<br>条款义务映射+影响范围分析</div></div>
<div class="mod"><div class="mod-title">差距分析</div><div class="mod-desc">AI对比企业制度与新法差距<br>自动生成变更影响评估</div></div>
<div class="mod"><div class="mod-title">变更推送 &amp; 整改触发</div><div class="mod-desc">按角色/域精准推送摘要<br>自动创建整改任务→闭环跟踪</div></div>
</div>
</div>
<!-- AI推理引擎 -->
<div class="box c-blue">
<div class="box-header">AI推理引擎 <span class="en">RAG + LLM + 图谱</span></div>
<div class="box-body">
<div class="mod"><div class="mod-title">混合检索</div><div class="mod-desc">BM25关键词 + BGE-M3向量<br>本地+网络双路召回</div></div>
<div class="mod"><div class="mod-title">BGE-M3嵌入</div><div class="mod-desc">中英双语 8192 tokens<br>Dense+Sparse+Multi-vec</div></div>
<div class="mod"><div class="mod-title">Reranker精排</div><div class="mod-desc">Cross-Encoder语义精排<br>Top-K结果重排序</div></div>
<div class="mod"><div class="mod-title">LLM生成</div><div class="mod-desc">DeepSeek/Qwen2.5<br>引文锚定+置信度评分</div></div>
<div class="mod"><div class="mod-title">知识图谱</div><div class="mod-desc">Neo4j法规实体关系图<br>多跳推理+条款关联</div></div>
<div class="mod"><div class="mod-title">NLP分析</div><div class="mod-desc">实体识别/文档分类<br>隐患实体抽取</div></div>
</div>
</div>
<!-- Worker集群 -->
<div class="box c-orange">
<div class="box-header">Worker集群 <span class="en">异步任务执行</span></div>
<div class="box-body">
<div class="mod"><div class="mod-title">解析Worker</div><div class="mod-desc">消费解析任务→调用mcp-server</div></div>
<div class="mod"><div class="mod-title">向量化Worker</div><div class="mod-desc">文本清洗→切分→嵌入→入库</div></div>
<div class="mod"><div class="mod-title">合规Worker</div><div class="mod-desc">比对法规→风险评分→报告</div></div>
<div class="mod"><div class="mod-title">感知Worker</div><div class="mod-desc">法规变更检测→增量重索引</div></div>
<div class="mod"><div class="mod-title">推送Worker</div><div class="mod-desc">消息分发→Email/Bot/站内</div></div>
<div class="mod"><div class="mod-title">调度框架</div><div class="mod-desc">Celery + Cron定时<br>失败重试+死信队列</div></div>
</div>
</div>
</div>
<div class="conn-label">服务 ↔ 中间件 双向通信</div>
<div class="arrows"><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- 数据存储与中间件 -->
<div class="mid-grid">
<div class="mid-card" style="border-color:#148F77;background:#E0F7F3;"><div class="name" style="color:#0E6655">Milvus</div><div class="desc">向量数据库<br>Dense+Sparse+Hybrid</div></div>
<div class="mid-card" style="border-color:#000E5E;background:#D3E7F3;"><div class="name" style="color:#000E5E">PostgreSQL</div><div class="desc">关系数据库<br>元数据/权限/任务</div></div>
<div class="mid-card" style="border-color:#F26B43;background:#FEF0EB;"><div class="name" style="color:#D4532B">S3 / MinIO</div><div class="desc">对象存储<br>原始文件/解析产物</div></div>
<div class="mid-card" style="border-color:#7D3CB5;background:#F0E6F6;"><div class="name" style="color:#5B2C8B">Neo4j</div><div class="desc">图数据库<br>法规实体关系图谱</div></div>
<div class="mid-card" style="border-color:#E20074;background:#FDF2F7;"><div class="name" style="color:#B0005A">RabbitMQ</div><div class="desc">消息队列<br>异步任务分发</div></div>
<div class="mid-card" style="border-color:#32B9AF;background:#E0F7F5;"><div class="name" style="color:#1A8A82">Redis 7.x</div><div class="desc">缓存/会话<br>热数据/分布式锁</div></div>
</div>
<div class="conn-label">中间件 ↔ AI模型 调用链路</div>
<div class="arrows"><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- AI模型层 -->
<div class="ai-grid">
<div class="box c-teal">
<div class="box-header">嵌入模型</div>
<div class="box-body">
<div class="mod"><div class="mod-title">BGE-M3 (主模型)</div><div class="mod-desc">中英双语 100+语言</div></div>
<div class="mod"><div class="mod-title">bge-large-zh-v1.5</div><div class="mod-desc">中文专项嵌入</div></div>
<div class="mod"><div class="mod-title">多语言E5</div><div class="mod-desc">跨语言检索备选</div></div>
<div class="mod"><div class="mod-title">8192 token上下文</div><div class="mod-desc">长文档向量化支持</div></div>
</div>
</div>
<div class="box c-purple">
<div class="box-header">LLM大模型</div>
<div class="box-body">
<div class="mod"><div class="mod-title">DeepSeek-V3 / R1</div><div class="mod-desc">推理能力强, 国产开源</div></div>
<div class="mod"><div class="mod-title">Qwen2.5-72B</div><div class="mod-desc">中英双语, 合规场景优化</div></div>
<div class="mod"><div class="mod-title">本地私有化部署</div><div class="mod-desc">vLLM/TGI推理加速</div></div>
<div class="mod"><div class="mod-title">引文锚定生成</div><div class="mod-desc">输出含原文出处+页码</div></div>
</div>
</div>
<div class="box c-orange">
<div class="box-header">文档解析模型</div>
<div class="box-body">
<div class="mod"><div class="mod-title">MinerU (多模态)</div><div class="mod-desc">版面感知PDF解析</div></div>
<div class="mod"><div class="mod-title">阿里云文档解析</div><div class="mod-desc">云端高精度解析</div></div>
<div class="mod"><div class="mod-title">版面感知OCR</div><div class="mod-desc">109语言扫描件识别</div></div>
<div class="mod"><div class="mod-title">表格/图片识别</div><div class="mod-desc">复杂版面结构提取</div></div>
</div>
</div>
<div class="box c-blue">
<div class="box-header">专项模型</div>
<div class="box-body">
<div class="mod"><div class="mod-title">Cross-Encoder</div><div class="mod-desc">Reranker语义精排</div></div>
<div class="mod"><div class="mod-title">NLP实体抽取</div><div class="mod-desc">法规条款/隐患实体</div></div>
<div class="mod"><div class="mod-title">SIF风险评分</div><div class="mod-desc">高严重性事件潜力预测</div></div>
<div class="mod"><div class="mod-title">合规分类器</div><div class="mod-desc">法规域/文档类型分类</div></div>
</div>
</div>
</div>
<div class="conn-label">AI模型 ← 法规数据源 学习与检索</div>
<div class="arrows"><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
<!-- 法规数据源 -->
<div class="src-grid">
<div class="mid-card" style="border-color:#E20074;background:#FDF2F7;"><div class="name" style="color:#B0005A">车辆安全法规</div><div class="desc">GB 7258 · GB 18384<br>UN-ECE R155/156</div></div>
<div class="mid-card" style="border-color:#7D3CB5;background:#F0E6F6;"><div class="name" style="color:#5B2C8B">数据安全法规</div><div class="desc">PIPL · DSL · GDPR<br>GB/T 35273</div></div>
<div class="mid-card" style="border-color:#148F77;background:#E0F7F3;"><div class="name" style="color:#0E6655">工厂EHS法规</div><div class="desc">GB 6441 · AQ/T系列<br>ISO 45001 · IATF 16949</div></div>
<div class="mid-card" style="border-color:#32B9AF;background:#E0F7F5;"><div class="name" style="color:#1A8A82">碳排放法规</div><div class="desc">NEV积分 · CCER<br>CBAM碳边境税</div></div>
<div class="mid-card" style="border-color:#F26B43;background:#FEF0EB;"><div class="name" style="color:#D4532B">企业内部文档</div><div class="desc">Confluence · SharePoint<br>历史报告 · 审计记录</div></div>
<div class="mid-card" style="border-color:#000E5E;background:#D3E7F3;"><div class="name" style="color:#000E5E">行业案例库</div><div class="desc">处罚案例 · 事故通报<br>整改最佳实践</div></div>
</div>
<!-- 数据流 -->
<div class="flow-section">
<div class="flow-title">核心数据流 (Data Flows)</div>
<div class="flow-item"><div class="flow-num" style="background:#E20074;">1</div><b style="color:#E20074;">法规感知闭环:</b>&nbsp; 定时爬取法规源 → NLP变更感知(Diff) → 自动解析入库(MinerU+嵌入) → Milvus+PostgreSQL+Neo4j同步 → 差距分析 → 按角色推送 → 触发整改 ↺ 持续监控</div>
<div class="flow-item"><div class="flow-num">2</div><b>上传→解析→入库:</b>&nbsp; 用户上传 → API Gateway → kbmp-service → 队列 → Worker → mcp-server解析 → 文本切分 → BGE-M3嵌入 → Milvus+PostgreSQL写入</div>
<div class="flow-item"><div class="flow-num">3</div><b>检索→问答:</b>&nbsp; 用户提问 → 意图识别 → BM25+向量双路召回 → Cross-Encoder精排 → LLM生成(引文锚定) → 返回结果</div>
<div class="flow-item"><div class="flow-num">4</div><b>合规审查:</b>&nbsp; 文件上传 → OCR解析 → 条款级分块 → 法规域匹配 → 语义比对 → 风险评分 → 整改建议 → 报告生成</div>
<div class="flow-item"><div class="flow-num">5</div><b>EHS隐患:</b>&nbsp; 巡检文本NLP → 隐患实体抽取 → SIF风险评分 → 四维根因分析 → 整改工单 → 验收关闭 → 模型优化</div>
</div>
</div><!-- /main -->
<!-- ════════════════════ 右侧栏 ════════════════════ -->
<div class="sidebar">
<!-- 安全 -->
<div class="side-box c-gray">
<div class="side-header" style="background:linear-gradient(135deg,#000E5E,#1A2B6B);">🔐 安全与治理</div>
<div class="side-body">
<div class="side-item">Token鉴权 (JWT/OAuth2)</div>
<div class="side-item">RBAC角色权限矩阵</div>
<div class="side-item">知识库分区隔离</div>
<div class="side-item">敏感数据脱敏</div>
<div class="side-item">全链路审计日志</div>
<div class="side-item">PIPL/DSL数据主权</div>
<div class="side-item">私有化部署 (数据不出厂)</div>
<div class="side-item">WAF & DDoS防护</div>
<div class="side-item">TLS端到端加密</div>
</div>
</div>
<!-- 运维 -->
<div class="side-box c-blue">
<div class="side-header" style="background:linear-gradient(135deg,#1A8A82,#32B9AF);">📊 运维观测</div>
<div class="side-body">
<div class="side-item">Prometheus指标采集</div>
<div class="side-item">Grafana可视化面板</div>
<div class="side-item">Loki/ELK日志聚合</div>
<div class="side-item">分布式链路追踪</div>
<div class="side-item">告警规则引擎</div>
<div class="side-item">SLA可用性监控</div>
<div class="side-item">性能基线管理</div>
<div class="side-item">容量规划报表</div>
</div>
</div>
<!-- 基础设施 -->
<div class="side-box c-dark">
<div class="side-header">🏗️ 基础设施</div>
<div class="side-body">
<div class="side-item">Kubernetes容器编排</div>
<div class="side-item">Docker容器运行时</div>
<div class="side-item">GPU集群 (A100/H100)</div>
<div class="side-item">vLLM/TGI推理加速</div>
<div class="side-item">CI/CD流水线</div>
<div class="side-item">Nginx/Traefik网关</div>
<div class="side-item">VPN & 网络隔离</div>
<div class="side-item">数据备份 & 灾备</div>
</div>
</div>
<!-- 开源技术栈 -->
<div class="side-box" style="border-color:#000E5E;">
<div class="side-header" style="background:linear-gradient(135deg,#000E5E,#1A2B6B);">🔧 核心开源技术栈</div>
<div class="side-body">
<div class="side-item"><b>LangChain</b> RAG编排框架</div>
<div class="side-item"><b>LlamaIndex</b> 数据索引引擎</div>
<div class="side-item"><b>RAGFlow</b> 文档理解</div>
<div class="side-item"><b>BGE-M3</b> 多语言嵌入</div>
<div class="side-item"><b>MinerU</b> 文档解析OCR</div>
<div class="side-item"><b>Milvus</b> 向量数据库</div>
<div class="side-item"><b>Neo4j</b> 知识图谱</div>
<div class="side-item"><b>FastAPI</b> API框架</div>
<div class="side-item"><b>Celery</b> 异步任务队列</div>
<div class="side-item"><b>DeepSeek</b> 推理LLM</div>
<div class="side-item"><b>Qwen2.5</b> 双语LLM</div>
<div class="side-item"><b>PyMuPDF</b> PDF处理</div>
</div>
</div>
<!-- 合规闭环 -->
<div class="side-box" style="border-color:#E20074;">
<div class="side-header" style="background:linear-gradient(135deg,#B0005A,#E20074);">📡 法规感知自动更新闭环</div>
<div class="side-body">
<div class="side-item"><b>① 法规源监控</b></div>
<div class="side-item" style="padding-left:24px;">定时爬取国标网·工信部·UN-ECE</div>
<div class="side-item"><b>② 智能变更感知</b></div>
<div class="side-item" style="padding-left:24px;">NLP Diff · 新增/修订/废止识别</div>
<div class="side-item"><b>③ 自动解析入库</b></div>
<div class="side-item" style="padding-left:24px;">解析→分块→嵌入→Milvus+PG</div>
<div class="side-item"><b>④ 知识图谱同步</b></div>
<div class="side-item" style="padding-left:24px;">Neo4j关系更新·影响范围分析</div>
<div class="side-item"><b>⑤ 差距分析&推送</b></div>
<div class="side-item" style="padding-left:24px;">AI比对制度差距·按角色推送</div>
<div class="side-item"><b>⑥ 触发整改闭环 ↺</b></div>
<div class="side-item" style="padding-left:24px;">自动创建整改任务·闭环跟踪</div>
</div>
</div>
<div class="side-box" style="border-color:#F26B43;">
<div class="side-header" style="background:linear-gradient(135deg,#D4532B,#F26B43);">🔄 三类合规闭环</div>
<div class="side-body">
<div class="side-item"><b>法规变更闭环</b></div>
<div class="side-item" style="padding-left:24px;">监控→感知→更新→推送→整改→归档</div>
<div class="side-item"><b>文档审查闭环</b></div>
<div class="side-item" style="padding-left:24px;">上传→解析→比对→标注→整改→复审</div>
<div class="side-item"><b>EHS安全闭环</b></div>
<div class="side-item" style="padding-left:24px;">发现→评级→派发→跟踪→验收→优化</div>
</div>
</div>
</div><!-- /sidebar -->
</div><!-- /layout -->
<div class="footer">AI+合规智能中枢 v1.0 &nbsp;|&nbsp; T-systems AI Regulations Team &nbsp;|&nbsp; 2026.04</div>
</div>
</body>
</html>

Binary file not shown.

BIN
AI_Regulations_Report.pptx Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,535 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI + Compliance Hub - Compliance Analysis</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--primary: #e20074;
--accent: var(--primary);
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold", "TeleNeoWeb-Medium", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1600px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
--sidebar-w: 240px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
margin: 0;
font-family: var(--font-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-display);
}
p { margin: 0; text-wrap: pretty; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--fg); text-decoration: underline; }
button { font: inherit; }
/* ── Sidebar shell ── */
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); z-index: 10; }
.sidebar-brand { display: flex; align-items: center; gap: 10px; height: 56px; padding: 0 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.brand-logo { width: 26px; height: 26px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.brand-logo svg { color: #fff; }
.sidebar-brand-name { font-family: var(--font-display); font-size: 13px; font-weight: 700; line-height: 1.2; }
.sidebar-brand-sub { font-size: 10px; color: var(--muted); font-family: var(--font-mono); letter-spacing: 0.04em; }
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
.nav-group { padding: 0 8px 4px; }
.nav-group + .nav-group { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.nav-group-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 0 8px 6px; display: block; }
.nav-item { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; transition: background 140ms, color 140ms; position: relative; }
.nav-item:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); text-decoration: none; }
.nav-item.active { background: color-mix(in oklab, var(--accent), transparent 90%); color: var(--accent); font-weight: 600; }
.nav-item.active::before { content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
.nav-icon { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.7; }
.nav-item.active .nav-icon { opacity: 1; }
.sidebar-footer { border-top: 1px solid var(--border); padding: 10px 8px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.sidebar-user { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; }
.sidebar-user:hover { background: color-mix(in oklab, var(--fg), transparent 94%); }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sidebar-user-info { min-width: 0; }
.sidebar-user-name { font-size: 13px; font-weight: 600; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-user-role { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
.sidebar-action { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; border: none; background: transparent; width: 100%; text-align: left; transition: background 140ms, color 140ms; }
.sidebar-action:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); }
.content-area { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
.content-topbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border); background: color-mix(in oklab, var(--bg), transparent 4%); backdrop-filter: blur(10px); }
.topbar-title { font-weight: 600; font-size: 15px; color: var(--fg); flex: 1; }
.search { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 0 12px; height: 34px; width: 240px; }
.search input { border: 0; outline: none; background: transparent; width: 100%; color: var(--fg); font-size: 13px; }
.search input::placeholder { color: var(--muted); }
.footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 34px; padding: 0 24px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; }
.footer-status { display: inline-flex; align-items: center; gap: 8px; }
.footer-dot { width: 7px; height: 7px; border-radius: 50%; background: #19d3a2; box-shadow: 0 0 0 3px color-mix(in oklab, #19d3a2, transparent 82%); }
@media (max-width: 700px) { .app-shell { grid-template-columns: 1fr; } .sidebar { display: none; } }
/* ── Page / component styles ── */
.page {
max-width: 1600px;
margin: 0 auto;
padding: 24px;
display: grid;
gap: 20px;
}
.hero {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.eyebrow {
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.hero h1 { font-size: clamp(30px, 4vw, 44px); }
.hero p { color: var(--muted); max-width: 74ch; }
.btn {
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.workspace {
display: grid;
grid-template-columns: 0.95fr 1.25fr 0.9fr;
gap: 16px;
min-height: 760px;
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: 18px;
min-width: 0;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.section-head h2 { font-size: var(--text-xl); }
.helper {
color: var(--muted);
font-size: var(--text-sm);
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.source-list,
.finding-list,
.actions {
display: grid;
gap: 12px;
}
.source-item,
.finding,
.action-item,
.stage {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface), var(--bg) 14%);
padding: 14px;
}
.pill,
.status {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 4px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.status.ok { color: var(--success); }
.status.warn { color: color-mix(in oklab, var(--warn), black 24%); }
.status.risk { color: var(--danger); }
.paragraph {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 18px;
display: grid;
gap: 14px;
}
.paragraph mark {
background: color-mix(in oklab, var(--accent), white 80%);
color: inherit;
padding: 0 3px;
border-radius: 4px;
}
.stage-list {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.finding strong,
.source-item strong,
.action-item strong,
.stage strong {
display: block;
margin-bottom: 6px;
}
.score-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.progress {
height: 8px;
border-radius: 999px;
background: color-mix(in oklab, var(--fg), transparent 95%);
overflow: hidden;
margin-top: 10px;
}
.progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: color-mix(in oklab, var(--accent), white 28%);
}
.conclusion {
margin-top: 16px;
border-top: 1px solid var(--border);
padding-top: 16px;
display: grid;
gap: 12px;
}
.conclusion-box {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px;
background: color-mix(in oklab, var(--surface), var(--bg) 10%);
}
@media (max-width: 1280px) {
.workspace { grid-template-columns: 1fr; }
.hero { flex-direction: column; align-items: start; }
}
@media (max-width: 760px) {
.page { padding: 12px; }
}
</style>
</head>
<body data-page="analysis">
<div class="app-shell">
<aside class="sidebar" aria-label="Primary navigation">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
</svg>
</div>
<div>
<div class="sidebar-brand-name">T-Systems</div>
<div class="sidebar-brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<div class="nav-group">
<span class="nav-group-label">主导航</span>
<a class="nav-item" href="index.html"><svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h5v5H2zm7 0h5v5H9zM2 9h5v5H2zm7 0h5v5H9z" fill="currentColor" opacity=".7"/></svg>概览</a>
<a class="nav-item" href="dashboard.html"><svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M1.5 2.5h13v1H1.5zm0 3h13v1H1.5zm0 3h8v1h-8zm0 3h6v1h-6z" fill="currentColor"/></svg>系统状态</a>
</div>
<div class="nav-group">
<span class="nav-group-label">工作台</span>
<a class="nav-item" href="document-management.html"><svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M3 1h7l3 3v11H3V1zm1 1v12h8V5h-3V2H4zm5 .5V4h1.5L9 1.5zM6 7h4v1H6zm0 2h4v1H6zm0 2h3v1H6z" fill="currentColor"/></svg>文档管理</a>
<a class="nav-item active" href="compliance-analysis.html"><svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M8 1l7 3-1 6a7 7 0 01-6 5A7 7 0 011 10L0 4l8-3zm0 1.2L1.3 4.8l.8 5.1A6 6 0 008 14.8a6 6 0 005.9-4.9l.8-5.1L8 2.2zM7.5 5h1v4.5l-1 .5V5zm0 5.5h1v1h-1v-1z" fill="currentColor"/></svg>合规分析</a>
</div>
<div class="nav-group">
<span class="nav-group-label">对话</span>
<a class="nav-item" href="regulation-chat.html"><svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h12a1 1 0 011 1v8a1 1 0 01-1 1H5l-3 2.5V3a1 1 0 011-1zm0 1v9.5L4.5 11H14V3H2zm2 2h8v1H4zm0 2h6v1H4z" fill="currentColor"/></svg>法规对话</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar">TS</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">T-Systems User</div>
<div class="sidebar-user-role">Compliance Analyst</div>
</div>
</div>
<button class="sidebar-action od-theme-toggle" type="button" data-od-theme aria-label="Toggle theme">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3a5 5 0 100 10A5 5 0 008 3zM2 8a6 6 0 1112 0A6 6 0 012 8z" fill="currentColor"/></svg>
主题
</button>
</div>
</aside>
<div class="content-area">
<header class="content-topbar">
<div class="topbar-title">合规分析</div>
<div class="search">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M6.5 1a5.5 5.5 0 014.23 9.02l3.62 3.62-.7.71-3.63-3.63A5.5 5.5 0 116.5 1zm0 1a4.5 4.5 0 100 9 4.5 4.5 0 000-9z" fill="currentColor" opacity=".5"/></svg>
<input type="search" placeholder="Search findings…" aria-label="Search" />
</div>
<button class="btn btn-primary">New analysis</button>
</header>
<main class="page">
<section class="hero" data-od-id="analysis-hero">
<div>
<div class="eyebrow">Compliance analysis workspace</div>
<h1>From retrieved regulation to conclusion-ready action.</h1>
<p>This screen is built for a reviewer comparing one paragraph of product documentation against candidate regulations, system reasoning, and recommended changes. The rhythm is left-to-right: evidence retrieval, close reading, and decision output.</p>
</div>
<div style="display:flex; gap:12px; flex-wrap:wrap;">
<a class="btn" href="document-detail.html">Open parse detail</a>
<a class="btn btn-primary" href="regulation-chat.html">Ask follow-up in chat</a>
</div>
</section>
<section class="workspace" data-od-id="analysis-workspace">
<aside class="card">
<div class="section-head">
<h2>Retrieved regulations</h2>
<span class="helper">Top 10 by dense similarity</span>
</div>
<div class="source-list">
<div class="source-item">
<strong>GB 26112-2010 §4.2 Roof crush resistance</strong>
<div class="helper">Primary match · mandatory requirement · promoted as lead citation</div>
<div class="score-row">
<span class="pill">Vehicle safety</span>
<span class="status risk">High impact</span>
</div>
</div>
<div class="source-item">
<strong>C-NCAP rulebook §3.1 occupant safety context</strong>
<div class="helper">Supporting match · interpretation context · not mandatory on its own</div>
<div class="score-row">
<span class="pill">Assessment</span>
<span class="status warn">Context only</span>
</div>
</div>
<div class="source-item">
<strong>Internal design note: structure validation test plan</strong>
<div class="helper">Evidence match · useful support artifact with no direct legal force</div>
<div class="score-row">
<span class="pill">Evidence</span>
<span class="status ok">Supporting</span>
</div>
</div>
</div>
</aside>
<section class="card">
<div class="section-head">
<h2>Document paragraph under review</h2>
<span class="helper">Chunk 148 · supplier safety narrative</span>
</div>
<div class="paragraph">
<p><strong>Source text</strong></p>
<p>The roof support structure is designed to satisfy national crush-resistance requirements, and the module enclosure preserves occupant safety under static load conditions. Validation results are available in the body engineering report and are considered aligned with the current certification baseline.</p>
<p><strong>Analysis focus</strong></p>
<p>The system flags that the narrative claims compliance but omits the explicit load threshold. The phrase <mark>satisfy national crush-resistance requirements</mark> should be tied to a stated requirement derived from <mark>GB 26112-2010 §4.2</mark> to avoid unsupported compliance language.</p>
</div>
<div class="section-head" style="margin-top:18px;">
<h2>Analysis stages</h2>
<span class="helper">Streaming reasoning workflow</span>
</div>
<div class="stage-list">
<div class="stage">
<strong>1. Clause retrieval</strong>
<div class="helper">Dense retrieval found 10 nearby standards; 3 were promoted after category scoring.</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="stage">
<strong>2. Requirement extraction</strong>
<div class="helper">Relevant mandatory threshold identified and isolated for reviewer verification.</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="stage">
<strong>3. Gap analysis</strong>
<div class="helper">Document contains claim language but no direct numeric evidence or linked report identifier.</div>
<div class="progress"><span style="width:88%"></span></div>
</div>
<div class="stage">
<strong>4. Recommendation synthesis</strong>
<div class="helper">Drafting precise remediation text for reviewer approval.</div>
<div class="progress"><span style="width:62%"></span></div>
</div>
</div>
</section>
<aside class="card">
<div class="section-head">
<h2>Findings and conclusion</h2>
<span class="helper">Reviewer-ready output</span>
</div>
<div class="finding-list">
<div class="finding">
<strong>Unsupported compliance statement</strong>
<p class="helper">The paragraph asserts conformity without quoting the actual threshold or citing a specific verification artifact.</p>
<span class="status risk" style="margin-top:10px;">Needs revision</span>
</div>
<div class="finding">
<strong>Evidence linkage incomplete</strong>
<p class="helper">Body engineering report exists, but the report ID and page range are missing from the narrative used in the dossier.</p>
<span class="status warn" style="margin-top:10px;">Evidence gap</span>
</div>
</div>
<div class="conclusion">
<div class="conclusion-box">
<strong>Recommended replacement text</strong>
<p class="helper">"The roof support structure was validated in accordance with GB 26112-2010 §4.2; see the linked body engineering report for the supporting test result and formal threshold statement."</p>
</div>
<div class="actions">
<div class="action-item">
<strong>Next action</strong>
<p class="helper">Assign to Body Structure team for wording update, then rerun the same paragraph through clause verification.</p>
</div>
<div class="action-item">
<strong>Escalation</strong>
<p class="helper">If report ID cannot be linked within 24 hours, downgrade dossier status and notify homologation lead.</p>
</div>
</div>
</div>
</aside>
</section>
</main>
<footer class="footer">
<span>T-Systems Regulation Hub</span>
<div class="footer-status"><span class="footer-dot" aria-hidden="true"></span><span>Online</span></div>
</footer>
</div>
</div>
<script src="ui-preferences.js"></script>
</body>
</html>

View File

@@ -0,0 +1,808 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI + Compliance Hub - Dashboard</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--primary: #e20074;
--accent: var(--primary);
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #d97706;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold", "TeleNeoWeb-Medium", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--leading-body: 1.55;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 140ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--sidebar-w: 240px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1014;
--surface: #17181d;
--fg: #f2f4f8;
--fg-2: #e2e6ef;
--muted: #9aa2b0;
--border: #252830;
--border-soft: #1e2028;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 72%);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1014; --surface: #17181d; --fg: #f2f4f8; --fg-2: #e2e6ef;
--muted: #9aa2b0; --border: #252830; --border-soft: #1e2028;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e; --warn: #facc15; --danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 72%);
color-scheme: dark;
}
:root[data-theme="light"] { color-scheme: light; }
* { box-sizing: border-box; margin: 0; }
html { -webkit-text-size-adjust: 100%; height: 100%; }
body {
height: 100%;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
h1,h2,h3,h4 {
font-family: var(--font-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-display);
text-wrap: balance;
}
p { text-wrap: pretty; }
a { color: inherit; text-decoration: none; }
a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; }
/* ── App chrome ─────────────────────────────── */
.app-shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
min-height: 100vh;
}
/* ── Sidebar ────────────────────────────────── */
.sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 0;
z-index: 10;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
height: 56px;
padding: 0 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.brand-logo {
width: 26px;
height: 26px;
background: var(--accent);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-logo svg { color: #fff; }
.sidebar-brand-name {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 700;
line-height: 1.2;
color: var(--fg);
}
.sidebar-brand-sub {
font-size: 10px;
color: var(--muted);
font-family: var(--font-mono);
letter-spacing: 0.04em;
}
.sidebar-nav {
flex: 1;
padding: 12px 0;
overflow-y: auto;
}
.nav-group {
padding: 0 8px 4px;
}
.nav-group + .nav-group {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.nav-group-label {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
padding: 0 8px 6px;
display: block;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
height: 36px;
padding: 0 8px;
border-radius: var(--radius-sm);
color: var(--muted);
font-size: var(--text-sm);
cursor: pointer;
transition: background var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
position: relative;
}
.nav-item:hover {
background: color-mix(in oklab, var(--fg), transparent 94%);
color: var(--fg);
text-decoration: none;
}
.nav-item.active {
background: color-mix(in oklab, var(--accent), transparent 90%);
color: var(--accent);
font-weight: 600;
}
.nav-item.active::before {
content: "";
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--accent);
}
.nav-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.7;
}
.nav-item.active .nav-icon { opacity: 1; }
.nav-badge {
margin-left: auto;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--accent), transparent 84%);
color: var(--accent);
font-size: 10px;
font-family: var(--font-mono);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.sidebar-footer {
border-top: 1px solid var(--border);
padding: 10px 8px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.sidebar-user:hover { background: color-mix(in oklab, var(--fg), transparent 94%); }
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sidebar-user-info { min-width: 0; }
.sidebar-user-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-user-role {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
}
.sidebar-action {
display: flex;
align-items: center;
gap: 10px;
height: 34px;
padding: 0 8px;
border-radius: var(--radius-sm);
color: var(--muted);
font-size: var(--text-sm);
cursor: pointer;
border: none;
background: transparent;
width: 100%;
text-align: left;
transition: background var(--motion-fast), color var(--motion-fast);
}
.sidebar-action:hover {
background: color-mix(in oklab, var(--fg), transparent 94%);
color: var(--fg);
}
/* ── Content area ─────────────────────────── */
.content-area {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 100vh;
}
.content-topbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 24px;
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--bg), transparent 4%);
backdrop-filter: blur(10px);
}
.topbar-title {
font-weight: 600;
font-size: var(--text-base);
color: var(--fg);
flex: 1;
}
.search {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 0 12px;
height: 34px;
width: 260px;
}
.search input {
border: 0; outline: none; background: transparent;
width: 100%; color: var(--fg); font-size: var(--text-sm);
}
.search input::placeholder { color: var(--muted); }
.btn {
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
padding: 0 14px;
background: var(--surface);
color: var(--fg);
font-size: var(--text-sm);
cursor: pointer;
transition: background var(--motion-fast), border-color var(--motion-fast);
white-space: nowrap;
}
.btn:hover { border-color: var(--fg); }
.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: var(--accent-on);
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* ── Page ─────────────────────────────────── */
.page {
padding: 24px;
display: grid;
gap: 20px;
flex: 1;
}
.page-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.page-head h1 { font-size: clamp(22px, 3vw, 32px); }
.page-head p { color: var(--muted); max-width: 68ch; font-size: var(--text-sm); }
/* ── Cards ────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 18px 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0,1fr));
gap: 14px;
}
.stat-card .label {
color: var(--muted); font-size: var(--text-xs);
font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em;
}
.stat-card .value {
margin-top: 12px; font-size: 32px; line-height: 1;
font-family: var(--font-display); font-variant-numeric: tabular-nums;
}
.stat-card .sub {
margin-top: 10px; color: var(--muted); font-size: var(--text-xs); line-height: 1.5;
}
.panel-grid {
display: grid;
grid-template-columns: 1.4fr 0.9fr;
gap: 20px;
}
.stack { display: grid; gap: 20px; }
.section-head {
display: flex; align-items: center;
justify-content: space-between; gap: 12px; margin-bottom: 14px;
}
.section-head h2 { font-size: var(--text-lg); }
.ghost-link {
color: var(--muted); font-size: var(--text-sm);
border-radius: var(--radius-sm); padding: 4px 0;
transition: color var(--motion-fast);
}
.ghost-link:hover { color: var(--fg); text-decoration: none; }
/* ── Data rows ─────────────────────────────── */
.task-list, .program-list, .event-list { display: grid; gap: 10px; }
.task-row, .program-row, .event-row {
display: grid; gap: 10px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 12px 14px;
background: color-mix(in oklab, var(--surface), var(--bg) 20%);
}
.task-row { grid-template-columns: 1.5fr 0.9fr 0.9fr 0.7fr; align-items: center; }
.program-row { grid-template-columns: 1fr auto; align-items: start; }
.event-row { grid-template-columns: 90px 1fr; align-items: start; }
.kpi-strip { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-top: 14px; }
.kpi {
border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 12px; background: color-mix(in oklab, var(--surface), var(--bg) 18%);
}
.kpi strong { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 18px; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.status {
display: inline-flex; align-items: center; gap: 6px;
width: fit-content; padding: 3px 9px; border-radius: var(--radius-pill);
font-size: var(--text-xs); border: 1px solid var(--border); font-family: var(--font-mono);
}
.status::before {
content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor;
}
.status.ok { color: var(--success); }
.status.warn { color: var(--warn); }
.status.risk { color: var(--danger); }
.meter {
height: 6px; border-radius: 999px;
background: color-mix(in oklab, var(--fg), transparent 94%);
overflow: hidden; margin-top: 10px;
}
.meter > span {
display: block; height: 100%;
background: color-mix(in oklab, var(--accent), white 30%);
border-radius: inherit;
}
.pill {
display: inline-flex; align-items: center; justify-content: center;
height: 20px; padding: 0 7px; border-radius: var(--radius-pill);
border: 1px solid var(--border); color: var(--muted);
font-size: var(--text-xs); font-family: var(--font-mono);
}
.footer-note { color: var(--muted); font-size: var(--text-xs); line-height: 1.5; }
/* ── Footer ───────────────────────────────── */
.footer {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; min-height: 34px; padding: 0 24px;
border-top: 1px solid var(--border);
color: var(--muted); font-size: var(--text-xs);
font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase;
background: color-mix(in oklab, var(--bg), var(--surface) 12%);
}
.footer-status { display: inline-flex; align-items: center; gap: 8px; }
.footer-dot {
width: 7px; height: 7px; border-radius: 50%; background: #19d3a2;
box-shadow: 0 0 0 3px color-mix(in oklab, #19d3a2, transparent 82%);
}
/* ── Responsive ───────────────────────────── */
@media (max-width: 1180px) {
.stats-grid, .panel-grid, .kpi-strip { grid-template-columns: 1fr 1fr; }
.task-row { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
:root { --sidebar-w: 200px; }
}
@media (max-width: 700px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.stats-grid, .panel-grid, .kpi-strip { grid-template-columns: 1fr; }
.page-head { flex-direction: column; align-items: start; }
.event-row, .program-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body data-page="dashboard">
<div class="app-shell">
<!-- ── Sidebar ── -->
<aside class="sidebar" aria-label="Primary navigation">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
</svg>
</div>
<div>
<div class="sidebar-brand-name">T-Systems</div>
<div class="sidebar-brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<div class="nav-group">
<span class="nav-group-label">主导航</span>
<a class="nav-item" href="index.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 2h5v5H2zm7 0h5v5H9zM2 9h5v5H2zm7 0h5v5H9z" fill="currentColor" opacity=".7"/>
</svg>
概览
</a>
<a class="nav-item active" href="dashboard.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M1.5 2.5h13v1H1.5zm0 3h13v1H1.5zm0 3h8v1h-8zm0 3h6v1h-6zM12 8.5a3.5 3.5 0 110 7 3.5 3.5 0 010-7zm0 1a2.5 2.5 0 100 5 2.5 2.5 0 000-5zm.5 1v2h1.5v1H11v-3h1.5z" fill="currentColor"/>
</svg>
系统状态
<span class="nav-badge">3</span>
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">工作台</span>
<a class="nav-item" href="document-management.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 1h7l3 3v11H3V1zm1 1v12h8V5h-3V2H4zm5 .5V4h1.5L9 1.5zM6 7h4v1H6zm0 2h4v1H6zm0 2h3v1H6z" fill="currentColor"/>
</svg>
文档管理
</a>
<a class="nav-item" href="compliance-analysis.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 1l7 3-1 6a7 7 0 01-6 5A7 7 0 011 10L0 4l8-3zm0 1.2L1.3 4.8l.8 5.1A6 6 0 008 14.8a6 6 0 005.9-4.9l.8-5.1L8 2.2zM7.5 5h1v4.5l-1 .5V5zm0 5.5h1v1h-1v-1z" fill="currentColor"/>
</svg>
合规分析
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">对话</span>
<a class="nav-item" href="regulation-chat.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 2h12a1 1 0 011 1v8a1 1 0 01-1 1H5l-3 2.5V3a1 1 0 011-1zm0 1v9.5L4.5 11H14V3H2zm2 2h8v1H4zm0 2h6v1H4z" fill="currentColor"/>
</svg>
法规对话
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar">TS</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">T-Systems User</div>
<div class="sidebar-user-role">Compliance Analyst</div>
</div>
</div>
<button class="sidebar-action od-theme-toggle" type="button" data-od-theme aria-label="Toggle theme">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 3a5 5 0 100 10A5 5 0 008 3zM2 8a6 6 0 1112 0A6 6 0 012 8z" fill="currentColor"/>
</svg>
主题
</button>
</div>
</aside>
<!-- ── Content area ── -->
<div class="content-area">
<header class="content-topbar">
<div class="topbar-title">系统状态</div>
<div class="search">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6.5 1a5.5 5.5 0 014.23 9.02l3.62 3.62-.7.71-3.63-3.63A5.5 5.5 0 116.5 1zm0 1a4.5 4.5 0 100 9 4.5 4.5 0 000-9z" fill="currentColor" opacity=".5"/>
</svg>
<input type="search" placeholder="Search regulations, documents…" aria-label="Search" />
</div>
<button class="btn">Export status</button>
<a class="btn btn-primary" href="upload-modal.html">New upload</a>
</header>
<main class="page">
<section class="page-head" data-od-id="dashboard-head">
<div>
<div class="eyebrow">Operations dashboard</div>
<h1>Track ingestion health and active compliance work.</h1>
<p style="margin-top:6px;">Designed for the team lead who needs to know which documents are blocked, which standards changed, and which program teams need intervention today.</p>
</div>
<span class="pill">v1.0.0</span>
</section>
<section class="stats-grid" data-od-id="dashboard-stats">
<article class="card stat-card">
<div class="label">Documents total</div>
<div class="value mono">--</div>
<div class="sub">Live document totals populate here from the operations snapshot.</div>
</article>
<article class="card stat-card">
<div class="label">Vector chunks</div>
<div class="value mono">--</div>
<div class="sub">Dense collection `regulations_dense_1024_v2` currently serving retrieval</div>
</article>
<article class="card stat-card">
<div class="label">Analysis backlog</div>
<div class="value mono">--</div>
<div class="sub">Open investigations, reviewer backlog, and blocked runs roll up here.</div>
</article>
<article class="card stat-card">
<div class="label">Average ingest time</div>
<div class="value mono">--</div>
<div class="sub">Aliyun parse plus embedding latency trends appear here once runs are active.</div>
</article>
</section>
<section class="panel-grid" data-od-id="dashboard-main">
<div class="stack">
<article class="card">
<div class="section-head">
<h2>Workflow queue</h2>
<a class="ghost-link" href="document-management.html">Open documents →</a>
</div>
<div class="task-list">
<div class="task-row">
<div>
<strong>GB/T 31484-2015 battery density revision</strong>
<div class="footer-note">Uploaded by EV Safety Team · version 2026-04 addendum</div>
</div>
<span class="status warn">Embedding</span>
<span class="mono" style="font-size:12px;">chunk build active</span>
<a class="ghost-link" href="document-detail.html">Inspect</a>
</div>
<div class="task-row">
<div>
<strong>UNECE R155 annex interpretation note</strong>
<div class="footer-note">Parser artifacts ready · waiting for compliance analyst assignment</div>
</div>
<span class="status ok">Ready</span>
<span class="mono" style="font-size:12px;">19 clauses linked</span>
<a class="ghost-link" href="compliance-analysis.html">Analyze</a>
</div>
<div class="task-row">
<div>
<strong>GB 26112-2010 roof strength scan</strong>
<div class="footer-note">OCR confidence dropped below threshold on 6 pages</div>
</div>
<span class="status risk">Failed</span>
<span class="mono" style="font-size:12px;">Retry #2</span>
<a class="ghost-link" href="document-management.html">Resolve</a>
</div>
</div>
</article>
<article class="card">
<div class="section-head">
<h2>Active compliance programs</h2>
<a class="ghost-link" href="compliance-analysis.html">Review findings →</a>
</div>
<div class="program-list">
<div class="program-row">
<div>
<strong>Intelligent cockpit homologation</strong>
<p class="footer-note">42 related standards across driver monitoring, EMC, and child safety. Four findings still open for MY27 platform.</p>
</div>
<span class="status risk">High risk</span>
</div>
<div class="program-row">
<div>
<strong>Battery swap certification dossier</strong>
<p class="footer-note">Clause mapping complete. Thermal event test evidence package still awaiting supplier document refresh.</p>
</div>
<span class="status warn">Pending</span>
</div>
<div class="program-row">
<div>
<strong>Connected fleet cybersecurity</strong>
<p class="footer-note">RAG checks aligned with UNECE R155. Chat follow-up requested on remote key rotation obligations.</p>
</div>
<span class="status ok">On track</span>
</div>
</div>
<div class="kpi-strip">
<div class="kpi">
<div class="footer-note">Retrieval hit rate</div>
<strong>87%</strong>
<div class="meter"><span style="width:87%"></span></div>
</div>
<div class="kpi">
<div class="footer-note">Evidence coverage</div>
<strong>72%</strong>
<div class="meter"><span style="width:72%"></span></div>
</div>
<div class="kpi">
<div class="footer-note">Reviewer SLA</div>
<strong>18h</strong>
<div class="meter"><span style="width:64%"></span></div>
</div>
</div>
</article>
</div>
<div class="stack">
<article class="card">
<div class="section-head">
<h2>System health</h2>
<a class="ghost-link" href="#">Refresh</a>
</div>
<div class="task-list">
<div class="task-row" style="grid-template-columns: 1fr auto;">
<div>
<strong>Aliyun parser backend</strong>
<div class="footer-note">Poll interval 5 s · timeout 900 s</div>
</div>
<span class="status warn">Queue depth 7</span>
</div>
<div class="task-row" style="grid-template-columns: 1fr auto;">
<div>
<strong>Embedding model</strong>
<div class="footer-note">text-embedding-v3 · dimension 1024</div>
</div>
<span class="status ok">Healthy</span>
</div>
<div class="task-row" style="grid-template-columns: 1fr auto;">
<div>
<strong>Vector store</strong>
<div class="footer-note">Milvus `regulations_dense_1024_v2`</div>
</div>
<span class="status ok">Serving</span>
</div>
</div>
</article>
<article class="card">
<div class="section-head">
<h2>Regulatory watch</h2>
<a class="ghost-link" href="regulation-chat.html">Ask chat →</a>
</div>
<div class="event-list">
<div class="event-row">
<span class="mono footer-note">Recent</span>
<div>
<strong>GB 38031 thermal propagation draft updated</strong>
<p class="footer-note">Potential impact on current battery enclosure narrative. Evidence gap flagged in two supplier submissions.</p>
</div>
</div>
<div class="event-row">
<span class="mono footer-note">Recent</span>
<div>
<strong>UNECE R155 Q&amp;A added note on incident response logs</strong>
<p class="footer-note">Connected fleet program must confirm retention windows and ownership controls.</p>
</div>
</div>
<div class="event-row">
<span class="mono footer-note">Recent</span>
<div>
<strong>GB/T 18487 charging interface interpretation circulated</strong>
<p class="footer-note">No blocker yet, but three documents should be re-run against the new clause wording.</p>
</div>
</div>
</div>
</article>
</div>
</section>
</main>
<footer class="footer">
<span>T-Systems Regulation Hub</span>
<div class="footer-status">
<span class="footer-dot" aria-hidden="true"></span>
<span>Online</span>
</div>
</footer>
</div>
</div>
<script src="ui-preferences.js"></script>
</body>
</html>

View File

@@ -0,0 +1,623 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI + Compliance Hub - Document Detail</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--primary: #e20074;
--accent: var(--primary);
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold", "TeleNeoWeb-Medium", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1440px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
--sidebar-w: 240px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
margin: 0;
font-family: var(--font-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-display);
}
p { margin: 0; text-wrap: pretty; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--fg); text-decoration: underline; }
button { font: inherit; }
/* ── Sidebar shell ── */
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); z-index: 10; }
.sidebar-brand { display: flex; align-items: center; gap: 10px; height: 56px; padding: 0 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.brand-logo { width: 26px; height: 26px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.brand-logo svg { color: #fff; }
.sidebar-brand-name { font-family: var(--font-display); font-size: 13px; font-weight: 700; line-height: 1.2; }
.sidebar-brand-sub { font-size: 10px; color: var(--muted); font-family: var(--font-mono); letter-spacing: 0.04em; }
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
.nav-group { padding: 0 8px 4px; }
.nav-group + .nav-group { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.nav-group-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 0 8px 6px; display: block; }
.nav-item { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; transition: background 140ms, color 140ms; position: relative; }
.nav-item:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); text-decoration: none; }
.nav-item.active { background: color-mix(in oklab, var(--accent), transparent 90%); color: var(--accent); font-weight: 600; }
.nav-item.active::before { content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
.nav-icon { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.7; }
.nav-item.active .nav-icon { opacity: 1; }
.sidebar-footer { border-top: 1px solid var(--border); padding: 10px 8px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.sidebar-user { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; }
.sidebar-user:hover { background: color-mix(in oklab, var(--fg), transparent 94%); }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sidebar-user-info { min-width: 0; }
.sidebar-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-user-role { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
.sidebar-action { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; border: none; background: transparent; width: 100%; text-align: left; transition: background 140ms, color 140ms; }
.sidebar-action:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); }
.content-area { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
.content-topbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border); background: color-mix(in oklab, var(--bg), transparent 4%); backdrop-filter: blur(10px); }
.topbar-title { font-weight: 600; font-size: 15px; color: var(--fg); flex: 1; }
.footer-dot { width: 7px; height: 7px; border-radius: 50%; background: #19d3a2; box-shadow: 0 0 0 3px color-mix(in oklab, #19d3a2, transparent 82%); }
.footer-status { display: inline-flex; align-items: center; gap: 8px; }
@media (max-width: 700px) { .app-shell { grid-template-columns: 1fr; } .sidebar { display: none; } }
/* ── Page-specific styles ── */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 36px;
padding: 0 24px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-xs);
font-family: var(--font-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
background: color-mix(in oklab, var(--bg), var(--surface) 12%);
}
.page {
max-width: 1440px;
margin: 0 auto;
padding: 24px;
display: grid;
gap: 20px;
}
.topline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
min-height: 56px;
}
.back { color: var(--muted); font-size: var(--text-sm); }
.eyebrow {
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.hero {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
}
.hero h1 { font-size: clamp(30px, 4vw, 46px); }
.hero p { max-width: 72ch; color: var(--muted); }
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip,
.status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.status.ok { color: var(--success); }
.status.warn { color: color-mix(in oklab, var(--warn), black 24%); }
.status.risk { color: var(--danger); }
.grid {
display: grid;
grid-template-columns: 0.9fr 1.4fr;
gap: 20px;
}
.stack {
display: grid;
gap: 20px;
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: 18px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-head h2 { font-size: var(--text-xl); }
.helper {
color: var(--muted);
font-size: var(--text-sm);
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.timeline {
display: grid;
gap: 14px;
}
.step {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 14px 14px 16px;
background: color-mix(in oklab, var(--surface), var(--bg) 14%);
position: relative;
}
.step::before {
content: "";
position: absolute;
left: -1px;
top: 0;
bottom: 0;
width: 3px;
border-radius: 3px 0 0 3px;
background: var(--border);
}
.step.active::before { background: var(--accent); }
.step.done::before { background: var(--success); }
.step.fail::before { background: var(--danger); }
.progress {
height: 8px;
border-radius: 999px;
background: color-mix(in oklab, var(--fg), transparent 95%);
overflow: hidden;
margin-top: 10px;
}
.progress > span {
display: block;
height: 100%;
background: color-mix(in oklab, var(--accent), white 28%);
border-radius: inherit;
}
.artifact-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.artifact {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px;
background: color-mix(in oklab, var(--surface), var(--bg) 16%);
}
.artifact strong { display: block; margin-bottom: 6px; }
.table {
display: grid;
gap: 10px;
}
.table-row {
display: grid;
grid-template-columns: 1.1fr 0.9fr 0.9fr 0.9fr;
gap: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 14px;
background: color-mix(in oklab, var(--surface), var(--bg) 12%);
}
.log {
display: grid;
gap: 10px;
}
.log-item {
display: grid;
grid-template-columns: 90px 1fr;
gap: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
@media (max-width: 1100px) {
.grid,
.artifact-grid,
.table-row { grid-template-columns: 1fr; }
.hero { flex-direction: column; align-items: start; }
}
@media (max-width: 760px) {
.page { padding: 12px; }
.topline { align-items: start; flex-direction: column; }
.log-item { grid-template-columns: 1fr; }
}
</style>
</head>
<body data-page="detail">
<div class="app-shell">
<aside class="sidebar" aria-label="Primary navigation">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
</svg>
</div>
<div>
<div class="sidebar-brand-name">T-Systems</div>
<div class="sidebar-brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<div class="nav-group">
<span class="nav-group-label">主导航</span>
<a class="nav-item" href="index.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h5v5H2zm7 0h5v5H9zM2 9h5v5H2zm7 0h5v5H9z" fill="currentColor" opacity=".7"/></svg>
概览
</a>
<a class="nav-item" href="dashboard.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M1.5 2.5h13v1H1.5zm0 3h13v1H1.5zm0 3h8v1h-8zm0 3h6v1h-6z" fill="currentColor"/></svg>
系统状态
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">工作台</span>
<a class="nav-item active" href="document-management.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M3 1h7l3 3v11H3V1zm1 1v12h8V5h-3V2H4zm5 .5V4h1.5L9 1.5zM6 7h4v1H6zm0 2h4v1H6zm0 2h3v1H6z" fill="currentColor"/></svg>
文档管理
</a>
<a class="nav-item" href="compliance-analysis.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M8 1l7 3-1 6a7 7 0 01-6 5A7 7 0 011 10L0 4l8-3zm0 1.2L1.3 4.8l.8 5.1A6 6 0 008 14.8a6 6 0 005.9-4.9l.8-5.1L8 2.2zM7.5 5h1v4.5l-1 .5V5zm0 5.5h1v1h-1v-1z" fill="currentColor"/></svg>
合规分析
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">对话</span>
<a class="nav-item" href="regulation-chat.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h12a1 1 0 011 1v8a1 1 0 01-1 1H5l-3 2.5V3a1 1 0 011-1zm0 1v9.5L4.5 11H14V3H2zm2 2h8v1H4zm0 2h6v1H4z" fill="currentColor"/></svg>
法规对话
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar">TS</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">T-Systems User</div>
<div class="sidebar-user-role">Compliance Analyst</div>
</div>
</div>
<button class="sidebar-action od-theme-toggle" type="button" data-od-theme aria-label="Toggle theme">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3a5 5 0 100 10A5 5 0 008 3zM2 8a6 6 0 1112 0A6 6 0 012 8z" fill="currentColor"/></svg>
主题
</button>
</div>
</aside>
<div class="content-area">
<header class="content-topbar">
<span class="topbar-title">文档解析详情</span>
</header>
<main class="page">
<section class="topline" data-od-id="detail-top">
<a class="back" href="document-management.html">← Back to document management</a>
<div class="meta-row">
<span class="chip">Battery safety</span>
<span class="chip mono">doc_id: GBT-31484-2015-r2</span>
<span class="status warn">Embedding in progress</span>
</div>
</section>
<section class="hero" data-od-id="detail-hero">
<div>
<div class="eyebrow">Document detail</div>
<h1>Trace parse artifacts from raw upload to vector index.</h1>
<p>This view is for operators diagnosing why one document is delayed or degraded. It surfaces parser settings, semantic structure, chunk generation, and Milvus insertion as separate observable stages.</p>
</div>
<div class="card" style="min-width:280px;">
<div class="helper">Current run</div>
<h2 style="font-size:24px; margin-top:8px;">Battery density addendum review</h2>
<div class="helper" style="margin-top:8px;">Uploaded 09:14 by Battery Safety Team · parser backend `aliyun`</div>
</div>
</section>
<section class="grid" data-od-id="detail-main">
<div class="stack">
<article class="card">
<div class="section-head">
<h2>Pipeline progression</h2>
<span class="helper">Live state</span>
</div>
<div class="timeline">
<div class="step done">
<strong>1. Object storage ingestion</strong>
<div class="helper">Stored in bucket `upload-files` with artifact prefix `artifacts`</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="step done">
<strong>2. Aliyun parse layout extraction</strong>
<div class="helper">Parsed pages, recovered tables, and OCR confidence summarize here once the run completes.</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="step done">
<strong>3. Semantic blocks</strong>
<div class="helper">Semantic block persistence is tracked here after parse artifact storage completes.</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="step active">
<strong>4. Vector chunk build</strong>
<div class="helper">Using overlap chunking with header-prefixed embedding text</div>
<div class="progress"><span style="width:76%"></span></div>
</div>
<div class="step">
<strong>5. Embedding generation</strong>
<div class="helper">Target model `text-embedding-v3` · dimension 1024</div>
<div class="progress"><span style="width:38%"></span></div>
</div>
<div class="step">
<strong>6. Milvus insertion</strong>
<div class="helper">Waiting for chunk vectors before collection sync</div>
<div class="progress"><span style="width:8%"></span></div>
</div>
</div>
</article>
<article class="card">
<div class="section-head">
<h2>Run log</h2>
<span class="helper">Recent events</span>
</div>
<div class="log">
<div class="log-item">
<span class="mono helper">09:18:11</span>
<div>
<strong>Semantic block serialization completed</strong>
<div class="helper">Stored block tree and section hierarchy in Postgres parse artifact store.</div>
</div>
</div>
<div class="log-item">
<span class="mono helper">09:20:44</span>
<div>
<strong>Chunk builder emitted overlap windows</strong>
<div class="helper">Header context is prepended to vector chunks for downstream retrieval quality.</div>
</div>
</div>
<div class="log-item">
<span class="mono helper">09:22:08</span>
<div>
<strong>Embedding worker rate-limited temporarily</strong>
<div class="helper">Retry budget still healthy. No manual action required unless latency exceeds 15 minutes.</div>
</div>
</div>
</div>
</article>
</div>
<div class="stack">
<article class="card">
<div class="section-head">
<h2>Artifacts generated</h2>
<span class="helper">Output layers</span>
</div>
<div class="artifact-grid">
<div class="artifact">
<strong>Layout JSON</strong>
<span class="helper">Page, table, and text-span counts populate from the parser artifact output.</span>
</div>
<div class="artifact">
<strong>Semantic blocks</strong>
<span class="helper">Semantic nodes are mapped into chapter and clause hierarchy here.</span>
</div>
<div class="artifact">
<strong>Vector chunks</strong>
<span class="helper">Overlap windows and embedding text populate after chunk generation.</span>
</div>
</div>
</article>
<article class="card">
<div class="section-head">
<h2>Chunk profile</h2>
<span class="helper">Top segments</span>
</div>
<div class="table">
<div class="table-row">
<div>
<strong>4.2 Energy density threshold</strong>
<div class="helper">Critical requirement clause</div>
</div>
<span class="mono">chunk count pending</span>
<span class="mono">character count pending</span>
<span class="status ok">Linked</span>
</div>
<div class="table-row">
<div>
<strong>5.1 Thermal event test method</strong>
<div class="helper">Supplier evidence cross-reference</div>
</div>
<span class="mono">chunk count pending</span>
<span class="mono">character count pending</span>
<span class="status warn">Review</span>
</div>
<div class="table-row">
<div>
<strong>Appendix A formulas and tables</strong>
<div class="helper">Dense table extraction from scan</div>
</div>
<span class="mono">chunk count pending</span>
<span class="mono">character count pending</span>
<span class="status warn">Noisy</span>
</div>
</div>
</article>
<article class="card">
<div class="section-head">
<h2>Configuration snapshot</h2>
<span class="helper">Runtime values</span>
</div>
<div class="table">
<div class="table-row">
<div><strong>Parser backend</strong><div class="helper">Document extraction engine</div></div>
<span class="mono">aliyun</span>
<span class="mono">5 s poll</span>
<span class="mono">900 s timeout</span>
</div>
<div class="table-row">
<div><strong>Embedding target</strong><div class="helper">Vector generation</div></div>
<span class="mono">text-embedding-v3</span>
<span class="mono">1024 dim</span>
<span class="mono">top_k 10</span>
</div>
<div class="table-row">
<div><strong>Collection</strong><div class="helper">Milvus destination</div></div>
<span class="mono">regulations_dense_1024_v2</span>
<span class="mono">dense-only</span>
<span class="mono">ready</span>
</div>
</div>
</article>
</div>
</section>
</main>
<footer class="footer">
<span>T-Systems Regulation</span>
<div class="footer-status">
<span>Desktop Web</span>
<span class="footer-dot" aria-hidden="true"></span>
<span>Online</span>
</div>
</footer>
</div>
</div>
<script src="ui-preferences.js"></script>
</body>
</html>

View File

@@ -0,0 +1,693 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI + Compliance Hub - Document Management</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--primary: #e20074;
--accent: var(--primary);
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold", "TeleNeoWeb-Medium", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1440px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
--sidebar-w: 240px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
margin: 0;
font-family: var(--font-display);
letter-spacing: var(--tracking-display);
line-height: var(--leading-tight);
}
p { margin: 0; text-wrap: pretty; }
button, input, select { font: inherit; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--fg); text-decoration: underline; }
/* ── App shell ── */
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
/* ── Sidebar ── */
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); z-index: 10; }
.sidebar-brand { display: flex; align-items: center; gap: 10px; height: 56px; padding: 0 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.brand-logo { width: 26px; height: 26px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.brand-logo svg { color: #fff; }
.sidebar-brand-name { font-family: var(--font-display); font-size: 13px; font-weight: 700; line-height: 1.2; }
.sidebar-brand-sub { font-size: 10px; color: var(--muted); font-family: var(--font-mono); letter-spacing: 0.04em; }
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
.nav-group { padding: 0 8px 4px; }
.nav-group + .nav-group { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.nav-group-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 0 8px 6px; display: block; }
.nav-item { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; transition: background 140ms, color 140ms; position: relative; }
.nav-item:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); text-decoration: none; }
.nav-item.active { background: color-mix(in oklab, var(--accent), transparent 90%); color: var(--accent); font-weight: 600; }
.nav-item.active::before { content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
.nav-icon { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.7; }
.nav-item.active .nav-icon { opacity: 1; }
.nav-badge { margin-left: auto; min-width: 20px; height: 18px; padding: 0 6px; border-radius: 9999px; background: color-mix(in oklab, var(--accent), transparent 84%); color: var(--accent); font-size: 10px; font-family: var(--font-mono); display: flex; align-items: center; justify-content: center; font-weight: 700; }
.sidebar-footer { border-top: 1px solid var(--border); padding: 10px 8px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.sidebar-user { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; }
.sidebar-user:hover { background: color-mix(in oklab, var(--fg), transparent 94%); }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sidebar-user-info { min-width: 0; }
.sidebar-user-name { font-size: 13px; font-weight: 600; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-user-role { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
.sidebar-action { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; border: none; background: transparent; width: 100%; text-align: left; transition: background 140ms, color 140ms; }
.sidebar-action:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); }
/* ── Content area ── */
.content-area { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
.content-topbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border); background: color-mix(in oklab, var(--bg), transparent 4%); backdrop-filter: blur(10px); }
.topbar-title { font-weight: 600; font-size: 15px; color: var(--fg); flex: 1; }
.search { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 0 12px; height: 34px; width: 240px; }
.search input { border: 0; outline: none; background: transparent; width: 100%; color: var(--fg); font-size: 13px; }
.search input::placeholder { color: var(--muted); }
/* ── Buttons ── */
.btn {
min-height: 34px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
font-size: 13px;
cursor: pointer;
transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard);
}
.btn:hover { border-color: var(--fg); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn:focus-visible,
.control:focus-visible,
.table-row:focus-within {
outline: none;
box-shadow: var(--focus-ring);
}
/* ── Page layout ── */
.page {
padding: 24px;
display: grid;
gap: 20px;
}
.page-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.page-head h1 { font-size: clamp(30px, 4vw, 44px); }
.page-head p { max-width: 70ch; color: var(--muted); }
.control-bar {
display: grid;
grid-template-columns: 1.2fr repeat(4, minmax(0, 180px));
gap: 12px;
}
.control {
min-height: 44px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 0 12px;
color: var(--fg);
}
.control::placeholder { color: var(--muted); }
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in oklab, var(--surface), var(--bg) 16%);
padding: 14px 16px;
}
.batch-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
overflow: hidden;
}
.table-head,
.table-row {
display: grid;
grid-template-columns: 28px 1.4fr 0.8fr 0.85fr 0.85fr 0.75fr 0.75fr 0.6fr;
gap: 12px;
align-items: center;
padding: 14px 16px;
}
.table-head {
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-xs);
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.08em;
background: color-mix(in oklab, var(--surface), var(--bg) 24%);
}
.table-row {
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.table-row:last-child { border-bottom: 0; }
.table-row:hover { background: color-mix(in oklab, var(--fg), transparent 97%); }
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 36px;
padding: 0 24px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-xs);
font-family: var(--font-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
background: color-mix(in oklab, var(--bg), var(--surface) 12%);
margin-top: auto;
}
.footer-status {
display: inline-flex;
align-items: center;
gap: 10px;
}
.footer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #19d3a2;
box-shadow: 0 0 0 4px color-mix(in oklab, #19d3a2, transparent 84%);
}
.doc-title {
display: grid;
gap: 4px;
min-width: 0;
}
.doc-title strong { font-size: var(--text-sm); }
.doc-title span,
.helper {
color: var(--muted);
font-size: var(--text-sm);
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 4px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.status.ok { color: var(--success); }
.status.warn { color: color-mix(in oklab, var(--warn), black 24%); }
.status.risk { color: var(--danger); }
.link-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.text-link {
color: var(--muted);
font-size: var(--text-sm);
}
.text-link:hover { color: var(--accent); }
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: 16px;
}
.summary-card strong {
display: block;
font-size: 28px;
line-height: 1;
margin: 10px 0 8px;
}
.summary-card span {
color: var(--muted);
font-size: var(--text-sm);
}
.pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
/* ── Responsive ── */
@media (max-width: 700px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
@media (max-width: 1280px) {
.control-bar,
.summary { grid-template-columns: 1fr 1fr; }
.table-head,
.table-row { grid-template-columns: 28px 1.3fr 0.9fr 0.9fr 0.8fr 0.7fr; }
.table-head > :nth-child(7),
.table-head > :nth-child(8),
.table-row > :nth-child(7),
.table-row > :nth-child(8) { display: none; }
}
@media (max-width: 840px) {
.content-topbar,
.page { padding-inline: 12px; }
.control-bar,
.summary,
.page-head { grid-template-columns: 1fr; }
.page-head { display: grid; align-items: start; }
.table-head { display: none; }
.table-row {
grid-template-columns: 28px 1fr;
align-items: start;
}
.table-row > *:nth-child(n+3) {
grid-column: 2;
}
.batch-bar {
flex-direction: column;
align-items: start;
}
.footer {
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding-block: 10px;
}
}
</style>
</head>
<body data-page="documents">
<div class="app-shell">
<!-- Sidebar -->
<aside class="sidebar" aria-label="Primary navigation">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
</svg>
</div>
<div>
<div class="sidebar-brand-name">T-Systems</div>
<div class="sidebar-brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<div class="nav-group">
<span class="nav-group-label">主导航</span>
<a class="nav-item" href="index.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h5v5H2zm7 0h5v5H9zM2 9h5v5H2zm7 0h5v5H9z" fill="currentColor" opacity=".7"/></svg>
概览
</a>
<a class="nav-item" href="dashboard.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M1.5 2.5h13v1H1.5zm0 3h13v1H1.5zm0 3h8v1h-8zm0 3h6v1h-6zM12 8.5a3.5 3.5 0 110 7 3.5 3.5 0 010-7zm0 1a2.5 2.5 0 100 5 2.5 2.5 0 000-5zm.5 1v2h1.5v1H11v-3h1.5z" fill="currentColor"/></svg>
系统状态
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">工作台</span>
<a class="nav-item active" href="document-management.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M3 1h7l3 3v11H3V1zm1 1v12h8V5h-3V2H4zm5 .5V4h1.5L9 1.5zM6 7h4v1H6zm0 2h4v1H6zm0 2h3v1H6z" fill="currentColor"/></svg>
文档管理
</a>
<a class="nav-item" href="compliance-analysis.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M8 1l7 3-1 6a7 7 0 01-6 5A7 7 0 011 10L0 4l8-3zm0 1.2L1.3 4.8l.8 5.1A6 6 0 008 14.8a6 6 0 005.9-4.9l.8-5.1L8 2.2zM7.5 5h1v4.5l-1 .5V5zm0 5.5h1v1h-1v-1z" fill="currentColor"/></svg>
合规分析
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">对话</span>
<a class="nav-item" href="regulation-chat.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h12a1 1 0 011 1v8a1 1 0 01-1 1H5l-3 2.5V3a1 1 0 011-1zm0 1v9.5L4.5 11H14V3H2zm2 2h8v1H4zm0 2h6v1H4z" fill="currentColor"/></svg>
法规对话
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar">TS</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">T-Systems User</div>
<div class="sidebar-user-role">Compliance Analyst</div>
</div>
</div>
<button class="sidebar-action od-theme-toggle" type="button" data-od-theme aria-label="Toggle theme">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3a5 5 0 100 10A5 5 0 008 3zM2 8a6 6 0 1112 0A6 6 0 012 8z" fill="currentColor"/></svg>
主题
</button>
</div>
</aside>
<!-- Content area -->
<div class="content-area">
<header class="content-topbar">
<div class="topbar-title">文档管理</div>
<div class="search">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M6.5 1a5.5 5.5 0 014.23 9.02l3.62 3.62-.7.71-3.63-3.63A5.5 5.5 0 116.5 1zm0 1a4.5 4.5 0 100 9 4.5 4.5 0 000-9z" fill="currentColor" opacity=".5"/></svg>
<input type="search" placeholder="Search documents…" aria-label="Search" />
</div>
<button class="btn">Import history</button>
<a class="btn btn-primary" href="upload-modal.html">Upload documents</a>
</header>
<main class="page">
<section class="page-head" data-od-id="docs-head">
<div>
<div class="eyebrow">Document management</div>
<h1>Library control for ingestion and indexing.</h1>
</div>
<div style="display:grid; gap:12px; justify-items:start;">
<p style="margin:0;">Analysts can triage failed jobs, normalize metadata, and move a document from upload to parse-ready without leaving the operations workspace.</p>
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
<span class="pill">Queue</span>
<button class="btn">Import history</button>
<a class="btn btn-primary" href="upload-modal.html">Upload documents</a>
</div>
</div>
</section>
<section class="summary" data-od-id="docs-summary">
<div class="summary-card">
<div class="helper">Total library</div>
<strong class="mono">--</strong>
<span>Across GB, GB/T, UNECE, ISO, and enterprise interpretation notes</span>
</div>
<div class="summary-card">
<div class="helper">Processing</div>
<strong class="mono">--</strong>
<span>Waiting on parse, chunking, embedding, or index sync</span>
</div>
<div class="summary-card">
<div class="helper">Failed</div>
<strong class="mono">--</strong>
<span>Timeout, OCR confidence, duplicate ID, or vector schema mismatch</span>
</div>
<div class="summary-card">
<div class="helper">Avg summary latency</div>
<strong class="mono">--</strong>
<span>Document summary generation after artifacts complete</span>
</div>
</section>
<section class="control-bar" data-od-id="docs-filters">
<input class="control" type="text" value="GB/T" aria-label="Keyword filter" />
<select class="control" aria-label="Status filter">
<option>All statuses</option>
<option selected>Processing + failed</option>
<option>Indexed</option>
</select>
<select class="control" aria-label="Regulation type filter">
<option selected>Vehicle safety</option>
<option>Cybersecurity</option>
<option>Battery</option>
</select>
<select class="control" aria-label="Parser filter">
<option selected>Aliyun parser</option>
<option>Legacy local parser</option>
</select>
<select class="control" aria-label="Owner filter">
<option selected>All owners</option>
<option>Battery Safety Team</option>
<option>Connected Vehicle Team</option>
</select>
</section>
<section class="batch-bar" data-od-id="docs-batch">
<div>
<strong>4 selected</strong>
<span class="helper">Batch actions apply metadata, retry parse, or archive stale drafts.</span>
</div>
<div class="batch-actions">
<button class="btn">Assign category</button>
<button class="btn">Retry parse</button>
<button class="btn">Mark superseded</button>
<button class="btn">Delete</button>
</div>
</section>
<section class="card" data-od-id="docs-table">
<div class="table-head">
<span></span>
<span>Document</span>
<span>Type</span>
<span>Status</span>
<span>Artifacts</span>
<span>Updated</span>
<span>Owner</span>
<span>Actions</span>
</div>
<div class="table-row">
<input type="checkbox" checked aria-label="Select document" />
<div class="doc-title">
<strong>GB/T 31484-2015 battery energy density methods</strong>
<span class="mono">doc_id: GBT-31484-2015-r2 · version 2026 addendum</span>
</div>
<span>Battery</span>
<span class="status warn">Embedding</span>
<span class="mono">chunk build active</span>
<span class="mono">09:42</span>
<span>Battery Safety</span>
<div class="link-row">
<a class="text-link" href="document-detail.html">Inspect</a>
<a class="text-link" href="#">Retry</a>
</div>
</div>
<div class="table-row">
<input type="checkbox" checked aria-label="Select document" />
<div class="doc-title">
<strong>UNECE R155 cybersecurity management Q&amp;A</strong>
<span class="mono">doc_id: UNECE-R155-qa-2026-05 · summary ready</span>
</div>
<span>Cybersecurity</span>
<span class="status ok">Indexed</span>
<span class="mono">retrieval-ready</span>
<span class="mono">08:18</span>
<span>Connected Fleet</span>
<div class="link-row">
<a class="text-link" href="compliance-analysis.html">Analyze</a>
<a class="text-link" href="regulation-chat.html">Chat</a>
</div>
</div>
<div class="table-row">
<input type="checkbox" aria-label="Select document" />
<div class="doc-title">
<strong>GB 26112-2010 roof strength scan</strong>
<span class="mono">doc_id: GB-26112-scan-ocr · 6 pages low confidence</span>
</div>
<span>Vehicle safety</span>
<span class="status risk">Failed</span>
<span class="mono">OCR blocked</span>
<span class="mono">Yesterday</span>
<span>Body Structure</span>
<div class="link-row">
<a class="text-link" href="#">View error</a>
<a class="text-link" href="#">Re-upload</a>
</div>
</div>
<div class="table-row">
<input type="checkbox" checked aria-label="Select document" />
<div class="doc-title">
<strong>GB/T 18487 charging interface interpretation</strong>
<span class="mono">doc_id: GBT-18487-note-2026 · duplicate metadata candidate</span>
</div>
<span>Charging</span>
<span class="status warn">Review</span>
<span class="mono">Summary only</span>
<span class="mono">11:06</span>
<span>EV Platform</span>
<div class="link-row">
<a class="text-link" href="#">Normalize</a>
<a class="text-link" href="#">Merge</a>
</div>
</div>
<div class="table-row">
<input type="checkbox" aria-label="Select document" />
<div class="doc-title">
<strong>Supplier thermal runaway test report</strong>
<span class="mono">doc_id: supplier-pack-773A · confidential appendix attached</span>
</div>
<span>Evidence</span>
<span class="status ok">Indexed</span>
<span class="mono">248 chunks</span>
<span class="mono">Monday</span>
<span>Supplier QA</span>
<div class="link-row">
<a class="text-link" href="document-detail.html">Inspect</a>
<a class="text-link" href="#">Download</a>
</div>
</div>
</section>
</main>
<footer class="footer">
<span>T-Systems Regulation Hub</span>
<div class="footer-status">
<span class="footer-dot" aria-hidden="true"></span>
<span>Online</span>
</div>
</footer>
</div>
</div>
<script src="ui-preferences.js"></script>
</body>
</html>

View File

@@ -0,0 +1,696 @@
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI + Compliance Hub Prototype Suite</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--primary: #e20074;
--accent: var(--primary);
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold", "TeleNeoWeb-Medium", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1200px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
--sidebar-w: 240px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
transition: color var(--motion-fast) var(--ease-standard);
}
a:hover { color: var(--fg); text-decoration: underline; }
button, input, select { font: inherit; }
p { text-wrap: pretty; }
h1, h2, h3 { font-family: var(--font-display); line-height: var(--leading-tight); letter-spacing: var(--tracking-display); margin: 0; text-wrap: balance; }
/* ── Sidebar shell ── */
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); z-index: 10; }
.sidebar-brand { display: flex; align-items: center; gap: 10px; height: 56px; padding: 0 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.brand-logo { width: 26px; height: 26px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.brand-logo svg { color: #fff; }
.sidebar-brand-name { font-family: var(--font-display); font-size: 13px; font-weight: 700; line-height: 1.2; }
.sidebar-brand-sub { font-size: 10px; color: var(--muted); font-family: var(--font-mono); letter-spacing: 0.04em; }
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
.nav-group { padding: 0 8px 4px; }
.nav-group + .nav-group { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.nav-group-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 0 8px 6px; display: block; }
.nav-item { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; transition: background 140ms, color 140ms; position: relative; }
.nav-item:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); text-decoration: none; }
.nav-item.active { background: color-mix(in oklab, var(--accent), transparent 90%); color: var(--accent); font-weight: 600; }
.nav-item.active::before { content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
.nav-icon { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.7; }
.nav-item.active .nav-icon { opacity: 1; }
.sidebar-footer { border-top: 1px solid var(--border); padding: 10px 8px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.sidebar-user { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; }
.sidebar-user:hover { background: color-mix(in oklab, var(--fg), transparent 94%); }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sidebar-user-info { min-width: 0; }
.sidebar-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-user-role { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
.sidebar-action { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; border: none; background: transparent; width: 100%; text-align: left; transition: background 140ms, color 140ms; }
.sidebar-action:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); }
.content-area { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
.content-topbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border); background: color-mix(in oklab, var(--bg), transparent 4%); backdrop-filter: blur(10px); }
.topbar-title { font-weight: 600; font-size: 15px; color: var(--fg); flex: 1; }
.footer-dot { width: 7px; height: 7px; border-radius: 50%; background: #19d3a2; box-shadow: 0 0 0 3px color-mix(in oklab, #19d3a2, transparent 82%); }
.footer-status { display: inline-flex; align-items: center; gap: 8px; }
@media (max-width: 700px) { .app-shell { grid-template-columns: 1fr; } .sidebar { display: none; } }
/* ── Page-specific styles ── */
.container {
max-width: var(--container-max);
margin: 0 auto;
padding: 0 var(--container-gutter-desktop);
}
.meta {
color: var(--muted);
font-size: var(--text-sm);
}
.hero {
padding: 72px 0 36px;
display: grid;
grid-template-columns: 1.3fr 0.9fr;
gap: var(--space-12);
align-items: start;
}
.eyebrow {
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 var(--space-4);
}
.hero h1 {
font-size: clamp(40px, 6vw, 64px);
max-width: 11ch;
}
.hero-copy {
margin-top: var(--space-5);
max-width: 58ch;
color: var(--muted);
font-size: var(--text-lg);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: var(--space-6);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard);
}
.btn:focus-visible,
.screen-card a:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
}
.btn-primary:hover {
background: var(--accent-hover);
text-decoration: none;
}
.btn-secondary:hover {
border-color: var(--fg);
text-decoration: none;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-5);
box-shadow: var(--elev-flat);
}
.summary-grid,
.screen-grid {
display: grid;
gap: var(--space-4);
}
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 44px;
}
.screen-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 64px;
}
.screen-card {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-height: 280px;
}
.screen-card a {
color: inherit;
display: block;
border-radius: inherit;
}
.screen-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.chip {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
padding: 4px 10px;
color: var(--muted);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.mini-shot {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background:
linear-gradient(180deg, color-mix(in oklab, var(--accent), white 94%), transparent),
var(--surface);
padding: var(--space-4);
min-height: 148px;
}
.mini-toolbar {
display: flex;
gap: 6px;
margin-bottom: var(--space-4);
}
.mini-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--border);
}
.mini-layout {
display: grid;
grid-template-columns: 140px 1fr;
gap: var(--space-3);
min-height: 94px;
}
.mini-nav,
.mini-body,
.mini-row,
.mini-block {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface), var(--bg) 24%);
}
.mini-nav { padding: var(--space-3); }
.mini-body { padding: var(--space-3); display: grid; gap: var(--space-2); }
.mini-row {
height: 12px;
background: color-mix(in oklab, var(--fg), transparent 96%);
}
.mini-row.accent {
width: 42%;
background: color-mix(in oklab, var(--accent), white 76%);
border-color: color-mix(in oklab, var(--accent), white 70%);
}
.mini-block { height: 56px; }
.section-title {
display: flex;
align-items: end;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.section-title h2 { font-size: var(--text-2xl); }
.section-title p { margin: 0; max-width: 58ch; color: var(--muted); }
.flow {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--space-3);
margin-bottom: 80px;
}
.flow-step {
position: relative;
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.flow-step strong {
display: block;
margin-bottom: 6px;
font-size: var(--text-sm);
}
.flow-step span {
display: block;
color: var(--muted);
font-size: var(--text-xs);
}
.flow-step::after {
content: "";
position: absolute;
top: 50%;
right: -13px;
width: 10px;
height: 1px;
background: var(--border);
}
.flow-step:last-child::after { display: none; }
.footer {
padding: 24px 0 48px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-sm);
}
@media (max-width: 1023px) {
.hero,
.summary-grid,
.screen-grid,
.flow {
grid-template-columns: 1fr;
}
.container { padding: 0 var(--container-gutter-tablet); }
.flow-step::after { display: none; }
}
@media (max-width: 639px) {
.container { padding: 0 var(--container-gutter-phone); }
.hero { padding-top: 48px; }
.hero-copy { font-size: var(--text-base); }
.screen-head { align-items: start; flex-direction: column; }
}
</style>
</head>
<body data-page="index">
<div class="app-shell">
<aside class="sidebar" aria-label="Primary navigation">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
</svg>
</div>
<div>
<div class="sidebar-brand-name">T-Systems</div>
<div class="sidebar-brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<div class="nav-group">
<span class="nav-group-label">主导航</span>
<a class="nav-item active" href="index.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h5v5H2zm7 0h5v5H9zM2 9h5v5H2zm7 0h5v5H9z" fill="currentColor" opacity=".7"/></svg>
概览
</a>
<a class="nav-item" href="dashboard.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M1.5 2.5h13v1H1.5zm0 3h13v1H1.5zm0 3h8v1h-8zm0 3h6v1h-6z" fill="currentColor"/></svg>
系统状态
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">工作台</span>
<a class="nav-item" href="document-management.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M3 1h7l3 3v11H3V1zm1 1v12h8V5h-3V2H4zm5 .5V4h1.5L9 1.5zM6 7h4v1H6zm0 2h4v1H6zm0 2h3v1H6z" fill="currentColor"/></svg>
文档管理
</a>
<a class="nav-item" href="compliance-analysis.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M8 1l7 3-1 6a7 7 0 01-6 5A7 7 0 011 10L0 4l8-3zm0 1.2L1.3 4.8l.8 5.1A6 6 0 008 14.8a6 6 0 005.9-4.9l.8-5.1L8 2.2zM7.5 5h1v4.5l-1 .5V5zm0 5.5h1v1h-1v-1z" fill="currentColor"/></svg>
合规分析
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">对话</span>
<a class="nav-item" href="regulation-chat.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h12a1 1 0 011 1v8a1 1 0 01-1 1H5l-3 2.5V3a1 1 0 011-1zm0 1v9.5L4.5 11H14V3H2zm2 2h8v1H4zm0 2h6v1H4z" fill="currentColor"/></svg>
法规对话
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar">TS</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">T-Systems User</div>
<div class="sidebar-user-role">Compliance Analyst</div>
</div>
</div>
<button class="sidebar-action od-theme-toggle" type="button" data-od-theme aria-label="Toggle theme">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3a5 5 0 100 10A5 5 0 008 3zM2 8a6 6 0 1112 0A6 6 0 012 8z" fill="currentColor"/></svg>
主题
</button>
</div>
</aside>
<div class="content-area">
<header class="content-topbar">
<span class="topbar-title">概览</span>
</header>
<main class="container">
<section class="hero" data-od-id="hero">
<div>
<p class="eyebrow">Prototype suite</p>
<h1>Operational screens for AI document compliance work.</h1>
<p class="hero-copy">
This launcher maps the full desktop workflow: intake, parsing, embeddings, retrieval-led analysis, and citation-backed chat.
Each screen is isolated as its own product surface so reviewers can inspect decisions without switching fake demo controls.
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="dashboard.html">Open dashboard</a>
<a class="btn btn-secondary" href="regulation-chat.html">Jump to regulation chat</a>
</div>
</div>
<aside class="card">
<p class="eyebrow">Scope</p>
<div class="summary-grid" style="grid-template-columns: 1fr; margin: 0; gap: 14px;">
<div>
<strong>6 product screens</strong>
<div class="meta">Launcher, operations overview, doc management, upload, parse detail, analysis, chat</div>
</div>
<div>
<strong>Backend-aware flows</strong>
<div class="meta">Aliyun parsing, chunk generation, text-embedding-v3, dense vector collection, citation retrieval</div>
</div>
<div>
<strong>Review posture</strong>
<div class="meta">Quiet utility chrome, clear status states, one accent reserved for action and escalation</div>
</div>
</div>
</aside>
</section>
<section data-od-id="workflow">
<div class="section-title">
<div>
<p class="eyebrow">Interaction rhythm</p>
<h2>One sequence, six focused stops.</h2>
</div>
<p>The product cadence moves from portfolio awareness to precise intervention. Each screen hands off to the next likely action instead of collapsing everything into a single dense page.</p>
</div>
<div class="flow">
<div class="flow-step">
<strong>01 Dashboard</strong>
<span>Watch ingestion health, queue pressure, policy risk, and active investigations.</span>
</div>
<div class="flow-step">
<strong>02 Library</strong>
<span>Filter standards, inspect states, trigger retry, delete, and batch assign metadata.</span>
</div>
<div class="flow-step">
<strong>03 Upload</strong>
<span>Stage files, assign regulation type and version, and monitor import queue progress.</span>
</div>
<div class="flow-step">
<strong>04 Parse detail</strong>
<span>Follow document parsing, semantic blocks, vector chunks, and embedding/index milestones.</span>
</div>
<div class="flow-step">
<strong>05 Analysis</strong>
<span>Compare source passages with retrieved regulations, findings, and conclusion-ready actions.</span>
</div>
<div class="flow-step">
<strong>06 Chat</strong>
<span>Interrogate a clause with citations, trace history, and export reasoning with sources.</span>
</div>
</div>
</section>
<section data-od-id="screens">
<div class="section-title">
<div>
<p class="eyebrow">Screens</p>
<h2>Open any surface directly.</h2>
</div>
<p>Each tile previews its UI structure and primary job. These are entry points into a realistic desktop workflow, not storyboards.</p>
</div>
<div class="screen-grid">
<article class="screen-card card">
<div class="screen-head">
<div>
<h3>Dashboard</h3>
<div class="meta">Overview of system health, backlog, and current compliance programs</div>
</div>
<span class="chip">Operations</span>
</div>
<a href="dashboard.html">
<div class="mini-shot" aria-hidden="true">
<div class="mini-toolbar"><span class="mini-dot"></span><span class="mini-dot"></span><span class="mini-dot"></span></div>
<div class="mini-layout">
<div class="mini-nav"></div>
<div class="mini-body">
<div class="mini-row accent"></div>
<div class="mini-block"></div>
<div class="mini-row"></div>
</div>
</div>
</div>
</a>
</article>
<article class="screen-card card">
<div class="screen-head">
<div>
<h3>Document management</h3>
<div class="meta">Library, filters, batch actions, and ingestion state control</div>
</div>
<span class="chip">Library</span>
</div>
<a href="document-management.html">
<div class="mini-shot" aria-hidden="true">
<div class="mini-toolbar"><span class="mini-dot"></span><span class="mini-dot"></span><span class="mini-dot"></span></div>
<div class="mini-layout">
<div class="mini-nav"></div>
<div class="mini-body">
<div class="mini-row accent"></div>
<div class="mini-row"></div>
<div class="mini-block"></div>
</div>
</div>
</div>
</a>
</article>
<article class="screen-card card">
<div class="screen-head">
<div>
<h3>Upload modal</h3>
<div class="meta">Drag-drop intake, metadata assignment, and import queue feedback</div>
</div>
<span class="chip">Intake</span>
</div>
<a href="upload-modal.html">
<div class="mini-shot" aria-hidden="true">
<div class="mini-toolbar"><span class="mini-dot"></span><span class="mini-dot"></span><span class="mini-dot"></span></div>
<div class="mini-layout" style="grid-template-columns: 1fr;">
<div class="mini-body">
<div class="mini-block"></div>
<div class="mini-row accent"></div>
<div class="mini-row"></div>
</div>
</div>
</div>
</a>
</article>
<article class="screen-card card">
<div class="screen-head">
<div>
<h3>Document detail</h3>
<div class="meta">Parsing, chunking, embedding, and vector store progress by artifact stage</div>
</div>
<span class="chip">Pipeline</span>
</div>
<a href="document-detail.html">
<div class="mini-shot" aria-hidden="true">
<div class="mini-toolbar"><span class="mini-dot"></span><span class="mini-dot"></span><span class="mini-dot"></span></div>
<div class="mini-layout">
<div class="mini-nav"></div>
<div class="mini-body">
<div class="mini-row accent"></div>
<div class="mini-block"></div>
<div class="mini-block"></div>
</div>
</div>
</div>
</a>
</article>
<article class="screen-card card">
<div class="screen-head">
<div>
<h3>Compliance analysis</h3>
<div class="meta">Retrieval to reasoning to conclusion workspace with tracked evidence</div>
</div>
<span class="chip">Analysis</span>
</div>
<a href="compliance-analysis.html">
<div class="mini-shot" aria-hidden="true">
<div class="mini-toolbar"><span class="mini-dot"></span><span class="mini-dot"></span><span class="mini-dot"></span></div>
<div class="mini-layout">
<div class="mini-nav"></div>
<div class="mini-body">
<div class="mini-row accent"></div>
<div class="mini-row"></div>
<div class="mini-block"></div>
</div>
</div>
</div>
</a>
</article>
<article class="screen-card card">
<div class="screen-head">
<div>
<h3>Regulation chat</h3>
<div class="meta">Source-backed question answering with history, quick prompts, and citation rail</div>
</div>
<span class="chip">Copilot</span>
</div>
<a href="regulation-chat.html">
<div class="mini-shot" aria-hidden="true">
<div class="mini-toolbar"><span class="mini-dot"></span><span class="mini-dot"></span><span class="mini-dot"></span></div>
<div class="mini-layout">
<div class="mini-nav"></div>
<div class="mini-body">
<div class="mini-row accent"></div>
<div class="mini-row"></div>
<div class="mini-row"></div>
</div>
</div>
</div>
</a>
</article>
</div>
</section>
</main>
<footer class="footer">
<div class="container">Prototype prepared for product evaluators reviewing document ingestion, AI parsing, and compliance reasoning workflows.</div>
</footer>
</div>
</div>
<script src="ui-preferences.js"></script>
</body></html>

View File

@@ -0,0 +1,608 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI + Compliance Hub - Regulation Chat</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--primary: #e20074;
--accent: var(--primary);
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold", "TeleNeoWeb-Medium", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1600px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
--sidebar-w: 240px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
margin: 0;
font-family: var(--font-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-display);
}
p { margin: 0; text-wrap: pretty; }
button, input { font: inherit; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--fg); text-decoration: underline; }
/* ── Sidebar shell ── */
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); z-index: 10; }
.sidebar-brand { display: flex; align-items: center; gap: 10px; height: 56px; padding: 0 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.brand-logo { width: 26px; height: 26px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.brand-logo svg { color: #fff; }
.sidebar-brand-name { font-family: var(--font-display); font-size: 13px; font-weight: 700; line-height: 1.2; }
.sidebar-brand-sub { font-size: 10px; color: var(--muted); font-family: var(--font-mono); letter-spacing: 0.04em; }
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
.nav-group { padding: 0 8px 4px; }
.nav-group + .nav-group { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.nav-group-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 0 8px 6px; display: block; }
.nav-item { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; transition: background 140ms, color 140ms; position: relative; }
.nav-item:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); text-decoration: none; }
.nav-item.active { background: color-mix(in oklab, var(--accent), transparent 90%); color: var(--accent); font-weight: 600; }
.nav-item.active::before { content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
.nav-icon { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.7; }
.nav-item.active .nav-icon { opacity: 1; }
.sidebar-footer { border-top: 1px solid var(--border); padding: 10px 8px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.sidebar-user { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 6px; cursor: pointer; }
.sidebar-user:hover { background: color-mix(in oklab, var(--fg), transparent 94%); }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sidebar-user-info { min-width: 0; }
.sidebar-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sidebar-user-role { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
.sidebar-action { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px; border-radius: 6px; color: var(--muted); font-size: 13px; cursor: pointer; border: none; background: transparent; width: 100%; text-align: left; transition: background 140ms, color 140ms; }
.sidebar-action:hover { background: color-mix(in oklab, var(--fg), transparent 94%); color: var(--fg); }
.content-area { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
.content-topbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; height: 56px; padding: 0 24px; border-bottom: 1px solid var(--border); background: color-mix(in oklab, var(--bg), transparent 4%); backdrop-filter: blur(10px); }
.topbar-title { font-weight: 600; font-size: 15px; color: var(--fg); flex: 1; }
.footer-status { display: inline-flex; align-items: center; gap: 8px; }
.footer-dot { width: 7px; height: 7px; border-radius: 50%; background: #19d3a2; }
@media (max-width: 700px) { .app-shell { grid-template-columns: 1fr; } .sidebar { display: none; } }
/* ── Page content ── */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 36px;
padding: 0 24px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-xs);
font-family: var(--font-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
background: color-mix(in oklab, var(--bg), var(--surface) 12%);
}
.page {
max-width: 1600px;
margin: 0 auto;
padding: 24px;
display: grid;
gap: 20px;
}
.hero {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.hero h1 { font-size: clamp(30px, 4vw, 44px); }
.hero p { max-width: 72ch; color: var(--muted); }
.eyebrow {
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
gap: 16px;
min-height: 760px;
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: 18px;
min-width: 0;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.section-head h2 { font-size: var(--text-xl); }
.helper {
color: var(--muted);
font-size: var(--text-sm);
}
.history,
.quick-list,
.sources,
.messages {
display: grid;
gap: 12px;
}
.history-item,
.quick-item,
.source-item,
.message {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface), var(--bg) 14%);
padding: 14px;
}
.history-item.active {
background: color-mix(in oklab, var(--accent), white 92%);
border-color: color-mix(in oklab, var(--accent), white 70%);
}
.pill,
.status {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 4px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
font-size: var(--text-xs);
font-family: var(--font-mono);
}
.status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.status.ok { color: var(--success); }
.status.warn { color: color-mix(in oklab, var(--warn), black 24%); }
.status.risk { color: var(--danger); }
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.chat-column {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 16px;
min-height: 0;
}
.messages {
align-content: start;
overflow: auto;
min-height: 0;
padding-right: 4px;
}
.message.assistant {
background: color-mix(in oklab, var(--surface), var(--bg) 8%);
}
.message.user {
background: color-mix(in oklab, var(--accent), white 92%);
border-color: color-mix(in oklab, var(--accent), white 70%);
}
.message-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.composer {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: 14px;
display: grid;
gap: 12px;
}
.input {
min-height: 52px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface), var(--bg) 8%);
padding: 0 14px;
color: var(--fg);
width: 100%;
}
.input:focus-visible,
.quick-item:focus-visible,
.history-item:focus-visible,
.btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.btn {
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.source-item strong,
.history-item strong,
.quick-item strong {
display: block;
margin-bottom: 6px;
}
.citation {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-sm);
}
@media (max-width: 1320px) {
.shell { grid-template-columns: 1fr; }
.hero { flex-direction: column; align-items: start; }
}
@media (max-width: 760px) {
.page { padding: 12px; }
}
</style>
</head>
<body data-page="chat">
<div class="app-shell">
<!-- ── Sidebar ── -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 5.5h18v2H3zm8 2h2v11h-2zm-5 3h3v2H6zm9 0h3v2h-3zm-5 4h2v2h-2z" fill="currentColor" />
</svg>
</div>
<div>
<div class="sidebar-brand-name">T-Systems</div>
<div class="sidebar-brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<!-- 主导航 -->
<div class="nav-group">
<span class="nav-group-label">主导航</span>
<a class="nav-item" href="index.html">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.5z"/><path d="M9 21V12h6v9"/>
</svg>
概览
</a>
<a class="nav-item" href="dashboard.html">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
系统状态
</a>
</div>
<!-- 工作台 -->
<div class="nav-group">
<span class="nav-group-label">工作台</span>
<a class="nav-item" href="document-management.html">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
文档管理
</a>
<a class="nav-item" href="compliance-analysis.html">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
合规分析
</a>
</div>
<!-- 对话 -->
<div class="nav-group">
<span class="nav-group-label">对话</span>
<a class="nav-item active" href="regulation-chat.html">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
法规对话
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar"></div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">张工程师</div>
<div class="sidebar-user-role">compliance.eng</div>
</div>
</div>
<button class="sidebar-action" type="button" data-od-theme aria-label="Toggle color theme">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
主题: 自动
</button>
</div>
</aside>
<!-- ── Content area ── -->
<div class="content-area">
<header class="content-topbar">
<span class="topbar-title">法规对话</span>
<div class="footer-status">
<span class="footer-dot" aria-hidden="true"></span>
<span style="font-size:12px; color:var(--muted); font-family:var(--font-mono);">Session synced</span>
</div>
</header>
<main class="page">
<section class="hero" data-od-id="chat-hero">
<div>
<div class="eyebrow">Regulation chat</div>
<h1>Ask clause-level questions with citations intact.</h1>
<p>The chat surface is tuned for compliance follow-up after analysis. Conversation history stays visible, quick prompts speed common queries, and the right rail keeps cited standards inspectable while the answer streams.</p>
</div>
<div style="display:flex; gap:12px; flex-wrap:wrap;">
<a class="pill" href="compliance-analysis.html">From analysis workspace</a>
<span class="status ok">Session synced</span>
</div>
</section>
<section class="shell" data-od-id="chat-shell">
<aside class="card">
<div class="section-head">
<h2>History</h2>
<span class="helper">3 recent threads</span>
</div>
<div class="history">
<div class="history-item active" tabindex="0">
<strong>Roof crush resistance wording</strong>
<div class="helper">Started 10:14 · 5 replies · linked to GB 26112-2010</div>
</div>
<div class="history-item" tabindex="0">
<strong>R155 incident log retention</strong>
<div class="helper">Started yesterday · 8 replies · cybersecurity watch</div>
</div>
<div class="history-item" tabindex="0">
<strong>Thermal runaway evidence checklist</strong>
<div class="helper">Started Monday · 4 replies · supplier dossier</div>
</div>
</div>
<div class="section-head" style="margin-top:20px;">
<h2>Quick questions</h2>
<span class="helper">Common prompts</span>
</div>
<div class="quick-list">
<button class="quick-item" type="button"><strong>What exact numeric threshold is required here?</strong><span class="helper">Turn vague compliance language into a requirement statement.</span></button>
<button class="quick-item" type="button"><strong>Which citations are mandatory versus contextual?</strong><span class="helper">Separate legal requirement from scoring or best practice references.</span></button>
<button class="quick-item" type="button"><strong>Draft replacement wording for the dossier.</strong><span class="helper">Generate an auditable sentence that cites the source clause.</span></button>
</div>
</aside>
<section class="chat-column">
<div class="card">
<div class="section-head">
<h2>Active thread</h2>
<span class="helper">Chunk 148 · GB 26112-2010 context</span>
</div>
</div>
<div class="messages">
<article class="message user">
<div class="message-top">
<strong>Reviewer</strong>
<span class="helper mono">10:14</span>
</div>
<p>Does this paragraph need a numeric load statement, or is the current "meets national crush-resistance requirements" wording enough?</p>
</article>
<article class="message assistant">
<div class="message-top">
<strong>Compliance assistant</strong>
<span class="helper mono">10:14</span>
</div>
<p>The current wording is not sufficient on its own. The retrieved primary citation, <strong>GB 26112-2010 §4.2</strong>, defines a measurable resistance requirement. To make the dossier auditable, the paragraph should state the tested load threshold and link the supporting report.</p>
<div class="citation">
Primary citation: GB 26112-2010 §4.2 · lead match for the current paragraph · linked body engineering report candidate
</div>
</article>
<article class="message user">
<div class="message-top">
<strong>Reviewer</strong>
<span class="helper mono">10:16</span>
</div>
<p>Draft the exact replacement sentence I can hand back to the body structure team.</p>
</article>
<article class="message assistant">
<div class="message-top">
<strong>Compliance assistant</strong>
<span class="helper mono">10:16</span>
</div>
<p>Use: "The roof support structure was validated in accordance with <strong>GB 26112-2010 §4.2</strong>; supporting evidence is documented in the linked body engineering report."</p>
<div class="citation">
Evidence used: GB 26112-2010 §4.2, internal engineering report reference, related C-NCAP context kept as secondary only.
</div>
</article>
</div>
<form class="composer">
<input class="input" type="text" value="Which part of this answer is mandatory compliance language and which part is supporting evidence?" aria-label="Chat input" />
<div class="btn-row">
<div class="helper">Citations stay attached to each answer and are exportable with the thread.</div>
<button class="btn btn-primary" type="submit">Send question</button>
</div>
</form>
</section>
<aside class="card">
<div class="section-head">
<h2>Citation rail</h2>
<span class="helper">Sources in current answer</span>
</div>
<div class="sources">
<div class="source-item">
<strong>GB 26112-2010 §4.2</strong>
<div class="helper">Primary mandatory requirement used to justify the numeric threshold.</div>
<div class="citation">Reason surfaced: direct match on roof crush resistance wording and the validation requirement.</div>
</div>
<div class="source-item">
<strong>Linked body engineering report</strong>
<div class="helper">Internal evidence artifact proposed for linking into the final dossier.</div>
<div class="citation">The relevant static load summary should be attached before final sign-off.</div>
</div>
<div class="source-item">
<strong>C-NCAP rulebook §3.1</strong>
<div class="helper">Contextual safety framing only, not the basis of the compliance statement.</div>
<div class="citation">Kept in thread for reviewer context but not recommended as the lead citation.</div>
</div>
</div>
</aside>
</section>
</main>
<footer class="footer">
<span>T-Systems Regulation</span>
<div class="footer-status">
<span>Desktop Web</span>
<span class="footer-dot" aria-hidden="true"></span>
<span>Online</span>
</div>
</footer>
</div>
</div>
<script src="ui-preferences.js"></script>
</body>
</html>

View File

@@ -0,0 +1,582 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI + Compliance Hub - Upload Documents</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--surface-warm: var(--surface);
--fg: #111111;
--fg-2: var(--fg);
--muted: #6b6b6b;
--meta: var(--muted);
--border: #e5e5e5;
--border-soft: var(--border);
--primary: #e20074;
--accent: var(--primary);
--accent-on: #ffffff;
--accent-hover: color-mix(in oklab, var(--accent), black 8%);
--accent-active: color-mix(in oklab, var(--accent), black 14%);
--success: #17a34a;
--warn: #eab308;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold", "TeleNeoWeb-Medium", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-body: "TeleNeoWeb-Regular", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 20px;
--text-xl: 24px;
--text-2xl: 32px;
--text-3xl: 48px;
--text-4xl: 64px;
--leading-body: 1.5;
--leading-tight: 1.2;
--tracking-display: -0.01em;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-20: 80px;
--section-y-desktop: 80px;
--section-y-tablet: 48px;
--section-y-phone: 32px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
--elev-flat: none;
--elev-ring: 0 0 0 1px var(--border);
--elev-raised: 0 2px 8px color-mix(in oklab, var(--fg), transparent 92%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 70%);
--motion-fast: 150ms;
--motion-base: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--container-max: 1200px;
--container-gutter-desktop: 24px;
--container-gutter-tablet: 16px;
--container-gutter-phone: 12px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0f1014;
--surface: #17181d;
--surface-warm: #1d1f26;
--fg: #f5f7fb;
--fg-2: #e5e8ef;
--muted: #a2a9b8;
--meta: #858d9c;
--border: #2a2d35;
--border-soft: #21242c;
--accent-hover: color-mix(in oklab, var(--accent), white 12%);
--accent-active: color-mix(in oklab, var(--accent), black 6%);
--success: #22c55e;
--warn: #facc15;
--danger: #f87171;
--elev-raised: 0 14px 36px color-mix(in oklab, black, transparent 74%);
--focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 56%);
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(180deg, color-mix(in oklab, var(--bg), var(--fg) 4%), var(--bg)),
var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--leading-body);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
display: grid;
place-items: center;
padding: 24px;
}
h1, h2, h3 {
margin: 0;
font-family: var(--font-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-display);
}
p { margin: 0; text-wrap: pretty; }
button, input, select, textarea { font: inherit; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--fg); text-decoration: underline; }
.od-theme-toggle {
cursor: pointer;
transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard);
}
.od-theme-toggle:hover {
color: var(--fg);
border-color: var(--fg);
text-decoration: none;
}
.od-theme-toggle:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 36px;
padding: 0 24px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: var(--text-xs);
font-family: var(--font-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
background: color-mix(in oklab, var(--bg), var(--surface) 12%);
}
.footer-status {
display: inline-flex;
align-items: center;
gap: 10px;
}
.footer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #19d3a2;
box-shadow: 0 0 0 4px color-mix(in oklab, #19d3a2, transparent 84%);
}
.frame {
width: min(1160px, 100%);
display: grid;
gap: 18px;
}
.topline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.back {
color: var(--muted);
font-size: var(--text-sm);
}
.modal {
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 0;
border: 1px solid var(--border);
border-radius: 18px;
overflow: hidden;
background: var(--surface);
box-shadow: var(--elev-raised);
}
.panel {
padding: 28px;
min-width: 0;
}
.panel + .panel {
border-left: 1px solid var(--border);
background: color-mix(in oklab, var(--surface), var(--bg) 24%);
}
.eyebrow {
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 10px;
}
.lead {
color: var(--muted);
margin-top: 12px;
max-width: 56ch;
}
.dropzone {
margin-top: 24px;
border: 1px dashed color-mix(in oklab, var(--accent), white 54%);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--accent), white 94%);
padding: 30px;
display: grid;
gap: 14px;
min-height: 220px;
align-content: center;
justify-items: center;
text-align: center;
}
.drop-visual {
width: 56px;
height: 56px;
border-radius: 18px;
border: 1px solid color-mix(in oklab, var(--accent), white 48%);
background: var(--surface);
display: grid;
place-items: center;
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 18px;
}
.btn {
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--fg);
transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard);
}
.btn:hover { border-color: var(--fg); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.theme-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 40px;
padding: 0 12px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
font-size: var(--text-sm);
cursor: pointer;
transition: background var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), color var(--motion-fast) var(--ease-standard);
}
.theme-toggle:hover {
color: var(--fg);
border-color: var(--fg);
text-decoration: none;
}
.theme-toggle:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.btn:focus-visible,
.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.selected-files {
margin-top: 22px;
display: grid;
gap: 12px;
}
.file-row,
.queue-row {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 14px 16px;
display: grid;
gap: 10px;
}
.file-top,
.queue-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.meta,
.hint {
color: var(--muted);
font-size: var(--text-sm);
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 22px;
}
.field {
display: grid;
gap: 6px;
}
.field label {
color: var(--muted);
font-size: var(--text-sm);
}
.field input,
.field select,
.field textarea {
min-height: 44px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 0 12px;
color: var(--fg);
}
.field textarea {
min-height: 104px;
padding: 12px;
resize: vertical;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 4px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.status.ok { color: var(--success); }
.status.warn { color: color-mix(in oklab, var(--warn), black 24%); }
.status.risk { color: var(--danger); }
.progress {
height: 8px;
border-radius: 999px;
background: color-mix(in oklab, var(--fg), transparent 95%);
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
background: color-mix(in oklab, var(--accent), white 26%);
border-radius: inherit;
}
.queue {
margin-top: 18px;
display: grid;
gap: 12px;
}
.summary {
display: grid;
gap: 12px;
margin-top: 22px;
}
.summary-card {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in oklab, var(--surface), var(--bg) 12%);
padding: 14px 16px;
}
@media (max-width: 980px) {
.modal,
.grid { grid-template-columns: 1fr; }
.panel + .panel { border-left: 0; border-top: 1px solid var(--border); }
}
</style>
</head>
<body data-page="upload">
<div class="frame">
<div class="topline">
<a class="back" href="document-management.html">← Back to document management</a>
<span class="hint">Modal review surface for intake, metadata, and queue behavior</span>
</div>
<main class="modal">
<section class="panel" data-od-id="upload-form">
<div class="eyebrow">Upload documents</div>
<h1>Stage files for parsing and indexing.</h1>
<p class="lead">This intake flow supports PDF, DOCX, and supplier evidence bundles. Metadata is captured up front so the downstream parser and retrieval pipeline stay normalized.</p>
<div class="dropzone">
<div class="drop-visual">PDF</div>
<h2 style="font-size:28px;">Drop files here or browse your local archive.</h2>
<p class="hint">Recommended per batch: up to 20 files, 200 MB combined, one regulation family per batch.</p>
<div class="btn-row">
<button class="btn btn-primary">Choose files</button>
<button class="btn">Paste object storage link</button>
</div>
</div>
<div class="selected-files">
<div class="file-row">
<div class="file-top">
<div>
<strong>GBT31484_revision_notes.pdf</strong>
<div class="meta mono">size pending validation · scanned appendix included</div>
</div>
<span class="status ok">Ready</span>
</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="file-row">
<div class="file-top">
<div>
<strong>supplier_thermal_test_report.docx</strong>
<div class="meta mono">size pending validation · confidential evidence supplement</div>
</div>
<span class="status warn">Metadata</span>
</div>
<div class="progress"><span style="width:58%"></span></div>
</div>
</div>
<div class="grid">
<div class="field">
<label for="regulation-type">Regulation type</label>
<select id="regulation-type">
<option selected>Battery safety</option>
<option>Vehicle safety</option>
<option>Cybersecurity</option>
<option>Charging</option>
</select>
</div>
<div class="field">
<label for="version">Version / release</label>
<input id="version" type="text" value="current revision" />
</div>
<div class="field">
<label for="owner">Owning team</label>
<select id="owner">
<option selected>Battery Safety Team</option>
<option>Connected Fleet</option>
<option>Homologation Office</option>
</select>
</div>
<div class="field">
<label for="parser">Parser backend</label>
<select id="parser">
<option selected>Aliyun parser</option>
<option>Legacy local parser</option>
</select>
</div>
<div class="field" style="grid-column:1 / -1;">
<label for="notes">Reviewer note</label>
<textarea id="notes">Re-run this batch against the latest battery energy density requirements and keep supplier evidence attached to the same review thread.</textarea>
</div>
</div>
<div class="btn-row" style="margin-top:24px;">
<button class="btn">Save draft batch</button>
<button class="btn btn-primary">Start import queue</button>
</div>
</section>
<aside class="panel" data-od-id="upload-queue">
<div class="eyebrow">Import queue</div>
<h2 style="font-size:28px;">Batch progress and validation feedback</h2>
<p class="lead" style="font-size:16px;">Reviewers can monitor preflight checks before the parser job is submitted, then watch queue depth and failure mode without leaving the modal.</p>
<div class="summary">
<div class="summary-card">
<strong class="mono">2 files validated</strong>
<div class="hint">Files already validated for metadata completeness</div>
</div>
<div class="summary-card">
<strong class="mono">live queue</strong>
<div class="hint">Current Aliyun parser queue depth populates here</div>
</div>
<div class="summary-card">
<strong class="mono">text-embedding-v3</strong>
<div class="hint">Embedding target applied after semantic block extraction</div>
</div>
</div>
<div class="queue">
<div class="queue-row">
<div class="queue-top">
<div>
<strong>Preflight validation</strong>
<div class="meta">Duplicate ID scan, file-type validation, metadata completeness</div>
</div>
<span class="status ok">Passed</span>
</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="queue-row">
<div class="queue-top">
<div>
<strong>Object storage upload</strong>
<div class="meta">Bucket `upload-files` · transient object names attached to batch</div>
</div>
<span class="status ok">Completed</span>
</div>
<div class="progress"><span style="width:100%"></span></div>
</div>
<div class="queue-row">
<div class="queue-top">
<div>
<strong>Aliyun parse submission</strong>
<div class="meta">Polling every <span class="mono">5s</span> · timeout <span class="mono">900s</span></div>
</div>
<span class="status warn">Queued</span>
</div>
<div class="progress"><span style="width:42%"></span></div>
</div>
<div class="queue-row">
<div class="queue-top">
<div>
<strong>Chunking + embedding</strong>
<div class="meta">Build semantic blocks, overlapping vector chunks, then 1024-d embeddings</div>
</div>
<span class="status warn">Waiting</span>
</div>
<div class="progress"><span style="width:12%"></span></div>
</div>
</div>
<div class="summary-card" style="margin-top:18px;">
<strong>Potential issue to resolve</strong>
<p class="hint" style="margin-top:8px;">`supplier_thermal_test_report.docx` lacks a formal standard number. The batch can continue, but retrieval quality improves if you assign an evidence relationship before submit.</p>
</div>
</aside>
</main>
</div>
<script src="ui-preferences.js"></script>
</body>
</html>

View File

@@ -0,0 +1,709 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>System Status — T-Systems Regulation Hub</title>
<style>
/* ─── Design tokens ─────────────────────────────────────────────── */
:root {
/* Sidebar (light rail) */
--rail-bg: #ffffff;
--rail-surface: #f7f8fa;
--rail-fg: #111827;
--rail-muted: #8b929e;
--rail-border: #e8eaed;
--rail-hover: rgba(0,0,0,.04);
--rail-active: rgba(226,0,116,.07);
/* Canvas (content area) */
--bg: #f2f4f7;
--surface: #ffffff;
--fg: #111827;
--muted: #6b7280;
--border: #e5e7eb;
/* Brand */
--accent: #e20074;
--accent-dim: rgba(226,0,116,.10);
--accent-hover: #c8006a;
--success: #16a34a;
--warn: #d97706;
--danger: #dc2626;
/* Type */
--font-display: "TeleNeoWeb-Bold","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--font-body: "TeleNeoWeb-Regular","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--font-mono: ui-monospace,"JetBrains Mono",Menlo,monospace;
--radius-sm: 6px;
--radius-md: 10px;
--radius-pill: 9999px;
--sidebar-w: 232px;
--shadow-card: 0 1px 4px rgba(0,0,0,.06), 0 0 0 1px rgba(0,0,0,.04);
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
}
/* ─── Reset ────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1,h2,h3,h4 { font-family: var(--font-display); line-height: 1.2; letter-spacing: -0.015em; text-wrap: balance; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; cursor: pointer; }
/* ─── App shell ─────────────────────────────────────────────────── */
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
/* ─── Sidebar ───────────────────────────────────────────────────── */
.sidebar {
position: sticky; top: 0; height: 100vh;
overflow-y: auto; overflow-x: hidden;
display: flex; flex-direction: column;
background: var(--rail-bg);
border-right: 1px solid var(--rail-border);
z-index: 20;
}
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
height: 54px; padding: 0 16px;
border-bottom: 1px solid var(--rail-border);
flex-shrink: 0;
}
.brand-logo {
width: 28px; height: 28px;
background: var(--accent);
border-radius: 7px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.brand-logo svg { color: #fff; }
.brand-name { font-family: var(--font-display); font-size: 13px; color: var(--rail-fg); line-height: 1.2; font-weight: 700; }
.brand-sub { font-size: 10px; color: var(--rail-muted); font-family: var(--font-mono); letter-spacing: .04em; margin-top: 2px; }
.sidebar-nav { flex: 1; padding: 10px 0; }
.nav-group { padding: 0 8px 4px; }
.nav-group + .nav-group { margin-top: 8px; padding-top: 10px; border-top: 1px solid var(--rail-border); }
.nav-group-label {
display: block;
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .12em;
color: var(--rail-muted);
padding: 0 8px 6px;
}
.nav-item {
display: flex; align-items: center; gap: 9px;
height: 34px; padding: 0 8px;
border-radius: var(--radius-sm);
color: #4b5563;
font-size: 13px;
transition: background 120ms, color 120ms;
position: relative;
}
.nav-item:hover { background: var(--rail-hover); color: var(--rail-fg); text-decoration: none; }
.nav-item.active {
background: var(--rail-active);
color: var(--accent);
font-weight: 600;
}
.nav-item.active::before {
content: "";
position: absolute; left: 0; top: 6px; bottom: 6px;
width: 3px; border-radius: 0 3px 3px 0;
background: var(--accent);
}
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; opacity: .55; }
.nav-item:hover .nav-icon,
.nav-item.active .nav-icon { opacity: 1; }
.nav-badge {
margin-left: auto;
min-width: 18px; height: 17px; padding: 0 5px;
border-radius: var(--radius-pill);
background: var(--accent-dim); color: var(--accent);
font-size: 10px; font-family: var(--font-mono); font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.sidebar-footer {
border-top: 1px solid var(--rail-border);
padding: 10px 8px;
flex-shrink: 0;
}
.sidebar-user {
display: flex; align-items: center; gap: 9px;
padding: 8px; border-radius: var(--radius-sm);
cursor: pointer;
transition: background 120ms;
}
.sidebar-user:hover { background: var(--rail-hover); }
.avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--accent); color: #fff;
font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.user-name { font-size: 12px; font-weight: 600; color: var(--rail-fg); }
.user-role { font-size: 10px; color: var(--rail-muted); font-family: var(--font-mono); margin-top: 1px; }
.sidebar-action {
display: flex; align-items: center; gap: 9px;
height: 32px; padding: 0 8px;
border-radius: var(--radius-sm);
color: var(--rail-muted); font-size: 12px;
border: none; background: transparent; width: 100%;
text-align: left;
transition: background 120ms, color 120ms;
}
.sidebar-action:hover { background: var(--rail-hover); color: var(--rail-fg); }
/* ─── Content area ──────────────────────────────────────────────── */
.content-area { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 10px;
height: 54px; padding: 0 24px;
border-bottom: 1px solid var(--border);
background: rgba(242,244,247,.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
flex-shrink: 0;
}
.topbar-title { font-weight: 600; font-size: 14px; flex: 1; color: var(--fg); }
.search {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--surface);
padding: 0 10px; height: 32px; width: 240px;
transition: border-color 140ms;
}
.search:focus-within { border-color: color-mix(in oklab, var(--accent), transparent 60%); }
.search input { border: 0; outline: none; background: transparent; width: 100%; color: var(--fg); font-size: 13px; }
.search input::placeholder { color: var(--muted); }
.btn {
height: 32px; border-radius: var(--radius-sm);
border: 1px solid var(--border);
padding: 0 12px;
background: var(--surface); color: var(--fg);
font-size: 13px; font-weight: 500;
cursor: pointer;
transition: background 120ms, border-color 120ms;
white-space: nowrap;
display: inline-flex; align-items: center; gap: 6px;
}
.btn:hover { background: var(--bg); border-color: #b0b7c0; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* ─── Page ──────────────────────────────────────────────────────── */
.page { padding: 20px; display: grid; gap: 18px; flex: 1; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.eyebrow {
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .1em;
color: var(--accent); margin-bottom: 6px;
}
.page-head h1 { font-size: clamp(20px, 2.4vw, 28px); }
.page-head-desc { margin-top: 4px; font-size: 13px; color: var(--muted); max-width: 520px; line-height: 1.55; }
/* ─── Cards ─────────────────────────────────────────────────────── */
.card {
background: var(--surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: 16px 18px;
}
/* ─── Stats row ─────────────────────────────────────────────────── */
.stats-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 12px; }
.stat-card {
border-top: 2px solid var(--border);
transition: border-color 200ms;
}
.stat-card:hover { border-top-color: var(--accent); }
.stat-card .s-label {
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .1em; color: var(--muted);
}
.stat-card .s-value {
margin-top: 10px;
font-size: 32px; line-height: 1;
font-family: var(--font-display);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
color: var(--fg);
}
.stat-card .s-sub { margin-top: 8px; font-size: 11px; color: var(--muted); line-height: 1.5; }
/* ─── Two-column panel grid ─────────────────────────────────────── */
.panel-grid { display: grid; grid-template-columns: 1.4fr 0.9fr; gap: 18px; }
.stack { display: grid; gap: 18px; }
/* ─── Section heads ─────────────────────────────────────────────── */
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.section-head h2 { font-size: 16px; }
.ghost-link {
color: var(--muted); font-size: 12px;
padding: 2px 0; border-radius: 4px;
transition: color 120ms;
}
.ghost-link:hover { color: var(--fg); text-decoration: none; }
/* ─── Task rows ─────────────────────────────────────────────────── */
.task-list, .program-list, .event-list { display: grid; gap: 8px; }
.task-row, .program-row, .event-row {
display: grid; gap: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
background: var(--surface);
transition: border-color 120ms, box-shadow 120ms;
}
.task-row:hover, .program-row:hover {
border-color: #c8cdd8;
box-shadow: var(--shadow-sm);
}
.task-row { grid-template-columns: 1.6fr 0.8fr 0.8fr 0.6fr; align-items: center; }
.program-row{ grid-template-columns: 1fr auto; align-items: start; }
.event-row { grid-template-columns: 76px 1fr; align-items: start; }
/* ─── KPI strip ─────────────────────────────────────────────────── */
.kpi-strip { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 12px; }
.kpi {
border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 10px 12px; background: #f8f9fb;
}
.kpi strong { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 17px; color: var(--fg); }
.kpi-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.meter { height: 3px; border-radius: 999px; background: #e5e7eb; overflow: hidden; margin-top: 8px; }
.meter > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
/* ─── Dark mode ────────────────────────────────────────────────── */
[data-theme="dark"] {
--rail-bg: #1a1c22;
--rail-surface: #22242c;
--rail-fg: #f0f2f5;
--rail-muted: #7a8390;
--rail-border: #2d3038;
--rail-hover: rgba(255,255,255,.05);
--rail-active: rgba(226,0,116,.12);
--bg: #111318;
--surface: #1a1c22;
--fg: #f0f2f5;
--muted: #7a8390;
--border: #2d3038;
}
[data-theme="dark"] body { color-scheme: dark; }
[data-theme="dark"] .topbar { background: rgba(17,19,24,.9); }
[data-theme="dark"] .kpi { background: #1e2028; }
[data-theme="dark"] .task-row, [data-theme="dark"] .program-row, [data-theme="dark"] .event-row { background: #1e2028; }
/* ─── Status pills ──────────────────────────────────────────────── */
.status {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: var(--radius-pill);
font-size: 11px; font-family: var(--font-mono); font-weight: 600;
width: fit-content; white-space: nowrap;
}
.status::before {
content: ""; width: 5px; height: 5px;
border-radius: 50%; background: currentColor; flex-shrink: 0;
}
.status.ok { color: var(--success); background: color-mix(in oklab,var(--success),transparent 90%); }
.status.warn { color: var(--warn); background: color-mix(in oklab,var(--warn),transparent 90%); }
.status.risk { color: var(--danger); background: color-mix(in oklab,var(--danger),transparent 90%); }
/* ─── Pill (version tag) ────────────────────────────────────────── */
.pill {
display: inline-flex; align-items: center;
height: 20px; padding: 0 8px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
color: var(--muted); font-size: 10px;
font-family: var(--font-mono); font-weight: 500;
}
/* ─── Task CTA buttons ──────────────────────────────────────────── */
.task-cta {
display: inline-flex; align-items: center; gap: 4px;
height: 26px; padding: 0 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent; color: var(--muted);
font-size: 11px; font-family: var(--font-body);
white-space: nowrap; cursor: pointer;
transition: border-color 120ms, color 120ms, background 120ms;
}
.task-cta:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); text-decoration: none; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12px; }
.note { font-size: 12px; color: var(--muted); line-height: 1.5; }
/* ─── Footer ────────────────────────────────────────────────────── */
.footer {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
min-height: 34px; padding: 0 20px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 10px; font-family: var(--font-mono);
letter-spacing: .1em; text-transform: uppercase;
flex-shrink: 0;
}
.footer-live { display: inline-flex; align-items: center; gap: 7px; }
.footer-dot {
width: 6px; height: 6px; border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34,197,94,.2);
}
/* ─── Responsive ────────────────────────────────────────────────── */
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(2,1fr); }
.panel-grid { grid-template-columns: 1fr; }
.kpi-strip { grid-template-columns: 1fr 1fr; }
.task-row { grid-template-columns: 1fr auto; }
}
@media (max-width: 700px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.stats-grid, .kpi-strip { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="app-shell">
<!-- ─── Sidebar ─────────────────────────────────────────────────────── -->
<aside class="sidebar" aria-label="Primary navigation">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
</svg>
</div>
<div>
<div class="brand-name">T-Systems</div>
<div class="brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<div class="nav-group">
<span class="nav-group-label">Main</span>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/index.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none">
<path d="M2 2h5v5H2zm7 0h5v5H9zM2 9h5v5H2zm7 0h5v5H9z" fill="currentColor" opacity=".6"/>
</svg>
Overview
</a>
<a class="nav-item" href="perception.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.5" fill="currentColor"/>
<path d="M8 2.5C4.91 2.5 2.5 5.42 2.5 8S4.91 13.5 8 13.5 13.5 10.58 13.5 8 11.09 2.5 8 2.5zm0 9.5C5.52 12 3.5 10.24 3.5 8S5.52 4 8 4s4.5 1.76 4.5 4-2.02 4-4.5 4z" fill="currentColor" opacity=".45"/>
</svg>
Regulatory Signals
<span class="nav-badge">6</span>
</a>
<a class="nav-item active" href="dashboard-sidebar.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none">
<path d="M1.5 2.5h13v1H1.5zm0 3h13v1H1.5zm0 3h8v1h-8zm0 3h6v1h-6z" fill="currentColor"/>
</svg>
System Status
<span class="nav-badge">3</span>
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">Workbench</span>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/document-management.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none">
<path d="M3 1h7l3 3v11H3V1zm1 1v12h8V5h-3V2H4zm5 .5V4h1.5L9 1.5zM6 7h4v1H6zm0 2h4v1H6zm0 2h3v1H6z" fill="currentColor"/>
</svg>
Documents
</a>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/compliance-analysis.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none">
<path d="M8 1l7 3-1 6a7 7 0 01-6 5A7 7 0 011 10L0 4l8-3zm0 1.2L1.3 4.8l.8 5.1A6 6 0 008 14.8a6 6 0 005.9-4.9l.8-5.1L8 2.2zM7.5 5h1v4.5h-1V5zm0 5.5h1v1h-1v-1z" fill="currentColor"/>
</svg>
Compliance Analysis
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">Chat</span>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/regulation-chat.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none">
<path d="M2 2h12a1 1 0 011 1v8a1 1 0 01-1 1H5l-3 2.5V3a1 1 0 011-1zm0 1v9.5L4.5 11H14V3H2zm2 2h8v1H4zm0 2h6v1H4z" fill="currentColor"/>
</svg>
Regulation Q&amp;A
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar">TS</div>
<div>
<div class="user-name">T-Systems User</div>
<div class="user-role">Compliance Analyst</div>
</div>
</div>
<button class="sidebar-action" type="button" onclick="toggleTheme()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M8 3a5 5 0 100 10A5 5 0 008 3zM2 8a6 6 0 1112 0A6 6 0 012 8z" fill="currentColor"/>
</svg>
<span>Dark mode</span>
</button>
</div>
</aside>
<!-- ─── Content ──────────────────────────────────────────────────────── -->
<div class="content-area">
<header class="topbar">
<span class="topbar-title">System Status</span>
<div class="search">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
<path d="M6.5 1a5.5 5.5 0 014.23 9.02l3.62 3.62-.7.71-3.63-3.63A5.5 5.5 0 116.5 1zm0 1a4.5 4.5 0 100 9 4.5 4.5 0 000-9z" fill="currentColor" opacity=".4"/>
</svg>
<input type="search" placeholder="Search regulations, documents…" aria-label="Search" />
</div>
<button class="btn">Export status</button>
<a class="btn btn-primary" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/upload-modal.html">New upload</a>
</header>
<main class="page">
<!-- Page head -->
<section class="page-head">
<div>
<div class="eyebrow">System Status</div>
<h1>System Status</h1>
<p class="page-head-desc">Ingestion pipeline, active compliance programs, and regulatory watch — all in one place.</p>
</div>
<span class="pill">v1.0.0</span>
</section>
<!-- Stats -->
<section class="stats-grid">
<article class="card stat-card">
<div class="s-label">Documents total</div>
<div class="s-value mono" id="ds-docs"></div>
<div class="s-sub">Ingested into the knowledge base</div>
</article>
<article class="card stat-card">
<div class="s-label">Vector chunks</div>
<div class="s-value mono" id="ds-chunks"></div>
<div class="s-sub">regulations_dense_1024_v2 serving retrieval</div>
</article>
<article class="card stat-card">
<div class="s-label">High-impact signals</div>
<div class="s-value mono" id="ds-high"></div>
<div class="s-sub">Regulatory signals requiring immediate review</div>
</article>
<article class="card stat-card">
<div class="s-label">Last 90 days</div>
<div class="s-value mono" id="ds-90d"></div>
<div class="s-sub">Recent regulatory publications</div>
</article>
</section>
<!-- Two-column panels -->
<section class="panel-grid">
<!-- Left column -->
<div class="stack">
<article class="card">
<div class="section-head">
<h2>Workflow queue</h2>
<a class="ghost-link" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/document-management.html">Open documents &rarr;</a>
</div>
<div class="task-list">
<div class="task-row">
<div>
<strong>GB/T 31484-2015 battery density revision</strong>
<div class="note">Uploaded by EV Safety Team · version 2026-04 addendum</div>
</div>
<span class="status warn">Embedding</span>
<span class="mono">chunk build active</span>
<a class="task-cta" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/document-detail.html">Inspect &rarr;</a>
</div>
<div class="task-row">
<div>
<strong>UNECE R155 annex interpretation note</strong>
<div class="note">Parser artifacts ready · waiting for analyst assignment</div>
</div>
<span class="status ok">Ready</span>
<span class="mono">19 clauses linked</span>
<a class="task-cta" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/compliance-analysis.html">Analyze &rarr;</a>
</div>
<div class="task-row">
<div>
<strong>GB 26112-2010 roof strength scan</strong>
<div class="note">OCR confidence dropped below threshold on 6 pages</div>
</div>
<span class="status risk">Failed</span>
<span class="mono">Retry #2</span>
<a class="task-cta" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/document-management.html">Resolve &rarr;</a>
</div>
</div>
</article>
<article class="card">
<div class="section-head">
<h2>Active compliance programs</h2>
<a class="ghost-link" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/compliance-analysis.html">Review findings &rarr;</a>
</div>
<div class="program-list">
<div class="program-row">
<div>
<strong>Intelligent cockpit homologation</strong>
<p class="note">42 related standards across driver monitoring, EMC, and child safety. Four findings still open for MY27 platform.</p>
</div>
<span class="status risk">High risk</span>
</div>
<div class="program-row">
<div>
<strong>Battery swap certification dossier</strong>
<p class="note">Clause mapping complete. Thermal event test evidence package awaiting supplier document refresh.</p>
</div>
<span class="status warn">Pending</span>
</div>
<div class="program-row">
<div>
<strong>Connected fleet cybersecurity</strong>
<p class="note">RAG checks aligned with UNECE R155. Chat follow-up requested on remote key rotation obligations.</p>
</div>
<span class="status ok">On track</span>
</div>
</div>
<div class="kpi-strip">
<div class="kpi">
<div class="kpi-label">Retrieval hit rate</div>
<strong>87%</strong>
<div class="meter"><span style="width:87%"></span></div>
</div>
<div class="kpi">
<div class="kpi-label">Evidence coverage</div>
<strong>72%</strong>
<div class="meter"><span style="width:72%"></span></div>
</div>
<div class="kpi">
<div class="kpi-label">Reviewer SLA</div>
<strong>18h</strong>
<div class="meter"><span style="width:64%"></span></div>
</div>
</div>
</article>
</div>
<!-- Right column -->
<div class="stack">
<article class="card">
<div class="section-head"><h2>System health</h2><a class="ghost-link" href="#">Refresh</a></div>
<div class="task-list">
<div class="task-row" style="grid-template-columns:1fr auto">
<div>
<strong>Aliyun parser backend</strong>
<div class="note">Poll interval 5 s · timeout 900 s</div>
</div>
<span class="status warn">Queue depth 7</span>
</div>
<div class="task-row" style="grid-template-columns:1fr auto">
<div>
<strong>Embedding model</strong>
<div class="note">text-embedding-v3 · dimension 1024</div>
</div>
<span class="status ok">Healthy</span>
</div>
<div class="task-row" style="grid-template-columns:1fr auto">
<div>
<strong>Vector store</strong>
<div class="note">Milvus regulations_dense_1024_v2</div>
</div>
<span class="status ok">Serving</span>
</div>
</div>
</article>
<article class="card">
<div class="section-head">
<h2>Regulatory watch</h2>
<a class="ghost-link" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/regulation-chat.html">Ask chat &rarr;</a>
</div>
<div class="event-list">
<div class="event-row">
<span class="mono note">2d ago</span>
<div>
<strong>GB 38031 thermal propagation draft updated</strong>
<p class="note" style="-webkit-line-clamp:2;display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden">Potential impact on current battery enclosure narrative. Evidence gap flagged in two supplier submissions.</p>
</div>
</div>
<div class="event-row">
<span class="mono note">5d ago</span>
<div>
<strong>UNECE R155 Q&amp;A added note on incident response logs</strong>
<p class="note" style="-webkit-line-clamp:2;display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden">Connected fleet program must confirm retention windows and ownership controls.</p>
</div>
</div>
<div class="event-row">
<span class="mono note">12d ago</span>
<div>
<strong>GB/T 18487 charging interface interpretation circulated</strong>
<p class="note" style="-webkit-line-clamp:2;display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden">No blocker yet, but three documents should be re-run against the new clause wording.</p>
</div>
</div>
</div>
</article>
</div>
</section>
</main>
<footer class="footer">
<span>T-Systems Regulation Hub</span>
<div class="footer-live">
<span class="footer-dot" aria-hidden="true"></span>
<span>Online</span>
</div>
</footer>
</div>
</div>
<script>
// ─── Theme toggle (P0 fix: reads data-theme attribute) ───────────────
function toggleTheme() {
const html = document.documentElement;
const next = html.dataset.theme === 'dark' ? 'light' : 'dark';
html.dataset.theme = next;
localStorage.setItem('theme', next);
const btn = document.querySelector('.sidebar-action');
if (btn) btn.querySelector('span') && (btn.querySelector('span').textContent = next === 'dark' ? 'Light mode' : 'Dark mode');
}
// Restore saved theme
(function() {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.dataset.theme = saved;
})();
// ─── Live stats from perception API ──────────────────────────────────
async function loadDashboardStats() {
try {
const r = await fetch('http://6.86.80.9:5173/api/v1/perception/stats');
if (!r.ok) return;
const s = await r.json();
const set = (id, val) => { const el = document.getElementById(id); if (el && val != null) el.textContent = val; };
set('ds-high', s.high_impact);
set('ds-90d', s.recent_90d);
set('ds-docs', s.total);
} catch(e) { /* silent — fallback to — */ }
}
loadDashboardStats();
</script>
</body>
</html>

11
Prototype/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url=dashboard-sidebar.html" />
<title>TSI Regulation Hub</title>
</head>
<body>
<script>location.replace("dashboard-sidebar.html");</script>
</body>
</html>

915
Prototype/perception.html Normal file
View File

@@ -0,0 +1,915 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Regulatory Signals — T-Systems Regulation Hub</title>
<style>
/* ─── Design tokens (identical to dashboard-sidebar.html) ────────── */
:root {
--rail-bg: #ffffff;
--rail-surface: #f7f8fa;
--rail-fg: #111827;
--rail-muted: #8b929e;
--rail-border: #e8eaed;
--rail-hover: rgba(0,0,0,.04);
--rail-active: rgba(226,0,116,.07);
--bg: #f2f4f7;
--surface: #ffffff;
--fg: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #e20074;
--accent-dim: rgba(226,0,116,.10);
--accent-hover: #c8006a;
--success: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--font-display: "TeleNeoWeb-Bold","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--font-body: "TeleNeoWeb-Regular","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--font-mono: ui-monospace,"JetBrains Mono",Menlo,monospace;
--radius-sm: 6px;
--radius-md: 10px;
--radius-pill: 9999px;
--sidebar-w: 232px;
--shadow-card: 0 1px 4px rgba(0,0,0,.06), 0 0 0 1px rgba(0,0,0,.04);
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
}
/* ─── Reset ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1,h2,h3,h4 { font-family: var(--font-display); line-height: 1.2; letter-spacing: -0.015em; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; cursor: pointer; }
/* ─── App shell ──────────────────────────────────────────────────── */
.app-shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
/* ─── Sidebar (light rail — identical to dashboard) ───────────────── */
.sidebar {
position: sticky; top: 0; height: 100vh;
overflow-y: auto; overflow-x: hidden;
display: flex; flex-direction: column;
background: var(--rail-bg);
border-right: 1px solid var(--rail-border);
z-index: 20;
}
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
height: 54px; padding: 0 16px;
border-bottom: 1px solid var(--rail-border);
flex-shrink: 0;
}
.brand-logo {
width: 28px; height: 28px;
background: var(--accent);
border-radius: 7px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.brand-logo svg { color: #fff; }
.brand-name { font-family: var(--font-display); font-size: 13px; color: var(--rail-fg); line-height: 1.2; font-weight: 700; }
.brand-sub { font-size: 10px; color: var(--rail-muted); font-family: var(--font-mono); letter-spacing: .04em; margin-top: 2px; }
.sidebar-nav { flex: 1; padding: 10px 0; }
.nav-group { padding: 0 8px 4px; }
.nav-group + .nav-group { margin-top: 8px; padding-top: 10px; border-top: 1px solid var(--rail-border); }
.nav-group-label {
display: block;
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .12em;
color: var(--rail-muted);
padding: 0 8px 6px;
}
.nav-item {
display: flex; align-items: center; gap: 9px;
height: 34px; padding: 0 8px;
border-radius: var(--radius-sm);
color: #4b5563;
font-size: 13px;
transition: background 120ms, color 120ms;
position: relative;
}
.nav-item:hover { background: var(--rail-hover); color: var(--rail-fg); text-decoration: none; }
.nav-item.active { background: var(--rail-active); color: var(--accent); font-weight: 600; }
.nav-item.active::before {
content: "";
position: absolute; left: 0; top: 6px; bottom: 6px;
width: 3px; border-radius: 0 3px 3px 0;
background: var(--accent);
}
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; opacity: .55; }
.nav-item:hover .nav-icon,
.nav-item.active .nav-icon { opacity: 1; }
.nav-badge {
margin-left: auto;
min-width: 18px; height: 17px; padding: 0 5px;
border-radius: var(--radius-pill);
background: var(--accent-dim); color: var(--accent);
font-size: 10px; font-family: var(--font-mono); font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.sidebar-footer {
border-top: 1px solid var(--rail-border);
padding: 10px 8px;
flex-shrink: 0;
}
.sidebar-user {
display: flex; align-items: center; gap: 9px;
padding: 8px; border-radius: var(--radius-sm);
cursor: pointer;
transition: background 120ms;
}
.sidebar-user:hover { background: var(--rail-hover); }
.avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--accent); color: #fff;
font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.user-name { font-size: 12px; font-weight: 600; color: var(--rail-fg); }
.user-role { font-size: 10px; color: var(--rail-muted); font-family: var(--font-mono); margin-top: 1px; }
/* ─── Content area ───────────────────────────────────────────────── */
.content-area { display: flex; flex-direction: column; min-width: 0; min-height: 100vh; }
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 10px;
height: 54px; padding: 0 24px;
border-bottom: 1px solid var(--border);
background: rgba(242,244,247,.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
flex-shrink: 0;
}
.topbar-title { font-weight: 600; font-size: 14px; }
.topbar-sub { color: var(--muted); font-family: var(--font-mono); font-size: 10px; margin-left: 4px; font-weight: 400; }
.btn {
height: 32px; border-radius: var(--radius-sm);
border: 1px solid var(--border);
padding: 0 12px;
background: var(--surface); color: var(--fg);
font-size: 13px; font-weight: 500;
transition: background 120ms, border-color 120ms;
white-space: nowrap;
display: inline-flex; align-items: center; gap: 6px;
}
.btn:hover { background: var(--bg); border-color: #b0b7c0; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-primary:disabled { opacity: .45; cursor: not-allowed; }
.btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }
.search {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--surface);
padding: 0 10px; height: 32px; width: 220px;
transition: border-color 140ms;
}
.search:focus-within { border-color: color-mix(in oklab, var(--accent), transparent 60%); }
.search input { border: 0; outline: none; background: transparent; width: 100%; color: var(--fg); font-size: 13px; }
.search input::placeholder { color: var(--muted); }
/* ─── Stats bar ──────────────────────────────────────────────────── */
.stats-bar {
display: grid; grid-template-columns: repeat(4,1fr);
gap: 1px;
background: var(--border);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.stat-cell {
background: var(--surface);
padding: 14px 20px;
}
.stat-cell .s-label {
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .1em;
color: var(--muted);
}
.stat-cell .s-value {
margin-top: 6px;
font-size: 28px; line-height: 1;
font-family: var(--font-display);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.stat-cell .s-sub { margin-top: 4px; font-size: 11px; color: var(--muted); }
/* ─── Work area (filter bar + split) ────────────────────────────── */
.work-area { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.filter-bar {
display: flex; align-items: center; gap: 6px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
flex-wrap: wrap;
}
.filter-label {
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .08em;
color: var(--muted);
}
.chip {
padding: 3px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
background: transparent; color: var(--muted);
font-size: 11px; font-family: var(--font-mono);
transition: all 100ms;
}
.chip:hover { border-color: var(--fg); color: var(--fg); }
.chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); font-weight: 600; }
.sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; margin: 0 2px; }
/* ─── Two-pane split ─────────────────────────────────────────────── */
.perception-split {
flex: 1; display: grid;
grid-template-columns: 360px 1fr;
min-height: 0;
}
/* ─── Feed pane ─────────────────────────────────────────────────── */
.feed-pane {
display: flex; flex-direction: column;
border-right: 1px solid var(--border);
min-height: 0;
background: var(--bg);
}
.feed-pane-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.feed-pane-head h2 { font-size: 13px; font-weight: 700; }
.feed-count { font-family: var(--font-mono); font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
.feed-scroll {
flex: 1; overflow-y: auto;
padding: 10px 10px;
display: flex; flex-direction: column; gap: 6px;
}
.feed-scroll::-webkit-scrollbar { width: 4px; }
.feed-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Event card */
.ev-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 11px 13px;
cursor: pointer;
transition: border-color 100ms, box-shadow 100ms;
}
.ev-card:hover { border-color: #c4c9d4; }
.ev-card.selected {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.ev-head { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; }
.src-tag {
font-size: 10px; font-weight: 700; padding: 2px 6px;
border-radius: 4px; font-family: var(--font-mono);
}
.std-code { font-family: var(--font-mono); font-size: 10px; color: var(--muted); }
.ev-title { font-weight: 600; font-size: 12px; line-height: 1.4; margin-bottom: 4px; }
.ev-summary {
font-size: 11px; color: var(--muted); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.ev-foot { display: flex; align-items: center; gap: 6px; margin-top: 7px; }
.ev-date { font-family: var(--font-mono); font-size: 10px; color: var(--muted); }
.ev-tag {
font-size: 10px; font-family: var(--font-mono);
padding: 1px 5px; border-radius: 3px;
border: 1px solid var(--border); color: var(--muted);
}
.imp-dot {
margin-left: auto;
font-size: 10px; font-family: var(--font-mono);
}
.loading-msg { font-family: var(--font-mono); font-size: 12px; color: var(--muted); text-align: center; padding: 40px 0; }
/* ─── Analysis pane ─────────────────────────────────────────────── */
.analysis-pane {
display: flex; flex-direction: column;
min-height: 0; overflow-y: auto;
padding: 16px 20px;
gap: 12px;
}
.analysis-pane::-webkit-scrollbar { width: 4px; }
.analysis-pane::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.analysis-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 10px;
text-align: center;
color: var(--muted);
min-height: 300px;
}
.analysis-empty-ring {
width: 40px; height: 40px; border-radius: 50%;
border: 1.5px solid var(--border);
display: flex; align-items: center; justify-content: center;
}
.analysis-empty-label { font-size: 13px; }
.analysis-empty-hint { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
/* Detail card */
.detail-card {
background: var(--surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: 14px 16px;
}
.detail-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
.detail-title { font-weight: 700; font-size: 14px; line-height: 1.3; margin-bottom: 5px; }
.detail-summary { font-size: 13px; color: var(--muted); line-height: 1.6; }
.detail-meta { display: flex; gap: 14px; margin-top: 10px; flex-wrap: wrap; }
.meta-item { font-family: var(--font-mono); font-size: 10px; color: var(--muted); }
.meta-item strong { color: var(--fg); }
.action-row { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
/* Output card */
.output-card {
background: var(--surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: 14px 16px;
display: none;
}
.output-head {
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 10px;
display: flex; align-items: center; gap: 7px;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
.blink { animation: blink 1s step-end infinite; }
.md-h2 { font-size: 13px; font-weight: 700; color: var(--accent); margin: 14px 0 5px; font-family: var(--font-display); }
.md-h3 { font-size: 12px; font-weight: 700; margin: 10px 0 3px; }
.md-li { display: flex; gap: 7px; margin-bottom: 3px; padding-left: 3px; font-size: 12px; line-height: 1.6; }
.md-li-dot { color: var(--accent); flex-shrink: 0; }
.md-p { font-size: 12px; line-height: 1.7; margin-bottom: 3px; }
.md-empty { height: 5px; }
/* Docs card */
.docs-card {
background: var(--surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: 12px 16px;
display: none;
}
.docs-head {
font-family: var(--font-mono); font-size: 10px;
text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin-bottom: 8px;
}
.doc-row { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; border-top: 1px solid var(--border); }
.doc-row:first-of-type { border-top: none; }
.doc-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); font-weight: 700; flex-shrink: 0; width: 32px; }
.doc-name { font-size: 12px; font-weight: 600; line-height: 1.4; }
.doc-clause { font-family: var(--font-mono); font-size: 10px; color: var(--muted); }
.doc-snippet { font-size: 11px; color: var(--muted); line-height: 1.5; margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* Status pills */
.status {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: var(--radius-pill);
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
white-space: nowrap;
}
.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
.status.ok { color: var(--success); background: color-mix(in oklab,var(--success),transparent 90%); }
.status.warn { color: var(--warn); background: color-mix(in oklab,var(--warn),transparent 90%); }
.status.risk { color: var(--danger); background: color-mix(in oklab,var(--danger),transparent 90%); }
.status.info { color: #3b82f6; background: color-mix(in oklab,#3b82f6,transparent 90%); }
/* Footer */
.footer {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
min-height: 34px; padding: 0 20px;
border-top: 1px solid var(--border);
color: var(--muted); font-size: 10px;
font-family: var(--font-mono);
letter-spacing: .1em; text-transform: uppercase;
flex-shrink: 0;
}
.footer-live { display: inline-flex; align-items: center; gap: 7px; }
.footer-dot {
width: 6px; height: 6px; border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34,197,94,.2);
}
/* Dark mode */
[data-theme="dark"] {
--rail-bg: #1a1c22;
--rail-surface: #22242c;
--rail-fg: #f0f2f5;
--rail-muted: #7a8390;
--rail-border: #2d3038;
--rail-hover: rgba(255,255,255,.05);
--rail-active: rgba(226,0,116,.12);
--bg: #111318;
--surface: #1a1c22;
--fg: #f0f2f5;
--muted: #7a8390;
--border: #2d3038;
}
[data-theme="dark"] body { color-scheme: dark; }
[data-theme="dark"] .topbar { background: rgba(17,19,24,.9); }
[data-theme="dark"] .stats-bar { background: var(--border); }
[data-theme="dark"] .stat-cell { background: var(--surface); }
[data-theme="dark"] .filter-bar { background: var(--surface); }
[data-theme="dark"] .ev-card { background: var(--surface); }
[data-theme="dark"] .detail-card,
[data-theme="dark"] .output-card,
[data-theme="dark"] .docs-card { background: var(--surface); }
/* Sidebar action */
.sidebar-action {
display: flex; align-items: center; gap: 9px;
height: 32px; padding: 0 8px;
border-radius: var(--radius-sm);
color: var(--rail-muted); font-size: 12px;
border: none; background: transparent; width: 100%;
text-align: left; cursor: pointer;
transition: background 120ms, color 120ms;
}
.sidebar-action:hover { background: var(--rail-hover); color: var(--rail-fg); }
/* Responsive */
@media (max-width: 1100px) {
.stats-bar { grid-template-columns: 1fr 1fr; }
.perception-split { grid-template-columns: 300px 1fr; }
}
@media (max-width: 800px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.stats-bar { grid-template-columns: 1fr 1fr; }
.perception-split { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app-shell">
<!-- ─── Sidebar (dark rail) ─────────────────────────────────────────── -->
<aside class="sidebar" aria-label="Primary navigation">
<div class="sidebar-brand">
<div class="brand-logo">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h12v1.5H2zm5.25 1.5h1.5v8h-1.5zm-3 2h2.25v1.5H4.25zm5.25 0h2.25v1.5H9.5zm-3 3h2.5v1.5H6.5z" fill="currentColor"/>
</svg>
</div>
<div>
<div class="brand-name">T-Systems</div>
<div class="brand-sub">Regulation Hub</div>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<div class="nav-group">
<span class="nav-group-label">Main</span>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/index.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h5v5H2zm7 0h5v5H9zM2 9h5v5H2zm7 0h5v5H9z" fill="currentColor" opacity=".6"/></svg>
Overview
</a>
<a class="nav-item active" href="perception.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.5" fill="currentColor"/>
<path d="M8 2.5C4.91 2.5 2.5 5.42 2.5 8S4.91 13.5 8 13.5 13.5 10.58 13.5 8 11.09 2.5 8 2.5zm0 9.5C5.52 12 3.5 10.24 3.5 8S5.52 4 8 4s4.5 1.76 4.5 4-2.02 4-4.5 4z" fill="currentColor" opacity=".45"/>
</svg>
Regulatory Signals
<span class="nav-badge" id="badge-high"></span>
</a>
<a class="nav-item" href="dashboard-sidebar.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M1.5 2.5h13v1H1.5zm0 3h13v1H1.5zm0 3h8v1h-8zm0 3h6v1h-6z" fill="currentColor"/></svg>
System Status
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">Workbench</span>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/document-management.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M3 1h7l3 3v11H3V1zm1 1v12h8V5h-3V2H4zm5 .5V4h1.5L9 1.5zM6 7h4v1H6zm0 2h4v1H6zm0 2h3v1H6z" fill="currentColor"/></svg>
Documents
</a>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/compliance-analysis.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M8 1l7 3-1 6a7 7 0 01-6 5A7 7 0 011 10L0 4l8-3zm0 1.2L1.3 4.8l.8 5.1A6 6 0 008 14.8a6 6 0 005.9-4.9l.8-5.1L8 2.2zM7.5 5h1v4.5h-1V5zm0 5.5h1v1h-1v-1z" fill="currentColor"/></svg>
Compliance Analysis
</a>
</div>
<div class="nav-group">
<span class="nav-group-label">Chat</span>
<a class="nav-item" href="cc29bcb0-df2d-4d50-9428-7caa406ecb29/regulation-chat.html">
<svg class="nav-icon" viewBox="0 0 16 16" fill="none"><path d="M2 2h12a1 1 0 011 1v8a1 1 0 01-1 1H5l-3 2.5V3a1 1 0 011-1zm0 1v9.5L4.5 11H14V3H2zm2 2h8v1H4zm0 2h6v1H4z" fill="currentColor"/></svg>
Regulation Q&amp;A
</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="avatar">TS</div>
<div>
<div class="user-name">T-Systems User</div>
<div class="user-role">Compliance Analyst</div>
</div>
</div>
<button class="sidebar-action" type="button" onclick="toggleTheme()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M8 3a5 5 0 100 10A5 5 0 008 3zM2 8a6 6 0 1112 0A6 6 0 012 8z" fill="currentColor"/>
</svg>
<span>Dark mode</span>
</button>
</div>
</aside>
<!-- ─── Content ───────────────────────────────────────────────────────── -->
<div class="content-area">
<!-- Topbar -->
<div class="topbar">
<span class="topbar-title">
Regulatory Signals
<span class="topbar-sub">Real-time monitoring &middot; Knowledge-base impact analysis</span>
</span>
<div class="search">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none">
<path d="M6.5 1a5.5 5.5 0 014.23 9.02l3.62 3.62-.7.71-3.63-3.63A5.5 5.5 0 116.5 1zm0 1a4.5 4.5 0 100 9 4.5 4.5 0 000-9z" fill="currentColor" opacity=".4"/>
</svg>
<input type="search" placeholder="Search signals…" aria-label="Search signals" />
</div>
<button class="btn btn-sm" onclick="loadFeed()">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M13.5 8a5.5 5.5 0 11-1.1-3.3" stroke="currentColor" stroke-width="1.4"/><path d="M10 4.5l2.5.2.3-2.7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Refresh
</button>
</div>
<!-- Stats bar — inlined (no card borders, flush panel style) -->
<div class="stats-bar">
<div class="stat-cell">
<div class="s-label">Total signals</div>
<div class="s-value" id="stat-total">&mdash;</div>
<div class="s-sub">All regulatory events in feed</div>
</div>
<div class="stat-cell">
<div class="s-label">High impact</div>
<div class="s-value" id="stat-high" style="color:var(--danger)">&mdash;</div>
<div class="s-sub">Requires immediate review</div>
</div>
<div class="stat-cell">
<div class="s-label">Medium impact</div>
<div class="s-value" id="stat-med" style="color:var(--warn)">&mdash;</div>
<div class="s-sub">Scheduled for assessment</div>
</div>
<div class="stat-cell">
<div class="s-label">Last 90 days</div>
<div class="s-value" id="stat-90d" style="color:var(--accent)">&mdash;</div>
<div class="s-sub">Recent publications</div>
</div>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<span class="filter-label">Source</span>
<button class="chip on" data-src="" onclick="setSource(this,'')">All</button>
<button class="chip" data-src="MIIT" onclick="setSource(this,'MIIT')">MIIT</button>
<button class="chip" data-src="UN-ECE" onclick="setSource(this,'UN-ECE')">UN-ECE</button>
<button class="chip" data-src="ISO" onclick="setSource(this,'ISO')">ISO</button>
<button class="chip" data-src="国标委" onclick="setSource(this,'国标委')">GB Comm.</button>
<button class="chip" data-src="EUR-Lex" onclick="setSource(this,'EUR-Lex')">EUR-Lex</button>
<button class="chip" data-src="IATF" onclick="setSource(this,'IATF')">IATF</button>
<div class="sep"></div>
<span class="filter-label">Impact</span>
<button class="chip on" data-imp="" onclick="setImpact(this,'')">All</button>
<button class="chip" data-imp="high" onclick="setImpact(this,'high')">High</button>
<button class="chip" data-imp="medium" onclick="setImpact(this,'medium')">Medium</button>
<button class="chip" data-imp="low" onclick="setImpact(this,'low')">Low</button>
</div>
<!-- Two-pane split -->
<div class="perception-split work-area">
<!-- Feed pane -->
<div class="feed-pane">
<div class="feed-pane-head">
<h2>Signal feed</h2>
<span class="feed-count" id="feed-count"></span>
</div>
<div class="feed-scroll" id="feed-scroll">
<div class="loading-msg">Loading…</div>
</div>
</div>
<!-- Analysis pane -->
<div class="analysis-pane" id="analysis-pane">
<div class="analysis-empty" id="analysis-empty">
<div class="analysis-empty-ring">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 2a6 6 0 100 12A6 6 0 008 2zm0 2v4l3 1.5-.5 1-3.5-1.75V4H8z" fill="currentColor" opacity=".3"/>
</svg>
</div>
<div class="analysis-empty-label">Select a signal to view impact analysis</div>
<div class="analysis-empty-hint">← Choose from the signal feed</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-live"><span class="footer-dot"></span><span>Live feed</span></div>
<span>T-Systems Regulation Hub</span>
</div>
</div>
</div>
<script>
// ─── Theme toggle ────────────────────────────────────────────────────
function toggleTheme() {
const html = document.documentElement;
const next = html.dataset.theme === 'dark' ? 'light' : 'dark';
html.dataset.theme = next;
localStorage.setItem('theme', next);
const spans = document.querySelectorAll('.sidebar-action span');
spans.forEach(s => s.textContent = next === 'dark' ? 'Light mode' : 'Dark mode');
}
(function() {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.dataset.theme = saved;
})();
const API = 'http://6.86.80.9:5173/api/v1';
const SRC_COLOR = {
MIIT: '#e20074', 'UN-ECE': '#3b82f6', ISO: '#7c3aed',
'国标委': '#059669', 'EUR-Lex': '#d97706', IATF: '#7c3aed'
};
const IMP_COLOR = { high: 'var(--danger)', medium: 'var(--warn)', low: 'var(--success)' };
const IMP_LABEL = { high: 'High', medium: 'Medium', low: 'Low' };
const STA_LABEL = { enacted: 'Enacted', draft: 'Draft', consultation: 'Consultation' };
const STA_CLASS = { enacted: 'ok', draft: 'warn', consultation: 'info' };
let currentSource = '', currentImpact = '', selectedId = null, abortCtrl = null;
let allEvents = [];
async function loadStats() {
try {
const r = await fetch(`${API}/perception/stats`);
if (!r.ok) return;
const s = await r.json();
document.getElementById('stat-total').textContent = s.total ?? '—';
document.getElementById('stat-high').textContent = s.high_impact ?? '—';
document.getElementById('stat-med').textContent = s.medium_impact ?? '—';
document.getElementById('stat-90d').textContent = s.recent_90d ?? '—';
const badge = document.getElementById('badge-high');
if (badge) badge.textContent = s.high_impact ?? '—';
} catch(e) { console.warn('stats:', e); }
}
async function loadFeed() {
const scroll = document.getElementById('feed-scroll');
scroll.innerHTML = '<div class="loading-msg">Loading…</div>';
const params = new URLSearchParams();
if (currentSource) params.set('source', currentSource);
if (currentImpact) params.set('impact_level', currentImpact);
try {
const r = await fetch(`${API}/perception/events?${params}`);
if (!r.ok) throw new Error(r.status);
const data = await r.json();
allEvents = data.events || [];
const countEl = document.getElementById('feed-count');
if (countEl) countEl.textContent = `${allEvents.length} / ${data.total}`;
renderFeed(allEvents);
} catch(e) {
scroll.innerHTML = `<div class="loading-msg" style="color:var(--danger)">Failed to load: ${e.message}</div>`;
}
}
function renderFeed(events) {
const scroll = document.getElementById('feed-scroll');
if (!events.length) {
scroll.innerHTML = '<div class="loading-msg">No signals match the current filters.</div>';
return;
}
scroll.innerHTML = events.map(ev => {
const sc = SRC_COLOR[ev.source] || '#888';
const stc = STA_CLASS[ev.status] || 'info';
const sel = ev.id === selectedId;
return `<div class="ev-card${sel ? ' selected' : ''}" onclick="selectEvent('${ev.id}')" id="card-${ev.id}">
<div class="ev-head">
<span class="src-tag" style="color:${sc};background:${sc}18">${ev.source}</span>
<span class="std-code">${ev.standard_code}</span>
<span class="status ${stc}" style="margin-left:auto">${STA_LABEL[ev.status] || ev.status}</span>
</div>
<div class="ev-title">${ev.title}</div>
<div class="ev-summary">${ev.summary}</div>
<div class="ev-foot">
<span class="ev-date">${ev.published_at}</span>
${(ev.tags||[]).slice(0,2).map(t=>`<span class="ev-tag">${t}</span>`).join('')}
<span class="imp-dot" style="color:${IMP_COLOR[ev.impact_level]||'var(--muted)'}">&#9679; ${IMP_LABEL[ev.impact_level]||ev.impact_level}</span>
</div>
</div>`;
}).join('');
}
function selectEvent(id) {
if (id === selectedId) return;
selectedId = id;
document.querySelectorAll('.ev-card').forEach(c => c.classList.remove('selected'));
const card = document.getElementById(`card-${id}`);
if (card) card.classList.add('selected');
const ev = allEvents.find(e => e.id === id);
if (ev) renderDetail(ev);
}
function renderDetail(ev) {
const sc = SRC_COLOR[ev.source] || '#888';
const stc = STA_CLASS[ev.status] || 'info';
const pane = document.getElementById('analysis-pane');
pane.innerHTML = `
<div class="detail-card">
<div class="detail-head">
<span class="src-tag" style="color:${sc};background:${sc}18;font-size:10px;padding:2px 7px;border-radius:4px">${ev.source}</span>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--muted)">${ev.standard_code}</span>
<span class="status ${stc}" style="margin-left:auto">${STA_LABEL[ev.status]||ev.status}</span>
</div>
<div class="detail-title">${ev.title}</div>
<div class="detail-summary">${ev.summary}</div>
<div class="detail-meta">
<span class="meta-item">Published <strong>${ev.published_at}</strong></span>
${ev.effective_at ? `<span class="meta-item">Effective <strong>${ev.effective_at}</strong></span>` : ''}
<span class="meta-item">Category <strong>${ev.category}</strong></span>
<span class="meta-item">Impact <strong style="color:${IMP_COLOR[ev.impact_level]||'var(--muted)'}">${IMP_LABEL[ev.impact_level]||ev.impact_level}</strong></span>
</div>
</div>
<div class="action-row">
<button class="btn btn-primary" id="btn-analyze" onclick="startAnalysis('${ev.id}')">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M8 1l1.8 5.5H15l-4.8 3.5 1.8 5.5L8 12l-4 3.5 1.8-5.5L1 6.5h5.2z" fill="currentColor"/></svg>
Run impact analysis
</button>
<button class="btn" id="btn-abort" style="display:none" onclick="stopAnalysis()">Stop</button>
${ev.source_url ? `<a href="${ev.source_url}" target="_blank" class="btn">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M9 2h5v5M9.5 6.5L14 2M3 4h4M3 8h8M3 12h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Source
</a>` : ''}
</div>
<div class="docs-card" id="docs-card">
<div class="docs-head">Affected documents</div>
<div id="docs-list"></div>
</div>
<div class="output-card" id="output-card">
<div class="output-head" id="output-head">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none"><path d="M8 1l1.8 5.5H15l-4.8 3.5 1.8 5.5L8 12l-4 3.5 1.8-5.5L1 6.5h5.2z" fill="currentColor"/></svg>
AI impact analysis
</div>
<div id="output-body"></div>
</div>
`;
}
async function startAnalysis(eventId) {
if (abortCtrl) abortCtrl.abort();
abortCtrl = new AbortController();
const btnA = document.getElementById('btn-analyze');
const btnX = document.getElementById('btn-abort');
btnA.disabled = true;
btnX.style.display = '';
const outputCard = document.getElementById('output-card');
const outputHead = document.getElementById('output-head');
const outputBody = document.getElementById('output-body');
const docsCard = document.getElementById('docs-card');
outputCard.style.display = '';
outputBody.innerHTML = '';
outputHead.innerHTML = `<svg width="11" height="11" viewBox="0 0 16 16" fill="none"><path d="M8 1l1.8 5.5H15l-4.8 3.5 1.8 5.5L8 12l-4 3.5 1.8-5.5L1 6.5h5.2z" fill="currentColor"/></svg> AI impact analysis <span class="blink" style="color:var(--accent)">&#9611;</span>`;
try {
const res = await fetch(`${API}/perception/events/${eventId}/analyze`, {
method: 'POST', headers: { Accept: 'text/event-stream' }, signal: abortCtrl.signal
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '', rawText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() ?? '';
for (const block of parts) {
if (!block.trim()) continue;
let evtName = 'message';
const dataLines = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) evtName = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
}
const payload = dataLines.join('\n');
if (!payload) continue;
if (evtName === 'sources') {
try { renderDocs(JSON.parse(payload), docsCard); } catch {}
} else if (evtName === 'content') {
rawText += payload;
outputBody.innerHTML = renderMarkdown(rawText);
}
}
}
} catch(e) {
if (e.name !== 'AbortError') {
const ob = document.getElementById('output-body');
if (ob) ob.innerHTML += `<div style="color:var(--danger);font-size:12px;margin-top:8px">Analysis error: ${e.message}</div>`;
}
} finally {
const blink = document.querySelector('#output-head .blink');
if (blink) blink.remove();
const ab = document.getElementById('btn-abort');
if (ab) ab.style.display = 'none';
const ba = document.getElementById('btn-analyze');
if (ba) { ba.disabled = false; ba.innerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M8 1l1.8 5.5H15l-4.8 3.5 1.8 5.5L8 12l-4 3.5 1.8-5.5L1 6.5h5.2z" fill="currentColor"/></svg> Re-analyse`; }
}
}
function stopAnalysis() {
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
const ab = document.getElementById('btn-abort');
if (ab) ab.style.display = 'none';
const ba = document.getElementById('btn-analyze');
if (ba) ba.disabled = false;
const h = document.getElementById('output-head');
if (h) { const b = h.querySelector('.blink'); if (b) b.remove(); }
}
function renderDocs(docs, card) {
if (!docs || !docs.length) return;
card.style.display = '';
document.getElementById('docs-list').innerHTML = docs.map(d => `
<div class="doc-row">
<div class="doc-score">${Math.round(d.score * 100)}%</div>
<div>
<div class="doc-name">${d.doc_name}</div>
<div class="doc-clause">${d.clause || ''}</div>
<div class="doc-snippet">${d.snippet || ''}</div>
</div>
</div>`).join('');
}
function renderMarkdown(text) {
return text.split('\n').map(line => {
if (line.startsWith('## ')) return `<div class="md-h2">${line.slice(3)}</div>`;
if (line.startsWith('### ')) return `<div class="md-h3">${line.slice(4)}</div>`;
if (line.startsWith('- ') || line.startsWith('* ')) {
const c = line.slice(2).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
return `<div class="md-li"><span class="md-li-dot">·</span><span>${c}</span></div>`;
}
if (/^\d+\./.test(line)) {
const c = line.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
return `<div class="md-p" style="padding-left:8px">${c}</div>`;
}
if (!line.trim()) return '<div class="md-empty"></div>';
return `<div class="md-p">${line.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')}</div>`;
}).join('');
}
function setSource(btn, src) {
currentSource = src;
document.querySelectorAll('[data-src]').forEach(c => c.classList.toggle('on', c.dataset.src === src));
loadFeed();
}
function setImpact(btn, imp) {
currentImpact = imp;
document.querySelectorAll('[data-imp]').forEach(c => c.classList.toggle('on', c.dataset.imp === imp));
loadFeed();
}
// Boot
loadStats();
loadFeed();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

475
aliyun_parser/parse_pdf.py Normal file
View File

@@ -0,0 +1,475 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
阿里云文档智能 API 解析 PDF输出三层结构 chunks
- structure_nodes: 目录树结构
- semantic_blocks: 语义块(章节文本、表格、图片)
- vector_chunks: 检索块(带 overlap 切分)
"""
import argparse
import json
import re
import time
from pathlib import Path
from typing import Dict, List
from alibabacloud_docmind_api20220711.client import Client as DocmindClient
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_docmind_api20220711 import models as docmind_models
from alibabacloud_tea_util import models as util_models
# ===================== 阿里云配置 =====================
ALIBABA_ACCESS_KEY_ID = "LTAI5t6fWvAsvZkoF9WTbtys"
ALIBABA_ACCESS_KEY_SECRET = "WX4oaE4FLYRa5L85TMQkqRPHeTJAF0"
ALIBABA_ENDPOINT = "docmind-api.cn-hangzhou.aliyuncs.com"
# ===================== 切分参数 =====================
MAX_CHARS = 600
OVERLAP_CHARS = 80
# ===================== 布局类型常量 =====================
TOC_TITLES = {"目次", "目录"}
TITLE_SUBTYPES = {"doc_title", "para_title"}
TEXT_SUBTYPES = {"para", "none"}
FIGURE_TYPES = {"figure", "figure_name", "figure_note"}
FIGURE_SUBTYPES = {"picture", "pic_title", "pic_caption"}
# ===================== 阿里云 API 客户端 =====================
def init_client() -> DocmindClient:
config = open_api_models.Config(
access_key_id=ALIBABA_ACCESS_KEY_ID,
access_key_secret=ALIBABA_ACCESS_KEY_SECRET,
)
config.endpoint = ALIBABA_ENDPOINT
return DocmindClient(config)
def submit_job(client: DocmindClient, file_path: str) -> str:
"""提交文档解析任务"""
file_name = Path(file_path).name
request = docmind_models.SubmitDocParserJobAdvanceRequest(
file_url_object=open(file_path, "rb"),
file_name=file_name,
file_name_extension=Path(file_path).suffix.lstrip("."),
llm_enhancement=True,
enhancement_mode="VLM",
)
runtime = util_models.RuntimeOptions()
response = client.submit_doc_parser_job_advance(request, runtime)
return response.body.data.id
def query_status(client: DocmindClient, task_id: str) -> Dict:
"""查询任务状态"""
request = docmind_models.QueryDocParserStatusRequest(id=task_id)
response = client.query_doc_parser_status(request)
return response.body.data.to_map() if response.body.data else None
def wait_for_completion(client: DocmindClient, task_id: str, poll_interval: int = 5) -> bool:
"""等待任务完成"""
while True:
status_data = query_status(client, task_id)
if not status_data:
return False
status = status_data.get("Status", "").lower()
if status == "success":
return True
elif status == "failed":
print(f"任务失败: {status_data}")
return False
print(f"任务状态: {status}, 等待中...")
time.sleep(poll_interval)
def get_result(client: DocmindClient, task_id: str, layout_num: int = 0, layout_step_size: int = 50) -> Dict:
"""获取解析结果"""
request = docmind_models.GetDocParserResultRequest(
id=task_id,
layout_step_size=layout_step_size,
layout_num=layout_num,
)
response = client.get_doc_parser_result(request)
return response.body.data if response.body.data else None
def collect_all_results(client: DocmindClient, task_id: str, layout_step_size: int = 50) -> List[Dict]:
"""收集所有解析结果"""
all_layouts = []
layout_num = 0
while True:
result_data = get_result(client, task_id, layout_num, layout_step_size)
if not result_data:
break
layouts = result_data.get("layouts", [])
if not layouts:
break
all_layouts.extend(layouts)
layout_num += len(layouts)
if len(layouts) < layout_step_size:
break
return all_layouts
# ===================== 文本处理 =====================
def normalize_text(text: str) -> str:
text = text.replace("\r", "\n")
text = text.replace(" ", " ")
text = re.sub(r"\n+", "\n", text)
text = re.sub(r"[ \t]+", " ", text)
return text.strip()
def get_page(layout: Dict) -> int:
return layout.get("pageNum", layout.get("pageNumber", 0))
def get_text(layout: Dict) -> str:
text = normalize_text(layout.get("text", ""))
if text:
return text
return normalize_text(layout.get("markdownContent", ""))
# ===================== 布局类型判断 =====================
def is_title(layout: Dict) -> bool:
return layout.get("type") == "title" or layout.get("subType") in TITLE_SUBTYPES
def is_text(layout: Dict) -> bool:
return layout.get("type") == "text" and layout.get("subType", "none") in TEXT_SUBTYPES
def is_figure(layout: Dict) -> bool:
return layout.get("type") in FIGURE_TYPES or layout.get("subType") in FIGURE_SUBTYPES
def is_table(layout: Dict) -> bool:
return layout.get("type") == "table"
def is_toc_layout(layout: Dict) -> bool:
text = get_text(layout)
if text in TOC_TITLES:
return True
if get_page(layout) == 1 and re.match(r"^\d+(\.\d+)*\s+.+[.。…]{2,}\s*\d+$", text):
return True
return False
def extract_table_text(layout: Dict) -> str:
rows = []
for cell in layout.get("cells", []):
texts = []
for cell_layout in cell.get("layouts", []):
cell_text = normalize_text(cell_layout.get("text", ""))
if cell_text:
texts.append(cell_text)
if texts:
rows.append(" ".join(texts))
return "\n".join(rows).strip()
# ===================== 结构层:目录树 =====================
def build_structure_nodes(layouts: List[Dict]) -> List[Dict]:
nodes = []
for layout in layouts:
if not is_title(layout):
continue
text = get_text(layout)
if not text or text in TOC_TITLES:
continue
nodes.append(
{
"unique_id": layout.get("uniqueId"),
"page": get_page(layout),
"index": layout.get("index", 0),
"level": layout.get("level", 0),
"title": text,
"type": layout.get("type"),
"sub_type": layout.get("subType"),
}
)
return nodes
# ===================== 语义层:章节内容 =====================
def update_section_path(section_stack: List[Dict], layout: Dict) -> List[Dict]:
level = layout.get("level", 0)
title = get_text(layout)
while section_stack and section_stack[-1]["level"] >= level:
section_stack.pop()
section_stack.append(
{
"level": level,
"title": title,
"page": get_page(layout),
"unique_id": layout.get("uniqueId"),
}
)
return section_stack
def section_path_titles(section_stack: List[Dict]) -> List[str]:
return [item["title"] for item in section_stack]
def flush_text_block(blocks: List[Dict], semantic_blocks: List[Dict], block_id: int) -> int:
if not blocks:
return block_id
texts = [item["text"] for item in blocks if item["text"]]
merged_text = "\n".join(texts).strip()
if not merged_text:
return block_id
semantic_blocks.append(
{
"semantic_id": f"semantic-{block_id}",
"block_type": "section_text",
"page_start": min(item["page"] for item in blocks),
"page_end": max(item["page"] for item in blocks),
"section_path": blocks[0]["section_path"],
"section_level": blocks[0]["section_level"],
"section_title": blocks[0]["section_title"],
"source_ids": [item["unique_id"] for item in blocks if item.get("unique_id")],
"text": merged_text,
}
)
return block_id + 1
def build_semantic_blocks(layouts: List[Dict]) -> List[Dict]:
semantic_blocks = []
section_stack = []
pending_text_blocks = []
block_id = 1
skip_toc_page = False
for layout in layouts:
text = get_text(layout)
page = get_page(layout)
if is_toc_layout(layout):
skip_toc_page = True
continue
if skip_toc_page and page == 1:
continue
if skip_toc_page and page != 1:
skip_toc_page = False
if is_title(layout):
block_id = flush_text_block(pending_text_blocks, semantic_blocks, block_id)
pending_text_blocks = []
section_stack = update_section_path(section_stack, layout)
continue
section_path = section_path_titles(section_stack)
section_title = section_path[-1] if section_path else "未分类"
section_level = len(section_path)
if is_table(layout):
block_id = flush_text_block(pending_text_blocks, semantic_blocks, block_id)
pending_text_blocks = []
table_text = extract_table_text(layout)
if table_text:
semantic_blocks.append(
{
"semantic_id": f"semantic-{block_id}",
"block_type": "table",
"page_start": page,
"page_end": page,
"section_path": section_path,
"section_level": section_level,
"section_title": section_title,
"source_ids": [layout.get("uniqueId")],
"text": table_text,
}
)
block_id += 1
continue
if is_figure(layout):
block_id = flush_text_block(pending_text_blocks, semantic_blocks, block_id)
pending_text_blocks = []
if text:
semantic_blocks.append(
{
"semantic_id": f"semantic-{block_id}",
"block_type": "figure",
"page_start": page,
"page_end": page,
"section_path": section_path,
"section_level": section_level,
"section_title": section_title,
"source_ids": [layout.get("uniqueId")],
"text": text,
}
)
block_id += 1
continue
if is_text(layout) and text:
pending_text_blocks.append(
{
"page": page,
"text": text,
"unique_id": layout.get("uniqueId"),
"section_path": section_path,
"section_level": section_level,
"section_title": section_title,
}
)
flush_text_block(pending_text_blocks, semantic_blocks, block_id)
return semantic_blocks
# ===================== 检索层:向量 chunks =====================
def split_text_with_overlap(text: str, max_chars: int, overlap_chars: int) -> List[str]:
text = text.strip()
if len(text) <= max_chars:
return [text] if text else []
parts = []
start = 0
while start < len(text):
end = min(len(text), start + max_chars)
parts.append(text[start:end].strip())
if end >= len(text):
break
start = max(0, end - overlap_chars)
return [part for part in parts if part]
def build_vector_chunks(
semantic_blocks: List[Dict],
doc_id: str,
doc_title: str,
max_chars: int,
overlap_chars: int,
) -> List[Dict]:
vector_chunks = []
chunk_index = 1
for block in semantic_blocks:
pieces = split_text_with_overlap(block["text"], max_chars, overlap_chars)
for piece_index, piece in enumerate(pieces, start=1):
if block["section_path"]:
header = f"标准:{doc_title}\n章节:{' > '.join(block['section_path'])}\n\n"
else:
header = f"标准:{doc_title}\n\n"
vector_chunks.append(
{
"doc_id": doc_id,
"doc_title": doc_title,
"chunk_id": f"chunk-{chunk_index}",
"chunk_index": chunk_index,
"semantic_id": block["semantic_id"],
"chunk_type": block["block_type"],
"piece_index": piece_index,
"page_start": block["page_start"],
"page_end": block["page_end"],
"section_path": block["section_path"],
"section_level": block["section_level"],
"section_title": block["section_title"],
"source_ids": block["source_ids"],
"text": piece,
"embedding_text": header + piece,
}
)
chunk_index += 1
return vector_chunks
# ===================== 主转换函数 =====================
def convert_layouts(
layouts: List[Dict],
doc_id: str,
doc_title: str,
max_chars: int,
overlap_chars: int,
) -> Dict:
structure_nodes = build_structure_nodes(layouts)
semantic_blocks = build_semantic_blocks(layouts)
vector_chunks = build_vector_chunks(
semantic_blocks,
doc_id=doc_id,
doc_title=doc_title,
max_chars=max_chars,
overlap_chars=overlap_chars,
)
return {
"doc_id": doc_id,
"doc_title": doc_title,
"structure_nodes": structure_nodes,
"semantic_blocks": semantic_blocks,
"vector_chunks": vector_chunks,
}
# ===================== CLI 入口 =====================
def main() -> None:
parser = argparse.ArgumentParser(description="阿里云文档智能解析 PDF输出三层结构 chunks")
parser.add_argument("pdf_path", help="PDF 文件路径")
parser.add_argument("--out", default="vector_chunks.json", help="输出 JSON 文件路径")
parser.add_argument("--layouts-out", dest="layouts_output", help="输出原始 layouts JSON")
parser.add_argument("--doc-id", default="GB14747-2006", help="文档 ID")
parser.add_argument("--doc-title", default="GB 14747—2006 儿童三轮车安全要求", help="文档标题")
parser.add_argument("--max-chars", type=int, default=MAX_CHARS, help="单个检索 chunk 最大字符数")
parser.add_argument("--overlap-chars", type=int, default=OVERLAP_CHARS, help="相邻检索 chunk 重叠字符数")
parser.add_argument("--poll-interval", type=int, default=5, help="轮询间隔(秒)")
args = parser.parse_args()
pdf_path = Path(args.pdf_path).expanduser().resolve()
if not pdf_path.exists():
raise FileNotFoundError(f"PDF 文件不存在: {pdf_path}")
# 1. 提交阿里云任务
client = init_client()
print(f"提交任务: {pdf_path}")
task_id = submit_job(client, str(pdf_path))
print(f"任务 ID: {task_id}")
# 2. 等待完成
print("等待任务完成...")
if not wait_for_completion(client, task_id, args.poll_interval):
print("任务失败,退出")
return
# 3. 获取 layouts
print("获取解析结果...")
layouts = collect_all_results(client, task_id)
print(f"获取到 {len(layouts)} 个布局块")
# 4. 输出原始 layouts可选
if args.layouts_output:
layouts_path = Path(args.layouts_output).expanduser().resolve()
layouts_path.write_text(json.dumps(layouts, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"原始 layouts 已写入: {layouts_path}")
# 5. 转换为三层结构
print("转换为三层结构...")
data = convert_layouts(
layouts,
doc_id=args.doc_id,
doc_title=args.doc_title,
max_chars=args.max_chars,
overlap_chars=args.overlap_chars,
)
# 6. 输出结果
output_path = Path(args.out).expanduser().resolve()
output_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"结构层节点数: {len(data['structure_nodes'])}")
print(f"语义层块数: {len(data['semantic_blocks'])}")
print(f"检索层块数: {len(data['vector_chunks'])}")
print(f"输出文件: {output_path}")
if __name__ == "__main__":
main()

122
aliyun_parser/schema.sql Normal file
View File

@@ -0,0 +1,122 @@
-- 法规文档向量检索系统数据库表结构
-- PostgreSQL
-- ==================== 文档表 ====================
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
doc_id VARCHAR(128) UNIQUE NOT NULL, -- 文档唯一标识,如 "GB14747-2006"
title VARCHAR(512) NOT NULL, -- 文档标题
doc_type VARCHAR(32), -- 文档类型:标准/法规/规范
standard_number VARCHAR(64), -- 标准编号:如 "GB 14747-2006"
publish_date DATE, -- 发布日期
implement_date DATE, -- 实施日期
status VARCHAR(32), -- 状态:现行/废止/修订
source_url VARCHAR(512), -- 来源 URL
file_path VARCHAR(512), -- 本地 PDF 文件路径
file_size INT, -- 文件大小(字节)
upload_time TIMESTAMP DEFAULT NOW(), -- 上传时间
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE documents IS '文档元数据表';
COMMENT ON COLUMN documents.doc_id IS '文档唯一标识,用于关联 Milvus 和其他表';
COMMENT ON COLUMN documents.standard_number IS '标准编号,如 GB 14747-2006';
-- ==================== 章节结构表 ====================
CREATE TABLE sections (
id SERIAL PRIMARY KEY,
doc_id VARCHAR(128) NOT NULL,
unique_id VARCHAR(64) NOT NULL, -- 阿里云返回的唯一标识
level INT NOT NULL, -- 层级1, 2, 3...
title VARCHAR(512) NOT NULL, -- 章节标题
page INT, -- 所在页码
index INT, -- 页内顺序
parent_id INT, -- 父章节 ID树形结构
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_sections_doc_id FOREIGN KEY (doc_id) REFERENCES documents(doc_id),
CONSTRAINT fk_sections_parent_id FOREIGN KEY (parent_id) REFERENCES sections(id),
CONSTRAINT uq_sections_doc_unique UNIQUE (doc_id, unique_id)
);
COMMENT ON TABLE sections IS '章节结构表,用于目录导航';
COMMENT ON COLUMN sections.parent_id IS '父章节 ID构建树形结构';
COMMENT ON COLUMN sections.level IS '层级深度1 为最顶层';
-- ==================== 语义块表 ====================
CREATE TABLE semantic_blocks (
id SERIAL PRIMARY KEY,
doc_id VARCHAR(128) NOT NULL,
semantic_id VARCHAR(64) NOT NULL, -- 语义块唯一标识
block_type VARCHAR(32) NOT NULL, -- 类型section_text/table/figure
page_start INT NOT NULL, -- 起始页码
page_end INT NOT NULL, -- 结束页码
section_id INT, -- 所属章节
section_title VARCHAR(512), -- 章节标题(冗余,方便查询)
section_level INT, -- 章节层级
source_ids JSONB, -- 原始 layout IDsJSON 数组)
text TEXT NOT NULL, -- 完整内容(未被切分)
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_semantic_blocks_doc_id FOREIGN KEY (doc_id) REFERENCES documents(doc_id),
CONSTRAINT fk_semantic_blocks_section_id FOREIGN KEY (section_id) REFERENCES sections(id),
CONSTRAINT uq_semantic_blocks_doc_semantic UNIQUE (doc_id, semantic_id)
);
COMMENT ON TABLE semantic_blocks IS '语义块表,用于邻域扩展,恢复完整内容';
COMMENT ON COLUMN semantic_blocks.block_type IS '类型section_text正文、table表格、figure图示';
COMMENT ON COLUMN semantic_blocks.source_ids IS '原始阿里云 layout 的 uniqueId 数组';
COMMENT ON COLUMN semantic_blocks.text IS '完整语义内容,未被切分';
-- ==================== 向量块元数据表 ====================
CREATE TABLE vector_chunks (
id SERIAL PRIMARY KEY,
doc_id VARCHAR(128) NOT NULL,
chunk_id VARCHAR(64) NOT NULL, -- Milvus 主键
semantic_id VARCHAR(64) NOT NULL, -- 关联语义块
chunk_index INT NOT NULL, -- 切片序号(全局)
piece_index INT, -- 同语义块内的切片序号
page_start INT,
page_end INT,
section_title VARCHAR(512),
text VARCHAR(2048), -- 切片文本(可选,缩短版用于展示)
source_ids JSONB, -- 原始 layout IDsJSON 数组)
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_vector_chunks_doc_id FOREIGN KEY (doc_id) REFERENCES documents(doc_id),
CONSTRAINT fk_vector_chunks_semantic_id FOREIGN KEY (doc_id, semantic_id)
REFERENCES semantic_blocks(doc_id, semantic_id),
CONSTRAINT uq_vector_chunks_doc_chunk UNIQUE (doc_id, chunk_id)
);
COMMENT ON TABLE vector_chunks IS '向量块元数据表,用于快速关联查询';
COMMENT ON COLUMN vector_chunks.chunk_id IS 'Milvus 向量库主键';
COMMENT ON COLUMN vector_chunks.piece_index IS '同语义块内的切片序号,用于按序拼接';
-- ==================== 索引 ====================
CREATE INDEX idx_sections_doc_id ON sections(doc_id);
CREATE INDEX idx_sections_parent_id ON sections(parent_id);
CREATE INDEX idx_sections_level ON sections(level);
CREATE INDEX idx_semantic_blocks_doc_id ON semantic_blocks(doc_id);
CREATE INDEX idx_semantic_blocks_section_id ON semantic_blocks(section_id);
CREATE INDEX idx_semantic_blocks_block_type ON semantic_blocks(block_type);
CREATE INDEX idx_semantic_blocks_semantic_id ON semantic_blocks(semantic_id);
CREATE INDEX idx_vector_chunks_doc_id ON vector_chunks(doc_id);
CREATE INDEX idx_vector_chunks_semantic_id ON vector_chunks(semantic_id);
CREATE INDEX idx_vector_chunks_chunk_id ON vector_chunks(chunk_id);
-- ==================== 触发器:自动更新 updated_at ====================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tr_documents_updated_at
BEFORE UPDATE ON documents
FOR EACH ROW EXECUTE FUNCTION update_updated_at();

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
将 vector_chunks.json 向量化并上传到 Milvus 和 PostgreSQL
使用中转站的 OpenAI 兼容 API
"""
import argparse
import json
import time
from pathlib import Path
from typing import List, Dict
import psycopg2
from psycopg2.extras import execute_values
from pymilvus import (
connections,
Collection,
FieldSchema,
CollectionSchema,
DataType,
utility,
)
from openai import OpenAI
# ===================== 配置 =====================
# 中转站配置
RELAY_BASE_URL = "http://6.86.80.4:30080/v1"
RELAY_API_KEY = "sk-5HeY7gfSIlyZMacfuXOf5cphpymsNqufEu1ou4U3avbULcyY"
EMBEDDING_MODEL = "text-embedding-v3" # 中转站支持的 embedding 模型
# Milvus 配置
MILVUS_HOST = "localhost"
MILVUS_PORT = "19530"
COLLECTION_NAME = "regulation_chunks"
# PostgreSQL 配置
PG_HOST = "6.86.80.10"
PG_PORT = 5432
PG_USER = "postgresql"
PG_PASSWORD = "postgresql123456"
PG_DATABASE = "postgres"
# ===================== Embedding =====================
def get_openai_client(api_key: str, base_url: str) -> OpenAI:
"""创建 OpenAI 客户端连接到中转站"""
return OpenAI(api_key=api_key, base_url=base_url)
def get_embeddings_batch(client: OpenAI, texts: List[str], batch_size: int = 10) -> List[List[float]]:
"""批量获取文本向量"""
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
print(f"Embedding batch {i // batch_size + 1}/{(len(texts) - 1) // batch_size + 1}...")
response = client.embeddings.create(
model=EMBEDDING_MODEL,
input=batch,
)
embeddings = [item.embedding for item in response.data]
all_embeddings.extend(embeddings)
return all_embeddings
# ===================== Milvus =====================
def init_milvus(host: str, port: str):
connections.connect("default", host=host, port=port)
print(f"已连接 Milvus: {host}:{port}")
def create_collection(name: str, dim: int) -> Collection:
"""创建或获取 collection"""
if utility.has_collection(name):
print(f"Collection '{name}' 已存在,删除重建")
utility.drop_collection(name)
fields = [
FieldSchema(name="chunk_id", dtype=DataType.VARCHAR, max_length=64, is_primary=True),
FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=128),
FieldSchema(name="doc_title", dtype=DataType.VARCHAR, max_length=512),
FieldSchema(name="chunk_index", dtype=DataType.INT64),
FieldSchema(name="semantic_id", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="chunk_type", dtype=DataType.VARCHAR, max_length=32),
FieldSchema(name="page_start", dtype=DataType.INT64),
FieldSchema(name="page_end", dtype=DataType.INT64),
FieldSchema(name="section_title", dtype=DataType.VARCHAR, max_length=512),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2048),
FieldSchema(name="source_ids", dtype=DataType.VARCHAR, max_length=4096), # JSON 字符串
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=dim),
]
schema = CollectionSchema(fields, description="法规文档检索 chunks")
collection = Collection(name, schema)
# 创建向量索引IVF_FLAT适合中小规模
index_params = {
"metric_type": "COSINE",
"index_type": "IVF_FLAT",
"params": {"nlist": 128},
}
collection.create_index("embedding", index_params)
print(f"Collection '{name}' 创建完成,索引已建立")
return collection
def insert_chunks(collection: Collection, chunks: List[Dict], embeddings: List[List[float]]):
"""插入 chunks 到 Milvus"""
data = [
[c["chunk_id"] for c in chunks],
[c["doc_id"] for c in chunks],
[c["doc_title"] for c in chunks],
[c["chunk_index"] for c in chunks],
[c["semantic_id"] for c in chunks],
[c["chunk_type"] for c in chunks],
[c["page_start"] for c in chunks],
[c["page_end"] for c in chunks],
[c["section_title"] for c in chunks],
[c["text"] for c in chunks],
[json.dumps(c.get("source_ids", [])) for c in chunks], # JSON 字符串
embeddings,
]
collection.insert(data)
collection.flush()
print(f"已插入 {len(chunks)} 个 chunks")
def load_collection(collection: Collection):
"""加载 collection 到内存(搜索前必须)"""
collection.load()
print(f"Collection 已加载到内存")
# ===================== PostgreSQL =====================
def get_pg_connection(host: str, port: int, user: str, password: str, database: str):
"""获取 PostgreSQL 连接"""
conn = psycopg2.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
)
print(f"已连接 PostgreSQL: {host}:{port}/{database}")
return conn
def insert_chunks_to_pg(conn, chunks: List[Dict], doc_data: Dict):
"""插入 chunks 和相关数据到 PostgreSQL"""
cursor = conn.cursor()
try:
# 1. 插入文档
cursor.execute("""
INSERT INTO documents (doc_id, title, standard_number, upload_time)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (doc_id) DO UPDATE SET title = EXCLUDED.title, updated_at = NOW()
""", (doc_data["doc_id"], doc_data["doc_title"], doc_data.get("standard_number")))
# 2. 插入语义块
semantic_blocks = doc_data.get("semantic_blocks", [])
if semantic_blocks:
block_rows = [
(
doc_data["doc_id"],
block["semantic_id"],
block["block_type"],
block["page_start"],
block["page_end"],
block.get("section_title"),
block.get("section_level"),
json.dumps(block.get("source_ids", [])),
block["text"],
)
for block in semantic_blocks
]
execute_values(
cursor,
"""
INSERT INTO semantic_blocks
(doc_id, semantic_id, block_type, page_start, page_end, section_title, section_level, source_ids, text)
VALUES %s
ON CONFLICT (doc_id, semantic_id) DO UPDATE SET text = EXCLUDED.text
""",
block_rows,
)
print(f"已插入 {len(semantic_blocks)} 个语义块")
# 3. 插入向量块元数据
chunk_rows = [
(
doc_data["doc_id"],
chunk["chunk_id"],
chunk["semantic_id"],
chunk["chunk_index"],
chunk.get("piece_index"),
chunk["page_start"],
chunk["page_end"],
chunk.get("section_title"),
chunk["text"],
json.dumps(chunk.get("source_ids", [])),
)
for chunk in chunks
]
execute_values(
cursor,
"""
INSERT INTO vector_chunks
(doc_id, chunk_id, semantic_id, chunk_index, piece_index, page_start, page_end, section_title, text, source_ids)
VALUES %s
ON CONFLICT (doc_id, chunk_id) DO UPDATE SET text = EXCLUDED.text
""",
chunk_rows,
)
print(f"已插入 {len(chunks)} 个向量块元数据")
conn.commit()
print("PostgreSQL 数据插入完成")
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
# ===================== 主流程 =====================
def load_data(file_path: Path) -> Dict:
"""加载 vector_chunks.json返回完整数据"""
data = json.loads(file_path.read_text(encoding="utf-8"))
return data
def upload_to_milvus_and_pg(
chunks_file: str,
api_key: str,
base_url: str,
milvus_host: str,
milvus_port: str,
collection_name: str,
batch_size: int,
pg_host: str,
pg_port: int,
pg_user: str,
pg_password: str,
pg_database: str,
):
# 1. 加载完整数据
chunks_path = Path(chunks_file).expanduser().resolve()
if not chunks_path.exists():
raise FileNotFoundError(f"文件不存在: {chunks_path}")
data = load_data(chunks_path)
chunks = data.get("vector_chunks", [])
if not chunks:
raise ValueError("vector_chunks 为空")
print(f"加载 {len(chunks)} 个 chunks")
# 2. 初始化连接
client = get_openai_client(api_key, base_url)
init_milvus(milvus_host, milvus_port)
pg_conn = get_pg_connection(pg_host, pg_port, pg_user, pg_password, pg_database)
# 3. 获取 embeddings
texts = [c["embedding_text"] for c in chunks]
embeddings = get_embeddings_batch(client, texts, batch_size)
print(f"生成 {len(embeddings)} 个向量")
# 4. 获取 embedding 维度
embedding_dim = len(embeddings[0])
print(f"Embedding 维度: {embedding_dim}")
# 5. 创建 collection 并插入 Milvus
collection = create_collection(collection_name, embedding_dim)
insert_chunks(collection, chunks, embeddings)
load_collection(collection)
# 6. 插入 PostgreSQL
insert_chunks_to_pg(pg_conn, chunks, data)
# 7. 关闭连接
pg_conn.close()
print("上传完成!")
# ===================== CLI =====================
def main():
parser = argparse.ArgumentParser(description="将 vector_chunks 向量化并上传到 Milvus 和 PostgreSQL")
parser.add_argument("chunks_file", help="vector_chunks.json 文件路径")
parser.add_argument("--api-key", default=RELAY_API_KEY, help="中转站 API Key")
parser.add_argument("--base-url", default=RELAY_BASE_URL, help="中转站 Base URL")
parser.add_argument("--milvus-host", default=MILVUS_HOST, help="Milvus host")
parser.add_argument("--milvus-port", default=MILVUS_PORT, help="Milvus port")
parser.add_argument("--collection", default=COLLECTION_NAME, help="Milvus collection 名称")
parser.add_argument("--batch-size", type=int, default=10, help="Embedding 批量大小中转站限制最大10")
parser.add_argument("--pg-host", default=PG_HOST, help="PostgreSQL host")
parser.add_argument("--pg-port", type=int, default=PG_PORT, help="PostgreSQL port")
parser.add_argument("--pg-user", default=PG_USER, help="PostgreSQL user")
parser.add_argument("--pg-password", default=PG_PASSWORD, help="PostgreSQL password")
parser.add_argument("--pg-database", default=PG_DATABASE, help="PostgreSQL database")
args = parser.parse_args()
upload_to_milvus_and_pg(
chunks_file=args.chunks_file,
api_key=args.api_key,
base_url=args.base_url,
milvus_host=args.milvus_host,
milvus_port=args.milvus_port,
collection_name=args.collection,
batch_size=args.batch_size,
pg_host=args.pg_host,
pg_port=args.pg_port,
pg_user=args.pg_user,
pg_password=args.pg_password,
pg_database=args.pg_database,
)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
# 文档解析与向量检索说明
## 相关文件
- `aliyun_doc_parser.py`:调用阿里云文档智能解析 PDF生成原始 `layouts.json`
- `layouts_to_vector_chunks.py`:把 `layouts.json` 转成适合向量数据库入库的三层结构
- `layouts.json`:阿里云返回的原始布局结果
- `vector_chunks.json`:转换后的结构化输出
## 一、`layouts.json` 的结构
`layouts.json` 顶层是一个数组每个元素代表一个布局块layout。常见字段如下
- `type`:主类型,例如 `title``text``table``figure`
- `subType`:更细的语义类型,例如 `doc_title``para_title``para``picture``pic_title``pic_caption`
- `text`:当前布局块的纯文本
- `markdownContent`:带 markdown 标记的文本
- `pageNum`:页码
- `index`:页内顺序
- `level`:标题层级
- `uniqueId`:布局块唯一标识
- `blocks`:更细粒度的文本与样式信息
- `cells`:表格单元格,仅 `table` 类型存在
这个结构不是简单 OCR 文本流,而是已经带有版面理解和语义分类的结构化数据。
## 二、推荐的三层转换结构
### 1. 结构层 `structure_nodes`
结构层用于恢复文档标题树,不直接作为最终向量检索单元。
示例:
- `1 范围`
- `2 规范性引用文件`
- `3 术语和定义`
- `3.1 儿童三轮车`
- `3.2 轮距`
结构层主要用于给下游 chunk 绑定 `section_path`
### 2. 语义层 `semantic_blocks`
语义层是按文档意义聚合后的内容块,主要分为三类:
- `section_text`:同一章节下连续正文聚合而成
- `table`:表格内容单独成块
- `figure`:图、图名、图注等单独成块
这一层比单 layout 更适合做语义理解,也适合后续做上下文扩展。
### 3. 检索层 `vector_chunks`
检索层是最终写进向量数据库的 chunk。
处理方式:
-`semantic_blocks` 中较短的块直接入库
- 对较长的块按 `max_chars` 再切分
- 相邻切片保留 `overlap_chars` 重叠
- 每个 chunk 都带完整 metadata便于后续过滤、重排和邻域扩展
## 三、当前转换脚本做了什么
`layouts_to_vector_chunks.py` 当前已经实现:
1. 过滤目录页噪声(如 `目次`
2. 根据标题层级维护章节路径
3. 将正文聚合成 `section_text`
4. 将表格单独转成 `table`
5. 将图相关内容单独转成 `figure`
6. 对长文本继续切分为最终 `vector_chunks`
7. 为每个检索 chunk 生成 `embedding_text`
## 四、为什么不要直接按 layout 入库
如果把 `layouts.json` 的每条 layout 直接做向量:
- 颗粒度太碎
- 标题和正文容易分离
- 表格会丢失结构上下文
- 图示信息无法完整表达
- 检索命中结果噪声较大
对于标准文档,最合适的单位通常不是“句子”,而是“条款语义块”。
## 五、建议的入库字段
建议向量数据库每条记录至少保存:
- `embedding_text`:用于生成向量
- `text`:原始 chunk 文本
- `chunk_id`
- `semantic_id`
- `chunk_type``section_text` / `table` / `figure`
- `section_path`
- `section_title`
- `section_level`
- `page_start`
- `page_end`
- `doc_id`
- `doc_title`
- `source_ids`
其中:
- 向量化字段:`embedding_text`
- 展示字段:`text`
- 检索增强字段:其余 metadata
## 六、推荐的检索方式
不要只做最简单的 top-k 向量搜索,建议采用:
**向量召回 + metadata 重排 + 邻域扩展**
### 1. 向量召回
使用 `vector_chunks[*].embedding_text` 做 embedding并在向量数据库中检索 top 10 ~ 15 条。
查询时可以对用户问题做轻微改写,例如:
原问题:
`儿童三轮车的定义是什么?`
可改写为:
`请检索 GB 14747—2006 儿童三轮车安全要求 中关于“儿童三轮车定义”的条款、术语、表格或图示说明。`
这样更适合标准文档检索。
### 2. metadata 重排
向量召回后,根据 metadata 做轻量规则重排。
常见规则:
- `chunk_type == section_text`:对定义类、要求类问题优先级更高
- `section_path` 命中查询关键词:例如查询“定义”时,`术语和定义` 章节优先
- `chunk_type == table`:对“尺寸 / 参数 / 数值 / 对照 / 要求”类问题加权
- `chunk_type == figure`:对“图 / 结构 / 状态 / 示意”类问题加权
### 3. 邻域扩展
检索命中的是最终切片,但回答往往需要更完整上下文。
建议命中某个 `vector_chunk` 后:
1. 优先回捞同一个 `semantic_id` 下的所有 chunk
2. 如果还不够,再补充同 `section_path`、相邻页码或相邻 `chunk_index` 的内容
这样可以恢复完整条款,而不是只给模型一小段碎片。
## 七、不同问题的检索重点
### 1. 定义类问题
例如:
- `儿童三轮车的定义是什么?`
- `轮距是什么意思?`
优先检索:
- `section_text`
- `section_path` 中包含 `术语和定义` 的内容
### 2. 要求类问题
例如:
- `外露突出物有什么要求?`
- `辅助推杆有哪些安全要求?`
优先检索:
- `section_text`
- `table`
### 3. 数值 / 尺寸 / 对照类问题
例如:
- `鞍座到脚蹬距离要求是什么?`
- `哪些项目需要满足规定尺寸?`
优先检索:
- `table`
- `section_text`
### 4. 图示说明类问题
例如:
- `正常乘骑状态是什么意思?`
- `图1表示什么`
优先检索:
- `figure`
- 同章节相邻 `section_text`
## 八、推荐的最终检索流程
建议采用以下固定流程:
1.`vector_chunks.embedding_text` 做 embedding 检索
2. 取 top 10 ~ 15 条候选
3.`chunk_type + section_path` 做规则重排
4.`semantic_id` 为中心回捞完整语义块
5. 选 3 ~ 5 组上下文提供给大模型回答
## 九、给大模型的上下文组织方式
最终不要直接把原始 JSON 扔给模型,建议整理成如下格式:
```text
[命中片段 1]
章节3 术语和定义 > 3.1 儿童三轮车
页码1-2
类型section_text
内容:
......
[命中片段 2]
章节4 要求 > 4.3 外露突出物
页码5
类型section_text
内容:
......
[命中片段 3]
章节5 试验方法
页码8
类型table
内容:
......
```
这种格式更利于模型稳定回答并引用出处。
## 十、转换命令
生成三层结构:
```bash
python3 /home/huaci/dev/ai/SuperMew/tests/layouts_to_vector_chunks.py \
--layouts /home/huaci/dev/ai/SuperMew/tests/layouts.json \
--out /home/huaci/dev/ai/SuperMew/tests/vector_chunks.json
```
自定义切片大小:
```bash
python3 /home/huaci/dev/ai/SuperMew/tests/layouts_to_vector_chunks.py \
--layouts /home/huaci/dev/ai/SuperMew/tests/layouts.json \
--out /home/huaci/dev/ai/SuperMew/tests/vector_chunks.json \
--max-chars 500 \
--overlap-chars 80
```

View File

@@ -0,0 +1,5 @@
"""FastAPI dependency functions for authentication and authorisation.
Import `get_current_user` or `require_role` into route modules to protect
endpoints. Both use the shared JWTHandler wired through bootstrap.
"""

View File

@@ -0,0 +1,72 @@
"""FastAPI dependencies for JWT authentication.
Usage in a route:
from app.api.dependencies.auth import get_current_user, require_role
from app.domain.auth.models import UserRole
@router.get("/protected")
async def protected(user: UserClaims = Depends(get_current_user)):
return {"user": user.username}
@router.delete("/admin-only")
async def admin_only(user: UserClaims = Depends(require_role(UserRole.ADMIN))):
...
"""
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config.settings import settings
from app.domain.auth.models import UserClaims, UserRole
from app.shared.bootstrap import get_jwt_handler
# Use Bearer token scheme — client sends `Authorization: Bearer <token>`.
_bearer = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> UserClaims:
"""Extract and validate the JWT from the Authorization header.
Returns the decoded UserClaims on success.
Raises HTTP 401 when the token is missing, expired, or invalid.
When auth_enabled=False (development), returns a synthetic admin user.
"""
if not settings.auth_enabled:
# Development bypass — never enable this in production.
return UserClaims(user_id="dev", username="dev-admin", role=UserRole.ADMIN)
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
return get_jwt_handler().decode_token(credentials.credentials)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
headers={"WWW-Authenticate": "Bearer"},
) from exc
def require_role(*roles: UserRole):
"""Return a dependency that enforces one of the given roles.
Example:
Depends(require_role(UserRole.ADMIN, UserRole.LEGAL))
"""
async def _check(user: UserClaims = Depends(get_current_user)) -> UserClaims:
"""Verify the user holds one of the required roles."""
if user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{user.role}' is not permitted. Required: {[r.value for r in roles]}",
)
return user
return _check

View File

@@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from loguru import logger from loguru import logger
from app.api.middleware.audit import AuditMiddleware
from app.api.models import ErrorResponse from app.api.models import ErrorResponse
from app.api.routes import api_router from app.api.routes import api_router
from app.config.logging import setup_logging from app.config.logging import setup_logging
@@ -46,14 +47,23 @@ app = FastAPI(
redoc_url="/redoc", redoc_url="/redoc",
) )
# Tighten CORS — only allow configured origins.
# Set CORS_ALLOW_ORIGINS in .env to the real frontend URL in production.
_ORIGINS = [o.strip() for o in settings.cors_allow_origins.split(",") if o.strip()]
if not _ORIGINS:
_ORIGINS = ["http://localhost:5173"]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Audit middleware logs every authenticated API call for compliance traceability.
app.add_middleware(AuditMiddleware)
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")

View File

@@ -0,0 +1 @@
"""HTTP middleware for cross-cutting concerns: audit logging."""

View File

@@ -0,0 +1,56 @@
"""Audit logging middleware.
Logs every API request with method, path, status code, response time,
and the authenticated user identity (extracted from the JWT when present).
Log lines are structured so they can be ingested by ELK / Loki.
"""
from __future__ import annotations
import time
from fastapi import Request, Response
from loguru import logger
from starlette.middleware.base import BaseHTTPMiddleware
class AuditMiddleware(BaseHTTPMiddleware):
"""Log all API calls. Skips health/docs paths to reduce noise."""
# Paths that produce no audit log entry.
_SKIP_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json")
async def dispatch(self, request: Request, call_next) -> Response:
"""Intercept the request, call the handler, and log the outcome."""
path = request.url.path
if path == "/" or any(path == p or path.startswith(p + "/") for p in self._SKIP_PREFIXES):
return await call_next(request)
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = int((time.perf_counter() - start) * 1000)
# Extract user identity from JWT header for structured audit records.
# The token is not re-validated here — auth dependencies do that upstream.
user_id = "anonymous"
username = "anonymous"
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
try:
from app.shared.bootstrap import get_jwt_handler
claims = get_jwt_handler().decode_token(auth_header[7:])
user_id = claims.user_id
username = claims.username
except Exception:
pass
logger.info(
"AUDIT method={} path={} status={} elapsed_ms={} user_id={} username={}",
request.method,
path,
response.status_code,
elapsed_ms,
user_id,
username,
)
return response

View File

@@ -1,6 +1,7 @@
"""Initialize the app.api.routes package.""" """Initialize the app.api.routes package."""
from fastapi import APIRouter from fastapi import APIRouter
from .auth import router as auth_router
from .compliance import router as compliance_router from .compliance import router as compliance_router
from .documents import router as documents_router from .documents import router as documents_router
from .knowledge import router as knowledge_router from .knowledge import router as knowledge_router
@@ -14,7 +15,8 @@ from .rag import router as rag_router
# Keep package boundaries explicit so backend imports stay predictable. # Keep package boundaries explicit so backend imports stay predictable.
api_router = APIRouter() api_router = APIRouter()
# Keep package boundaries explicit so backend imports stay predictable. # Auth routes first so /auth/token is easy to discover.
api_router.include_router(auth_router)
api_router.include_router(documents_router) api_router.include_router(documents_router)
api_router.include_router(knowledge_router) api_router.include_router(knowledge_router)
api_router.include_router(agent_router) api_router.include_router(agent_router)
@@ -25,6 +27,7 @@ api_router.include_router(rag_router)
__all__ = [ __all__ = [
"api_router", "api_router",
"auth_router",
"documents_router", "documents_router",
"knowledge_router", "knowledge_router",
"agent_router", "agent_router",

View File

@@ -0,0 +1,63 @@
"""Authentication routes — token issuance only.
POST /auth/token — exchange username + password for a JWT.
GET /auth/me — return the current user identity (requires token).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from app.api.dependencies.auth import get_current_user
from app.config.settings import settings
from app.domain.auth.models import UserClaims
from app.shared.bootstrap import get_jwt_handler, get_user_store
router = APIRouter(prefix="/auth", tags=["认证"])
class TokenResponse(BaseModel):
"""JWT token response body."""
access_token: str
token_type: str = "bearer"
expires_in: int
@router.post("/token", response_model=TokenResponse)
async def login(form: OAuth2PasswordRequestForm = Depends()):
"""Issue a JWT for valid username + password credentials.
Uses standard OAuth2 password grant form fields — compatible with
Swagger UI Authorize button.
"""
user = get_user_store().authenticate(form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = get_jwt_handler().create_access_token(
user_id=user.id,
username=user.username,
role=user.role,
)
return TokenResponse(
access_token=token,
token_type="bearer",
expires_in=settings.auth_token_expire_minutes * 60,
)
@router.get("/me")
async def get_me(current_user: UserClaims = Depends(get_current_user)):
"""Return the identity of the currently authenticated user."""
return {
"user_id": current_user.user_id,
"username": current_user.username,
"role": current_user.role.value,
}

View File

@@ -5,17 +5,21 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator, Optional
from fastapi import APIRouter, File, UploadFile from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from loguru import logger
from app.api.dependencies.auth import get_current_user
from app.domain.auth.models import UserClaims
from app.schemas.compliance import ( from app.schemas.compliance import (
AnalyzeResponse, AnalyzeResponse,
ComplianceChatRequest, ComplianceChatRequest,
) )
from app.services.mock_data import generate_task_id, get_mock_compliance_result from app.services.mock_data import generate_task_id, get_mock_compliance_result
from app.shared.bootstrap import get_agent_conversation_service from app.shared.bootstrap import get_agent_conversation_service, get_retrieval_service
from app.config.settings import settings
router = APIRouter(prefix="/compliance", tags=["合规分析"]) router = APIRouter(prefix="/compliance", tags=["合规分析"])
@@ -62,6 +66,172 @@ async def get_result(task_id: str):
return task["result"] return task["result"]
def _sse(data: dict) -> str:
return f"event: message\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@router.post("/analyze-stream")
async def analyze_stream(
text: Optional[str] = Form(None),
doc_id: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
domains: Optional[str] = Form(None),
title: Optional[str] = Form(None),
current_user: UserClaims = Depends(get_current_user),
):
"""Stream compliance analysis as SSE events.
Stages: clause_split → retrieval (per clause) → gap_check → conclusion
Events: stage | source | finding | done | error
"""
from app.application.compliance.pipeline import (
extract_text_from_doc_id,
extract_text_from_file,
run_clauses_parallel,
split_into_clauses,
synthesize_conclusion,
)
from app.services.llm.llm_factory import get_llm_client
from app.shared.bootstrap import get_retrieval_service
# Read file content eagerly (before async generator)
file_content: bytes | None = None
file_name: str | None = None
if file is not None:
file_content = await file.read()
file_name = file.filename
async def generate() -> AsyncGenerator[str, None]:
try:
client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
retrieval_service = get_retrieval_service()
# ── Stage 1: extract text ─────────────────────────────────────
yield _sse({"type": "stage", "stage": "extracting", "label": "Extracting text…"})
await asyncio.sleep(0)
if text:
para_text = text.strip()
elif doc_id:
try:
para_text = await asyncio.to_thread(extract_text_from_doc_id, doc_id)
except Exception as exc:
yield _sse({"type": "error", "text": f"Document not found: {exc}"})
return
elif file_content is not None:
para_text = await asyncio.to_thread(
extract_text_from_file, file_content, file_name or "upload"
)
else:
yield _sse({"type": "error", "text": "No input provided"})
return
if not para_text.strip():
yield _sse({"type": "error", "text": "Could not extract text from the provided input"})
return
# ── Stage 2: split into clauses ───────────────────────────────
yield _sse({"type": "stage", "stage": "splitting", "label": "Splitting into clauses…"})
await asyncio.sleep(0)
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
# ── Stage 3: retrieve + gap check (parallel across all clauses) ────────────
findings: list[dict] = []
yield _sse({
"type": "stage",
"stage": "analyzing",
"label": f"Analyzing {len(clauses)} clauses in parallel…",
})
await asyncio.sleep(0)
clause_results = await run_clauses_parallel(
clauses, retrieval_service, client,
top_k=5,
domains=domains or None,
)
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",
"standard": getattr(chunk, "doc_title", "") or getattr(chunk, "doc_name", ""),
"clause": getattr(chunk, "section_title", "") or "",
"score": round(float(getattr(chunk, "score", 0)), 3),
"status": "retrieved",
"full_content": (getattr(chunk, "text", "") or "")[:300],
})
if finding:
findings.append(finding)
yield _sse({"type": "finding", **finding})
await asyncio.sleep(0)
# ── Stage 4: synthesize conclusion ────────────────────────────
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
await asyncio.sleep(0)
conclusion_data = await asyncio.to_thread(
synthesize_conclusion, para_text, findings, client
)
yield _sse({"type": "done", **conclusion_data})
# 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)})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@router.post("/chat/{segment_id}") @router.post("/chat/{segment_id}")
async def compliance_chat(segment_id: int, request: ComplianceChatRequest): async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
"""Stream compliance Q&A grounded in real vector retrieval.""" """Stream compliance Q&A grounded in real vector retrieval."""
@@ -98,3 +268,226 @@ async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
media_type="text/event-stream", media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, 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,12 +5,15 @@ from __future__ import annotations
from io import BytesIO from io import BytesIO
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from loguru import logger from loguru import logger
from app.api.dependencies.auth import get_current_user
from app.api.models import DocumentUploadResponse from app.api.models import DocumentUploadResponse
from app.application.documents import DocumentProcessResult from app.application.documents import DocumentProcessResult
from app.config.settings import settings
from app.domain.auth.models import UserClaims
from app.shared.bootstrap import get_document_command_service, get_document_query_service from app.shared.bootstrap import get_document_command_service, get_document_query_service
# Keep route handlers close to their transport-layer wiring for easier auditing. # Keep route handlers close to their transport-layer wiring for easier auditing.
@@ -31,15 +34,60 @@ def _document_response(result: DocumentProcessResult) -> DocumentUploadResponse:
) )
def _run_process_in_background(
*,
doc_id: str,
file_name: str,
final_doc_name: str,
content: bytes,
regulation_type: str,
version: str,
generate_summary: bool,
run_id: str | None,
) -> None:
"""Run document processing synchronously inside a FastAPI BackgroundTask thread.
FastAPI executes BackgroundTasks in a threadpool executor, so blocking I/O
(parser API calls, embedding, Milvus upsert) is safe here.
"""
try:
svc = get_document_command_service()
svc._process_document(
doc_id=doc_id,
file_name=file_name,
final_doc_name=final_doc_name,
content=content,
regulation_type=regulation_type,
version=version,
generate_summary=generate_summary,
run_id=run_id,
)
except Exception:
logger.exception("BackgroundTask document processing failed: doc_id={}", doc_id)
@router.post("/upload", response_model=DocumentUploadResponse) @router.post("/upload", response_model=DocumentUploadResponse)
async def upload_document( async def upload_document(
background_tasks: BackgroundTasks,
file: UploadFile = File(..., description="上传的文档文件"), file: UploadFile = File(..., description="上传的文档文件"),
doc_id: str | None = Form(None, description="客户端预分配的文档ID不传则自动生成"),
doc_name: str | None = Form(None, description="文档名称"), doc_name: str | None = Form(None, description="文档名称"),
regulation_type: str | None = Form(None, description="法规类型"), regulation_type: str | None = Form(None, description="法规类型"),
version: str | None = Form(None, description="文档版本"), version: str | None = Form(None, description="文档版本"),
generate_summary: bool = Form(False, description="是否生成摘要"), generate_summary: bool = Form(False, description="是否生成摘要"),
sync: bool = Form(False, description="同步处理(演示/测试用,默认异步处理)"),
current_user: UserClaims = Depends(get_current_user),
): ):
"""Handle upload document.""" """Upload a document and process it asynchronously.
Default path (sync=false):
1. Store binary to MinIO immediately — returns within seconds.
2. Schedule parse→embed→index as a FastAPI BackgroundTask (same process,
threadpool) OR enqueue to Celery workers when USE_CELERY_WORKER=true.
3. Poll GET /documents/status/{doc_id} for progress.
sync=true path: full inline processing, blocks until complete (demo / CI use).
"""
content = await file.read() content = await file.read()
if not file.filename: if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空") raise HTTPException(status_code=400, detail="文件名不能为空")
@@ -47,7 +95,12 @@ async def upload_document(
raise HTTPException(status_code=400, detail="上传文件为空") raise HTTPException(status_code=400, detail="上传文件为空")
try: try:
result = get_document_command_service().upload_and_process( svc = get_document_command_service()
if sync:
# Synchronous fallback: full inline processing.
result = svc.upload_and_process(
doc_id=doc_id,
file_name=file.filename, file_name=file.filename,
content=content, content=content,
content_type=file.content_type or "application/octet-stream", content_type=file.content_type or "application/octet-stream",
@@ -56,9 +109,59 @@ async def upload_document(
version=version or "", version=version or "",
generate_summary=generate_summary, generate_summary=generate_summary,
) )
else:
# Step 1: store binary and create the document record (fast, sync).
stored_doc_id, run_id = svc.store_document(
doc_id=doc_id,
file_name=file.filename,
content=content,
content_type=file.content_type or "application/octet-stream",
doc_name=doc_name,
regulation_type=regulation_type or "",
version=version or "",
generate_summary=generate_summary,
)
final_doc_name = doc_name or file.filename
# Step 2: schedule processing via Celery worker OR FastAPI BackgroundTask.
if settings.use_celery_worker:
from app.infrastructure.tasks.document_tasks import process_document_task
process_document_task.delay(
doc_id=stored_doc_id,
file_name=file.filename,
doc_name=final_doc_name,
regulation_type=regulation_type or "",
version=version or "",
generate_summary=generate_summary,
run_id=run_id,
)
processing_note = "已入 Celery 队列,由 Worker 处理。"
else:
# Default: run in FastAPI's threadpool — no external worker needed.
background_tasks.add_task(
_run_process_in_background,
doc_id=stored_doc_id,
file_name=file.filename,
final_doc_name=final_doc_name,
content=content,
regulation_type=regulation_type or "",
version=version or "",
generate_summary=generate_summary,
run_id=run_id,
)
processing_note = "正在后台处理。"
result = DocumentProcessResult(
doc_id=stored_doc_id,
doc_name=final_doc_name,
status="stored",
message=f"文件已存储,{processing_note}请轮询 GET /documents/status/{{doc_id}} 查看进度。",
)
if result.status == "failed": if result.status == "failed":
raise HTTPException(status_code=500, detail=result.message) raise HTTPException(status_code=500, detail=result.message)
return _document_response(result) return _document_response(result)
except HTTPException: except HTTPException:
raise raise
except Exception as exc: except Exception as exc:
@@ -104,7 +207,7 @@ async def download_document(doc_id: str):
@router.get("/list") @router.get("/list")
async def list_documents(): async def list_documents(current_user: UserClaims = Depends(get_current_user)):
"""List documents.""" """List documents."""
documents = get_document_query_service().list_documents() documents = get_document_query_service().list_documents()
return { return {
@@ -146,7 +249,7 @@ async def get_document_management_list():
@router.delete("/{doc_id}") @router.delete("/{doc_id}")
async def delete_document(doc_id: str): async def delete_document(doc_id: str, current_user: UserClaims = Depends(get_current_user)):
"""Delete a document and its associated data.""" """Delete a document and its associated data."""
deleted = get_document_command_service().delete(doc_id) deleted = get_document_command_service().delete(doc_id)
if not deleted: if not deleted:

View File

@@ -4,10 +4,12 @@ from __future__ import annotations
import json import json
from fastapi import APIRouter, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.shared.bootstrap import get_perception_service from app.shared.bootstrap import get_crawl_service, get_event_store, get_perception_service
from app.api.dependencies.auth import get_current_user
from app.domain.auth.models import UserClaims
from app.shared.async_utils import iter_in_thread from app.shared.async_utils import iter_in_thread
router = APIRouter(prefix="/perception", tags=["智能感知"]) router = APIRouter(prefix="/perception", tags=["智能感知"])
@@ -65,3 +67,77 @@ async def analyze_event(event_id: str):
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
}, },
) )
@router.post("/crawl")
async def run_crawl(
body: dict = None,
current_user: UserClaims = Depends(get_current_user),
):
"""Trigger manual crawl of regulatory sources. Streams SSE progress.
Body (optional): {"sources": ["CATARC", "国标委·强制性", "EUR-Lex"]}
Omit sources to crawl all registered sources.
"""
sources: list[str] | None = (body or {}).get("sources")
crawl_svc = get_crawl_service()
async def crawl_stream():
async for item in iter_in_thread(crawl_svc.run_crawl(sources=sources)):
event_name = item.get("event", "message")
data = item.get("data", "")
if isinstance(data, (dict, list)):
data = json.dumps(data, ensure_ascii=False)
yield f"event: {event_name}\ndata: {data}\n\n"
return StreamingResponse(
crawl_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/events/{event_id}/process")
async def process_event(
event_id: str,
current_user: UserClaims = Depends(get_current_user),
):
"""Trigger LLM pipeline (extract + assess + diff) for a single event."""
from datetime import UTC, datetime
from app.infrastructure.perception.llm_pipeline import LlmPipeline
from app.shared.bootstrap import get_retrieval_service
event = get_perception_service().get_event(event_id)
if not event:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
store = get_event_store()
pipeline = LlmPipeline()
structure = pipeline.extract_structure(event)
event.update(structure)
event["affected_docs"] = pipeline.assess_impact(event, get_retrieval_service())
event["processed_at"] = datetime.now(UTC).isoformat()
store.upsert(event)
return {"status": "ok", "event_id": event_id, "processed_at": event["processed_at"]}
@router.get("/events/{event_id}/diff")
async def get_event_diff(event_id: str):
"""Return semantic diff detail for an event (only available if previously crawled twice)."""
event = get_perception_service().get_event(event_id)
if not event:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
if not event.get("change_summary"):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="No diff available for this event")
return {
"event_id": event_id,
"change_summary": event.get("change_summary"),
"changed_sections": event.get("changed_sections") or [],
"previous_hash": event.get("previous_hash"),
"content_hash": event.get("content_hash"),
}

View File

@@ -5,10 +5,12 @@ from __future__ import annotations
import json import json
from typing import AsyncGenerator from typing import AsyncGenerator
from fastapi import APIRouter from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.api.dependencies.auth import get_current_user
from app.config.settings import settings from app.config.settings import settings
from app.domain.auth.models import UserClaims
from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestion from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestion
from app.shared.async_utils import iter_in_thread from app.shared.async_utils import iter_in_thread
from app.shared.bootstrap import get_agent_conversation_service from app.shared.bootstrap import get_agent_conversation_service
@@ -27,7 +29,10 @@ _DEFAULT_QUICK_QUESTIONS = [
@router.post("/chat") @router.post("/chat")
async def rag_chat(request: RagChatRequest): async def rag_chat(
request: RagChatRequest,
current_user: UserClaims = Depends(get_current_user),
):
"""Stream RAG Q&A using the real agent service.""" """Stream RAG Q&A using the real agent service."""
session_id, event_stream = get_agent_conversation_service().stream_chat( session_id, event_stream = get_agent_conversation_service().stream_chat(
query=request.query, query=request.query,

View File

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

View File

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

@@ -277,7 +277,6 @@ class DocumentCommandService:
message="Document record created", message="Document record created",
) )
temp_path = ""
try: try:
self.binary_store.save( self.binary_store.save(
object_name=object_name, object_name=object_name,
@@ -297,117 +296,20 @@ class DocumentCommandService:
stage="store", stage="store",
message="Source file stored", message="Source file stored",
) )
# Delegate parse → embed → index to the shared processing method.
suffix = os.path.splitext(file_name)[1] # This same method is invoked by the Celery worker for async processing.
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file: return self._process_document(
temp_file.write(content)
temp_path = temp_file.name
parsed_document = self.parser.parse(
file_path=temp_path,
doc_id=doc_id, doc_id=doc_id,
doc_name=final_doc_name, file_name=file_name,
) final_doc_name=final_doc_name,
self._safe_mark_run_parsed(doc_id=doc_id, run_id=run_id, parsed_document=parsed_document) content=content,
artifact_keys: dict[str, str] = {}
try:
artifact_keys = self._save_parse_artifacts(doc_id=doc_id, parsed_document=parsed_document)
except Exception:
logger.warning("Parse artifact binary persistence failed for doc_id={}", doc_id)
self.document_repository.update_status(
doc_id,
DocumentStatus.PARSED,
parser_name=parsed_document.parser_name,
metadata={
"parser_backend": parsed_document.parser_name,
"parse_task_id": parsed_document.metadata.get("task_id", ""),
"layout_count": parsed_document.metadata.get("layout_count", len(parsed_document.raw_layouts)),
"structure_node_count": len(parsed_document.structure_nodes),
"semantic_block_count": len(parsed_document.semantic_blocks),
"vector_chunk_count": len(parsed_document.vector_chunks),
"artifact_keys": artifact_keys,
"processing_stage": "parsed",
},
)
current_status = DocumentStatus.PARSED
current_stage = "embed"
self._safe_replace_processing_artifacts(doc_id=doc_id, run_id=run_id, artifact_keys=artifact_keys)
self._safe_append_status_event(
doc_id=doc_id,
run_id=run_id,
from_status=DocumentStatus.STORED.value,
to_status=DocumentStatus.PARSED.value,
stage="parse",
message="Document parsed",
metadata={"artifact_count": len(artifact_keys)},
)
if self.parse_artifact_store:
try:
self.parse_artifact_store.save(
doc_id,
parsed_document.structure_nodes,
parsed_document.semantic_blocks,
)
except Exception:
logger.warning("ParseArtifactStore.save failed for doc_id={}", doc_id)
chunks = self.chunk_builder.build(
parsed_document=parsed_document,
regulation_type=regulation_type, regulation_type=regulation_type,
version=version, version=version,
) generate_summary=generate_summary,
if not chunks:
raise ValueError("解析完成但没有生成可入库的 chunks")
vectors = self.embedding_provider.embed_texts([chunk.embedding_text for chunk in chunks])
current_stage = "index"
inserted = self.vector_index.upsert(chunks, vectors)
if inserted != len(chunks):
logger.warning("Milvus upsert count mismatched: inserted={}, chunks={}", inserted, len(chunks))
health = self.vector_index.health()
self.document_repository.update_status(
doc_id,
DocumentStatus.INDEXED,
chunk_count=len(chunks),
summary="",
summary_latency_ms=0,
index_name=health.get("collection_name", ""),
metadata={
"index_collection": health.get("collection_name", ""),
"processing_stage": "indexed",
},
)
current_status = DocumentStatus.INDEXED
index_name = health.get("collection_name", "")
self._safe_mark_run_indexed(
doc_id=doc_id,
run_id=run_id, run_id=run_id,
chunk_count=len(chunks),
index_name=index_name,
)
self._safe_append_status_event(
doc_id=doc_id,
run_id=run_id,
from_status=DocumentStatus.PARSED.value,
to_status=DocumentStatus.INDEXED.value,
stage="index",
message="Document indexed",
metadata={"chunk_count": len(chunks), "index_name": index_name},
)
stored = self.document_repository.get(doc_id)
return DocumentProcessResult(
doc_id=doc_id,
doc_name=final_doc_name,
status=(stored.status.value if stored else DocumentStatus.INDEXED.value),
message="处理成功",
num_chunks=len(chunks),
summary=stored.summary if stored else "",
summary_latency_ms=stored.summary_latency_ms if stored else 0,
) )
except Exception as exc: except Exception as exc:
logger.exception("文档处理失败: doc_id={}", doc_id) logger.exception("文档存储失败: doc_id={}", doc_id)
failure_stage = current_stage failure_stage = current_stage
self.document_repository.update_status( self.document_repository.update_status(
doc_id, doc_id,
@@ -439,6 +341,183 @@ class DocumentCommandService:
status=DocumentStatus.FAILED.value, status=DocumentStatus.FAILED.value,
message=f"文档处理失败: {exc}", message=f"文档处理失败: {exc}",
) )
def store_document(
self,
*,
doc_id: str | None = None,
file_name: str,
content: bytes,
content_type: str,
doc_name: str | None,
regulation_type: str,
version: str,
generate_summary: bool,
) -> tuple[str, str | None]:
"""Store the binary file and create the Document record.
Returns (doc_id, run_id). Does NOT parse, embed, or index.
This is the fast synchronous first step; processing is enqueued separately.
The caller is responsible for enqueuing the follow-up process_document_task.
"""
doc_id = doc_id or str(uuid.uuid4())[:8]
final_doc_name = doc_name or file_name
object_name = f"{doc_id}/{file_name}"
document = Document(
doc_id=doc_id,
doc_name=final_doc_name,
file_name=file_name,
object_name=object_name,
content_type=content_type,
size_bytes=len(content),
regulation_type=regulation_type,
version=version,
metadata={"generate_summary": generate_summary},
)
self.document_repository.create(document)
run_id = self._safe_create_processing_run(
doc_id=doc_id, trigger_type="upload", generate_summary=generate_summary
)
self.binary_store.save(
object_name=object_name, data=content,
content_type=content_type, metadata={"doc_id": doc_id},
)
self.document_repository.update_status(doc_id, DocumentStatus.STORED)
self._safe_mark_run_stored(doc_id=doc_id, run_id=run_id)
self._safe_append_status_event(
doc_id=doc_id, run_id=run_id,
from_status=DocumentStatus.PENDING.value, to_status=DocumentStatus.STORED.value,
stage="store", message="Source file stored",
)
return doc_id, run_id
def _process_document(
self,
*,
doc_id: str,
file_name: str,
final_doc_name: str,
content: bytes,
regulation_type: str,
version: str,
generate_summary: bool,
run_id: str | None = None,
) -> DocumentProcessResult:
"""Run parse → chunk → embed → index for a document that is already stored.
Called both synchronously (from upload_and_process) and asynchronously
(from the Celery process_document_task worker). All side-effects write
through DocumentProcessingStore so callers can poll progress.
"""
current_status = DocumentStatus.STORED
current_stage = "parse"
temp_path = ""
try:
suffix = os.path.splitext(file_name)[1]
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_file.write(content)
temp_path = temp_file.name
parsed_document = self.parser.parse(
file_path=temp_path,
doc_id=doc_id,
doc_name=final_doc_name,
)
self._safe_mark_run_parsed(doc_id=doc_id, run_id=run_id, parsed_document=parsed_document)
artifact_keys: dict[str, str] = {}
try:
artifact_keys = self._save_parse_artifacts(doc_id=doc_id, parsed_document=parsed_document)
except Exception:
logger.warning("Parse artifact binary persistence failed for doc_id={}", doc_id)
self.document_repository.update_status(
doc_id,
DocumentStatus.PARSED,
parser_name=parsed_document.parser_name,
metadata={
"parser_backend": parsed_document.parser_name,
"parse_task_id": parsed_document.metadata.get("task_id", ""),
"layout_count": parsed_document.metadata.get("layout_count", len(parsed_document.raw_layouts)),
"structure_node_count": len(parsed_document.structure_nodes),
"semantic_block_count": len(parsed_document.semantic_blocks),
"vector_chunk_count": len(parsed_document.vector_chunks),
"artifact_keys": artifact_keys,
"processing_stage": "parsed",
},
)
current_status = DocumentStatus.PARSED
current_stage = "embed"
self._safe_replace_processing_artifacts(doc_id=doc_id, run_id=run_id, artifact_keys=artifact_keys)
self._safe_append_status_event(
doc_id=doc_id, run_id=run_id,
from_status=DocumentStatus.STORED.value, to_status=DocumentStatus.PARSED.value,
stage="parse", message="Document parsed", metadata={"artifact_count": len(artifact_keys)},
)
if self.parse_artifact_store:
try:
self.parse_artifact_store.save(
doc_id, parsed_document.structure_nodes, parsed_document.semantic_blocks,
)
except Exception:
logger.warning("ParseArtifactStore.save failed for doc_id={}", doc_id)
chunks = self.chunk_builder.build(
parsed_document=parsed_document,
regulation_type=regulation_type,
version=version,
)
if not chunks:
raise ValueError("解析完成但没有生成可入库的 chunks")
vectors = self.embedding_provider.embed_texts([chunk.embedding_text for chunk in chunks])
current_stage = "index"
inserted = self.vector_index.upsert(chunks, vectors)
if inserted != len(chunks):
logger.warning("Milvus upsert count mismatched: inserted={}, chunks={}", inserted, len(chunks))
health = self.vector_index.health()
index_name = health.get("collection_name", "")
self.document_repository.update_status(
doc_id, DocumentStatus.INDEXED,
chunk_count=len(chunks), summary="", summary_latency_ms=0,
index_name=index_name,
metadata={"index_collection": index_name, "processing_stage": "indexed"},
)
self._safe_mark_run_indexed(doc_id=doc_id, run_id=run_id, chunk_count=len(chunks), index_name=index_name)
self._safe_append_status_event(
doc_id=doc_id, run_id=run_id,
from_status=DocumentStatus.PARSED.value, to_status=DocumentStatus.INDEXED.value,
stage="index", message="Document indexed",
metadata={"chunk_count": len(chunks), "index_name": index_name},
)
stored = self.document_repository.get(doc_id)
return DocumentProcessResult(
doc_id=doc_id, doc_name=final_doc_name,
status=(stored.status.value if stored else DocumentStatus.INDEXED.value),
message="处理成功", num_chunks=len(chunks),
summary=stored.summary if stored else "",
summary_latency_ms=stored.summary_latency_ms if stored else 0,
)
except Exception as exc:
logger.exception("文档处理失败: doc_id={}", doc_id)
self.document_repository.update_status(
doc_id, DocumentStatus.FAILED, error_message=str(exc),
metadata={"failure_reason": str(exc), "processing_stage": "failed", "failure_stage": current_stage},
)
self._safe_mark_run_failed(
doc_id=doc_id, run_id=run_id, failure_stage=current_stage, error_message=str(exc)
)
self._safe_append_status_event(
doc_id=doc_id, run_id=run_id,
from_status=current_status.value, to_status=DocumentStatus.FAILED.value,
stage=current_stage, message=str(exc),
)
return DocumentProcessResult(
doc_id=doc_id, doc_name=final_doc_name,
status=DocumentStatus.FAILED.value, message=f"文档处理失败: {exc}",
)
finally: finally:
if temp_path and os.path.exists(temp_path): if temp_path and os.path.exists(temp_path):
try: try:
@@ -446,7 +525,6 @@ class DocumentCommandService:
except OSError: except OSError:
logger.warning("临时文件清理失败: {}", temp_path) logger.warning("临时文件清理失败: {}", temp_path)
def delete(self, doc_id: str) -> bool: def delete(self, doc_id: str) -> bool:
"""Delete document record, binary file, and vector chunks.""" """Delete document record, binary file, and vector chunks."""
document = self.document_repository.get(doc_id) document = self.document_repository.get(doc_id)

View File

@@ -0,0 +1,147 @@
"""Orchestrates regulatory source crawlers and LLM enrichment pipeline."""
from __future__ import annotations
import hashlib
from typing import Any, Generator
from loguru import logger
from app.infrastructure.perception.base_event_store import BaseEventStore
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from app.infrastructure.perception.llm_pipeline import LlmPipeline
def _event_id(source: str, standard_code: str) -> str:
"""Deterministic 12-char ID from source + standard_code."""
return hashlib.sha256(f"{source}-{standard_code}".encode()).hexdigest()[:12]
def _content_hash(raw_text: str) -> str:
return hashlib.sha256(raw_text.encode()).hexdigest()
def _raw_to_dict(raw: RawEvent, event_id: str, content_hash: str) -> dict:
return {
"id": event_id,
"source": raw.source,
"source_label": raw.source_label,
"standard_code": raw.standard_code,
"title": raw.title,
"summary": raw.summary,
"full_text_url": raw.full_text_url,
"status": raw.status,
"impact_level": "medium",
"published_at": raw.published_at,
"effective_at": raw.effective_at,
"category": raw.category,
"tags": raw.tags,
"content_hash": content_hash,
"previous_hash": None,
}
class CrawlService:
"""Orchestrate crawlers, hash-based change detection, and LLM enrichment."""
def __init__(
self,
crawlers: dict[str, BaseCrawler],
event_store: BaseEventStore,
llm_pipeline: LlmPipeline,
retrieval_service: Any,
) -> None:
self._crawlers = crawlers
self._store = event_store
self._pipeline = llm_pipeline
self._retrieval = retrieval_service
def run_crawl(
self, sources: list[str] | None = None
) -> Generator[dict, None, None]:
"""Run crawl for selected sources. Yields SSE-ready progress dicts."""
targets = sources or list(self._crawlers.keys())
total_new = 0
total_updated = 0
for source_key in targets:
crawler = self._crawlers.get(source_key)
if not crawler:
yield {"event": "error", "data": f"Unknown source: {source_key}"}
continue
yield {"event": "progress", "data": {"source": source_key, "stage": "fetching"}}
try:
raw_events = crawler.fetch(limit=100)
except Exception as exc:
logger.exception("Crawler failed source={}", source_key)
yield {"event": "error", "data": {"source": source_key, "message": str(exc)}}
continue
yield {
"event": "progress",
"data": {"source": source_key, "stage": "processing", "fetched": len(raw_events)},
}
new_count = 0
updated_count = 0
for raw in raw_events:
eid = _event_id(raw.source, raw.standard_code)
new_hash = _content_hash(raw.raw_text or raw.title)
existing = self._store.get(eid)
if existing and existing.get("content_hash") == new_hash:
continue
is_update = existing is not None
old_text = existing.get("summary", "") if is_update else ""
previous_hash = existing.get("content_hash") if is_update else None
event_dict = _raw_to_dict(raw, eid, new_hash)
event_dict["previous_hash"] = previous_hash
try:
structure = self._pipeline.extract_structure(event_dict)
event_dict.update(structure)
except Exception as exc:
logger.warning("Structure extraction failed id={} err={}", eid, exc)
try:
affected = self._pipeline.assess_impact(event_dict, self._retrieval)
event_dict["affected_docs"] = affected
except Exception as exc:
logger.warning("Impact assessment failed id={} err={}", eid, exc)
if is_update and old_text and raw.raw_text:
try:
diff = self._pipeline.compute_diff(old_text, raw.raw_text)
event_dict["change_summary"] = diff.get("change_summary")
event_dict["changed_sections"] = diff.get("changed_sections")
except Exception as exc:
logger.warning("Diff failed id={} err={}", eid, exc)
self._store.upsert(event_dict)
if is_update:
updated_count += 1
else:
new_count += 1
total_new += new_count
total_updated += updated_count
yield {
"event": "progress",
"data": {
"source": source_key,
"stage": "done",
"new": new_count,
"updated": updated_count,
},
}
yield {
"event": "done",
"data": {"total_new": total_new, "total_updated": total_updated},
}

View File

@@ -6,7 +6,7 @@ import json
from typing import Generator from typing import Generator
from app.application.knowledge.services import KnowledgeRetrievalService from app.application.knowledge.services import KnowledgeRetrievalService
from app.infrastructure.perception.mock_event_store import MockEventStore from app.infrastructure.perception.base_event_store import BaseEventStore
from app.services.llm.llm_factory import get_llm_client from app.services.llm.llm_factory import get_llm_client
from app.config.settings import settings from app.config.settings import settings
@@ -22,7 +22,7 @@ class PerceptionService:
def __init__( def __init__(
self, self,
event_store: MockEventStore, event_store: BaseEventStore,
retrieval_service: KnowledgeRetrievalService, retrieval_service: KnowledgeRetrievalService,
) -> None: ) -> None:
self._store = event_store self._store = event_store

View File

@@ -82,6 +82,22 @@ class Settings(BaseSettings):
parser_backend: str = Field(default="aliyun", description="解析后端(local/aliyun)") parser_backend: str = Field(default="aliyun", description="解析后端(local/aliyun)")
chunk_backend: str = Field(default="aliyun", description="分块后端(local/aliyun)") chunk_backend: str = Field(default="aliyun", description="分块后端(local/aliyun)")
document_repository_backend: str = Field(default="json", description="文档元数据存储后端 (json/postgres)") document_repository_backend: str = Field(default="json", description="文档元数据存储后端 (json/postgres)")
# When True, document processing is enqueued to Celery workers via Redis.
# When False (default), processing runs in a FastAPI BackgroundTask in the same process —
# no external worker needed. Switch to True only when a Celery worker is running.
use_celery_worker: bool = Field(default=False, description="使用 Celery Worker 异步处理文档 (需要 Worker 运行中)")
# ── Perception crawl ──────────────────────────────────────────────────────
perception_crawl_timeout_seconds: int = Field(
default=120, description="HTTP timeout for regulatory source crawlers."
)
perception_max_events_per_source: int = Field(
default=100, description="Maximum events fetched per source per crawl run."
)
perception_diff_similarity_threshold: float = Field(
default=0.85,
description="Cosine similarity below which a paragraph is flagged as changed.",
)
# Keep configuration setup explicit so runtime behavior is easy to reason about. # Keep configuration setup explicit so runtime behavior is easy to reason about.
api_host: str = Field(default="0.0.0.0", description="API服务地址") api_host: str = Field(default="0.0.0.0", description="API服务地址")
@@ -109,6 +125,7 @@ class Settings(BaseSettings):
rag_retrieval_top_k: int = Field(default=20, description="精排前召回候选数量reranker 启用时生效)") rag_retrieval_top_k: int = Field(default=20, description="精排前召回候选数量reranker 启用时生效)")
rag_max_context_tokens: int = Field(default=2000, description="RAG最大上下文token数") rag_max_context_tokens: int = Field(default=2000, description="RAG最大上下文token数")
rag_summary_max_tokens: int = Field(default=10240, description="文档摘要最大token数") rag_summary_max_tokens: int = Field(default=10240, description="文档摘要最大token数")
rag_skills_max_tokens: int = Field(default=2048, description="技能类 RAG 最大 token 数")
reranker_enabled: bool = Field(default=False, description="是否启用 Cross-Encoder 精排") reranker_enabled: bool = Field(default=False, description="是否启用 Cross-Encoder 精排")
reranker_base_url: str = Field(default="", description="Reranker API 地址") reranker_base_url: str = Field(default="", description="Reranker API 地址")
@@ -124,6 +141,26 @@ class Settings(BaseSettings):
# Keep configuration setup explicit so runtime behavior is easy to reason about. # Keep configuration setup explicit so runtime behavior is easy to reason about.
session_max_sessions: int = Field(default=100, description="最大会话数量") session_max_sessions: int = Field(default=100, description="最大会话数量")
session_timeout_minutes: int = Field(default=30, description="会话超时时间(分钟)") session_timeout_minutes: int = Field(default=30, description="会话超时时间(分钟)")
session_backend: str = Field(
default="memory",
description="会话存储后端 (memory | redis)。redis 需要 Redis 可用。",
)
# ── Auth ──────────────────────────────────────────────────────────────────
# Generate a strong secret: python -c "import secrets; print(secrets.token_hex(32))"
auth_secret_key: str = Field(
default="change-me-in-production-must-be-32-or-more-characters-long",
description="JWT signing secret. MUST be changed in production.",
)
auth_algorithm: str = Field(default="HS256", description="JWT signing algorithm.")
auth_token_expire_minutes: int = Field(default=480, description="JWT TTL in minutes (default 8 hours).")
auth_enabled: bool = Field(default=True, description="Set False to bypass auth (development only).")
# ── CORS ──────────────────────────────────────────────────────────────────
cors_allow_origins: str = Field(
default="http://localhost:5173",
description="Comma-separated allowed CORS origins. Never use * in production.",
)
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -0,0 +1,10 @@
"""Auth domain: role definitions and token claim models.
The domain layer defines what a user identity looks like (UserClaims) and
what roles exist (UserRole). Infrastructure details (JWT, bcrypt, PostgreSQL)
live under infrastructure/auth and never leak into this package.
"""
from .models import UserClaims, UserRole
__all__ = ["UserClaims", "UserRole"]

View File

@@ -0,0 +1,42 @@
"""Auth domain models: roles and token claims.
UserRole defines the four roles from PPT Slide 12.
UserClaims is what the JWT decodes to — it is the identity object passed
through FastAPI dependency injection to route handlers.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass
class UserRole(str, enum.Enum):
"""Access roles mirroring the four-role RBAC matrix from the product spec.
ADMIN — full platform access including system management.
LEGAL — knowledge query, document review, compliance checks.
EHS — knowledge query, perception/regulatory signals.
READONLY — knowledge query only.
"""
ADMIN = "admin"
LEGAL = "legal"
EHS = "ehs"
READONLY = "readonly"
@dataclass
class UserClaims:
"""Decoded JWT payload representing an authenticated user.
Instances are created by JWTHandler.decode_token() and injected into
route handlers via the get_current_user FastAPI dependency.
"""
# Unique user identifier (UUID string stored in PostgreSQL users table).
user_id: str
# Display name used for audit log entries.
username: str
# Role determines which resources the user may access.
role: UserRole

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,5 @@
"""JWT token creation and validation infrastructure.
JWTHandler is the only component in this package. It is wired through
shared/bootstrap.py and injected into FastAPI dependencies.
"""

View File

@@ -0,0 +1,82 @@
"""JWT access token creation and decoding.
Uses python-jose for HS256 token signing. Token expiry is enforced at
decode time so expired tokens are rejected even if the signature is valid.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from jose import JWTError, jwt
from loguru import logger
from app.domain.auth.models import UserClaims, UserRole
class JWTHandler:
"""Create and validate HS256 JWT access tokens.
A single shared instance is wired by bootstrap.py. Use
get_jwt_handler() from shared.bootstrap for all token operations.
"""
def __init__(
self,
*,
secret_key: str,
algorithm: str = "HS256",
expire_minutes: int = 480,
) -> None:
"""Initialise the handler with signing credentials and token lifetime."""
self._secret = secret_key
self._algorithm = algorithm
self._expire_minutes = expire_minutes
def create_access_token(
self,
*,
user_id: str,
username: str,
role: str,
) -> str:
"""Return a signed JWT containing user identity and role claims."""
now = datetime.now(UTC)
payload: dict[str, Any] = {
"sub": user_id,
"username": username,
"role": role,
"iat": now,
"exp": now + timedelta(minutes=self._expire_minutes),
}
return jwt.encode(payload, self._secret, algorithm=self._algorithm)
def decode_token(self, token: str) -> UserClaims:
"""Decode and validate a JWT, returning UserClaims.
Raises ValueError with a descriptive message on expiry, tampering,
or any other validation failure so callers do not need to know jose.
"""
try:
payload = jwt.decode(token, self._secret, algorithms=[self._algorithm])
except JWTError as exc:
msg = str(exc).lower()
if "expired" in msg:
raise ValueError("Token expired") from exc
raise ValueError(f"Invalid token: {exc}") from exc
user_id = payload.get("sub")
username = payload.get("username", "")
role_str = payload.get("role", UserRole.READONLY.value)
if not user_id:
raise ValueError("Token missing subject claim")
try:
role = UserRole(role_str)
except ValueError:
logger.warning("Unknown role in token: {}, defaulting to readonly", role_str)
role = UserRole.READONLY
return UserClaims(user_id=user_id, username=username, role=role)

View File

@@ -0,0 +1,113 @@
"""PostgreSQL-backed user store for authentication.
Manages a `users` table with hashed passwords and roles.
Provides lookup by username for the login flow.
Table DDL is auto-applied on first connection.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import psycopg2
import psycopg2.extras
from loguru import logger
from passlib.context import CryptContext
from app.config.settings import settings
# bcrypt context — work factor 12 is a good production default.
_PWD_CTX = CryptContext(schemes=["bcrypt"], deprecated="auto")
# DDL executed once to ensure the table exists.
_CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(100) UNIQUE NOT NULL,
hashed_pw TEXT NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'readonly',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"""
@dataclass
class UserRecord:
"""A single row from the users table."""
id: str
username: str
hashed_pw: str
role: str
is_active: bool
class PostgresUserStore:
"""Read and verify users stored in the PostgreSQL users table.
The connection is opened on first use and shared for the lifetime
of the singleton instance wired by bootstrap.
"""
def __init__(self) -> None:
"""Initialise the store and ensure the users table exists."""
self._conn = psycopg2.connect(
host=settings.postgres_host,
port=settings.postgres_port,
user=settings.postgres_user,
password=settings.postgres_password,
dbname=settings.postgres_db,
cursor_factory=psycopg2.extras.RealDictCursor,
)
self._conn.autocommit = True
self._ensure_table()
def _ensure_table(self) -> None:
"""Create the users table if it does not already exist."""
with self._conn.cursor() as cur:
# Enable pgcrypto so gen_random_uuid() is available for UUID primary keys.
try:
cur.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto;")
except Exception:
self._conn.rollback()
cur.execute(_CREATE_TABLE_SQL)
def get_by_username(self, username: str) -> Optional[UserRecord]:
"""Return a UserRecord for the given username, or None if not found."""
with self._conn.cursor() as cur:
cur.execute(
"SELECT id, username, hashed_pw, role, is_active "
"FROM users WHERE username = %s",
(username,),
)
row = cur.fetchone()
if row is None:
return None
return UserRecord(
id=str(row["id"]),
username=row["username"],
hashed_pw=row["hashed_pw"],
role=row["role"],
is_active=row["is_active"],
)
def verify_password(self, plain: str, hashed: str) -> bool:
"""Return True if `plain` matches the stored bcrypt hash."""
return _PWD_CTX.verify(plain, hashed)
def authenticate(self, username: str, password: str) -> Optional[UserRecord]:
"""Return the UserRecord if credentials are valid, else None."""
user = self.get_by_username(username)
if user is None or not user.is_active:
return None
if not self.verify_password(password, user.hashed_pw):
return None
return user
@staticmethod
def hash_password(plain: str) -> str:
"""Hash a plain-text password with bcrypt."""
return _PWD_CTX.hash(plain)

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,39 @@
"""Abstract base class for regulatory event stores."""
from __future__ import annotations
from abc import ABC, abstractmethod
class BaseEventStore(ABC):
"""Port interface for regulatory event persistence."""
@abstractmethod
def all(self) -> list[dict]:
"""Return all events, most-recent first."""
@abstractmethod
def get(self, event_id: str) -> dict | None:
"""Return a single event by ID, or None."""
@abstractmethod
def filter(
self,
*,
source: str | None = None,
impact_level: str | None = None,
limit: int = 50,
) -> list[dict]:
"""Return filtered events sorted by published_at descending."""
@abstractmethod
def stats(self) -> dict:
"""Return {total, high_impact, medium_impact, low_impact, recent_90d}."""
@abstractmethod
def upsert(self, event: dict) -> None:
"""Insert or update an event record."""
@abstractmethod
def get_by_standard_code(self, standard_code: str) -> dict | None:
"""Return the most-recent event with matching standard_code, or None."""

View File

@@ -0,0 +1,43 @@
"""Shared utility functions for crawlers."""
from __future__ import annotations
import re
from datetime import date
def parse_date(text: str) -> str:
"""Return YYYY-MM-DD from common Chinese date formats, or today's date."""
text = text.strip()
if not text:
return date.today().isoformat()
m = re.search(r"(\d{4})[/-](\d{1,2})[/-](\d{1,2})", text)
if m:
try:
return date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat()
except ValueError:
pass
m2 = re.search(r"(\d{4})年(\d{1,2})月(\d{1,2})日?", text)
if m2:
try:
return date(int(m2.group(1)), int(m2.group(2)), int(m2.group(3))).isoformat()
except ValueError:
pass
return date.today().isoformat()
def extract_tags(standard_code: str, title: str) -> list[str]:
"""Derive simple keyword tags from standard code and title."""
tags: list[str] = []
code_upper = standard_code.upper()
if "GB" in code_upper:
tags.append("国家标准")
if "/T" in code_upper:
tags.append("推荐性")
else:
tags.append("强制性")
keywords = ["电动", "安全", "自动驾驶", "充电", "智能网联", "碰撞", "排放", "网络安全"]
for kw in keywords:
if kw in title:
tags.append(kw)
return tags[:5]

View File

@@ -0,0 +1,32 @@
"""Shared contracts for regulatory source crawlers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass
class RawEvent:
"""Raw regulatory event returned by a crawler before enrichment."""
source: str
source_label: str
standard_code: str
title: str
summary: str
full_text_url: str
status: str # 'enacted' | 'draft' | 'consultation'
published_at: str # YYYY-MM-DD string
effective_at: str | None
category: str
tags: list[str] = field(default_factory=list)
raw_text: str = "" # full crawled text for hashing + LLM
class BaseCrawler(ABC):
"""Abstract regulatory source crawler."""
@abstractmethod
def fetch(self, limit: int = 50) -> list[RawEvent]:
"""Fetch up to `limit` recent events from the data source."""

View File

@@ -0,0 +1,83 @@
"""Crawler for CATARC automotive standard catalogue."""
from __future__ import annotations
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
from loguru import logger
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from ._utils import extract_tags, parse_date
_BASE_URL = "https://www.catarc.org.cn/bzzxd/qcbz/index.html"
_HOST = "https://www.catarc.org.cn"
_STATUS_MAP = {
"现行": "enacted",
"即将实施": "enacted",
"废止": "enacted",
"征求意见": "consultation",
"报批": "draft",
}
class CatarcCrawler(BaseCrawler):
"""Scrape the CATARC automotive standard list page."""
def fetch(self, limit: int = 50) -> list[RawEvent]:
events: list[RawEvent] = []
page = 1
max_pages = max(10, limit)
while len(events) < limit and page <= max_pages:
url = f"{_BASE_URL}?page={page}"
try:
resp = httpx.get(url, timeout=30, follow_redirects=True)
resp.raise_for_status()
except Exception as exc:
logger.warning("CATARC fetch failed page={} err={}", page, exc)
break
soup = BeautifulSoup(resp.text, "lxml")
rows = soup.select("table tr")
if not rows:
break
batch: list[RawEvent] = []
for row in rows:
cells = row.find_all("td")
if len(cells) < 3:
continue
link = cells[0].find("a")
standard_code = link.get_text(strip=True) if link else cells[0].get_text(strip=True)
title = cells[1].get_text(strip=True) if len(cells) > 1 else standard_code
date_text = cells[2].get_text(strip=True) if len(cells) > 2 else ""
published_at = parse_date(date_text)
status_text = cells[3].get_text(strip=True) if len(cells) > 3 else ""
status = _STATUS_MAP.get(status_text, "enacted")
detail_url = urljoin(_HOST, link["href"]) if link and link.get("href") else url
raw_text = f"{standard_code} {title}"
batch.append(RawEvent(
source="CATARC",
source_label="全国汽车标准化技术委员会",
standard_code=standard_code,
title=title,
summary=title,
full_text_url=detail_url,
status=status,
published_at=published_at,
effective_at=None,
category="汽车标准",
tags=extract_tags(standard_code, title),
raw_text=raw_text,
))
if not batch:
break
events.extend(batch)
page += 1
return events[:limit]

View File

@@ -0,0 +1,117 @@
"""Crawler for EUR-Lex RSS feeds covering EU AI Act and automotive regulations."""
from __future__ import annotations
import re
from email.utils import parsedate_to_datetime
import httpx
from bs4 import BeautifulSoup
from loguru import logger
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from ._utils import parse_date
_EURLEX_RSS_URLS = [
"https://eur-lex.europa.eu/rss-feed/OJ-L.rss",
]
_AUTOMOTIVE_KEYWORDS = [
"vehicle", "automotive", "motor", "tyre", "emission", "ADAS", "autonomous",
"AI Act", "artificial intelligence", "cybersecurity", "software update",
"R155", "R156", "汽车", "车辆",
]
_AUTOMOTIVE_KEYWORDS_LOWER = [kw.lower() for kw in _AUTOMOTIVE_KEYWORDS]
def _is_automotive_relevant(title: str, description: str) -> bool:
combined = (title + " " + description).lower()
return any(kw in combined for kw in _AUTOMOTIVE_KEYWORDS_LOWER)
def _extract_celex(url: str) -> str:
m = re.search(r"CELEX[:/]([0-9A-Z]+)", url)
return m.group(1) if m else ""
def _parse_rss_date(rfc2822: str) -> str:
try:
dt = parsedate_to_datetime(rfc2822)
return dt.date().isoformat()
except Exception:
return parse_date(rfc2822)
class EurlexCrawler(BaseCrawler):
"""Fetch automotive-relevant EU regulations from EUR-Lex RSS feeds."""
def fetch(self, limit: int = 50) -> list[RawEvent]:
events: list[RawEvent] = []
for rss_url in _EURLEX_RSS_URLS:
if len(events) >= limit:
break
try:
resp = httpx.get(rss_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
except Exception as exc:
logger.warning("EUR-Lex RSS fetch failed url={} err={}", rss_url, exc)
continue
soup = BeautifulSoup(resp.content, "lxml-xml")
for item in soup.find_all("item"):
if len(events) >= limit:
break
title_tag = item.find("title")
title = title_tag.get_text(strip=True) if title_tag else ""
desc_tag = item.find("description")
description = desc_tag.get_text(strip=True) if desc_tag else ""
link_tag = item.find("link")
link = link_tag.get_text(strip=True) if link_tag else ""
pub_date_tag = item.find("pubDate")
pub_date = pub_date_tag.get_text(strip=True) if pub_date_tag else ""
if not _is_automotive_relevant(title, description):
continue
celex = _extract_celex(link)
standard_code = celex if celex else title[:60]
published_at = _parse_rss_date(pub_date) if pub_date else ""
events.append(RawEvent(
source="EUR-Lex",
source_label="欧盟官方公报",
standard_code=standard_code,
title=title,
summary=description[:500],
full_text_url=link,
status="enacted",
published_at=published_at,
effective_at=None,
category="EU法规",
tags=_extract_eurlex_tags(title, description),
raw_text=f"{title}\n{description}",
))
return events[:limit]
def _extract_eurlex_tags(title: str, description: str) -> list[str]:
combined = title + " " + description
tag_map = {
"AI Act": "EU AI Act",
"artificial intelligence": "EU AI Act",
"R155": "UN R155",
"R156": "UN R156",
"cybersecurity": "网络安全",
"emission": "排放",
"autonomous": "自动驾驶",
"ADAS": "ADAS",
}
combined_lower = combined.lower()
tags = []
for kw, tag in tag_map.items():
if kw.lower() in combined_lower:
tags.append(tag)
return tags[:5]

View File

@@ -0,0 +1,92 @@
"""Crawlers for the 国标委 (SAMR) standard information platform."""
from __future__ import annotations
import httpx
from loguru import logger
from app.infrastructure.perception.crawlers.base import BaseCrawler, RawEvent
from ._utils import extract_tags, parse_date
_BASE_URL = "https://openstd.samr.gov.cn/bzgk/std/std_list_type"
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; RegulatoryBot/1.0)"}
def _fetch_page(std_type: int, page: int, page_size: int) -> list[dict]:
params = {
"p.p1": std_type,
"p.p2": "",
"p.p90": "circulation_date",
"p.p91": "desc",
"p.p6": page,
"p.p7": page_size,
}
try:
resp = httpx.get(_BASE_URL, params=params, headers=_HEADERS, timeout=30)
resp.raise_for_status()
data = resp.json()
return data.get("rows", []) or []
except Exception as exc:
logger.warning("国标委 fetch failed type={} page={} err={}", std_type, page, exc)
return []
def _row_to_raw_event(row: dict, source_label: str) -> RawEvent:
standard_code = row.get("std_code", "")
title = row.get("std_name", standard_code)
published_at = parse_date(row.get("release_date", ""))
effective_at_raw = row.get("implement_date", "")
effective_at = parse_date(effective_at_raw) if effective_at_raw else None
status_text = row.get("std_status", "")
if "征求意见" in status_text:
status = "consultation"
elif "报批" in status_text or "草案" in status_text:
status = "draft"
else:
status = "enacted"
return RawEvent(
source="国标委",
source_label=source_label,
standard_code=standard_code,
title=title,
summary=title,
full_text_url=f"https://openstd.samr.gov.cn/bzgk/std/detail?id={row.get('id', '')}",
status=status,
published_at=published_at,
effective_at=effective_at,
category=row.get("std_type", "国家标准"),
tags=extract_tags(standard_code, title),
raw_text=f"{standard_code} {title}",
)
class GuobiaoMandatoryCrawler(BaseCrawler):
"""Fetch mandatory national standards (强制性) related to vehicles."""
def fetch(self, limit: int = 50) -> list[RawEvent]:
events: list[RawEvent] = []
page = 1
max_pages = max(10, limit)
while len(events) < limit and page <= max_pages:
rows = _fetch_page(std_type=1, page=page, page_size=20)
if not rows:
break
events.extend(_row_to_raw_event(r, "国标委·强制性") for r in rows)
page += 1
return events[:limit]
class GuobiaoRecommendedCrawler(BaseCrawler):
"""Fetch recommended national standards (推荐性) related to vehicles."""
def fetch(self, limit: int = 50) -> list[RawEvent]:
events: list[RawEvent] = []
page = 1
max_pages = max(10, limit)
while len(events) < limit and page <= max_pages:
rows = _fetch_page(std_type=2, page=page, page_size=20)
if not rows:
break
events.extend(_row_to_raw_event(r, "国标委·推荐性") for r in rows)
page += 1
return events[:limit]

View File

@@ -0,0 +1,241 @@
"""LLM-driven pipeline for regulatory event enrichment."""
from __future__ import annotations
import json
import math
from typing import Any
from loguru import logger
from app.config.settings import settings
from app.infrastructure.embedding.openai_compatible_embedding_provider import (
OpenAICompatibleEmbeddingProvider,
)
from app.services.llm.llm_factory import get_llm_client
_EXTRACT_SYSTEM = (
"You are a regulatory compliance expert specialising in automotive standards "
"(GB, UN-ECE, ISO, EU). Extract structured information from regulation text. "
"Return valid JSON only — no markdown fences, no extra keys."
)
_ASSESS_SYSTEM = (
"You are an automotive compliance analyst. Given a regulation and related document excerpts, "
"identify which documents are affected and what actions are required. "
"Return a JSON array only."
)
_DIFF_SYSTEM = (
"You are a regulatory change analyst. Given an old and new version of a regulation paragraph, "
"classify the type of change and summarise it. "
"Return JSON only: {\"change_type\": \"tightened|relaxed|added|removed\", \"summary\": \"...\"}"
)
_SIMILARITY_THRESHOLD = 0.85
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def _llm_json(client: Any, messages: list[dict]) -> Any:
"""Call LLM and parse JSON response; return None on failure."""
try:
resp = client.chat(messages)
text = (resp.content or "").strip()
if text.startswith("```"):
text = text.split("```")[1]
if text.startswith("json"):
text = text[4:]
return json.loads(text)
except Exception as exc:
logger.warning("LLM JSON parse failed: {}", exc)
return None
class LlmPipeline:
"""Three-step enrichment pipeline for crawled regulatory events."""
def __init__(self) -> None:
self._client = get_llm_client(
provider=settings.llm_provider,
model=settings.llm_model,
)
self._embedder = OpenAICompatibleEmbeddingProvider()
# ------------------------------------------------------------------
# Step 1: Structure extraction
# ------------------------------------------------------------------
def extract_structure(self, event: dict) -> dict:
"""Extract obligations, deadlines, scope, penalties, impact_level from event text."""
prompt = f"""Extract structured compliance information from this regulation:
Standard: {event.get('standard_code', '')}
Title: {event.get('title', '')}
Source: {event.get('source_label', '')}
Summary: {event.get('summary', '')}
Tags: {', '.join(event.get('tags') or [])}
Return JSON with exactly these keys:
{{
"obligations": [{{"text": "...", "deontic": "must|shall|may|prohibited", "subject": "...", "object": "...", "condition": ""}}],
"deadlines": [{{"date": "YYYY-MM-DD or null", "description": "..."}}],
"scope": "one sentence describing who/what this applies to",
"penalties": "one sentence on consequences of non-compliance, or null",
"impact_level": "high|medium|low"
}}"""
messages = [
{"role": "system", "content": _EXTRACT_SYSTEM},
{"role": "user", "content": prompt},
]
result = _llm_json(self._client, messages)
if not isinstance(result, dict):
return {
"obligations": [],
"deadlines": [],
"scope": "",
"penalties": "",
"impact_level": "medium",
}
return result
# ------------------------------------------------------------------
# Step 2: Impact assessment
# ------------------------------------------------------------------
def assess_impact(self, event: dict, retrieval_service: Any) -> list[dict]:
"""Use RAG to find affected documents and generate recommendations."""
obligations = event.get("obligations") or []
obligation_texts = " ".join(o.get("text", "") for o in obligations[:3])
query = f"{event.get('standard_code', '')} {event.get('title', '')} {obligation_texts}"
try:
chunks = retrieval_service.retrieve(query=query, top_k=5)
except Exception as exc:
logger.warning("RAG retrieval failed: {}", exc)
return []
if not chunks:
return []
seen: set[str] = set()
doc_excerpts: list[dict] = []
for chunk in chunks:
if chunk.doc_id not in seen:
seen.add(chunk.doc_id)
doc_excerpts.append({
"doc_id": chunk.doc_id,
"doc_name": chunk.doc_title,
"score": round(float(chunk.score if chunk.score is not None else 0), 4),
"snippet": (chunk.text or "")[:300],
"clause": getattr(chunk, "section_title", "") or "",
})
context = "\n".join(
f"[{d['doc_name']} {d['clause']}] score={d['score']}: {d['snippet']}"
for d in doc_excerpts
)
prompt = f"""Regulation: {event.get('standard_code')}{event.get('title')}
Obligations: {obligation_texts or event.get('summary', '')}
Affected documents found in knowledge base:
{context}
For each document, assess impact and recommend action. Return JSON array:
[{{"doc_id":"...","doc_name":"...","score":0.0,"key_clauses":"...","recommendation":"one sentence action"}}]"""
messages = [
{"role": "system", "content": _ASSESS_SYSTEM},
{"role": "user", "content": prompt},
]
result = _llm_json(self._client, messages)
if isinstance(result, list):
score_map = {d["doc_id"]: d["score"] for d in doc_excerpts}
for item in result:
if isinstance(item, dict) and item.get("doc_id") in score_map:
item["score"] = score_map[item["doc_id"]]
return result
return doc_excerpts
# ------------------------------------------------------------------
# Step 3: Semantic diff
# ------------------------------------------------------------------
def compute_diff(self, old_text: str, new_text: str) -> dict:
"""Compare old and new regulation text; return changed sections and summary."""
old_paras = [p.strip() for p in old_text.split("\n") if p.strip()]
new_paras = [p.strip() for p in new_text.split("\n") if p.strip()]
if not old_paras or not new_paras:
return {"changed_sections": [], "change_summary": "No comparable text."}
all_paras = old_paras + new_paras
try:
all_embeddings = self._embedder.embed_texts(all_paras)
except Exception as exc:
logger.warning("Embedding for diff failed: {}", exc)
return {"changed_sections": [], "change_summary": "Diff unavailable (embedding error)."}
old_embeddings = all_embeddings[: len(old_paras)]
new_embeddings = all_embeddings[len(old_paras):]
changed_sections: list[dict] = []
max_len = max(len(old_paras), len(new_paras))
for i in range(max_len):
if i >= len(old_paras):
# New paragraph added
changed_sections.append({
"old_text": "",
"new_text": new_paras[i][:300],
"similarity": 0.0,
"change_type": "added",
"summary": "New paragraph added.",
})
continue
if i >= len(new_paras):
# Old paragraph removed
changed_sections.append({
"old_text": old_paras[i][:300],
"new_text": "",
"similarity": 0.0,
"change_type": "removed",
"summary": "Paragraph removed.",
})
continue
# Both exist — compare via embeddings
sim = _cosine(old_embeddings[i], new_embeddings[i])
if sim < _SIMILARITY_THRESHOLD:
messages = [
{"role": "system", "content": _DIFF_SYSTEM},
{"role": "user", "content": f"OLD: {old_paras[i][:500]}\nNEW: {new_paras[i][:500]}"},
]
classification = _llm_json(self._client, messages) or {}
changed_sections.append({
"old_text": old_paras[i][:300],
"new_text": new_paras[i][:300],
"similarity": round(sim, 3),
"change_type": classification.get("change_type", "modified"),
"summary": classification.get("summary", ""),
})
if not changed_sections:
change_summary = "No substantive changes detected between versions."
else:
types = [s["change_type"] for s in changed_sections]
change_summary = (
f"{len(changed_sections)} paragraph(s) changed: "
+ ", ".join(f"{t}" for t in set(types))
+ ". "
+ (changed_sections[0].get("summary", "") if changed_sections else "")
)
return {"changed_sections": changed_sections, "change_summary": change_summary}

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from app.infrastructure.perception.base_event_store import BaseEventStore
MOCK_EVENTS: list[dict[str, Any]] = [ MOCK_EVENTS: list[dict[str, Any]] = [
# ------------------------------------------------------------------ HIGH # ------------------------------------------------------------------ HIGH
{ {
@@ -379,18 +381,18 @@ MOCK_EVENTS: list[dict[str, Any]] = [
}, },
] ]
# Index for fast lookup class MockEventStore(BaseEventStore):
_EVENT_INDEX: dict[str, dict] = {e["id"]: e for e in MOCK_EVENTS}
class MockEventStore:
"""In-memory mock store for regulatory events.""" """In-memory mock store for regulatory events."""
def __init__(self) -> None:
self._events: list[dict] = [dict(e) for e in MOCK_EVENTS]
self._index: dict[str, dict] = {e["id"]: e for e in self._events}
def all(self) -> list[dict]: def all(self) -> list[dict]:
return list(MOCK_EVENTS) return list(self._events)
def get(self, event_id: str) -> dict | None: def get(self, event_id: str) -> dict | None:
return _EVENT_INDEX.get(event_id) return self._index.get(event_id)
def filter( def filter(
self, self,
@@ -399,23 +401,39 @@ class MockEventStore:
impact_level: str | None = None, impact_level: str | None = None,
limit: int = 50, limit: int = 50,
) -> list[dict]: ) -> list[dict]:
events = list(MOCK_EVENTS) events = list(self._events)
if source: if source:
events = [e for e in events if e["source"] == source] events = [e for e in events if e["source"] == source]
if impact_level: if impact_level:
events = [e for e in events if e["impact_level"] == impact_level] events = [e for e in events if e["impact_level"] == impact_level]
events.sort(key=lambda e: e["published_at"], reverse=True) events.sort(key=lambda e: e.get("published_at") or "", reverse=True)
return events[:limit] return events[:limit]
def stats(self) -> dict: def stats(self) -> dict:
from datetime import date, timedelta from datetime import date, timedelta
events = MOCK_EVENTS events = self._events
cutoff = (date.today() - timedelta(days=90)).isoformat() cutoff = (date.today() - timedelta(days=90)).isoformat()
return { return {
"total": len(events), "total": len(events),
"high_impact": sum(1 for e in events if e["impact_level"] == "high"), "high_impact": sum(1 for e in events if e["impact_level"] == "high"),
"medium_impact": sum(1 for e in events if e["impact_level"] == "medium"), "medium_impact": sum(1 for e in events if e["impact_level"] == "medium"),
"low_impact": sum(1 for e in events if e["impact_level"] == "low"), "low_impact": sum(1 for e in events if e["impact_level"] == "low"),
"recent_90d": sum(1 for e in events if e["published_at"] >= cutoff), "recent_90d": sum(1 for e in events if (e.get("published_at") or "") >= cutoff),
} }
def upsert(self, event: dict) -> None:
"""Insert or update event in the in-memory list (used in tests)."""
existing = self._index.get(event["id"])
if existing:
existing.update(event)
else:
self._events.append(event)
self._index[event["id"]] = event
def get_by_standard_code(self, standard_code: str) -> dict | None:
"""Return most-recent event with matching standard_code."""
matches = [e for e in self._events if e.get("standard_code") == standard_code]
if not matches:
return None
return max(matches, key=lambda e: e.get("published_at", ""))

View File

@@ -0,0 +1,225 @@
"""PostgreSQL-backed regulatory event store."""
from __future__ import annotations
import json
from contextlib import contextmanager
from datetime import UTC, date, datetime, timedelta
from typing import Any
import psycopg2
import psycopg2.extras
from psycopg2.pool import ThreadedConnectionPool
from app.config.settings import settings
from app.infrastructure.perception.base_event_store import BaseEventStore
_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS regulation_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
source_label TEXT,
standard_code TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT,
full_text_url TEXT,
status TEXT,
impact_level TEXT,
published_at DATE,
effective_at DATE,
category TEXT,
tags TEXT[],
obligations JSONB,
deadlines JSONB,
scope TEXT,
penalties TEXT,
content_hash TEXT,
previous_hash TEXT,
change_summary TEXT,
changed_sections JSONB,
affected_docs JSONB,
crawled_at TIMESTAMPTZ DEFAULT now(),
processed_at TIMESTAMPTZ,
raw_storage_key TEXT
);
CREATE INDEX IF NOT EXISTS reg_events_source_date
ON regulation_events (source, published_at DESC);
CREATE INDEX IF NOT EXISTS reg_events_impact_date
ON regulation_events (impact_level, published_at DESC);
"""
_ALL_COLUMNS = (
"id", "source", "source_label", "standard_code", "title", "summary",
"full_text_url", "status", "impact_level", "published_at", "effective_at",
"category", "tags", "obligations", "deadlines", "scope", "penalties",
"content_hash", "previous_hash", "change_summary", "changed_sections",
"affected_docs", "crawled_at", "processed_at", "raw_storage_key",
)
def _row_to_dict(row: dict[str, Any]) -> dict:
"""Convert a psycopg2 RealDictRow to a plain dict with serialized JSON fields."""
d = dict(row)
for field in ("obligations", "deadlines", "changed_sections", "affected_docs"):
val = d.get(field)
if isinstance(val, str):
d[field] = json.loads(val)
for date_field in ("published_at", "effective_at"):
val = d.get(date_field)
if isinstance(val, datetime):
d[date_field] = val.date().isoformat()
elif isinstance(val, date):
d[date_field] = val.isoformat()
for ts_field in ("crawled_at", "processed_at"):
val = d.get(ts_field)
if isinstance(val, datetime):
d[ts_field] = val.isoformat()
return d
class PostgresEventStore(BaseEventStore):
"""Regulatory event store backed by PostgreSQL."""
def __init__(self) -> None:
self._pool = ThreadedConnectionPool(
minconn=1,
maxconn=5,
host=settings.postgres_host,
port=settings.postgres_port,
user=settings.postgres_user,
password=settings.postgres_password,
dbname=settings.postgres_db,
)
self._ensure_schema()
def _ensure_schema(self) -> None:
with self._conn() as conn:
try:
with conn.cursor() as cur:
cur.execute(_CREATE_TABLE)
conn.commit()
except Exception:
conn.rollback()
raise
@contextmanager
def _conn(self):
conn = None
try:
conn = self._pool.getconn()
yield conn
finally:
if conn is not None:
self._pool.putconn(conn)
def all(self) -> list[dict]:
with self._conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT * FROM regulation_events ORDER BY published_at DESC NULLS LAST"
)
return [_row_to_dict(r) for r in cur.fetchall()]
def get(self, event_id: str) -> dict | None:
with self._conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT * FROM regulation_events WHERE id = %s", (event_id,)
)
row = cur.fetchone()
return _row_to_dict(row) if row else None
def filter(
self,
*,
source: str | None = None,
impact_level: str | None = None,
limit: int = 50,
) -> list[dict]:
conditions: list[str] = []
params: list[Any] = []
if source:
conditions.append("source = %s")
params.append(source)
if impact_level:
conditions.append("impact_level = %s")
params.append(impact_level)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
params.append(limit)
sql = f"""
SELECT * FROM regulation_events
{where}
ORDER BY published_at DESC NULLS LAST
LIMIT %s
"""
with self._conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
return [_row_to_dict(r) for r in cur.fetchall()]
def stats(self) -> dict:
cutoff = (date.today() - timedelta(days=90)).isoformat()
with self._conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT COUNT(*) AS count FROM regulation_events")
total = (cur.fetchone() or {}).get("count", 0)
cur.execute(
"SELECT COUNT(*) AS count FROM regulation_events WHERE impact_level = 'high'"
)
high = (cur.fetchone() or {}).get("count", 0)
cur.execute(
"SELECT COUNT(*) AS count FROM regulation_events WHERE impact_level = 'medium'"
)
medium = (cur.fetchone() or {}).get("count", 0)
cur.execute(
"SELECT COUNT(*) AS count FROM regulation_events WHERE published_at >= %s",
(cutoff,),
)
recent = (cur.fetchone() or {}).get("count", 0)
return {
"total": int(total),
"high_impact": int(high),
"medium_impact": int(medium),
"recent_90d": int(recent),
}
def upsert(self, event: dict) -> None:
"""Insert or update a regulation event."""
cols = [c for c in _ALL_COLUMNS if c in event]
placeholders = ", ".join(f"%({c})s" for c in cols)
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c != "id")
sql = f"""
INSERT INTO regulation_events ({', '.join(cols)})
VALUES ({placeholders})
ON CONFLICT (id) DO UPDATE SET {updates}
"""
row: dict[str, Any] = {}
for c in cols:
val = event.get(c)
if c in ("obligations", "deadlines", "changed_sections", "affected_docs") and val is not None:
row[c] = json.dumps(val, ensure_ascii=False)
elif c == "tags" and isinstance(val, list):
row[c] = val
else:
row[c] = val
with self._conn() as conn:
try:
with conn.cursor() as cur:
cur.execute(sql, row)
conn.commit()
except Exception:
conn.rollback()
raise
def get_by_standard_code(self, standard_code: str) -> dict | None:
with self._conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"""SELECT * FROM regulation_events
WHERE standard_code = %s
ORDER BY published_at DESC NULLS LAST
LIMIT 1""",
(standard_code,),
)
row = cur.fetchone()
return _row_to_dict(row) if row else None

View File

@@ -0,0 +1,169 @@
"""Redis-backed conversation store for persistent chat sessions.
Sessions are stored as JSON strings under the key `session:{session_id}`.
The Redis TTL is refreshed on every write so active sessions stay alive.
On expiry, `get_session` returns None — callers should create a new session.
"""
from __future__ import annotations
import json
import time
import uuid
from typing import Any
from loguru import logger
from app.domain.conversation import ConversationMessage, ConversationSession, ConversationStore
class RedisConversationStore(ConversationStore):
"""Store conversation sessions in Redis with automatic TTL expiry.
Each session is serialised as a JSON object at key ``session:{session_id}``.
The TTL is reset on every write so sessions stay alive as long as they are active.
"""
# Prefix for all session keys to avoid collisions with other Redis consumers.
_PREFIX = "session:"
def __init__(self, *, redis_client: Any, timeout_seconds: int = 1800) -> None:
"""Initialise the store with an existing Redis client and a TTL in seconds."""
self._redis = redis_client
self._ttl = timeout_seconds
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _key(self, session_id: str) -> str:
"""Build the Redis key for a session."""
return f"{self._PREFIX}{session_id}"
def _serialise(self, session: ConversationSession) -> str:
"""Serialise a ConversationSession to a JSON string."""
return json.dumps(
{
"session_id": session.session_id,
"created_at": session.created_at,
"updated_at": session.updated_at,
"metadata": session.metadata,
"messages": [
{
"role": msg.role,
"content": msg.content,
"timestamp": msg.timestamp,
"sources": msg.sources,
}
for msg in session.messages
],
},
ensure_ascii=False,
)
def _deserialise(self, raw: bytes | str) -> ConversationSession:
"""Deserialise a JSON string back into a ConversationSession."""
data = json.loads(raw)
messages = [
ConversationMessage(
role=m["role"],
content=m["content"],
timestamp=m["timestamp"],
sources=m.get("sources", []),
)
for m in data.get("messages", [])
]
session = ConversationSession(
session_id=data["session_id"],
created_at=data.get("created_at", 0),
updated_at=data.get("updated_at", 0),
metadata=data.get("metadata", {}),
)
session.messages = messages
return session
def _save(self, session: ConversationSession) -> None:
"""Persist a session to Redis and refresh its TTL."""
self._redis.setex(self._key(session.session_id), self._ttl, self._serialise(session))
# ------------------------------------------------------------------
# ConversationStore protocol
# ------------------------------------------------------------------
def create_session(self, metadata: dict | None = None) -> ConversationSession:
"""Create a new empty session and persist it immediately."""
now = int(time.time())
session = ConversationSession(
session_id=str(uuid.uuid4())[:8],
created_at=now,
updated_at=now,
metadata=metadata or {},
)
self._save(session)
return session
def get_session(self, session_id: str) -> ConversationSession | None:
"""Return a session by ID, or None if it does not exist or has expired."""
raw = self._redis.get(self._key(session_id))
if raw is None:
return None
try:
return self._deserialise(raw)
except Exception:
logger.warning("Failed to deserialise session: {}", session_id)
return None
def save_message(
self,
session_id: str,
*,
role: str,
content: str,
sources: list[dict] | None = None,
) -> ConversationSession | None:
"""Append a message to a session and refresh its TTL."""
session = self.get_session(session_id)
if session is None:
return None
session.messages.append(
ConversationMessage(
role=role,
content=content,
timestamp=int(time.time()),
sources=sources or [],
)
)
session.updated_at = int(time.time())
self._save(session)
return session
def delete_session(self, session_id: str) -> bool:
"""Delete a session. Returns True if it existed, False otherwise."""
deleted = self._redis.delete(self._key(session_id))
return bool(deleted)
def list_sessions(self) -> list[dict]:
"""Return summary dicts for all live sessions visible in this Redis DB.
Note: KEYS is used for simplicity; replace with SCAN for large deployments.
"""
pattern = f"{self._PREFIX}*"
keys = self._redis.keys(pattern)
result = []
for key in keys:
raw = self._redis.get(key)
if raw is None:
continue
try:
data = json.loads(raw)
result.append(
{
"session_id": data["session_id"],
"message_count": len(data.get("messages", [])),
"created_at": data.get("created_at", 0),
"updated_at": data.get("updated_at", 0),
}
)
except Exception:
continue
return result

View File

@@ -0,0 +1,5 @@
"""Celery task definitions for background processing.
This package exposes the shared Celery application instance and all
registered task functions used by API routes to enqueue work.
"""

View File

@@ -0,0 +1,45 @@
"""Shared Celery application instance for background task processing.
All workers and enqueueing call sites import `celery_app` from this module
so the broker/backend configuration stays in one place.
"""
from __future__ import annotations
from celery import Celery
from app.config.settings import settings
def _redis_url() -> str:
"""Return a Redis connection URL from application settings."""
if settings.redis_password:
return (
f"redis://:{settings.redis_password}@"
f"{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"
)
return f"redis://{settings.redis_host}:{settings.redis_port}/{settings.redis_db}"
_BROKER = _redis_url()
_BACKEND = _redis_url()
celery_app = Celery(
"compliance_hub",
broker=_BROKER,
backend=_BACKEND,
include=["app.infrastructure.tasks.document_tasks"],
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
# Acknowledge task only after successful execution to avoid data loss.
task_acks_late=True,
task_reject_on_worker_lost=True,
# Keep results for 1 hour for status polling.
result_expires=3600,
)

View File

@@ -0,0 +1,73 @@
"""Celery tasks for document processing.
Each task is a thin wrapper that retrieves the already-stored document
binary and delegates to DocumentCommandService._process_document.
The task does not accept raw file bytes — it reads them from the binary
store using the doc_id, so the Celery message payload stays small.
"""
from __future__ import annotations
from loguru import logger
from app.infrastructure.tasks.celery_app import celery_app
@celery_app.task(
name="app.infrastructure.tasks.document_tasks.process_document_task",
bind=True,
max_retries=3,
default_retry_delay=30,
acks_late=True,
)
def process_document_task(
self,
doc_id: str,
file_name: str,
doc_name: str,
regulation_type: str,
version: str,
generate_summary: bool,
run_id: str | None = None,
) -> dict:
"""Parse, embed, and index a document that has already been stored.
The task reads the file binary from MinIO using doc_id so the Celery
message stays small. Retries up to 3 times with a 30-second delay on
transient infrastructure errors.
"""
# Import inside the task function to avoid pickling issues and to ensure
# that each worker process initialises its own bootstrap singletons.
from app.shared.bootstrap import get_document_command_service, get_document_query_service
logger.info("process_document_task started: doc_id={}", doc_id)
try:
svc = get_document_command_service()
doc = get_document_query_service().get(doc_id)
if not doc:
raise ValueError(f"Document record not found: {doc_id}")
# Read the stored binary from MinIO — avoids passing raw bytes in the task message.
content = svc.binary_store.read(doc.object_name)
result = svc._process_document(
doc_id=doc_id,
file_name=file_name,
final_doc_name=doc_name,
content=content,
regulation_type=regulation_type,
version=version,
generate_summary=generate_summary,
run_id=run_id,
)
logger.info(
"process_document_task completed: doc_id={} status={} chunks={}",
doc_id, result.status, result.num_chunks,
)
return {"doc_id": result.doc_id, "status": result.status, "num_chunks": result.num_chunks}
except Exception as exc:
logger.exception("process_document_task failed: doc_id={}", doc_id)
# Retry on transient errors; permanent errors (bad file, parse failure)
# will exhaust retries and leave the document in FAILED state.
raise self.retry(exc=exc)

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

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

View File

@@ -19,6 +19,15 @@ from app.infrastructure.parser.local_chunk_builder import LocalRegulationChunkBu
from app.infrastructure.parser.local_document_parser import LocalDocumentParser from app.infrastructure.parser.local_document_parser import LocalDocumentParser
from app.infrastructure.parser.vector_chunk_builder import AliyunVectorChunkBuilder from app.infrastructure.parser.vector_chunk_builder import AliyunVectorChunkBuilder
from app.infrastructure.perception.mock_event_store import MockEventStore from app.infrastructure.perception.mock_event_store import MockEventStore
from app.application.perception.crawl_service import CrawlService
from app.infrastructure.perception.base_event_store import BaseEventStore
from app.infrastructure.perception.crawlers.catarc_crawler import CatarcCrawler
from app.infrastructure.perception.crawlers.guobiao_crawler import (
GuobiaoMandatoryCrawler,
GuobiaoRecommendedCrawler,
)
from app.infrastructure.perception.crawlers.eurlex_crawler import EurlexCrawler
from app.infrastructure.perception.llm_pipeline import LlmPipeline
from app.infrastructure.session.in_memory_conversation_store import InMemoryConversationStore from app.infrastructure.session.in_memory_conversation_store import InMemoryConversationStore
from app.infrastructure.storage.json_document_processing_store import JsonDocumentProcessingStore from app.infrastructure.storage.json_document_processing_store import JsonDocumentProcessingStore
from app.infrastructure.storage.json_document_repository import JsonDocumentRepository from app.infrastructure.storage.json_document_repository import JsonDocumentRepository
@@ -31,6 +40,8 @@ from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatib
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
from app.services.llm.llm_factory import LLMFactory 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. # Keep shared wiring centralized so dependency construction remains consistent.
@@ -252,7 +263,31 @@ def get_document_query_service() -> DocumentQueryService:
@lru_cache @lru_cache
def get_conversation_store() -> InMemoryConversationStore: def get_conversation_store() -> InMemoryConversationStore:
"""Return conversation store.""" """Return the active conversation store based on settings.
When session_backend='redis', sessions survive backend restarts and scale
across multiple API worker processes. When session_backend='memory' (default),
sessions are process-local and lost on restart.
"""
if settings.session_backend == "redis":
import redis as redis_lib
from app.infrastructure.session.redis_conversation_store import RedisConversationStore
# Build the Redis client from the same connection settings used by Celery.
kwargs: dict = {
"host": settings.redis_host,
"port": settings.redis_port,
"db": settings.redis_db,
"decode_responses": False,
}
if settings.redis_password:
kwargs["password"] = settings.redis_password
redis_client = redis_lib.Redis(**kwargs)
return RedisConversationStore( # type: ignore[return-value]
redis_client=redis_client,
timeout_seconds=settings.session_timeout_minutes * 60,
)
return InMemoryConversationStore( return InMemoryConversationStore(
max_sessions=settings.session_max_sessions, max_sessions=settings.session_max_sessions,
timeout_minutes=settings.session_timeout_minutes, timeout_minutes=settings.session_timeout_minutes,
@@ -269,11 +304,57 @@ def get_agent_conversation_service() -> AgentConversationService:
) )
@lru_cache
def get_event_store() -> BaseEventStore:
"""Return event store selected by DOCUMENT_REPOSITORY_BACKEND setting."""
if settings.document_repository_backend == "postgres":
from app.infrastructure.perception.postgres_event_store import PostgresEventStore
return PostgresEventStore()
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 @lru_cache
def get_perception_service() -> PerceptionService: def get_perception_service() -> PerceptionService:
"""Return perception service for regulatory intelligence."""
return PerceptionService( return PerceptionService(
event_store=MockEventStore(), event_store=get_event_store(),
retrieval_service=get_retrieval_service(),
)
@lru_cache
def get_crawl_service() -> CrawlService:
crawlers = {
"CATARC": CatarcCrawler(),
"国标委·强制性": GuobiaoMandatoryCrawler(),
"国标委·推荐性": GuobiaoRecommendedCrawler(),
"EUR-Lex": EurlexCrawler(),
}
return CrawlService(
crawlers=crawlers,
event_store=get_event_store(),
llm_pipeline=LlmPipeline(),
retrieval_service=get_retrieval_service(), retrieval_service=get_retrieval_service(),
) )
@@ -284,6 +365,35 @@ def get_agent_session_service() -> AgentSessionService:
return AgentSessionService(conversation_store=get_conversation_store()) return AgentSessionService(conversation_store=get_conversation_store())
@lru_cache
def get_celery_app():
"""Return the shared Celery application instance.
Imported lazily so Celery is not required when running without workers
(e.g., tests that mock bootstrap or dev without Redis).
"""
from app.infrastructure.tasks.celery_app import celery_app
return celery_app
@lru_cache
def get_jwt_handler():
"""Return the shared JWTHandler instance for token creation and validation."""
from app.infrastructure.auth.jwt_handler import JWTHandler
return JWTHandler(
secret_key=settings.auth_secret_key,
algorithm=settings.auth_algorithm,
expire_minutes=settings.auth_token_expire_minutes,
)
@lru_cache
def get_user_store():
"""Return the PostgreSQL user store (lazy-connects on first call)."""
from app.infrastructure.auth.user_store import PostgresUserStore
return PostgresUserStore()
def preload_runtime_dependencies() -> None: def preload_runtime_dependencies() -> None:
"""Warm dependencies that are safe and useful to preload during startup.""" """Warm dependencies that are safe and useful to preload during startup."""
LLMFactory.preload_clients(["qwen", "deepseek"]) LLMFactory.preload_clients(["qwen", "deepseek"])

View File

@@ -1,30 +1,48 @@
# ── Web framework ─────────────────────────────────────────────────────────────
fastapi>=0.110.0 fastapi>=0.110.0
uvicorn[standard]>=0.27.0 uvicorn[standard]>=0.27.0
python-multipart>=0.0.9 python-multipart>=0.0.9
# ── Config & utilities ────────────────────────────────────────────────────────
pydantic>=2.0.0 pydantic>=2.0.0
pydantic-settings>=2.0.0 pydantic-settings>=2.0.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
loguru>=0.7.0 loguru>=0.7.0
httpx>=0.25.0 httpx>=0.25.0
beautifulsoup4>=4.12.0
lxml>=5.0.0
tiktoken>=0.5.0 tiktoken>=0.5.0
tenacity>=8.2.0 tenacity>=8.2.0
# ── Auth ──────────────────────────────────────────────────────────────────────
python-jose[cryptography]>=3.3.0
# passlib is incompatible with bcrypt>=4.0 (removed __about__, strict 72-byte limit).
# Pin bcrypt to 3.x until passlib ships a fix.
passlib[bcrypt]>=1.7.4
bcrypt>=3.2.0,<4.0.0
# ── Async task queue ──────────────────────────────────────────────────────────
celery>=5.3.0
redis>=4.5.0
# ── Storage & databases ───────────────────────────────────────────────────────
pymilvus>=2.4.0 pymilvus>=2.4.0
minio>=7.1.0 minio>=7.1.0
psycopg2-binary>=2.9.0 psycopg2-binary>=2.9.0
# ── Document parsing ─────────────────────────────────────────────────────────
pymupdf>=1.24.0 pymupdf>=1.24.0
python-docx>=1.1.0 python-docx>=1.1.0
numpy>=1.24.0
alibabacloud-docmind-api20220711>=1.0.6 alibabacloud-docmind-api20220711>=1.0.6
alibabacloud-tea-openapi>=0.3.11 alibabacloud-tea-openapi>=0.3.11
alibabacloud-tea-util>=0.3.13 alibabacloud-tea-util>=0.3.13
# ── RAG / LangChain ───────────────────────────────────────────────────────────
langchain>=0.1.0 langchain>=0.1.0
langchain-milvus>=0.1.0 langchain-milvus>=0.1.0
numpy>=1.24.0
# ── Testing ───────────────────────────────────────────────────────────────────
pytest>=7.4.0 pytest>=7.4.0
pytest-asyncio>=0.21.0 pytest-asyncio>=0.21.0
fakeredis>=2.0.0

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

View File

@@ -0,0 +1,95 @@
"""Contract tests: any BaseEventStore implementation must pass these."""
from app.infrastructure.perception.base_event_store import BaseEventStore
from app.infrastructure.perception.mock_event_store import MockEventStore
def _store() -> BaseEventStore:
return MockEventStore()
def test_is_base_event_store():
assert isinstance(_store(), BaseEventStore)
def test_all_returns_list():
result = _store().all()
assert isinstance(result, list)
assert len(result) > 0
def test_get_known_id():
store = _store()
first = store.all()[0]
result = store.get(first["id"])
assert result is not None
assert result["id"] == first["id"]
def test_get_unknown_returns_none():
assert _store().get("does-not-exist") is None
def test_filter_by_impact():
store = _store()
highs = store.filter(impact_level="high", limit=100)
assert all(e["impact_level"] == "high" for e in highs)
def test_filter_limit():
store = _store()
result = store.filter(limit=3)
assert len(result) <= 3
def test_stats_keys():
stats = _store().stats()
for key in ("total", "high_impact", "medium_impact", "recent_90d"):
assert key in stats, f"missing key: {key}"
def test_upsert_and_get():
store = _store()
event = {
"id": "test-upsert-001",
"source": "TEST",
"source_label": "Test Source",
"standard_code": "TST-001",
"title": "Test Event",
"summary": "A test event",
"full_text_url": "https://example.com",
"status": "draft",
"impact_level": "low",
"published_at": "2026-01-01",
"effective_at": None,
"category": "test",
"tags": ["test"],
"content_hash": "abc123",
"previous_hash": None,
}
store.upsert(event)
result = store.get("test-upsert-001")
assert result is not None
assert result["title"] == "Test Event"
def test_get_by_standard_code():
store = _store()
first = store.all()[0]
result = store.get_by_standard_code(first["standard_code"])
assert result is not None
assert result["standard_code"] == first["standard_code"]
def test_upsert_updates_existing():
store = _store()
first = store.all()[0]
original_id = first["id"]
store.upsert({"id": original_id, "title": "Updated Title", "impact_level": first["impact_level"],
"standard_code": first.get("standard_code", ""), "source": first["source"],
"source_label": first.get("source_label", ""), "summary": "Updated",
"full_text_url": "", "status": first["status"], "published_at": first.get("published_at", ""),
"effective_at": None, "category": first.get("category", ""), "tags": [],
"content_hash": "newhash", "previous_hash": None})
result = store.get(original_id)
assert result is not None
assert result["title"] == "Updated Title"

View File

@@ -0,0 +1,111 @@
"""Integration tests for CrawlService."""
from __future__ import annotations
from unittest.mock import MagicMock
import hashlib
import pytest
from app.infrastructure.perception.crawlers.base import RawEvent
from app.infrastructure.perception.mock_event_store import MockEventStore
def _make_raw_event(code="TST-001"):
return RawEvent(
source="TEST", source_label="Test", standard_code=code,
title=f"Test {code}", summary="Summary", full_text_url="https://example.com",
status="enacted", published_at="2026-01-01", effective_at=None,
category="test", tags=["test"], raw_text="full text",
)
def _make_service(raw_events):
from app.application.perception.crawl_service import CrawlService
mock_crawler = MagicMock()
mock_crawler.fetch.return_value = raw_events
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {
"obligations": [], "deadlines": [], "scope": "test",
"penalties": None, "impact_level": "low",
}
mock_pipeline.assess_impact.return_value = []
mock_pipeline.compute_diff.return_value = {
"changed_sections": [], "change_summary": "No changes.",
}
mock_retrieval = MagicMock()
store = MockEventStore()
return CrawlService(
crawlers={"TEST": mock_crawler},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=mock_retrieval,
)
def test_crawl_yields_progress_and_done():
svc = _make_service([_make_raw_event("TST-001")])
events = list(svc.run_crawl())
event_types = [e.get("event") for e in events]
assert "done" in event_types
def test_crawl_upserts_to_store():
store = MockEventStore()
from app.application.perception.crawl_service import CrawlService
mock_crawler = MagicMock()
mock_crawler.fetch.return_value = [_make_raw_event("NEW-001")]
mock_pipeline = MagicMock()
mock_pipeline.extract_structure.return_value = {
"obligations": [], "deadlines": [], "scope": "",
"penalties": None, "impact_level": "medium",
}
mock_pipeline.assess_impact.return_value = []
mock_pipeline.compute_diff.return_value = {
"changed_sections": [], "change_summary": "",
}
svc = CrawlService(
crawlers={"TEST": mock_crawler},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
)
list(svc.run_crawl())
result = store.get_by_standard_code("NEW-001")
assert result is not None
assert result["title"] == "Test NEW-001"
def test_crawl_skips_unchanged_events():
store = MockEventStore()
raw = _make_raw_event("SKIP-001")
content_hash = hashlib.sha256(raw.raw_text.encode()).hexdigest()
store.upsert({
"id": hashlib.sha256(f"TEST-SKIP-001".encode()).hexdigest()[:12],
"standard_code": "SKIP-001",
"source": "TEST",
"source_label": "Test",
"title": "Test SKIP-001",
"summary": "",
"full_text_url": "",
"status": "enacted",
"impact_level": "low",
"published_at": "2026-01-01",
"effective_at": None,
"category": "test",
"tags": [],
"content_hash": content_hash,
})
mock_pipeline = MagicMock()
from app.application.perception.crawl_service import CrawlService
mock_crawler = MagicMock()
mock_crawler.fetch.return_value = [raw]
svc = CrawlService(
crawlers={"TEST": mock_crawler},
event_store=store,
llm_pipeline=mock_pipeline,
retrieval_service=MagicMock(),
)
list(svc.run_crawl())
mock_pipeline.extract_structure.assert_not_called()

View File

@@ -0,0 +1,127 @@
"""Unit tests for crawlers — mock httpx responses."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.infrastructure.perception.crawlers.base import RawEvent, BaseCrawler
def test_raw_event_fields():
ev = RawEvent(
source="TEST",
source_label="Test",
standard_code="TST-001",
title="Test",
summary="Summary",
full_text_url="https://example.com",
status="enacted",
published_at="2026-01-01",
effective_at=None,
category="test",
tags=["a"],
raw_text="full text here",
)
assert ev.source == "TEST"
assert ev.tags == ["a"]
CATARC_HTML = """
<html><body>
<table>
<tr>
<td><a href="/std/detail/123">GB 18384-2025</a></td>
<td>电动汽车安全要求</td>
<td>2025-11-15</td>
<td>现行</td>
</tr>
<tr>
<td><a href="/std/detail/456">GB/T 40429-2026</a></td>
<td>汽车驾驶自动化分级</td>
<td>2026-02-01</td>
<td>即将实施</td>
</tr>
</table>
</body></html>
"""
def test_catarc_crawler_parses_html():
from app.infrastructure.perception.crawlers.catarc_crawler import CatarcCrawler
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = CATARC_HTML
mock_resp.raise_for_status = MagicMock()
with patch("httpx.get", return_value=mock_resp):
crawler = CatarcCrawler()
events = crawler.fetch(limit=10)
assert isinstance(events, list)
assert len(events) >= 1
assert all(isinstance(e, RawEvent) for e in events)
codes = [e.standard_code for e in events]
assert "GB 18384-2025" in codes
GUOBIAO_JSON = {
"rows": [
{
"std_code": "GB 18384-2025",
"std_name": "电动汽车安全要求",
"release_date": "2025-11-15",
"implement_date": "2026-07-01",
"std_status": "现行",
"std_type": "强制性",
},
]
}
def test_guobiao_crawler_parses_json():
from app.infrastructure.perception.crawlers.guobiao_crawler import GuobiaoMandatoryCrawler
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = GUOBIAO_JSON
mock_resp.raise_for_status = MagicMock()
with patch("httpx.get", return_value=mock_resp):
crawler = GuobiaoMandatoryCrawler()
events = crawler.fetch(limit=10)
assert len(events) >= 1
assert events[0].source == "国标委"
assert events[0].standard_code == "GB 18384-2025"
EURLEX_RSS = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>EUR-Lex</title>
<item>
<title>Regulation (EU) 2024/1689 — AI Act</title>
<link>https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689</link>
<description>The EU Artificial Intelligence Act enters into force.</description>
<pubDate>Fri, 12 Jul 2024 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>"""
def test_eurlex_crawler_parses_rss():
from app.infrastructure.perception.crawlers.eurlex_crawler import EurlexCrawler
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = EURLEX_RSS
mock_resp.content = EURLEX_RSS
mock_resp.raise_for_status = MagicMock()
with patch("httpx.get", return_value=mock_resp):
crawler = EurlexCrawler()
events = crawler.fetch(limit=5)
assert isinstance(events, list)
assert len(events) >= 1
assert events[0].source == "EUR-Lex"

View File

@@ -0,0 +1,77 @@
"""Unit tests for LlmPipeline — mock LLM client and embedding provider."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import json
import pytest
def _make_pipeline():
with patch("app.infrastructure.perception.llm_pipeline.get_llm_client") as mock_llm_fn, \
patch("app.infrastructure.perception.llm_pipeline.OpenAICompatibleEmbeddingProvider") as mock_emb_cls:
mock_client = MagicMock()
mock_client.chat.return_value = MagicMock(content='{"obligations":[{"text":"test obligation","deontic":"must","subject":"OEM","object":"system","condition":""}],"deadlines":[{"date":"2026-07-01","description":"实施截止"}],"scope":"适用于M1类车辆","penalties":"罚款","impact_level":"high"}')
mock_llm_fn.return_value = mock_client
mock_emb = MagicMock()
mock_emb.embed_texts.return_value = [[0.1] * 1024, [0.9] * 1024]
mock_emb_cls.return_value = mock_emb
from app.infrastructure.perception.llm_pipeline import LlmPipeline
return LlmPipeline(), mock_client, mock_emb
def test_extract_structure_returns_dict():
pipeline, mock_client, _ = _make_pipeline()
event = {
"id": "evt-001",
"standard_code": "GB 18384-2025",
"title": "电动汽车安全要求",
"summary": "新增 IP67 级别防护",
"source_label": "CATARC",
"tags": ["电池安全"],
}
result = pipeline.extract_structure(event)
assert isinstance(result, dict)
assert "obligations" in result
assert "impact_level" in result
def test_assess_impact_returns_list():
pipeline, mock_client, _ = _make_pipeline()
mock_client.chat.return_value = MagicMock(content='[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,"key_clauses":"§4.2","recommendation":"更新第4章"}]')
mock_retrieval = MagicMock()
chunk = MagicMock()
chunk.doc_id = "d1"
chunk.doc_title = "Safety Manual"
chunk.score = 0.85
chunk.text = "relevant text"
chunk.section_title = "§4.2"
mock_retrieval.retrieve.return_value = [chunk]
event = {
"standard_code": "GB 18384-2025",
"title": "电动汽车安全要求",
"obligations": [{"text": "OEM shall comply"}],
}
result = pipeline.assess_impact(event, mock_retrieval)
assert isinstance(result, list)
def test_compute_diff_no_change():
pipeline, _, mock_emb = _make_pipeline()
mock_emb.embed_texts.return_value = [[0.5] * 1024, [0.5] * 1024]
result = pipeline.compute_diff("paragraph one", "paragraph one")
assert isinstance(result, dict)
assert "changed_sections" in result
assert "change_summary" in result
def test_compute_diff_detects_change():
pipeline, mock_client, mock_emb = _make_pipeline()
mock_emb.embed_texts.return_value = [
[1.0] + [0.0] * 1023,
[0.0] + [1.0] + [0.0] * 1022,
]
mock_client.chat.return_value = MagicMock(content='{"change_type":"tightened","summary":"Requirement tightened"}')
result = pipeline.compute_diff("old paragraph text", "new tighter requirement text")
assert isinstance(result["changed_sections"], list)

View File

@@ -0,0 +1,98 @@
"""Unit tests for PostgresEventStore using a mocked psycopg2 pool."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
# Patch psycopg2 before importing the module under test
import sys
mock_psycopg2 = MagicMock()
mock_psycopg2.extras = MagicMock()
sys.modules.setdefault("psycopg2", mock_psycopg2)
sys.modules.setdefault("psycopg2.extras", mock_psycopg2.extras)
sys.modules.setdefault("psycopg2.pool", MagicMock())
from app.infrastructure.perception.base_event_store import BaseEventStore
SAMPLE_ROW = {
"id": "pg-001",
"source": "国标委",
"source_label": "国家标准化管理委员会",
"standard_code": "GB 18384-2025",
"title": "电动汽车安全要求",
"summary": "新增要求",
"full_text_url": "https://openstd.samr.gov.cn",
"status": "enacted",
"impact_level": "high",
"published_at": "2025-11-15",
"effective_at": "2026-07-01",
"category": "电动汽车安全",
"tags": ["电池安全"],
"obligations": None,
"deadlines": None,
"scope": None,
"penalties": None,
"content_hash": "abc123",
"previous_hash": None,
"change_summary": None,
"changed_sections": None,
"affected_docs": None,
"crawled_at": "2026-06-05T10:00:00+00:00",
"processed_at": None,
"raw_storage_key": None,
}
def _make_store_with_pool(mock_pool):
with patch("psycopg2.pool.ThreadedConnectionPool", return_value=mock_pool):
with patch(
"app.infrastructure.perception.postgres_event_store.PostgresEventStore._ensure_schema"
):
from app.infrastructure.perception.postgres_event_store import PostgresEventStore
return PostgresEventStore()
def _cursor_returning(rows):
cursor = MagicMock()
cursor.__enter__ = lambda s: s
cursor.__exit__ = MagicMock(return_value=False)
cursor.fetchall.return_value = rows
cursor.fetchone.return_value = rows[0] if rows else None
return cursor
def test_is_base_event_store():
mock_pool = MagicMock()
store = _make_store_with_pool(mock_pool)
assert isinstance(store, BaseEventStore)
def test_filter_returns_list():
mock_pool = MagicMock()
conn = MagicMock()
conn.__enter__ = lambda s: s
conn.__exit__ = MagicMock(return_value=False)
cursor = _cursor_returning([SAMPLE_ROW])
conn.cursor.return_value = cursor
mock_pool.getconn.return_value = conn
store = _make_store_with_pool(mock_pool)
result = store.filter(limit=10)
assert isinstance(result, list)
def test_stats_returns_correct_keys():
mock_pool = MagicMock()
conn = MagicMock()
conn.__enter__ = lambda s: s
conn.__exit__ = MagicMock(return_value=False)
cursor = MagicMock()
cursor.__enter__ = lambda s: s
cursor.__exit__ = MagicMock(return_value=False)
cursor.fetchone.return_value = {"count": 5}
conn.cursor.return_value = cursor
mock_pool.getconn.return_value = conn
store = _make_store_with_pool(mock_pool)
stats = store.stats()
for key in ("total", "high_impact", "medium_impact", "recent_90d"):
assert key in stats

36
dev.sh
View File

@@ -549,7 +549,7 @@ AI+合规智能中枢统一脚本
用法: 用法:
./dev.sh help ./dev.sh help
./dev.sh setup ./dev.sh setup
./dev.sh start [all|api|frontend] [--foreground] [--mode dev|static] ./dev.sh start [all|api|frontend|worker|beat] [--foreground] [--mode dev|static]
./dev.sh stop [all|api|frontend] ./dev.sh stop [all|api|frontend]
./dev.sh restart [all|api|frontend] [--mode dev|static] ./dev.sh restart [all|api|frontend] [--mode dev|static]
./dev.sh status ./dev.sh status
@@ -563,6 +563,9 @@ AI+合规智能中枢统一脚本
进行一次性的本地初始化。 进行一次性的本地初始化。
包含 Python 版本检查、.venv 虚拟环境创建、后端依赖安装、前端 npm install、 包含 Python 版本检查、.venv 虚拟环境创建、后端依赖安装、前端 npm install、
以及 6.86.80.8 基础服务端口连通性检查。 以及 6.86.80.8 基础服务端口连通性检查。
初始化完成后,首次运行前还需执行:
PYTHONPATH=backend .venv/bin/python scripts/seed_users.py
以创建 admin/legal/ehs/readonly 四个演示用户。
start start
启动服务。默认行为等同于 ./dev.sh start all。 启动服务。默认行为等同于 ./dev.sh start all。
@@ -570,6 +573,8 @@ AI+合规智能中枢统一脚本
all 同时启动 API 和前端。 all 同时启动 API 和前端。
api 只启动后端 API。 api 只启动后端 API。
frontend 只启动前端。 frontend 只启动前端。
worker 启动 Celery 文档处理 worker前台运行需要 Redis
beat 启动 Celery Beat 定时调度器(前台运行,需要 Redis
可选参数: 可选参数:
--foreground 仅对 start api 生效,前台运行并开启 --reload便于调试。 --foreground 仅对 start api 生效,前台运行并开启 --reload便于调试。
--mode dev 前端使用 Vite 开发服务器,默认端口 5173。 --mode dev 前端使用 Vite 开发服务器,默认端口 5173。
@@ -578,6 +583,7 @@ AI+合规智能中枢统一脚本
stop stop
停止服务。默认行为等同于 ./dev.sh stop all。 停止服务。默认行为等同于 ./dev.sh stop all。
会优先读取 logs/*.pidPID 文件失效时会回退到端口探测。 会优先读取 logs/*.pidPID 文件失效时会回退到端口探测。
注意: worker 和 beat 为前台进程,直接 Ctrl+C 停止。
restart restart
先停止再启动,支持 all/api/frontend。 先停止再启动,支持 all/api/frontend。
@@ -601,8 +607,11 @@ AI+合规智能中枢统一脚本
常用示例: 常用示例:
./dev.sh setup ./dev.sh setup
PYTHONPATH=backend .venv/bin/python scripts/seed_users.py
./dev.sh start ./dev.sh start
./dev.sh start api --foreground ./dev.sh start api --foreground
./dev.sh start worker
./dev.sh start beat
./dev.sh start frontend --mode static ./dev.sh start frontend --mode static
./dev.sh restart frontend --mode dev ./dev.sh restart frontend --mode dev
./dev.sh status ./dev.sh status
@@ -615,7 +624,7 @@ parse_target() {
local default_target="$1" local default_target="$1"
local candidate="${2:-}" local candidate="${2:-}"
case "$candidate" in case "$candidate" in
all|api|frontend) all|api|frontend|worker|beat)
echo "$candidate" echo "$candidate"
;; ;;
*) *)
@@ -646,6 +655,27 @@ main() {
shift || true shift || true
fi fi
# worker and beat are pass-through — forward remaining args to celery directly.
case "$target" in
worker)
print_header "AI+合规智能中枢 - 启动 Celery Worker"
require_venv
export PYTHONPATH="backend${PYTHONPATH:+:$PYTHONPATH}"
"$VENV_PYTHON" -m celery -A app.infrastructure.tasks.celery_app worker \
--loglevel=info \
--concurrency=2 \
--queues=celery \
"$@"
;;
beat)
print_header "AI+合规智能中枢 - 启动 Celery Beat"
require_venv
export PYTHONPATH="backend${PYTHONPATH:+:$PYTHONPATH}"
"$VENV_PYTHON" -m celery -A app.infrastructure.tasks.celery_app beat \
--loglevel=info \
"$@"
;;
*)
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--foreground) --foreground)
@@ -684,6 +714,8 @@ main() {
;; ;;
esac esac
;; ;;
esac
;;
stop) stop)
target="$(parse_target all "${1:-}")" target="$(parse_target all "${1:-}")"
print_header "AI+合规智能中枢 - 停止服务" print_header "AI+合规智能中枢 - 停止服务"

View File

@@ -58,7 +58,8 @@ services:
retries: 5 retries: 5
restart: unless-stopped restart: unless-stopped
# PostgreSQL数据库 (可选,启用 DOCUMENT_REPOSITORY_BACKEND=postgres 时使用) # PostgreSQL数据库 (启用 DOCUMENT_REPOSITORY_BACKEND=postgres 时使用
# 合规分析历史记录 Direction B、DOCX 报告下载及 Finding Chat 持久化 Direction C 均依赖此服务)
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: postgres container_name: postgres

Some files were not shown because too many files have changed in this diff Show More