Compare commits
35 Commits
09f9cf2bf0
...
main-ruqi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9212747e1b | ||
|
|
e7963b267e | ||
|
|
9fea9c6a53 | ||
|
|
06e0967128 | ||
|
|
746513cc54 | ||
|
|
ac490d851a | ||
|
|
bc8ccc1143 | ||
|
|
6414d67b3b | ||
|
|
9f15e40bbb | ||
|
|
65ba1b214d | ||
|
|
7cd7a10bea | ||
|
|
235de65975 | ||
|
|
07ccf055ab | ||
|
|
3dc12b0bfe | ||
|
|
08461215b0 | ||
|
|
dcda7e0423 | ||
|
|
f3dbdc7e3f | ||
|
|
932e2c7792 | ||
|
|
22ab39fed2 | ||
|
|
2080da87aa | ||
|
|
dd850f1023 | ||
|
|
91651682a3 | ||
|
|
c02c5cec0c | ||
|
|
4addda9182 | ||
|
|
0398fa3fb0 | ||
|
|
3b7153c95c | ||
|
|
3674f9171e | ||
|
|
30c7bda389 | ||
|
|
fec22a3a2c | ||
|
|
34d72d7ce9 | ||
|
|
987cc097da | ||
|
|
10a034e294 | ||
|
|
091a02c522 | ||
|
|
37f7a60b0a | ||
|
|
f9ee644f25 |
34
.env
34
.env
@@ -9,7 +9,7 @@ DEBUG=false
|
||||
# ===== Milvus向量数据库配置(已有)=====
|
||||
MILVUS_HOST=6.86.80.8
|
||||
MILVUS_PORT=19530
|
||||
MILVUS_COLLECTION=regulations_dense_1024_v1
|
||||
MILVUS_COLLECTION=regulations_dense_1024_v2
|
||||
MILVUS_DB_NAME=default
|
||||
MILVUS_INDEX_TYPE=IVF_FLAT
|
||||
MILVUS_NLIST=128
|
||||
@@ -48,8 +48,16 @@ CHUNK_OVERLAP=50
|
||||
MAX_FILE_SIZE_MB=100
|
||||
PARSER_BACKEND=aliyun
|
||||
CHUNK_BACKEND=aliyun
|
||||
# 文档元数据存储后端:json(默认)或 postgres
|
||||
DOCUMENT_REPOSITORY_BACKEND=json
|
||||
# 文档元数据存储后端:启用 postgres 以激活合规分析历史记录(Direction B)及 Finding Chat 持久化(Direction C)
|
||||
DOCUMENT_REPOSITORY_BACKEND=postgres
|
||||
# Set to true only when a Celery worker is actually running (./dev.sh start worker).
|
||||
# Default false: processing runs in FastAPI's threadpool — no external worker needed.
|
||||
USE_CELERY_WORKER=false
|
||||
|
||||
# ===== 法规感知爬取配置 =====
|
||||
PERCEPTION_CRAWL_TIMEOUT_SECONDS=120
|
||||
PERCEPTION_MAX_EVENTS_PER_SOURCE=100
|
||||
PERCEPTION_DIFF_SIMILARITY_THRESHOLD=0.85
|
||||
|
||||
# ===== API配置 =====
|
||||
API_HOST=0.0.0.0
|
||||
@@ -92,3 +100,23 @@ ALIYUN_LLM_ENHANCEMENT=true
|
||||
ALIYUN_ENHANCEMENT_MODE=VLM
|
||||
DOCUMENT_PARSE_ARTIFACT_PREFIX=artifacts
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ===== Milvus向量数据库配置(已有)=====
|
||||
MILVUS_HOST=6.86.80.8
|
||||
MILVUS_PORT=19530
|
||||
MILVUS_COLLECTION=regulations_dense_1024_v1
|
||||
MILVUS_COLLECTION=regulations_dense_1024_v2
|
||||
MILVUS_DB_NAME=default
|
||||
MILVUS_INDEX_TYPE=IVF_FLAT
|
||||
MILVUS_NLIST=128
|
||||
@@ -31,5 +31,5 @@ POSTGRES_PASSWORD=postgresql123456
|
||||
POSTGRES_DB=compliance_db
|
||||
|
||||
# ===== 文档元数据后端 =====
|
||||
# 改为 postgres 以启用 PG 持久化(structure_nodes + semantic_blocks 入库)
|
||||
# 改为 postgres 以启用合规分析历史记录(Direction B)和 Finding Chat(Direction C)
|
||||
DOCUMENT_REPOSITORY_BACKEND=json
|
||||
|
||||
43
.env.example
43
.env.example
@@ -9,7 +9,7 @@ DEBUG=false
|
||||
# ===== Milvus向量数据库配置 =====
|
||||
MILVUS_HOST=6.86.80.8
|
||||
MILVUS_PORT=19530
|
||||
MILVUS_COLLECTION=regulations_dense_1024_v1
|
||||
MILVUS_COLLECTION=regulations_dense_1024_v2
|
||||
MILVUS_DB_NAME=default
|
||||
MILVUS_INDEX_TYPE=IVF_FLAT
|
||||
MILVUS_NLIST=128
|
||||
@@ -50,7 +50,19 @@ DOCUMENT_METADATA_PATH=backend/data/documents.json
|
||||
PARSER_BACKEND=aliyun
|
||||
CHUNK_BACKEND=aliyun
|
||||
# 文档元数据存储后端:json(默认,无需数据库)或 postgres(启用 PG 持久化)
|
||||
# ⚠ 以下功能需要 postgres(设为 json 时功能静默降级或报 500):
|
||||
# - Direction B: 合规分析历史记录 (/compliance/history/*)
|
||||
# - Direction B: DOCX 报告下载
|
||||
# - Direction C: Finding Chat 消息持久化
|
||||
DOCUMENT_REPOSITORY_BACKEND=json
|
||||
# Set to true only when a Celery worker is running (./dev.sh start worker).
|
||||
# Default false: document processing runs in FastAPI's threadpool (no external worker needed).
|
||||
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
|
||||
@@ -96,11 +108,15 @@ RAG_TOP_K=10
|
||||
RAG_RETRIEVAL_TOP_K=20
|
||||
RAG_MAX_CONTEXT_TOKENS=4000
|
||||
RAG_SUMMARY_MAX_TOKENS=1024
|
||||
RAG_SKILLS_MAX_TOKENS=2048
|
||||
|
||||
# ===== Reranker配置(Cross-Encoder精排,默认关闭)=====
|
||||
# 设置 RERANKER_ENABLED=true 并配置 RERANKER_BASE_URL 以启用精排
|
||||
RERANKER_ENABLED=false
|
||||
RERANKER_BASE_URL=
|
||||
# ── Reranker (Cross-Encoder) ──────────────────────────────────────────────────
|
||||
# Set RERANKER_ENABLED=true and point to a TEI or Cohere-compatible rerank API.
|
||||
# Recommended model: BAAI/bge-reranker-v2.5-gemma2-lightweight (lighter) or
|
||||
# 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_API_KEY=
|
||||
RERANKER_TOP_K=5
|
||||
@@ -108,3 +124,20 @@ RERANKER_TOP_K=5
|
||||
# ===== 会话配置 =====
|
||||
SESSION_MAX_SESSIONS=100
|
||||
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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -58,4 +58,7 @@ Thumbs.db
|
||||
|
||||
|
||||
# logs files
|
||||
logs/
|
||||
logs/
|
||||
|
||||
# codex
|
||||
.agents
|
||||
@@ -0,0 +1,56 @@
|
||||
<h2>Compliance Analysis — 哪个方向最值得优化?</h2>
|
||||
<p class="subtitle">基于代码深度分析,发现了 4 个有价值的改进方向。选择你最希望深入的那个。</p>
|
||||
|
||||
<div class="options">
|
||||
|
||||
<div class="option" data-choice="A" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>⚡ 分析质量提升</h3>
|
||||
<p>并行子句处理(速度 3–5×)、跨编码器重排序、置信度过滤、修复 highlight_terms 失效 Bug、减少 LLM 静默失败。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>更快、更准确的分析</li><li>消除当前 Bug</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需要改造 pipeline.py</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="B" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>📋 分析历史 & 专业报告</h3>
|
||||
<p>持久化分析记录(PostgreSQL)、历史对比、PDF/DOCX 专业报告导出、分析版本追踪。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>结果不再丢失</li><li>可交付给客户的报告</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需要新增数据库表</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="C" onclick="toggleSelect(this)">
|
||||
<div class="letter">C</div>
|
||||
<div class="content">
|
||||
<h3>💬 深度 Chat 增强</h3>
|
||||
<p>每个 Finding 独立对话线程(持久化)、Chat 上下文绑定真实检索到的法规原文、多轮追问记忆、快捷建议问句生成。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>Finding 解读深度大幅提升</li><li>用户粘性强</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需重构 chat 端点</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option" data-choice="D" onclick="toggleSelect(this)">
|
||||
<div class="letter">D</div>
|
||||
<div class="content">
|
||||
<h3>📑 自定义规则 & 模板</h3>
|
||||
<p>用户自定义合规规则库、按行业预设模板(汽车/金融/医疗)、Prompt 版本管理、A/B 测试不同提示策略。</p>
|
||||
<div class="pros-cons" style="margin-top:10px">
|
||||
<div class="pros"><h4>收益</h4><ul><li>适应不同行业场景</li><li>可配置,无需改代码</li></ul></div>
|
||||
<div class="cons"><h4>难度</h4><ul><li>需要规则管理 UI</li></ul></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="subtitle" style="margin-top:20px">💡 也可以多选,或者在终端告诉我你有其他想法。</p>
|
||||
3
.superpowers/brainstorm/1055-1780892298/state/events
Normal file
3
.superpowers/brainstorm/1055-1780892298/state/events
Normal file
@@ -0,0 +1,3 @@
|
||||
{"type":"click","text":"C\n \n 💬 深度 Chat 增强\n 每个 Finding 独立对话线程(持久化)、Chat 上下文绑定真实检索到的法规原文、多轮追问记忆、快捷建议问句生成。\n \n 收益Finding 解读深度大幅提升用户粘性强\n 难度需重构 chat 端点","choice":"C","id":null,"timestamp":1780897984866}
|
||||
{"type":"click","text":"B\n \n 📋 分析历史 & 专业报告\n 持久化分析记录(PostgreSQL)、历史对比、PDF/DOCX 专业报告导出、分析版本追踪。\n \n 收益结果不再丢失可交付给客户的报告\n 难度需要新增数据库表","choice":"B","id":null,"timestamp":1780897985879}
|
||||
{"type":"click","text":"A\n \n ⚡ 分析质量提升\n 并行子句处理(速度 3–5×)、跨编码器重排序、置信度过滤、修复 highlight_terms 失效 Bug、减少 LLM 静默失败。\n \n 收益更快、更准确的分析消除当前 Bug\n 难度需要改造 pipeline.py","choice":"A","id":null,"timestamp":1780897986554}
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1780894411095}
|
||||
1
.superpowers/brainstorm/1055-1780892298/state/server.pid
Normal file
1
.superpowers/brainstorm/1055-1780892298/state/server.pid
Normal file
@@ -0,0 +1 @@
|
||||
1055
|
||||
@@ -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>
|
||||
198
.superpowers/brainstorm/1652-1779893150/content/color-style.html
Normal file
198
.superpowers/brainstorm/1652-1779893150/content/color-style.html
Normal 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>
|
||||
@@ -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>适合 10–15 分钟快速汇报,老板/领导层受众,重点突出进展和价值</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>适合 20–30 分钟完整汇报,技术+管理混合受众,各模块有独立页面</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">文档处理 Pipeline(5步)</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>
|
||||
@@ -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>
|
||||
@@ -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"}
|
||||
1
.superpowers/brainstorm/1652-1779893150/state/server.pid
Normal file
1
.superpowers/brainstorm/1652-1779893150/state/server.pid
Normal file
@@ -0,0 +1 @@
|
||||
1652
|
||||
@@ -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>
|
||||
@@ -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 & /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 BM25(Milvus)→ RRF 融合</span>
|
||||
<span class="badge badge-p2">Phase 2</span>
|
||||
</div>
|
||||
<div class="arrow">↓</div>
|
||||
|
||||
<div class="node node-upgrade">
|
||||
<span>Cross-Encoder Reranker(Top-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>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1779289950370}
|
||||
1
.superpowers/brainstorm/1946-1779287418/state/server.pid
Normal file
1
.superpowers/brainstorm/1946-1779287418/state/server.pid
Normal file
@@ -0,0 +1 @@
|
||||
1946
|
||||
@@ -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&Adapters)</li>
|
||||
<li>Agent 协同实现方式</li>
|
||||
<li>踩坑与解决方案</li>
|
||||
<li>风格:代码 + 图表,技术感强</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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>完整汇报,适合正式会议,45–60 分钟</p>
|
||||
<ol style="margin-top:10px;padding-left:18px;font-size:13px;color:#888;line-height:2.2">
|
||||
<li>封面</li>
|
||||
<li>背景与痛点(3 张卡片)</li>
|
||||
<li>项目目标 & 范围</li>
|
||||
<li>阶段成果总览</li>
|
||||
<li>模块详情 1/2(感知 / 文档 / 合规)</li>
|
||||
<li>模块详情 2/2(Agent 对话 / 监控)</li>
|
||||
<li>系统架构</li>
|
||||
<li>技术亮点(Agent 协同 / SSE / 双引擎)</li>
|
||||
<li>业务价值 & KPI</li>
|
||||
<li>四阶段路线图</li>
|
||||
<li>近期行动项 & 资源需求</li>
|
||||
<li>总结 & 致谢</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>项目背景 & 我们的目标</li>
|
||||
<li>本阶段工作总览(模块 + 状态 + 负责人)</li>
|
||||
<li>核心功能演示 1/2</li>
|
||||
<li>核心功能演示 2/2</li>
|
||||
<li>系统架构 & 技术选型</li>
|
||||
<li>业务价值量化</li>
|
||||
<li>四阶段路线图</li>
|
||||
<li>下阶段重点 & 分工</li>
|
||||
<li>结语 & Q&A</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1779878105020}
|
||||
1
.superpowers/brainstorm/1959-1779875884/state/server.pid
Normal file
1
.superpowers/brainstorm/1959-1779875884/state/server.pid
Normal file
@@ -0,0 +1 @@
|
||||
1959
|
||||
259
01_Architecture.html
Normal file
259
01_Architecture.html
Normal 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 | 2026.04</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ L1 应用接入层 ═══ -->
|
||||
<div class="layer c1">
|
||||
<div class="lh"><span class="ico">🌐</span> 应用接入层 <span class="en">Application & 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">移动端 & 企业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隐患识别 & 体系审计</div><div class="d">SIF预测 · 四维根因 · ISO 45001扫描</div></div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="arr-row"><div class="arr"></div><div class="arr"></div><div class="arr"></div></div>
|
||||
|
||||
<!-- ═══ L3 法规感知 & 知识自动更新闭环 ═══ -->
|
||||
<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> 法规感知 & 知识自动更新闭环 <span class="en">Regulation Awareness & 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;">⑤ 差距分析 & 推送</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">文档解析 & 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 & 合规比对</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 数据 & 知识层 ═══ -->
|
||||
<div class="layer c4">
|
||||
<div class="lh"><span class="ico">💾</span> 数据 & 知识层 <span class="en">Data & 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">消息队列 & 缓存</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 & 网络</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 | T-systems AI Regulations Team | 2026.04</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
567
02_Architecture_Detail.html
Normal file
567
02_Architecture_Detail.html
Normal 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">变更推送 & 整改触发</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> 定时爬取法规源 → NLP变更感知(Diff) → 自动解析入库(MinerU+嵌入) → Milvus+PostgreSQL+Neo4j同步 → 差距分析 → 按角色推送 → 触发整改 ↺ 持续监控</div>
|
||||
<div class="flow-item"><div class="flow-num">2</div><b>上传→解析→入库:</b> 用户上传 → API Gateway → kbmp-service → 队列 → Worker → mcp-server解析 → 文本切分 → BGE-M3嵌入 → Milvus+PostgreSQL写入</div>
|
||||
<div class="flow-item"><div class="flow-num">3</div><b>检索→问答:</b> 用户提问 → 意图识别 → BM25+向量双路召回 → Cross-Encoder精排 → LLM生成(引文锚定) → 返回结果</div>
|
||||
<div class="flow-item"><div class="flow-num">4</div><b>合规审查:</b> 文件上传 → OCR解析 → 条款级分块 → 法规域匹配 → 语义比对 → 风险评分 → 整改建议 → 报告生成</div>
|
||||
<div class="flow-item"><div class="flow-num">5</div><b>EHS隐患:</b> 巡检文本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 | T-systems AI Regulations Team | 2026.04</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -4,6 +4,12 @@
|
||||
|
||||
- Backend code lives under `backend/app/`; frontend is the Vite app in `frontend/`.
|
||||
|
||||
## Frontend UX Constraints
|
||||
|
||||
- Frontend work in `frontend/` must target desktop Web first.
|
||||
- Do not proactively add mobile-specific adaptations, responsive reflow for small screens, or mobile-first layout compromises unless the user explicitly asks for them.
|
||||
- When desktop and mobile requirements conflict, preserve the desktop Web layout and interaction model by default.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
- Backend entrypoint is `backend/app/main.py`, which re-exports `app` from `app.api.main`.
|
||||
@@ -39,6 +45,15 @@
|
||||
- `tests/verify_mvp.py` also expects the `BGEM3Embedder` stack to be available and explicitly mentions `FlagEmbedding`.
|
||||
- For backend-only changes, prefer focused import/startup checks unless you know the external services and model dependencies are available.
|
||||
|
||||
## Backend Architecture Authority
|
||||
|
||||
- `docs/architecture/backend-project-architecture.md` is the authoritative backend architecture document for ongoing backend development.
|
||||
- New backend business logic must follow `api -> application -> domain ports -> infrastructure`.
|
||||
- Treat `backend/app/shared/bootstrap.py` as the current composition root for backend dependency wiring.
|
||||
- Do not add new business orchestration to `backend/app/services/*` or `backend/app/workflows/*` unless the task is explicitly a migration step.
|
||||
- API routes must not directly access `ConversationStore`; session access should go through application services.
|
||||
- Legacy files may be patched for compatibility or bug fixes, but should not gain new long-term responsibilities.
|
||||
|
||||
## Backend Commenting Standard
|
||||
|
||||
- All comments and docstrings in `backend/**/*.py` must be written in English.
|
||||
|
||||
BIN
AI_Regulations_Architecture.docx
Normal file
BIN
AI_Regulations_Architecture.docx
Normal file
Binary file not shown.
BIN
AI_Regulations_Report.pptx
Normal file
BIN
AI_Regulations_Report.pptx
Normal file
Binary file not shown.
BIN
Bosch_Workshop_2026_March_AI.pdf
Normal file
BIN
Bosch_Workshop_2026_March_AI.pdf
Normal file
Binary file not shown.
@@ -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>
|
||||
808
Prototype/cc29bcb0-df2d-4d50-9428-7caa406ecb29/dashboard.html
Normal file
808
Prototype/cc29bcb0-df2d-4d50-9428-7caa406ecb29/dashboard.html
Normal 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&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>
|
||||
@@ -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>
|
||||
@@ -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&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>
|
||||
696
Prototype/cc29bcb0-df2d-4d50-9428-7caa406ecb29/index.html
Normal file
696
Prototype/cc29bcb0-df2d-4d50-9428-7caa406ecb29/index.html
Normal 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>
|
||||
@@ -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>
|
||||
582
Prototype/cc29bcb0-df2d-4d50-9428-7caa406ecb29/upload-modal.html
Normal file
582
Prototype/cc29bcb0-df2d-4d50-9428-7caa406ecb29/upload-modal.html
Normal 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>
|
||||
709
Prototype/dashboard-sidebar.html
Normal file
709
Prototype/dashboard-sidebar.html
Normal 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&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 →</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 →</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 →</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 →</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 →</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 →</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&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
11
Prototype/index.html
Normal 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
915
Prototype/perception.html
Normal 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&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 · 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">—</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)">—</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)">—</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)">—</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)'}">● ${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)">▋</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>
|
||||
1059
Prototype/regulation-hub-export.html
Normal file
1059
Prototype/regulation-hub-export.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id
|
||||
ALIBABA_ACCESS_KEY_SECRET=your_aliyun_access_key_secret
|
||||
EMBEDDING_API_KEY=your_embedding_api_key_here
|
||||
EMBEDDING_MODEL=text-embedding-v3
|
||||
EMBEDDING_DIM=1536
|
||||
EMBEDDING_DIM=1024
|
||||
PARSER_BACKEND=aliyun
|
||||
CHUNK_BACKEND=aliyun
|
||||
PARSER_FAILURE_MODE=fail
|
||||
|
||||
139
README.md
139
README.md
@@ -1,139 +0,0 @@
|
||||
# AI+合规智能中枢 - 法律法规文档解析入库
|
||||
|
||||
面向车企与工厂的合规智能平台,实现法规文档的解析、分块、嵌入和向量存储。
|
||||
|
||||
## MVP功能
|
||||
|
||||
本次实现的核心功能(最小可用版本):
|
||||
|
||||
- ✅ PDF/DOC/DOCX 文档解析(阿里云文档智能)
|
||||
- ✅ 基于阿里云 `vector_chunks` 的统一切片
|
||||
- ✅ OpenAI 兼容 embedding(`text-embedding-v3`,1536维)
|
||||
- ✅ Milvus 向量数据库存储与 dense-only 检索
|
||||
- ✅ FastAPI接口封装
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
AIRegulation-DocAnalysis-Demo/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # FastAPI 接口层
|
||||
│ │ ├── application/ # 用例编排层
|
||||
│ │ ├── domain/ # 领域模型与稳定端口
|
||||
│ │ ├── infrastructure/ # MinIO / Milvus / 阿里云 / embedding / session 适配
|
||||
│ │ ├── config/ # 配置与日志
|
||||
│ │ └── workers/
|
||||
│ ├── requirements.txt
|
||||
│ └── main.py
|
||||
├── frontend/ # Vite React 前端
|
||||
├── tests/ # 根级测试,导入 backend/app
|
||||
├── docker/
|
||||
│ └── docker-compose.yml
|
||||
├── pyproject.toml
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
./dev.sh setup
|
||||
```
|
||||
|
||||
### 2. 启动Milvus向量数据库
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
等待Milvus启动完成(约30秒):
|
||||
```bash
|
||||
docker-compose logs -f milvus
|
||||
```
|
||||
|
||||
### 3. 启动API服务
|
||||
|
||||
```bash
|
||||
./dev.sh start api --foreground
|
||||
```
|
||||
|
||||
访问API文档:http://localhost:8000/docs
|
||||
|
||||
## API接口
|
||||
|
||||
### 上传文档
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/documents/upload \
|
||||
-F "file=@your_regulation.pdf" \
|
||||
-F "doc_name=GB 7258-2017" \
|
||||
-F "regulation_type=车辆安全"
|
||||
```
|
||||
|
||||
### 检索法规
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/knowledge/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "机动车安全技术要求", "top_k": 10}'
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
|------|------|
|
||||
| 文档解析 | 阿里云文档智能 + python-docx |
|
||||
| 分块策略 | 阿里云 `vector_chunks` |
|
||||
| 嵌入模型 | `text-embedding-v3`(1536维 Dense) |
|
||||
| 向量数据库 | Milvus 2.4(本地Docker部署) |
|
||||
| 检索方式 | Dense-only 检索 |
|
||||
| API框架 | FastAPI |
|
||||
|
||||
## 配置
|
||||
|
||||
创建 `.env` 文件(参考 `.env.example`):
|
||||
|
||||
```env
|
||||
# Milvus配置
|
||||
MILVUS_HOST=localhost
|
||||
MILVUS_PORT=19530
|
||||
|
||||
# 阿里云文档解析
|
||||
ALIBABA_ACCESS_KEY_ID=your_aliyun_access_key_id
|
||||
ALIBABA_ACCESS_KEY_SECRET=your_aliyun_access_key_secret
|
||||
PARSER_BACKEND=aliyun
|
||||
CHUNK_BACKEND=aliyun
|
||||
|
||||
# embedding 配置
|
||||
EMBEDDING_MODEL=text-embedding-v3
|
||||
EMBEDDING_DIM=1536
|
||||
EMBEDDING_API_KEY=your_embedding_api_key_here
|
||||
|
||||
# 分块配置
|
||||
CHUNK_SIZE=512
|
||||
```
|
||||
|
||||
## 后续迭代(不在本次MVP范围)
|
||||
|
||||
- LLM摘要生成(当前上传主链路默认不生成)
|
||||
- 文档上传UI界面
|
||||
- 混合检索问答功能
|
||||
- 法规变更监控与自动更新
|
||||
|
||||
## 解析产物
|
||||
|
||||
上传成功后,系统会把阿里云解析的中间结果持久化到 MinIO:
|
||||
|
||||
- `artifacts/{doc_id}/layouts.json`
|
||||
- `artifacts/{doc_id}/structure_nodes.json`
|
||||
- `artifacts/{doc_id}/semantic_blocks.json`
|
||||
- `artifacts/{doc_id}/vector_chunks.json`
|
||||
|
||||
当前默认 Milvus collection 为 `regulations_dense_1536_v2`。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
475
aliyun_parser/parse_pdf.py
Normal file
475
aliyun_parser/parse_pdf.py
Normal 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
122
aliyun_parser/schema.sql
Normal 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 IDs(JSON 数组)
|
||||
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 IDs(JSON 数组)
|
||||
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();
|
||||
327
aliyun_parser/upload_to_milvus.py
Normal file
327
aliyun_parser/upload_to_milvus.py
Normal 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()
|
||||
5212
aliyun_parser/vector_chunks.json
Normal file
5212
aliyun_parser/vector_chunks.json
Normal file
File diff suppressed because it is too large
Load Diff
263
aliyun_parser/嵌入和å¬å›ž.md
Normal file
263
aliyun_parser/嵌入和å¬å›ž.md
Normal 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
|
||||
```
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
`backend` 是当前正式使用的 FastAPI 后端目录,入口为 `app.main:app`。
|
||||
|
||||
## 架构约束入口
|
||||
|
||||
- Backend authoritative architecture 文档:`docs/architecture/backend-project-architecture.md`
|
||||
- Backend migration RFC:`docs/rfc/backend-api-parsing-embedding-migration-requirements.md`
|
||||
- 后续 backend 新增功能和重构默认遵守:`api -> application -> domain ports -> infrastructure`
|
||||
- `backend/app/services/*` 与 `backend/app/workflows/*` 为迁移期 legacy 目录,除迁移或兼容修复外,不应新增业务编排逻辑。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
@@ -34,10 +41,15 @@ PYTHONPATH=backend uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```text
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/ # FastAPI 路由与模型
|
||||
│ ├── config/ # 配置与日志
|
||||
│ ├── services/ # 文档处理、LLM、RAG、存储
|
||||
│ └── workers/ # 任务相关代码
|
||||
│ ├── api/ # FastAPI 路由与 transport models
|
||||
│ ├── application/ # 用例编排层
|
||||
│ ├── domain/ # 核心业务模型与稳定端口
|
||||
│ ├── infrastructure/ # 外部系统适配器
|
||||
│ ├── shared/ # composition root 与横切支撑
|
||||
│ ├── config/ # 配置与日志
|
||||
│ ├── services/ # legacy façade / 兼容入口
|
||||
│ ├── workflows/ # legacy workflow 入口
|
||||
│ └── workers/ # 任务相关代码
|
||||
├── .env.example
|
||||
├── requirements.txt
|
||||
└── main.py
|
||||
@@ -46,4 +58,13 @@ backend/
|
||||
## 说明
|
||||
|
||||
- 路由前缀保持为 `/api/v1`,以兼容当前前端。
|
||||
- 原 `backend/app/api/routes/docs.py`、`rag.py`、`compliance.py`、`status.py` 仍保留在仓库中,但不再作为主路由入口。
|
||||
- 当前主业务链路入口是 `documents`、`knowledge`、`agent`。
|
||||
- `compliance.py` 当前仍被挂载,但尚未满足目标架构约束;在迁移前不应继续扩展业务编排。
|
||||
- `docs.py` 与 `rag.py` 为遗留/非主入口,不应继续扩展。
|
||||
|
||||
## 开发约束
|
||||
|
||||
- backend 开发前先阅读 `docs/architecture/backend-project-architecture.md`。
|
||||
- 新增业务能力默认落在 `application` 层,由 `api` 调用,不要直接写进 route。
|
||||
- route 不应直接访问 MinIO、Milvus、Parser SDK、LLM SDK 或 `ConversationStore`。
|
||||
- `backend/app/shared/bootstrap.py` 是当前 composition root;依赖装配优先收口到这里。
|
||||
|
||||
8
backend/aliyun_parser/.claude/settings.local.json
Normal file
8
backend/aliyun_parser/.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python3 *)",
|
||||
"Bash(PGPASSWORD=postgresql123456 psql *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
475
backend/aliyun_parser/parse_pdf.py
Normal file
475
backend/aliyun_parser/parse_pdf.py
Normal 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()
|
||||
115
backend/aliyun_parser/rebuild_milvus_collection.py
Normal file
115
backend/aliyun_parser/rebuild_milvus_collection.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Rebuild the migrated Milvus collection from saved vector chunks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from pymilvus import Collection, CollectionSchema, DataType, FieldSchema, connections, utility
|
||||
|
||||
|
||||
DEFAULT_COLLECTION = "regulations_dense_1024_v2"
|
||||
DEFAULT_DIM = 1024
|
||||
|
||||
|
||||
def build_collection(name: str, dim: int) -> Collection:
|
||||
"""Create the migrated Milvus collection from scratch."""
|
||||
if utility.has_collection(name):
|
||||
utility.drop_collection(name)
|
||||
|
||||
schema = CollectionSchema(
|
||||
fields=[
|
||||
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=128, is_primary=True, auto_id=False),
|
||||
FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=64),
|
||||
FieldSchema(name="doc_title", dtype=DataType.VARCHAR, max_length=256),
|
||||
FieldSchema(name="chunk_id", dtype=DataType.VARCHAR, max_length=128),
|
||||
FieldSchema(name="chunk_index", dtype=DataType.INT64),
|
||||
FieldSchema(name="piece_index", dtype=DataType.INT64),
|
||||
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name="embedding_text", dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=dim),
|
||||
FieldSchema(name="semantic_id", dtype=DataType.VARCHAR, max_length=128),
|
||||
FieldSchema(name="chunk_type", dtype=DataType.VARCHAR, max_length=64),
|
||||
FieldSchema(name="page_start", dtype=DataType.INT64),
|
||||
FieldSchema(name="page_end", dtype=DataType.INT64),
|
||||
FieldSchema(name="section_level", dtype=DataType.INT64),
|
||||
FieldSchema(name="source_ids", dtype=DataType.VARCHAR, max_length=4096),
|
||||
FieldSchema(name="section_path", dtype=DataType.VARCHAR, max_length=4096),
|
||||
FieldSchema(name="section_title", dtype=DataType.VARCHAR, max_length=512),
|
||||
FieldSchema(name="metadata_json", dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name="created_at", dtype=DataType.INT64),
|
||||
],
|
||||
description="Dense-only regulations index",
|
||||
enable_dynamic_field=False,
|
||||
)
|
||||
collection = Collection(name=name, schema=schema)
|
||||
collection.create_index(
|
||||
field_name="embedding",
|
||||
index_params={
|
||||
"metric_type": "COSINE",
|
||||
"index_type": "IVF_FLAT",
|
||||
"params": {"nlist": 128},
|
||||
},
|
||||
)
|
||||
return collection
|
||||
|
||||
|
||||
def load_chunks(payload_path: Path) -> list[dict]:
|
||||
"""Load vector chunks emitted by the Aliyun parser pipeline."""
|
||||
payload = json.loads(payload_path.read_text(encoding="utf-8"))
|
||||
if isinstance(payload, dict):
|
||||
chunks = payload.get("vector_chunks", [])
|
||||
else:
|
||||
chunks = payload
|
||||
if not isinstance(chunks, list):
|
||||
raise ValueError("vector chunk payload must be a list or a dict containing vector_chunks")
|
||||
return chunks
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Rebuild the target collection from a vector chunk payload."""
|
||||
parser = argparse.ArgumentParser(description="Rebuild the migrated Milvus collection.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Milvus host")
|
||||
parser.add_argument("--port", default="19530", help="Milvus port")
|
||||
parser.add_argument("--collection", default=DEFAULT_COLLECTION, help="Milvus collection name")
|
||||
parser.add_argument("--dim", type=int, default=DEFAULT_DIM, help="Embedding dimension")
|
||||
parser.add_argument("--payload", required=True, help="Path to vector_chunks.json or a compatible JSON file")
|
||||
args = parser.parse_args()
|
||||
|
||||
connections.connect("default", host=args.host, port=args.port)
|
||||
collection = build_collection(args.collection, args.dim)
|
||||
chunks = load_chunks(Path(args.payload))
|
||||
if not chunks:
|
||||
print("No vector chunks found; collection was created but remains empty.")
|
||||
return
|
||||
|
||||
data = [
|
||||
[chunk["chunk_id"] for chunk in chunks],
|
||||
[chunk["doc_id"] for chunk in chunks],
|
||||
[chunk["doc_title"] for chunk in chunks],
|
||||
[chunk["chunk_id"] for chunk in chunks],
|
||||
[int(chunk.get("chunk_index", 0) or 0) for chunk in chunks],
|
||||
[int(chunk.get("piece_index", 0) or 0) for chunk in chunks],
|
||||
[str(chunk.get("text", ""))[:65535] for chunk in chunks],
|
||||
[str(chunk.get("embedding_text", chunk.get("text", "")))[:65535] for chunk in chunks],
|
||||
[chunk["embedding"] for chunk in chunks],
|
||||
[str(chunk.get("semantic_id", "")) for chunk in chunks],
|
||||
[str(chunk.get("chunk_type", "")) for chunk in chunks],
|
||||
[int(chunk.get("page_start", 0) or 0) for chunk in chunks],
|
||||
[int(chunk.get("page_end", 0) or 0) for chunk in chunks],
|
||||
[int(chunk.get("section_level", 0) or 0) for chunk in chunks],
|
||||
[json.dumps(chunk.get("source_ids", []), ensure_ascii=False) for chunk in chunks],
|
||||
[json.dumps(chunk.get("section_path", []), ensure_ascii=False) for chunk in chunks],
|
||||
[str(chunk.get("section_title", "")) for chunk in chunks],
|
||||
[json.dumps(chunk, ensure_ascii=False) for chunk in chunks],
|
||||
[int(chunk.get("created_at", 0) or 0) for chunk in chunks],
|
||||
]
|
||||
collection.insert(data)
|
||||
collection.flush()
|
||||
collection.load()
|
||||
print(f"Rebuilt collection {args.collection} with {len(chunks)} chunks.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
122
backend/aliyun_parser/schema.sql
Normal file
122
backend/aliyun_parser/schema.sql
Normal 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 IDs(JSON 数组)
|
||||
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 IDs(JSON 数组)
|
||||
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();
|
||||
327
backend/aliyun_parser/upload_to_milvus.py
Normal file
327
backend/aliyun_parser/upload_to_milvus.py
Normal 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()
|
||||
5212
backend/aliyun_parser/vector_chunks.json
Normal file
5212
backend/aliyun_parser/vector_chunks.json
Normal file
File diff suppressed because it is too large
Load Diff
263
backend/aliyun_parser/嵌入和召回.md
Normal file
263
backend/aliyun_parser/嵌入和召回.md
Normal 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
|
||||
```
|
||||
5
backend/app/api/dependencies/__init__.py
Normal file
5
backend/app/api/dependencies/__init__.py
Normal 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.
|
||||
"""
|
||||
72
backend/app/api/dependencies/auth.py
Normal file
72
backend/app/api/dependencies/auth.py
Normal 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
|
||||
@@ -3,15 +3,18 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.api.middleware.audit import AuditMiddleware
|
||||
from app.api.models import ErrorResponse
|
||||
from app.api.routes import api_router
|
||||
from app.config.logging import setup_logging
|
||||
from app.config.settings import settings
|
||||
from app.services.llm.llm_factory import LLMFactory
|
||||
from app.shared.bootstrap import cleanup_runtime_dependencies, preload_runtime_dependencies
|
||||
from app.shared.errors import VectorStoreSchemaError
|
||||
# Keep module behavior explicit so the backend flow stays easy to audit.
|
||||
|
||||
|
||||
@@ -24,12 +27,12 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
||||
logger.info(f"调试模式: {settings.debug}")
|
||||
logger.info("预加载LLM客户端...")
|
||||
LLMFactory.preload_clients(["qwen", "deepseek"])
|
||||
preload_runtime_dependencies()
|
||||
|
||||
yield
|
||||
|
||||
logger.info("应用关闭,执行清理...")
|
||||
LLMFactory.cleanup()
|
||||
cleanup_runtime_dependencies()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -44,27 +47,53 @@ app = FastAPI(
|
||||
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(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
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.exception_handler(VectorStoreSchemaError)
|
||||
async def vector_store_schema_exception_handler(request: Request, exc: VectorStoreSchemaError):
|
||||
"""Return a stable JSON response for vector store schema/runtime errors."""
|
||||
logger.error(f"向量库 schema 异常: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=jsonable_encoder(
|
||||
ErrorResponse(
|
||||
error="VectorStoreSchemaError",
|
||||
message=str(exc),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Global exception handler."""
|
||||
logger.error(f"未处理的异常: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse(
|
||||
error="InternalServerError",
|
||||
message=str(exc),
|
||||
).model_dump(),
|
||||
content=jsonable_encoder(
|
||||
ErrorResponse(
|
||||
error="InternalServerError",
|
||||
message=str(exc),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
1
backend/app/api/middleware/__init__.py
Normal file
1
backend/app/api/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""HTTP middleware for cross-cutting concerns: audit logging."""
|
||||
56
backend/app/api/middleware/audit.py
Normal file
56
backend/app/api/middleware/audit.py
Normal 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
|
||||
@@ -1,29 +1,38 @@
|
||||
"""Initialize the app.api.routes package."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from .auth import router as auth_router
|
||||
from .compliance import router as compliance_router
|
||||
from .documents import router as documents_router
|
||||
from .knowledge import router as knowledge_router
|
||||
from .agent import router as agent_router
|
||||
from .status import router as status_router
|
||||
from .perception import router as perception_router
|
||||
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()
|
||||
|
||||
# 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(knowledge_router)
|
||||
api_router.include_router(agent_router)
|
||||
api_router.include_router(compliance_router)
|
||||
api_router.include_router(status_router)
|
||||
api_router.include_router(perception_router)
|
||||
api_router.include_router(rag_router)
|
||||
|
||||
__all__ = [
|
||||
"api_router",
|
||||
"auth_router",
|
||||
"documents_router",
|
||||
"knowledge_router",
|
||||
"agent_router",
|
||||
"compliance_router",
|
||||
"status_router",
|
||||
"perception_router",
|
||||
"rag_router",
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.api.models import (
|
||||
)
|
||||
from app.config.settings import settings
|
||||
from app.shared.async_utils import iter_in_thread
|
||||
from app.shared.bootstrap import get_agent_conversation_service, get_conversation_store
|
||||
from app.shared.bootstrap import get_agent_conversation_service, get_agent_session_service
|
||||
# Keep route handlers close to their transport-layer wiring for easier auditing.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ async def chat_with_session(request: ChatRequest):
|
||||
model=request.model or settings.llm_model,
|
||||
top_k=request.top_k or settings.rag_top_k,
|
||||
)
|
||||
session = get_conversation_store().get_session(session_id)
|
||||
session = get_agent_session_service().get_session(session_id)
|
||||
return ChatResponse(
|
||||
session_id=session_id,
|
||||
answer=result.answer,
|
||||
@@ -133,45 +133,52 @@ async def chat_stream(request: ChatRequest):
|
||||
@router.get("/session/{session_id}", response_model=SessionInfo)
|
||||
async def get_session_info(session_id: str):
|
||||
"""Return session info."""
|
||||
session = get_conversation_store().get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或已过期")
|
||||
return SessionInfo(
|
||||
session_id=session.session_id,
|
||||
message_count=len(session.messages),
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
)
|
||||
try:
|
||||
session = get_agent_session_service().get_session(session_id)
|
||||
return SessionInfo(
|
||||
session_id=session.session_id,
|
||||
message_count=len(session.messages),
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/session/{session_id}/history")
|
||||
async def get_session_history(session_id: str, max_turns: int = 5):
|
||||
"""Return session history."""
|
||||
session = get_conversation_store().get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或已过期")
|
||||
history = [{"role": msg.role, "content": msg.content} for msg in session.messages[-(max_turns * 2):]]
|
||||
return {"session_id": session_id, "history": history}
|
||||
try:
|
||||
history = get_agent_session_service().get_history(session_id=session_id, max_turns=max_turns)
|
||||
return {"session_id": session_id, "history": history}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
@router.delete("/session/{session_id}")
|
||||
async def delete_session(session_id: str):
|
||||
"""Delete session."""
|
||||
if not get_conversation_store().delete_session(session_id):
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
return {"message": "会话已删除", "session_id": session_id}
|
||||
try:
|
||||
get_agent_session_service().delete_session(session_id)
|
||||
return {"message": "会话已删除", "session_id": session_id}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=List[SessionInfo])
|
||||
async def list_sessions():
|
||||
"""List sessions."""
|
||||
return [SessionInfo(**item) for item in get_conversation_store().list_sessions()]
|
||||
return [SessionInfo(**item) for item in get_agent_session_service().list_sessions()]
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
async def submit_feedback(request: FeedbackRequest):
|
||||
"""Submit feedback."""
|
||||
session = get_conversation_store().get_session(request.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
return {"message": "反馈已提交", "session_id": request.session_id, "message_index": request.message_index}
|
||||
try:
|
||||
result = get_agent_session_service().submit_feedback(
|
||||
session_id=request.session_id,
|
||||
message_index=request.message_index,
|
||||
)
|
||||
return {"message": "反馈已提交", "session_id": result.session_id, "message_index": result.message_index}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
63
backend/app/api/routes/auth.py
Normal file
63
backend/app/api/routes/auth.py
Normal 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,
|
||||
}
|
||||
@@ -5,17 +5,21 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
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 (
|
||||
AnalyzeResponse,
|
||||
ComplianceChatRequest,
|
||||
)
|
||||
from app.services.mock_data import generate_task_id, get_mock_compliance_result
|
||||
from app.shared.bootstrap import get_agent_conversation_service
|
||||
from app.shared.bootstrap import get_agent_conversation_service, get_retrieval_service
|
||||
from app.config.settings import settings
|
||||
|
||||
|
||||
router = APIRouter(prefix="/compliance", tags=["合规分析"])
|
||||
@@ -62,6 +66,172 @@ async def get_result(task_id: str):
|
||||
return task["result"]
|
||||
|
||||
|
||||
def _sse(data: dict) -> str:
|
||||
return f"event: message\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@router.post("/analyze-stream")
|
||||
async def analyze_stream(
|
||||
text: Optional[str] = Form(None),
|
||||
doc_id: Optional[str] = Form(None),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
domains: Optional[str] = Form(None),
|
||||
title: Optional[str] = Form(None),
|
||||
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}")
|
||||
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
||||
"""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",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def list_history(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return paginated list of saved compliance analyses (newest first)."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
try:
|
||||
repo = get_compliance_repository()
|
||||
records = await asyncio.to_thread(repo.list_analyses, limit, offset)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"created_at": r.created_at.isoformat(),
|
||||
"created_by": r.created_by,
|
||||
"doc_name": r.doc_name,
|
||||
"standard_name": r.standard_name,
|
||||
"risk_score": r.risk_score,
|
||||
"finding_count": len(r.findings),
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
except NotImplementedError:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/history/{analysis_id}")
|
||||
async def get_history_item(
|
||||
analysis_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return full analysis record including findings."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from fastapi import HTTPException
|
||||
repo = get_compliance_repository()
|
||||
record = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return {
|
||||
"id": record.id,
|
||||
"created_at": record.created_at.isoformat(),
|
||||
"created_by": record.created_by,
|
||||
"doc_name": record.doc_name,
|
||||
"standard_name": record.standard_name,
|
||||
"risk_score": record.risk_score,
|
||||
"conclusion": record.conclusion,
|
||||
"actions": record.actions,
|
||||
"para_text": record.para_text,
|
||||
"highlight_terms": record.highlight_terms,
|
||||
"findings": [
|
||||
{
|
||||
"id": f.id,
|
||||
"seq": f.seq,
|
||||
"title": f.title,
|
||||
"description": f.description,
|
||||
"status": f.status,
|
||||
"clause_ref": f.clause_ref,
|
||||
}
|
||||
for f in record.findings
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/history/{analysis_id}", status_code=204)
|
||||
async def delete_history_item(
|
||||
analysis_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a saved analysis (cascade removes findings and chat messages)."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
repo = get_compliance_repository()
|
||||
await asyncio.to_thread(repo.delete_analysis, analysis_id)
|
||||
|
||||
|
||||
@router.get("/history/{analysis_id}/download")
|
||||
async def download_history_docx(
|
||||
analysis_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return a DOCX compliance report for the given analysis."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from app.infrastructure.compliance.docx_export import generate_docx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
repo = get_compliance_repository()
|
||||
record = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
|
||||
docx_bytes = await asyncio.to_thread(generate_docx, record)
|
||||
safe_name = (record.doc_name or "report").replace(" ", "_")[:50]
|
||||
filename = f"compliance_{safe_name}_{record.created_at.strftime('%Y%m%d')}.docx"
|
||||
return Response(
|
||||
content=docx_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analyses/{analysis_id}/findings/{finding_id}/chat")
|
||||
async def get_finding_chat_history(
|
||||
analysis_id: str,
|
||||
finding_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Return persisted chat messages for a finding thread, oldest first."""
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
try:
|
||||
repo = get_compliance_repository()
|
||||
messages = await asyncio.to_thread(repo.get_messages, finding_id)
|
||||
return messages
|
||||
except NotImplementedError:
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/analyses/{analysis_id}/findings/{finding_id}/suggestions")
|
||||
async def get_finding_suggestions(
|
||||
analysis_id: str,
|
||||
finding_id: str,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Generate 3 LLM-powered follow-up question suggestions for a finding."""
|
||||
from app.application.compliance.pipeline import generate_suggestions
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from app.services.llm.llm_factory import get_llm_client
|
||||
from fastapi import HTTPException
|
||||
|
||||
repo = get_compliance_repository()
|
||||
analysis = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
|
||||
finding = next((f for f in analysis.findings if f.id == finding_id), None)
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
|
||||
client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
|
||||
questions = await asyncio.to_thread(generate_suggestions, finding, analysis, client)
|
||||
return {"questions": questions}
|
||||
|
||||
|
||||
@router.post("/analyses/{analysis_id}/findings/{finding_id}/chat")
|
||||
async def finding_chat(
|
||||
analysis_id: str,
|
||||
finding_id: str,
|
||||
request: ComplianceChatRequest,
|
||||
current_user: UserClaims = Depends(get_current_user),
|
||||
):
|
||||
"""Stream a grounded chat response for a specific finding.
|
||||
|
||||
Loads the finding and analysis from DB to build grounded context.
|
||||
Persists both user message and assistant response to finding_chat_messages.
|
||||
"""
|
||||
from app.application.compliance.pipeline import build_finding_context
|
||||
from app.shared.bootstrap import get_compliance_repository
|
||||
from fastapi import HTTPException
|
||||
|
||||
repo = get_compliance_repository()
|
||||
analysis = await asyncio.to_thread(repo.get_analysis, analysis_id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
finding = next((f for f in analysis.findings if f.id == finding_id), None)
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
|
||||
# Persist user message
|
||||
await asyncio.to_thread(
|
||||
repo.save_message, analysis_id, finding_id, "user", request.query
|
||||
)
|
||||
|
||||
# Build message history (last 10 messages = 5 turns)
|
||||
history = await asyncio.to_thread(repo.get_messages, finding_id)
|
||||
history_messages = [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in history[-10:]
|
||||
]
|
||||
|
||||
# Build grounded system context
|
||||
system_context = build_finding_context(finding, analysis)
|
||||
full_query = f"[Compliance Finding Context]\n{system_context}\n\nUser question: {request.query}"
|
||||
|
||||
assistant_buffer: list[str] = []
|
||||
|
||||
async def generate() -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
_, event_stream = get_agent_conversation_service().stream_chat(
|
||||
query=full_query,
|
||||
top_k=5,
|
||||
prompt_template="compliance_qa",
|
||||
)
|
||||
for event in event_stream:
|
||||
event_type = event.get("event", "")
|
||||
if event_type == "content":
|
||||
text = event.get("data", "")
|
||||
if text:
|
||||
assistant_buffer.append(text)
|
||||
yield _sse({"type": "chunk", "text": text})
|
||||
elif event_type == "done":
|
||||
yield _sse({"type": "done"})
|
||||
await asyncio.sleep(0)
|
||||
except Exception as exc:
|
||||
logger.exception("finding_chat stream error")
|
||||
yield _sse({"type": "error", "text": str(exc)})
|
||||
finally:
|
||||
# Persist assistant response after stream completes
|
||||
full_response = "".join(assistant_buffer)
|
||||
if full_response:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
repo.save_message, analysis_id, finding_id, "assistant", full_response
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist assistant message: {}", exc)
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
@@ -5,12 +5,15 @@ from __future__ import annotations
|
||||
from io import BytesIO
|
||||
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 loguru import logger
|
||||
|
||||
from app.api.dependencies.auth import get_current_user
|
||||
from app.api.models import DocumentUploadResponse
|
||||
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
|
||||
# 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)
|
||||
async def upload_document(
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(..., description="上传的文档文件"),
|
||||
doc_id: str | None = Form(None, description="客户端预分配的文档ID,不传则自动生成"),
|
||||
doc_name: str | None = Form(None, description="文档名称"),
|
||||
regulation_type: str | None = Form(None, description="法规类型"),
|
||||
version: str | None = Form(None, 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()
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="文件名不能为空")
|
||||
@@ -47,18 +95,73 @@ async def upload_document(
|
||||
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||
|
||||
try:
|
||||
result = get_document_command_service().upload_and_process(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
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,
|
||||
)
|
||||
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":
|
||||
raise HTTPException(status_code=500, detail=result.message)
|
||||
return _document_response(result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -104,7 +207,7 @@ async def download_document(doc_id: str):
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_documents():
|
||||
async def list_documents(current_user: UserClaims = Depends(get_current_user)):
|
||||
"""List documents."""
|
||||
documents = get_document_query_service().list_documents()
|
||||
return {
|
||||
@@ -146,7 +249,7 @@ async def get_document_management_list():
|
||||
|
||||
|
||||
@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."""
|
||||
deleted = get_document_command_service().delete(doc_id)
|
||||
if not deleted:
|
||||
|
||||
@@ -29,14 +29,19 @@ async def search_knowledge(request: SearchRequest):
|
||||
results=[
|
||||
SearchResultItem(
|
||||
id=index + 1,
|
||||
content=item.content,
|
||||
content=item.text,
|
||||
score=item.score,
|
||||
metadata={
|
||||
"doc_id": item.doc_id,
|
||||
"doc_name": item.doc_name,
|
||||
"doc_title": item.doc_title,
|
||||
"chunk_id": item.chunk_id,
|
||||
"chunk_type": item.chunk_type,
|
||||
"section_title": item.section_title,
|
||||
"page_number": item.page_number,
|
||||
"page_start": item.page_start,
|
||||
"page_end": item.page_end,
|
||||
"section_level": item.section_level,
|
||||
"chunk_index": item.chunk_index,
|
||||
"piece_index": item.piece_index,
|
||||
**item.metadata,
|
||||
},
|
||||
)
|
||||
|
||||
143
backend/app/api/routes/perception.py
Normal file
143
backend/app/api/routes/perception.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Define API routes for perception (regulatory intelligence)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/perception", tags=["智能感知"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_perception_stats():
|
||||
"""Return KPI statistics for the perception dashboard."""
|
||||
return get_perception_service().get_stats()
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
source: str | None = Query(default=None, description="来源筛选 (MIIT/UN-ECE/ISO/国标委/EUR-Lex/IATF)"),
|
||||
impact_level: str | None = Query(default=None, description="影响等级 (high/medium/low)"),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
):
|
||||
"""Return regulatory events with optional filters."""
|
||||
events = get_perception_service().list_events(
|
||||
source=source,
|
||||
impact_level=impact_level,
|
||||
limit=limit,
|
||||
)
|
||||
return {"events": events, "total": len(events)}
|
||||
|
||||
|
||||
@router.get("/events/{event_id}")
|
||||
async def get_event(event_id: str):
|
||||
"""Return a single regulatory event by ID."""
|
||||
event = get_perception_service().get_event(event_id)
|
||||
if event is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
|
||||
return event
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/analyze")
|
||||
async def analyze_event(event_id: str):
|
||||
"""Stream SSE impact analysis for a regulatory event."""
|
||||
service = get_perception_service()
|
||||
|
||||
async def event_stream():
|
||||
async for item in iter_in_thread(service.analyze_event(event_id)):
|
||||
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(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"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"),
|
||||
}
|
||||
@@ -5,10 +5,12 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.dependencies.auth import get_current_user
|
||||
from app.config.settings import settings
|
||||
from app.domain.auth.models import UserClaims
|
||||
from app.schemas.rag import RagChatRequest, QuickQuestionsResponse, QuickQuestion
|
||||
from app.shared.async_utils import iter_in_thread
|
||||
from app.shared.bootstrap import get_agent_conversation_service
|
||||
@@ -27,7 +29,10 @@ _DEFAULT_QUICK_QUESTIONS = [
|
||||
|
||||
|
||||
@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."""
|
||||
session_id, event_stream = get_agent_conversation_service().stream_chat(
|
||||
query=request.query,
|
||||
@@ -50,8 +55,8 @@ async def rag_chat(request: RagChatRequest):
|
||||
{
|
||||
"id": str(s.get("chunk_id") or s.get("doc_id") or idx + 1),
|
||||
"score": s.get("score", 0),
|
||||
"preview": s.get("content", "")[:200],
|
||||
"doc_name": s.get("doc_name", ""),
|
||||
"preview": s.get("text", s.get("content", ""))[:200],
|
||||
"doc_name": s.get("doc_title", s.get("doc_name", "")),
|
||||
"clause": s.get("section_title", "法规片段"),
|
||||
"doc_id": s.get("doc_id"),
|
||||
"download_url": (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Initialize the app.application.agent package."""
|
||||
|
||||
from .services import AgentConversationService
|
||||
from .services import AgentConversationService, AgentSessionFeedbackResult, AgentSessionService
|
||||
# Keep package boundaries explicit so backend imports stay predictable.
|
||||
|
||||
|
||||
__all__ = ["AgentConversationService"]
|
||||
__all__ = ["AgentConversationService", "AgentSessionFeedbackResult", "AgentSessionService"]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Implement application-layer logic for services."""
|
||||
"""Implement application-layer logic for agent services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Generator
|
||||
|
||||
from app.domain.conversation import AnswerGenerator, AnswerResult, ConversationStore
|
||||
@@ -141,5 +142,50 @@ class AgentConversationService:
|
||||
content=full_answer,
|
||||
sources=sources_payload,
|
||||
)
|
||||
|
||||
|
||||
return session.session_id, event_stream()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSessionFeedbackResult:
|
||||
"""Represent the result of storing session feedback."""
|
||||
|
||||
session_id: str
|
||||
message_index: int
|
||||
|
||||
|
||||
class AgentSessionService:
|
||||
"""Provide application-layer access to session management workflows."""
|
||||
|
||||
def __init__(self, *, conversation_store: ConversationStore) -> None:
|
||||
"""Initialize the Agent Session Service instance."""
|
||||
self.conversation_store = conversation_store
|
||||
|
||||
def get_session(self, session_id: str):
|
||||
"""Return a session by id or raise when it does not exist."""
|
||||
session = self.conversation_store.get_session(session_id)
|
||||
if not session:
|
||||
raise ValueError("会话不存在或已过期")
|
||||
return session
|
||||
|
||||
def get_history(self, *, session_id: str, max_turns: int = 5) -> list[dict[str, str]]:
|
||||
"""Return the recent conversation history for a session."""
|
||||
session = self.get_session(session_id)
|
||||
return [{"role": msg.role, "content": msg.content} for msg in session.messages[-(max_turns * 2):]]
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""Delete a session or raise when it does not exist."""
|
||||
if not self.conversation_store.delete_session(session_id):
|
||||
raise ValueError("会话不存在")
|
||||
|
||||
def list_sessions(self) -> list[dict]:
|
||||
"""Return the list of visible sessions."""
|
||||
return self.conversation_store.list_sessions()
|
||||
|
||||
def submit_feedback(self, *, session_id: str, message_index: int) -> AgentSessionFeedbackResult:
|
||||
"""Validate feedback targets and return a normalized feedback result."""
|
||||
session = self.get_session(session_id)
|
||||
if message_index < 0 or message_index >= len(session.messages):
|
||||
raise ValueError("消息索引不存在")
|
||||
# Preserve the existing API behavior until a persistent feedback store is introduced.
|
||||
return AgentSessionFeedbackResult(session_id=session_id, message_index=message_index)
|
||||
|
||||
1
backend/app/application/compliance/__init__.py
Normal file
1
backend/app/application/compliance/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Compliance application layer."""
|
||||
370
backend/app/application/compliance/pipeline.py
Normal file
370
backend/app/application/compliance/pipeline.py
Normal 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 1–4 s.
|
||||
_llm_retry = retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=4),
|
||||
retry=retry_if_exception_type((ValueError, TimeoutError, ConnectionError)),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.application.knowledge import KnowledgeRetrievalService
|
||||
from app.domain.retrieval import RetrievedChunk
|
||||
from app.domain.compliance.ports import AnalysisRecord, FindingRecord
|
||||
from app.services.llm.base_client import BaseLLMClient
|
||||
|
||||
|
||||
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
|
||||
@@ -7,16 +7,22 @@ import tempfile
|
||||
import uuid
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from loguru import logger
|
||||
from app.config.settings import settings
|
||||
|
||||
from app.domain.documents import (
|
||||
ChunkBuilder,
|
||||
Document,
|
||||
DocumentArtifact,
|
||||
DocumentBinaryStore,
|
||||
DocumentParser,
|
||||
DocumentProcessingRun,
|
||||
DocumentProcessingStore,
|
||||
DocumentRepository,
|
||||
DocumentStatus,
|
||||
DocumentStatusEvent,
|
||||
ParseArtifactStore,
|
||||
ParsedDocument,
|
||||
)
|
||||
@@ -39,6 +45,7 @@ class DocumentProcessResult:
|
||||
|
||||
class DocumentCommandService:
|
||||
"""Provide the Document Command Service service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -49,6 +56,7 @@ class DocumentCommandService:
|
||||
embedding_provider: EmbeddingProvider,
|
||||
vector_index: VectorIndex,
|
||||
parse_artifact_store: ParseArtifactStore | None = None,
|
||||
document_processing_store: DocumentProcessingStore | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Document Command Service instance."""
|
||||
self.document_repository = document_repository
|
||||
@@ -58,6 +66,11 @@ class DocumentCommandService:
|
||||
self.embedding_provider = embedding_provider
|
||||
self.vector_index = vector_index
|
||||
self.parse_artifact_store = parse_artifact_store
|
||||
self.document_processing_store = document_processing_store
|
||||
|
||||
def _utcnow(self) -> datetime:
|
||||
"""Return the current UTC timestamp for persisted processing metadata."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
def _save_parse_artifacts(self, *, doc_id: str, parsed_document: ParsedDocument) -> dict[str, str]:
|
||||
"""Persist parse artifacts so troubleshooting does not depend on provider retention windows."""
|
||||
@@ -80,6 +93,143 @@ class DocumentCommandService:
|
||||
artifact_keys[name] = object_name
|
||||
return artifact_keys
|
||||
|
||||
def _safe_create_processing_run(self, *, doc_id: str, trigger_type: str, generate_summary: bool) -> str | None:
|
||||
"""Create a processing run record when the optional store is available."""
|
||||
if not self.document_processing_store:
|
||||
return None
|
||||
run = DocumentProcessingRun(
|
||||
run_id=str(uuid.uuid4()),
|
||||
doc_id=doc_id,
|
||||
trigger_type=trigger_type,
|
||||
run_status="running",
|
||||
parser_backend=settings.parser_backend,
|
||||
chunk_backend=settings.chunk_backend,
|
||||
embedding_model=settings.embedding_model,
|
||||
metadata={"generate_summary": generate_summary},
|
||||
)
|
||||
try:
|
||||
created = self.document_processing_store.create_run(run)
|
||||
return created.run_id
|
||||
except Exception:
|
||||
logger.warning("DocumentProcessingStore.create_run failed for doc_id={}", doc_id)
|
||||
return None
|
||||
|
||||
def _safe_append_status_event(
|
||||
self,
|
||||
*,
|
||||
doc_id: str,
|
||||
run_id: str | None,
|
||||
from_status: str,
|
||||
to_status: str,
|
||||
stage: str,
|
||||
message: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
"""Append a status event without allowing auxiliary persistence failures to abort processing."""
|
||||
if not self.document_processing_store or not run_id:
|
||||
return
|
||||
event = DocumentStatusEvent(
|
||||
event_id=str(uuid.uuid4()),
|
||||
doc_id=doc_id,
|
||||
run_id=run_id,
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
stage=stage,
|
||||
message=message,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
try:
|
||||
self.document_processing_store.append_status_event(event)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"DocumentProcessingStore.append_status_event failed for doc_id={}, run_id={}",
|
||||
doc_id,
|
||||
run_id,
|
||||
)
|
||||
|
||||
def _safe_mark_run_stored(self, *, doc_id: str, run_id: str | None) -> None:
|
||||
"""Mark the processing run as stored without affecting the main workflow."""
|
||||
if not self.document_processing_store or not run_id:
|
||||
return
|
||||
try:
|
||||
self.document_processing_store.mark_run_stored(run_id, stored_at=self._utcnow())
|
||||
except Exception:
|
||||
logger.warning("DocumentProcessingStore.mark_run_stored failed for doc_id={}, run_id={}", doc_id, run_id)
|
||||
|
||||
def _safe_mark_run_parsed(self, *, doc_id: str, run_id: str | None, parsed_document: ParsedDocument) -> None:
|
||||
"""Persist parse completion details without failing the document pipeline."""
|
||||
if not self.document_processing_store or not run_id:
|
||||
return
|
||||
try:
|
||||
self.document_processing_store.mark_run_parsed(
|
||||
run_id,
|
||||
parser_backend=parsed_document.parser_name,
|
||||
layout_count=int(parsed_document.metadata.get("layout_count", len(parsed_document.raw_layouts)) or 0),
|
||||
structure_node_count=len(parsed_document.structure_nodes),
|
||||
semantic_block_count=len(parsed_document.semantic_blocks),
|
||||
vector_chunk_count=len(parsed_document.vector_chunks),
|
||||
parsed_at=self._utcnow(),
|
||||
metadata={"parse_task_id": parsed_document.metadata.get("task_id", "")},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("DocumentProcessingStore.mark_run_parsed failed for doc_id={}, run_id={}", doc_id, run_id)
|
||||
|
||||
def _safe_replace_processing_artifacts(self, *, doc_id: str, run_id: str | None, artifact_keys: dict[str, str]) -> None:
|
||||
"""Store artifact references without turning persistence drift into a user-visible failure."""
|
||||
if not self.document_processing_store or not run_id:
|
||||
return
|
||||
artifacts = [
|
||||
DocumentArtifact(
|
||||
artifact_id=str(uuid.uuid4()),
|
||||
doc_id=doc_id,
|
||||
run_id=run_id,
|
||||
artifact_type=artifact_type,
|
||||
object_name=object_name,
|
||||
content_type="application/json",
|
||||
byte_size=0,
|
||||
checksum="",
|
||||
)
|
||||
for artifact_type, object_name in artifact_keys.items()
|
||||
]
|
||||
try:
|
||||
self.document_processing_store.replace_artifacts_for_run(run_id, artifacts)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"DocumentProcessingStore.replace_artifacts_for_run failed for doc_id={}, run_id={}",
|
||||
doc_id,
|
||||
run_id,
|
||||
)
|
||||
|
||||
def _safe_mark_run_indexed(self, *, doc_id: str, run_id: str | None, chunk_count: int, index_name: str) -> None:
|
||||
"""Mark the processing run as indexed without affecting the success path."""
|
||||
if not self.document_processing_store or not run_id:
|
||||
return
|
||||
now = self._utcnow()
|
||||
try:
|
||||
self.document_processing_store.mark_run_indexed(
|
||||
run_id,
|
||||
chunk_count=chunk_count,
|
||||
index_name=index_name,
|
||||
indexed_at=now,
|
||||
finished_at=now,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("DocumentProcessingStore.mark_run_indexed failed for doc_id={}, run_id={}", doc_id, run_id)
|
||||
|
||||
def _safe_mark_run_failed(self, *, doc_id: str, run_id: str | None, failure_stage: str, error_message: str) -> None:
|
||||
"""Mark the processing run as failed without masking the original error handling path."""
|
||||
if not self.document_processing_store or not run_id:
|
||||
return
|
||||
try:
|
||||
self.document_processing_store.mark_run_failed(
|
||||
run_id,
|
||||
failure_stage=failure_stage,
|
||||
error_message=error_message,
|
||||
finished_at=self._utcnow(),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("DocumentProcessingStore.mark_run_failed failed for doc_id={}, run_id={}", doc_id, run_id)
|
||||
|
||||
def upload_and_process(
|
||||
self,
|
||||
*,
|
||||
@@ -91,11 +241,128 @@ class DocumentCommandService:
|
||||
regulation_type: str,
|
||||
version: str,
|
||||
generate_summary: bool,
|
||||
trigger_type: str = "upload",
|
||||
) -> DocumentProcessResult:
|
||||
"""Handle upload and process for the Document Command Service instance."""
|
||||
doc_id = doc_id or str(uuid.uuid4())[:8]
|
||||
final_doc_name = doc_name or file_name
|
||||
object_name = f"{doc_id}/{file_name}"
|
||||
run_id: str | None = None
|
||||
current_status = DocumentStatus.PENDING
|
||||
current_stage = "store"
|
||||
|
||||
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=trigger_type,
|
||||
generate_summary=generate_summary,
|
||||
)
|
||||
self._safe_append_status_event(
|
||||
doc_id=doc_id,
|
||||
run_id=run_id,
|
||||
from_status="",
|
||||
to_status=DocumentStatus.PENDING.value,
|
||||
stage="document_created",
|
||||
message="Document record created",
|
||||
)
|
||||
|
||||
try:
|
||||
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)
|
||||
current_status = DocumentStatus.STORED
|
||||
current_stage = "parse"
|
||||
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",
|
||||
)
|
||||
# Delegate parse → embed → index to the shared processing method.
|
||||
# This same method is invoked by the Celery worker for async processing.
|
||||
return self._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 as exc:
|
||||
logger.exception("文档存储失败: doc_id={}", doc_id)
|
||||
failure_stage = current_stage
|
||||
self.document_repository.update_status(
|
||||
doc_id,
|
||||
DocumentStatus.FAILED,
|
||||
error_message=str(exc),
|
||||
metadata={
|
||||
"failure_reason": str(exc),
|
||||
"processing_stage": "failed",
|
||||
"failure_stage": failure_stage,
|
||||
},
|
||||
)
|
||||
self._safe_mark_run_failed(
|
||||
doc_id=doc_id,
|
||||
run_id=run_id,
|
||||
failure_stage=failure_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=failure_stage,
|
||||
message=str(exc),
|
||||
)
|
||||
return DocumentProcessResult(
|
||||
doc_id=doc_id,
|
||||
doc_name=final_doc_name,
|
||||
status=DocumentStatus.FAILED.value,
|
||||
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,
|
||||
@@ -109,17 +376,44 @@ class DocumentCommandService:
|
||||
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:
|
||||
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)
|
||||
|
||||
suffix = os.path.splitext(file_name)[1]
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
||||
temp_file.write(content)
|
||||
@@ -130,7 +424,14 @@ class DocumentCommandService:
|
||||
doc_id=doc_id,
|
||||
doc_name=final_doc_name,
|
||||
)
|
||||
artifact_keys = self._save_parse_artifacts(doc_id=doc_id, parsed_document=parsed_document)
|
||||
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,
|
||||
@@ -146,12 +447,18 @@ class DocumentCommandService:
|
||||
"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,
|
||||
doc_id, parsed_document.structure_nodes, parsed_document.semantic_blocks,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("ParseArtifactStore.save failed for doc_id={}", doc_id)
|
||||
@@ -165,49 +472,51 @@ class DocumentCommandService:
|
||||
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=health.get("collection_name", ""),
|
||||
metadata={
|
||||
"index_collection": health.get("collection_name", ""),
|
||||
"processing_stage": "indexed",
|
||||
},
|
||||
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,
|
||||
doc_id=doc_id, doc_name=final_doc_name,
|
||||
status=(stored.status.value if stored else DocumentStatus.INDEXED.value),
|
||||
message="处理成功",
|
||||
num_chunks=len(chunks),
|
||||
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",
|
||||
},
|
||||
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}",
|
||||
doc_id=doc_id, doc_name=final_doc_name,
|
||||
status=DocumentStatus.FAILED.value, message=f"文档处理失败: {exc}",
|
||||
)
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
@@ -216,7 +525,6 @@ class DocumentCommandService:
|
||||
except OSError:
|
||||
logger.warning("临时文件清理失败: {}", temp_path)
|
||||
|
||||
|
||||
def delete(self, doc_id: str) -> bool:
|
||||
"""Delete document record, binary file, and vector chunks."""
|
||||
document = self.document_repository.get(doc_id)
|
||||
@@ -235,6 +543,11 @@ class DocumentCommandService:
|
||||
self.parse_artifact_store.delete(doc_id)
|
||||
except Exception:
|
||||
logger.warning("ParseArtifactStore delete failed for doc_id={}", doc_id)
|
||||
if self.document_processing_store:
|
||||
try:
|
||||
self.document_processing_store.delete_by_document(doc_id)
|
||||
except Exception:
|
||||
logger.warning("DocumentProcessingStore delete failed for doc_id={}", doc_id)
|
||||
self.document_repository.delete(doc_id)
|
||||
return True
|
||||
|
||||
@@ -253,6 +566,7 @@ class DocumentCommandService:
|
||||
regulation_type=document.regulation_type,
|
||||
version=document.version,
|
||||
generate_summary=bool(document.metadata.get("generate_summary", False)),
|
||||
trigger_type="retry",
|
||||
)
|
||||
|
||||
|
||||
@@ -272,7 +586,7 @@ class DocumentQueryService:
|
||||
"""Return documents with real-time state from Milvus as the authoritative source.
|
||||
|
||||
Algorithm:
|
||||
1. Query Milvus for all doc metadata (doc_id, doc_name, chunk_count, …).
|
||||
1. Query Milvus for all doc metadata (doc_id, doc_title, chunk_count, …).
|
||||
2. Load JSON/PG metadata records and index them by doc_id.
|
||||
3. Merge: Milvus-present docs get status=INDEXED and live chunk_count;
|
||||
metadata-only docs with status=INDEXED are demoted to FAILED.
|
||||
@@ -300,8 +614,8 @@ class DocumentQueryService:
|
||||
doc.chunk_count = row["chunk_count"]
|
||||
doc.status = DocumentStatus.INDEXED
|
||||
# Backfill fields that may be missing from older JSON records.
|
||||
if not doc.doc_name and row.get("doc_name"):
|
||||
doc.doc_name = row["doc_name"]
|
||||
if not doc.doc_name and row.get("doc_title"):
|
||||
doc.doc_name = row["doc_title"]
|
||||
if not doc.regulation_type and row.get("regulation_type"):
|
||||
doc.regulation_type = row["regulation_type"]
|
||||
if not doc.version and row.get("version"):
|
||||
@@ -317,8 +631,8 @@ class DocumentQueryService:
|
||||
if doc_id not in meta_by_id:
|
||||
synthetic = Document(
|
||||
doc_id=doc_id,
|
||||
doc_name=row.get("doc_name", doc_id),
|
||||
file_name=row.get("doc_name", doc_id),
|
||||
doc_name=row.get("doc_title", doc_id),
|
||||
file_name=row.get("doc_title", doc_id),
|
||||
object_name="",
|
||||
content_type="",
|
||||
size_bytes=0,
|
||||
|
||||
@@ -29,11 +29,16 @@ def _reciprocal_rank_fusion(
|
||||
RetrievedChunk(
|
||||
chunk_id=chunk_map[ck].chunk_id,
|
||||
doc_id=chunk_map[ck].doc_id,
|
||||
doc_name=chunk_map[ck].doc_name,
|
||||
content=chunk_map[ck].content,
|
||||
doc_title=chunk_map[ck].doc_title,
|
||||
text=chunk_map[ck].text,
|
||||
score=scores[ck],
|
||||
chunk_type=chunk_map[ck].chunk_type,
|
||||
section_title=chunk_map[ck].section_title,
|
||||
page_number=chunk_map[ck].page_number,
|
||||
page_start=chunk_map[ck].page_start,
|
||||
page_end=chunk_map[ck].page_end,
|
||||
section_level=chunk_map[ck].section_level,
|
||||
chunk_index=chunk_map[ck].chunk_index,
|
||||
piece_index=chunk_map[ck].piece_index,
|
||||
metadata=chunk_map[ck].metadata,
|
||||
)
|
||||
for ck in sorted_keys
|
||||
|
||||
1
backend/app/application/perception/__init__.py
Normal file
1
backend/app/application/perception/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Perception application package."""
|
||||
147
backend/app/application/perception/crawl_service.py
Normal file
147
backend/app/application/perception/crawl_service.py
Normal 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},
|
||||
}
|
||||
143
backend/app/application/perception/services.py
Normal file
143
backend/app/application/perception/services.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Perception application service — event listing and streaming impact analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Generator
|
||||
|
||||
from app.application.knowledge.services import KnowledgeRetrievalService
|
||||
from app.infrastructure.perception.base_event_store import BaseEventStore
|
||||
from app.services.llm.llm_factory import get_llm_client
|
||||
from app.config.settings import settings
|
||||
|
||||
|
||||
_ANALYSIS_SYSTEM_PROMPT = (
|
||||
"你是汽车行业法规合规专家,专注于中国国家标准(GB)、国际法规(UN-ECE、ISO)"
|
||||
"及欧盟法规(EUR-Lex)的解读与合规建议。回答需专业、简洁、结构清晰。"
|
||||
)
|
||||
|
||||
|
||||
class PerceptionService:
|
||||
"""Orchestrate regulatory event queries and streaming impact analysis."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_store: BaseEventStore,
|
||||
retrieval_service: KnowledgeRetrievalService,
|
||||
) -> None:
|
||||
self._store = event_store
|
||||
self._retrieval = retrieval_service
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_events(
|
||||
self,
|
||||
*,
|
||||
source: str | None = None,
|
||||
impact_level: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
return self._store.filter(source=source, impact_level=impact_level, limit=limit)
|
||||
|
||||
def get_event(self, event_id: str) -> dict | None:
|
||||
return self._store.get(event_id)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
return self._store.stats()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Streaming analysis
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def analyze_event(self, event_id: str) -> Generator[dict, None, None]:
|
||||
"""Yield SSE-ready dicts: sources → content chunks → done."""
|
||||
event = self._store.get(event_id)
|
||||
if not event:
|
||||
yield {"event": "error", "data": f"事件 {event_id} 不存在"}
|
||||
return
|
||||
|
||||
# --- 1. RAG retrieval: find related library documents ---
|
||||
query = event["title"] + " " + " ".join(event["tags"])
|
||||
chunks: list = []
|
||||
affected_docs: list[dict] = []
|
||||
try:
|
||||
chunks = self._retrieval.retrieve(query=query, top_k=5)
|
||||
seen: set[str] = set()
|
||||
for chunk in chunks:
|
||||
if chunk.doc_id not in seen:
|
||||
seen.add(chunk.doc_id)
|
||||
affected_docs.append(
|
||||
{
|
||||
"doc_id": chunk.doc_id,
|
||||
"doc_title": chunk.doc_title,
|
||||
"score": round(float(chunk.score), 4),
|
||||
"snippet": (chunk.text or "")[:180],
|
||||
"clause": getattr(chunk, "section_title", "") or "",
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
yield {"event": "sources", "data": json.dumps(affected_docs, ensure_ascii=False)}
|
||||
|
||||
# --- 2. Build context from retrieved chunks ---
|
||||
context_parts = [
|
||||
f"[文档{i}: {c.doc_title}]\n{(c.text or '')[:400]}"
|
||||
for i, c in enumerate(chunks[:5], 1)
|
||||
]
|
||||
context = "\n\n".join(context_parts) if context_parts else "(知识库中暂无相关文档)"
|
||||
|
||||
# --- 3. Build prompt ---
|
||||
effective = event.get("effective_at") or "待定"
|
||||
user_content = f"""请对以下法规动态进行专业影响分析。
|
||||
|
||||
【法规动态】
|
||||
标准编号:{event['standard_code']}
|
||||
标题:{event['title']}
|
||||
来源:{event['source_label']}
|
||||
摘要:{event['summary']}
|
||||
生效日期:{effective}
|
||||
分类:{event['category']}
|
||||
关键词:{', '.join(event['tags'])}
|
||||
|
||||
【知识库关联文档】
|
||||
{context}
|
||||
|
||||
请用 Markdown 格式,从以下四个维度进行分析:
|
||||
|
||||
## 核心变化
|
||||
列出本次法规更新最关键的 3-5 项变化(用 - 列表)
|
||||
|
||||
## 业务影响
|
||||
分析对现有产品、认证流程、技术文档的具体影响
|
||||
|
||||
## 整改建议
|
||||
给出优先级排序的行动清单(标注 🔴高 🟡中 🟢低 优先级)
|
||||
|
||||
## 时间节点
|
||||
关键合规时间表与里程碑提醒"""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _ANALYSIS_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
# --- 4. Stream LLM response ---
|
||||
try:
|
||||
client = get_llm_client(
|
||||
provider=settings.llm_provider,
|
||||
model=settings.llm_model,
|
||||
)
|
||||
if hasattr(client, "stream_chat"):
|
||||
for chunk in client.stream_chat(messages):
|
||||
yield {"event": "content", "data": chunk}
|
||||
else:
|
||||
response = client.chat(messages)
|
||||
yield {"event": "content", "data": response.content or ""}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
yield {"event": "error", "data": str(exc)}
|
||||
return
|
||||
|
||||
yield {"event": "done", "data": "{}"}
|
||||
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
|
||||
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||
milvus_host: str = Field(default="6.86.80.8", description="Milvus服务地址")
|
||||
milvus_port: int = Field(default=19530, description="Milvus服务端口")
|
||||
milvus_collection: str = Field(default="regulations_dense_1024_v1", description="法规向量集合名称")
|
||||
milvus_collection: str = Field(default="regulations_dense_1024_v2", description="法规向量集合名称")
|
||||
milvus_db_name: str = Field(default="default", description="Milvus数据库名称")
|
||||
|
||||
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||
@@ -78,9 +78,26 @@ class Settings(BaseSettings):
|
||||
chunk_overlap: int = Field(default=50, description="分块重叠大小")
|
||||
max_file_size_mb: int = Field(default=100, description="最大文件大小(MB)")
|
||||
document_metadata_path: str = Field(default="backend/data/documents.json", description="文档元数据存储路径")
|
||||
document_processing_metadata_path: str = Field(default="backend/data/document_processing.json", description="文档处理历史存储路径")
|
||||
parser_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)")
|
||||
# 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.
|
||||
api_host: str = Field(default="0.0.0.0", description="API服务地址")
|
||||
@@ -108,6 +125,7 @@ class Settings(BaseSettings):
|
||||
rag_retrieval_top_k: int = Field(default=20, description="精排前召回候选数量(reranker 启用时生效)")
|
||||
rag_max_context_tokens: int = Field(default=2000, description="RAG最大上下文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_base_url: str = Field(default="", description="Reranker API 地址")
|
||||
@@ -123,6 +141,26 @@ class Settings(BaseSettings):
|
||||
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||
session_max_sessions: int = Field(default=100, 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
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
||||
# Milvus
|
||||
milvus_host: str = "6.86.80.8"
|
||||
milvus_port: int = 19530
|
||||
milvus_collection: str = "regulations_dense_1024_v1"
|
||||
milvus_collection: str = "regulations_dense_1024_v2"
|
||||
|
||||
# LLM / embedding defaults aligned with the migrated backend path.
|
||||
llm_model: str = "qwen-max"
|
||||
@@ -47,7 +47,7 @@ class Settings(BaseSettings):
|
||||
api_port: int = 8000
|
||||
|
||||
# Legacy aliases retained for old utility modules.
|
||||
regulations_collection: str = "regulations_dense_1024_v1"
|
||||
regulations_collection: str = "regulations_dense_1024_v2"
|
||||
compliance_collection: str = "compliance_cache"
|
||||
|
||||
# Preserve the legacy module API while keeping env resolution centralized at the repo root.
|
||||
|
||||
10
backend/app/domain/auth/__init__.py
Normal file
10
backend/app/domain/auth/__init__.py
Normal 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"]
|
||||
42
backend/app/domain/auth/models.py
Normal file
42
backend/app/domain/auth/models.py
Normal 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
|
||||
0
backend/app/domain/compliance/__init__.py
Normal file
0
backend/app/domain/compliance/__init__.py
Normal file
66
backend/app/domain/compliance/ports.py
Normal file
66
backend/app/domain/compliance/ports.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Domain ports for compliance history persistence."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class FindingRecord:
|
||||
"""Single finding row linked to an analysis."""
|
||||
id: str
|
||||
analysis_id: str
|
||||
seq: int
|
||||
title: str
|
||||
description: str
|
||||
status: str # "ok" | "warn" | "risk"
|
||||
clause_ref: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisRecord:
|
||||
"""Full compliance analysis record with nested findings."""
|
||||
id: str # UUID string; empty string means not yet persisted
|
||||
created_at: datetime
|
||||
created_by: Optional[str]
|
||||
doc_name: str
|
||||
standard_name: str
|
||||
risk_score: int
|
||||
conclusion: str
|
||||
actions: list # list[dict] — serialised action items
|
||||
para_text: str
|
||||
highlight_terms: list # list[str]
|
||||
findings: list[FindingRecord] = field(default_factory=list)
|
||||
|
||||
|
||||
class ComplianceRepository(ABC):
|
||||
"""Port for persisting and retrieving compliance analysis records."""
|
||||
|
||||
@abstractmethod
|
||||
def save_analysis(self, record: AnalysisRecord) -> str:
|
||||
"""Persist a new analysis record and return the assigned UUID string."""
|
||||
|
||||
@abstractmethod
|
||||
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]:
|
||||
"""Return analyses ordered by created_at DESC, without nested findings."""
|
||||
|
||||
@abstractmethod
|
||||
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]:
|
||||
"""Return a single analysis with all nested findings, or None."""
|
||||
|
||||
@abstractmethod
|
||||
def delete_analysis(self, analysis_id: str) -> None:
|
||||
"""Delete an analysis and all related findings and chat messages (cascade)."""
|
||||
|
||||
@abstractmethod
|
||||
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str:
|
||||
"""Persist a chat message and return its UUID string."""
|
||||
|
||||
@abstractmethod
|
||||
def get_messages(self, finding_id: str) -> list[dict]:
|
||||
"""Return chat messages for a finding ordered by created_at ASC.
|
||||
|
||||
Each dict has keys: id, role, content, created_at (ISO string).
|
||||
"""
|
||||
@@ -8,18 +8,91 @@ from typing import Any
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(init=False)
|
||||
class AnswerSource:
|
||||
"""Represent answer source data."""
|
||||
"""Represent answer source data with legacy aliases."""
|
||||
|
||||
doc_id: str
|
||||
doc_name: str
|
||||
doc_title: str
|
||||
chunk_id: str
|
||||
chunk_type: str
|
||||
section_title: str
|
||||
page_number: int
|
||||
page_start: int
|
||||
page_end: int
|
||||
section_level: int
|
||||
chunk_index: int
|
||||
piece_index: int
|
||||
score: float
|
||||
content: str
|
||||
text: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
doc_id: str,
|
||||
doc_title: str | None = None,
|
||||
chunk_id: str,
|
||||
chunk_type: str = "",
|
||||
section_title: str = "",
|
||||
page_start: int = 0,
|
||||
page_end: int = 0,
|
||||
section_level: int = 0,
|
||||
chunk_index: int = 0,
|
||||
piece_index: int = 0,
|
||||
score: float = 0.0,
|
||||
text: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
doc_name: str | None = None,
|
||||
content: str | None = None,
|
||||
page_number: int | None = None,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
"""Initialize the answer source while accepting legacy field names."""
|
||||
self.doc_id = doc_id
|
||||
self.doc_title = doc_title if doc_title is not None else (doc_name or "")
|
||||
self.chunk_id = chunk_id
|
||||
self.chunk_type = chunk_type
|
||||
self.section_title = section_title
|
||||
self.page_start = int(page_start or page_number or 0)
|
||||
self.page_end = int(page_end or self.page_start)
|
||||
self.section_level = int(section_level or 0)
|
||||
self.chunk_index = int(chunk_index or 0)
|
||||
self.piece_index = int(piece_index or 0)
|
||||
self.score = float(score)
|
||||
self.text = text if text is not None else (content or "")
|
||||
self.metadata = dict(metadata or {})
|
||||
|
||||
@property
|
||||
def doc_name(self) -> str:
|
||||
"""Return the legacy document name alias."""
|
||||
return self.doc_title
|
||||
|
||||
@doc_name.setter
|
||||
def doc_name(self, value: str) -> None:
|
||||
"""Update the legacy document name alias."""
|
||||
self.doc_title = value
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
"""Return the legacy content alias."""
|
||||
return self.text
|
||||
|
||||
@content.setter
|
||||
def content(self, value: str) -> None:
|
||||
"""Update the legacy content alias."""
|
||||
self.text = value
|
||||
|
||||
@property
|
||||
def page_number(self) -> int:
|
||||
"""Return the legacy page number alias."""
|
||||
return self.page_start
|
||||
|
||||
@page_number.setter
|
||||
def page_number(self, value: int) -> None:
|
||||
"""Update the legacy page number alias."""
|
||||
self.page_start = value
|
||||
self.page_end = max(self.page_end, value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationMessage:
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
"""Initialize the app.domain.documents package."""
|
||||
|
||||
from .models import Chunk, Document, DocumentStatus, ParsedDocument
|
||||
from .ports import ChunkBuilder, DocumentBinaryStore, DocumentParser, DocumentRepository, ParseArtifactStore
|
||||
from .models import Chunk, Document, DocumentArtifact, DocumentProcessingRun, DocumentStatus, DocumentStatusEvent, ParsedDocument
|
||||
from .ports import (
|
||||
ChunkBuilder,
|
||||
DocumentBinaryStore,
|
||||
DocumentParser,
|
||||
DocumentProcessingStore,
|
||||
DocumentRepository,
|
||||
ParseArtifactStore,
|
||||
)
|
||||
# Keep package boundaries explicit so backend imports stay predictable.
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Chunk",
|
||||
"Document",
|
||||
"DocumentArtifact",
|
||||
"DocumentProcessingRun",
|
||||
"DocumentStatus",
|
||||
"DocumentStatusEvent",
|
||||
"ParsedDocument",
|
||||
"ChunkBuilder",
|
||||
"DocumentBinaryStore",
|
||||
"DocumentParser",
|
||||
"DocumentProcessingStore",
|
||||
"DocumentRepository",
|
||||
"ParseArtifactStore",
|
||||
]
|
||||
|
||||
@@ -60,19 +60,171 @@ class ParsedDocument:
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(init=False)
|
||||
class Chunk:
|
||||
"""Represent the Chunk type."""
|
||||
"""Represent one retrieval chunk with backward-compatible aliases."""
|
||||
|
||||
chunk_id: str
|
||||
doc_id: str
|
||||
doc_name: str
|
||||
content: str
|
||||
doc_title: str
|
||||
text: str
|
||||
embedding_text: str
|
||||
chunk_type: str = ""
|
||||
chunk_index: int = 0
|
||||
piece_index: int = 0
|
||||
page_start: int = 0
|
||||
page_end: int = 0
|
||||
section_title: str = ""
|
||||
section_path: list[str] = field(default_factory=list)
|
||||
page_number: int = 0
|
||||
section_level: int = 0
|
||||
source_ids: list[str] = field(default_factory=list)
|
||||
regulation_type: str = ""
|
||||
version: str = ""
|
||||
semantic_id: str = ""
|
||||
block_type: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
chunk_id: str,
|
||||
doc_id: str,
|
||||
doc_title: str | None = None,
|
||||
text: str | None = None,
|
||||
embedding_text: str = "",
|
||||
chunk_type: str = "",
|
||||
chunk_index: int = 0,
|
||||
piece_index: int = 0,
|
||||
page_start: int = 0,
|
||||
page_end: int = 0,
|
||||
section_title: str = "",
|
||||
section_path: list[str] | None = None,
|
||||
section_level: int = 0,
|
||||
source_ids: list[str] | None = None,
|
||||
regulation_type: str = "",
|
||||
version: str = "",
|
||||
semantic_id: str = "",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
doc_name: str | None = None,
|
||||
content: str | None = None,
|
||||
page_number: int | None = None,
|
||||
block_type: str | None = None,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
"""Initialize the chunk while accepting legacy field names."""
|
||||
self.chunk_id = chunk_id
|
||||
self.doc_id = doc_id
|
||||
self.doc_title = doc_title if doc_title is not None else (doc_name or "")
|
||||
self.text = text if text is not None else (content or "")
|
||||
self.embedding_text = embedding_text or self.text
|
||||
self.chunk_type = chunk_type or (block_type or "")
|
||||
self.chunk_index = int(chunk_index or 0)
|
||||
self.piece_index = int(piece_index or 0)
|
||||
self.page_start = int(page_start or page_number or 0)
|
||||
self.page_end = int(page_end or self.page_start)
|
||||
self.section_title = section_title
|
||||
self.section_path = list(section_path or [])
|
||||
self.section_level = int(section_level or 0)
|
||||
self.source_ids = list(source_ids or [])
|
||||
self.regulation_type = regulation_type
|
||||
self.version = version
|
||||
self.semantic_id = semantic_id
|
||||
self.metadata = dict(metadata or {})
|
||||
|
||||
@property
|
||||
def doc_name(self) -> str:
|
||||
"""Return the legacy document name alias."""
|
||||
return self.doc_title
|
||||
|
||||
@doc_name.setter
|
||||
def doc_name(self, value: str) -> None:
|
||||
"""Update the legacy document name alias."""
|
||||
self.doc_title = value
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
"""Return the legacy content alias."""
|
||||
return self.text
|
||||
|
||||
@content.setter
|
||||
def content(self, value: str) -> None:
|
||||
"""Update the legacy content alias."""
|
||||
self.text = value
|
||||
|
||||
@property
|
||||
def page_number(self) -> int:
|
||||
"""Return the legacy page number alias."""
|
||||
return self.page_start
|
||||
|
||||
@page_number.setter
|
||||
def page_number(self, value: int) -> None:
|
||||
"""Update the legacy page number alias."""
|
||||
self.page_start = value
|
||||
self.page_end = max(self.page_end, value)
|
||||
|
||||
@property
|
||||
def block_type(self) -> str:
|
||||
"""Return the legacy block type alias."""
|
||||
return self.chunk_type
|
||||
|
||||
@block_type.setter
|
||||
def block_type(self, value: str) -> None:
|
||||
"""Update the legacy block type alias."""
|
||||
self.chunk_type = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentProcessingRun:
|
||||
"""Represent one processing attempt for a document."""
|
||||
|
||||
run_id: str
|
||||
doc_id: str
|
||||
trigger_type: str
|
||||
run_status: str
|
||||
parser_backend: str = ""
|
||||
chunk_backend: str = ""
|
||||
embedding_model: str = ""
|
||||
index_name: str = ""
|
||||
started_at: datetime = field(default_factory=utcnow)
|
||||
stored_at: datetime | None = None
|
||||
parsed_at: datetime | None = None
|
||||
indexed_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
layout_count: int = 0
|
||||
structure_node_count: int = 0
|
||||
semantic_block_count: int = 0
|
||||
vector_chunk_count: int = 0
|
||||
chunk_count: int = 0
|
||||
failure_stage: str = ""
|
||||
error_message: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentStatusEvent:
|
||||
"""Represent a document lifecycle event emitted during processing."""
|
||||
|
||||
event_id: str
|
||||
doc_id: str
|
||||
run_id: str
|
||||
from_status: str
|
||||
to_status: str
|
||||
stage: str
|
||||
message: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
occurred_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentArtifact:
|
||||
"""Represent a persisted artifact reference for one processing run."""
|
||||
|
||||
artifact_id: str
|
||||
doc_id: str
|
||||
run_id: str
|
||||
artifact_type: str
|
||||
object_name: str
|
||||
content_type: str
|
||||
byte_size: int = 0
|
||||
checksum: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from .models import Chunk, Document, DocumentStatus, ParsedDocument
|
||||
from .models import Chunk, Document, DocumentArtifact, DocumentProcessingRun, DocumentStatus, DocumentStatusEvent, ParsedDocument
|
||||
# Keep domain contracts explicit so adapters can swap implementations cleanly.
|
||||
|
||||
|
||||
@@ -128,3 +128,111 @@ class ParseArtifactStore(ABC):
|
||||
def get_structure_nodes(self, doc_id: str) -> list[dict]:
|
||||
"""Return all structure nodes for a document."""
|
||||
pass
|
||||
|
||||
|
||||
class DocumentProcessingStore(ABC):
|
||||
"""Persist document processing runs, events, and artifact references."""
|
||||
|
||||
@abstractmethod
|
||||
def create_run(self, run: DocumentProcessingRun) -> DocumentProcessingRun:
|
||||
"""Create a new processing run record."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def mark_run_stored(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
stored_at: object | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> DocumentProcessingRun | None:
|
||||
"""Mark a run as having persisted the source file."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def mark_run_parsed(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
parser_backend: str,
|
||||
layout_count: int,
|
||||
structure_node_count: int,
|
||||
semantic_block_count: int,
|
||||
vector_chunk_count: int,
|
||||
parsed_at: object | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> DocumentProcessingRun | None:
|
||||
"""Record parse completion details for a run."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def mark_run_indexed(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
chunk_count: int,
|
||||
index_name: str,
|
||||
indexed_at: object | None = None,
|
||||
finished_at: object | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> DocumentProcessingRun | None:
|
||||
"""Mark a run as successfully indexed."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def mark_run_failed(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
failure_stage: str,
|
||||
error_message: str,
|
||||
finished_at: object | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> DocumentProcessingRun | None:
|
||||
"""Mark a run as failed."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def append_status_event(self, event: DocumentStatusEvent) -> DocumentStatusEvent:
|
||||
"""Append a document status event."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def replace_artifacts_for_run(self, run_id: str, artifacts: list[DocumentArtifact]) -> list[DocumentArtifact]:
|
||||
"""Replace all artifacts for a run with the provided list."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_by_document(self, doc_id: str) -> None:
|
||||
"""Delete all processing data for a document."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_runs_by_document(self, doc_id: str) -> list[DocumentProcessingRun]:
|
||||
"""List all processing runs for a document."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_run(self, run_id: str) -> DocumentProcessingRun | None:
|
||||
"""Return one processing run by identifier."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_status_events_by_document(self, doc_id: str) -> list[DocumentStatusEvent]:
|
||||
"""List status events for a document."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_status_events_by_run(self, run_id: str) -> list[DocumentStatusEvent]:
|
||||
"""List status events for a run."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_artifacts_by_document(self, doc_id: str) -> list[DocumentArtifact]:
|
||||
"""List artifact references for a document."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_artifacts_by_run(self, run_id: str) -> list[DocumentArtifact]:
|
||||
"""List artifact references for a run."""
|
||||
pass
|
||||
|
||||
@@ -16,14 +16,88 @@ class RetrievalQuery:
|
||||
filters: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(init=False)
|
||||
class RetrievedChunk:
|
||||
"""Represent the Retrieved Chunk type."""
|
||||
"""Represent the retrieved chunk payload with legacy aliases."""
|
||||
|
||||
chunk_id: str
|
||||
doc_id: str
|
||||
doc_name: str
|
||||
content: str
|
||||
doc_title: str
|
||||
text: str
|
||||
score: float
|
||||
chunk_type: str = ""
|
||||
section_title: str = ""
|
||||
page_number: int = 0
|
||||
page_start: int = 0
|
||||
page_end: int = 0
|
||||
section_level: int = 0
|
||||
chunk_index: int = 0
|
||||
piece_index: int = 0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
chunk_id: str,
|
||||
doc_id: str,
|
||||
doc_title: str | None = None,
|
||||
text: str | None = None,
|
||||
score: float = 0.0,
|
||||
chunk_type: str = "",
|
||||
section_title: str = "",
|
||||
page_start: int = 0,
|
||||
page_end: int = 0,
|
||||
section_level: int = 0,
|
||||
chunk_index: int = 0,
|
||||
piece_index: int = 0,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
doc_name: str | None = None,
|
||||
content: str | None = None,
|
||||
page_number: int | None = None,
|
||||
block_type: str | None = None,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
"""Initialize the retrieved chunk while accepting legacy field names."""
|
||||
self.chunk_id = chunk_id
|
||||
self.doc_id = doc_id
|
||||
self.doc_title = doc_title if doc_title is not None else (doc_name or "")
|
||||
self.text = text if text is not None else (content or "")
|
||||
self.score = float(score)
|
||||
self.chunk_type = chunk_type or (block_type or "")
|
||||
self.section_title = section_title
|
||||
self.page_start = int(page_start or page_number or 0)
|
||||
self.page_end = int(page_end or self.page_start)
|
||||
self.section_level = int(section_level or 0)
|
||||
self.chunk_index = int(chunk_index or 0)
|
||||
self.piece_index = int(piece_index or 0)
|
||||
self.metadata = dict(metadata or {})
|
||||
|
||||
@property
|
||||
def doc_name(self) -> str:
|
||||
"""Return the legacy document name alias."""
|
||||
return self.doc_title
|
||||
|
||||
@doc_name.setter
|
||||
def doc_name(self, value: str) -> None:
|
||||
"""Update the legacy document name alias."""
|
||||
self.doc_title = value
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
"""Return the legacy content alias."""
|
||||
return self.text
|
||||
|
||||
@content.setter
|
||||
def content(self, value: str) -> None:
|
||||
"""Update the legacy content alias."""
|
||||
self.text = value
|
||||
|
||||
@property
|
||||
def page_number(self) -> int:
|
||||
"""Return the legacy page number alias."""
|
||||
return self.page_start
|
||||
|
||||
@page_number.setter
|
||||
def page_number(self, value: int) -> None:
|
||||
"""Update the legacy page number alias."""
|
||||
self.page_start = value
|
||||
self.page_end = max(self.page_end, value)
|
||||
|
||||
5
backend/app/infrastructure/auth/__init__.py
Normal file
5
backend/app/infrastructure/auth/__init__.py
Normal 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.
|
||||
"""
|
||||
82
backend/app/infrastructure/auth/jwt_handler.py
Normal file
82
backend/app/infrastructure/auth/jwt_handler.py
Normal 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)
|
||||
113
backend/app/infrastructure/auth/user_store.py
Normal file
113
backend/app/infrastructure/auth/user_store.py
Normal 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)
|
||||
0
backend/app/infrastructure/compliance/__init__.py
Normal file
0
backend/app/infrastructure/compliance/__init__.py
Normal file
101
backend/app/infrastructure/compliance/docx_export.py
Normal file
101
backend/app/infrastructure/compliance/docx_export.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""DOCX report generator for compliance analysis results.
|
||||
|
||||
Uses python-docx (already in requirements.txt). Returns raw bytes so the
|
||||
caller can stream the response without writing to disk.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
from app.domain.compliance.ports import AnalysisRecord
|
||||
|
||||
_STATUS_LABEL = {"ok": "Compliant", "warn": "Warning", "risk": "Non-Compliant"}
|
||||
_STATUS_COLOR = {
|
||||
"ok": RGBColor(0x22, 0x8B, 0x22),
|
||||
"warn": RGBColor(0xFF, 0x8C, 0x00),
|
||||
"risk": RGBColor(0xDC, 0x14, 0x3C),
|
||||
}
|
||||
|
||||
|
||||
def generate_docx(record: AnalysisRecord) -> bytes:
|
||||
"""Generate a compliance report DOCX and return its raw bytes.
|
||||
|
||||
Structure:
|
||||
- Cover: document name, standard, date, risk score
|
||||
- Executive summary (conclusion)
|
||||
- Findings table
|
||||
- Recommended actions
|
||||
- Footer note
|
||||
"""
|
||||
doc = Document()
|
||||
|
||||
# ── Cover ──────────────────────────────────────────────────────────────────
|
||||
title_para = doc.add_heading("Compliance Analysis Report", level=0)
|
||||
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
doc.add_paragraph("")
|
||||
meta_table = doc.add_table(rows=4, cols=2)
|
||||
meta_table.style = "Table Grid"
|
||||
labels = ["Document", "Standard", "Date", "Risk Score"]
|
||||
values = [
|
||||
record.doc_name,
|
||||
record.standard_name,
|
||||
record.created_at.strftime("%Y-%m-%d %H:%M UTC") if record.created_at else "",
|
||||
f"{record.risk_score} / 100",
|
||||
]
|
||||
for i, (label, value) in enumerate(zip(labels, values)):
|
||||
meta_table.cell(i, 0).text = label
|
||||
meta_table.cell(i, 1).text = value
|
||||
|
||||
# ── Executive Summary ──────────────────────────────────────────────────────
|
||||
doc.add_heading("Executive Summary", level=1)
|
||||
doc.add_paragraph(record.conclusion)
|
||||
|
||||
# ── Findings ───────────────────────────────────────────────────────────────
|
||||
doc.add_heading("Findings", level=1)
|
||||
if record.findings:
|
||||
table = doc.add_table(rows=1, cols=4)
|
||||
table.style = "Table Grid"
|
||||
hdr = table.rows[0].cells
|
||||
for i, h in enumerate(["#", "Status", "Title", "Description / Clause"]):
|
||||
hdr[i].text = h
|
||||
for run in hdr[i].paragraphs[0].runs:
|
||||
run.bold = True
|
||||
|
||||
for f in record.findings:
|
||||
row = table.add_row().cells
|
||||
row[0].text = str(f.seq + 1)
|
||||
row[1].text = _STATUS_LABEL.get(f.status, f.status)
|
||||
row[2].text = f.title
|
||||
desc = f.description
|
||||
if f.clause_ref:
|
||||
desc += f"\n[{f.clause_ref}]"
|
||||
row[3].text = desc
|
||||
else:
|
||||
doc.add_paragraph("No findings recorded.")
|
||||
|
||||
# ── Recommended Actions ────────────────────────────────────────────────────
|
||||
doc.add_heading("Recommended Actions", level=1)
|
||||
for i, action in enumerate(record.actions, start=1):
|
||||
label = action.get("label", "Action")
|
||||
value = action.get("value", "")
|
||||
doc.add_paragraph(f"{i}. {label}: {value}", style="List Number")
|
||||
|
||||
# ── Footer note ────────────────────────────────────────────────────────────
|
||||
doc.add_paragraph("")
|
||||
footer = doc.add_paragraph(
|
||||
f"Generated by AI Regulation Analysis System — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
|
||||
)
|
||||
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
for run in footer.runs:
|
||||
run.font.size = Pt(8)
|
||||
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||||
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
280
backend/app/infrastructure/compliance/repository.py
Normal file
280
backend/app/infrastructure/compliance/repository.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# backend/app/infrastructure/compliance/repository.py
|
||||
"""PostgreSQL-backed compliance analysis repository.
|
||||
|
||||
Follows the same psycopg2 pattern as PostgresDocumentRepository:
|
||||
ThreadedConnectionPool + RealDictCursor for reads, _ensure_schema on init.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import psycopg2.pool
|
||||
from loguru import logger
|
||||
|
||||
from app.domain.compliance.ports import (
|
||||
AnalysisRecord,
|
||||
ComplianceRepository,
|
||||
FindingRecord,
|
||||
)
|
||||
|
||||
|
||||
class PostgresComplianceRepository(ComplianceRepository):
|
||||
"""Stores compliance analyses, findings, and finding chat messages in PostgreSQL."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
user: str,
|
||||
password: str,
|
||||
dbname: str,
|
||||
minconn: int = 1,
|
||||
maxconn: int = 5,
|
||||
) -> None:
|
||||
self._pool = psycopg2.pool.ThreadedConnectionPool(
|
||||
minconn=minconn,
|
||||
maxconn=maxconn,
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
dbname=dbname,
|
||||
)
|
||||
self._ensure_schema()
|
||||
|
||||
@contextmanager
|
||||
def _conn(self):
|
||||
conn = self._pool.getconn()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
self._pool.putconn(conn)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
"""Create tables if they do not exist."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS compliance_analyses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by VARCHAR(255),
|
||||
doc_name VARCHAR(500),
|
||||
standard_name VARCHAR(500),
|
||||
risk_score INTEGER,
|
||||
conclusion TEXT,
|
||||
actions JSONB,
|
||||
para_text TEXT,
|
||||
highlight_terms JSONB
|
||||
);
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS compliance_findings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
title VARCHAR(500),
|
||||
description TEXT,
|
||||
status VARCHAR(50),
|
||||
clause_ref VARCHAR(200)
|
||||
);
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS finding_chat_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
analysis_id UUID NOT NULL REFERENCES compliance_analyses(id) ON DELETE CASCADE,
|
||||
finding_id UUID NOT NULL REFERENCES compliance_findings(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def save_analysis(self, record: AnalysisRecord) -> str:
|
||||
"""Insert analysis + findings; return the new analysis UUID."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO compliance_analyses
|
||||
(created_by, doc_name, standard_name, risk_score,
|
||||
conclusion, actions, para_text, highlight_terms)
|
||||
VALUES
|
||||
(%(created_by)s, %(doc_name)s, %(standard_name)s, %(risk_score)s,
|
||||
%(conclusion)s, %(actions)s, %(para_text)s, %(highlight_terms)s)
|
||||
RETURNING id
|
||||
""",
|
||||
{
|
||||
"created_by": record.created_by,
|
||||
"doc_name": record.doc_name,
|
||||
"standard_name": record.standard_name,
|
||||
"risk_score": record.risk_score,
|
||||
"conclusion": record.conclusion,
|
||||
"actions": json.dumps(record.actions, ensure_ascii=False),
|
||||
"para_text": record.para_text,
|
||||
"highlight_terms": json.dumps(record.highlight_terms, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
analysis_id = str(row["id"])
|
||||
|
||||
if record.findings:
|
||||
with conn.cursor() as cur:
|
||||
for f in record.findings:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO compliance_findings
|
||||
(analysis_id, seq, title, description, status, clause_ref)
|
||||
VALUES
|
||||
(%(analysis_id)s, %(seq)s, %(title)s, %(desc)s, %(status)s, %(clause_ref)s)
|
||||
""",
|
||||
{
|
||||
"analysis_id": analysis_id,
|
||||
"seq": f.seq,
|
||||
"title": f.title,
|
||||
"desc": f.description,
|
||||
"status": f.status,
|
||||
"clause_ref": f.clause_ref,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
return analysis_id
|
||||
|
||||
def list_analyses(self, limit: int = 50, offset: int = 0) -> list[AnalysisRecord]:
|
||||
"""Return analyses without nested findings, ordered newest first."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, created_at, created_by, doc_name, standard_name,
|
||||
risk_score, conclusion, actions, para_text, highlight_terms
|
||||
FROM compliance_analyses
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %(limit)s OFFSET %(offset)s
|
||||
""",
|
||||
{"limit": limit, "offset": offset},
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_record(dict(r)) for r in rows]
|
||||
|
||||
def get_analysis(self, analysis_id: str) -> Optional[AnalysisRecord]:
|
||||
"""Return analysis with nested findings list."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM compliance_analyses WHERE id = %(id)s",
|
||||
{"id": analysis_id},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
record = self._row_to_record(dict(row))
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, analysis_id, seq, title, description, status, clause_ref
|
||||
FROM compliance_findings
|
||||
WHERE analysis_id = %(id)s
|
||||
ORDER BY seq
|
||||
""",
|
||||
{"id": analysis_id},
|
||||
)
|
||||
findings = [
|
||||
FindingRecord(
|
||||
id=str(r["id"]),
|
||||
analysis_id=str(r["analysis_id"]),
|
||||
seq=r["seq"],
|
||||
title=r["title"] or "",
|
||||
description=r["description"] or "",
|
||||
status=r["status"] or "ok",
|
||||
clause_ref=r["clause_ref"],
|
||||
)
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
record.findings = findings
|
||||
return record
|
||||
|
||||
def delete_analysis(self, analysis_id: str) -> None:
|
||||
"""Delete analysis; findings and chat messages cascade automatically."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM compliance_analyses WHERE id = %(id)s",
|
||||
{"id": analysis_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def save_message(self, analysis_id: str, finding_id: str, role: str, content: str) -> str:
|
||||
"""Persist a chat message; return its UUID."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO finding_chat_messages
|
||||
(analysis_id, finding_id, role, content)
|
||||
VALUES
|
||||
(%(analysis_id)s, %(finding_id)s, %(role)s, %(content)s)
|
||||
RETURNING id
|
||||
""",
|
||||
{
|
||||
"analysis_id": analysis_id,
|
||||
"finding_id": finding_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return str(row["id"])
|
||||
|
||||
def get_messages(self, finding_id: str) -> list[dict]:
|
||||
"""Return messages for a finding, oldest first."""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, role, content, created_at
|
||||
FROM finding_chat_messages
|
||||
WHERE finding_id = %(finding_id)s
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
{"finding_id": finding_id},
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"role": r["role"],
|
||||
"content": r["content"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def _row_to_record(self, row: dict) -> AnalysisRecord:
|
||||
"""Convert a RealDictCursor row to an AnalysisRecord (no findings)."""
|
||||
actions = row.get("actions") or []
|
||||
if isinstance(actions, str):
|
||||
actions = json.loads(actions)
|
||||
highlight_terms = row.get("highlight_terms") or []
|
||||
if isinstance(highlight_terms, str):
|
||||
highlight_terms = json.loads(highlight_terms)
|
||||
return AnalysisRecord(
|
||||
id=str(row["id"]),
|
||||
created_at=row["created_at"] if isinstance(row["created_at"], datetime) else datetime.utcnow(),
|
||||
created_by=row.get("created_by"),
|
||||
doc_name=row.get("doc_name") or "",
|
||||
standard_name=row.get("standard_name") or "",
|
||||
risk_score=int(row.get("risk_score") or 0),
|
||||
conclusion=row.get("conclusion") or "",
|
||||
actions=actions,
|
||||
para_text=row.get("para_text") or "",
|
||||
highlight_terms=highlight_terms,
|
||||
findings=[],
|
||||
)
|
||||
@@ -45,10 +45,10 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
||||
context_tokens = 0
|
||||
for idx, chunk in enumerate(retrieved_chunks, start=1):
|
||||
block = (
|
||||
f"[{idx}] 文档: {chunk.doc_name}\n"
|
||||
f"[{idx}] 文档: {chunk.doc_title}\n"
|
||||
f"章节: {chunk.section_title or '未标注'}\n"
|
||||
f"页码: {chunk.page_number}\n"
|
||||
f"内容: {chunk.content}"
|
||||
f"页码: {chunk.page_start}" + (f"-{chunk.page_end}" if chunk.page_end and chunk.page_end != chunk.page_start else "") + "\n"
|
||||
f"内容: {chunk.text}"
|
||||
)
|
||||
block_tokens = self._estimate_tokens(block)
|
||||
if context_tokens + block_tokens > settings.rag_max_context_tokens:
|
||||
@@ -67,17 +67,37 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
||||
)
|
||||
return messages, context_tokens
|
||||
|
||||
def _is_context_truncated(self, *, retrieved_chunks: list[RetrievedChunk], context_tokens: int) -> bool:
|
||||
"""Return whether the prompt context had to omit retrieved chunks to fit the token budget."""
|
||||
if not retrieved_chunks:
|
||||
return False
|
||||
estimated_total_tokens = sum(
|
||||
self._estimate_tokens(
|
||||
f"[{idx}] 文档: {chunk.doc_title}\n"
|
||||
f"章节: {chunk.section_title or '未标注'}\n"
|
||||
f"页码: {chunk.page_start}" + (f"-{chunk.page_end}" if chunk.page_end and chunk.page_end != chunk.page_start else "") + "\n"
|
||||
f"内容: {chunk.text}"
|
||||
)
|
||||
for idx, chunk in enumerate(retrieved_chunks, start=1)
|
||||
)
|
||||
return estimated_total_tokens > context_tokens
|
||||
|
||||
def _sources(self, chunks: list[RetrievedChunk]) -> list[AnswerSource]:
|
||||
"""Handle sources for this module for the Open A I Compatible Answer Generator instance."""
|
||||
return [
|
||||
AnswerSource(
|
||||
doc_id=chunk.doc_id,
|
||||
doc_name=chunk.doc_name,
|
||||
doc_title=chunk.doc_title,
|
||||
chunk_id=chunk.chunk_id,
|
||||
chunk_type=chunk.chunk_type,
|
||||
section_title=chunk.section_title,
|
||||
page_number=chunk.page_number,
|
||||
page_start=chunk.page_start,
|
||||
page_end=chunk.page_end,
|
||||
section_level=chunk.section_level,
|
||||
chunk_index=chunk.chunk_index,
|
||||
piece_index=chunk.piece_index,
|
||||
score=chunk.score,
|
||||
content=chunk.content,
|
||||
text=chunk.text,
|
||||
metadata=chunk.metadata,
|
||||
)
|
||||
for chunk in chunks
|
||||
@@ -111,7 +131,10 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
||||
latency_ms=latency_ms,
|
||||
retrieved_count=len(retrieved_chunks),
|
||||
context_tokens=context_tokens,
|
||||
truncated=len(retrieved_chunks) > len(messages),
|
||||
truncated=self._is_context_truncated(
|
||||
retrieved_chunks=retrieved_chunks,
|
||||
context_tokens=context_tokens,
|
||||
),
|
||||
error=response.error,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class LocalRegulationChunkBuilder(ChunkBuilder):
|
||||
"""Adapt the existing markdown chunker to the new chunk builder port."""
|
||||
|
||||
def __init__(self, *, chunk_size: int = 512, chunk_overlap: int = 50) -> None:
|
||||
"""Initialize the local markdown chunk builder."""
|
||||
self.chunker = RegulationChunker(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
@@ -22,6 +23,7 @@ class LocalRegulationChunkBuilder(ChunkBuilder):
|
||||
regulation_type: str,
|
||||
version: str,
|
||||
) -> list[Chunk]:
|
||||
"""Build migrated chunk objects from the legacy markdown chunker output."""
|
||||
markdown_text = parsed_document.raw_text.strip()
|
||||
if not markdown_text:
|
||||
return []
|
||||
@@ -50,16 +52,18 @@ class LocalRegulationChunkBuilder(ChunkBuilder):
|
||||
Chunk(
|
||||
chunk_id=item.metadata.chunk_id,
|
||||
doc_id=parsed_document.doc_id,
|
||||
doc_name=parsed_document.doc_name,
|
||||
content=item.content,
|
||||
doc_title=parsed_document.doc_name,
|
||||
text=item.content,
|
||||
embedding_text=item.content,
|
||||
chunk_type="local_markdown_chunk",
|
||||
section_title=item.metadata.section_title or item.metadata.section_number,
|
||||
section_path=section_path,
|
||||
page_number=item.metadata.page_number,
|
||||
page_start=item.metadata.page_number,
|
||||
page_end=item.metadata.page_number,
|
||||
section_level=len(section_path),
|
||||
regulation_type=regulation_type,
|
||||
version=version,
|
||||
semantic_id=item.metadata.clause_number,
|
||||
block_type="local_markdown_chunk",
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -19,29 +19,35 @@ class AliyunVectorChunkBuilder(ChunkBuilder):
|
||||
"""Handle build for the Aliyun Vector Chunk Builder instance."""
|
||||
chunks: list[Chunk] = []
|
||||
for index, item in enumerate(parsed_document.vector_chunks):
|
||||
content = item.get("content") or item.get("text") or ""
|
||||
embedding_text = item.get("embedding_text") or content
|
||||
text = item.get("text") or ""
|
||||
embedding_text = item.get("embedding_text") or text
|
||||
if not embedding_text.strip():
|
||||
continue
|
||||
section_path = item.get("section_path") or []
|
||||
section_title = item.get("section_title") or (section_path[-1] if section_path else "")
|
||||
page_number = item.get("page_start") or item.get("page") or 0
|
||||
chunk_id = item.get("chunk_id") or f"{parsed_document.doc_id}-chunk-{index}"
|
||||
metadata = {k: v for k, v in item.items() if k not in {"content", "embedding_text"}}
|
||||
metadata = dict(item)
|
||||
metadata["regulation_type"] = regulation_type
|
||||
metadata["version"] = version
|
||||
chunks.append(
|
||||
Chunk(
|
||||
chunk_id=str(chunk_id),
|
||||
doc_id=parsed_document.doc_id,
|
||||
doc_name=parsed_document.doc_name,
|
||||
content=content,
|
||||
doc_title=str(item.get("doc_title") or parsed_document.doc_name),
|
||||
text=text,
|
||||
embedding_text=embedding_text,
|
||||
chunk_type=str(item.get("chunk_type", item.get("block_type", ""))),
|
||||
chunk_index=int(item.get("chunk_index") or 0),
|
||||
piece_index=int(item.get("piece_index") or 0),
|
||||
page_start=int(item.get("page_start") or 0),
|
||||
page_end=int(item.get("page_end") or 0),
|
||||
section_title=section_title,
|
||||
section_path=section_path,
|
||||
page_number=int(page_number or 0),
|
||||
section_level=int(item.get("section_level") or len(section_path)),
|
||||
source_ids=[str(v) for v in item.get("source_ids", [])],
|
||||
regulation_type=regulation_type,
|
||||
version=version,
|
||||
semantic_id=item.get("semantic_id", ""),
|
||||
block_type=item.get("block_type", ""),
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
|
||||
1
backend/app/infrastructure/perception/__init__.py
Normal file
1
backend/app/infrastructure/perception/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Perception infrastructure package."""
|
||||
39
backend/app/infrastructure/perception/base_event_store.py
Normal file
39
backend/app/infrastructure/perception/base_event_store.py
Normal 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."""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user