Compare commits
35 Commits
bf6d47e1fd
...
feature/fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
09f9cf2bf0 | ||
|
|
4e90b4f2da | ||
|
|
6bf5600a26 |
2
.env
2
.env
@@ -9,7 +9,7 @@ DEBUG=false
|
|||||||
# ===== Milvus向量数据库配置(已有)=====
|
# ===== Milvus向量数据库配置(已有)=====
|
||||||
MILVUS_HOST=6.86.80.8
|
MILVUS_HOST=6.86.80.8
|
||||||
MILVUS_PORT=19530
|
MILVUS_PORT=19530
|
||||||
MILVUS_COLLECTION=regulations_dense_1024_v1
|
MILVUS_COLLECTION=regulations_dense_1024_v2
|
||||||
MILVUS_DB_NAME=default
|
MILVUS_DB_NAME=default
|
||||||
MILVUS_INDEX_TYPE=IVF_FLAT
|
MILVUS_INDEX_TYPE=IVF_FLAT
|
||||||
MILVUS_NLIST=128
|
MILVUS_NLIST=128
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# ===== Milvus向量数据库配置(已有)=====
|
# ===== Milvus向量数据库配置(已有)=====
|
||||||
MILVUS_HOST=6.86.80.8
|
MILVUS_HOST=6.86.80.8
|
||||||
MILVUS_PORT=19530
|
MILVUS_PORT=19530
|
||||||
MILVUS_COLLECTION=regulations_dense_1024_v1
|
MILVUS_COLLECTION=regulations_dense_1024_v2
|
||||||
MILVUS_DB_NAME=default
|
MILVUS_DB_NAME=default
|
||||||
MILVUS_INDEX_TYPE=IVF_FLAT
|
MILVUS_INDEX_TYPE=IVF_FLAT
|
||||||
MILVUS_NLIST=128
|
MILVUS_NLIST=128
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ DEBUG=false
|
|||||||
# ===== Milvus向量数据库配置 =====
|
# ===== Milvus向量数据库配置 =====
|
||||||
MILVUS_HOST=6.86.80.8
|
MILVUS_HOST=6.86.80.8
|
||||||
MILVUS_PORT=19530
|
MILVUS_PORT=19530
|
||||||
MILVUS_COLLECTION=regulations_dense_1024_v1
|
MILVUS_COLLECTION=regulations_dense_1024_v2
|
||||||
MILVUS_DB_NAME=default
|
MILVUS_DB_NAME=default
|
||||||
MILVUS_INDEX_TYPE=IVF_FLAT
|
MILVUS_INDEX_TYPE=IVF_FLAT
|
||||||
MILVUS_NLIST=128
|
MILVUS_NLIST=128
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,3 +59,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# logs files
|
# logs files
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# codex
|
||||||
|
.agents
|
||||||
@@ -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/`.
|
- 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
|
## Entrypoints
|
||||||
|
|
||||||
- Backend entrypoint is `backend/app/main.py`, which re-exports `app` from `app.api.main`.
|
- 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`.
|
- `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.
|
- 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
|
## Backend Commenting Standard
|
||||||
|
|
||||||
- All comments and docstrings in `backend/**/*.py` must be written in English.
|
- 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
|
ALIBABA_ACCESS_KEY_SECRET=your_aliyun_access_key_secret
|
||||||
EMBEDDING_API_KEY=your_embedding_api_key_here
|
EMBEDDING_API_KEY=your_embedding_api_key_here
|
||||||
EMBEDDING_MODEL=text-embedding-v3
|
EMBEDDING_MODEL=text-embedding-v3
|
||||||
EMBEDDING_DIM=1536
|
EMBEDDING_DIM=1024
|
||||||
PARSER_BACKEND=aliyun
|
PARSER_BACKEND=aliyun
|
||||||
CHUNK_BACKEND=aliyun
|
CHUNK_BACKEND=aliyun
|
||||||
PARSER_FAILURE_MODE=fail
|
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` 是当前正式使用的 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
|
```bash
|
||||||
@@ -34,9 +41,14 @@ PYTHONPATH=backend uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
```text
|
```text
|
||||||
backend/
|
backend/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── api/ # FastAPI 路由与模型
|
│ ├── api/ # FastAPI 路由与 transport models
|
||||||
|
│ ├── application/ # 用例编排层
|
||||||
|
│ ├── domain/ # 核心业务模型与稳定端口
|
||||||
|
│ ├── infrastructure/ # 外部系统适配器
|
||||||
|
│ ├── shared/ # composition root 与横切支撑
|
||||||
│ ├── config/ # 配置与日志
|
│ ├── config/ # 配置与日志
|
||||||
│ ├── services/ # 文档处理、LLM、RAG、存储
|
│ ├── services/ # legacy façade / 兼容入口
|
||||||
|
│ ├── workflows/ # legacy workflow 入口
|
||||||
│ └── workers/ # 任务相关代码
|
│ └── workers/ # 任务相关代码
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
@@ -46,4 +58,13 @@ backend/
|
|||||||
## 说明
|
## 说明
|
||||||
|
|
||||||
- 路由前缀保持为 `/api/v1`,以兼容当前前端。
|
- 路由前缀保持为 `/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
|
||||||
|
```
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -11,7 +12,8 @@ from app.api.models import ErrorResponse
|
|||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.config.logging import setup_logging
|
from app.config.logging import setup_logging
|
||||||
from app.config.settings import settings
|
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.
|
# Keep module behavior explicit so the backend flow stays easy to audit.
|
||||||
|
|
||||||
|
|
||||||
@@ -24,12 +26,12 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
||||||
logger.info(f"调试模式: {settings.debug}")
|
logger.info(f"调试模式: {settings.debug}")
|
||||||
logger.info("预加载LLM客户端...")
|
logger.info("预加载LLM客户端...")
|
||||||
LLMFactory.preload_clients(["qwen", "deepseek"])
|
preload_runtime_dependencies()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
logger.info("应用关闭,执行清理...")
|
logger.info("应用关闭,执行清理...")
|
||||||
LLMFactory.cleanup()
|
cleanup_runtime_dependencies()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -55,16 +57,33 @@ app.add_middleware(
|
|||||||
app.include_router(api_router, prefix="/api/v1")
|
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)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
"""Global exception handler."""
|
"""Global exception handler."""
|
||||||
logger.error(f"未处理的异常: {exc}")
|
logger.error(f"未处理的异常: {exc}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content=ErrorResponse(
|
content=jsonable_encoder(
|
||||||
|
ErrorResponse(
|
||||||
error="InternalServerError",
|
error="InternalServerError",
|
||||||
message=str(exc),
|
message=str(exc),
|
||||||
).model_dump(),
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from .documents import router as documents_router
|
|||||||
from .knowledge import router as knowledge_router
|
from .knowledge import router as knowledge_router
|
||||||
from .agent import router as agent_router
|
from .agent import router as agent_router
|
||||||
from .status import router as status_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.
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ api_router.include_router(knowledge_router)
|
|||||||
api_router.include_router(agent_router)
|
api_router.include_router(agent_router)
|
||||||
api_router.include_router(compliance_router)
|
api_router.include_router(compliance_router)
|
||||||
api_router.include_router(status_router)
|
api_router.include_router(status_router)
|
||||||
|
api_router.include_router(perception_router)
|
||||||
|
api_router.include_router(rag_router)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"api_router",
|
"api_router",
|
||||||
@@ -26,4 +30,6 @@ __all__ = [
|
|||||||
"agent_router",
|
"agent_router",
|
||||||
"compliance_router",
|
"compliance_router",
|
||||||
"status_router",
|
"status_router",
|
||||||
|
"perception_router",
|
||||||
|
"rag_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.api.models import (
|
|||||||
)
|
)
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.shared.async_utils import iter_in_thread
|
from app.shared.async_utils import iter_in_thread
|
||||||
from app.shared.bootstrap import get_agent_conversation_service, 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.
|
# 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,
|
model=request.model or settings.llm_model,
|
||||||
top_k=request.top_k or settings.rag_top_k,
|
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(
|
return ChatResponse(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
answer=result.answer,
|
answer=result.answer,
|
||||||
@@ -133,45 +133,52 @@ async def chat_stream(request: ChatRequest):
|
|||||||
@router.get("/session/{session_id}", response_model=SessionInfo)
|
@router.get("/session/{session_id}", response_model=SessionInfo)
|
||||||
async def get_session_info(session_id: str):
|
async def get_session_info(session_id: str):
|
||||||
"""Return session info."""
|
"""Return session info."""
|
||||||
session = get_conversation_store().get_session(session_id)
|
try:
|
||||||
if not session:
|
session = get_agent_session_service().get_session(session_id)
|
||||||
raise HTTPException(status_code=404, detail="会话不存在或已过期")
|
|
||||||
return SessionInfo(
|
return SessionInfo(
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
message_count=len(session.messages),
|
message_count=len(session.messages),
|
||||||
created_at=session.created_at,
|
created_at=session.created_at,
|
||||||
updated_at=session.updated_at,
|
updated_at=session.updated_at,
|
||||||
)
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/session/{session_id}/history")
|
@router.get("/session/{session_id}/history")
|
||||||
async def get_session_history(session_id: str, max_turns: int = 5):
|
async def get_session_history(session_id: str, max_turns: int = 5):
|
||||||
"""Return session history."""
|
"""Return session history."""
|
||||||
session = get_conversation_store().get_session(session_id)
|
try:
|
||||||
if not session:
|
history = get_agent_session_service().get_history(session_id=session_id, max_turns=max_turns)
|
||||||
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}
|
return {"session_id": session_id, "history": history}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/session/{session_id}")
|
@router.delete("/session/{session_id}")
|
||||||
async def delete_session(session_id: str):
|
async def delete_session(session_id: str):
|
||||||
"""Delete session."""
|
"""Delete session."""
|
||||||
if not get_conversation_store().delete_session(session_id):
|
try:
|
||||||
raise HTTPException(status_code=404, detail="会话不存在")
|
get_agent_session_service().delete_session(session_id)
|
||||||
return {"message": "会话已删除", "session_id": 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])
|
@router.get("/sessions", response_model=List[SessionInfo])
|
||||||
async def list_sessions():
|
async def list_sessions():
|
||||||
"""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")
|
@router.post("/feedback")
|
||||||
async def submit_feedback(request: FeedbackRequest):
|
async def submit_feedback(request: FeedbackRequest):
|
||||||
"""Submit feedback."""
|
"""Submit feedback."""
|
||||||
session = get_conversation_store().get_session(request.session_id)
|
try:
|
||||||
if not session:
|
result = get_agent_session_service().submit_feedback(
|
||||||
raise HTTPException(status_code=404, detail="会话不存在")
|
session_id=request.session_id,
|
||||||
return {"message": "反馈已提交", "session_id": request.session_id, "message_index": request.message_index}
|
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))
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, File, UploadFile
|
from fastapi import APIRouter, File, Form, UploadFile
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from app.schemas.compliance import (
|
from app.schemas.compliance import (
|
||||||
AnalyzeResponse,
|
AnalyzeResponse,
|
||||||
ComplianceChatRequest,
|
ComplianceChatRequest,
|
||||||
)
|
)
|
||||||
from app.services.mock_data import generate_task_id, get_mock_compliance_result
|
from app.services.mock_data import generate_task_id, get_mock_compliance_result
|
||||||
from app.shared.bootstrap import get_agent_conversation_service
|
from app.shared.bootstrap import get_agent_conversation_service, get_retrieval_service
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/compliance", tags=["合规分析"])
|
router = APIRouter(prefix="/compliance", tags=["合规分析"])
|
||||||
@@ -62,6 +64,128 @@ async def get_result(task_id: str):
|
|||||||
return task["result"]
|
return task["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(data: dict) -> str:
|
||||||
|
return f"event: message\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/analyze-stream")
|
||||||
|
async def analyze_stream(
|
||||||
|
text: Optional[str] = Form(None),
|
||||||
|
doc_id: Optional[str] = Form(None),
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
domains: Optional[str] = Form(None),
|
||||||
|
title: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
|
"""Stream compliance analysis as SSE events.
|
||||||
|
|
||||||
|
Stages: clause_split → retrieval (per clause) → gap_check → conclusion
|
||||||
|
Events: stage | source | finding | done | error
|
||||||
|
"""
|
||||||
|
from app.application.compliance.pipeline import (
|
||||||
|
check_clause_compliance,
|
||||||
|
extract_text_from_doc_id,
|
||||||
|
extract_text_from_file,
|
||||||
|
retrieve_for_clause,
|
||||||
|
split_into_clauses,
|
||||||
|
synthesize_conclusion,
|
||||||
|
)
|
||||||
|
from app.services.llm.llm_factory import get_llm_client
|
||||||
|
from app.shared.bootstrap import get_retrieval_service
|
||||||
|
|
||||||
|
# Read file content eagerly (before async generator)
|
||||||
|
file_content: bytes | None = None
|
||||||
|
file_name: str | None = None
|
||||||
|
if file is not None:
|
||||||
|
file_content = await file.read()
|
||||||
|
file_name = file.filename
|
||||||
|
|
||||||
|
async def generate() -> AsyncGenerator[str, None]:
|
||||||
|
try:
|
||||||
|
client = get_llm_client(provider=settings.llm_provider, model=settings.llm_model)
|
||||||
|
retrieval_service = get_retrieval_service()
|
||||||
|
|
||||||
|
# ── Stage 1: extract text ─────────────────────────────────────
|
||||||
|
yield _sse({"type": "stage", "stage": "extracting", "label": "Extracting text…"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
if text:
|
||||||
|
para_text = text.strip()
|
||||||
|
elif doc_id:
|
||||||
|
try:
|
||||||
|
para_text = await asyncio.to_thread(extract_text_from_doc_id, doc_id)
|
||||||
|
except Exception as exc:
|
||||||
|
yield _sse({"type": "error", "text": f"Document not found: {exc}"})
|
||||||
|
return
|
||||||
|
elif file_content is not None:
|
||||||
|
para_text = await asyncio.to_thread(
|
||||||
|
extract_text_from_file, file_content, file_name or "upload"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
yield _sse({"type": "error", "text": "No input provided"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not para_text.strip():
|
||||||
|
yield _sse({"type": "error", "text": "Could not extract text from the provided input"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Stage 2: split into clauses ───────────────────────────────
|
||||||
|
yield _sse({"type": "stage", "stage": "splitting", "label": "Splitting into clauses…"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
clauses: list[str] = await asyncio.to_thread(split_into_clauses, para_text, client)
|
||||||
|
|
||||||
|
# ── Stage 3: retrieve + gap check per clause ──────────────────
|
||||||
|
findings: list[dict] = []
|
||||||
|
|
||||||
|
for i, clause in enumerate(clauses):
|
||||||
|
yield _sse({
|
||||||
|
"type": "stage",
|
||||||
|
"stage": "analyzing",
|
||||||
|
"label": f"Analyzing clause {i + 1}/{len(clauses)}…",
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
chunks = await asyncio.to_thread(
|
||||||
|
retrieve_for_clause, clause, retrieval_service, 5, domains or None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Emit source events
|
||||||
|
for chunk in chunks[:3]:
|
||||||
|
yield _sse({
|
||||||
|
"type": "source",
|
||||||
|
"standard": getattr(chunk, "doc_title", "") or getattr(chunk, "doc_name", ""),
|
||||||
|
"clause": getattr(chunk, "section_title", "") or "",
|
||||||
|
"score": round(float(getattr(chunk, "score", 0)), 3),
|
||||||
|
"status": "retrieved",
|
||||||
|
"full_content": (getattr(chunk, "text", "") or "")[:300],
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
finding = await asyncio.to_thread(check_clause_compliance, clause, chunks, client)
|
||||||
|
if finding:
|
||||||
|
findings.append(finding)
|
||||||
|
yield _sse({"type": "finding", **finding})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# ── Stage 4: synthesize conclusion ────────────────────────────
|
||||||
|
yield _sse({"type": "stage", "stage": "concluding", "label": "Generating conclusion…"})
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
conclusion_data = await asyncio.to_thread(
|
||||||
|
synthesize_conclusion, para_text, findings, client
|
||||||
|
)
|
||||||
|
yield _sse({"type": "done", **conclusion_data})
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("analyze-stream pipeline error")
|
||||||
|
yield _sse({"type": "error", "text": str(exc)})
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat/{segment_id}")
|
@router.post("/chat/{segment_id}")
|
||||||
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
async def compliance_chat(segment_id: int, request: ComplianceChatRequest):
|
||||||
"""Stream compliance Q&A grounded in real vector retrieval."""
|
"""Stream compliance Q&A grounded in real vector retrieval."""
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ def _document_response(result: DocumentProcessResult) -> DocumentUploadResponse:
|
|||||||
@router.post("/upload", response_model=DocumentUploadResponse)
|
@router.post("/upload", response_model=DocumentUploadResponse)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
file: UploadFile = File(..., description="上传的文档文件"),
|
file: UploadFile = File(..., description="上传的文档文件"),
|
||||||
|
doc_id: str | None = Form(None, description="客户端预分配的文档ID,不传则自动生成"),
|
||||||
doc_name: str | None = Form(None, description="文档名称"),
|
doc_name: str | None = Form(None, description="文档名称"),
|
||||||
regulation_type: str | None = Form(None, description="法规类型"),
|
regulation_type: str | None = Form(None, description="法规类型"),
|
||||||
version: str | None = Form(None, description="文档版本"),
|
version: str | None = Form(None, description="文档版本"),
|
||||||
@@ -48,6 +49,7 @@ async def upload_document(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = get_document_command_service().upload_and_process(
|
result = get_document_command_service().upload_and_process(
|
||||||
|
doc_id=doc_id,
|
||||||
file_name=file.filename,
|
file_name=file.filename,
|
||||||
content=content,
|
content=content,
|
||||||
content_type=file.content_type or "application/octet-stream",
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
|||||||
@@ -29,14 +29,19 @@ async def search_knowledge(request: SearchRequest):
|
|||||||
results=[
|
results=[
|
||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
id=index + 1,
|
id=index + 1,
|
||||||
content=item.content,
|
content=item.text,
|
||||||
score=item.score,
|
score=item.score,
|
||||||
metadata={
|
metadata={
|
||||||
"doc_id": item.doc_id,
|
"doc_id": item.doc_id,
|
||||||
"doc_name": item.doc_name,
|
"doc_title": item.doc_title,
|
||||||
"chunk_id": item.chunk_id,
|
"chunk_id": item.chunk_id,
|
||||||
|
"chunk_type": item.chunk_type,
|
||||||
"section_title": item.section_title,
|
"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,
|
**item.metadata,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
67
backend/app/api/routes/perception.py
Normal file
67
backend/app/api/routes/perception.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Define API routes for perception (regulatory intelligence)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.shared.bootstrap import get_perception_service
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -50,8 +50,8 @@ async def rag_chat(request: RagChatRequest):
|
|||||||
{
|
{
|
||||||
"id": str(s.get("chunk_id") or s.get("doc_id") or idx + 1),
|
"id": str(s.get("chunk_id") or s.get("doc_id") or idx + 1),
|
||||||
"score": s.get("score", 0),
|
"score": s.get("score", 0),
|
||||||
"preview": s.get("content", "")[:200],
|
"preview": s.get("text", s.get("content", ""))[:200],
|
||||||
"doc_name": s.get("doc_name", ""),
|
"doc_name": s.get("doc_title", s.get("doc_name", "")),
|
||||||
"clause": s.get("section_title", "法规片段"),
|
"clause": s.get("section_title", "法规片段"),
|
||||||
"doc_id": s.get("doc_id"),
|
"doc_id": s.get("doc_id"),
|
||||||
"download_url": (
|
"download_url": (
|
||||||
|
|||||||
@@ -1,32 +1,53 @@
|
|||||||
"""Define API routes for status."""
|
"""Define API routes for status."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.shared.bootstrap import get_document_query_service, get_vector_index
|
from app.shared.bootstrap import (
|
||||||
# Keep route handlers close to their transport-layer wiring for easier auditing.
|
get_bm25_retriever,
|
||||||
|
get_binary_store,
|
||||||
|
get_conversation_store,
|
||||||
|
get_document_query_service,
|
||||||
|
get_vector_index,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/status", tags=["系统状态"])
|
router = APIRouter(prefix="/status", tags=["系统状态"])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Simple TTL cache for /stats (avoids O(N) doc scan on every request)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_stats_cache: dict[str, Any] = {}
|
||||||
|
_stats_cache_time: float = 0.0
|
||||||
|
_STATS_TTL_SECONDS: float = 10.0
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats():
|
async def get_stats():
|
||||||
"""Return stats."""
|
"""Return document statistics (cached for 10 s)."""
|
||||||
|
global _stats_cache, _stats_cache_time
|
||||||
|
now = time.time()
|
||||||
|
if _stats_cache and (now - _stats_cache_time) < _STATS_TTL_SECONDS:
|
||||||
|
return _stats_cache
|
||||||
|
|
||||||
documents = get_document_query_service().list_documents()
|
documents = get_document_query_service().list_documents()
|
||||||
indexed = sum(1 for item in documents if item.status.value == "indexed")
|
indexed = sum(1 for d in documents if d.status.value == "indexed")
|
||||||
failed = sum(1 for item in documents if item.status.value == "failed")
|
failed = sum(1 for d in documents if d.status.value == "failed")
|
||||||
return {
|
_stats_cache = {
|
||||||
"documents_total": len(documents),
|
"documents_total": len(documents),
|
||||||
"documents_indexed": indexed,
|
"documents_indexed": indexed,
|
||||||
"documents_failed": failed,
|
"documents_failed": failed,
|
||||||
"chunks_total": sum(item.chunk_count for item in documents),
|
"chunks_total": sum(d.chunk_count for d in documents),
|
||||||
}
|
}
|
||||||
|
_stats_cache_time = now
|
||||||
|
return _stats_cache
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
async def get_config():
|
async def get_config():
|
||||||
"""Return config."""
|
"""Return system configuration."""
|
||||||
return {
|
return {
|
||||||
"embedding_model": settings.embedding_model,
|
"embedding_model": settings.embedding_model,
|
||||||
"embedding_dim": settings.embedding_dim,
|
"embedding_dim": settings.embedding_dim,
|
||||||
@@ -44,5 +65,49 @@ async def get_config():
|
|||||||
|
|
||||||
@router.get("/milvus/health")
|
@router.get("/milvus/health")
|
||||||
async def milvus_health():
|
async def milvus_health():
|
||||||
"""Handle milvus health."""
|
"""Return Milvus health (kept for backwards compat)."""
|
||||||
return get_vector_index().health()
|
return get_vector_index().health()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def get_health():
|
||||||
|
"""Return aggregate health of all backend services."""
|
||||||
|
# --- Milvus ---
|
||||||
|
try:
|
||||||
|
milvus_info = get_vector_index().health()
|
||||||
|
milvus_status = "ok" if milvus_info.get("connected") else "error"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
milvus_info = {}
|
||||||
|
milvus_status = "error"
|
||||||
|
milvus_info["error"] = str(exc)
|
||||||
|
|
||||||
|
# --- MinIO ---
|
||||||
|
try:
|
||||||
|
minio_connected = get_binary_store().client.connected
|
||||||
|
minio_status = "ok" if minio_connected else "error"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
minio_status = "error"
|
||||||
|
minio_connected = False
|
||||||
|
|
||||||
|
# --- BM25 ---
|
||||||
|
bm25 = get_bm25_retriever()
|
||||||
|
|
||||||
|
# --- Sessions ---
|
||||||
|
try:
|
||||||
|
session_count = len(get_conversation_store().list_sessions())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
session_count = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"milvus": {"status": milvus_status, **milvus_info},
|
||||||
|
"minio": {"status": minio_status, "connected": minio_connected},
|
||||||
|
"bm25": {"available": bm25 is not None},
|
||||||
|
"reranker": {
|
||||||
|
"enabled": settings.reranker_enabled,
|
||||||
|
"model": settings.reranker_model if settings.reranker_enabled else None,
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"active": session_count,
|
||||||
|
"max": settings.session_max_sessions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Initialize the app.application.agent package."""
|
"""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.
|
# 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 __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from app.domain.conversation import AnswerGenerator, AnswerResult, ConversationStore
|
from app.domain.conversation import AnswerGenerator, AnswerResult, ConversationStore
|
||||||
@@ -143,3 +144,48 @@ class AgentConversationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return session.session_id, event_stream()
|
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."""
|
||||||
215
backend/app/application/compliance/pipeline.py
Normal file
215
backend/app/application/compliance/pipeline.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""Compliance analysis pipeline helpers.
|
||||||
|
|
||||||
|
All functions are synchronous — call them via asyncio.to_thread() in async SSE generators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.application.knowledge import KnowledgeRetrievalService
|
||||||
|
from app.domain.retrieval import RetrievedChunk
|
||||||
|
from app.services.llm.base_client import BaseLLMClient
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(text: str):
|
||||||
|
"""Extract JSON from LLM response, tolerating markdown wrappers."""
|
||||||
|
stripped = text.strip()
|
||||||
|
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", stripped)
|
||||||
|
if match:
|
||||||
|
stripped = match.group(1).strip()
|
||||||
|
try:
|
||||||
|
return json.loads(stripped)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
for pattern in (r"(\[[\s\S]*\])", r"(\{[\s\S]*\})"):
|
||||||
|
m = re.search(pattern, stripped)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return json.loads(m.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"No valid JSON found in LLM response: {text[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_doc_id(doc_id: str) -> str:
|
||||||
|
from app.shared.bootstrap import get_document_query_service, get_retrieval_service
|
||||||
|
doc = get_document_query_service().get(doc_id)
|
||||||
|
if not doc:
|
||||||
|
raise ValueError(f"Document '{doc_id}' not found")
|
||||||
|
service = get_retrieval_service()
|
||||||
|
chunks = service.retrieve(query=doc.doc_name, top_k=30)
|
||||||
|
doc_chunks = [c for c in chunks if c.doc_id == doc_id]
|
||||||
|
if not doc_chunks:
|
||||||
|
doc_chunks = chunks[:15]
|
||||||
|
return "\n\n".join(c.text for c in doc_chunks[:15])
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_file(content: bytes, filename: str) -> str:
|
||||||
|
from app.shared.bootstrap import get_document_command_service
|
||||||
|
suffix = os.path.splitext(filename or "doc.pdf")[1] or ".pdf"
|
||||||
|
tmp_path = ""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
service = get_document_command_service()
|
||||||
|
parsed = service.parser.parse(file_path=tmp_path, doc_id="tmp_analysis", doc_name=filename)
|
||||||
|
if parsed.raw_text:
|
||||||
|
return parsed.raw_text[:4000]
|
||||||
|
return "\n".join(
|
||||||
|
b.get("text", "") for b in parsed.semantic_blocks[:30] if b.get("text")
|
||||||
|
)[:4000]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("File text extraction failed: {}", exc)
|
||||||
|
return ""
|
||||||
|
finally:
|
||||||
|
if tmp_path:
|
||||||
|
try: os.unlink(tmp_path)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
|
|
||||||
|
def split_into_clauses(text: str, client: "BaseLLMClient") -> list[str]:
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance analysis expert. Split the following text into 3-8 "
|
||||||
|
"semantically complete compliance clauses. Each clause should be an independent "
|
||||||
|
"compliance requirement or technical statement.\n"
|
||||||
|
"Return as JSON array of strings, e.g.:\n"
|
||||||
|
'["Clause one...", "Clause two..."]\n'
|
||||||
|
"Return ONLY the JSON array.\n\n"
|
||||||
|
f"Text:\n{text[:2000]}"
|
||||||
|
)
|
||||||
|
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1000)
|
||||||
|
if response.is_success:
|
||||||
|
try:
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, list):
|
||||||
|
clauses = [str(c).strip() for c in result if str(c).strip()]
|
||||||
|
if clauses:
|
||||||
|
return clauses[:8]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning("Clause split JSON parse failed, using fallback")
|
||||||
|
sentences = re.split(r"[.?!;\n]+", text)
|
||||||
|
return [s.strip() for s in sentences if len(s.strip()) > 20][:6]
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_for_clause(
|
||||||
|
clause: str,
|
||||||
|
retrieval_service: "KnowledgeRetrievalService",
|
||||||
|
top_k: int = 5,
|
||||||
|
domains: str | None = None,
|
||||||
|
) -> list["RetrievedChunk"]:
|
||||||
|
return retrieval_service.retrieve(query=clause, top_k=top_k, filters=domains)
|
||||||
|
|
||||||
|
|
||||||
|
def check_clause_compliance(
|
||||||
|
clause: str,
|
||||||
|
chunks: list["RetrievedChunk"],
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
) -> dict | None:
|
||||||
|
if not chunks:
|
||||||
|
return None
|
||||||
|
reg_context = "\n".join(
|
||||||
|
f"[{i+1}] {c.doc_title} {c.section_title or ''}: {c.text[:300]}"
|
||||||
|
for i, c in enumerate(chunks[:5])
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance expert. Judge whether the following business clause "
|
||||||
|
"complies with the retrieved regulations.\n\n"
|
||||||
|
f"Business clause:\n{clause}\n\n"
|
||||||
|
f"Retrieved regulations:\n{reg_context}\n\n"
|
||||||
|
"Return JSON:\n"
|
||||||
|
"{\n"
|
||||||
|
' "status": "ok" | "warn" | "risk",\n'
|
||||||
|
' "title": "Short finding title (max 30 chars)",\n'
|
||||||
|
' "desc": "Description (50-120 chars)",\n'
|
||||||
|
' "clause_ref": "Regulation clause reference e.g. Art.9.1 or Sec.3.1"\n'
|
||||||
|
"}\n"
|
||||||
|
"status: ok=compliant, warn=gap exists, risk=critical/missing\n"
|
||||||
|
"Return ONLY the JSON object."
|
||||||
|
)
|
||||||
|
response = client.chat([{"role": "user", "content": prompt}], max_tokens=500)
|
||||||
|
if not response.is_success:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, dict) and "status" in result:
|
||||||
|
return {
|
||||||
|
"title": str(result.get("title", "Compliance finding")),
|
||||||
|
"desc": str(result.get("desc", "")),
|
||||||
|
"status": result.get("status", "info"),
|
||||||
|
"clause_ref": result.get("clause_ref"),
|
||||||
|
}
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
logger.warning("Gap check JSON parse failed: {}", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize_conclusion(
|
||||||
|
para_text: str,
|
||||||
|
findings: list[dict],
|
||||||
|
client: "BaseLLMClient",
|
||||||
|
) -> dict:
|
||||||
|
if not findings:
|
||||||
|
return {
|
||||||
|
"conclusion": "No significant compliance gaps found. Continue monitoring regulation updates.",
|
||||||
|
"actions": [{"label": "Next action", "value": "Monitor regulation updates"}],
|
||||||
|
"risk_score": 10,
|
||||||
|
"highlight_terms": [],
|
||||||
|
"para_text": para_text[:800],
|
||||||
|
}
|
||||||
|
findings_text = "\n".join(
|
||||||
|
f"- [{f['status'].upper()}] {f['title']}: {f['desc']}"
|
||||||
|
for f in findings
|
||||||
|
)
|
||||||
|
prompt = (
|
||||||
|
"You are a compliance analysis expert. Generate a summary report "
|
||||||
|
"based on the following compliance findings.\n\n"
|
||||||
|
f"Original text (first 600 chars):\n{para_text[:600]}\n\n"
|
||||||
|
f"Findings:\n{findings_text}\n\n"
|
||||||
|
"Return JSON:\n"
|
||||||
|
"{\n"
|
||||||
|
' "conclusion": "Overall compliance conclusion (100-200 chars)",\n'
|
||||||
|
' "actions": [\n'
|
||||||
|
' {"label": "Action label", "value": "Description"},\n'
|
||||||
|
' {"label": "Priority", "value": "High/Medium/Low", "risk": true}\n'
|
||||||
|
' ],\n'
|
||||||
|
' "risk_score": 0-100 (integer, higher=riskier),\n'
|
||||||
|
' "highlight_terms": ["Key terms to highlight, max 10 terms"],\n'
|
||||||
|
' "para_text": "Original text or summary (max 600 chars)"\n'
|
||||||
|
"}\n"
|
||||||
|
"Return ONLY the JSON object."
|
||||||
|
)
|
||||||
|
response = client.chat([{"role": "user", "content": prompt}], max_tokens=1200)
|
||||||
|
fallback = {
|
||||||
|
"conclusion": "Compliance analysis complete. Review findings and create remediation plan.",
|
||||||
|
"actions": [
|
||||||
|
{"label": "Next action", "value": "Review critical findings"},
|
||||||
|
{"label": "Escalation", "value": "Legal review required", "risk": True},
|
||||||
|
],
|
||||||
|
"risk_score": 60,
|
||||||
|
"highlight_terms": [],
|
||||||
|
"para_text": para_text[:800],
|
||||||
|
}
|
||||||
|
if not response.is_success:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
result = _extract_json(response.content)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return {
|
||||||
|
"conclusion": str(result.get("conclusion", fallback["conclusion"])),
|
||||||
|
"actions": result.get("actions", fallback["actions"]),
|
||||||
|
"risk_score": int(result.get("risk_score", 60)),
|
||||||
|
"highlight_terms": result.get("highlight_terms", []),
|
||||||
|
"para_text": str(result.get("para_text", para_text[:800])),
|
||||||
|
}
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
logger.warning("Conclusion synthesis JSON parse failed: {}", exc)
|
||||||
|
return fallback
|
||||||
@@ -7,16 +7,22 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from app.config.settings import settings
|
||||||
|
|
||||||
from app.domain.documents import (
|
from app.domain.documents import (
|
||||||
ChunkBuilder,
|
ChunkBuilder,
|
||||||
Document,
|
Document,
|
||||||
|
DocumentArtifact,
|
||||||
DocumentBinaryStore,
|
DocumentBinaryStore,
|
||||||
DocumentParser,
|
DocumentParser,
|
||||||
|
DocumentProcessingRun,
|
||||||
|
DocumentProcessingStore,
|
||||||
DocumentRepository,
|
DocumentRepository,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
|
DocumentStatusEvent,
|
||||||
ParseArtifactStore,
|
ParseArtifactStore,
|
||||||
ParsedDocument,
|
ParsedDocument,
|
||||||
)
|
)
|
||||||
@@ -39,6 +45,7 @@ class DocumentProcessResult:
|
|||||||
|
|
||||||
class DocumentCommandService:
|
class DocumentCommandService:
|
||||||
"""Provide the Document Command Service service."""
|
"""Provide the Document Command Service service."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -49,6 +56,7 @@ class DocumentCommandService:
|
|||||||
embedding_provider: EmbeddingProvider,
|
embedding_provider: EmbeddingProvider,
|
||||||
vector_index: VectorIndex,
|
vector_index: VectorIndex,
|
||||||
parse_artifact_store: ParseArtifactStore | None = None,
|
parse_artifact_store: ParseArtifactStore | None = None,
|
||||||
|
document_processing_store: DocumentProcessingStore | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the Document Command Service instance."""
|
"""Initialize the Document Command Service instance."""
|
||||||
self.document_repository = document_repository
|
self.document_repository = document_repository
|
||||||
@@ -58,6 +66,11 @@ class DocumentCommandService:
|
|||||||
self.embedding_provider = embedding_provider
|
self.embedding_provider = embedding_provider
|
||||||
self.vector_index = vector_index
|
self.vector_index = vector_index
|
||||||
self.parse_artifact_store = parse_artifact_store
|
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]:
|
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."""
|
"""Persist parse artifacts so troubleshooting does not depend on provider retention windows."""
|
||||||
@@ -80,6 +93,143 @@ class DocumentCommandService:
|
|||||||
artifact_keys[name] = object_name
|
artifact_keys[name] = object_name
|
||||||
return artifact_keys
|
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(
|
def upload_and_process(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -91,11 +241,15 @@ class DocumentCommandService:
|
|||||||
regulation_type: str,
|
regulation_type: str,
|
||||||
version: str,
|
version: str,
|
||||||
generate_summary: bool,
|
generate_summary: bool,
|
||||||
|
trigger_type: str = "upload",
|
||||||
) -> DocumentProcessResult:
|
) -> DocumentProcessResult:
|
||||||
"""Handle upload and process for the Document Command Service instance."""
|
"""Handle upload and process for the Document Command Service instance."""
|
||||||
doc_id = doc_id or str(uuid.uuid4())[:8]
|
doc_id = doc_id or str(uuid.uuid4())[:8]
|
||||||
final_doc_name = doc_name or file_name
|
final_doc_name = doc_name or file_name
|
||||||
object_name = f"{doc_id}/{file_name}"
|
object_name = f"{doc_id}/{file_name}"
|
||||||
|
run_id: str | None = None
|
||||||
|
current_status = DocumentStatus.PENDING
|
||||||
|
current_stage = "store"
|
||||||
|
|
||||||
document = Document(
|
document = Document(
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
@@ -109,6 +263,19 @@ class DocumentCommandService:
|
|||||||
metadata={"generate_summary": generate_summary},
|
metadata={"generate_summary": generate_summary},
|
||||||
)
|
)
|
||||||
self.document_repository.create(document)
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
temp_path = ""
|
temp_path = ""
|
||||||
try:
|
try:
|
||||||
@@ -119,6 +286,17 @@ class DocumentCommandService:
|
|||||||
metadata={"doc_id": doc_id},
|
metadata={"doc_id": doc_id},
|
||||||
)
|
)
|
||||||
self.document_repository.update_status(doc_id, DocumentStatus.STORED)
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
suffix = os.path.splitext(file_name)[1]
|
suffix = os.path.splitext(file_name)[1]
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
||||||
@@ -130,7 +308,13 @@ class DocumentCommandService:
|
|||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
doc_name=final_doc_name,
|
doc_name=final_doc_name,
|
||||||
)
|
)
|
||||||
|
self._safe_mark_run_parsed(doc_id=doc_id, run_id=run_id, parsed_document=parsed_document)
|
||||||
|
|
||||||
|
artifact_keys: dict[str, str] = {}
|
||||||
|
try:
|
||||||
artifact_keys = self._save_parse_artifacts(doc_id=doc_id, parsed_document=parsed_document)
|
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(
|
self.document_repository.update_status(
|
||||||
doc_id,
|
doc_id,
|
||||||
DocumentStatus.PARSED,
|
DocumentStatus.PARSED,
|
||||||
@@ -146,6 +330,18 @@ class DocumentCommandService:
|
|||||||
"processing_stage": "parsed",
|
"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:
|
if self.parse_artifact_store:
|
||||||
try:
|
try:
|
||||||
self.parse_artifact_store.save(
|
self.parse_artifact_store.save(
|
||||||
@@ -165,6 +361,7 @@ class DocumentCommandService:
|
|||||||
raise ValueError("解析完成但没有生成可入库的 chunks")
|
raise ValueError("解析完成但没有生成可入库的 chunks")
|
||||||
|
|
||||||
vectors = self.embedding_provider.embed_texts([chunk.embedding_text for chunk in chunks])
|
vectors = self.embedding_provider.embed_texts([chunk.embedding_text for chunk in chunks])
|
||||||
|
current_stage = "index"
|
||||||
inserted = self.vector_index.upsert(chunks, vectors)
|
inserted = self.vector_index.upsert(chunks, vectors)
|
||||||
if inserted != len(chunks):
|
if inserted != len(chunks):
|
||||||
logger.warning("Milvus upsert count mismatched: inserted={}, chunks={}", inserted, len(chunks))
|
logger.warning("Milvus upsert count mismatched: inserted={}, chunks={}", inserted, len(chunks))
|
||||||
@@ -182,6 +379,23 @@ class DocumentCommandService:
|
|||||||
"processing_stage": "indexed",
|
"processing_stage": "indexed",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
current_status = DocumentStatus.INDEXED
|
||||||
|
index_name = health.get("collection_name", "")
|
||||||
|
self._safe_mark_run_indexed(
|
||||||
|
doc_id=doc_id,
|
||||||
|
run_id=run_id,
|
||||||
|
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)
|
stored = self.document_repository.get(doc_id)
|
||||||
return DocumentProcessResult(
|
return DocumentProcessResult(
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
@@ -194,6 +408,7 @@ class DocumentCommandService:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("文档处理失败: doc_id={}", doc_id)
|
logger.exception("文档处理失败: doc_id={}", doc_id)
|
||||||
|
failure_stage = current_stage
|
||||||
self.document_repository.update_status(
|
self.document_repository.update_status(
|
||||||
doc_id,
|
doc_id,
|
||||||
DocumentStatus.FAILED,
|
DocumentStatus.FAILED,
|
||||||
@@ -201,8 +416,23 @@ class DocumentCommandService:
|
|||||||
metadata={
|
metadata={
|
||||||
"failure_reason": str(exc),
|
"failure_reason": str(exc),
|
||||||
"processing_stage": "failed",
|
"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(
|
return DocumentProcessResult(
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
doc_name=final_doc_name,
|
doc_name=final_doc_name,
|
||||||
@@ -235,6 +465,11 @@ class DocumentCommandService:
|
|||||||
self.parse_artifact_store.delete(doc_id)
|
self.parse_artifact_store.delete(doc_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("ParseArtifactStore delete failed for doc_id={}", doc_id)
|
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)
|
self.document_repository.delete(doc_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -253,6 +488,7 @@ class DocumentCommandService:
|
|||||||
regulation_type=document.regulation_type,
|
regulation_type=document.regulation_type,
|
||||||
version=document.version,
|
version=document.version,
|
||||||
generate_summary=bool(document.metadata.get("generate_summary", False)),
|
generate_summary=bool(document.metadata.get("generate_summary", False)),
|
||||||
|
trigger_type="retry",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -272,7 +508,7 @@ class DocumentQueryService:
|
|||||||
"""Return documents with real-time state from Milvus as the authoritative source.
|
"""Return documents with real-time state from Milvus as the authoritative source.
|
||||||
|
|
||||||
Algorithm:
|
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.
|
2. Load JSON/PG metadata records and index them by doc_id.
|
||||||
3. Merge: Milvus-present docs get status=INDEXED and live chunk_count;
|
3. Merge: Milvus-present docs get status=INDEXED and live chunk_count;
|
||||||
metadata-only docs with status=INDEXED are demoted to FAILED.
|
metadata-only docs with status=INDEXED are demoted to FAILED.
|
||||||
@@ -300,8 +536,8 @@ class DocumentQueryService:
|
|||||||
doc.chunk_count = row["chunk_count"]
|
doc.chunk_count = row["chunk_count"]
|
||||||
doc.status = DocumentStatus.INDEXED
|
doc.status = DocumentStatus.INDEXED
|
||||||
# Backfill fields that may be missing from older JSON records.
|
# Backfill fields that may be missing from older JSON records.
|
||||||
if not doc.doc_name and row.get("doc_name"):
|
if not doc.doc_name and row.get("doc_title"):
|
||||||
doc.doc_name = row["doc_name"]
|
doc.doc_name = row["doc_title"]
|
||||||
if not doc.regulation_type and row.get("regulation_type"):
|
if not doc.regulation_type and row.get("regulation_type"):
|
||||||
doc.regulation_type = row["regulation_type"]
|
doc.regulation_type = row["regulation_type"]
|
||||||
if not doc.version and row.get("version"):
|
if not doc.version and row.get("version"):
|
||||||
@@ -317,8 +553,8 @@ class DocumentQueryService:
|
|||||||
if doc_id not in meta_by_id:
|
if doc_id not in meta_by_id:
|
||||||
synthetic = Document(
|
synthetic = Document(
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
doc_name=row.get("doc_name", doc_id),
|
doc_name=row.get("doc_title", doc_id),
|
||||||
file_name=row.get("doc_name", doc_id),
|
file_name=row.get("doc_title", doc_id),
|
||||||
object_name="",
|
object_name="",
|
||||||
content_type="",
|
content_type="",
|
||||||
size_bytes=0,
|
size_bytes=0,
|
||||||
|
|||||||
@@ -29,11 +29,16 @@ def _reciprocal_rank_fusion(
|
|||||||
RetrievedChunk(
|
RetrievedChunk(
|
||||||
chunk_id=chunk_map[ck].chunk_id,
|
chunk_id=chunk_map[ck].chunk_id,
|
||||||
doc_id=chunk_map[ck].doc_id,
|
doc_id=chunk_map[ck].doc_id,
|
||||||
doc_name=chunk_map[ck].doc_name,
|
doc_title=chunk_map[ck].doc_title,
|
||||||
content=chunk_map[ck].content,
|
text=chunk_map[ck].text,
|
||||||
score=scores[ck],
|
score=scores[ck],
|
||||||
|
chunk_type=chunk_map[ck].chunk_type,
|
||||||
section_title=chunk_map[ck].section_title,
|
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,
|
metadata=chunk_map[ck].metadata,
|
||||||
)
|
)
|
||||||
for ck in sorted_keys
|
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."""
|
||||||
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.mock_event_store import MockEventStore
|
||||||
|
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: MockEventStore,
|
||||||
|
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.
|
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||||
milvus_host: str = Field(default="6.86.80.8", description="Milvus服务地址")
|
milvus_host: str = Field(default="6.86.80.8", description="Milvus服务地址")
|
||||||
milvus_port: int = Field(default=19530, 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数据库名称")
|
milvus_db_name: str = Field(default="default", description="Milvus数据库名称")
|
||||||
|
|
||||||
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
# Keep configuration setup explicit so runtime behavior is easy to reason about.
|
||||||
@@ -78,6 +78,7 @@ class Settings(BaseSettings):
|
|||||||
chunk_overlap: int = Field(default=50, description="分块重叠大小")
|
chunk_overlap: int = Field(default=50, description="分块重叠大小")
|
||||||
max_file_size_mb: int = Field(default=100, description="最大文件大小(MB)")
|
max_file_size_mb: int = Field(default=100, description="最大文件大小(MB)")
|
||||||
document_metadata_path: str = Field(default="backend/data/documents.json", description="文档元数据存储路径")
|
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)")
|
parser_backend: str = Field(default="aliyun", description="解析后端(local/aliyun)")
|
||||||
chunk_backend: str = Field(default="aliyun", description="分块后端(local/aliyun)")
|
chunk_backend: str = Field(default="aliyun", description="分块后端(local/aliyun)")
|
||||||
document_repository_backend: str = Field(default="json", description="文档元数据存储后端 (json/postgres)")
|
document_repository_backend: str = Field(default="json", description="文档元数据存储后端 (json/postgres)")
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
|||||||
# Milvus
|
# Milvus
|
||||||
milvus_host: str = "6.86.80.8"
|
milvus_host: str = "6.86.80.8"
|
||||||
milvus_port: int = 19530
|
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 / embedding defaults aligned with the migrated backend path.
|
||||||
llm_model: str = "qwen-max"
|
llm_model: str = "qwen-max"
|
||||||
@@ -47,7 +47,7 @@ class Settings(BaseSettings):
|
|||||||
api_port: int = 8000
|
api_port: int = 8000
|
||||||
|
|
||||||
# Legacy aliases retained for old utility modules.
|
# 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"
|
compliance_collection: str = "compliance_cache"
|
||||||
|
|
||||||
# Preserve the legacy module API while keeping env resolution centralized at the repo root.
|
# Preserve the legacy module API while keeping env resolution centralized at the repo root.
|
||||||
|
|||||||
@@ -8,18 +8,91 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(init=False)
|
||||||
class AnswerSource:
|
class AnswerSource:
|
||||||
"""Represent answer source data."""
|
"""Represent answer source data with legacy aliases."""
|
||||||
|
|
||||||
doc_id: str
|
doc_id: str
|
||||||
doc_name: str
|
doc_title: str
|
||||||
chunk_id: str
|
chunk_id: str
|
||||||
|
chunk_type: str
|
||||||
section_title: str
|
section_title: str
|
||||||
page_number: int
|
page_start: int
|
||||||
|
page_end: int
|
||||||
|
section_level: int
|
||||||
|
chunk_index: int
|
||||||
|
piece_index: int
|
||||||
score: float
|
score: float
|
||||||
content: str
|
text: str
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
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
|
@dataclass
|
||||||
class ConversationMessage:
|
class ConversationMessage:
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
"""Initialize the app.domain.documents package."""
|
"""Initialize the app.domain.documents package."""
|
||||||
|
|
||||||
from .models import Chunk, Document, DocumentStatus, ParsedDocument
|
from .models import Chunk, Document, DocumentArtifact, DocumentProcessingRun, DocumentStatus, DocumentStatusEvent, ParsedDocument
|
||||||
from .ports import ChunkBuilder, DocumentBinaryStore, DocumentParser, DocumentRepository, ParseArtifactStore
|
from .ports import (
|
||||||
|
ChunkBuilder,
|
||||||
|
DocumentBinaryStore,
|
||||||
|
DocumentParser,
|
||||||
|
DocumentProcessingStore,
|
||||||
|
DocumentRepository,
|
||||||
|
ParseArtifactStore,
|
||||||
|
)
|
||||||
# Keep package boundaries explicit so backend imports stay predictable.
|
# Keep package boundaries explicit so backend imports stay predictable.
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Chunk",
|
"Chunk",
|
||||||
"Document",
|
"Document",
|
||||||
|
"DocumentArtifact",
|
||||||
|
"DocumentProcessingRun",
|
||||||
"DocumentStatus",
|
"DocumentStatus",
|
||||||
|
"DocumentStatusEvent",
|
||||||
"ParsedDocument",
|
"ParsedDocument",
|
||||||
"ChunkBuilder",
|
"ChunkBuilder",
|
||||||
"DocumentBinaryStore",
|
"DocumentBinaryStore",
|
||||||
"DocumentParser",
|
"DocumentParser",
|
||||||
|
"DocumentProcessingStore",
|
||||||
"DocumentRepository",
|
"DocumentRepository",
|
||||||
"ParseArtifactStore",
|
"ParseArtifactStore",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -60,19 +60,171 @@ class ParsedDocument:
|
|||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(init=False)
|
||||||
class Chunk:
|
class Chunk:
|
||||||
"""Represent the Chunk type."""
|
"""Represent one retrieval chunk with backward-compatible aliases."""
|
||||||
|
|
||||||
chunk_id: str
|
chunk_id: str
|
||||||
doc_id: str
|
doc_id: str
|
||||||
doc_name: str
|
doc_title: str
|
||||||
content: str
|
text: str
|
||||||
embedding_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_title: str = ""
|
||||||
section_path: list[str] = field(default_factory=list)
|
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 = ""
|
regulation_type: str = ""
|
||||||
version: str = ""
|
version: str = ""
|
||||||
semantic_id: str = ""
|
semantic_id: str = ""
|
||||||
block_type: str = ""
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
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 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.
|
# 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]:
|
def get_structure_nodes(self, doc_id: str) -> list[dict]:
|
||||||
"""Return all structure nodes for a document."""
|
"""Return all structure nodes for a document."""
|
||||||
pass
|
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
|
filters: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(init=False)
|
||||||
class RetrievedChunk:
|
class RetrievedChunk:
|
||||||
"""Represent the Retrieved Chunk type."""
|
"""Represent the retrieved chunk payload with legacy aliases."""
|
||||||
|
|
||||||
chunk_id: str
|
chunk_id: str
|
||||||
doc_id: str
|
doc_id: str
|
||||||
doc_name: str
|
doc_title: str
|
||||||
content: str
|
text: str
|
||||||
score: float
|
score: float
|
||||||
|
chunk_type: str = ""
|
||||||
section_title: 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)
|
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)
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
context_tokens = 0
|
context_tokens = 0
|
||||||
for idx, chunk in enumerate(retrieved_chunks, start=1):
|
for idx, chunk in enumerate(retrieved_chunks, start=1):
|
||||||
block = (
|
block = (
|
||||||
f"[{idx}] 文档: {chunk.doc_name}\n"
|
f"[{idx}] 文档: {chunk.doc_title}\n"
|
||||||
f"章节: {chunk.section_title or '未标注'}\n"
|
f"章节: {chunk.section_title or '未标注'}\n"
|
||||||
f"页码: {chunk.page_number}\n"
|
f"页码: {chunk.page_start}" + (f"-{chunk.page_end}" if chunk.page_end and chunk.page_end != chunk.page_start else "") + "\n"
|
||||||
f"内容: {chunk.content}"
|
f"内容: {chunk.text}"
|
||||||
)
|
)
|
||||||
block_tokens = self._estimate_tokens(block)
|
block_tokens = self._estimate_tokens(block)
|
||||||
if context_tokens + block_tokens > settings.rag_max_context_tokens:
|
if context_tokens + block_tokens > settings.rag_max_context_tokens:
|
||||||
@@ -67,17 +67,37 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
)
|
)
|
||||||
return messages, context_tokens
|
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]:
|
def _sources(self, chunks: list[RetrievedChunk]) -> list[AnswerSource]:
|
||||||
"""Handle sources for this module for the Open A I Compatible Answer Generator instance."""
|
"""Handle sources for this module for the Open A I Compatible Answer Generator instance."""
|
||||||
return [
|
return [
|
||||||
AnswerSource(
|
AnswerSource(
|
||||||
doc_id=chunk.doc_id,
|
doc_id=chunk.doc_id,
|
||||||
doc_name=chunk.doc_name,
|
doc_title=chunk.doc_title,
|
||||||
chunk_id=chunk.chunk_id,
|
chunk_id=chunk.chunk_id,
|
||||||
|
chunk_type=chunk.chunk_type,
|
||||||
section_title=chunk.section_title,
|
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,
|
score=chunk.score,
|
||||||
content=chunk.content,
|
text=chunk.text,
|
||||||
metadata=chunk.metadata,
|
metadata=chunk.metadata,
|
||||||
)
|
)
|
||||||
for chunk in chunks
|
for chunk in chunks
|
||||||
@@ -111,7 +131,10 @@ class OpenAICompatibleAnswerGenerator(AnswerGenerator):
|
|||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
retrieved_count=len(retrieved_chunks),
|
retrieved_count=len(retrieved_chunks),
|
||||||
context_tokens=context_tokens,
|
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,
|
error=response.error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class LocalRegulationChunkBuilder(ChunkBuilder):
|
|||||||
"""Adapt the existing markdown chunker to the new chunk builder port."""
|
"""Adapt the existing markdown chunker to the new chunk builder port."""
|
||||||
|
|
||||||
def __init__(self, *, chunk_size: int = 512, chunk_overlap: int = 50) -> None:
|
def __init__(self, *, chunk_size: int = 512, chunk_overlap: int = 50) -> None:
|
||||||
|
"""Initialize the local markdown chunk builder."""
|
||||||
self.chunker = RegulationChunker(
|
self.chunker = RegulationChunker(
|
||||||
chunk_size=chunk_size,
|
chunk_size=chunk_size,
|
||||||
chunk_overlap=chunk_overlap,
|
chunk_overlap=chunk_overlap,
|
||||||
@@ -22,6 +23,7 @@ class LocalRegulationChunkBuilder(ChunkBuilder):
|
|||||||
regulation_type: str,
|
regulation_type: str,
|
||||||
version: str,
|
version: str,
|
||||||
) -> list[Chunk]:
|
) -> list[Chunk]:
|
||||||
|
"""Build migrated chunk objects from the legacy markdown chunker output."""
|
||||||
markdown_text = parsed_document.raw_text.strip()
|
markdown_text = parsed_document.raw_text.strip()
|
||||||
if not markdown_text:
|
if not markdown_text:
|
||||||
return []
|
return []
|
||||||
@@ -50,16 +52,18 @@ class LocalRegulationChunkBuilder(ChunkBuilder):
|
|||||||
Chunk(
|
Chunk(
|
||||||
chunk_id=item.metadata.chunk_id,
|
chunk_id=item.metadata.chunk_id,
|
||||||
doc_id=parsed_document.doc_id,
|
doc_id=parsed_document.doc_id,
|
||||||
doc_name=parsed_document.doc_name,
|
doc_title=parsed_document.doc_name,
|
||||||
content=item.content,
|
text=item.content,
|
||||||
embedding_text=item.content,
|
embedding_text=item.content,
|
||||||
|
chunk_type="local_markdown_chunk",
|
||||||
section_title=item.metadata.section_title or item.metadata.section_number,
|
section_title=item.metadata.section_title or item.metadata.section_number,
|
||||||
section_path=section_path,
|
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,
|
regulation_type=regulation_type,
|
||||||
version=version,
|
version=version,
|
||||||
semantic_id=item.metadata.clause_number,
|
semantic_id=item.metadata.clause_number,
|
||||||
block_type="local_markdown_chunk",
|
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,29 +19,35 @@ class AliyunVectorChunkBuilder(ChunkBuilder):
|
|||||||
"""Handle build for the Aliyun Vector Chunk Builder instance."""
|
"""Handle build for the Aliyun Vector Chunk Builder instance."""
|
||||||
chunks: list[Chunk] = []
|
chunks: list[Chunk] = []
|
||||||
for index, item in enumerate(parsed_document.vector_chunks):
|
for index, item in enumerate(parsed_document.vector_chunks):
|
||||||
content = item.get("content") or item.get("text") or ""
|
text = item.get("text") or ""
|
||||||
embedding_text = item.get("embedding_text") or content
|
embedding_text = item.get("embedding_text") or text
|
||||||
if not embedding_text.strip():
|
if not embedding_text.strip():
|
||||||
continue
|
continue
|
||||||
section_path = item.get("section_path") or []
|
section_path = item.get("section_path") or []
|
||||||
section_title = item.get("section_title") or (section_path[-1] if section_path else "")
|
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}"
|
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(
|
chunks.append(
|
||||||
Chunk(
|
Chunk(
|
||||||
chunk_id=str(chunk_id),
|
chunk_id=str(chunk_id),
|
||||||
doc_id=parsed_document.doc_id,
|
doc_id=parsed_document.doc_id,
|
||||||
doc_name=parsed_document.doc_name,
|
doc_title=str(item.get("doc_title") or parsed_document.doc_name),
|
||||||
content=content,
|
text=text,
|
||||||
embedding_text=embedding_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_title=section_title,
|
||||||
section_path=section_path,
|
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,
|
regulation_type=regulation_type,
|
||||||
version=version,
|
version=version,
|
||||||
semantic_id=item.get("semantic_id", ""),
|
semantic_id=item.get("semantic_id", ""),
|
||||||
block_type=item.get("block_type", ""),
|
|
||||||
metadata=metadata,
|
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."""
|
||||||
421
backend/app/infrastructure/perception/mock_event_store.py
Normal file
421
backend/app/infrastructure/perception/mock_event_store.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""Mock regulatory event store with 20 high-quality pre-seeded events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MOCK_EVENTS: list[dict[str, Any]] = [
|
||||||
|
# ------------------------------------------------------------------ HIGH
|
||||||
|
{
|
||||||
|
"id": "evt-001",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "GB 18384-2025",
|
||||||
|
"title": "《电动汽车安全要求》国家标准第三版正式发布",
|
||||||
|
"summary": (
|
||||||
|
"新增 IP67 级别高压系统密封防护要求;热失控预警响应时间压缩至 5 分钟;"
|
||||||
|
"调整碰撞安全测试工况,新增侧柱碰工况。本标准于 2026 年 7 月 1 日强制实施。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2025-11-15",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "电动汽车安全",
|
||||||
|
"tags": ["电池安全", "高压防护", "碰撞安全", "热失控"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-002",
|
||||||
|
"source": "UN-ECE",
|
||||||
|
"source_label": "联合国欧洲经委会",
|
||||||
|
"standard_code": "UN R155 Amendment 3",
|
||||||
|
"title": "UN-ECE R155 网络安全法规第三次修订正式生效",
|
||||||
|
"summary": (
|
||||||
|
"新增对 OTA(空中升级)全生命周期的安全审计要求;强化车辆 TARA"
|
||||||
|
"(威胁分析与风险评估)文档化义务;扩展 CSMS 监控范围至售后服务商。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-01-20",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "网络安全",
|
||||||
|
"tags": ["OTA", "网络安全", "CSMS", "TARA", "R155"],
|
||||||
|
"source_url": "https://unece.org/transport/vehicle-regulations",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-003",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "GB/T 40429-2026(征求意见稿)",
|
||||||
|
"title": "《汽车整车信息安全技术要求》修订征求意见",
|
||||||
|
"summary": (
|
||||||
|
"增加基于人工智能的异常行为检测要求;新增车云通信双向认证机制规范;"
|
||||||
|
"提出数据最小化原则在车辆 OBD 数据收集中的应用细则。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-03-05",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "信息安全",
|
||||||
|
"tags": ["信息安全", "数据安全", "AI检测", "OBD"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-004",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "NEV 双积分 2026",
|
||||||
|
"title": "2026 年度新能源汽车双积分管理办法年度调整",
|
||||||
|
"summary": (
|
||||||
|
"纯电动乘用车标准车型积分(CAFC)基准值上调 8%;"
|
||||||
|
"提高 A 级及以上续航里程门槛;新增氢燃料电池商用车积分计算细则。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-02-28",
|
||||||
|
"effective_at": "2026-04-01",
|
||||||
|
"category": "新能源政策",
|
||||||
|
"tags": ["双积分", "纯电动", "燃料电池", "碳配额"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-017",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "智能网联汽车准入管理办法实施细则",
|
||||||
|
"title": "智能网联汽车准入管理实施细则正式落地",
|
||||||
|
"summary": (
|
||||||
|
"明确 L3 及以上自动驾驶功能的准入申报路径;"
|
||||||
|
"要求 OEM 建立数据安全管理体系并完成等保 2.0 三级认证;"
|
||||||
|
"道路测试数据留存期延长至 3 年。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-03-01",
|
||||||
|
"effective_at": "2026-09-01",
|
||||||
|
"category": "智能网联",
|
||||||
|
"tags": ["智能网联", "L3自动驾驶", "准入管理", "数据留存"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-018",
|
||||||
|
"source": "EUR-Lex",
|
||||||
|
"source_label": "欧盟官方公报",
|
||||||
|
"standard_code": "EU Cyber Resilience Act (CRA)",
|
||||||
|
"title": "《欧盟网络韧性法案》核心条款对车联网设备生效",
|
||||||
|
"summary": (
|
||||||
|
"联网汽车 ECU 须满足 CRA「重要类 II」安全要求;"
|
||||||
|
"强制 SBOM(软件物料清单)公开披露;"
|
||||||
|
"OEM 须提供至少 10 年的漏洞修复支持承诺。"
|
||||||
|
),
|
||||||
|
"impact_level": "high",
|
||||||
|
"published_at": "2026-02-15",
|
||||||
|
"effective_at": "2027-01-01",
|
||||||
|
"category": "网络安全",
|
||||||
|
"tags": ["CRA", "SBOM", "漏洞管理", "网络韧性"],
|
||||||
|
"source_url": "https://eur-lex.europa.eu",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
# --------------------------------------------------------------- MEDIUM
|
||||||
|
{
|
||||||
|
"id": "evt-005",
|
||||||
|
"source": "UN-ECE",
|
||||||
|
"source_label": "联合国欧洲经委会",
|
||||||
|
"standard_code": "UN R156 Amendment 2",
|
||||||
|
"title": "UN-ECE R156 软件升级与 SUMS 法规补充修订",
|
||||||
|
"summary": (
|
||||||
|
"明确 SUMS(软件更新管理系统)对 ECU 版本追溯的最低保留年限为 15 年;"
|
||||||
|
"新增售后 OTA 推送的用户知情同意要求规范。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-01-10",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "软件升级",
|
||||||
|
"tags": ["OTA", "SUMS", "软件版本", "R156"],
|
||||||
|
"source_url": "https://unece.org/transport/vehicle-regulations",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-006",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB/T 35273-2026",
|
||||||
|
"title": "《信息安全技术 个人信息安全规范》更新版发布",
|
||||||
|
"summary": (
|
||||||
|
"将车内人脸识别、声纹采集列为敏感个人信息;"
|
||||||
|
"补充自动驾驶场景下乘员行为数据的去标识化技术规范;"
|
||||||
|
"强化数据出境安全评估触发阈值。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2025-12-01",
|
||||||
|
"effective_at": "2026-06-01",
|
||||||
|
"category": "数据安全",
|
||||||
|
"tags": ["个人信息", "PIPL", "数据安全", "生物识别"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-007",
|
||||||
|
"source": "EUR-Lex",
|
||||||
|
"source_label": "欧盟官方公报",
|
||||||
|
"standard_code": "EU AI Act — Art.13 & Art.14",
|
||||||
|
"title": "《欧盟人工智能法案》第13-14条透明度与人工监督条款正式生效",
|
||||||
|
"summary": (
|
||||||
|
"要求在汽车 ADAS 系统中植入 AI 使用记录日志;"
|
||||||
|
"驾驶员监控 AI 系统须披露决策逻辑;"
|
||||||
|
"高风险 AI 系统需提供人工干预接口。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-02-01",
|
||||||
|
"effective_at": "2026-08-01",
|
||||||
|
"category": "AI 法规",
|
||||||
|
"tags": ["AI法案", "透明度", "ADAS", "高风险AI"],
|
||||||
|
"source_url": "https://eur-lex.europa.eu",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-008",
|
||||||
|
"source": "ISO",
|
||||||
|
"source_label": "国际标准化组织",
|
||||||
|
"standard_code": "ISO 45001:2025 Amd.1",
|
||||||
|
"title": "ISO 45001 职业健康安全管理体系第一次修正",
|
||||||
|
"summary": (
|
||||||
|
"新增心理健康风险纳入 OHS 危害辨识范围;"
|
||||||
|
"明确远程办公人员安全管理职责;"
|
||||||
|
"更新绩效评价指标体系,新增事故未遂事件统计要求。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2025-10-20",
|
||||||
|
"effective_at": "2026-01-01",
|
||||||
|
"category": "EHS 管理",
|
||||||
|
"tags": ["ISO 45001", "EHS", "职业健康", "安全管理"],
|
||||||
|
"source_url": "https://www.iso.org",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-009",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "GB/T 28001-2026(征求意见)",
|
||||||
|
"title": "《汽车产品安全召回管理规程》修订征求意见",
|
||||||
|
"summary": (
|
||||||
|
"扩展召回触发条件,将 OTA 推送导致的功能异常纳入强制报告范围;"
|
||||||
|
"缩短重大安全隐患召回启动时限至 15 个工作日。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-03-15",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "召回管理",
|
||||||
|
"tags": ["召回", "OTA", "安全隐患", "产品安全"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-010",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB 38031-2025",
|
||||||
|
"title": "《电动汽车用动力蓄电池安全要求》修订版发布",
|
||||||
|
"summary": (
|
||||||
|
"新增电池系统针刺、浸水、挤压等极端工况测试程序;"
|
||||||
|
"热扩散防护等级要求升级;"
|
||||||
|
"强化 BMS(电池管理系统)状态监测数据记录要求。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2025-09-15",
|
||||||
|
"effective_at": "2026-03-01",
|
||||||
|
"category": "电池安全",
|
||||||
|
"tags": ["动力电池", "BMS", "热扩散", "安全测试"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-016",
|
||||||
|
"source": "UN-ECE",
|
||||||
|
"source_label": "联合国欧洲经委会",
|
||||||
|
"standard_code": "UN R100 Rev.4(草案)",
|
||||||
|
"title": "UN R100 电动汽车安全认证法规第四次修订草案发布",
|
||||||
|
"summary": (
|
||||||
|
"拟对 400V 以上高压系统的绝缘电阻监测提出实时 CAN 总线传输要求;"
|
||||||
|
"新增极低温工况(-40°C)的电池性能验证程序。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-04-08",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "电动汽车安全",
|
||||||
|
"tags": ["R100", "高压安全", "绝缘监测", "低温性能"],
|
||||||
|
"source_url": "https://unece.org/transport/vehicle-regulations",
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-019",
|
||||||
|
"source": "ISO",
|
||||||
|
"source_label": "国际标准化组织",
|
||||||
|
"standard_code": "ISO/SAE 21434:2026 Amd.1",
|
||||||
|
"title": "ISO/SAE 21434 汽车网络安全工程第一次修正",
|
||||||
|
"summary": (
|
||||||
|
"将 AI 推理组件纳入汽车网络安全工程范围;"
|
||||||
|
"补充端到端加密通信在 V2X 场景中的 TARA 建模要求;"
|
||||||
|
"新增第三方 ECU 供应商 CSMS 审计方法。"
|
||||||
|
),
|
||||||
|
"impact_level": "medium",
|
||||||
|
"published_at": "2026-04-10",
|
||||||
|
"effective_at": "2026-10-01",
|
||||||
|
"category": "网络安全",
|
||||||
|
"tags": ["ISO 21434", "网络安全", "V2X", "AI安全"],
|
||||||
|
"source_url": "https://www.iso.org",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
# ------------------------------------------------------------------ LOW
|
||||||
|
{
|
||||||
|
"id": "evt-011",
|
||||||
|
"source": "ISO",
|
||||||
|
"source_label": "国际标准化组织",
|
||||||
|
"standard_code": "ISO 26262:2026 Ed.3(征求意见)",
|
||||||
|
"title": "ISO 26262 功能安全第三版征求意见启动",
|
||||||
|
"summary": (
|
||||||
|
"拟新增对 AI/ML 组件功能安全验证方法的指导附录;"
|
||||||
|
"讨论 SOTIF(预期功能安全)与 ISO 26262 的协调融合路径。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-04-01",
|
||||||
|
"effective_at": None,
|
||||||
|
"category": "功能安全",
|
||||||
|
"tags": ["功能安全", "ASIL", "AI安全", "SOTIF"],
|
||||||
|
"source_url": "https://www.iso.org",
|
||||||
|
"status": "consultation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-012",
|
||||||
|
"source": "EUR-Lex",
|
||||||
|
"source_label": "欧盟官方公报",
|
||||||
|
"standard_code": "REACH Regulation Update 2026",
|
||||||
|
"title": "欧盟 REACH 法规限制物质清单更新(第 22 批)",
|
||||||
|
"summary": (
|
||||||
|
"新增 3 种 SVHCs(高度关注物质),包括特定阻燃剂和密封材料成分;"
|
||||||
|
"汽车零部件豁免条款调整,影响部分内饰材料供应商。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-01-30",
|
||||||
|
"effective_at": "2026-09-01",
|
||||||
|
"category": "环保法规",
|
||||||
|
"tags": ["REACH", "SVHCs", "环保", "化学品管理"],
|
||||||
|
"source_url": "https://eur-lex.europa.eu",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-013",
|
||||||
|
"source": "MIIT",
|
||||||
|
"source_label": "工业和信息化部",
|
||||||
|
"standard_code": "CCER 汽车碳配额 2026",
|
||||||
|
"title": "自愿减排(CCER)汽车行业核算方法学更新",
|
||||||
|
"summary": (
|
||||||
|
"更新纯电动汽车全生命周期碳排放核算边界;"
|
||||||
|
"新增动力电池回收环节碳减排量认定方法;"
|
||||||
|
"与全国碳市场对接的企业碳账户数据接口规范发布。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-02-10",
|
||||||
|
"effective_at": "2026-06-01",
|
||||||
|
"category": "碳排放",
|
||||||
|
"tags": ["CCER", "碳排放", "碳中和", "碳核算"],
|
||||||
|
"source_url": "https://www.miit.gov.cn/",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-014",
|
||||||
|
"source": "IATF",
|
||||||
|
"source_label": "国际汽车工作组",
|
||||||
|
"standard_code": "IATF 16949:2025 CSR 通告",
|
||||||
|
"title": "IATF 16949 质量管理体系客户特殊要求更新通告",
|
||||||
|
"summary": (
|
||||||
|
"多家主机厂(OEM)同步更新 CSR,涵盖软件定义汽车(SDV)"
|
||||||
|
"场景下的质量过程管控;电子电气 BOM 变更管理流程补充规范。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-03-20",
|
||||||
|
"effective_at": "2026-07-01",
|
||||||
|
"category": "质量管理",
|
||||||
|
"tags": ["IATF 16949", "质量管理", "SDV", "CSR"],
|
||||||
|
"source_url": "https://www.iatfglobaloversight.org",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-015",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB 7258-2025 勘误",
|
||||||
|
"title": "《机动车运行安全技术条件》年度勘误发布",
|
||||||
|
"summary": (
|
||||||
|
"更正第 12 章灯光系统技术要求中的参数引用错误;"
|
||||||
|
"澄清前雾灯安装位置尺寸定义;此次为勘误性修订,不影响已认证车型。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-01-05",
|
||||||
|
"effective_at": "2026-01-05",
|
||||||
|
"category": "运行安全",
|
||||||
|
"tags": ["GB 7258", "灯光", "运行安全", "勘误"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-020",
|
||||||
|
"source": "国标委",
|
||||||
|
"source_label": "国家标准化管理委员会",
|
||||||
|
"standard_code": "GB/T 27930-2026",
|
||||||
|
"title": "《电动汽车非车载传导式充电通信协议》更新版发布",
|
||||||
|
"summary": (
|
||||||
|
"兼容 CHAdeMO 4.0 与 CCS2 双协议栈;"
|
||||||
|
"新增大功率充电(>350kW)通信握手流程;"
|
||||||
|
"强化充电过程 BMS 实时诊断数据上报规范。"
|
||||||
|
),
|
||||||
|
"impact_level": "low",
|
||||||
|
"published_at": "2026-03-25",
|
||||||
|
"effective_at": "2026-12-01",
|
||||||
|
"category": "充电标准",
|
||||||
|
"tags": ["充电协议", "BMS", "大功率充电", "CHAdeMO"],
|
||||||
|
"source_url": "https://openstd.samr.gov.cn",
|
||||||
|
"status": "enacted",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Index for fast lookup
|
||||||
|
_EVENT_INDEX: dict[str, dict] = {e["id"]: e for e in MOCK_EVENTS}
|
||||||
|
|
||||||
|
|
||||||
|
class MockEventStore:
|
||||||
|
"""In-memory mock store for regulatory events."""
|
||||||
|
|
||||||
|
def all(self) -> list[dict]:
|
||||||
|
return list(MOCK_EVENTS)
|
||||||
|
|
||||||
|
def get(self, event_id: str) -> dict | None:
|
||||||
|
return _EVENT_INDEX.get(event_id)
|
||||||
|
|
||||||
|
def filter(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
impact_level: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict]:
|
||||||
|
events = list(MOCK_EVENTS)
|
||||||
|
if source:
|
||||||
|
events = [e for e in events if e["source"] == source]
|
||||||
|
if impact_level:
|
||||||
|
events = [e for e in events if e["impact_level"] == impact_level]
|
||||||
|
events.sort(key=lambda e: e["published_at"], reverse=True)
|
||||||
|
return events[:limit]
|
||||||
|
|
||||||
|
def stats(self) -> dict:
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
events = MOCK_EVENTS
|
||||||
|
cutoff = (date.today() - timedelta(days=90)).isoformat()
|
||||||
|
return {
|
||||||
|
"total": len(events),
|
||||||
|
"high_impact": sum(1 for e in events if e["impact_level"] == "high"),
|
||||||
|
"medium_impact": sum(1 for e in events if e["impact_level"] == "medium"),
|
||||||
|
"low_impact": sum(1 for e in events if e["impact_level"] == "low"),
|
||||||
|
"recent_90d": sum(1 for e in events if e["published_at"] >= cutoff),
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"""Implement infrastructure support for json document processing history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.domain.documents import DocumentArtifact, DocumentProcessingRun, DocumentProcessingStore, DocumentStatusEvent
|
||||||
|
# Keep JSON persistence behavior aligned with the lightweight document repository adapter.
|
||||||
|
|
||||||
|
|
||||||
|
class JsonDocumentProcessingStore(DocumentProcessingStore):
|
||||||
|
"""Persist processing history in a standalone JSON file."""
|
||||||
|
|
||||||
|
def __init__(self, file_path: str) -> None:
|
||||||
|
"""Initialize the JSON processing history store."""
|
||||||
|
self.file_path = Path(file_path)
|
||||||
|
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not self.file_path.exists():
|
||||||
|
self._save(self._empty_payload())
|
||||||
|
|
||||||
|
def _empty_payload(self) -> dict[str, dict[str, dict[str, Any]]]:
|
||||||
|
"""Return the canonical empty JSON structure for processing history."""
|
||||||
|
return {"runs": {}, "status_events": {}, "artifacts": {}}
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, dict[str, dict[str, Any]]]:
|
||||||
|
"""Load the full JSON payload and normalize missing sections."""
|
||||||
|
if not self.file_path.exists():
|
||||||
|
return self._empty_payload()
|
||||||
|
payload = json.loads(self.file_path.read_text(encoding="utf-8") or "{}")
|
||||||
|
normalized = self._empty_payload()
|
||||||
|
for key in normalized:
|
||||||
|
section = payload.get(key, {})
|
||||||
|
normalized[key] = section if isinstance(section, dict) else {}
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _save(self, payload: dict[str, dict[str, dict[str, Any]]]) -> None:
|
||||||
|
"""Persist the full JSON payload with stable formatting."""
|
||||||
|
self.file_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def _serialize_datetime(self, value: datetime | None) -> str | None:
|
||||||
|
"""Serialize optional datetimes into ISO8601 strings."""
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
def _deserialize_datetime(self, value: str | None) -> datetime | None:
|
||||||
|
"""Deserialize optional ISO8601 strings into datetimes."""
|
||||||
|
return datetime.fromisoformat(value) if value else None
|
||||||
|
|
||||||
|
def _serialize_run(self, run: DocumentProcessingRun) -> dict[str, Any]:
|
||||||
|
"""Serialize one processing run to a JSON-compatible payload."""
|
||||||
|
return {
|
||||||
|
"run_id": run.run_id,
|
||||||
|
"doc_id": run.doc_id,
|
||||||
|
"trigger_type": run.trigger_type,
|
||||||
|
"run_status": run.run_status,
|
||||||
|
"parser_backend": run.parser_backend,
|
||||||
|
"chunk_backend": run.chunk_backend,
|
||||||
|
"embedding_model": run.embedding_model,
|
||||||
|
"index_name": run.index_name,
|
||||||
|
"started_at": self._serialize_datetime(run.started_at),
|
||||||
|
"stored_at": self._serialize_datetime(run.stored_at),
|
||||||
|
"parsed_at": self._serialize_datetime(run.parsed_at),
|
||||||
|
"indexed_at": self._serialize_datetime(run.indexed_at),
|
||||||
|
"finished_at": self._serialize_datetime(run.finished_at),
|
||||||
|
"layout_count": run.layout_count,
|
||||||
|
"structure_node_count": run.structure_node_count,
|
||||||
|
"semantic_block_count": run.semantic_block_count,
|
||||||
|
"vector_chunk_count": run.vector_chunk_count,
|
||||||
|
"chunk_count": run.chunk_count,
|
||||||
|
"failure_stage": run.failure_stage,
|
||||||
|
"error_message": run.error_message,
|
||||||
|
"metadata": run.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _deserialize_run(self, payload: dict[str, Any]) -> DocumentProcessingRun:
|
||||||
|
"""Deserialize one JSON payload into a processing run dataclass."""
|
||||||
|
return DocumentProcessingRun(
|
||||||
|
run_id=payload["run_id"],
|
||||||
|
doc_id=payload["doc_id"],
|
||||||
|
trigger_type=payload["trigger_type"],
|
||||||
|
run_status=payload["run_status"],
|
||||||
|
parser_backend=payload.get("parser_backend", ""),
|
||||||
|
chunk_backend=payload.get("chunk_backend", ""),
|
||||||
|
embedding_model=payload.get("embedding_model", ""),
|
||||||
|
index_name=payload.get("index_name", ""),
|
||||||
|
started_at=self._deserialize_datetime(payload.get("started_at")) or datetime.now(UTC),
|
||||||
|
stored_at=self._deserialize_datetime(payload.get("stored_at")),
|
||||||
|
parsed_at=self._deserialize_datetime(payload.get("parsed_at")),
|
||||||
|
indexed_at=self._deserialize_datetime(payload.get("indexed_at")),
|
||||||
|
finished_at=self._deserialize_datetime(payload.get("finished_at")),
|
||||||
|
layout_count=int(payload.get("layout_count", 0) or 0),
|
||||||
|
structure_node_count=int(payload.get("structure_node_count", 0) or 0),
|
||||||
|
semantic_block_count=int(payload.get("semantic_block_count", 0) or 0),
|
||||||
|
vector_chunk_count=int(payload.get("vector_chunk_count", 0) or 0),
|
||||||
|
chunk_count=int(payload.get("chunk_count", 0) or 0),
|
||||||
|
failure_stage=payload.get("failure_stage", ""),
|
||||||
|
error_message=payload.get("error_message", ""),
|
||||||
|
metadata=payload.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _serialize_event(self, event: DocumentStatusEvent) -> dict[str, Any]:
|
||||||
|
"""Serialize one status event to a JSON-compatible payload."""
|
||||||
|
return {
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"doc_id": event.doc_id,
|
||||||
|
"run_id": event.run_id,
|
||||||
|
"from_status": event.from_status,
|
||||||
|
"to_status": event.to_status,
|
||||||
|
"stage": event.stage,
|
||||||
|
"message": event.message,
|
||||||
|
"metadata": event.metadata,
|
||||||
|
"occurred_at": self._serialize_datetime(event.occurred_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _deserialize_event(self, payload: dict[str, Any]) -> DocumentStatusEvent:
|
||||||
|
"""Deserialize one JSON payload into a status event dataclass."""
|
||||||
|
return DocumentStatusEvent(
|
||||||
|
event_id=payload["event_id"],
|
||||||
|
doc_id=payload["doc_id"],
|
||||||
|
run_id=payload["run_id"],
|
||||||
|
from_status=payload.get("from_status", ""),
|
||||||
|
to_status=payload["to_status"],
|
||||||
|
stage=payload.get("stage", ""),
|
||||||
|
message=payload.get("message", ""),
|
||||||
|
metadata=payload.get("metadata", {}),
|
||||||
|
occurred_at=self._deserialize_datetime(payload.get("occurred_at")) or datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _serialize_artifact(self, artifact: DocumentArtifact) -> dict[str, Any]:
|
||||||
|
"""Serialize one artifact reference to a JSON-compatible payload."""
|
||||||
|
return {
|
||||||
|
"artifact_id": artifact.artifact_id,
|
||||||
|
"doc_id": artifact.doc_id,
|
||||||
|
"run_id": artifact.run_id,
|
||||||
|
"artifact_type": artifact.artifact_type,
|
||||||
|
"object_name": artifact.object_name,
|
||||||
|
"content_type": artifact.content_type,
|
||||||
|
"byte_size": artifact.byte_size,
|
||||||
|
"checksum": artifact.checksum,
|
||||||
|
"metadata": artifact.metadata,
|
||||||
|
"created_at": self._serialize_datetime(artifact.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _deserialize_artifact(self, payload: dict[str, Any]) -> DocumentArtifact:
|
||||||
|
"""Deserialize one JSON payload into an artifact dataclass."""
|
||||||
|
return DocumentArtifact(
|
||||||
|
artifact_id=payload["artifact_id"],
|
||||||
|
doc_id=payload["doc_id"],
|
||||||
|
run_id=payload["run_id"],
|
||||||
|
artifact_type=payload["artifact_type"],
|
||||||
|
object_name=payload["object_name"],
|
||||||
|
content_type=payload.get("content_type", ""),
|
||||||
|
byte_size=int(payload.get("byte_size", 0) or 0),
|
||||||
|
checksum=payload.get("checksum", ""),
|
||||||
|
metadata=payload.get("metadata", {}),
|
||||||
|
created_at=self._deserialize_datetime(payload.get("created_at")) or datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _merge_metadata(self, original: dict[str, Any], update: dict | None) -> dict[str, Any]:
|
||||||
|
"""Merge metadata updates onto an existing payload."""
|
||||||
|
merged = dict(original)
|
||||||
|
if update:
|
||||||
|
merged.update(update)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def create_run(self, run: DocumentProcessingRun) -> DocumentProcessingRun:
|
||||||
|
"""Create a new processing run record."""
|
||||||
|
payload = self._load()
|
||||||
|
payload["runs"][run.run_id] = self._serialize_run(run)
|
||||||
|
self._save(payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def mark_run_stored(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
stored_at: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Mark a run as having persisted the source file."""
|
||||||
|
payload = self._load()
|
||||||
|
run_payload = payload["runs"].get(run_id)
|
||||||
|
if not run_payload:
|
||||||
|
return None
|
||||||
|
run = self._deserialize_run(run_payload)
|
||||||
|
run.stored_at = stored_at or datetime.now(UTC)
|
||||||
|
run.metadata = self._merge_metadata(run.metadata, metadata)
|
||||||
|
payload["runs"][run_id] = self._serialize_run(run)
|
||||||
|
self._save(payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
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: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Record parse completion details for a run."""
|
||||||
|
payload = self._load()
|
||||||
|
run_payload = payload["runs"].get(run_id)
|
||||||
|
if not run_payload:
|
||||||
|
return None
|
||||||
|
run = self._deserialize_run(run_payload)
|
||||||
|
run.parser_backend = parser_backend
|
||||||
|
run.layout_count = layout_count
|
||||||
|
run.structure_node_count = structure_node_count
|
||||||
|
run.semantic_block_count = semantic_block_count
|
||||||
|
run.vector_chunk_count = vector_chunk_count
|
||||||
|
run.parsed_at = parsed_at or datetime.now(UTC)
|
||||||
|
run.metadata = self._merge_metadata(run.metadata, metadata)
|
||||||
|
payload["runs"][run_id] = self._serialize_run(run)
|
||||||
|
self._save(payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def mark_run_indexed(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
chunk_count: int,
|
||||||
|
index_name: str,
|
||||||
|
indexed_at: datetime | None = None,
|
||||||
|
finished_at: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Mark a run as successfully indexed."""
|
||||||
|
payload = self._load()
|
||||||
|
run_payload = payload["runs"].get(run_id)
|
||||||
|
if not run_payload:
|
||||||
|
return None
|
||||||
|
run = self._deserialize_run(run_payload)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
run.run_status = "succeeded"
|
||||||
|
run.chunk_count = chunk_count
|
||||||
|
run.index_name = index_name
|
||||||
|
run.indexed_at = indexed_at or now
|
||||||
|
run.finished_at = finished_at or now
|
||||||
|
run.metadata = self._merge_metadata(run.metadata, metadata)
|
||||||
|
payload["runs"][run_id] = self._serialize_run(run)
|
||||||
|
self._save(payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def mark_run_failed(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
failure_stage: str,
|
||||||
|
error_message: str,
|
||||||
|
finished_at: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Mark a run as failed."""
|
||||||
|
payload = self._load()
|
||||||
|
run_payload = payload["runs"].get(run_id)
|
||||||
|
if not run_payload:
|
||||||
|
return None
|
||||||
|
run = self._deserialize_run(run_payload)
|
||||||
|
run.run_status = "failed"
|
||||||
|
run.failure_stage = failure_stage
|
||||||
|
run.error_message = error_message
|
||||||
|
run.finished_at = finished_at or datetime.now(UTC)
|
||||||
|
run.metadata = self._merge_metadata(run.metadata, metadata)
|
||||||
|
payload["runs"][run_id] = self._serialize_run(run)
|
||||||
|
self._save(payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
def append_status_event(self, event: DocumentStatusEvent) -> DocumentStatusEvent:
|
||||||
|
"""Append a document status event."""
|
||||||
|
payload = self._load()
|
||||||
|
payload["status_events"][event.event_id] = self._serialize_event(event)
|
||||||
|
self._save(payload)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def replace_artifacts_for_run(self, run_id: str, artifacts: list[DocumentArtifact]) -> list[DocumentArtifact]:
|
||||||
|
"""Replace all artifacts for a run with the provided list."""
|
||||||
|
payload = self._load()
|
||||||
|
payload["artifacts"] = {
|
||||||
|
artifact_id: artifact_payload
|
||||||
|
for artifact_id, artifact_payload in payload["artifacts"].items()
|
||||||
|
if artifact_payload.get("run_id") != run_id
|
||||||
|
}
|
||||||
|
for artifact in artifacts:
|
||||||
|
payload["artifacts"][artifact.artifact_id] = self._serialize_artifact(artifact)
|
||||||
|
self._save(payload)
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
def delete_by_document(self, doc_id: str) -> None:
|
||||||
|
"""Delete all processing data for a document."""
|
||||||
|
payload = self._load()
|
||||||
|
payload["runs"] = {
|
||||||
|
run_id: run_payload
|
||||||
|
for run_id, run_payload in payload["runs"].items()
|
||||||
|
if run_payload.get("doc_id") != doc_id
|
||||||
|
}
|
||||||
|
payload["status_events"] = {
|
||||||
|
event_id: event_payload
|
||||||
|
for event_id, event_payload in payload["status_events"].items()
|
||||||
|
if event_payload.get("doc_id") != doc_id
|
||||||
|
}
|
||||||
|
payload["artifacts"] = {
|
||||||
|
artifact_id: artifact_payload
|
||||||
|
for artifact_id, artifact_payload in payload["artifacts"].items()
|
||||||
|
if artifact_payload.get("doc_id") != doc_id
|
||||||
|
}
|
||||||
|
self._save(payload)
|
||||||
|
|
||||||
|
def list_runs_by_document(self, doc_id: str) -> list[DocumentProcessingRun]:
|
||||||
|
"""List all processing runs for a document."""
|
||||||
|
payload = self._load()
|
||||||
|
runs = [
|
||||||
|
self._deserialize_run(run_payload)
|
||||||
|
for run_payload in payload["runs"].values()
|
||||||
|
if run_payload.get("doc_id") == doc_id
|
||||||
|
]
|
||||||
|
runs.sort(key=lambda run: run.started_at)
|
||||||
|
return runs
|
||||||
|
|
||||||
|
def get_run(self, run_id: str) -> DocumentProcessingRun | None:
|
||||||
|
"""Return one processing run by identifier."""
|
||||||
|
payload = self._load()
|
||||||
|
run_payload = payload["runs"].get(run_id)
|
||||||
|
return self._deserialize_run(run_payload) if run_payload else None
|
||||||
|
|
||||||
|
def list_status_events_by_document(self, doc_id: str) -> list[DocumentStatusEvent]:
|
||||||
|
"""List status events for a document."""
|
||||||
|
payload = self._load()
|
||||||
|
events = [
|
||||||
|
self._deserialize_event(event_payload)
|
||||||
|
for event_payload in payload["status_events"].values()
|
||||||
|
if event_payload.get("doc_id") == doc_id
|
||||||
|
]
|
||||||
|
events.sort(key=lambda event: event.occurred_at)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def list_status_events_by_run(self, run_id: str) -> list[DocumentStatusEvent]:
|
||||||
|
"""List status events for a run."""
|
||||||
|
payload = self._load()
|
||||||
|
events = [
|
||||||
|
self._deserialize_event(event_payload)
|
||||||
|
for event_payload in payload["status_events"].values()
|
||||||
|
if event_payload.get("run_id") == run_id
|
||||||
|
]
|
||||||
|
events.sort(key=lambda event: event.occurred_at)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def list_artifacts_by_document(self, doc_id: str) -> list[DocumentArtifact]:
|
||||||
|
"""List artifact references for a document."""
|
||||||
|
payload = self._load()
|
||||||
|
artifacts = [
|
||||||
|
self._deserialize_artifact(artifact_payload)
|
||||||
|
for artifact_payload in payload["artifacts"].values()
|
||||||
|
if artifact_payload.get("doc_id") == doc_id
|
||||||
|
]
|
||||||
|
artifacts.sort(key=lambda artifact: artifact.created_at)
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
def list_artifacts_by_run(self, run_id: str) -> list[DocumentArtifact]:
|
||||||
|
"""List artifact references for a run."""
|
||||||
|
payload = self._load()
|
||||||
|
artifacts = [
|
||||||
|
self._deserialize_artifact(artifact_payload)
|
||||||
|
for artifact_payload in payload["artifacts"].values()
|
||||||
|
if artifact_payload.get("run_id") == run_id
|
||||||
|
]
|
||||||
|
artifacts.sort(key=lambda artifact: artifact.created_at)
|
||||||
|
return artifacts
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""Implement infrastructure support for postgres document processing history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
from psycopg2.pool import ThreadedConnectionPool
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.domain.documents import DocumentArtifact, DocumentProcessingRun, DocumentProcessingStore, DocumentStatusEvent
|
||||||
|
# Keep SQL mapping local to this adapter so the domain stays storage-agnostic.
|
||||||
|
|
||||||
|
_CREATE_RUNS_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS document_processing_runs (
|
||||||
|
run_id VARCHAR(128) PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
trigger_type VARCHAR(32) NOT NULL,
|
||||||
|
run_status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||||
|
parser_backend VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
chunk_backend VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
embedding_model VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
index_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
stored_at TIMESTAMPTZ,
|
||||||
|
parsed_at TIMESTAMPTZ,
|
||||||
|
indexed_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
layout_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
structure_node_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
semantic_block_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
vector_chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failure_stage VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_dpr_doc FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_processing_runs_doc_id ON document_processing_runs(doc_id, started_at DESC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CREATE_EVENTS_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS document_status_history (
|
||||||
|
event_id VARCHAR(128) PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
run_id VARCHAR(128) NOT NULL,
|
||||||
|
from_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
to_status VARCHAR(32) NOT NULL,
|
||||||
|
stage VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_dsh_doc FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_dsh_run FOREIGN KEY (run_id) REFERENCES document_processing_runs(run_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_status_history_doc_id ON document_status_history(doc_id, occurred_at ASC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_status_history_run_id ON document_status_history(run_id, occurred_at ASC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CREATE_ARTIFACTS_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS document_artifacts (
|
||||||
|
artifact_id VARCHAR(128) PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
run_id VARCHAR(128) NOT NULL,
|
||||||
|
artifact_type VARCHAR(64) NOT NULL,
|
||||||
|
object_name VARCHAR(1024) NOT NULL,
|
||||||
|
content_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
byte_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
checksum VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_da_doc FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_da_run FOREIGN KEY (run_id) REFERENCES document_processing_runs(run_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_artifacts_doc_id ON document_artifacts(doc_id, created_at ASC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_artifacts_run_id ON document_artifacts(run_id, created_at ASC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresDocumentProcessingStore(DocumentProcessingStore):
|
||||||
|
"""Persist processing history in PostgreSQL using handwritten SQL."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the store and ensure the required tables exist."""
|
||||||
|
self._pool = ThreadedConnectionPool(
|
||||||
|
minconn=1,
|
||||||
|
maxconn=5,
|
||||||
|
host=settings.postgres_host,
|
||||||
|
port=settings.postgres_port,
|
||||||
|
user=settings.postgres_user,
|
||||||
|
password=settings.postgres_password,
|
||||||
|
dbname=settings.postgres_db,
|
||||||
|
)
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
def _ensure_schema(self) -> None:
|
||||||
|
"""Create processing history tables and indexes if they are missing."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(_CREATE_RUNS_TABLE)
|
||||||
|
cur.execute(_CREATE_EVENTS_TABLE)
|
||||||
|
cur.execute(_CREATE_ARTIFACTS_TABLE)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _conn(self):
|
||||||
|
"""Borrow one connection from the pool and return it afterwards."""
|
||||||
|
conn = self._pool.getconn()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
self._pool.putconn(conn)
|
||||||
|
|
||||||
|
def _normalize_metadata(self, value: Any) -> dict[str, Any]:
|
||||||
|
"""Return a JSON-object payload regardless of the row representation."""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if not value:
|
||||||
|
return {}
|
||||||
|
return json.loads(value)
|
||||||
|
|
||||||
|
def _row_to_run(self, row: dict[str, Any]) -> DocumentProcessingRun:
|
||||||
|
"""Map one run row into the domain dataclass."""
|
||||||
|
return DocumentProcessingRun(
|
||||||
|
run_id=row["run_id"],
|
||||||
|
doc_id=row["doc_id"],
|
||||||
|
trigger_type=row["trigger_type"],
|
||||||
|
run_status=row["run_status"],
|
||||||
|
parser_backend=row["parser_backend"],
|
||||||
|
chunk_backend=row["chunk_backend"],
|
||||||
|
embedding_model=row["embedding_model"],
|
||||||
|
index_name=row["index_name"],
|
||||||
|
started_at=row["started_at"],
|
||||||
|
stored_at=row["stored_at"],
|
||||||
|
parsed_at=row["parsed_at"],
|
||||||
|
indexed_at=row["indexed_at"],
|
||||||
|
finished_at=row["finished_at"],
|
||||||
|
layout_count=row["layout_count"],
|
||||||
|
structure_node_count=row["structure_node_count"],
|
||||||
|
semantic_block_count=row["semantic_block_count"],
|
||||||
|
vector_chunk_count=row["vector_chunk_count"],
|
||||||
|
chunk_count=row["chunk_count"],
|
||||||
|
failure_stage=row["failure_stage"],
|
||||||
|
error_message=row["error_message"],
|
||||||
|
metadata=self._normalize_metadata(row["metadata"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _row_to_event(self, row: dict[str, Any]) -> DocumentStatusEvent:
|
||||||
|
"""Map one event row into the domain dataclass."""
|
||||||
|
return DocumentStatusEvent(
|
||||||
|
event_id=row["event_id"],
|
||||||
|
doc_id=row["doc_id"],
|
||||||
|
run_id=row["run_id"],
|
||||||
|
from_status=row["from_status"],
|
||||||
|
to_status=row["to_status"],
|
||||||
|
stage=row["stage"],
|
||||||
|
message=row["message"],
|
||||||
|
metadata=self._normalize_metadata(row["metadata"]),
|
||||||
|
occurred_at=row["occurred_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _row_to_artifact(self, row: dict[str, Any]) -> DocumentArtifact:
|
||||||
|
"""Map one artifact row into the domain dataclass."""
|
||||||
|
return DocumentArtifact(
|
||||||
|
artifact_id=row["artifact_id"],
|
||||||
|
doc_id=row["doc_id"],
|
||||||
|
run_id=row["run_id"],
|
||||||
|
artifact_type=row["artifact_type"],
|
||||||
|
object_name=row["object_name"],
|
||||||
|
content_type=row["content_type"],
|
||||||
|
byte_size=row["byte_size"],
|
||||||
|
checksum=row["checksum"],
|
||||||
|
metadata=self._normalize_metadata(row["metadata"]),
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_run(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
assignments: dict[str, Any],
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Update one run row and return the latest stored state."""
|
||||||
|
set_clauses = []
|
||||||
|
params: dict[str, Any] = {"run_id": run_id, "updated_at": datetime.now(UTC)}
|
||||||
|
for key, value in assignments.items():
|
||||||
|
set_clauses.append(f"{key} = %({key})s")
|
||||||
|
params[key] = value
|
||||||
|
set_clauses.append("updated_at = %(updated_at)s")
|
||||||
|
if metadata is not None:
|
||||||
|
set_clauses.append("metadata = COALESCE(metadata, '{}'::jsonb) || %(metadata)s::jsonb")
|
||||||
|
params["metadata"] = json.dumps(metadata, ensure_ascii=False)
|
||||||
|
sql = f"""
|
||||||
|
UPDATE document_processing_runs
|
||||||
|
SET {", ".join(set_clauses)}
|
||||||
|
WHERE run_id = %(run_id)s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return self._row_to_run(dict(row)) if row else None
|
||||||
|
|
||||||
|
def create_run(self, run: DocumentProcessingRun) -> DocumentProcessingRun:
|
||||||
|
"""Create a new processing run record."""
|
||||||
|
sql = """
|
||||||
|
INSERT INTO document_processing_runs
|
||||||
|
(run_id, doc_id, trigger_type, run_status, parser_backend, chunk_backend,
|
||||||
|
embedding_model, index_name, started_at, stored_at, parsed_at, indexed_at,
|
||||||
|
finished_at, layout_count, structure_node_count, semantic_block_count,
|
||||||
|
vector_chunk_count, chunk_count, failure_stage, error_message, metadata)
|
||||||
|
VALUES
|
||||||
|
(%(run_id)s, %(doc_id)s, %(trigger_type)s, %(run_status)s, %(parser_backend)s,
|
||||||
|
%(chunk_backend)s, %(embedding_model)s, %(index_name)s, %(started_at)s,
|
||||||
|
%(stored_at)s, %(parsed_at)s, %(indexed_at)s, %(finished_at)s, %(layout_count)s,
|
||||||
|
%(structure_node_count)s, %(semantic_block_count)s, %(vector_chunk_count)s,
|
||||||
|
%(chunk_count)s, %(failure_stage)s, %(error_message)s, %(metadata)s)
|
||||||
|
"""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
sql,
|
||||||
|
{
|
||||||
|
"run_id": run.run_id,
|
||||||
|
"doc_id": run.doc_id,
|
||||||
|
"trigger_type": run.trigger_type,
|
||||||
|
"run_status": run.run_status,
|
||||||
|
"parser_backend": run.parser_backend,
|
||||||
|
"chunk_backend": run.chunk_backend,
|
||||||
|
"embedding_model": run.embedding_model,
|
||||||
|
"index_name": run.index_name,
|
||||||
|
"started_at": run.started_at,
|
||||||
|
"stored_at": run.stored_at,
|
||||||
|
"parsed_at": run.parsed_at,
|
||||||
|
"indexed_at": run.indexed_at,
|
||||||
|
"finished_at": run.finished_at,
|
||||||
|
"layout_count": run.layout_count,
|
||||||
|
"structure_node_count": run.structure_node_count,
|
||||||
|
"semantic_block_count": run.semantic_block_count,
|
||||||
|
"vector_chunk_count": run.vector_chunk_count,
|
||||||
|
"chunk_count": run.chunk_count,
|
||||||
|
"failure_stage": run.failure_stage,
|
||||||
|
"error_message": run.error_message,
|
||||||
|
"metadata": json.dumps(run.metadata, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return run
|
||||||
|
|
||||||
|
def mark_run_stored(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
stored_at: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Mark a run as having persisted its source file."""
|
||||||
|
return self._update_run(
|
||||||
|
run_id,
|
||||||
|
assignments={"stored_at": stored_at or datetime.now(UTC)},
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
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: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Record parse completion metrics for a run."""
|
||||||
|
return self._update_run(
|
||||||
|
run_id,
|
||||||
|
assignments={
|
||||||
|
"parser_backend": parser_backend,
|
||||||
|
"parsed_at": parsed_at or datetime.now(UTC),
|
||||||
|
"layout_count": layout_count,
|
||||||
|
"structure_node_count": structure_node_count,
|
||||||
|
"semantic_block_count": semantic_block_count,
|
||||||
|
"vector_chunk_count": vector_chunk_count,
|
||||||
|
},
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_run_indexed(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
chunk_count: int,
|
||||||
|
index_name: str,
|
||||||
|
indexed_at: datetime | None = None,
|
||||||
|
finished_at: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Mark a run as successfully indexed."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
return self._update_run(
|
||||||
|
run_id,
|
||||||
|
assignments={
|
||||||
|
"run_status": "succeeded",
|
||||||
|
"chunk_count": chunk_count,
|
||||||
|
"index_name": index_name,
|
||||||
|
"indexed_at": indexed_at or now,
|
||||||
|
"finished_at": finished_at or now,
|
||||||
|
},
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_run_failed(
|
||||||
|
self,
|
||||||
|
run_id: str,
|
||||||
|
*,
|
||||||
|
failure_stage: str,
|
||||||
|
error_message: str,
|
||||||
|
finished_at: datetime | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> DocumentProcessingRun | None:
|
||||||
|
"""Mark a run as failed and persist the terminal error details."""
|
||||||
|
return self._update_run(
|
||||||
|
run_id,
|
||||||
|
assignments={
|
||||||
|
"run_status": "failed",
|
||||||
|
"failure_stage": failure_stage,
|
||||||
|
"error_message": error_message,
|
||||||
|
"finished_at": finished_at or datetime.now(UTC),
|
||||||
|
},
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def append_status_event(self, event: DocumentStatusEvent) -> DocumentStatusEvent:
|
||||||
|
"""Append a document status event."""
|
||||||
|
sql = """
|
||||||
|
INSERT INTO document_status_history
|
||||||
|
(event_id, doc_id, run_id, from_status, to_status, stage, message, metadata, occurred_at)
|
||||||
|
VALUES
|
||||||
|
(%(event_id)s, %(doc_id)s, %(run_id)s, %(from_status)s, %(to_status)s,
|
||||||
|
%(stage)s, %(message)s, %(metadata)s, %(occurred_at)s)
|
||||||
|
"""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
sql,
|
||||||
|
{
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"doc_id": event.doc_id,
|
||||||
|
"run_id": event.run_id,
|
||||||
|
"from_status": event.from_status,
|
||||||
|
"to_status": event.to_status,
|
||||||
|
"stage": event.stage,
|
||||||
|
"message": event.message,
|
||||||
|
"metadata": json.dumps(event.metadata, ensure_ascii=False),
|
||||||
|
"occurred_at": event.occurred_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return event
|
||||||
|
|
||||||
|
def replace_artifacts_for_run(self, run_id: str, artifacts: list[DocumentArtifact]) -> list[DocumentArtifact]:
|
||||||
|
"""Replace all artifact references for one run using a delete-then-insert strategy."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM document_artifacts WHERE run_id = %s", (run_id,))
|
||||||
|
if artifacts:
|
||||||
|
psycopg2.extras.execute_values(
|
||||||
|
cur,
|
||||||
|
"""
|
||||||
|
INSERT INTO document_artifacts
|
||||||
|
(artifact_id, doc_id, run_id, artifact_type, object_name,
|
||||||
|
content_type, byte_size, checksum, metadata, created_at)
|
||||||
|
VALUES %s
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
artifact.artifact_id,
|
||||||
|
artifact.doc_id,
|
||||||
|
artifact.run_id,
|
||||||
|
artifact.artifact_type,
|
||||||
|
artifact.object_name,
|
||||||
|
artifact.content_type,
|
||||||
|
artifact.byte_size,
|
||||||
|
artifact.checksum,
|
||||||
|
json.dumps(artifact.metadata, ensure_ascii=False),
|
||||||
|
artifact.created_at,
|
||||||
|
)
|
||||||
|
for artifact in artifacts
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
def delete_by_document(self, doc_id: str) -> None:
|
||||||
|
"""Delete all processing rows for a document explicitly."""
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM document_status_history WHERE doc_id = %s", (doc_id,))
|
||||||
|
cur.execute("DELETE FROM document_artifacts WHERE doc_id = %s", (doc_id,))
|
||||||
|
cur.execute("DELETE FROM document_processing_runs WHERE doc_id = %s", (doc_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def list_runs_by_document(self, doc_id: str) -> list[DocumentProcessingRun]:
|
||||||
|
"""List processing runs for a document in chronological order."""
|
||||||
|
sql = "SELECT * FROM document_processing_runs WHERE doc_id = %s ORDER BY started_at ASC"
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (doc_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [self._row_to_run(dict(row)) for row in rows]
|
||||||
|
|
||||||
|
def get_run(self, run_id: str) -> DocumentProcessingRun | None:
|
||||||
|
"""Return one processing run by identifier."""
|
||||||
|
sql = "SELECT * FROM document_processing_runs WHERE run_id = %s"
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (run_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return self._row_to_run(dict(row)) if row else None
|
||||||
|
|
||||||
|
def list_status_events_by_document(self, doc_id: str) -> list[DocumentStatusEvent]:
|
||||||
|
"""List all status events for a document."""
|
||||||
|
sql = "SELECT * FROM document_status_history WHERE doc_id = %s ORDER BY occurred_at ASC"
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (doc_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [self._row_to_event(dict(row)) for row in rows]
|
||||||
|
|
||||||
|
def list_status_events_by_run(self, run_id: str) -> list[DocumentStatusEvent]:
|
||||||
|
"""List all status events for a run."""
|
||||||
|
sql = "SELECT * FROM document_status_history WHERE run_id = %s ORDER BY occurred_at ASC"
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (run_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [self._row_to_event(dict(row)) for row in rows]
|
||||||
|
|
||||||
|
def list_artifacts_by_document(self, doc_id: str) -> list[DocumentArtifact]:
|
||||||
|
"""List all artifact references for a document."""
|
||||||
|
sql = "SELECT * FROM document_artifacts WHERE doc_id = %s ORDER BY created_at ASC"
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (doc_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [self._row_to_artifact(dict(row)) for row in rows]
|
||||||
|
|
||||||
|
def list_artifacts_by_run(self, run_id: str) -> list[DocumentArtifact]:
|
||||||
|
"""List all artifact references for a run."""
|
||||||
|
sql = "SELECT * FROM document_artifacts WHERE run_id = %s ORDER BY created_at ASC"
|
||||||
|
with self._conn() as conn:
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(sql, (run_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [self._row_to_artifact(dict(row)) for row in rows]
|
||||||
@@ -56,7 +56,21 @@ class BM25Retriever:
|
|||||||
try:
|
try:
|
||||||
rows = self._vector_index.collection.query(
|
rows = self._vector_index.collection.query(
|
||||||
expr='doc_id != ""',
|
expr='doc_id != ""',
|
||||||
output_fields=["id", "doc_id", "doc_name", "content", "section_title", "page_number"],
|
output_fields=[
|
||||||
|
"id",
|
||||||
|
"chunk_id",
|
||||||
|
"doc_id",
|
||||||
|
"doc_title",
|
||||||
|
"text",
|
||||||
|
"chunk_type",
|
||||||
|
"section_title",
|
||||||
|
"page_start",
|
||||||
|
"page_end",
|
||||||
|
"section_level",
|
||||||
|
"chunk_index",
|
||||||
|
"piece_index",
|
||||||
|
"metadata_json",
|
||||||
|
],
|
||||||
limit=16384,
|
limit=16384,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -64,19 +78,33 @@ class BM25Retriever:
|
|||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
RetrievedChunk(
|
RetrievedChunk(
|
||||||
chunk_id=str(row.get("id", "")),
|
chunk_id=str(row.get("chunk_id") or row.get("id", "")),
|
||||||
doc_id=str(row.get("doc_id", "")),
|
doc_id=str(row.get("doc_id", "")),
|
||||||
doc_name=str(row.get("doc_name", "")),
|
doc_title=str(row.get("doc_title", "")),
|
||||||
content=str(row.get("content", "")),
|
text=str(row.get("text", "")),
|
||||||
score=0.0,
|
score=0.0,
|
||||||
|
chunk_type=str(row.get("chunk_type", "")),
|
||||||
section_title=str(row.get("section_title", "")),
|
section_title=str(row.get("section_title", "")),
|
||||||
page_number=int(row.get("page_number") or 0),
|
page_start=int(row.get("page_start") or 0),
|
||||||
metadata={},
|
page_end=int(row.get("page_end") or 0),
|
||||||
|
section_level=int(row.get("section_level") or 0),
|
||||||
|
chunk_index=int(row.get("chunk_index") or 0),
|
||||||
|
piece_index=int(row.get("piece_index") or 0),
|
||||||
|
metadata=self._parse_metadata_json(row.get("metadata_json", "")),
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
if row.get("content")
|
if row.get("text")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _parse_metadata_json(self, raw_metadata: str) -> dict:
|
||||||
|
"""Parse metadata_json into a dict for BM25-side filtering."""
|
||||||
|
if not raw_metadata:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(__import__("json").loads(raw_metadata))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
def _ensure_built(self) -> None:
|
def _ensure_built(self) -> None:
|
||||||
if self._index is not None:
|
if self._index is not None:
|
||||||
return
|
return
|
||||||
@@ -93,7 +121,7 @@ class BM25Retriever:
|
|||||||
self._chunks = []
|
self._chunks = []
|
||||||
self._index = BM25Okapi([[]])
|
self._index = BM25Okapi([[]])
|
||||||
return
|
return
|
||||||
tokenized = [_tokenize(c.content) for c in chunks]
|
tokenized = [_tokenize(c.text) for c in chunks]
|
||||||
self._chunks = chunks
|
self._chunks = chunks
|
||||||
self._index = BM25Okapi(tokenized)
|
self._index = BM25Okapi(tokenized)
|
||||||
logger.info("BM25Retriever: index built with %d chunks", len(chunks))
|
logger.info("BM25Retriever: index built with %d chunks", len(chunks))
|
||||||
@@ -127,20 +155,26 @@ class BM25Retriever:
|
|||||||
for score, chunk in ranked[: top_k * 2]:
|
for score, chunk in ranked[: top_k * 2]:
|
||||||
if score <= 0:
|
if score <= 0:
|
||||||
break
|
break
|
||||||
# Apply simple regulation_type filter if provided
|
if filters:
|
||||||
if filters and chunk.metadata.get("regulation_type"):
|
normalized_filter = filters.replace("doc_name", "doc_title").strip()
|
||||||
types = [t.strip() for t in filters.split(",")]
|
if normalized_filter.startswith('doc_title == "'):
|
||||||
if chunk.metadata.get("regulation_type") not in types:
|
expected_title = normalized_filter[len('doc_title == "'):-1]
|
||||||
|
if chunk.doc_title != expected_title:
|
||||||
continue
|
continue
|
||||||
results.append(
|
results.append(
|
||||||
RetrievedChunk(
|
RetrievedChunk(
|
||||||
chunk_id=chunk.chunk_id,
|
chunk_id=chunk.chunk_id,
|
||||||
doc_id=chunk.doc_id,
|
doc_id=chunk.doc_id,
|
||||||
doc_name=chunk.doc_name,
|
doc_title=chunk.doc_title,
|
||||||
content=chunk.content,
|
text=chunk.text,
|
||||||
score=score,
|
score=score,
|
||||||
|
chunk_type=chunk.chunk_type,
|
||||||
section_title=chunk.section_title,
|
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,
|
||||||
metadata=chunk.metadata,
|
metadata=chunk.metadata,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class OpenAICompatibleReranker(Reranker):
|
|||||||
if not chunks:
|
if not chunks:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
texts = [chunk.content for chunk in chunks]
|
texts = [chunk.text for chunk in chunks]
|
||||||
start = time.time()
|
start = time.time()
|
||||||
try:
|
try:
|
||||||
scores = self._call_reranker(query, texts)
|
scores = self._call_reranker(query, texts)
|
||||||
|
|||||||
@@ -4,57 +4,150 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
from pymilvus import Collection, CollectionSchema, DataType, FieldSchema, connections, utility
|
from loguru import logger
|
||||||
|
from pymilvus import Collection, CollectionSchema, DataType, FieldSchema, MilvusException, connections, utility
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.domain.documents import Chunk
|
from app.domain.documents import Chunk
|
||||||
from app.domain.retrieval import RetrievedChunk, VectorIndex
|
from app.domain.retrieval import RetrievedChunk, VectorIndex
|
||||||
|
from app.shared.errors import VectorStoreSchemaError
|
||||||
# Keep adapter behavior explicit so integration details remain easy to audit.
|
# Keep adapter behavior explicit so integration details remain easy to audit.
|
||||||
|
|
||||||
|
|
||||||
|
_REQUIRED_SCHEMA_FIELDS = (
|
||||||
|
"doc_id",
|
||||||
|
"doc_title",
|
||||||
|
"chunk_id",
|
||||||
|
"text",
|
||||||
|
"embedding",
|
||||||
|
"section_title",
|
||||||
|
"metadata_json",
|
||||||
|
)
|
||||||
|
_SCHEMA_RECOVERY_TOKENS = (
|
||||||
|
"field doc_title not exist",
|
||||||
|
"field text not exist",
|
||||||
|
"field embedding not exist",
|
||||||
|
"collection not loaded",
|
||||||
|
"can't find collection",
|
||||||
|
"not found[collection",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MilvusVectorIndex(VectorIndex):
|
class MilvusVectorIndex(VectorIndex):
|
||||||
"""Provide the Milvus Vector Index index implementation."""
|
"""Provide the Milvus Vector Index index implementation."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize the Milvus Vector Index instance."""
|
"""Initialize the Milvus Vector Index instance."""
|
||||||
self.collection_name = settings.milvus_collection
|
self.collection_name = settings.milvus_collection
|
||||||
self.db_name = settings.milvus_db_name
|
self.db_name = settings.milvus_db_name
|
||||||
|
self.host = settings.milvus_host
|
||||||
|
self.port = settings.milvus_port
|
||||||
|
# Use an adapter-specific alias so this index never reuses unrelated global Milvus state.
|
||||||
|
self.alias = f"vector-index::{self.host}:{self.port}/{self.db_name}/{self.collection_name}"
|
||||||
|
self._connect()
|
||||||
|
self.collection = self._bind_collection()
|
||||||
|
|
||||||
|
def _connect(self, *, refresh: bool = False) -> None:
|
||||||
|
"""Establish the Milvus connection for this adapter."""
|
||||||
|
if refresh:
|
||||||
|
try:
|
||||||
|
connections.disconnect(self.alias)
|
||||||
|
except Exception:
|
||||||
|
# Best-effort disconnect keeps refresh idempotent when no alias is active yet.
|
||||||
|
pass
|
||||||
connections.connect(
|
connections.connect(
|
||||||
alias="default",
|
alias=self.alias,
|
||||||
host=settings.milvus_host,
|
host=self.host,
|
||||||
port=settings.milvus_port,
|
port=self.port,
|
||||||
db_name=self.db_name,
|
db_name=self.db_name,
|
||||||
)
|
)
|
||||||
self.collection = self._ensure_collection()
|
|
||||||
|
def _schema_field_names(self, collection: Collection) -> list[str]:
|
||||||
|
"""Return the field names exposed by the bound Milvus collection."""
|
||||||
|
return [field.name for field in collection.schema.fields]
|
||||||
|
|
||||||
|
def _raise_schema_error(self, *, message: str, actual_fields: Iterable[str]) -> None:
|
||||||
|
"""Raise a typed schema error for the active collection."""
|
||||||
|
raise VectorStoreSchemaError(
|
||||||
|
message=message,
|
||||||
|
host=self.host,
|
||||||
|
db_name=self.db_name,
|
||||||
|
collection_name=self.collection_name,
|
||||||
|
expected_fields=list(_REQUIRED_SCHEMA_FIELDS),
|
||||||
|
actual_fields=list(actual_fields),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_schema(self, collection: Collection) -> None:
|
||||||
|
"""Ensure the collection schema matches the dense-only adapter contract."""
|
||||||
|
actual_fields = self._schema_field_names(collection)
|
||||||
|
missing_fields = [field_name for field_name in _REQUIRED_SCHEMA_FIELDS if field_name not in actual_fields]
|
||||||
|
if missing_fields:
|
||||||
|
self._raise_schema_error(
|
||||||
|
message=f"Milvus collection schema mismatch; missing required fields: {missing_fields}",
|
||||||
|
actual_fields=actual_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_collection_binding(self, collection: Collection, *, event: str) -> None:
|
||||||
|
"""Record the bound collection details for runtime diagnostics."""
|
||||||
|
try:
|
||||||
|
num_entities = collection.num_entities
|
||||||
|
except Exception:
|
||||||
|
num_entities = "unknown"
|
||||||
|
logger.info(
|
||||||
|
"Milvus binding {} alias={} host={} db={} collection={} fields={} num_entities={}",
|
||||||
|
event,
|
||||||
|
self.alias,
|
||||||
|
self.host,
|
||||||
|
self.db_name,
|
||||||
|
self.collection_name,
|
||||||
|
self._schema_field_names(collection),
|
||||||
|
num_entities,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _bind_collection(self, *, force_refresh: bool = False) -> Collection:
|
||||||
|
"""Bind and validate the configured Milvus collection."""
|
||||||
|
if force_refresh:
|
||||||
|
self._connect(refresh=True)
|
||||||
|
collection = self._ensure_collection()
|
||||||
|
self._validate_schema(collection)
|
||||||
|
self._log_collection_binding(collection, event="refreshed" if force_refresh else "initialized")
|
||||||
|
return collection
|
||||||
|
|
||||||
def _ensure_collection(self) -> Collection:
|
def _ensure_collection(self) -> Collection:
|
||||||
"""Handle ensure collection for this module for the Milvus Vector Index instance."""
|
"""Handle ensure collection for this module for the Milvus Vector Index instance."""
|
||||||
if utility.has_collection(self.collection_name):
|
if utility.has_collection(self.collection_name, using=self.alias):
|
||||||
collection = Collection(self.collection_name)
|
collection = Collection(self.collection_name, using=self.alias)
|
||||||
collection.load()
|
collection.load()
|
||||||
return collection
|
return collection
|
||||||
schema = CollectionSchema(
|
schema = CollectionSchema(
|
||||||
fields=[
|
fields=[
|
||||||
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=128, is_primary=True, auto_id=False),
|
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_id", dtype=DataType.VARCHAR, max_length=64),
|
||||||
FieldSchema(name="doc_name", dtype=DataType.VARCHAR, max_length=256),
|
FieldSchema(name="doc_title", dtype=DataType.VARCHAR, max_length=256),
|
||||||
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535),
|
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=settings.embedding_dim),
|
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=settings.embedding_dim),
|
||||||
FieldSchema(name="section_title", dtype=DataType.VARCHAR, max_length=512),
|
|
||||||
FieldSchema(name="section_path", dtype=DataType.VARCHAR, max_length=4096),
|
|
||||||
FieldSchema(name="page_number", dtype=DataType.INT64),
|
|
||||||
FieldSchema(name="regulation_type", dtype=DataType.VARCHAR, max_length=128),
|
|
||||||
FieldSchema(name="version", dtype=DataType.VARCHAR, max_length=64),
|
|
||||||
FieldSchema(name="semantic_id", dtype=DataType.VARCHAR, max_length=128),
|
FieldSchema(name="semantic_id", dtype=DataType.VARCHAR, max_length=128),
|
||||||
FieldSchema(name="block_type", dtype=DataType.VARCHAR, max_length=64),
|
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="metadata_json", dtype=DataType.VARCHAR, max_length=65535),
|
||||||
FieldSchema(name="created_at", dtype=DataType.INT64),
|
FieldSchema(name="created_at", dtype=DataType.INT64),
|
||||||
],
|
],
|
||||||
description="Dense-only regulations index",
|
description="Dense-only regulations index",
|
||||||
enable_dynamic_field=False,
|
enable_dynamic_field=False,
|
||||||
)
|
)
|
||||||
collection = Collection(name=self.collection_name, schema=schema)
|
collection = Collection(name=self.collection_name, schema=schema, using=self.alias)
|
||||||
collection.create_index(
|
collection.create_index(
|
||||||
field_name="embedding",
|
field_name="embedding",
|
||||||
index_params={
|
index_params={
|
||||||
@@ -73,21 +166,34 @@ class MilvusVectorIndex(VectorIndex):
|
|||||||
data = []
|
data = []
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
for chunk, vector in zip(chunks, vectors):
|
for chunk, vector in zip(chunks, vectors):
|
||||||
|
metadata = dict(chunk.metadata)
|
||||||
|
doc_title = str(metadata.get("doc_title", chunk.doc_title))
|
||||||
|
text = str(metadata.get("text", chunk.text))
|
||||||
|
embedding_text = str(metadata.get("embedding_text", chunk.embedding_text))
|
||||||
|
page_start = int(metadata.get("page_start", 0) or 0)
|
||||||
|
page_end = int(metadata.get("page_end", 0) or 0)
|
||||||
|
section_path = metadata.get("section_path", chunk.section_path)
|
||||||
|
source_ids = metadata.get("source_ids", [])
|
||||||
data.append(
|
data.append(
|
||||||
{
|
{
|
||||||
"id": chunk.chunk_id,
|
"id": chunk.chunk_id,
|
||||||
"doc_id": chunk.doc_id,
|
"doc_id": chunk.doc_id,
|
||||||
"doc_name": chunk.doc_name,
|
"doc_title": doc_title[:256],
|
||||||
"content": chunk.content[:65535],
|
"chunk_id": chunk.chunk_id[:128],
|
||||||
|
"chunk_index": int(metadata.get("chunk_index", chunk.chunk_index) or 0),
|
||||||
|
"piece_index": int(metadata.get("piece_index", chunk.piece_index) or 0),
|
||||||
|
"text": text[:65535],
|
||||||
|
"embedding_text": embedding_text[:65535],
|
||||||
"embedding": vector,
|
"embedding": vector,
|
||||||
"section_title": chunk.section_title[:512],
|
"semantic_id": str(metadata.get("semantic_id", chunk.semantic_id))[:128],
|
||||||
"section_path": json.dumps(chunk.section_path, ensure_ascii=False)[:4096],
|
"chunk_type": str(metadata.get("chunk_type", chunk.chunk_type))[:64],
|
||||||
"page_number": chunk.page_number,
|
"page_start": page_start,
|
||||||
"regulation_type": chunk.regulation_type[:128],
|
"page_end": page_end,
|
||||||
"version": chunk.version[:64],
|
"section_level": int(metadata.get("section_level", chunk.section_level) or 0),
|
||||||
"semantic_id": chunk.semantic_id[:128],
|
"source_ids": json.dumps(source_ids, ensure_ascii=False)[:4096],
|
||||||
"block_type": chunk.block_type[:64],
|
"section_path": json.dumps(section_path, ensure_ascii=False)[:4096],
|
||||||
"metadata_json": json.dumps(chunk.metadata, ensure_ascii=False)[:65535],
|
"section_title": str(metadata.get("section_title", chunk.section_title))[:512],
|
||||||
|
"metadata_json": json.dumps(metadata, ensure_ascii=False)[:65535],
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -107,30 +213,73 @@ class MilvusVectorIndex(VectorIndex):
|
|||||||
|
|
||||||
filters = filters.strip()
|
filters = filters.strip()
|
||||||
|
|
||||||
|
# Normalize legacy field names so callers can keep older filter payloads.
|
||||||
|
replacements = {
|
||||||
|
"doc_name": "doc_title",
|
||||||
|
"content": "text",
|
||||||
|
"page_number": "page_start",
|
||||||
|
"block_type": "chunk_type",
|
||||||
|
}
|
||||||
|
for legacy_name, new_name in replacements.items():
|
||||||
|
filters = filters.replace(legacy_name, new_name)
|
||||||
|
|
||||||
# Check if already a Milvus expression (contains operators)
|
# Check if already a Milvus expression (contains operators)
|
||||||
if any(op in filters for op in ["==", "!=", "in", "not in", ">", "<", ">=", "<=", "and", "or"]):
|
if any(op in filters for op in ["==", "!=", "in", "not in", ">", "<", ">=", "<=", "and", "or"]):
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
# Parse simple regulation_type filter
|
# Parse simple document-title filter.
|
||||||
# Support: "GB" or "GB,UN-ECE" or "GB, UN-ECE"
|
titles = [title.strip() for title in filters.split(",") if title.strip()]
|
||||||
types = [t.strip() for t in filters.split(",") if t.strip()]
|
|
||||||
|
|
||||||
if not types:
|
if not titles:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(types) == 1:
|
if len(titles) == 1:
|
||||||
# Single value: regulation_type == "GB"
|
return f'doc_title == "{titles[0]}"'
|
||||||
return f'regulation_type == "{types[0]}"'
|
|
||||||
else:
|
quoted_titles = [f'"{title}"' for title in titles]
|
||||||
# Multiple values: regulation_type in ["GB", "UN-ECE"]
|
return f'doc_title in [{", ".join(quoted_titles)}]'
|
||||||
quoted_types = [f'"{t}"' for t in types]
|
|
||||||
return f'regulation_type in [{", ".join(quoted_types)}]'
|
def _should_refresh_after_exception(self, exc: Exception) -> bool:
|
||||||
|
"""Return whether the Milvus error suggests stale connection or collection state."""
|
||||||
|
if not isinstance(exc, MilvusException):
|
||||||
|
return False
|
||||||
|
normalized = str(exc).lower()
|
||||||
|
return any(token in normalized for token in _SCHEMA_RECOVERY_TOKENS)
|
||||||
|
|
||||||
|
def _run_with_refresh(self, operation):
|
||||||
|
"""Run a Milvus operation and retry once after a forced reconnect when appropriate."""
|
||||||
|
try:
|
||||||
|
return operation()
|
||||||
|
except VectorStoreSchemaError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
if not self._should_refresh_after_exception(exc):
|
||||||
|
raise
|
||||||
|
logger.warning(
|
||||||
|
"Milvus operation failed for alias={} collection={}; forcing reconnect and retry: {}",
|
||||||
|
self.alias,
|
||||||
|
self.collection_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
self.collection = self._bind_collection(force_refresh=True)
|
||||||
|
try:
|
||||||
|
return operation()
|
||||||
|
except VectorStoreSchemaError:
|
||||||
|
raise
|
||||||
|
except Exception as retry_exc:
|
||||||
|
if isinstance(retry_exc, MilvusException):
|
||||||
|
self._raise_schema_error(
|
||||||
|
message=f"Milvus operation failed after refresh: {retry_exc}",
|
||||||
|
actual_fields=self._schema_field_names(self.collection),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
def search(self, query_vector: list[float], top_k: int, filters: str | None = None) -> list[RetrievedChunk]:
|
def search(self, query_vector: list[float], top_k: int, filters: str | None = None) -> list[RetrievedChunk]:
|
||||||
"""Handle search for the Milvus Vector Index instance."""
|
"""Handle search for the Milvus Vector Index instance."""
|
||||||
milvus_expr = self._parse_filters(filters)
|
milvus_expr = self._parse_filters(filters)
|
||||||
|
|
||||||
results = self.collection.search(
|
results = self._run_with_refresh(
|
||||||
|
lambda: self.collection.search(
|
||||||
data=[query_vector],
|
data=[query_vector],
|
||||||
anns_field="embedding",
|
anns_field="embedding",
|
||||||
param={"metric_type": "COSINE", "params": {"nprobe": settings.milvus_nprobe}},
|
param={"metric_type": "COSINE", "params": {"nprobe": settings.milvus_nprobe}},
|
||||||
@@ -138,17 +287,24 @@ class MilvusVectorIndex(VectorIndex):
|
|||||||
expr=milvus_expr,
|
expr=milvus_expr,
|
||||||
output_fields=[
|
output_fields=[
|
||||||
"doc_id",
|
"doc_id",
|
||||||
"doc_name",
|
"doc_title",
|
||||||
"content",
|
"chunk_id",
|
||||||
|
"chunk_index",
|
||||||
|
"piece_index",
|
||||||
|
"text",
|
||||||
|
"embedding_text",
|
||||||
"section_title",
|
"section_title",
|
||||||
"page_number",
|
|
||||||
"regulation_type",
|
|
||||||
"version",
|
|
||||||
"semantic_id",
|
"semantic_id",
|
||||||
"block_type",
|
"chunk_type",
|
||||||
|
"page_start",
|
||||||
|
"page_end",
|
||||||
|
"section_level",
|
||||||
|
"source_ids",
|
||||||
|
"section_path",
|
||||||
"metadata_json",
|
"metadata_json",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
)
|
||||||
payload: list[RetrievedChunk] = []
|
payload: list[RetrievedChunk] = []
|
||||||
for hits in results:
|
for hits in results:
|
||||||
for hit in hits:
|
for hit in hits:
|
||||||
@@ -161,13 +317,18 @@ class MilvusVectorIndex(VectorIndex):
|
|||||||
metadata = {"raw_metadata": raw_metadata}
|
metadata = {"raw_metadata": raw_metadata}
|
||||||
payload.append(
|
payload.append(
|
||||||
RetrievedChunk(
|
RetrievedChunk(
|
||||||
chunk_id=str(hit.id),
|
chunk_id=str(hit.entity.get("chunk_id", hit.id)),
|
||||||
doc_id=hit.entity.get("doc_id", ""),
|
doc_id=hit.entity.get("doc_id", ""),
|
||||||
doc_name=hit.entity.get("doc_name", ""),
|
doc_title=hit.entity.get("doc_title", ""),
|
||||||
content=hit.entity.get("content", ""),
|
text=hit.entity.get("text", ""),
|
||||||
score=float(hit.score),
|
score=float(hit.score),
|
||||||
|
chunk_type=hit.entity.get("chunk_type", ""),
|
||||||
section_title=hit.entity.get("section_title", ""),
|
section_title=hit.entity.get("section_title", ""),
|
||||||
page_number=int(hit.entity.get("page_number", 0) or 0),
|
page_start=int(hit.entity.get("page_start", 0) or 0),
|
||||||
|
page_end=int(hit.entity.get("page_end", 0) or 0),
|
||||||
|
section_level=int(hit.entity.get("section_level", 0) or 0),
|
||||||
|
chunk_index=int(hit.entity.get("chunk_index", 0) or 0),
|
||||||
|
piece_index=int(hit.entity.get("piece_index", 0) or 0),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -176,7 +337,9 @@ class MilvusVectorIndex(VectorIndex):
|
|||||||
def count_by_document(self) -> dict[str, int]:
|
def count_by_document(self) -> dict[str, int]:
|
||||||
"""Return doc_id -> chunk count from Milvus."""
|
"""Return doc_id -> chunk count from Milvus."""
|
||||||
try:
|
try:
|
||||||
rows = self.collection.query(expr="doc_id != \"\"", output_fields=["doc_id"])
|
rows = self._run_with_refresh(
|
||||||
|
lambda: self.collection.query(expr="doc_id != \"\"", output_fields=["doc_id", "doc_title"])
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
@@ -189,9 +352,11 @@ class MilvusVectorIndex(VectorIndex):
|
|||||||
def list_document_metadata(self) -> list[dict]:
|
def list_document_metadata(self) -> list[dict]:
|
||||||
"""Return one metadata row per document from Milvus (single query, no embeddings)."""
|
"""Return one metadata row per document from Milvus (single query, no embeddings)."""
|
||||||
try:
|
try:
|
||||||
rows = self.collection.query(
|
rows = self._run_with_refresh(
|
||||||
|
lambda: self.collection.query(
|
||||||
expr="doc_id != \"\"",
|
expr="doc_id != \"\"",
|
||||||
output_fields=["doc_id", "doc_name", "regulation_type", "version"],
|
output_fields=["doc_id", "doc_title", "metadata_json"],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
@@ -204,15 +369,26 @@ class MilvusVectorIndex(VectorIndex):
|
|||||||
continue
|
continue
|
||||||
counts[doc_id] = counts.get(doc_id, 0) + 1
|
counts[doc_id] = counts.get(doc_id, 0) + 1
|
||||||
if doc_id not in seen:
|
if doc_id not in seen:
|
||||||
|
metadata: dict[str, object] = {}
|
||||||
|
raw_metadata = row.get("metadata_json", "")
|
||||||
|
if raw_metadata:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(raw_metadata)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
metadata = {}
|
||||||
seen[doc_id] = {
|
seen[doc_id] = {
|
||||||
"doc_id": doc_id,
|
"doc_id": doc_id,
|
||||||
"doc_name": row.get("doc_name", ""),
|
"doc_title": row.get("doc_title", ""),
|
||||||
"regulation_type": row.get("regulation_type", ""),
|
"regulation_type": str(metadata.get("regulation_type", "")),
|
||||||
"version": row.get("version", ""),
|
"version": str(metadata.get("version", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{**meta, "chunk_count": counts[meta["doc_id"]]}
|
{
|
||||||
|
**meta,
|
||||||
|
"doc_name": meta.get("doc_title", ""),
|
||||||
|
"chunk_count": counts[meta["doc_id"]],
|
||||||
|
}
|
||||||
for meta in seen.values()
|
for meta in seen.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -81,3 +81,29 @@ class AnalyzeResponse(BaseModel):
|
|||||||
"""Define the Analyze Response API model."""
|
"""Define the Analyze Response API model."""
|
||||||
task_id: str
|
task_id: str
|
||||||
status: str = "processing"
|
status: str = "processing"
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeStreamSource(BaseModel):
|
||||||
|
"""SSE source event payload for analyze-stream."""
|
||||||
|
standard: str
|
||||||
|
clause: str
|
||||||
|
score: float
|
||||||
|
status: str
|
||||||
|
full_content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeStreamFinding(BaseModel):
|
||||||
|
"""SSE finding event payload for analyze-stream."""
|
||||||
|
title: str
|
||||||
|
desc: str
|
||||||
|
status: str
|
||||||
|
clause_ref: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeStreamDone(BaseModel):
|
||||||
|
"""SSE done event payload for analyze-stream."""
|
||||||
|
conclusion: str
|
||||||
|
actions: list[dict]
|
||||||
|
risk_score: int
|
||||||
|
highlight_terms: list[str]
|
||||||
|
para_text: str
|
||||||
@@ -67,14 +67,14 @@ class DocumentProcessor:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": item.chunk_id,
|
"id": item.chunk_id,
|
||||||
"content": item.content,
|
"content": item.text,
|
||||||
"score": item.score,
|
"score": item.score,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"doc_id": item.doc_id,
|
"doc_id": item.doc_id,
|
||||||
"doc_name": item.doc_name,
|
"doc_name": item.doc_title,
|
||||||
"chunk_id": item.chunk_id,
|
"chunk_id": item.chunk_id,
|
||||||
"section_title": item.section_title,
|
"section_title": item.section_title,
|
||||||
"page_number": item.page_number,
|
"page_number": item.page_start,
|
||||||
**item.metadata,
|
**item.metadata,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,136 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from app.application.agent import AgentConversationService
|
from app.application.agent import AgentConversationService, AgentSessionService
|
||||||
from app.application.documents import DocumentCommandService, DocumentQueryService
|
from app.application.documents import DocumentCommandService, DocumentQueryService
|
||||||
from app.application.knowledge import KnowledgeRetrievalService
|
from app.application.knowledge import KnowledgeRetrievalService
|
||||||
|
from app.application.perception.services import PerceptionService
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
from app.domain.documents import DocumentBinaryStore
|
||||||
|
from app.domain.retrieval import VectorIndex
|
||||||
from app.infrastructure.embedding.openai_compatible_embedding_provider import OpenAICompatibleEmbeddingProvider
|
from app.infrastructure.embedding.openai_compatible_embedding_provider import OpenAICompatibleEmbeddingProvider
|
||||||
from app.infrastructure.llm.openai_compatible_answer_generator import OpenAICompatibleAnswerGenerator
|
from app.infrastructure.llm.openai_compatible_answer_generator import OpenAICompatibleAnswerGenerator
|
||||||
from app.infrastructure.parser.aliyun_document_parser import AliyunDocumentParser
|
from app.infrastructure.parser.aliyun_document_parser import AliyunDocumentParser
|
||||||
from app.infrastructure.parser.local_chunk_builder import LocalRegulationChunkBuilder
|
from app.infrastructure.parser.local_chunk_builder import LocalRegulationChunkBuilder
|
||||||
from app.infrastructure.parser.local_document_parser import LocalDocumentParser
|
from app.infrastructure.parser.local_document_parser import LocalDocumentParser
|
||||||
from app.infrastructure.parser.vector_chunk_builder import AliyunVectorChunkBuilder
|
from app.infrastructure.parser.vector_chunk_builder import AliyunVectorChunkBuilder
|
||||||
|
from app.infrastructure.perception.mock_event_store import MockEventStore
|
||||||
from app.infrastructure.session.in_memory_conversation_store import InMemoryConversationStore
|
from app.infrastructure.session.in_memory_conversation_store import InMemoryConversationStore
|
||||||
|
from app.infrastructure.storage.json_document_processing_store import JsonDocumentProcessingStore
|
||||||
from app.infrastructure.storage.json_document_repository import JsonDocumentRepository
|
from app.infrastructure.storage.json_document_repository import JsonDocumentRepository
|
||||||
from app.infrastructure.storage.minio_binary_store import MinioDocumentBinaryStore
|
from app.infrastructure.storage.minio_binary_store import MinioDocumentBinaryStore
|
||||||
|
from app.infrastructure.storage.postgres_document_processing_store import PostgresDocumentProcessingStore
|
||||||
from app.infrastructure.storage.postgres_document_repository import PostgresDocumentRepository
|
from app.infrastructure.storage.postgres_document_repository import PostgresDocumentRepository
|
||||||
from app.infrastructure.storage.postgres_parse_artifact_store import PostgresParseArtifactStore
|
from app.infrastructure.storage.postgres_parse_artifact_store import PostgresParseArtifactStore
|
||||||
from app.infrastructure.vectorstore.bm25_retriever import BM25Retriever
|
from app.infrastructure.vectorstore.bm25_retriever import BM25Retriever
|
||||||
|
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
|
||||||
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
from app.infrastructure.vectorstore.dense_retriever import DenseRetriever
|
||||||
from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
|
from app.infrastructure.vectorstore.milvus_vector_index import MilvusVectorIndex
|
||||||
from app.infrastructure.vectorstore.cross_encoder_reranker import OpenAICompatibleReranker
|
from app.services.llm.llm_factory import LLMFactory
|
||||||
# Keep shared wiring centralized so dependency construction remains consistent.
|
# Keep shared wiring centralized so dependency construction remains consistent.
|
||||||
|
|
||||||
|
|
||||||
|
class LazyBinaryStore(DocumentBinaryStore):
|
||||||
|
"""Delay MinIO connection work until binary storage is actually needed."""
|
||||||
|
|
||||||
|
def __init__(self, factory: Callable[[], DocumentBinaryStore]) -> None:
|
||||||
|
"""Initialize the lazy binary store wrapper."""
|
||||||
|
self._factory = factory
|
||||||
|
self._store: DocumentBinaryStore | None = None
|
||||||
|
|
||||||
|
def _get_store(self) -> DocumentBinaryStore:
|
||||||
|
"""Create the underlying store on first use and reuse it afterwards."""
|
||||||
|
if self._store is None:
|
||||||
|
self._store = self._factory()
|
||||||
|
return self._store
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self):
|
||||||
|
"""Expose the underlying client for compatibility with health endpoints."""
|
||||||
|
return self._get_store().client
|
||||||
|
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
object_name: str,
|
||||||
|
data: bytes,
|
||||||
|
content_type: str,
|
||||||
|
metadata: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Save data through the underlying binary store implementation."""
|
||||||
|
self._get_store().save(
|
||||||
|
object_name=object_name,
|
||||||
|
data=data,
|
||||||
|
content_type=content_type,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(self, object_name: str) -> bytes:
|
||||||
|
"""Read data through the underlying binary store implementation."""
|
||||||
|
return self._get_store().read(object_name)
|
||||||
|
|
||||||
|
def delete(self, object_name: str) -> None:
|
||||||
|
"""Delete data through the underlying binary store implementation."""
|
||||||
|
self._get_store().delete(object_name)
|
||||||
|
|
||||||
|
|
||||||
|
class LazyVectorIndex(VectorIndex):
|
||||||
|
"""Delay Milvus connection work until vector operations are actually needed."""
|
||||||
|
|
||||||
|
def __init__(self, factory: Callable[[], VectorIndex]) -> None:
|
||||||
|
"""Initialize the lazy vector index wrapper."""
|
||||||
|
self._factory = factory
|
||||||
|
self._index: VectorIndex | None = None
|
||||||
|
|
||||||
|
def _get_index(self) -> VectorIndex:
|
||||||
|
"""Create the underlying index on first use and reuse it afterwards."""
|
||||||
|
if self._index is None:
|
||||||
|
self._index = self._factory()
|
||||||
|
return self._index
|
||||||
|
|
||||||
|
@property
|
||||||
|
def collection(self):
|
||||||
|
"""Expose the underlying Milvus collection for compatibility adapters."""
|
||||||
|
return self._get_index().collection
|
||||||
|
|
||||||
|
def upsert(self, chunks, vectors) -> int:
|
||||||
|
"""Insert or update vectors through the underlying vector index implementation."""
|
||||||
|
return self._get_index().upsert(chunks, vectors)
|
||||||
|
|
||||||
|
def delete_by_document(self, doc_id: str) -> int:
|
||||||
|
"""Delete vectors through the underlying vector index implementation."""
|
||||||
|
return self._get_index().delete_by_document(doc_id)
|
||||||
|
|
||||||
|
def search(self, query_vector: list[float], top_k: int, filters: str | None = None):
|
||||||
|
"""Search vectors through the underlying vector index implementation."""
|
||||||
|
return self._get_index().search(query_vector, top_k, filters)
|
||||||
|
|
||||||
|
def count_by_document(self) -> dict[str, int]:
|
||||||
|
"""Count document vectors through the underlying vector index implementation."""
|
||||||
|
return self._get_index().count_by_document()
|
||||||
|
|
||||||
|
def list_document_metadata(self) -> list[dict]:
|
||||||
|
"""List document metadata through the underlying vector index implementation."""
|
||||||
|
return self._get_index().list_document_metadata()
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
"""Return vector index health through the underlying vector index implementation."""
|
||||||
|
return self._get_index().health()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _build_binary_store() -> MinioDocumentBinaryStore:
|
||||||
|
"""Return the concrete binary store implementation."""
|
||||||
|
return MinioDocumentBinaryStore()
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _build_vector_index() -> MilvusVectorIndex:
|
||||||
|
"""Return the concrete vector index implementation."""
|
||||||
|
return MilvusVectorIndex()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_document_repository():
|
def get_document_repository():
|
||||||
@@ -44,9 +151,17 @@ def get_parse_artifact_store():
|
|||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_binary_store() -> MinioDocumentBinaryStore:
|
def get_document_processing_store():
|
||||||
|
"""Return document processing store for the active repository backend."""
|
||||||
|
if settings.document_repository_backend == "postgres":
|
||||||
|
return PostgresDocumentProcessingStore()
|
||||||
|
return JsonDocumentProcessingStore(settings.document_processing_metadata_path)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_binary_store() -> DocumentBinaryStore:
|
||||||
"""Return binary store."""
|
"""Return binary store."""
|
||||||
return MinioDocumentBinaryStore()
|
return LazyBinaryStore(_build_binary_store)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
@@ -75,9 +190,9 @@ def get_embedding_provider() -> OpenAICompatibleEmbeddingProvider:
|
|||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_vector_index() -> MilvusVectorIndex:
|
def get_vector_index() -> VectorIndex:
|
||||||
"""Return vector index."""
|
"""Return vector index."""
|
||||||
return MilvusVectorIndex()
|
return LazyVectorIndex(_build_vector_index)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
@@ -121,6 +236,7 @@ def get_document_command_service() -> DocumentCommandService:
|
|||||||
embedding_provider=get_embedding_provider(),
|
embedding_provider=get_embedding_provider(),
|
||||||
vector_index=get_vector_index(),
|
vector_index=get_vector_index(),
|
||||||
parse_artifact_store=get_parse_artifact_store(),
|
parse_artifact_store=get_parse_artifact_store(),
|
||||||
|
document_processing_store=get_document_processing_store(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,3 +267,28 @@ def get_agent_conversation_service() -> AgentConversationService:
|
|||||||
answer_generator=OpenAICompatibleAnswerGenerator(),
|
answer_generator=OpenAICompatibleAnswerGenerator(),
|
||||||
conversation_store=get_conversation_store(),
|
conversation_store=get_conversation_store(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_perception_service() -> PerceptionService:
|
||||||
|
"""Return perception service for regulatory intelligence."""
|
||||||
|
return PerceptionService(
|
||||||
|
event_store=MockEventStore(),
|
||||||
|
retrieval_service=get_retrieval_service(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_agent_session_service() -> AgentSessionService:
|
||||||
|
"""Return agent session service."""
|
||||||
|
return AgentSessionService(conversation_store=get_conversation_store())
|
||||||
|
|
||||||
|
|
||||||
|
def preload_runtime_dependencies() -> None:
|
||||||
|
"""Warm dependencies that are safe and useful to preload during startup."""
|
||||||
|
LLMFactory.preload_clients(["qwen", "deepseek"])
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_runtime_dependencies() -> None:
|
||||||
|
"""Release runtime dependencies that expose explicit cleanup hooks."""
|
||||||
|
LLMFactory.cleanup()
|
||||||
|
|||||||
30
backend/app/shared/errors.py
Normal file
30
backend/app/shared/errors.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Define shared backend exception types."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStoreSchemaError(RuntimeError):
|
||||||
|
"""Signal that the active vector store schema does not match backend expectations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
host: str,
|
||||||
|
db_name: str,
|
||||||
|
collection_name: str,
|
||||||
|
expected_fields: list[str],
|
||||||
|
actual_fields: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the vector store schema error details."""
|
||||||
|
self.host = host
|
||||||
|
self.db_name = db_name
|
||||||
|
self.collection_name = collection_name
|
||||||
|
self.expected_fields = expected_fields
|
||||||
|
self.actual_fields = actual_fields
|
||||||
|
# Keep the message self-contained so runtime logs show the full mismatch context.
|
||||||
|
details = (
|
||||||
|
f"{message} | host={host} db={db_name} collection={collection_name} "
|
||||||
|
f"expected_fields={expected_fields} actual_fields={actual_fields}"
|
||||||
|
)
|
||||||
|
super().__init__(details)
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
131
backend/data/document_processing.json
Normal file
131
backend/data/document_processing.json
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
{
|
||||||
|
"runs": {
|
||||||
|
"8e722053-5009-40fe-a483-535b40ebbb16": {
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"trigger_type": "upload",
|
||||||
|
"run_status": "succeeded",
|
||||||
|
"parser_backend": "aliyun_docmind",
|
||||||
|
"chunk_backend": "aliyun",
|
||||||
|
"embedding_model": "text-embedding-v3",
|
||||||
|
"index_name": "regulations_dense_1024_v2",
|
||||||
|
"started_at": "2026-05-26T12:18:27.208692+00:00",
|
||||||
|
"stored_at": "2026-05-26T12:18:27.712855+00:00",
|
||||||
|
"parsed_at": "2026-05-26T12:18:42.989238+00:00",
|
||||||
|
"indexed_at": "2026-05-26T12:18:51.172418+00:00",
|
||||||
|
"finished_at": "2026-05-26T12:18:51.172418+00:00",
|
||||||
|
"layout_count": 48,
|
||||||
|
"structure_node_count": 6,
|
||||||
|
"semantic_block_count": 33,
|
||||||
|
"vector_chunk_count": 34,
|
||||||
|
"chunk_count": 34,
|
||||||
|
"failure_stage": "",
|
||||||
|
"error_message": "",
|
||||||
|
"metadata": {
|
||||||
|
"generate_summary": true,
|
||||||
|
"parse_task_id": "docmind-20260526-10b94713ccb348498b12180a5dcf32ff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status_events": {
|
||||||
|
"d0532baf-0d65-4130-b282-ec51f04132fd": {
|
||||||
|
"event_id": "d0532baf-0d65-4130-b282-ec51f04132fd",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"from_status": "",
|
||||||
|
"to_status": "pending",
|
||||||
|
"stage": "document_created",
|
||||||
|
"message": "Document record created",
|
||||||
|
"metadata": {},
|
||||||
|
"occurred_at": "2026-05-26T12:18:27.235921+00:00"
|
||||||
|
},
|
||||||
|
"a5e32db5-25c3-4c73-a987-7311f0e72a31": {
|
||||||
|
"event_id": "a5e32db5-25c3-4c73-a987-7311f0e72a31",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"from_status": "pending",
|
||||||
|
"to_status": "stored",
|
||||||
|
"stage": "store",
|
||||||
|
"message": "Source file stored",
|
||||||
|
"metadata": {},
|
||||||
|
"occurred_at": "2026-05-26T12:18:27.741462+00:00"
|
||||||
|
},
|
||||||
|
"18e04ce7-9d7a-4008-8600-e2590100bd85": {
|
||||||
|
"event_id": "18e04ce7-9d7a-4008-8600-e2590100bd85",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"from_status": "stored",
|
||||||
|
"to_status": "parsed",
|
||||||
|
"stage": "parse",
|
||||||
|
"message": "Document parsed",
|
||||||
|
"metadata": {
|
||||||
|
"artifact_count": 4
|
||||||
|
},
|
||||||
|
"occurred_at": "2026-05-26T12:18:43.218026+00:00"
|
||||||
|
},
|
||||||
|
"d3b06025-5c91-4a42-9e5f-dce1c5312b96": {
|
||||||
|
"event_id": "d3b06025-5c91-4a42-9e5f-dce1c5312b96",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"from_status": "parsed",
|
||||||
|
"to_status": "indexed",
|
||||||
|
"stage": "index",
|
||||||
|
"message": "Document indexed",
|
||||||
|
"metadata": {
|
||||||
|
"chunk_count": 34,
|
||||||
|
"index_name": "regulations_dense_1024_v2"
|
||||||
|
},
|
||||||
|
"occurred_at": "2026-05-26T12:18:51.195442+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"47fe2877-a8f5-4e1d-901b-80cd0194ba96": {
|
||||||
|
"artifact_id": "47fe2877-a8f5-4e1d-901b-80cd0194ba96",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"artifact_type": "layouts",
|
||||||
|
"object_name": "artifacts/7cbdfe3c/layouts.json",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"byte_size": 0,
|
||||||
|
"checksum": "",
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "2026-05-26T12:18:43.188467+00:00"
|
||||||
|
},
|
||||||
|
"44aa075b-86b2-48a7-9d14-a2453bd53863": {
|
||||||
|
"artifact_id": "44aa075b-86b2-48a7-9d14-a2453bd53863",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"artifact_type": "structure_nodes",
|
||||||
|
"object_name": "artifacts/7cbdfe3c/structure_nodes.json",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"byte_size": 0,
|
||||||
|
"checksum": "",
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "2026-05-26T12:18:43.188494+00:00"
|
||||||
|
},
|
||||||
|
"dedcc8fe-fa58-4de6-984d-f44332af5204": {
|
||||||
|
"artifact_id": "dedcc8fe-fa58-4de6-984d-f44332af5204",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"artifact_type": "semantic_blocks",
|
||||||
|
"object_name": "artifacts/7cbdfe3c/semantic_blocks.json",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"byte_size": 0,
|
||||||
|
"checksum": "",
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "2026-05-26T12:18:43.188511+00:00"
|
||||||
|
},
|
||||||
|
"9b0d8bda-e69e-4a4e-ae06-a308afe43109": {
|
||||||
|
"artifact_id": "9b0d8bda-e69e-4a4e-ae06-a308afe43109",
|
||||||
|
"doc_id": "7cbdfe3c",
|
||||||
|
"run_id": "8e722053-5009-40fe-a483-535b40ebbb16",
|
||||||
|
"artifact_type": "vector_chunks",
|
||||||
|
"object_name": "artifacts/7cbdfe3c/vector_chunks.json",
|
||||||
|
"content_type": "application/json",
|
||||||
|
"byte_size": 0,
|
||||||
|
"checksum": "",
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "2026-05-26T12:18:43.188526+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,385 +1,38 @@
|
|||||||
{
|
{
|
||||||
"69280841": {
|
"7cbdfe3c": {
|
||||||
"doc_id": "69280841",
|
"doc_id": "7cbdfe3c",
|
||||||
"doc_name": "TCT算法接口.pdf",
|
"doc_name": "使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||||
"file_name": "TCT算法接口.pdf",
|
"file_name": "使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||||
"object_name": "69280841/TCT算法接口.pdf",
|
"object_name": "7cbdfe3c/使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||||
"content_type": "application/pdf",
|
"content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
"size_bytes": 165557,
|
"size_bytes": 1199920,
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "local_markdown_parser",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "embedding 维度不匹配,期望 1536",
|
|
||||||
"created_at": "2026-05-18T07:12:16.668306+00:00",
|
|
||||||
"updated_at": "2026-05-18T07:12:19.417142+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"structure_nodes": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"44121fbb": {
|
|
||||||
"doc_id": "44121fbb",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "44121fbb/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a5cb9d0>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"created_at": "2026-05-18T09:53:47.996183+00:00",
|
|
||||||
"updated_at": "2026-05-18T09:53:50.825868+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"failure_reason": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a5cb9d0>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"processing_stage": "failed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"77debb4a": {
|
|
||||||
"doc_id": "77debb4a",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "77debb4a/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a6dd480>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"created_at": "2026-05-18T10:05:46.104259+00:00",
|
|
||||||
"updated_at": "2026-05-18T10:05:48.704061+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"failure_reason": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a6dd480>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"processing_stage": "failed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"d12bdcc8": {
|
|
||||||
"doc_id": "d12bdcc8",
|
|
||||||
"doc_name": "TCT算法接口.pdf",
|
|
||||||
"file_name": "TCT算法接口.pdf",
|
|
||||||
"object_name": "d12bdcc8/TCT算法接口.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 165557,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a5bf570>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"created_at": "2026-05-18T10:07:22.199824+00:00",
|
|
||||||
"updated_at": "2026-05-18T10:07:24.653751+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"failure_reason": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a5bf570>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"processing_stage": "failed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"3c2e8c9c": {
|
|
||||||
"doc_id": "3c2e8c9c",
|
|
||||||
"doc_name": "20260415_Continental tire mobile app solution.pdf",
|
|
||||||
"file_name": "20260415_Continental tire mobile app solution.pdf",
|
|
||||||
"object_name": "3c2e8c9c/20260415_Continental tire mobile app solution.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 2178074,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a5bc8d0>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"created_at": "2026-05-18T10:09:58.338274+00:00",
|
|
||||||
"updated_at": "2026-05-18T10:10:01.295502+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"failure_reason": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614a5bc8d0>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"processing_stage": "failed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"d22d21a0": {
|
|
||||||
"doc_id": "d22d21a0",
|
|
||||||
"doc_name": "20260415_Continental tire mobile app solution.pdf",
|
|
||||||
"file_name": "20260415_Continental tire mobile app solution.pdf",
|
|
||||||
"object_name": "d22d21a0/20260415_Continental tire mobile app solution.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 2178074,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614b994160>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"created_at": "2026-05-18T10:12:20.078027+00:00",
|
|
||||||
"updated_at": "2026-05-18T10:12:22.999843+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"failure_reason": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614b994160>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"processing_stage": "failed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"35f129d3": {
|
|
||||||
"doc_id": "35f129d3",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "35f129d3/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614b995370>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"created_at": "2026-05-18T10:13:24.706512+00:00",
|
|
||||||
"updated_at": "2026-05-18T10:13:27.180509+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"failure_reason": "unable to load credentials from any of the providers in the chain: ['EnvironmentVariableCredentialsProvider: Environment variable accessKeyId cannot be empty', 'CLIProfileCredentialsProvider: unable to open credentials file: C:\\\\Users\\\\A200477427\\\\.aliyun/config.json', 'ProfileCredentialsProvider: failed to get credential from credentials file: $C:\\\\Users\\\\A200477427\\\\.alibabacloud/credentials.ini', \"EcsRamRoleCredentialsProvider: HTTPConnectionPool(host='100.100.100.200', port=80): Max retries exceeded with url: /latest/meta-data/ram/security-credentials/ (Caused by ConnectTimeoutError(<HTTPConnection(host='100.100.100.200', port=80) at 0x2614b995370>, 'Connection to 100.100.100.200 timed out. (connect timeout=1.0)'))\"]",
|
|
||||||
"processing_stage": "failed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"efc21515": {
|
|
||||||
"doc_id": "efc21515",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "efc21515/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "aliyun_docmind",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "Client error '400 Bad Request' for url 'http://6.86.80.4:30080/v1/embeddings'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400",
|
|
||||||
"created_at": "2026-05-18T13:47:32.076786+00:00",
|
|
||||||
"updated_at": "2026-05-18T13:47:57.998073+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"parser_backend": "aliyun_docmind",
|
|
||||||
"parse_task_id": "docmind-20260518-a6e84447457f43cb85f95225cfc6495b",
|
|
||||||
"layout_count": 87,
|
|
||||||
"structure_node_count": 20,
|
|
||||||
"semantic_block_count": 27,
|
|
||||||
"vector_chunk_count": 27,
|
|
||||||
"artifact_keys": {
|
|
||||||
"layouts": "artifacts/efc21515/layouts.json",
|
|
||||||
"structure_nodes": "artifacts/efc21515/structure_nodes.json",
|
|
||||||
"semantic_blocks": "artifacts/efc21515/semantic_blocks.json",
|
|
||||||
"vector_chunks": "artifacts/efc21515/vector_chunks.json"
|
|
||||||
},
|
|
||||||
"processing_stage": "failed",
|
|
||||||
"failure_reason": "Client error '400 Bad Request' for url 'http://6.86.80.4:30080/v1/embeddings'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"0d4b08bc": {
|
|
||||||
"doc_id": "0d4b08bc",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "0d4b08bc/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "aliyun_docmind",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "Client error '404 Not Found' for url 'http://6.86.80.4:30080/v1/embeddings'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404",
|
|
||||||
"created_at": "2026-05-18T14:03:15.134344+00:00",
|
|
||||||
"updated_at": "2026-05-18T14:03:34.843448+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"parser_backend": "aliyun_docmind",
|
|
||||||
"parse_task_id": "docmind-20260518-78353d85daa24147b68d8fb71895179f",
|
|
||||||
"layout_count": 87,
|
|
||||||
"structure_node_count": 20,
|
|
||||||
"semantic_block_count": 27,
|
|
||||||
"vector_chunk_count": 27,
|
|
||||||
"artifact_keys": {
|
|
||||||
"layouts": "artifacts/0d4b08bc/layouts.json",
|
|
||||||
"structure_nodes": "artifacts/0d4b08bc/structure_nodes.json",
|
|
||||||
"semantic_blocks": "artifacts/0d4b08bc/semantic_blocks.json",
|
|
||||||
"vector_chunks": "artifacts/0d4b08bc/vector_chunks.json"
|
|
||||||
},
|
|
||||||
"processing_stage": "failed",
|
|
||||||
"failure_reason": "Client error '404 Not Found' for url 'http://6.86.80.4:30080/v1/embeddings'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"4302f314": {
|
|
||||||
"doc_id": "4302f314",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "4302f314/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "aliyun_docmind",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "embedding 维度不匹配,期望 1536",
|
|
||||||
"created_at": "2026-05-18T14:11:29.943973+00:00",
|
|
||||||
"updated_at": "2026-05-18T14:11:48.554500+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"parser_backend": "aliyun_docmind",
|
|
||||||
"parse_task_id": "docmind-20260518-23935ee455ac4b26ac4201ac4781ee52",
|
|
||||||
"layout_count": 87,
|
|
||||||
"structure_node_count": 20,
|
|
||||||
"semantic_block_count": 27,
|
|
||||||
"vector_chunk_count": 27,
|
|
||||||
"artifact_keys": {
|
|
||||||
"layouts": "artifacts/4302f314/layouts.json",
|
|
||||||
"structure_nodes": "artifacts/4302f314/structure_nodes.json",
|
|
||||||
"semantic_blocks": "artifacts/4302f314/semantic_blocks.json",
|
|
||||||
"vector_chunks": "artifacts/4302f314/vector_chunks.json"
|
|
||||||
},
|
|
||||||
"processing_stage": "failed",
|
|
||||||
"failure_reason": "embedding 维度不匹配,期望 1536"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"765ed1ee": {
|
|
||||||
"doc_id": "765ed1ee",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "765ed1ee/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "aliyun_docmind",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "<MilvusException: (code=1100, message=the dim (1024) of field data(embedding) is not equal to schema dim (1536): invalid parameter[expected=1536][actual=1024])>",
|
|
||||||
"created_at": "2026-05-18T14:18:28.875138+00:00",
|
|
||||||
"updated_at": "2026-05-18T14:18:57.389110+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"parser_backend": "aliyun_docmind",
|
|
||||||
"parse_task_id": "docmind-20260518-f116856bc29245baa2531b245078a701",
|
|
||||||
"layout_count": 87,
|
|
||||||
"structure_node_count": 20,
|
|
||||||
"semantic_block_count": 27,
|
|
||||||
"vector_chunk_count": 27,
|
|
||||||
"artifact_keys": {
|
|
||||||
"layouts": "artifacts/765ed1ee/layouts.json",
|
|
||||||
"structure_nodes": "artifacts/765ed1ee/structure_nodes.json",
|
|
||||||
"semantic_blocks": "artifacts/765ed1ee/semantic_blocks.json",
|
|
||||||
"vector_chunks": "artifacts/765ed1ee/vector_chunks.json"
|
|
||||||
},
|
|
||||||
"processing_stage": "failed",
|
|
||||||
"failure_reason": "<MilvusException: (code=1100, message=the dim (1024) of field data(embedding) is not equal to schema dim (1536): invalid parameter[expected=1536][actual=1024])>"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"05cabe09": {
|
|
||||||
"doc_id": "05cabe09",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "05cabe09/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "failed",
|
|
||||||
"regulation_type": "",
|
|
||||||
"version": "",
|
|
||||||
"summary": "",
|
|
||||||
"summary_latency_ms": 0,
|
|
||||||
"chunk_count": 0,
|
|
||||||
"parser_name": "aliyun_docmind",
|
|
||||||
"index_name": "",
|
|
||||||
"error_message": "embedding 维度不匹配,期望 1536",
|
|
||||||
"created_at": "2026-05-18T14:24:32.156500+00:00",
|
|
||||||
"updated_at": "2026-05-18T14:24:50.114138+00:00",
|
|
||||||
"metadata": {
|
|
||||||
"generate_summary": true,
|
|
||||||
"parser_backend": "aliyun_docmind",
|
|
||||||
"parse_task_id": "docmind-20260518-897d858983df48e28e9819e563d46208",
|
|
||||||
"layout_count": 87,
|
|
||||||
"structure_node_count": 20,
|
|
||||||
"semantic_block_count": 27,
|
|
||||||
"vector_chunk_count": 27,
|
|
||||||
"artifact_keys": {
|
|
||||||
"layouts": "artifacts/05cabe09/layouts.json",
|
|
||||||
"structure_nodes": "artifacts/05cabe09/structure_nodes.json",
|
|
||||||
"semantic_blocks": "artifacts/05cabe09/semantic_blocks.json",
|
|
||||||
"vector_chunks": "artifacts/05cabe09/vector_chunks.json"
|
|
||||||
},
|
|
||||||
"processing_stage": "failed",
|
|
||||||
"failure_reason": "embedding 维度不匹配,期望 1536"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"9acb2ba0": {
|
|
||||||
"doc_id": "9acb2ba0",
|
|
||||||
"doc_name": "大众汽车手册.pdf",
|
|
||||||
"file_name": "大众汽车手册.pdf",
|
|
||||||
"object_name": "9acb2ba0/大众汽车手册.pdf",
|
|
||||||
"content_type": "application/pdf",
|
|
||||||
"size_bytes": 766565,
|
|
||||||
"status": "indexed",
|
"status": "indexed",
|
||||||
"regulation_type": "",
|
"regulation_type": "",
|
||||||
"version": "",
|
"version": "",
|
||||||
"summary": "",
|
"summary": "",
|
||||||
"summary_latency_ms": 0,
|
"summary_latency_ms": 0,
|
||||||
"chunk_count": 27,
|
"chunk_count": 34,
|
||||||
"parser_name": "aliyun_docmind",
|
"parser_name": "aliyun_docmind",
|
||||||
"index_name": "regulations_dense_1024_v1",
|
"index_name": "regulations_dense_1024_v2",
|
||||||
"error_message": "",
|
"error_message": "",
|
||||||
"created_at": "2026-05-18T14:29:01.368719+00:00",
|
"created_at": "2026-05-26T12:18:27.206125+00:00",
|
||||||
"updated_at": "2026-05-18T14:29:23.699068+00:00",
|
"updated_at": "2026-05-26T12:18:51.171308+00:00",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"generate_summary": true,
|
"generate_summary": true,
|
||||||
"parser_backend": "aliyun_docmind",
|
"parser_backend": "aliyun_docmind",
|
||||||
"parse_task_id": "docmind-20260518-e5fd4a5419e74d569c562e389e6ae72c",
|
"parse_task_id": "docmind-20260526-10b94713ccb348498b12180a5dcf32ff",
|
||||||
"layout_count": 87,
|
"layout_count": 48,
|
||||||
"structure_node_count": 20,
|
"structure_node_count": 6,
|
||||||
"semantic_block_count": 27,
|
"semantic_block_count": 33,
|
||||||
"vector_chunk_count": 27,
|
"vector_chunk_count": 34,
|
||||||
"artifact_keys": {
|
"artifact_keys": {
|
||||||
"layouts": "artifacts/9acb2ba0/layouts.json",
|
"layouts": "artifacts/7cbdfe3c/layouts.json",
|
||||||
"structure_nodes": "artifacts/9acb2ba0/structure_nodes.json",
|
"structure_nodes": "artifacts/7cbdfe3c/structure_nodes.json",
|
||||||
"semantic_blocks": "artifacts/9acb2ba0/semantic_blocks.json",
|
"semantic_blocks": "artifacts/7cbdfe3c/semantic_blocks.json",
|
||||||
"vector_chunks": "artifacts/9acb2ba0/vector_chunks.json"
|
"vector_chunks": "artifacts/7cbdfe3c/vector_chunks.json"
|
||||||
},
|
},
|
||||||
"processing_stage": "indexed",
|
"processing_stage": "indexed",
|
||||||
"index_collection": "regulations_dense_1024_v1"
|
"index_collection": "regulations_dense_1024_v2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
- 上传入口保持为 `/api/v1/documents/upload`
|
- 上传入口保持为 `/api/v1/documents/upload`
|
||||||
- 默认 `PARSER_BACKEND=aliyun`
|
- 默认 `PARSER_BACKEND=aliyun`
|
||||||
- 默认 `CHUNK_BACKEND=aliyun`
|
- 默认 `CHUNK_BACKEND=aliyun`
|
||||||
- 默认 Milvus collection 为 `regulations_dense_1536_v2`
|
- 默认 Milvus collection 为 `regulations_dense_1024_v2`
|
||||||
- 解析产物落到 MinIO `artifacts/{doc_id}/`
|
- 解析产物落到 MinIO `artifacts/{doc_id}/`
|
||||||
|
|
||||||
完整主链路如下:
|
完整主链路如下:
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
5. 转换为 `structure_nodes / semantic_blocks / vector_chunks`
|
5. 转换为 `structure_nodes / semantic_blocks / vector_chunks`
|
||||||
6. 三层结构 JSON 回写 MinIO
|
6. 三层结构 JSON 回写 MinIO
|
||||||
7. 使用 `vector_chunks[*].embedding_text` 调 embedding API
|
7. 使用 `vector_chunks[*].embedding_text` 调 embedding API
|
||||||
8. 写入 `regulations_dense_1536_v2`
|
8. 写入 `regulations_dense_1024_v2`
|
||||||
9. 文档状态更新为 `indexed`
|
9. 文档状态更新为 `indexed`
|
||||||
|
|
||||||
运行时转换逻辑位于 `backend/app/infrastructure/parser/aliyun_layout_normalizer.py`。
|
运行时转换逻辑位于 `backend/app/infrastructure/parser/aliyun_layout_normalizer.py`。
|
||||||
|
|||||||
@@ -10,6 +10,31 @@
|
|||||||
- 本文档负责冻结目标模块边界、依赖规则和实现组织方式。
|
- 本文档负责冻结目标模块边界、依赖规则和实现组织方式。
|
||||||
- 后续任何代码重构、能力替换或底座升级,都应同时满足 RFC 与本文档。
|
- 后续任何代码重构、能力替换或底座升级,都应同时满足 RFC 与本文档。
|
||||||
|
|
||||||
|
## 1.1 Document Status And Authority
|
||||||
|
|
||||||
|
本文档不是仅供参考的“目标态草案”,而是当前 backend 持续开发的强制架构基线。
|
||||||
|
|
||||||
|
- 新增 backend 功能默认必须遵守本文档定义的模块边界与依赖方向。
|
||||||
|
- 历史实现、迁移中代码和兼容 façade 的存在,不构成继续偏离本文档的理由。
|
||||||
|
- 当现状与本文档冲突时,新增代码按本文档落位;旧代码按迁移计划逐步收口,但不允许继续扩大 legacy 边界。
|
||||||
|
- 评审、重构验收和后续架构讨论,均以本文档作为 backend 内部结构的 authority。
|
||||||
|
|
||||||
|
## 1.2 Authoritative Scope
|
||||||
|
|
||||||
|
本文档约束的 backend 范围包括:
|
||||||
|
|
||||||
|
- `backend/app/api/*`
|
||||||
|
- `backend/app/application/*`
|
||||||
|
- `backend/app/domain/*`
|
||||||
|
- `backend/app/infrastructure/*`
|
||||||
|
- `backend/app/shared/*`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `backend/app/services/*` 与 `backend/app/workflows/*` 当前属于迁移期 legacy 目录,不是新增业务逻辑的默认落点。
|
||||||
|
- `backend/app/api/routes/docs.py` 与 `backend/app/api/routes/rag.py` 视为遗留或非主入口,除迁移、兼容或下线动作外,不应继续扩展。
|
||||||
|
- `backend/app/api/routes/compliance.py` 当前仍对外暴露,但尚未完全满足本文档约束;在迁移到 application service 之前,应视为受控 legacy 入口,而不是新的架构样板。
|
||||||
|
|
||||||
## 2. Current-State Problems
|
## 2. Current-State Problems
|
||||||
|
|
||||||
基于当前代码,后端已经具备以下能力:
|
基于当前代码,后端已经具备以下能力:
|
||||||
@@ -22,6 +47,18 @@
|
|||||||
|
|
||||||
但这些能力当前主要是“可运行”,还不是“结构清晰、便于替换、便于演进”的状态。核心问题如下。
|
但这些能力当前主要是“可运行”,还不是“结构清晰、便于替换、便于演进”的状态。核心问题如下。
|
||||||
|
|
||||||
|
### 2.0 Current-State Verdict
|
||||||
|
|
||||||
|
基于当前仓库,现状裁决如下:
|
||||||
|
|
||||||
|
- 已基本符合:`documents` 上传/查询主链路已经通过 `DocumentCommandService` 与 `DocumentQueryService` 收口。
|
||||||
|
- 已基本符合:`knowledge` 检索已经通过 `KnowledgeRetrievalService` 统一对外暴露。
|
||||||
|
- 已基本符合:`agent` 问答主链路已经通过 `AgentConversationService` 收口,`shared/bootstrap.py` 已承担 composition root 角色。
|
||||||
|
- 部分符合:Agent session 详情、历史、删除、反馈等接口曾经直接访问 `ConversationStore`,需要继续收口到 application service。
|
||||||
|
- 未完全符合:`compliance` 路由仍直接处理文件落盘、任务状态和 mock 结果,不符合 `api -> application -> domain ports -> infrastructure`。
|
||||||
|
- 未完全符合:部分 `infrastructure` adapter 仍依赖 `services/*` 内的 legacy 实现,说明迁移尚未彻底完成。
|
||||||
|
- 未完全符合:`api/main.py` 的生命周期预热逻辑仍直接依赖旧 LLM factory,尚未完全回到统一 wiring 边界。
|
||||||
|
|
||||||
### 2.1 `DocumentProcessor` 责任过载
|
### 2.1 `DocumentProcessor` 责任过载
|
||||||
|
|
||||||
现状判断:
|
现状判断:
|
||||||
@@ -603,6 +640,7 @@ infrastructure -> external systems
|
|||||||
- `application` 只能依赖 `domain`、端口接口,以及通过 composition root 注入进来的实现实例
|
- `application` 只能依赖 `domain`、端口接口,以及通过 composition root 注入进来的实现实例
|
||||||
- `domain` 不能依赖 `api` 或 `infrastructure`
|
- `domain` 不能依赖 `api` 或 `infrastructure`
|
||||||
- `infrastructure` 可以依赖 `domain` 定义的端口和数据模型,但不能反向驱动 application 逻辑
|
- `infrastructure` 可以依赖 `domain` 定义的端口和数据模型,但不能反向驱动 application 逻辑
|
||||||
|
- `api/main.py` 这类应用入口可以保留轻量 startup/shutdown 生命周期代码,但不应长期直接依赖 legacy service factory;预热与装配逻辑应逐步收口到明确的 wiring 边界
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
@@ -739,6 +777,54 @@ infrastructure -> external systems
|
|||||||
- 内部 DTO / VO / domain object 收敛到 `application` 或 `domain`
|
- 内部 DTO / VO / domain object 收敛到 `application` 或 `domain`
|
||||||
- 不允许 API model 直接渗透到 domain
|
- 不允许 API model 直接渗透到 domain
|
||||||
|
|
||||||
|
### 10.10 应用入口与启动生命周期
|
||||||
|
|
||||||
|
当前:
|
||||||
|
|
||||||
|
- `backend/app/api/main.py`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 保留 FastAPI app、middleware 和 lifespan 入口职责
|
||||||
|
- 逐步去除对 legacy LLM factory 的直接依赖
|
||||||
|
- 预热、清理和依赖装配应保持在明确的 wiring / bootstrap 边界内,而不是继续把旧 service factory 固化为应用入口依赖
|
||||||
|
|
||||||
|
### 10.11 Compliance 路由
|
||||||
|
|
||||||
|
当前:
|
||||||
|
|
||||||
|
- `backend/app/api/routes/compliance.py`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 如继续保留该能力,应迁移到独立的 application service 与稳定端口
|
||||||
|
- 在迁移完成前,该路由视为受控 legacy 入口,可修 bug,但不应继续扩展业务编排职责
|
||||||
|
|
||||||
|
### 10.12 遗留路由入口
|
||||||
|
|
||||||
|
当前:
|
||||||
|
|
||||||
|
- `backend/app/api/routes/docs.py`
|
||||||
|
- `backend/app/api/routes/rag.py`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 作为遗留或演示入口逐步归档、下线或迁移
|
||||||
|
- 不再作为新增 backend 能力的开发入口
|
||||||
|
|
||||||
|
### 10.13 Legacy Workflow 与 Service 目录
|
||||||
|
|
||||||
|
当前:
|
||||||
|
|
||||||
|
- `backend/app/workflows/*`
|
||||||
|
- `backend/app/services/*`
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 保留迁移期兼容价值,但不再承载新的长期业务编排
|
||||||
|
- 若某个 legacy 实现仍被 `infrastructure` adapter 间接复用,应视为过渡依赖,后续逐步迁入 `infrastructure` 或更稳定的底层支撑模块
|
||||||
|
- 任何新增 backend 业务能力,都不应再以这些目录作为默认落点
|
||||||
|
|
||||||
## 11. Technology Replacement Boundaries
|
## 11. Technology Replacement Boundaries
|
||||||
|
|
||||||
### 11.1 本地解析 / MinerU -> 阿里云文档解析
|
### 11.1 本地解析 / MinerU -> 阿里云文档解析
|
||||||
@@ -790,6 +876,10 @@ infrastructure -> external systems
|
|||||||
- 禁止新建第二个“大一统流程类”替代 `DocumentProcessor`
|
- 禁止新建第二个“大一统流程类”替代 `DocumentProcessor`
|
||||||
- 禁止 `knowledge` 和 `agent` 各自维护独立检索实现
|
- 禁止 `knowledge` 和 `agent` 各自维护独立检索实现
|
||||||
- 禁止 parser、embedding、vector index、llm provider 的替换穿透到 API 层
|
- 禁止 parser、embedding、vector index、llm provider 的替换穿透到 API 层
|
||||||
|
- 禁止新增 route 直接访问 `ConversationStore`
|
||||||
|
- 禁止新增代码把 `backend/app/services/*` 或 `backend/app/workflows/*` 作为默认业务落点
|
||||||
|
- 禁止新增 `infrastructure -> services/*` 的过渡依赖;已有依赖只允许在迁移窗口内逐步消除,不允许继续扩散
|
||||||
|
- 禁止在 README、开发说明或评审结论中把 legacy 目录描述为当前 backend 的主结构
|
||||||
|
|
||||||
## 13. Architecture Review Checklist
|
## 13. Architecture Review Checklist
|
||||||
|
|
||||||
@@ -807,3 +897,7 @@ infrastructure -> external systems
|
|||||||
10. 是否明确 `knowledge` 与 `agent` 共用同一 retrieval 底座。
|
10. 是否明确 `knowledge` 与 `agent` 共用同一 retrieval 底座。
|
||||||
11. 是否明确 API 层只负责 transport concerns,不再直接承担业务编排。
|
11. 是否明确 API 层只负责 transport concerns,不再直接承担业务编排。
|
||||||
12. 是否保证后续替换方案时,上层 application service 与外部 API 契约不被迫变化。
|
12. 是否保证后续替换方案时,上层 application service 与外部 API 契约不被迫变化。
|
||||||
|
13. 是否仍存在 route 直接访问 `ConversationStore`、文件系统、对象存储或任务状态存储。
|
||||||
|
14. 是否新增了 `infrastructure -> services/*` 依赖。
|
||||||
|
15. 是否把新的 backend 业务逻辑写进了 `services/*` 或 `workflows/*`。
|
||||||
|
16. README、backend README 与协作说明是否仍与当前 authoritative architecture 保持一致。
|
||||||
|
|||||||
623
docs/architecture/document-core-processing-flow.md
Normal file
623
docs/architecture/document-core-processing-flow.md
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
# 核心文档处理主链路说明
|
||||||
|
|
||||||
|
本文件说明当前默认生产链路中的核心文档处理流程,也就是:
|
||||||
|
|
||||||
|
- `AliyunDocumentParser`
|
||||||
|
- `AliyunVectorChunkBuilder`
|
||||||
|
- `OpenAICompatibleEmbeddingProvider`
|
||||||
|
- `MilvusVectorIndex`
|
||||||
|
|
||||||
|
目标是回答四个核心问题:
|
||||||
|
|
||||||
|
1. `ParsedDocument` 为什么是多层结构
|
||||||
|
2. 这些结构分别保存到哪里
|
||||||
|
3. 哪一步才真正做了向量化
|
||||||
|
4. Milvus 里最后到底存的是什么
|
||||||
|
|
||||||
|
数据库表设计、关系模型、DDL 和 PostgreSQL 职责边界已经单独整理到 [document-processing-database-design.md](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/docs/architecture/document-processing-database-design.md:1)。本文件保留流程视角,只在必要处给出与存储分工相关的摘要,不再作为数据库设计 authority。
|
||||||
|
|
||||||
|
## 1. 主链路总览
|
||||||
|
|
||||||
|
当前默认实现由 `DocumentCommandService.upload_and_process()` 统一编排。它不是“parse 完直接进向量库”,而是先生成结构化解析产物,再把其中适合检索的一层送去 embedding 和 Milvus。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant API as API / Service
|
||||||
|
participant MinIO as BinaryStore
|
||||||
|
participant Parser as AliyunDocumentParser
|
||||||
|
participant PG as DocumentRepository / ParseArtifactStore
|
||||||
|
participant Embed as EmbeddingProvider
|
||||||
|
participant Milvus as VectorIndex
|
||||||
|
|
||||||
|
API->>MinIO: 保存原始文件
|
||||||
|
API->>Parser: parse(file_path, doc_id, doc_name)
|
||||||
|
Parser-->>API: ParsedDocument
|
||||||
|
API->>MinIO: 保存 layouts/structure_nodes/semantic_blocks/vector_chunks JSON
|
||||||
|
API->>PG: 更新 documents.status=parsed
|
||||||
|
API->>PG: 保存 structure_nodes / semantic_blocks
|
||||||
|
API->>API: chunk_builder.build(parsed_document)
|
||||||
|
API->>Embed: embed_texts([chunk.embedding_text])
|
||||||
|
Embed-->>API: vectors
|
||||||
|
API->>Milvus: upsert(chunks, vectors)
|
||||||
|
API->>PG: 更新 documents.status=indexed
|
||||||
|
```
|
||||||
|
|
||||||
|
主链路编排代码在 [services.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/application/documents/services.py:83):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upload_and_process(
|
||||||
|
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,
|
||||||
|
) -> DocumentProcessResult:
|
||||||
|
doc_id = doc_id or str(uuid.uuid4())[:8]
|
||||||
|
final_doc_name = doc_name or file_name
|
||||||
|
object_name = f"{doc_id}/{file_name}"
|
||||||
|
|
||||||
|
self.document_repository.create(document)
|
||||||
|
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)
|
||||||
|
|
||||||
|
parsed_document = self.parser.parse(file_path=temp_path, doc_id=doc_id, doc_name=final_doc_name)
|
||||||
|
artifact_keys = self._save_parse_artifacts(doc_id=doc_id, parsed_document=parsed_document)
|
||||||
|
self.document_repository.update_status(doc_id, DocumentStatus.PARSED, parser_name=parsed_document.parser_name, metadata={...})
|
||||||
|
|
||||||
|
if self.parse_artifact_store:
|
||||||
|
self.parse_artifact_store.save(doc_id, parsed_document.structure_nodes, parsed_document.semantic_blocks)
|
||||||
|
|
||||||
|
chunks = self.chunk_builder.build(parsed_document=parsed_document, regulation_type=regulation_type, version=version)
|
||||||
|
vectors = self.embedding_provider.embed_texts([chunk.embedding_text for chunk in chunks])
|
||||||
|
inserted = self.vector_index.upsert(chunks, vectors)
|
||||||
|
|
||||||
|
self.document_repository.update_status(doc_id, DocumentStatus.INDEXED, chunk_count=len(chunks), index_name=health.get("collection_name", ""), metadata={...})
|
||||||
|
```
|
||||||
|
|
||||||
|
默认绑定关系在 [bootstrap.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/shared/bootstrap.py:157):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_parser():
|
||||||
|
if settings.parser_backend == "aliyun":
|
||||||
|
return AliyunDocumentParser()
|
||||||
|
return LocalDocumentParser()
|
||||||
|
|
||||||
|
|
||||||
|
def get_chunk_builder():
|
||||||
|
if settings.chunk_backend == "aliyun":
|
||||||
|
return AliyunVectorChunkBuilder()
|
||||||
|
return LocalRegulationChunkBuilder(...)
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedding_provider() -> OpenAICompatibleEmbeddingProvider:
|
||||||
|
return OpenAICompatibleEmbeddingProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def get_vector_index() -> VectorIndex:
|
||||||
|
return LazyVectorIndex(_build_vector_index)
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说,当前默认主链路是:
|
||||||
|
|
||||||
|
- parser: `AliyunDocumentParser`
|
||||||
|
- chunk builder: `AliyunVectorChunkBuilder`
|
||||||
|
- embedding provider: `OpenAICompatibleEmbeddingProvider`
|
||||||
|
- vector index: `MilvusVectorIndex`
|
||||||
|
|
||||||
|
## 2. `ParsedDocument` 为什么是三层结构
|
||||||
|
|
||||||
|
`ParsedDocument` 不是最终入库格式,而是 parser 输出给后续处理步骤的统一中间结构。它把“结构理解”和“向量检索准备”拆成了三层。
|
||||||
|
|
||||||
|
定义在 [models.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/domain/documents/models.py:49):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class ParsedDocument:
|
||||||
|
doc_id: str
|
||||||
|
doc_name: str
|
||||||
|
structure_nodes: list[dict[str, Any]]
|
||||||
|
semantic_blocks: list[dict[str, Any]]
|
||||||
|
vector_chunks: list[dict[str, Any]]
|
||||||
|
parser_name: str
|
||||||
|
raw_text: str = ""
|
||||||
|
raw_layouts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
这三层的职责不同:
|
||||||
|
|
||||||
|
- `structure_nodes`
|
||||||
|
- 标题层级骨架
|
||||||
|
- 描述“文档有哪些章、节、条”
|
||||||
|
- 用于保留结构,不直接做 embedding
|
||||||
|
|
||||||
|
- `semantic_blocks`
|
||||||
|
- 语义块层
|
||||||
|
- 把正文、表格、图片说明整理成连续的语义单元
|
||||||
|
- 是从原始 layout 到检索 chunk 之间的中间层
|
||||||
|
|
||||||
|
- `vector_chunks`
|
||||||
|
- 检索和向量化层
|
||||||
|
- 已经是适合送给 embedding 模型的 chunk 视图
|
||||||
|
- 后续 `ChunkBuilder` 基本就是把这层映射成统一 `Chunk`
|
||||||
|
|
||||||
|
### 2.1 这三层是怎么从 parser 结果生成的
|
||||||
|
|
||||||
|
`AliyunDocumentParser.parse()` 先通过网关拿到阿里云返回的 `layouts`,再把 `layouts` 转成三层结构。
|
||||||
|
|
||||||
|
代码在 [aliyun_document_parser.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/infrastructure/parser/aliyun_document_parser.py:28):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def parse(self, *, file_path: str, doc_id: str, doc_name: str) -> ParsedDocument:
|
||||||
|
payload = self.gateway.parse_document(file_path=file_path)
|
||||||
|
layouts = payload.layouts
|
||||||
|
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_name,
|
||||||
|
max_chars=MAX_CHARS,
|
||||||
|
overlap_chars=OVERLAP_CHARS,
|
||||||
|
)
|
||||||
|
raw_text = "\n\n".join(
|
||||||
|
block.get("text", "")
|
||||||
|
for block in semantic_blocks
|
||||||
|
if block.get("text")
|
||||||
|
)
|
||||||
|
return ParsedDocument(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_name=doc_name,
|
||||||
|
structure_nodes=structure_nodes,
|
||||||
|
semantic_blocks=semantic_blocks,
|
||||||
|
vector_chunks=vector_chunks,
|
||||||
|
parser_name=self.parser_name,
|
||||||
|
raw_text=raw_text,
|
||||||
|
raw_layouts=layouts,
|
||||||
|
metadata={...},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
- parser 原始输出是 `layouts`
|
||||||
|
- 当前系统真正消费的是 `ParsedDocument`
|
||||||
|
- `ParsedDocument` 是由 normalizer 从 `layouts` 规整出来的
|
||||||
|
|
||||||
|
### 2.2 第一层:`structure_nodes`
|
||||||
|
|
||||||
|
这一层只保留标题和层级。
|
||||||
|
|
||||||
|
代码在 [aliyun_layout_normalizer.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/infrastructure/parser/aliyun_layout_normalizer.py:85):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_structure_nodes(layouts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
nodes: list[dict[str, Any]] = []
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"unique_id": "l-title-001",
|
||||||
|
"page": 2,
|
||||||
|
"index": 11,
|
||||||
|
"level": 1,
|
||||||
|
"title": "1 范围",
|
||||||
|
"type": "title",
|
||||||
|
"sub_type": "para_title"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unique_id": "l-title-002",
|
||||||
|
"page": 3,
|
||||||
|
"index": 18,
|
||||||
|
"level": 2,
|
||||||
|
"title": "1.1 适用对象",
|
||||||
|
"type": "title",
|
||||||
|
"sub_type": "para_title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
这层的意义是“保留目录树”,不是直接拿来检索。
|
||||||
|
|
||||||
|
### 2.3 第二层:`semantic_blocks`
|
||||||
|
|
||||||
|
这一层会把连续正文合并成一个语义块,也会单独处理表格和图片说明。
|
||||||
|
|
||||||
|
代码在 [aliyun_layout_normalizer.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/infrastructure/parser/aliyun_layout_normalizer.py:163):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_semantic_blocks(layouts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
semantic_blocks: list[dict[str, Any]] = []
|
||||||
|
section_stack: list[dict[str, Any]] = []
|
||||||
|
pending_text_blocks: list[dict[str, Any]] = []
|
||||||
|
block_id = 1
|
||||||
|
|
||||||
|
for layout in layouts:
|
||||||
|
text = get_text(layout)
|
||||||
|
page = get_page(layout)
|
||||||
|
|
||||||
|
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):
|
||||||
|
...
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
正文合并后会形成类似这样的语义块:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"semantic_id": "semantic-1",
|
||||||
|
"block_type": "section_text",
|
||||||
|
"page_start": 2,
|
||||||
|
"page_end": 2,
|
||||||
|
"section_path": ["1 范围", "1.1 适用对象"],
|
||||||
|
"section_level": 2,
|
||||||
|
"section_title": "1.1 适用对象",
|
||||||
|
"source_ids": ["l-text-001", "l-text-002"],
|
||||||
|
"text": "本标准适用于道路车辆动力电池系统的安全要求。企业应建立一致的测试和验证方法。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 这些结构分别保存到哪里
|
||||||
|
|
||||||
|
### 3.1 原始文件和中间 artifacts 先落 MinIO
|
||||||
|
|
||||||
|
当前链路在上传阶段会先把原始文件保存到对象存储;解析完成后,又会把结构化中间产物保存为 JSON。
|
||||||
|
|
||||||
|
保存 artifacts 的代码在 [services.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/application/documents/services.py:62):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _save_parse_artifacts(self, *, doc_id: str, parsed_document: ParsedDocument) -> dict[str, str]:
|
||||||
|
prefix = f"{parsed_document.metadata.get('artifact_prefix', 'artifacts').strip('/')}/{doc_id}"
|
||||||
|
artifact_payloads = {
|
||||||
|
"layouts": parsed_document.raw_layouts,
|
||||||
|
"structure_nodes": parsed_document.structure_nodes,
|
||||||
|
"semantic_blocks": parsed_document.semantic_blocks,
|
||||||
|
"vector_chunks": parsed_document.vector_chunks,
|
||||||
|
}
|
||||||
|
artifact_keys: dict[str, str] = {}
|
||||||
|
for name, payload in artifact_payloads.items():
|
||||||
|
object_name = f"{prefix}/{name}.json"
|
||||||
|
self.binary_store.save(
|
||||||
|
object_name=object_name,
|
||||||
|
data=json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"),
|
||||||
|
content_type="application/json",
|
||||||
|
metadata={"doc_id": doc_id, "artifact_type": name},
|
||||||
|
)
|
||||||
|
artifact_keys[name] = object_name
|
||||||
|
return artifact_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
`DocumentBinaryStore` 的当前默认实现是 `MinioDocumentBinaryStore`,也就是:
|
||||||
|
|
||||||
|
- 原始上传文件进 MinIO
|
||||||
|
- `layouts.json` 进 MinIO
|
||||||
|
- `structure_nodes.json` 进 MinIO
|
||||||
|
- `semantic_blocks.json` 进 MinIO
|
||||||
|
- `vector_chunks.json` 进 MinIO
|
||||||
|
|
||||||
|
### 3.2 PostgreSQL 在流程中的职责摘要
|
||||||
|
|
||||||
|
当前流程中,PostgreSQL 承担的是“文档元数据 + 结构化快照”的职责,而不是向量或大对象存储:
|
||||||
|
|
||||||
|
- `documents` 保存当前文档主记录、状态、统计和索引信息
|
||||||
|
- `structure_nodes` 保存当前最新解析快照的目录结构
|
||||||
|
- `semantic_blocks` 保存当前最新解析快照的语义块结构
|
||||||
|
|
||||||
|
更完整的 PostgreSQL 设计,包括:
|
||||||
|
|
||||||
|
- `documents`
|
||||||
|
- `document_processing_runs`
|
||||||
|
- `document_status_history`
|
||||||
|
- `document_artifacts`
|
||||||
|
- `structure_nodes`
|
||||||
|
- `semantic_blocks`
|
||||||
|
|
||||||
|
见 [document-processing-database-design.md](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/docs/architecture/document-processing-database-design.md:1)。
|
||||||
|
|
||||||
|
### 3.3 存储分工一览
|
||||||
|
|
||||||
|
| 数据层 | 保存位置 | 是否直接用于 embedding | 是否最终进入 Milvus | 主要用途 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 原始文件 | MinIO | 否 | 否 | 保留原始上传文档 |
|
||||||
|
| `raw_layouts` | MinIO `layouts.json` | 否 | 否 | 保留 parser 原始返回 |
|
||||||
|
| `structure_nodes` | MinIO + PostgreSQL | 否 | 否 | 目录树、层级结构 |
|
||||||
|
| `semantic_blocks` | MinIO + PostgreSQL | 否 | 间接 | 语义单元、中间层 |
|
||||||
|
| `vector_chunks` | MinIO | 是 | 间接 | embedding 前的检索块 |
|
||||||
|
| `Chunk` | 内存态 + Milvus | 是 | 是 | 统一向量入库模型 |
|
||||||
|
| `documents` 元数据 | PostgreSQL | 否 | 否 | 处理状态、统计、索引信息 |
|
||||||
|
|
||||||
|
## 4. 哪一步才真正“变成向量”
|
||||||
|
|
||||||
|
这是整个流程最关键的点。
|
||||||
|
|
||||||
|
结论先说清楚:
|
||||||
|
|
||||||
|
- parse 不做向量化
|
||||||
|
- 保存 artifacts 不做向量化
|
||||||
|
- `ChunkBuilder.build()` 也不做向量化
|
||||||
|
- 只有 `EmbeddingProvider.embed_texts()` 才真正调用 embedding 模型
|
||||||
|
- 只有 `VectorIndex.upsert()` 才真正把向量写入向量库
|
||||||
|
|
||||||
|
### 4.1 `vector_chunks` 先被映射成统一 `Chunk`
|
||||||
|
|
||||||
|
`AliyunVectorChunkBuilder` 并不做 embedding,它只负责把 `ParsedDocument.vector_chunks` 转成领域层统一 `Chunk` 模型。
|
||||||
|
|
||||||
|
代码在 [vector_chunk_builder.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/infrastructure/parser/vector_chunk_builder.py:12):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
parsed_document: ParsedDocument,
|
||||||
|
regulation_type: str,
|
||||||
|
version: str,
|
||||||
|
) -> list[Chunk]:
|
||||||
|
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
|
||||||
|
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"}}
|
||||||
|
chunks.append(
|
||||||
|
Chunk(
|
||||||
|
chunk_id=str(chunk_id),
|
||||||
|
doc_id=parsed_document.doc_id,
|
||||||
|
doc_name=parsed_document.doc_name,
|
||||||
|
content=content,
|
||||||
|
embedding_text=embedding_text,
|
||||||
|
section_title=section_title,
|
||||||
|
section_path=section_path,
|
||||||
|
page_number=int(page_number or 0),
|
||||||
|
regulation_type=regulation_type,
|
||||||
|
version=version,
|
||||||
|
semantic_id=item.get("semantic_id", ""),
|
||||||
|
block_type=item.get("block_type", ""),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return chunks
|
||||||
|
```
|
||||||
|
|
||||||
|
`Chunk` 的定义在 [models.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/domain/documents/models.py:63):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class Chunk:
|
||||||
|
chunk_id: str
|
||||||
|
doc_id: str
|
||||||
|
doc_name: str
|
||||||
|
content: str
|
||||||
|
embedding_text: str
|
||||||
|
section_title: str = ""
|
||||||
|
section_path: list[str] = field(default_factory=list)
|
||||||
|
page_number: int = 0
|
||||||
|
regulation_type: str = ""
|
||||||
|
version: str = ""
|
||||||
|
semantic_id: str = ""
|
||||||
|
block_type: str = ""
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
一个 `Chunk` 的典型样子如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chunk_id": "doc-001-chunk-1",
|
||||||
|
"doc_id": "doc-001",
|
||||||
|
"doc_name": "动力电池安全规范",
|
||||||
|
"content": "本标准适用于道路车辆动力电池系统的安全要求。企业应建立一致的测试和验证方法。",
|
||||||
|
"embedding_text": "标准:动力电池安全规范\n章节:1 范围 > 1.1 适用对象\n\n本标准适用于道路车辆动力电池系统的安全要求。企业应建立一致的测试和验证方法。",
|
||||||
|
"section_title": "1.1 适用对象",
|
||||||
|
"section_path": ["1 范围", "1.1 适用对象"],
|
||||||
|
"page_number": 2,
|
||||||
|
"regulation_type": "GB",
|
||||||
|
"version": "2025",
|
||||||
|
"semantic_id": "semantic-1",
|
||||||
|
"block_type": "section_text",
|
||||||
|
"metadata": {
|
||||||
|
"chunk_index": 1,
|
||||||
|
"piece_index": 1,
|
||||||
|
"source_ids": ["l-text-001", "l-text-002"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这里最关键的是要区分两个字段:
|
||||||
|
|
||||||
|
- `content`
|
||||||
|
- 用于检索命中后的展示内容
|
||||||
|
- 更接近用户最终看到的正文片段
|
||||||
|
|
||||||
|
- `embedding_text`
|
||||||
|
- 用于送给 embedding 模型
|
||||||
|
- 比 `content` 多了“标准名 + 章节路径”的上下文
|
||||||
|
|
||||||
|
所以“向量化输入”不是纯正文,而是增强后的上下文文本。
|
||||||
|
|
||||||
|
### 4.2 真正调用 embedding API 的地方
|
||||||
|
|
||||||
|
真正把文本变成向量的是 `OpenAICompatibleEmbeddingProvider.embed_texts()`。
|
||||||
|
|
||||||
|
代码在 [openai_compatible_embedding_provider.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/infrastructure/embedding/openai_compatible_embedding_provider.py:64):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说,只有在这一步:
|
||||||
|
|
||||||
|
- 输入:`list[str]` 的 `embedding_text`
|
||||||
|
- 输出:`list[list[float]]` 的 dense vectors
|
||||||
|
|
||||||
|
前面的 parse、normalizer、chunk builder 都只是准备文本,没有任何向量值产生。
|
||||||
|
|
||||||
|
### 4.3 真正把向量写进 Milvus 的地方
|
||||||
|
|
||||||
|
向量值生成之后,`MilvusVectorIndex.upsert()` 才会把 `Chunk + vector` 写入向量库。
|
||||||
|
|
||||||
|
代码在 [milvus_vector_index.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/infrastructure/vectorstore/milvus_vector_index.py:69):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upsert(self, chunks: list[Chunk], vectors: list[list[float]]) -> int:
|
||||||
|
if len(chunks) != len(vectors):
|
||||||
|
raise ValueError("chunks 与 vectors 数量不一致")
|
||||||
|
data = []
|
||||||
|
now = int(time.time())
|
||||||
|
for chunk, vector in zip(chunks, vectors):
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"id": chunk.chunk_id,
|
||||||
|
"doc_id": chunk.doc_id,
|
||||||
|
"doc_name": chunk.doc_name,
|
||||||
|
"content": chunk.content[:65535],
|
||||||
|
"embedding": vector,
|
||||||
|
"section_title": chunk.section_title[:512],
|
||||||
|
"section_path": json.dumps(chunk.section_path, ensure_ascii=False)[:4096],
|
||||||
|
"page_number": chunk.page_number,
|
||||||
|
"regulation_type": chunk.regulation_type[:128],
|
||||||
|
"version": chunk.version[:64],
|
||||||
|
"semantic_id": chunk.semantic_id[:128],
|
||||||
|
"block_type": chunk.block_type[:64],
|
||||||
|
"metadata_json": json.dumps(chunk.metadata, ensure_ascii=False)[:65535],
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.collection.insert(data)
|
||||||
|
self.collection.flush()
|
||||||
|
return len(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是说,Milvus 最终存进去的是:
|
||||||
|
|
||||||
|
- 主键:`chunk_id`
|
||||||
|
- 文档维度字段:`doc_id`、`doc_name`
|
||||||
|
- 检索展示字段:`content`
|
||||||
|
- 向量字段:`embedding`
|
||||||
|
- 过滤/回溯字段:`section_title`、`section_path`、`page_number`、`regulation_type`、`version`、`semantic_id`、`block_type`
|
||||||
|
- 附加元数据:`metadata_json`
|
||||||
|
|
||||||
|
## 5. Milvus 里最后到底存的是什么
|
||||||
|
|
||||||
|
### 5.1 Collection schema
|
||||||
|
|
||||||
|
当前 `MilvusVectorIndex` 初始化 collection 时定义的 schema 在 [milvus_vector_index.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/infrastructure/vectorstore/milvus_vector_index.py:37):
|
||||||
|
|
||||||
|
```python
|
||||||
|
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_name", dtype=DataType.VARCHAR, max_length=256),
|
||||||
|
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535),
|
||||||
|
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=settings.embedding_dim),
|
||||||
|
FieldSchema(name="section_title", dtype=DataType.VARCHAR, max_length=512),
|
||||||
|
FieldSchema(name="section_path", dtype=DataType.VARCHAR, max_length=4096),
|
||||||
|
FieldSchema(name="page_number", dtype=DataType.INT64),
|
||||||
|
FieldSchema(name="regulation_type", dtype=DataType.VARCHAR, max_length=128),
|
||||||
|
FieldSchema(name="version", dtype=DataType.VARCHAR, max_length=64),
|
||||||
|
FieldSchema(name="semantic_id", dtype=DataType.VARCHAR, max_length=128),
|
||||||
|
FieldSchema(name="block_type", dtype=DataType.VARCHAR, max_length=64),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
这说明 Milvus 存的不是“只有 embedding 的极简向量表”,而是:
|
||||||
|
|
||||||
|
- 一个 dense vector
|
||||||
|
- 一组检索时要返回或过滤的结构化字段
|
||||||
|
|
||||||
|
但要注意:这并不意味着 Milvus 是业务主记录库。它仍然主要服务于检索,而不是替代 PostgreSQL 的文档管理职责。
|
||||||
|
|
||||||
|
### 5.2 `list_documents()` 为什么会先看 Milvus
|
||||||
|
|
||||||
|
文档列表查询在 [services.py](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/backend/app/application/documents/services.py:271) 中实现,它会:
|
||||||
|
|
||||||
|
1. 从 Milvus 查询当前真的存在向量的文档
|
||||||
|
2. 从文档元数据仓储加载文档记录
|
||||||
|
3. 以 Milvus 为索引状态真相源进行 merge
|
||||||
|
|
||||||
|
原因不是“Milvus 替代 PostgreSQL”,而是:
|
||||||
|
|
||||||
|
- `indexed` 这个状态最终是否真实成立,要看 Milvus 里有没有对应 chunk
|
||||||
|
- 但下载、删除、重试、文件定位、错误信息仍然要依赖文档元数据仓储
|
||||||
|
|
||||||
|
所以:
|
||||||
|
|
||||||
|
- Milvus 是“索引真相源”
|
||||||
|
- PostgreSQL/JSON 是“文档元数据真相源”
|
||||||
|
|
||||||
|
这两者职责不同,不能互相替代。
|
||||||
508
docs/architecture/document-processing-database-design.md
Normal file
508
docs/architecture/document-processing-database-design.md
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
# 文档处理链路数据库设计
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
本文档定义当前文档处理主链路的 PostgreSQL 数据库设计,覆盖上传、解析、索引、状态查询、重试、删除这条核心链路,以及围绕该链路的常用运维与审计需求。
|
||||||
|
|
||||||
|
本文档的目标不是替代 [document-core-processing-flow.md](/abs/path/C:/Users/A200477427/Developers/AIRegulation/AIRegulation-DocAnalysis/docs/architecture/document-core-processing-flow.md:1) 的流程说明,而是补齐关系型存储的 authority,使后续从 JSON 元数据切换到 PostgreSQL 时有清晰、稳定、可实施的数据库设计基线。
|
||||||
|
|
||||||
|
## 1.1 Scope And Design Target
|
||||||
|
|
||||||
|
本文档只覆盖以下范围:
|
||||||
|
|
||||||
|
- 文档主记录
|
||||||
|
- 文档处理运行记录
|
||||||
|
- 文档状态历史
|
||||||
|
- 解析产物引用
|
||||||
|
- 当前最新结构化解析快照
|
||||||
|
|
||||||
|
本文档不覆盖以下范围:
|
||||||
|
|
||||||
|
- Agent 会话
|
||||||
|
- 反馈和人工审核
|
||||||
|
- 合规分析任务
|
||||||
|
- Milvus collection schema 的详细实现
|
||||||
|
|
||||||
|
设计原则采用 `Compat First`:
|
||||||
|
|
||||||
|
- 保持与当前 `DocumentRepository` / `ParseArtifactStore` 主流程兼容
|
||||||
|
- 新增关系表以补足运维与审计能力
|
||||||
|
- 不为了理想化模型而反推大规模接口重写
|
||||||
|
|
||||||
|
## 2. Storage Responsibilities
|
||||||
|
|
||||||
|
当前系统采用三类存储,各自职责必须清晰分离:
|
||||||
|
|
||||||
|
| 存储 | 保存内容 | 是否业务主记录 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| MinIO | 原始文件、`layouts.json`、`structure_nodes.json`、`semantic_blocks.json`、`vector_chunks.json` | 否 | 负责大对象与产物归档,不承担关系查询 |
|
||||||
|
| Milvus | chunk 级向量和检索辅助字段 | 否 | 负责向量检索,不承担文档生命周期管理 |
|
||||||
|
| PostgreSQL | 文档元数据、处理状态、结构化快照、处理历史、artifact 引用 | 是 | 负责文档管理、运维可观测性和关系查询 |
|
||||||
|
|
||||||
|
约束说明:
|
||||||
|
|
||||||
|
- PostgreSQL 不保存 embedding 向量。
|
||||||
|
- PostgreSQL 不新增 `vector_chunks` 内容表。
|
||||||
|
- Milvus 可以保存 `doc_id`、`doc_name`、`regulation_type`、`version` 等检索辅助字段,但不是业务真相源。
|
||||||
|
- 文档下载、删除、重试仍以 PostgreSQL 中的文档主记录为入口。
|
||||||
|
|
||||||
|
## 3. Design Overview
|
||||||
|
|
||||||
|
### 3.1 Entity Responsibilities
|
||||||
|
|
||||||
|
数据库采用“当前态主记录 + 当前快照 + 历史过程”的分层模型:
|
||||||
|
|
||||||
|
- `documents`
|
||||||
|
- 当前文档主记录
|
||||||
|
- 保存供管理、下载、重试、删除直接使用的元数据和当前状态
|
||||||
|
- `document_processing_runs`
|
||||||
|
- 每次上传或重试对应一次处理运行
|
||||||
|
- 保存运行级统计、阶段时间点和失败信息
|
||||||
|
- `document_status_history`
|
||||||
|
- 追加式状态事件流
|
||||||
|
- 保存每次状态变更的上下文
|
||||||
|
- `document_artifacts`
|
||||||
|
- 保存 MinIO artifact 的引用信息
|
||||||
|
- 不保存 artifact 内容本体
|
||||||
|
- `structure_nodes`
|
||||||
|
- 当前最新解析快照中的目录结构
|
||||||
|
- `semantic_blocks`
|
||||||
|
- 当前最新解析快照中的语义块结构
|
||||||
|
|
||||||
|
### 3.2 Current Snapshot Vs Historical Records
|
||||||
|
|
||||||
|
本设计显式区分两类数据:
|
||||||
|
|
||||||
|
- 当前快照
|
||||||
|
- `documents`
|
||||||
|
- `structure_nodes`
|
||||||
|
- `semantic_blocks`
|
||||||
|
- 历史过程
|
||||||
|
- `document_processing_runs`
|
||||||
|
- `document_status_history`
|
||||||
|
- `document_artifacts`
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `structure_nodes` 和 `semantic_blocks` 只保存“最新一次成功解析后”的当前快照
|
||||||
|
- 历史版本回溯依赖 `document_processing_runs`、`document_artifacts` 和 MinIO 中对应 run 的 artifact 文件
|
||||||
|
|
||||||
|
## 4. Table Design
|
||||||
|
|
||||||
|
### 4.1 `documents`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 作为文档生命周期的主记录表
|
||||||
|
- 为下载、删除、重试、管理列表、状态查询提供当前态真相
|
||||||
|
|
||||||
|
字段设计:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
doc_id VARCHAR(128) PRIMARY KEY,
|
||||||
|
doc_name VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
file_name VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
object_name VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
|
content_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
regulation_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
version VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
|
summary_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
parser_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
index_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_documents_status
|
||||||
|
CHECK (status IN ('pending', 'stored', 'parsed', 'indexed', 'failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_status_updated_at
|
||||||
|
ON documents(status, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_regulation_version
|
||||||
|
ON documents(regulation_type, version);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_updated_at
|
||||||
|
ON documents(updated_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `object_name`
|
||||||
|
- 原始上传文件在 MinIO 中的对象路径
|
||||||
|
- 当前实现依赖该字段完成下载、重试和删除,v1 不拆分为独立文件表
|
||||||
|
- `status`
|
||||||
|
- 当前文档处理状态
|
||||||
|
- 仅表示当前态,不承担历史审计职责
|
||||||
|
- `metadata`
|
||||||
|
- 保存轻量、变动频率较高、暂不值得列式建模的附加信息
|
||||||
|
- 典型内容包括 `parse_task_id`、`processing_stage`、`artifact_keys`、统计计数等
|
||||||
|
|
||||||
|
### 4.2 `document_processing_runs`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 记录一次上传或一次重试的完整处理运行
|
||||||
|
- 用于解释“这份文档本次处理为什么成功或失败”
|
||||||
|
|
||||||
|
字段设计:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS document_processing_runs (
|
||||||
|
run_id BIGSERIAL PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
trigger_type VARCHAR(16) NOT NULL,
|
||||||
|
run_status VARCHAR(16) NOT NULL,
|
||||||
|
parser_backend VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
chunk_backend VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
embedding_model VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
index_name VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
stored_at TIMESTAMPTZ,
|
||||||
|
parsed_at TIMESTAMPTZ,
|
||||||
|
indexed_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
layout_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
structure_node_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
semantic_block_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
vector_chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
chunk_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failure_stage VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
CONSTRAINT fk_runs_document
|
||||||
|
FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT chk_runs_trigger_type
|
||||||
|
CHECK (trigger_type IN ('upload', 'retry')),
|
||||||
|
CONSTRAINT chk_runs_status
|
||||||
|
CHECK (run_status IN ('running', 'succeeded', 'failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_doc_started_at
|
||||||
|
ON document_processing_runs(doc_id, started_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_status_started_at
|
||||||
|
ON document_processing_runs(run_status, started_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `trigger_type`
|
||||||
|
- 标识该次处理由首次上传还是 retry 触发
|
||||||
|
- `run_status`
|
||||||
|
- 只表示该次运行的最终结果
|
||||||
|
- `failure_stage`
|
||||||
|
- 建议取值与应用层关键阶段一致,例如 `store`、`parse`、`artifact_persist`、`embed`、`index`
|
||||||
|
- `metadata`
|
||||||
|
- 保存运行级附加上下文,例如配置快照、后端实现名、provider 返回信息摘要
|
||||||
|
|
||||||
|
### 4.3 `document_status_history`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 保存状态变化事件流
|
||||||
|
- 用于排障、审计和运行轨迹分析
|
||||||
|
|
||||||
|
字段设计:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS document_status_history (
|
||||||
|
event_id BIGSERIAL PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
run_id BIGINT,
|
||||||
|
from_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
to_status VARCHAR(32) NOT NULL,
|
||||||
|
stage VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
message TEXT NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_status_document
|
||||||
|
FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_status_run
|
||||||
|
FOREIGN KEY (run_id) REFERENCES document_processing_runs(run_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT chk_status_history_to_status
|
||||||
|
CHECK (to_status IN ('pending', 'stored', 'parsed', 'indexed', 'failed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_status_history_doc_occurred_at
|
||||||
|
ON document_status_history(doc_id, occurred_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_status_history_run_occurred_at
|
||||||
|
ON document_status_history(run_id, occurred_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `from_status` 可以为空字符串
|
||||||
|
- 用于首个事件,例如文档创建时进入 `pending`
|
||||||
|
- `stage`
|
||||||
|
- 用于记录状态推进对应的业务阶段
|
||||||
|
- `message`
|
||||||
|
- 用于记录面向排障的人类可读说明
|
||||||
|
|
||||||
|
### 4.4 `document_artifacts`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 保存解析产物在 MinIO 中的位置与基本属性
|
||||||
|
- 支持后续定位某次 run 的 artifacts,而不扫描对象存储
|
||||||
|
|
||||||
|
字段设计:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS document_artifacts (
|
||||||
|
artifact_id BIGSERIAL PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
run_id BIGINT,
|
||||||
|
artifact_type VARCHAR(32) NOT NULL,
|
||||||
|
object_name VARCHAR(1024) NOT NULL,
|
||||||
|
content_type VARCHAR(128) NOT NULL DEFAULT 'application/json',
|
||||||
|
byte_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
checksum VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_artifacts_document
|
||||||
|
FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_artifacts_run
|
||||||
|
FOREIGN KEY (run_id) REFERENCES document_processing_runs(run_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT chk_artifact_type
|
||||||
|
CHECK (artifact_type IN ('layouts', 'structure_nodes', 'semantic_blocks', 'vector_chunks'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_doc_created_at
|
||||||
|
ON document_artifacts(doc_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artifacts_run_type
|
||||||
|
ON document_artifacts(run_id, artifact_type);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_artifacts_run_type_object
|
||||||
|
ON document_artifacts(run_id, artifact_type, object_name);
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- 该表只记录 artifact 引用,不记录原始文件
|
||||||
|
- 原始文件仍由 `documents.object_name` 表达,这是为了保持当前下载和重试逻辑兼容
|
||||||
|
|
||||||
|
### 4.5 `structure_nodes`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 保存当前最新解析快照中的标题层级结构
|
||||||
|
- 供目录树查询、结构化浏览、调试和审计使用
|
||||||
|
|
||||||
|
字段设计:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS structure_nodes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
unique_id VARCHAR(128),
|
||||||
|
page INTEGER NOT NULL DEFAULT 0,
|
||||||
|
idx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
type VARCHAR(64),
|
||||||
|
sub_type VARCHAR(64),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_structure_nodes_document
|
||||||
|
FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_structure_nodes_doc_idx
|
||||||
|
ON structure_nodes(doc_id, idx);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_structure_nodes_doc_level
|
||||||
|
ON structure_nodes(doc_id, level);
|
||||||
|
```
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
|
||||||
|
- 该表表示当前快照,不做多版本建模
|
||||||
|
- 新一轮成功解析会覆盖同一 `doc_id` 的旧快照
|
||||||
|
|
||||||
|
### 4.6 `semantic_blocks`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 保存当前最新解析快照中的语义块
|
||||||
|
- 供结构回溯、调试和后续关系型查询使用
|
||||||
|
|
||||||
|
字段设计:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS semantic_blocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
doc_id VARCHAR(128) NOT NULL,
|
||||||
|
semantic_id VARCHAR(128) NOT NULL,
|
||||||
|
block_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
page_start INTEGER NOT NULL DEFAULT 0,
|
||||||
|
page_end INTEGER NOT NULL DEFAULT 0,
|
||||||
|
section_path JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
section_level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
section_title VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
source_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_semantic_blocks_document
|
||||||
|
FOREIGN KEY (doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_semantic_blocks_doc_semantic
|
||||||
|
UNIQUE (doc_id, semantic_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_semantic_blocks_doc_id
|
||||||
|
ON semantic_blocks(doc_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_semantic_blocks_doc_section_title
|
||||||
|
ON semantic_blocks(doc_id, section_title);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_semantic_blocks_doc_block_type
|
||||||
|
ON semantic_blocks(doc_id, block_type);
|
||||||
|
```
|
||||||
|
|
||||||
|
设计约束:
|
||||||
|
|
||||||
|
- 该表表示当前快照,不保存历史版本
|
||||||
|
- 历史回溯应通过 run 对应的 artifact 文件完成
|
||||||
|
|
||||||
|
## 5. Relationship Model
|
||||||
|
|
||||||
|
实体关系如下:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
documents ||--o{ document_processing_runs : has
|
||||||
|
documents ||--o{ document_status_history : has
|
||||||
|
documents ||--o{ document_artifacts : has
|
||||||
|
documents ||--o{ structure_nodes : has
|
||||||
|
documents ||--o{ semantic_blocks : has
|
||||||
|
document_processing_runs ||--o{ document_status_history : emits
|
||||||
|
document_processing_runs ||--o{ document_artifacts : produces
|
||||||
|
```
|
||||||
|
|
||||||
|
关系语义:
|
||||||
|
|
||||||
|
- `documents` 是聚合根
|
||||||
|
- `document_processing_runs` 记录一次完整处理尝试
|
||||||
|
- `document_status_history` 记录状态推进轨迹
|
||||||
|
- `document_artifacts` 记录 MinIO 中可回放的结构化产物
|
||||||
|
- `structure_nodes` / `semantic_blocks` 代表“当前版本”的关系型快照
|
||||||
|
|
||||||
|
## 6. Flow-To-Table Mapping
|
||||||
|
|
||||||
|
### 6.1 Upload
|
||||||
|
|
||||||
|
上传开始时:
|
||||||
|
|
||||||
|
1. 创建 `documents`
|
||||||
|
2. 创建一条 `document_processing_runs`
|
||||||
|
3. 写入一条 `document_status_history`,`to_status='pending'`
|
||||||
|
|
||||||
|
### 6.2 Store Original File
|
||||||
|
|
||||||
|
原始文件写入 MinIO 成功后:
|
||||||
|
|
||||||
|
1. 更新 `documents.status='stored'`
|
||||||
|
2. 更新当前 run 的 `stored_at`
|
||||||
|
3. 追加 `document_status_history`
|
||||||
|
|
||||||
|
### 6.3 Parse And Persist Artifacts
|
||||||
|
|
||||||
|
解析成功后:
|
||||||
|
|
||||||
|
1. 更新当前 run 的 `parsed_at`
|
||||||
|
2. 更新 run 的 `layout_count`、`structure_node_count`、`semantic_block_count`、`vector_chunk_count`
|
||||||
|
3. 更新 `documents.status='parsed'`
|
||||||
|
4. 刷新 `structure_nodes`
|
||||||
|
5. 刷新 `semantic_blocks`
|
||||||
|
6. 为 `layouts`、`structure_nodes`、`semantic_blocks`、`vector_chunks` 写入 `document_artifacts`
|
||||||
|
7. 追加 `document_status_history`
|
||||||
|
|
||||||
|
### 6.4 Embed And Index
|
||||||
|
|
||||||
|
向量化和入库成功后:
|
||||||
|
|
||||||
|
1. 更新当前 run 的 `indexed_at`、`finished_at`
|
||||||
|
2. 更新当前 run 的 `run_status='succeeded'`
|
||||||
|
3. 更新 `documents.status='indexed'`
|
||||||
|
4. 更新 `documents.chunk_count`、`index_name`
|
||||||
|
5. 追加 `document_status_history`
|
||||||
|
|
||||||
|
### 6.5 Failure
|
||||||
|
|
||||||
|
任一阶段失败时:
|
||||||
|
|
||||||
|
1. 更新当前 run 的 `run_status='failed'`
|
||||||
|
2. 记录 `failure_stage` 和 `error_message`
|
||||||
|
3. 更新 `finished_at`
|
||||||
|
4. 更新 `documents.status='failed'`
|
||||||
|
5. 更新 `documents.error_message`
|
||||||
|
6. 追加 `document_status_history`
|
||||||
|
|
||||||
|
### 6.6 Retry
|
||||||
|
|
||||||
|
重试时:
|
||||||
|
|
||||||
|
1. 保留现有 `documents.doc_id`
|
||||||
|
2. 新建一条 `document_processing_runs`
|
||||||
|
3. 为本次重试重新写入状态历史
|
||||||
|
4. 本次重试成功后覆盖 `structure_nodes` / `semantic_blocks` 当前快照
|
||||||
|
5. 历史 run 和 artifact 记录继续保留
|
||||||
|
|
||||||
|
### 6.7 Delete
|
||||||
|
|
||||||
|
删除文档时:
|
||||||
|
|
||||||
|
1. 应用层先删除 MinIO 原始文件和 artifacts
|
||||||
|
2. 应用层删除 Milvus 中按 `doc_id` 关联的向量
|
||||||
|
3. 最后删除 `documents`
|
||||||
|
4. 依赖外键 `ON DELETE CASCADE` 清理 run、status history、artifacts、structure nodes、semantic blocks
|
||||||
|
|
||||||
|
## 7. Alignment With Current Backend
|
||||||
|
|
||||||
|
### 7.1 Compatible Parts
|
||||||
|
|
||||||
|
当前代码已天然兼容以下设计:
|
||||||
|
|
||||||
|
- `documents`
|
||||||
|
- `structure_nodes`
|
||||||
|
- `semantic_blocks`
|
||||||
|
- 当前快照覆盖式更新
|
||||||
|
- `doc_id` 作为跨 MinIO / Milvus / PostgreSQL 的统一关联键
|
||||||
|
|
||||||
|
### 7.2 Required Future Additions
|
||||||
|
|
||||||
|
若后续正式切到 PostgreSQL 默认元数据后端,应新增以下内部 store 或 repository:
|
||||||
|
|
||||||
|
- `DocumentProcessingRunStore`
|
||||||
|
- `DocumentStatusEventStore`
|
||||||
|
- `DocumentArtifactStore`
|
||||||
|
|
||||||
|
这些新增能力属于内部增强,不要求修改现有 HTTP API。
|
||||||
|
|
||||||
|
### 7.3 Migration Guidance
|
||||||
|
|
||||||
|
从当前 JSON 元数据切换到 PostgreSQL 时,建议按以下顺序进行:
|
||||||
|
|
||||||
|
1. 迁移 `documents.json` 中已有文档主记录到 `documents`
|
||||||
|
2. 将 `DOCUMENT_REPOSITORY_BACKEND` 切换为 `postgres`
|
||||||
|
3. 为新上传或重试的文档开始写入 run / status history / artifact records
|
||||||
|
4. 历史文档若缺少 run 级数据,可允许为空,不阻塞切换
|
||||||
|
|
||||||
|
## 8. Non-Goals
|
||||||
|
|
||||||
|
以下能力不在本设计 v1 范围内:
|
||||||
|
|
||||||
|
- 将 Milvus 替换为 PostgreSQL 向量能力
|
||||||
|
- 在 PostgreSQL 中保存向量字段
|
||||||
|
- 为 `vector_chunks` 建独立关系表
|
||||||
|
- 为 `structure_nodes` / `semantic_blocks` 建历史版本仓库
|
||||||
|
- 将原始文件抽象成独立 `document_files` 表
|
||||||
|
|
||||||
|
这些能力可能在后续重构时被讨论,但不应影响当前主链路切换和现有应用层兼容性。
|
||||||
636
docs/superpowers/plans/2026-05-21-system-status-optimizations.md
Normal file
636
docs/superpowers/plans/2026-05-21-system-status-optimizations.md
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
# System Status Module Optimization Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Upgrade the 系统状态 page from a static one-shot snapshot into a reliable observability dashboard with loading states, service health checks, auto-refresh, BM25/Reranker visibility, and config display fixes.
|
||||||
|
|
||||||
|
**Architecture:** Backend adds a unified `/status/health` aggregate endpoint (Milvus + MinIO + BM25 + Reranker + Sessions) and a TTL cache on `/status/stats`. Frontend adds loading/error states, a refresh button, auto-polling while documents are processing, a health services panel, and config value truncation fixes.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI, Python 3.11, React 18, TypeScript, CSS-in-JS (inline styles with theme context)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `backend/app/api/routes/status.py` | Modify | Add `/health` endpoint, TTL cache on `/stats`, import session store |
|
||||||
|
| `frontend/src/api/status.ts` | Modify | Add `SystemHealth` type + `getSystemHealth()` |
|
||||||
|
| `frontend/src/api/index.ts` | Modify | Export `SystemHealth` interface |
|
||||||
|
| `frontend/src/pages/Status/StatusPage.tsx` | Modify | Loading/error states, refresh, auto-poll, health panel, config fix |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — Add `/status/health` Endpoint + TTL Cache on `/status/stats`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/api/routes/status.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current `status.py`**
|
||||||
|
|
||||||
|
```
|
||||||
|
File: backend/app/api/routes/status.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Note existing imports and route signatures before editing.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `status.py` with the new version**
|
||||||
|
|
||||||
|
Replace the entire content of `backend/app/api/routes/status.py` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Define API routes for status."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.shared.bootstrap import (
|
||||||
|
get_bm25_retriever,
|
||||||
|
get_binary_store,
|
||||||
|
get_conversation_store,
|
||||||
|
get_document_query_service,
|
||||||
|
get_vector_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/status", tags=["系统状态"])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Simple TTL cache for /stats (avoids O(N) doc scan on every request)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_stats_cache: dict[str, Any] = {}
|
||||||
|
_stats_cache_time: float = 0.0
|
||||||
|
_STATS_TTL_SECONDS: float = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_stats_cache() -> None:
|
||||||
|
global _stats_cache_time
|
||||||
|
_stats_cache_time = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats():
|
||||||
|
"""Return document statistics (cached for 10 s)."""
|
||||||
|
global _stats_cache, _stats_cache_time
|
||||||
|
now = time.time()
|
||||||
|
if _stats_cache and (now - _stats_cache_time) < _STATS_TTL_SECONDS:
|
||||||
|
return _stats_cache
|
||||||
|
|
||||||
|
documents = get_document_query_service().list_documents()
|
||||||
|
indexed = sum(1 for d in documents if d.status.value == "indexed")
|
||||||
|
failed = sum(1 for d in documents if d.status.value == "failed")
|
||||||
|
_stats_cache = {
|
||||||
|
"documents_total": len(documents),
|
||||||
|
"documents_indexed": indexed,
|
||||||
|
"documents_failed": failed,
|
||||||
|
"chunks_total": sum(d.chunk_count for d in documents),
|
||||||
|
}
|
||||||
|
_stats_cache_time = now
|
||||||
|
return _stats_cache
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_config():
|
||||||
|
"""Return system configuration."""
|
||||||
|
return {
|
||||||
|
"embedding_model": settings.embedding_model,
|
||||||
|
"embedding_dim": settings.embedding_dim,
|
||||||
|
"embedding_base_url": settings.embedding_base_url,
|
||||||
|
"milvus_collection": settings.milvus_collection,
|
||||||
|
"parser_backend": settings.parser_backend,
|
||||||
|
"chunk_backend": settings.chunk_backend,
|
||||||
|
"artifact_prefix": settings.document_parse_artifact_prefix,
|
||||||
|
"parser_failure_mode": settings.parser_failure_mode,
|
||||||
|
"llm_provider": settings.llm_provider,
|
||||||
|
"llm_model": settings.llm_model,
|
||||||
|
"document_metadata_path": settings.document_metadata_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/milvus/health")
|
||||||
|
async def milvus_health():
|
||||||
|
"""Return Milvus health (kept for backwards compat)."""
|
||||||
|
return get_vector_index().health()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def get_health():
|
||||||
|
"""Return aggregate health of all backend services."""
|
||||||
|
# --- Milvus ---
|
||||||
|
try:
|
||||||
|
milvus_info = get_vector_index().health()
|
||||||
|
milvus_status = "ok" if milvus_info.get("connected") else "error"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
milvus_info = {}
|
||||||
|
milvus_status = "error"
|
||||||
|
milvus_info["error"] = str(exc)
|
||||||
|
|
||||||
|
# --- MinIO ---
|
||||||
|
try:
|
||||||
|
minio_connected = get_binary_store().client.connected
|
||||||
|
minio_status = "ok" if minio_connected else "error"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
minio_status = "error"
|
||||||
|
minio_connected = False
|
||||||
|
|
||||||
|
# --- BM25 ---
|
||||||
|
bm25 = get_bm25_retriever()
|
||||||
|
|
||||||
|
# --- Sessions ---
|
||||||
|
try:
|
||||||
|
session_count = len(get_conversation_store().list_sessions())
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
session_count = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"milvus": {"status": milvus_status, **milvus_info},
|
||||||
|
"minio": {"status": minio_status, "connected": minio_connected},
|
||||||
|
"bm25": {"available": bm25 is not None},
|
||||||
|
"reranker": {
|
||||||
|
"enabled": settings.reranker_enabled,
|
||||||
|
"model": settings.reranker_model if settings.reranker_enabled else None,
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"active": session_count,
|
||||||
|
"max": settings.session_max_sessions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify Python syntax**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && python -c "from app.api.routes.status import router; print('OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `OK`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/api/routes/status.py
|
||||||
|
git commit -m "feat(status): add /health aggregate endpoint and 10s TTL cache on /stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend API — Add `SystemHealth` Type + `getSystemHealth()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/api/index.ts`
|
||||||
|
- Modify: `frontend/src/api/status.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `SystemHealth` interface to `frontend/src/api/index.ts`**
|
||||||
|
|
||||||
|
Open `frontend/src/api/index.ts`. After the `SystemConfig` interface (around line 240), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ServiceHealth {
|
||||||
|
status: 'ok' | 'error' | 'unknown';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemHealth {
|
||||||
|
milvus: ServiceHealth & {
|
||||||
|
connected?: boolean;
|
||||||
|
collection_name?: string;
|
||||||
|
num_entities?: number;
|
||||||
|
};
|
||||||
|
minio: ServiceHealth & { connected?: boolean };
|
||||||
|
bm25: { available: boolean };
|
||||||
|
reranker: { enabled: boolean; model?: string | null };
|
||||||
|
sessions: { active: number; max: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `getSystemHealth()` to `frontend/src/api/status.ts`**
|
||||||
|
|
||||||
|
Open `frontend/src/api/status.ts`. The current content is:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { fetchAPI, type SystemConfig, type SystemStats } from './index';
|
||||||
|
|
||||||
|
export async function getSystemStats(): Promise<SystemStats> {
|
||||||
|
return fetchAPI<SystemStats>('/status/stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemConfig(): Promise<SystemConfig> {
|
||||||
|
return fetchAPI<SystemConfig>('/status/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMilvusHealth(): Promise<{ connected: boolean; collections: string[] }> {
|
||||||
|
return fetchAPI('/status/milvus/health');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SystemConfig, SystemStats };
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { fetchAPI, type SystemConfig, type SystemHealth, type SystemStats } from './index';
|
||||||
|
|
||||||
|
export async function getSystemStats(): Promise<SystemStats> {
|
||||||
|
return fetchAPI<SystemStats>('/status/stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemConfig(): Promise<SystemConfig> {
|
||||||
|
return fetchAPI<SystemConfig>('/status/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemHealth(): Promise<SystemHealth> {
|
||||||
|
return fetchAPI<SystemHealth>('/status/health');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SystemConfig, SystemHealth, SystemStats };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/api/index.ts frontend/src/api/status.ts
|
||||||
|
git commit -m "feat(status): add SystemHealth type and getSystemHealth() API function"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — Loading/Error States + Refresh Button + Auto-Poll
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/pages/Status/StatusPage.tsx`
|
||||||
|
|
||||||
|
This task replaces the `useEffect` + `loadData` pattern and the top of the component with loading/error/refresh support. Auto-poll fires every 5 s while any document is `parsing` or `pending`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current `StatusPage.tsx`**
|
||||||
|
|
||||||
|
```
|
||||||
|
File: frontend/src/pages/Status/StatusPage.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Identify: the `useState` block, `loadData` function, and the single `useEffect`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the import block and state/effect section**
|
||||||
|
|
||||||
|
Find and replace the imports at the top of the file:
|
||||||
|
|
||||||
|
**Old:**
|
||||||
|
```typescript
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
```
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
```typescript
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `SystemHealth` to the API import**
|
||||||
|
|
||||||
|
**Old:**
|
||||||
|
```typescript
|
||||||
|
import { getSystemStats, getSystemConfig, type SystemStats, type SystemConfig } from '../../api/status';
|
||||||
|
```
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
```typescript
|
||||||
|
import { getSystemStats, getSystemConfig, getSystemHealth, type SystemStats, type SystemConfig, type SystemHealth } from '../../api/status';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace the state declarations and loadData + useEffect block inside the component**
|
||||||
|
|
||||||
|
Find the block starting with `const [stats, setStats]` and ending after the closing `}, []);` of the first `useEffect`. Replace it entirely with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [stats, setStats] = useState<SystemStats>({
|
||||||
|
documents_total: 0,
|
||||||
|
documents_indexed: 0,
|
||||||
|
documents_failed: 0,
|
||||||
|
chunks_total: 0,
|
||||||
|
});
|
||||||
|
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||||
|
const [docs, setDocs] = useState<DocInfo[]>([]);
|
||||||
|
const [health, setHealth] = useState<SystemHealth | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [statsRes, configRes, docsRes, healthRes] = await Promise.all([
|
||||||
|
getSystemStats(),
|
||||||
|
getSystemConfig(),
|
||||||
|
getDocumentList(),
|
||||||
|
getSystemHealth(),
|
||||||
|
]);
|
||||||
|
setStats(statsRes);
|
||||||
|
setConfig(configRes);
|
||||||
|
setDocs(docsRes.docs);
|
||||||
|
setHealth(healthRes);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load status data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// Auto-poll every 5 s while any document is still processing
|
||||||
|
useEffect(() => {
|
||||||
|
const hasProcessing = docs.some(d => d.status === 'parsing' || d.status === 'pending');
|
||||||
|
if (!hasProcessing) return;
|
||||||
|
const id = window.setInterval(() => void loadData(), 5000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [docs, loadData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add loading banner and error banner at the top of the returned JSX**
|
||||||
|
|
||||||
|
Find the `return (` statement in the component. Inside `<Content>`, right after `<TPattern />`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, color: theme.text3, fontSize: 13 }}>
|
||||||
|
<span style={{ display: 'inline-block', width: 12, height: 12, borderRadius: '50%', border: `2px solid ${theme.accent}`, borderTopColor: 'transparent', animation: 'spin 0.8s linear infinite' }} />
|
||||||
|
<span className="mono">LOADING...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#d6454520',
|
||||||
|
border: '1px solid #d64545',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 13, color: '#d64545' }}>{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => void loadData()}
|
||||||
|
style={{ background: 'none', border: '1px solid #d64545', borderRadius: 6, color: '#d64545', cursor: 'pointer', padding: '4px 10px', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add a refresh button to the stats section header**
|
||||||
|
|
||||||
|
Find the `<section style={{ marginBottom: 48 }}>` that wraps the 4 stats cards. Add a header row with a refresh button just before the grid `<div>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<section style={{ marginBottom: 48 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, letterSpacing: '1px', margin: 0 }}>
|
||||||
|
DOCUMENT STATISTICS
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => void loadData()}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: theme.text3,
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: 11,
|
||||||
|
opacity: loading ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↻ 刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* existing 4-card grid stays here unchanged */}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Add CSS keyframe for the spinner**
|
||||||
|
|
||||||
|
Find the `<Content>` wrapping element. Add a `<style>` tag as the very first child inside `<Content>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/pages/Status/StatusPage.tsx
|
||||||
|
git commit -m "feat(status): add loading/error states, refresh button, auto-poll for processing docs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend — Service Health Panel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/pages/Status/StatusPage.tsx`
|
||||||
|
|
||||||
|
Adds a new section after the stats cards that shows a colored status badge for each service (Milvus, MinIO, BM25, Reranker) plus the active session count.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `ServiceBadge` helper component** (add just before `StatsCard`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ServiceBadge = ({
|
||||||
|
label,
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
status: 'ok' | 'error' | 'unknown' | boolean;
|
||||||
|
detail?: string;
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isOk = status === 'ok' || status === true;
|
||||||
|
const color = isOk ? theme.green : '#d64545';
|
||||||
|
const dot = isOk ? '●' : '●';
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: theme.bgCard,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ color, fontSize: 10 }}>{dot}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color, fontWeight: 600 }}>
|
||||||
|
{detail ?? (isOk ? 'OK' : 'ERROR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Insert the health section between the stats section and the SYSTEM CONFIGURATION section**
|
||||||
|
|
||||||
|
Find `<section style={{ marginBottom: 48 }}>` for SYSTEM CONFIGURATION. Just before it, insert:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<section style={{ marginBottom: 48 }}>
|
||||||
|
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
|
||||||
|
SERVICE HEALTH
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 12 }}>
|
||||||
|
<ServiceBadge
|
||||||
|
label="MILVUS"
|
||||||
|
status={health?.milvus.status ?? 'unknown'}
|
||||||
|
detail={health ? (health.milvus.status === 'ok' ? `${health.milvus.num_entities ?? 0} entities` : 'disconnected') : '—'}
|
||||||
|
/>
|
||||||
|
<ServiceBadge
|
||||||
|
label="MINIO"
|
||||||
|
status={health?.minio.status ?? 'unknown'}
|
||||||
|
detail={health ? (health.minio.status === 'ok' ? 'connected' : 'disconnected') : '—'}
|
||||||
|
/>
|
||||||
|
<ServiceBadge
|
||||||
|
label="BM25 HYBRID"
|
||||||
|
status={health?.bm25.available ?? false}
|
||||||
|
detail={health ? (health.bm25.available ? 'enabled' : 'unavailable') : '—'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||||
|
<ServiceBadge
|
||||||
|
label="RERANKER"
|
||||||
|
status={health?.reranker.enabled ?? false}
|
||||||
|
detail={health ? (health.reranker.enabled ? health.reranker.model ?? 'enabled' : 'disabled') : '—'}
|
||||||
|
/>
|
||||||
|
<ServiceBadge
|
||||||
|
label="SESSIONS"
|
||||||
|
status="ok"
|
||||||
|
detail={health ? `${health.sessions.active} / ${health.sessions.max}` : '—'}
|
||||||
|
/>
|
||||||
|
<ServiceBadge
|
||||||
|
label="LLM"
|
||||||
|
status="ok"
|
||||||
|
detail={config ? `${config.llm_provider} · ${config.llm_model}` : '—'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/pages/Status/StatusPage.tsx
|
||||||
|
git commit -m "feat(status): add SERVICE HEALTH panel with Milvus/MinIO/BM25/Reranker/Session badges"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — Config Value Truncation Fix + Failed Docs Highlight
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/pages/Status/StatusPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Fix config value truncation**
|
||||||
|
|
||||||
|
In the SYSTEM CONFIGURATION section, find the `<span>` that renders the config value `v` — it currently renders as:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 500 }}>{v}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<span
|
||||||
|
title={v}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
maxWidth: 240,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
cursor: 'help',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to both the MODELS and STORAGE AND PATHS sub-sections since they share the same `.map(([k, v]) => ...)` render pattern.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Highlight failed documents in the document list**
|
||||||
|
|
||||||
|
In the DOCUMENT INDEX section, find the `docs.map(d => ...)` container `<div>`. Change the border to highlight failed docs:
|
||||||
|
|
||||||
|
**Old:**
|
||||||
|
```typescript
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
```
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
```typescript
|
||||||
|
border: `1px solid ${d.status === 'failed' ? '#d64545' : d.status === 'parsing' || d.status === 'pending' ? theme.accent + '80' : theme.border}`,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add pulsing indicator for in-progress documents**
|
||||||
|
|
||||||
|
In the same `docs.map` block, the status badge currently always shows a static green background. Update it to show different colors per status:
|
||||||
|
|
||||||
|
**Old:**
|
||||||
|
```typescript
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
background: d.status === 'failed' ? '#d64545' : theme.green,
|
||||||
|
borderRadius: 6,
|
||||||
|
}}>
|
||||||
|
<span className="mono" style={{ fontSize: 10, fontWeight: 600, color: '#fff' }}>
|
||||||
|
{d.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
```typescript
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
background:
|
||||||
|
d.status === 'failed' ? '#d64545' :
|
||||||
|
d.status === 'parsing' || d.status === 'pending' ? theme.accent :
|
||||||
|
theme.green,
|
||||||
|
borderRadius: 6,
|
||||||
|
opacity: d.status === 'parsing' || d.status === 'pending' ? 0.85 : 1,
|
||||||
|
}}>
|
||||||
|
<span className="mono" style={{ fontSize: 10, fontWeight: 600, color: '#fff' }}>
|
||||||
|
{d.status === 'parsing' ? '⟳ ' : ''}{d.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/pages/Status/StatusPage.tsx
|
||||||
|
git commit -m "fix(status): config value ellipsis truncation, failed doc highlight, parsing doc pulse"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- [x] Task 1 covers P2 (TTL cache) + P1 (service health backend)
|
||||||
|
- [x] Task 2 adds the `SystemHealth` type that Tasks 3–4 depend on
|
||||||
|
- [x] Task 3 covers P0 (loading/error/refresh/auto-poll)
|
||||||
|
- [x] Task 4 covers P1 (BM25/reranker/sessions visibility)
|
||||||
|
- [x] Task 5 covers P1 (config truncation) + P1 (failed doc visual)
|
||||||
|
- [x] All `getSystemHealth` references match the function name defined in Task 2
|
||||||
|
- [x] `health?.milvus.status` type matches `ServiceHealth.status: 'ok' | 'error' | 'unknown'`
|
||||||
|
- [x] Backend `/health` endpoint uses only already-imported bootstrap functions
|
||||||
|
- [x] No TBD or TODO left in plan
|
||||||
944
docs/superpowers/plans/2026-05-27-boss-report-html.md
Normal file
944
docs/superpowers/plans/2026-05-27-boss-report-html.md
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
# Boss Progress Report HTML Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a single self-contained HTML file (`boss-report.html`) that serves as a phase-1 progress report for the AI+合规智能中枢 project — usable both for in-person projection and async reading by the boss.
|
||||||
|
|
||||||
|
**Architecture:** Single HTML file, zero external dependencies except Google Fonts CDN. All styles inline via `<style>` tag, all interactivity via vanilla JS `<script>` tag. 8 vertical sections with a fixed top nav that highlights the active section on scroll. IntersectionObserver triggers fade-in animations as sections enter the viewport.
|
||||||
|
|
||||||
|
**Tech Stack:** HTML5, CSS3 (custom properties, grid, flexbox), vanilla JS (IntersectionObserver, scroll spy), Google Fonts (Noto Sans SC + JetBrains Mono)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `boss-report.html` | **CREATE** (project root) | The single deliverable — complete self-contained report |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: HTML skeleton + visual system + fixed nav
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `boss-report.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the HTML file with the base skeleton**
|
||||||
|
|
||||||
|
Create `C:\Projects\AIProjects\AIRegulations\AIRegulation-DocAnalysis-Demo\boss-report.html` with the following content:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI+合规智能中枢 — 阶段汇报 2026.05</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* ── Reset & base ── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a12;
|
||||||
|
--bg-card: #12121f;
|
||||||
|
--bg-hover: #1a1a2e;
|
||||||
|
--border: #2a2a40;
|
||||||
|
--text: #ffffff;
|
||||||
|
--text2: #c0c0d0;
|
||||||
|
--text3: #9a9aaa;
|
||||||
|
--accent: #e20074;
|
||||||
|
--accent-dk: #be0060;
|
||||||
|
--green: #00d4aa;
|
||||||
|
--orange: #ff8800;
|
||||||
|
--blue: #4a90d9;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* ── Fixed nav ── */
|
||||||
|
#nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
background: rgba(10,10,18,0.92);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 40px; height: 52px;
|
||||||
|
}
|
||||||
|
#nav .brand {
|
||||||
|
font-size: 13px; font-weight: 700; color: var(--accent);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
#nav ul {
|
||||||
|
display: flex; gap: 4px; list-style: none;
|
||||||
|
}
|
||||||
|
#nav ul li a {
|
||||||
|
display: block; padding: 6px 12px; border-radius: 6px;
|
||||||
|
font-size: 12px; color: var(--text3); text-decoration: none;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
#nav ul li a:hover,
|
||||||
|
#nav ul li a.active {
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section wrapper ── */
|
||||||
|
.section {
|
||||||
|
padding: 100px 40px 80px;
|
||||||
|
max-width: 1200px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fade-in animation ── */
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0; transform: translateY(24px);
|
||||||
|
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||||
|
}
|
||||||
|
.fade-in.visible {
|
||||||
|
opacity: 1; transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section label ── */
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: 2px;
|
||||||
|
color: var(--accent); text-transform: uppercase;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section title ── */
|
||||||
|
.section-title {
|
||||||
|
font-size: 32px; font-weight: 900; color: var(--text);
|
||||||
|
margin-bottom: 8px; line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-sub {
|
||||||
|
font-size: 15px; color: var(--text3); margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider line ── */
|
||||||
|
.divider {
|
||||||
|
height: 1px; background: var(--border);
|
||||||
|
max-width: 1200px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── Fixed Nav ── -->
|
||||||
|
<nav id="nav">
|
||||||
|
<div class="brand">AI+合规智能中枢</div>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#hero">概览</a></li>
|
||||||
|
<li><a href="#background">背景</a></li>
|
||||||
|
<li><a href="#progress">进展</a></li>
|
||||||
|
<li><a href="#architecture">架构</a></li>
|
||||||
|
<li><a href="#demo">功能</a></li>
|
||||||
|
<li><a href="#value">价值</a></li>
|
||||||
|
<li><a href="#roadmap">路线图</a></li>
|
||||||
|
<li><a href="#next">下一步</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Sections will be added in subsequent tasks -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Scroll spy
|
||||||
|
const sections = document.querySelectorAll('section[id]');
|
||||||
|
const navLinks = document.querySelectorAll('#nav ul li a');
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
navLinks.forEach(a => a.classList.remove('active'));
|
||||||
|
const active = document.querySelector(`#nav a[href="#${entry.target.id}"]`);
|
||||||
|
if (active) active.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.3 });
|
||||||
|
|
||||||
|
sections.forEach(s => observer.observe(s));
|
||||||
|
|
||||||
|
// Fade-in
|
||||||
|
const fadeObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
fadeObserver.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
|
||||||
|
document.querySelectorAll('.fade-in').forEach(el => fadeObserver.observe(el));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open in browser to verify nav renders**
|
||||||
|
|
||||||
|
Open `boss-report.html` in a browser. Expected: dark background page with a fixed top nav bar showing "AI+合规智能中枢" on the left and 8 nav links on the right. Page body should be empty below the nav.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Section 1 — Hero 封面
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `boss-report.html` — add hero section before `<script>` tag
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Hero section HTML**
|
||||||
|
|
||||||
|
Insert the following after the `<!-- Sections will be added in subsequent tasks -->` comment:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ── S1: Hero ── -->
|
||||||
|
<section id="hero" style="min-height:100vh; display:flex; align-items:center; position:relative; overflow:hidden;">
|
||||||
|
<!-- Background glow -->
|
||||||
|
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:600px; height:600px; background:radial-gradient(circle, rgba(226,0,116,0.12) 0%, transparent 70%); pointer-events:none;"></div>
|
||||||
|
<!-- Grid pattern -->
|
||||||
|
<div style="position:absolute; inset:0; background-image:linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px); background-size:60px 60px; opacity:0.3; pointer-events:none;"></div>
|
||||||
|
|
||||||
|
<div class="section fade-in" style="width:100%; padding-top:52px;">
|
||||||
|
<!-- Tag -->
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:8px; background:rgba(226,0,116,0.12); border:1px solid rgba(226,0,116,0.3); border-radius:20px; padding:6px 16px; margin-bottom:32px;">
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:var(--accent);display:inline-block;animation:pulse 2s infinite;"></span>
|
||||||
|
<span class="mono" style="font-size:11px; color:var(--accent); letter-spacing:1px;">INTERNAL · AI 合规项目组 · 2026.05</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 style="font-size:clamp(36px,5vw,64px); font-weight:900; line-height:1.1; margin-bottom:16px;">
|
||||||
|
AI + 合规智能中枢<br>
|
||||||
|
<span style="color:var(--accent);">阶段性进展汇报</span>
|
||||||
|
</h1>
|
||||||
|
<p style="font-size:18px; color:var(--text2); max-width:640px; margin-bottom:60px;">
|
||||||
|
基于 RAG 的多模块法规合规智能平台 — EMS/EHS 合规响应效率提升,降低人工成本
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- KPI cards -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px; max-width:720px;">
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-top:3px solid var(--accent); border-radius:var(--radius); padding:24px 28px;">
|
||||||
|
<div class="mono" style="font-size:48px; font-weight:700; color:var(--accent); line-height:1;">5</div>
|
||||||
|
<div style="font-size:13px; color:var(--text3); margin-top:6px;">功能模块<br>已完成/开发中</div>
|
||||||
|
</div>
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-top:3px solid var(--green); border-radius:var(--radius); padding:24px 28px;">
|
||||||
|
<div class="mono" style="font-size:48px; font-weight:700; color:var(--green); line-height:1;">17+</div>
|
||||||
|
<div style="font-size:13px; color:var(--text3); margin-top:6px;">API 接口<br>已设计并文档化</div>
|
||||||
|
</div>
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-top:3px solid var(--orange); border-radius:var(--radius); padding:24px 28px;">
|
||||||
|
<div class="mono" style="font-size:48px; font-weight:700; color:var(--orange); line-height:1;">6+</div>
|
||||||
|
<div style="font-size:13px; color:var(--text3); margin-top:6px;">法规来源<br>接入覆盖</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll hint -->
|
||||||
|
<div style="margin-top:60px; display:flex; align-items:center; gap:10px; color:var(--text3); font-size:13px;">
|
||||||
|
<div style="width:1px; height:40px; background:linear-gradient(to bottom, transparent, var(--accent));"></div>
|
||||||
|
向下滚动查看详情
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this keyframe to the `<style>` block (before the closing `</style>`):
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Refresh `boss-report.html`. Expected: Full-height dark hero section with grid background, pink glow, large title with magenta accent, 3 KPI cards (5 / 17+ / 6+), and a scroll hint at the bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Section 2 — 项目背景与痛点
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `boss-report.html` — add background section after the hero divider
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add background section HTML**
|
||||||
|
|
||||||
|
Insert the following after the hero `<div class="divider"></div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ── S2: Background ── -->
|
||||||
|
<section id="background">
|
||||||
|
<div class="section fade-in">
|
||||||
|
<div class="section-label">项目背景</div>
|
||||||
|
<h2 class="section-title">为什么要做这个系统?</h2>
|
||||||
|
<p class="section-sub">当前汽车行业合规管理面临三大核心痛点,传统人工方式已无法满足需求</p>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px;">
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:28px; position:relative; overflow:hidden;">
|
||||||
|
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:linear-gradient(90deg,var(--accent),var(--accent-dk));"></div>
|
||||||
|
<div style="font-size:36px; margin-bottom:16px;">📋</div>
|
||||||
|
<h3 style="font-size:18px; font-weight:700; color:var(--text); margin-bottom:10px;">法规碎片化</h3>
|
||||||
|
<p style="font-size:14px; color:var(--text2); line-height:1.7;">
|
||||||
|
GB、MIIT、UN-ECE、IATF 16949、ISO 45001、EUR-Lex 等多源法规并存,更新频繁,人工跟踪极易遗漏,合规窗口期短。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:28px; position:relative; overflow:hidden;">
|
||||||
|
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:linear-gradient(90deg,var(--orange),#ff5500);"></div>
|
||||||
|
<div style="font-size:36px; margin-bottom:16px;">⏱️</div>
|
||||||
|
<h3 style="font-size:18px; font-weight:700; color:var(--text); margin-bottom:10px;">响应周期长</h3>
|
||||||
|
<p style="font-size:14px; color:var(--text2); line-height:1.7;">
|
||||||
|
从法规发布到内部解读、影响评估、整改计划,人工流程往往需要数周,无法满足快速迭代的合规要求。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:28px; position:relative; overflow:hidden;">
|
||||||
|
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:linear-gradient(90deg,var(--green),#00a080);"></div>
|
||||||
|
<div style="font-size:36px; margin-bottom:16px;">💼</div>
|
||||||
|
<h3 style="font-size:18px; font-weight:700; color:var(--text); margin-bottom:10px;">人工成本高</h3>
|
||||||
|
<p style="font-size:14px; color:var(--text2); line-height:1.7;">
|
||||||
|
合规专家大量时间消耗在文档检索、条款比对、报告撰写等重复性工作上,高价值判断时间被严重压缩。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Refresh. Expected: Three cards with colored top borders (magenta / orange / green), emoji icons, Chinese titles and descriptions. Each card should animate in on scroll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Section 3 — 已完成进展
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `boss-report.html` — add progress section
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add progress section HTML**
|
||||||
|
|
||||||
|
Insert after the background `<div class="divider"></div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ── S3: Progress ── -->
|
||||||
|
<section id="progress">
|
||||||
|
<div class="section fade-in">
|
||||||
|
<div class="section-label">阶段成果</div>
|
||||||
|
<h2 class="section-title">已完成的工作</h2>
|
||||||
|
<p class="section-sub">5 个核心功能模块,覆盖从法规感知到报告输出的完整合规链路</p>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(1,1fr); gap:16px;">
|
||||||
|
|
||||||
|
<!-- Module 1 -->
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:24px 28px; display:grid; grid-template-columns:auto 1fr auto; gap:20px; align-items:start;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:10px; background:rgba(226,0,116,0.15); border:1px solid rgba(226,0,116,0.3); display:flex; align-items:center; justify-content:center; font-size:22px;">📡</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
|
||||||
|
<h3 style="font-size:16px; font-weight:700;">法规感知模块</h3>
|
||||||
|
<span class="mono" style="font-size:10px; background:rgba(0,212,170,0.15); color:var(--green); border:1px solid rgba(0,212,170,0.3); padding:2px 8px; border-radius:4px;">✓ 已完成</span>
|
||||||
|
</div>
|
||||||
|
<ul style="font-size:13px; color:var(--text2); line-height:2; list-style:none;">
|
||||||
|
<li>▸ 接入 MIIT、UN-ECE、ISO、国标委、EUR-Lex、IATF 六大法规来源,实时抓取动态</li>
|
||||||
|
<li>▸ 支持按来源/影响等级(高/中/低)过滤,KPI 统计卡(总计/高影响/近90天)</li>
|
||||||
|
<li>▸ 法规详情侧边面板,关联 AI 影响分析与条款摘要</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mono" style="font-size:11px; color:var(--text3); white-space:nowrap;">模块 01/05</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Module 2 -->
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:24px 28px; display:grid; grid-template-columns:auto 1fr auto; gap:20px; align-items:start;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:10px; background:rgba(74,144,217,0.15); border:1px solid rgba(74,144,217,0.3); display:flex; align-items:center; justify-content:center; font-size:22px;">📄</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
|
||||||
|
<h3 style="font-size:16px; font-weight:700;">文档分析模块</h3>
|
||||||
|
<span class="mono" style="font-size:10px; background:rgba(0,212,170,0.15); color:var(--green); border:1px solid rgba(0,212,170,0.3); padding:2px 8px; border-radius:4px;">✓ 已完成</span>
|
||||||
|
</div>
|
||||||
|
<ul style="font-size:13px; color:var(--text2); line-height:2; list-style:none;">
|
||||||
|
<li>▸ PDF/Word 文档上传解析,支持阿里云 DocMind OCR + MinerU 双引擎</li>
|
||||||
|
<li>▸ RAG 检索增强问答:BM25 稀疏 + 向量混合检索,Cross-Encoder 精排</li>
|
||||||
|
<li>▸ 文档知识库管理,Workspace 隔离,支持多文档联合问答</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mono" style="font-size:11px; color:var(--text3); white-space:nowrap;">模块 02/05</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Module 3 -->
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:24px 28px; display:grid; grid-template-columns:auto 1fr auto; gap:20px; align-items:start;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:10px; background:rgba(255,136,0,0.15); border:1px solid rgba(255,136,0,0.3); display:flex; align-items:center; justify-content:center; font-size:22px;">🏭</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
|
||||||
|
<h3 style="font-size:16px; font-weight:700;">EHS 合规模块</h3>
|
||||||
|
<span class="mono" style="font-size:10px; background:rgba(255,136,0,0.15); color:var(--orange); border:1px solid rgba(255,136,0,0.3); padding:2px 8px; border-radius:4px;">⟳ 进行中</span>
|
||||||
|
</div>
|
||||||
|
<ul style="font-size:13px; color:var(--text2); line-height:2; list-style:none;">
|
||||||
|
<li>▸ EHS 风险评估引擎,对接 ISO 45001 PDCA 体系,SIF 重伤风险识别</li>
|
||||||
|
<li>▸ 环境/职业健康/安全三维度合规检查,结构化整改建议生成</li>
|
||||||
|
<li>▸ 合规状态仪表盘,风险等级可视化,历史趋势追踪</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mono" style="font-size:11px; color:var(--text3); white-space:nowrap;">模块 03/05</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Module 4 -->
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:24px 28px; display:grid; grid-template-columns:auto 1fr auto; gap:20px; align-items:start;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:10px; background:rgba(0,212,170,0.15); border:1px solid rgba(0,212,170,0.3); display:flex; align-items:center; justify-content:center; font-size:22px;">🔌</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
|
||||||
|
<h3 style="font-size:16px; font-weight:700;">API 集成模块</h3>
|
||||||
|
<span class="mono" style="font-size:10px; background:rgba(0,212,170,0.15); color:var(--green); border:1px solid rgba(0,212,170,0.3); padding:2px 8px; border-radius:4px;">✓ 已完成</span>
|
||||||
|
</div>
|
||||||
|
<ul style="font-size:13px; color:var(--text2); line-height:2; list-style:none;">
|
||||||
|
<li>▸ 17+ REST API 接口完整设计,涵盖知识库、合规检查、订阅推送全链路</li>
|
||||||
|
<li>▸ RBAC 权限模型,5 角色分级管控(管理员/合规专员/EHS专员/审核员/查看员)</li>
|
||||||
|
<li>▸ 预留 PLM、ERP、OA、MES 系统集成接口,支持企业现有系统接入</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mono" style="font-size:11px; color:var(--text3); white-space:nowrap;">模块 04/05</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Module 5 -->
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); padding:24px 28px; display:grid; grid-template-columns:auto 1fr auto; gap:20px; align-items:start;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:10px; background:rgba(123,104,238,0.15); border:1px solid rgba(123,104,238,0.3); display:flex; align-items:center; justify-content:center; font-size:22px;">📊</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;">
|
||||||
|
<h3 style="font-size:16px; font-weight:700;">报告生成模块</h3>
|
||||||
|
<span class="mono" style="font-size:10px; background:rgba(255,136,0,0.15); color:var(--orange); border:1px solid rgba(255,136,0,0.3); padding:2px 8px; border-radius:4px;">⟳ 进行中</span>
|
||||||
|
</div>
|
||||||
|
<ul style="font-size:13px; color:var(--text2); line-height:2; list-style:none;">
|
||||||
|
<li>▸ AI 自动生成合规分析报告,支持 PDF/Word/Excel 多格式导出</li>
|
||||||
|
<li>▸ 报告模板引擎,适配不同法规体系(车辆安全/数据合规/EHS/碳排放)</li>
|
||||||
|
<li>▸ 报告版本管理与审批流,支持与 SharePoint/Confluence 协同</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mono" style="font-size:11px; color:var(--text3); white-space:nowrap;">模块 05/05</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Refresh. Expected: 5 module rows, each with colored icon box, title with status badge (绿色"✓ 已完成" or 橙色"⟳ 进行中"), and 3 bullet points of detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Section 4 — 技术架构概览
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `boss-report.html` — add architecture section
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add architecture section HTML**
|
||||||
|
|
||||||
|
Insert after the progress `<div class="divider"></div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ── S4: Architecture ── -->
|
||||||
|
<section id="architecture">
|
||||||
|
<div class="section fade-in">
|
||||||
|
<div class="section-label">技术架构</div>
|
||||||
|
<h2 class="section-title">系统分层架构</h2>
|
||||||
|
<p class="section-sub">清洁架构 + Ports & Adapters,5 层分离,高内聚低耦合,可独立替换各层实现</p>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px; max-width:960px; margin:0 auto;">
|
||||||
|
|
||||||
|
<!-- Layer 1: User -->
|
||||||
|
<div class="fade-in" style="border-radius:10px; border:1px solid rgba(226,0,116,0.4); background:rgba(226,0,116,0.06); overflow:hidden;">
|
||||||
|
<div style="padding:12px 20px; background:linear-gradient(135deg,#b0005a,#e20074); display:flex; align-items:center; gap:12px;">
|
||||||
|
<span style="font-size:18px;">👥</span>
|
||||||
|
<span style="font-weight:700; font-size:15px;">用户层</span>
|
||||||
|
<span style="font-size:11px; opacity:0.75;">合规专员 / EHS专员 / 审核员 / 管理员 / 查看员</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 20px; display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
|
<span style="background:rgba(226,0,116,0.12); border:1px solid rgba(226,0,116,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#f04090;">法规感知</span>
|
||||||
|
<span style="background:rgba(226,0,116,0.12); border:1px solid rgba(226,0,116,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#f04090;">文档分析</span>
|
||||||
|
<span style="background:rgba(226,0,116,0.12); border:1px solid rgba(226,0,116,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#f04090;">EHS合规</span>
|
||||||
|
<span style="background:rgba(226,0,116,0.12); border:1px solid rgba(226,0,116,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#f04090;">API集成</span>
|
||||||
|
<span style="background:rgba(226,0,116,0.12); border:1px solid rgba(226,0,116,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#f04090;">报告生成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center; color:var(--accent); font-size:18px;">↕</div>
|
||||||
|
|
||||||
|
<!-- Layer 2: Frontend -->
|
||||||
|
<div class="fade-in" style="border-radius:10px; border:1px solid rgba(0,212,170,0.4); background:rgba(0,212,170,0.06); overflow:hidden;">
|
||||||
|
<div style="padding:12px 20px; background:linear-gradient(135deg,#00a080,#00d4aa); display:flex; align-items:center; gap:12px;">
|
||||||
|
<span style="font-size:18px;">🖥️</span>
|
||||||
|
<span style="font-weight:700; font-size:15px;">前端层</span>
|
||||||
|
<span style="font-size:11px; opacity:0.75;">React 19 · TypeScript · Vite · React Router v7</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 20px; display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
|
<span style="background:rgba(0,212,170,0.1); border:1px solid rgba(0,212,170,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--green);">KeepAlive 路由</span>
|
||||||
|
<span style="background:rgba(0,212,170,0.1); border:1px solid rgba(0,212,170,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--green);">三主题系统 (Dark/Dim/Light)</span>
|
||||||
|
<span style="background:rgba(0,212,170,0.1); border:1px solid rgba(0,212,170,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--green);">shadcn/ui 组件库</span>
|
||||||
|
<span style="background:rgba(0,212,170,0.1); border:1px solid rgba(0,212,170,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--green);">Tailwind v4</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center; color:var(--accent); font-size:18px;">↕</div>
|
||||||
|
|
||||||
|
<!-- Layer 3: API -->
|
||||||
|
<div class="fade-in" style="border-radius:10px; border:1px solid rgba(74,144,217,0.4); background:rgba(74,144,217,0.06); overflow:hidden;">
|
||||||
|
<div style="padding:12px 20px; background:linear-gradient(135deg,#2060a0,#4a90d9); display:flex; align-items:center; gap:12px;">
|
||||||
|
<span style="font-size:18px;">⚡</span>
|
||||||
|
<span style="font-weight:700; font-size:15px;">API 层</span>
|
||||||
|
<span style="font-size:11px; opacity:0.75;">FastAPI · kbmp-service · mcp-server · Worker</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 20px; display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
|
<span style="background:rgba(74,144,217,0.1); border:1px solid rgba(74,144,217,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--blue);">知识库管理 API</span>
|
||||||
|
<span style="background:rgba(74,144,217,0.1); border:1px solid rgba(74,144,217,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--blue);">合规检查 API</span>
|
||||||
|
<span style="background:rgba(74,144,217,0.1); border:1px solid rgba(74,144,217,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--blue);">文档解析 API</span>
|
||||||
|
<span style="background:rgba(74,144,217,0.1); border:1px solid rgba(74,144,217,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--blue);">检索问答 API</span>
|
||||||
|
<span style="background:rgba(74,144,217,0.1); border:1px solid rgba(74,144,217,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--blue);">订阅推送 API</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center; color:var(--accent); font-size:18px;">↕</div>
|
||||||
|
|
||||||
|
<!-- Layer 4: AI Engine -->
|
||||||
|
<div class="fade-in" style="border-radius:10px; border:1px solid rgba(255,136,0,0.4); background:rgba(255,136,0,0.06); overflow:hidden;">
|
||||||
|
<div style="padding:12px 20px; background:linear-gradient(135deg,#c05000,#ff8800); display:flex; align-items:center; gap:12px;">
|
||||||
|
<span style="font-size:18px;">🧠</span>
|
||||||
|
<span style="font-weight:700; font-size:15px;">AI 引擎层</span>
|
||||||
|
<span style="font-size:11px; opacity:0.75;">RAG Pipeline · LLM · Embedding · Reranker</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 20px; display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
|
<span style="background:rgba(255,136,0,0.1); border:1px solid rgba(255,136,0,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--orange);">Qwen / DeepSeek LLM</span>
|
||||||
|
<span style="background:rgba(255,136,0,0.1); border:1px solid rgba(255,136,0,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--orange);">BM25 稀疏检索</span>
|
||||||
|
<span style="background:rgba(255,136,0,0.1); border:1px solid rgba(255,136,0,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--orange);">向量语义检索</span>
|
||||||
|
<span style="background:rgba(255,136,0,0.1); border:1px solid rgba(255,136,0,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--orange);">Cross-Encoder 精排</span>
|
||||||
|
<span style="background:rgba(255,136,0,0.1); border:1px solid rgba(255,136,0,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:var(--orange);">阿里云 DocMind OCR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center; color:var(--accent); font-size:18px;">↕</div>
|
||||||
|
|
||||||
|
<!-- Layer 5: Infrastructure -->
|
||||||
|
<div class="fade-in" style="border-radius:10px; border:1px solid rgba(123,104,238,0.4); background:rgba(123,104,238,0.06); overflow:hidden;">
|
||||||
|
<div style="padding:12px 20px; background:linear-gradient(135deg,#5040b0,#7b68ee); display:flex; align-items:center; gap:12px;">
|
||||||
|
<span style="font-size:18px;">🗄️</span>
|
||||||
|
<span style="font-weight:700; font-size:15px;">基础设施层</span>
|
||||||
|
<span style="font-size:11px; opacity:0.75;">Milvus · MySQL · S3/OSS · Prometheus · Grafana</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 20px; display:flex; gap:10px; flex-wrap:wrap;">
|
||||||
|
<span style="background:rgba(123,104,238,0.1); border:1px solid rgba(123,104,238,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#9b8cf0;">Milvus 向量数据库</span>
|
||||||
|
<span style="background:rgba(123,104,238,0.1); border:1px solid rgba(123,104,238,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#9b8cf0;">MySQL 结构化存储</span>
|
||||||
|
<span style="background:rgba(123,104,238,0.1); border:1px solid rgba(123,104,238,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#9b8cf0;">对象存储 (文档/Markdown)</span>
|
||||||
|
<span style="background:rgba(123,104,238,0.1); border:1px solid rgba(123,104,238,0.25); border-radius:6px; padding:4px 12px; font-size:12px; color:#9b8cf0;">Prometheus + Grafana</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Refresh. Expected: 5 colored layered boxes with arrows between them, each showing layer name, tech stack, and module tags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Section 5 — Demo 核心功能
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `boss-report.html` — add demo section
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add demo section HTML**
|
||||||
|
|
||||||
|
Insert after the architecture `<div class="divider"></div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ── S5: Demo ── -->
|
||||||
|
<section id="demo">
|
||||||
|
<div class="section fade-in">
|
||||||
|
<div class="section-label">产品演示</div>
|
||||||
|
<h2 class="section-title">核心功能一览</h2>
|
||||||
|
<p class="section-sub">Demo 已部署,以下为五大核心功能模块的界面与能力说明</p>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px;">
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden;">
|
||||||
|
<div style="height:140px; background:linear-gradient(135deg,#1a0a20,#2a1035); display:flex; align-items:center; justify-content:center; position:relative; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:8px;">
|
||||||
|
<div style="font-size:40px;">📡</div>
|
||||||
|
<div style="display:flex; gap:6px;">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:#d64545;"></span>
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:#ff8800;"></span>
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:#00d4aa;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute; top:10px; right:10px; background:rgba(0,212,170,0.2); color:var(--green); font-size:10px; padding:2px 8px; border-radius:4px; font-weight:700;">LIVE</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:18px 20px;">
|
||||||
|
<h3 style="font-size:15px; font-weight:700; margin-bottom:8px;">法规感知</h3>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6;">实时监控六大法规源,高/中/低影响分级,支持过滤筛选,点击查看 AI 解读</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden;">
|
||||||
|
<div style="height:140px; background:linear-gradient(135deg,#0a1020,#101a35); display:flex; align-items:center; justify-content:center; position:relative; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:40px;">💬</div>
|
||||||
|
<div style="position:absolute; bottom:12px; left:12px; right:12px; background:rgba(74,144,217,0.15); border:1px solid rgba(74,144,217,0.3); border-radius:6px; padding:6px 10px; font-size:11px; color:var(--blue);">"GB 18384 对我们的 BMS 设计有什么影响?"</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:18px 20px;">
|
||||||
|
<h3 style="font-size:15px; font-weight:700; margin-bottom:8px;">文档分析 Q&A</h3>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6;">上传法规/技术文档,AI 即可回答专业问题,引用原文段落,支持追问</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden;">
|
||||||
|
<div style="height:140px; background:linear-gradient(135deg,#0a1808,#101f0f); display:flex; align-items:center; justify-content:center; position:relative; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:40px;">🏭</div>
|
||||||
|
<div style="position:absolute; top:10px; left:10px; background:rgba(255,68,68,0.2); color:#ff6b6b; font-size:10px; padding:2px 8px; border-radius:4px; font-weight:700;">⚠ 高风险</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:18px 20px;">
|
||||||
|
<h3 style="font-size:15px; font-weight:700; margin-bottom:8px;">EHS 合规检查</h3>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6;">上传 EHS 文档,自动识别风险条款,生成整改建议,对标 ISO 45001</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden;">
|
||||||
|
<div style="height:140px; background:linear-gradient(135deg,#081510,#0f1f18); display:flex; align-items:center; justify-content:center; position:relative; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:40px;">🔌</div>
|
||||||
|
<div style="position:absolute; bottom:12px; left:12px; right:12px; display:flex; gap:6px;">
|
||||||
|
<span style="background:rgba(0,212,170,0.15); color:var(--green); font-size:10px; padding:2px 8px; border-radius:4px;">PLM</span>
|
||||||
|
<span style="background:rgba(0,212,170,0.15); color:var(--green); font-size:10px; padding:2px 8px; border-radius:4px;">ERP</span>
|
||||||
|
<span style="background:rgba(0,212,170,0.15); color:var(--green); font-size:10px; padding:2px 8px; border-radius:4px;">OA</span>
|
||||||
|
<span style="background:rgba(0,212,170,0.15); color:var(--green); font-size:10px; padding:2px 8px; border-radius:4px;">MES</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:18px 20px;">
|
||||||
|
<h3 style="font-size:15px; font-weight:700; margin-bottom:8px;">系统集成</h3>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6;">RBAC 权限管控,REST API 开放,预留企业系统对接接口,支持 Webhook 订阅</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; grid-column:span 2;">
|
||||||
|
<div style="height:140px; background:linear-gradient(135deg,#101018,#181830); display:flex; align-items:center; justify-content:center; position:relative; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:40px;">📊</div>
|
||||||
|
<div style="position:absolute; bottom:12px; right:12px; display:flex; gap:8px;">
|
||||||
|
<span style="background:rgba(226,0,116,0.15); color:var(--accent); font-size:10px; padding:2px 8px; border-radius:4px;">PDF</span>
|
||||||
|
<span style="background:rgba(226,0,116,0.15); color:var(--accent); font-size:10px; padding:2px 8px; border-radius:4px;">Word</span>
|
||||||
|
<span style="background:rgba(226,0,116,0.15); color:var(--accent); font-size:10px; padding:2px 8px; border-radius:4px;">Excel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:18px 20px;">
|
||||||
|
<h3 style="font-size:15px; font-weight:700; margin-bottom:8px;">合规报告生成</h3>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6;">AI 自动生成合规分析报告,多格式导出,支持模板定制,含版本管理与审批流</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Refresh. Expected: 5 feature cards in a 3-column grid (last card spanning 2 columns), each with a dark-tinted preview area and description below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Section 6 — 业务价值对比
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `boss-report.html` — add value comparison section
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add value section HTML**
|
||||||
|
|
||||||
|
Insert after the demo `<div class="divider"></div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ── S6: Value ── -->
|
||||||
|
<section id="value">
|
||||||
|
<div class="section fade-in">
|
||||||
|
<div class="section-label">业务价值</div>
|
||||||
|
<h2 class="section-title">有与没有的差距</h2>
|
||||||
|
<p class="section-sub">量化对比:AI 智能中枢 vs 传统人工合规方式</p>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr auto 1fr; gap:0; align-items:stretch; max-width:960px; margin:0 auto 48px;">
|
||||||
|
|
||||||
|
<!-- Without -->
|
||||||
|
<div class="fade-in" style="background:rgba(255,68,68,0.06); border:1px solid rgba(255,68,68,0.2); border-radius:12px 0 0 12px; padding:28px;">
|
||||||
|
<div style="font-size:13px; font-weight:700; color:#ff6b6b; margin-bottom:20px; letter-spacing:1px;">❌ 传统人工方式</div>
|
||||||
|
<ul style="list-style:none; display:flex; flex-direction:column; gap:16px;">
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:#ff6b6b; font-weight:700;">数周</span> — 法规解读 + 影响评估周期</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:#ff6b6b; font-weight:700;">人工检索</span> — 依赖专家经验,易遗漏</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:#ff6b6b; font-weight:700;">碎片化</span> — 各系统数据孤岛,无法联动</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:#ff6b6b; font-weight:700;">高成本</span> — 合规专家时间大量消耗在重复工作</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:#ff6b6b; font-weight:700;">被动响应</span> — 法规变化后才开始评估</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrow -->
|
||||||
|
<div style="display:flex; align-items:center; justify-content:center; padding:0 20px; background:var(--bg-card); border-top:1px solid var(--border); border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:28px; color:var(--accent);">→</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- With -->
|
||||||
|
<div class="fade-in" style="background:rgba(0,212,170,0.06); border:1px solid rgba(0,212,170,0.2); border-radius:0 12px 12px 0; padding:28px;">
|
||||||
|
<div style="font-size:13px; font-weight:700; color:var(--green); margin-bottom:20px; letter-spacing:1px;">✅ AI+合规智能中枢</div>
|
||||||
|
<ul style="list-style:none; display:flex; flex-direction:column; gap:16px;">
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:var(--green); font-weight:700;">分钟级</span> — 法规发布即自动解读,影响秒级评估</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:var(--green); font-weight:700;">RAG 智能检索</span> — 语义理解,精准定位条款</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:var(--green); font-weight:700;">统一平台</span> — 多源法规、多系统数据融合</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:var(--green); font-weight:700;">降低重复劳动 70%+</span> — 专家聚焦高价值判断</li>
|
||||||
|
<li style="font-size:14px; color:var(--text2);"><span style="color:var(--green); font-weight:700;">主动预警</span> — 订阅推送,法规变化即刻触达</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metric highlights -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; max-width:960px; margin:0 auto;">
|
||||||
|
<div class="fade-in" style="text-align:center; padding:24px; background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius);">
|
||||||
|
<div class="mono" style="font-size:36px; font-weight:700; color:var(--accent);">70%+</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-top:6px;">重复性工作减少</div>
|
||||||
|
</div>
|
||||||
|
<div class="fade-in" style="text-align:center; padding:24px; background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius);">
|
||||||
|
<div class="mono" style="font-size:36px; font-weight:700; color:var(--green);">分钟级</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-top:6px;">法规响应时间</div>
|
||||||
|
</div>
|
||||||
|
<div class="fade-in" style="text-align:center; padding:24px; background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius);">
|
||||||
|
<div class="mono" style="font-size:36px; font-weight:700; color:var(--orange);">6+</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-top:6px;">法规源统一管理</div>
|
||||||
|
</div>
|
||||||
|
<div class="fade-in" style="text-align:center; padding:24px; background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius);">
|
||||||
|
<div class="mono" style="font-size:36px; font-weight:700; color:var(--blue);">5</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-top:6px;">业务场景覆盖</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify in browser**
|
||||||
|
|
||||||
|
Refresh. Expected: Side-by-side red/green comparison panel, then 4 metric highlight cards below with large colored numbers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Section 7 — 路线图 & Section 8 — 下一步 + 页脚
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `boss-report.html` — add roadmap, next steps, and footer
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add roadmap + next steps + footer HTML**
|
||||||
|
|
||||||
|
Insert after the value `<div class="divider"></div>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ── S7: Roadmap ── -->
|
||||||
|
<section id="roadmap">
|
||||||
|
<div class="section fade-in">
|
||||||
|
<div class="section-label">发展路线</div>
|
||||||
|
<h2 class="section-title">四阶段推进计划</h2>
|
||||||
|
<p class="section-sub">当前处于第二阶段"能力建设",核心模块已完成,正在推进全量接入</p>
|
||||||
|
|
||||||
|
<div style="position:relative; padding:40px 0;">
|
||||||
|
<!-- Timeline line -->
|
||||||
|
<div style="position:absolute; top:60px; left:12.5%; right:12.5%; height:3px; background:linear-gradient(90deg, var(--green) 50%, var(--border) 50%);"></div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:20px; position:relative;">
|
||||||
|
|
||||||
|
<!-- Phase 1: DONE -->
|
||||||
|
<div class="fade-in" style="text-align:center;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:50%; background:var(--green); border:3px solid var(--green); display:flex; align-items:center; justify-content:center; margin:0 auto 16px; font-size:18px; position:relative; z-index:1;">✓</div>
|
||||||
|
<div style="background:rgba(0,212,170,0.1); border:1px solid rgba(0,212,170,0.3); border-radius:10px; padding:18px 14px;">
|
||||||
|
<div class="mono" style="font-size:10px; color:var(--green); margin-bottom:6px;">阶段 01 · 已完成</div>
|
||||||
|
<h4 style="font-size:14px; font-weight:700; margin-bottom:10px;">POC 验证</h4>
|
||||||
|
<ul style="font-size:12px; color:var(--text2); list-style:none; text-align:left; line-height:1.9;">
|
||||||
|
<li>▸ 架构设计完成</li>
|
||||||
|
<li>▸ 技术栈验证</li>
|
||||||
|
<li>▸ 核心 RAG 流程打通</li>
|
||||||
|
<li>▸ 前端原型搭建</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase 2: ACTIVE -->
|
||||||
|
<div class="fade-in" style="text-align:center;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:50%; background:var(--accent); border:3px solid var(--accent); display:flex; align-items:center; justify-content:center; margin:0 auto 16px; font-size:18px; position:relative; z-index:1; animation:pulse 2s infinite; box-shadow:0 0 20px rgba(226,0,116,0.4);">⟳</div>
|
||||||
|
<div style="background:rgba(226,0,116,0.1); border:2px solid var(--accent); border-radius:10px; padding:18px 14px; box-shadow:0 0 24px rgba(226,0,116,0.12);">
|
||||||
|
<div class="mono" style="font-size:10px; color:var(--accent); margin-bottom:6px;">阶段 02 · 进行中 ◀ 当前</div>
|
||||||
|
<h4 style="font-size:14px; font-weight:700; margin-bottom:10px;">能力建设</h4>
|
||||||
|
<ul style="font-size:12px; color:var(--text2); list-style:none; text-align:left; line-height:1.9;">
|
||||||
|
<li>▸ 5 大模块开发</li>
|
||||||
|
<li>▸ API 接口完整设计</li>
|
||||||
|
<li>▸ RBAC 权限系统</li>
|
||||||
|
<li>▸ Demo 可演示</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase 3 -->
|
||||||
|
<div class="fade-in" style="text-align:center;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:50%; background:var(--bg-card); border:3px solid var(--border); display:flex; align-items:center; justify-content:center; margin:0 auto 16px; font-size:18px; position:relative; z-index:1; color:var(--text3);">3</div>
|
||||||
|
<div style="background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:18px 14px;">
|
||||||
|
<div class="mono" style="font-size:10px; color:var(--text3); margin-bottom:6px;">阶段 03 · 规划中</div>
|
||||||
|
<h4 style="font-size:14px; font-weight:700; color:var(--text2); margin-bottom:10px;">全量接入</h4>
|
||||||
|
<ul style="font-size:12px; color:var(--text3); list-style:none; text-align:left; line-height:1.9;">
|
||||||
|
<li>▸ IT 系统对接</li>
|
||||||
|
<li>▸ 2-3 个试点部门</li>
|
||||||
|
<li>▸ 用户培训</li>
|
||||||
|
<li>▸ 数据迁移</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase 4 -->
|
||||||
|
<div class="fade-in" style="text-align:center;">
|
||||||
|
<div style="width:48px; height:48px; border-radius:50%; background:var(--bg-card); border:3px solid var(--border); display:flex; align-items:center; justify-content:center; margin:0 auto 16px; font-size:18px; position:relative; z-index:1; color:var(--text3);">4</div>
|
||||||
|
<div style="background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:18px 14px;">
|
||||||
|
<div class="mono" style="font-size:10px; color:var(--text3); margin-bottom:6px;">阶段 04 · 规划中</div>
|
||||||
|
<h4 style="font-size:14px; font-weight:700; color:var(--text2); margin-bottom:10px;">规模运营</h4>
|
||||||
|
<ul style="font-size:12px; color:var(--text3); list-style:none; text-align:left; line-height:1.9;">
|
||||||
|
<li>▸ API 平台开放</li>
|
||||||
|
<li>▸ PLM/ERP/MES 集成</li>
|
||||||
|
<li>▸ 全公司推广</li>
|
||||||
|
<li>▸ 持续优化迭代</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- ── S8: Next Steps ── -->
|
||||||
|
<section id="next">
|
||||||
|
<div class="section fade-in">
|
||||||
|
<div class="section-label">下一步</div>
|
||||||
|
<h2 class="section-title">近期行动项</h2>
|
||||||
|
<p class="section-sub">阶段二收尾,推进阶段三启动所需的关键决策与资源</p>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:20px; max-width:800px;">
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-left:4px solid var(--accent); border-radius:var(--radius); padding:24px;">
|
||||||
|
<div style="font-size:12px; font-weight:700; color:var(--accent); margin-bottom:12px; letter-spacing:1px;">🎯 技术</div>
|
||||||
|
<ul style="font-size:14px; color:var(--text2); list-style:none; line-height:2;">
|
||||||
|
<li>□ 完成 EHS 模块与报告模块开发</li>
|
||||||
|
<li>□ 后端 kbmp-service 生产环境部署</li>
|
||||||
|
<li>□ 端到端集成测试</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-left:4px solid var(--green); border-radius:var(--radius); padding:24px;">
|
||||||
|
<div style="font-size:12px; font-weight:700; color:var(--green); margin-bottom:12px; letter-spacing:1px;">📋 业务</div>
|
||||||
|
<ul style="font-size:14px; color:var(--text2); list-style:none; line-height:2;">
|
||||||
|
<li>□ 确定试点部门(EHS/合规/法务)</li>
|
||||||
|
<li>□ 收集 2-3 个真实合规场景需求</li>
|
||||||
|
<li>□ IT 系统对接方案评估</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-left:4px solid var(--orange); border-radius:var(--radius); padding:24px;">
|
||||||
|
<div style="font-size:12px; font-weight:700; color:var(--orange); margin-bottom:12px; letter-spacing:1px;">🔐 安全合规</div>
|
||||||
|
<ul style="font-size:14px; color:var(--text2); list-style:none; line-height:2;">
|
||||||
|
<li>□ PIPL/DSL 数据合规评审</li>
|
||||||
|
<li>□ 内部安全审计配合</li>
|
||||||
|
<li>□ 数据脱敏与权限细化</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fade-in" style="background:var(--bg-card); border:1px solid var(--border); border-left:4px solid var(--blue); border-radius:var(--radius); padding:24px;">
|
||||||
|
<div style="font-size:12px; font-weight:700; color:var(--blue); margin-bottom:12px; letter-spacing:1px;">💰 资源申请</div>
|
||||||
|
<ul style="font-size:14px; color:var(--text2); list-style:none; line-height:2;">
|
||||||
|
<li>□ GPU/云资源扩容评估</li>
|
||||||
|
<li>□ 阿里云 DocMind 商业授权</li>
|
||||||
|
<li>□ 阶段三人力资源确认</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Footer ── -->
|
||||||
|
<footer style="border-top:1px solid var(--border); padding:40px; text-align:center; background:var(--bg-card);">
|
||||||
|
<div style="max-width:1200px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:16px; font-weight:700; color:var(--text); margin-bottom:4px;">AI+合规智能中枢</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3);">EMS & EHS Compliance Intelligence Hub · Internal · Confidential</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div class="mono" style="font-size:13px; color:var(--accent); font-weight:600;">2026.05</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-top:4px;">AI 合规项目组 · T-Systems</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Finalize JS — move `querySelectorAll` after all sections are added**
|
||||||
|
|
||||||
|
In the `<script>` block, ensure the JS runs after all sections are in the DOM. The script block is already at the bottom of `<body>`, so this is already correct. No changes needed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full page verification**
|
||||||
|
|
||||||
|
Open `boss-report.html` in browser and scroll through all 8 sections. Expected:
|
||||||
|
1. ✅ Hero — dark full-viewport with KPI cards
|
||||||
|
2. ✅ Background — 3 pain point cards
|
||||||
|
3. ✅ Progress — 5 module rows with status badges
|
||||||
|
4. ✅ Architecture — 5 colored layer boxes
|
||||||
|
5. ✅ Demo — 5 feature cards in grid
|
||||||
|
6. ✅ Value — left/right comparison + 4 metric cards
|
||||||
|
7. ✅ Roadmap — 4-phase timeline, phase 2 highlighted in magenta
|
||||||
|
8. ✅ Next Steps — 4 action cards + footer
|
||||||
|
9. ✅ Fixed nav highlights active section on scroll
|
||||||
|
10. ✅ All `.fade-in` elements animate on scroll into view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage check:**
|
||||||
|
- ✅ Hero + KPI → Task 2
|
||||||
|
- ✅ Background / pain points → Task 3
|
||||||
|
- ✅ 5 module progress → Task 4
|
||||||
|
- ✅ Architecture diagram → Task 5
|
||||||
|
- ✅ Demo features → Task 6
|
||||||
|
- ✅ Business value comparison → Task 7
|
||||||
|
- ✅ Roadmap (4-phase) → Task 8
|
||||||
|
- ✅ Next steps + footer → Task 8
|
||||||
|
- ✅ Fixed nav with scroll spy → Task 1
|
||||||
|
- ✅ Fade-in animations → Task 1
|
||||||
|
- ✅ Dark theme, T-Systems magenta → Task 1
|
||||||
|
|
||||||
|
**Placeholder scan:** None found. All code is complete and explicit.
|
||||||
|
|
||||||
|
**Type consistency:** No typed interfaces — pure HTML/CSS. All color variables defined in `:root` in Task 1 and used consistently throughout.
|
||||||
|
|
||||||
|
**Scope check:** Single file deliverable, well within one implementation plan.
|
||||||
1328
docs/superpowers/plans/2026-05-27-team-report-ppt.md
Normal file
1328
docs/superpowers/plans/2026-05-27-team-report-ppt.md
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user