Jelajahi Sumber

feat: 劳动仲裁案件要素抽取与画像构建系统

王雨洁 4 minggu lalu
melakukan
3c8c968c3a
100 mengubah file dengan 58891 tambahan dan 0 penghapusan
  1. 68 0
      .claude/settings.local.json
  2. 44 0
      .gitignore
  3. 302 0
      README.md
  4. 7 0
      backend/.env.example
  5. 1 0
      backend/app/__init__.py
  6. 286 0
      backend/app/anj.py
  7. 33 0
      backend/app/config.py
  8. 16 0
      backend/app/db.py
  9. 795 0
      backend/app/extractor.py
  10. 5 0
      backend/app/extractors/__init__.py
  11. 98 0
      backend/app/extractors/rule_extractor.py
  12. 157 0
      backend/app/hierarchy_extract.py
  13. 1107 0
      backend/app/main.py
  14. 148 0
      backend/app/migrate.py
  15. 76 0
      backend/app/models.py
  16. 16 0
      backend/app/schemas.py
  17. 1 0
      backend/app/services/__init__.py
  18. 50 0
      backend/app/services/complexity_classifier.py
  19. 21 0
      backend/app/services/document_parser.py
  20. 190 0
      backend/app/services/hybrid_extractor.py
  21. 45 0
      backend/app/services/nlp_client.py
  22. 156 0
      backend/app/services/portrait_generator.py
  23. 33 0
      backend/app/services/profile_builder.py
  24. 45 0
      backend/app/services/risk_predictor.py
  25. 38 0
      backend/app/services/similar_cases_local.py
  26. 55 0
      backend/app/services/vector_store.py
  27. 17 0
      backend/config.py
  28. 4854 0
      backend/data/augmented_dataset.json
  29. 120 0
      backend/data/case_elements_hierarchy.json
  30. 278 0
      backend/data/case_elements_schema.json
  31. 174 0
      backend/data/raw_corpus.json
  32. 41007 0
      backend/data/training_dataset.json
  33. 4 0
      backend/requirements-vector.txt
  34. 11 0
      backend/requirements.txt
  35. 47 0
      backend/tools/evaluate_extraction.py
  36. 269 0
      backend/tools/evaluate_extractor.py
  37. 122 0
      backend/tools/prepare_dataset.py
  38. 24 0
      config.py
  39. 27 0
      docker-compose.yml
  40. 12 0
      frontend/index.html
  41. 2338 0
      frontend/package-lock.json
  42. 21 0
      frontend/package.json
  43. 1172 0
      frontend/src/App.jsx
  44. 32 0
      frontend/src/api.js
  45. 10 0
      frontend/src/main.jsx
  46. 861 0
      frontend/src/styles.css
  47. 9 0
      frontend/vite.config.js
  48. 1 0
      nlp-service/app/__init__.py
  49. 66 0
      nlp-service/app/main.py
  50. 5 0
      nlp-service/app/schemas.py
  51. 1 0
      nlp-service/app/services/__init__.py
  52. 303 0
      nlp-service/app/services/bert_multi_task_model.py
  53. 312 0
      nlp-service/app/services/extractor.py
  54. 53 0
      nlp-service/app/services/model_loader.py
  55. 24 0
      nlp-service/requirements.txt
  56. 1 0
      nlp-service/training/__init__.py
  57. 289 0
      nlp-service/training/augment_data.py
  58. 129 0
      nlp-service/training/bio_schema.py
  59. 379 0
      nlp-service/training/evaluate.py
  60. 361 0
      nlp-service/training/prepare_training_data.py
  61. 386 0
      nlp-service/training/train_bert.py
  62. 110 0
      nlp-service/training/train_with_split.py
  63. 213 0
      test_e2e.py
  64. 29 0
      案例文件/万涛案.txt
  65. 29 0
      案例文件/何伟案.txt
  66. 29 0
      案例文件/余伟案.txt
  67. 26 0
      案例文件/侯静案.txt
  68. 29 0
      案例文件/兰静案.txt
  69. 29 0
      案例文件/冯涛案.txt
  70. 30 0
      案例文件/刘强案.txt
  71. 28 0
      案例文件/刘洋案.txt
  72. 29 0
      案例文件/史强案.txt
  73. 28 0
      案例文件/吴敏案.txt
  74. 30 0
      案例文件/周磊案.txt
  75. 29 0
      案例文件/唐芳案.txt
  76. 29 0
      案例文件/孙伟案.txt
  77. 28 0
      案例文件/孟丽案.txt
  78. 27 0
      案例文件/宋涛案.txt
  79. 28 0
      案例文件/尤芳案.txt
  80. 26 0
      案例文件/崔静案.txt
  81. 29 0
      案例文件/常磊案.txt
  82. 29 0
      案例文件/张明案.txt
  83. 29 0
      案例文件/彭涛案.txt
  84. 29 0
      案例文件/徐静案.txt
  85. 29 0
      案例文件/戴丽案.txt
  86. 29 0
      案例文件/方芳案.txt
  87. 29 0
      案例文件/易芳案.txt
  88. 30 0
      案例文件/曹丽案.txt
  89. 30 0
      案例文件/李强案.txt
  90. 28 0
      案例文件/杨勇案.txt
  91. 29 0
      案例文件/林丹案.txt
  92. 28 0
      案例文件/段丽案.txt
  93. 29 0
      案例文件/沈磊案.txt
  94. 26 0
      案例文件/沈芳案.txt
  95. 29 0
      案例文件/潘强案.txt
  96. 26 0
      案例文件/熊伟案.txt
  97. 29 0
      案例文件/王芳案.txt
  98. 29 0
      案例文件/白涛案.txt
  99. 29 0
      案例文件/秦丽案.txt
  100. 28 0
      案例文件/蒋伟案.txt

+ 68 - 0
.claude/settings.local.json

@@ -0,0 +1,68 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(python tools/prepare_dataset.py)",
+      "Bash(PYTHONPATH=\"..;../../backend\" python prepare_training_data.py)",
+      "Bash(curl -s http://localhost:11434/api/tags)",
+      "Bash(python -c ')",
+      "Bash(PYTHONPATH=\".\" python -c \"from app.services.bert_multi_task_model import ChineseRobertaMultiTask; print\\('Model class imported OK'\\)\")",
+      "Bash(pip install *)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" --version)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m pip list)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124 -q)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m pip install transformers datasets accelerate seqeval scikit-learn tqdm peft -q)",
+      "Bash(C:/Users/Raclen/.conda/envs/graduation/python.exe *)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m pip install transformers datasets accelerate seqeval scikit-learn tqdm peft)",
+      "Bash(PYTHONPATH=\"..;../../backend\" \"C:/Users/Raclen/.conda/envs/graduation/python.exe\" train_bert.py --dataset ../../backend/data/augmented_dataset.json --output ../models/chinese_roberta_labor_extractor --epochs 10 --batch_size 4 --lr 2e-5)",
+      "Bash(tasklist)",
+      "Bash(HF_ENDPOINT=\"https://hf-mirror.com\" PYTHONPATH=\"..;../../backend\" \"C:/Users/Raclen/.conda/envs/graduation/python.exe\" train_bert.py --dataset ../../backend/data/augmented_dataset.json --output ../models/chinese_roberta_labor_extractor --epochs 10 --batch_size 4 --lr 2e-5)",
+      "Bash(HF_HUB_OFFLINE=1 \"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m uvicorn app.main:app --reload --port 8001 --host 0.0.0.0)",
+      "Bash(curl -s http://localhost:8001/health)",
+      "Bash(kill %1)",
+      "Bash(HF_HUB_OFFLINE=1 \"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m uvicorn app.main:app --port 8001 --host 0.0.0.0)",
+      "Bash(taskkill //F //PID 37940)",
+      "Bash(curl *)",
+      "Bash(taskkill //F //PID 2308)",
+      "Bash(' C:/Users/Raclen/.conda/envs/graduation/python.exe' -c ' *)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m uvicorn app.main:app --port 8000 --host 0.0.0.0)",
+      "Bash(curl -s http://localhost:8000/health)",
+      "Bash(npm run *)",
+      "WebFetch(domain:zhuanlan.zhihu.com)",
+      "WebSearch",
+      "Bash(D:/develop/Ollama/ollama.exe list *)",
+      "Bash(taskkill //F //IM python.exe)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" tools/prepare_dataset.py)",
+      "Bash(PYTHONPATH=\"..;../../backend\" \"C:/Users/Raclen/.conda/envs/graduation/python.exe\" prepare_training_data.py)",
+      "Bash(HF_ENDPOINT=\"https://hf-mirror.com\" PYTHONPATH=\"..;../../backend\" \"C:/Users/Raclen/.conda/envs/graduation/python.exe\" train_with_split.py)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m pip install jieba -q)",
+      "Bash(\"C:/Users/Raclen/.conda/envs/graduation/python.exe\" -m pip install jieba、)",
+      "Bash(python -c \"import sys; print\\(sys.executable\\)\")",
+      "Bash(npm --version)",
+      "Bash(mysql --version)",
+      "Bash(mysqladmin ping *)",
+      "Bash(sc query *)",
+      "Bash(conda env *)",
+      "Bash(pip list *)",
+      "Bash(mysql *)",
+      "Bash(conda run *)",
+      "Bash(ollama list *)",
+      "Bash(taskkill /F /IM \"python.exe\" /FI \"WINDOWTITLE eq uvicorn*\")",
+      "Bash(netstat -ano)",
+      "Bash(taskkill //PID 3208 //F)",
+      "Bash(taskkill //PID 36084 //F)",
+      "Bash(awk '{print $5}')",
+      "Bash(taskkill //PID 42320 //F)",
+      "Bash(taskkill //PID 47284 //F)",
+      "Bash(powershell *)",
+      "Bash(taskkill //F //IM \"python.exe\")",
+      "Bash(taskkill //PID 24200 //F)",
+      "Bash(export PYTHONIOENCODING=utf-8)",
+      "Bash(cp *)",
+      "Bash(npm install *)",
+      "Bash(taskkill *)",
+      "Bash(git init *)",
+      "Bash(git add *)",
+      "Bash(git commit *)"
+    ]
+  }
+}

+ 44 - 0
.gitignore

@@ -0,0 +1,44 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+.venv/
+venv/
+env/
+*.egg
+
+# Node
+node_modules/
+.pnp/
+.pnp.js
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# Environment & Secrets
+.env
+*.env.local
+*.env.production
+
+# Uploads & Data (large files)
+uploads/
+backend/uploads/
+*.bin
+*.safetensors
+*.pth
+
+# Models (too large for git, need separate download)
+nlp-service/models/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log

+ 302 - 0
README.md

@@ -0,0 +1,302 @@
+# 劳动仲裁案件要素抽取与画像构建系统
+
+面向劳动仲裁领域的多源异构案件材料要素抽取、多维画像构建与辅助分析系统。系统采用"规则引擎 + Chinese RoBERTa 多任务神经网络 + Ollama 大语言模型"三层混合架构,覆盖六大案由类型共计 70 余个要素字段,实现从文件上传到结构化要素、三维画像、相似案件匹配的一站式处理。
+
+## 系统架构
+
+```
+frontend/ (React 18 + Vite 5, :5173)
+    │ Axios HTTP
+    ▼
+backend/ (FastAPI, :8000)
+    ├── RuleBasedExtractor     正则规则抽取(70+ 字段基线覆盖)
+    ├── HybridExtractor        三层混合调度(规则/BERT/Ollama 按字段路由)
+    ├── PortraitGenerator      法律/事实/风险三维画像评分
+    ├── SimilarCaseMatcher     Jaccard 三维加权相似度匹配
+    └── RiskPredictor          风险等级评估
+    │ HTTP
+    ▼
+nlp-service/ (FastAPI, :8001)
+    ├── ChineseRobertaMultiTask    BERT 多任务模型(NER + 分类 + QA + 回归)
+    └── Ollama Qwen 2.5 3B        LLM 零样本增强(可选)
+    │
+    ▼
+MySQL 8.0 (graduation) + Qdrant 向量库(可选)
+```
+
+## 项目结构
+
+```
+second_type/
+├── frontend/                          # React 前端(:5173)
+│   └── src/
+│       ├── App.jsx                    # 主界面(上传、要素表、画像、相似案件、评估)
+│       ├── api.js                     # Axios HTTP 客户端
+│       └── styles.css                 # 样式表
+├── backend/                           # FastAPI 业务后端(:8000)
+│   ├── app/
+│   │   ├── main.py                    # 路由与业务逻辑(API 入口)
+│   │   ├── models.py                  # ORM 模型(Case, CaseFile, ProcessingTask 等)
+│   │   ├── schemas.py                 # Pydantic 请求/响应模型
+│   │   ├── config.py                  # 配置管理(读取 .env)
+│   │   ├── extractor.py               # 规则抽取器(22 个函数,770 行)
+│   │   ├── anj.py                     # Ollama LLM 调用封装
+│   │   ├── hierarchy_extract.py       # 案由层级模板
+│   │   ├── db.py                      # 数据库连接与 Session
+│   │   ├── migrate.py                 # 数据库迁移
+│   │   ├── services/
+│   │   │   ├── hybrid_extractor.py    # 混合抽取器(4 模式切换)
+│   │   │   ├── portrait_generator.py  # 画像生成(三维度评分)
+│   │   │   ├── similar_cases_local.py # 相似案件匹配(Jaccard)
+│   │   │   ├── document_parser.py     # 文件解析(PDF/DOCX/TXT)
+│   │   │   ├── complexity_classifier.py # 复杂度分类
+│   │   │   └── risk_predictor.py      # 风险评估
+│   │   └── extractors/
+│   │       └── rule_extractor.py      # 独立规则提取器
+│   ├── data/
+│   │   ├── case_elements_schema.json  # 要素 Schema(277 行)
+│   │   ├── case_elements_hierarchy.json # 案由层级模板
+│   │   ├── raw_corpus.json            # 清洗后语料
+│   │   ├── training_dataset.json      # 训练数据
+│   │   └── augmented_dataset.json     # 增强数据(150+ 条)
+│   ├── tools/
+│   │   ├── prepare_dataset.py         # 数据清洗脚本
+│   │   ├── evaluate_extraction.py     # 评估脚本
+│   │   └── evaluate_extractor.py      # 提取器评估
+│   ├── uploads/                       # 上传文件存储
+│   ├── .env                           # 环境变量配置
+│   └── requirements.txt               # Python 依赖
+├── nlp-service/                       # NLP 模型微服务(:8001)
+│   ├── app/
+│   │   ├── main.py                    # API 路由(/extract, /health)
+│   │   ├── schemas.py                 # 请求模型
+│   │   └── services/
+│   │       ├── extractor.py           # BERT 提取器(模型 + 规则回退)
+│   │       ├── bert_multi_task_model.py # 多任务模型定义
+│   │       └── model_loader.py        # 单例模型加载器
+│   ├── training/
+│   │   ├── train_bert.py              # 训练脚本
+│   │   ├── bio_schema.py              # BIO 标签体系(15 类实体,31 标签)
+│   │   ├── prepare_training_data.py   # 自动标注流水线
+│   │   ├── augment_data.py            # 数据增强
+│   │   └── evaluate.py                # 统一评估框架
+│   └── models/
+│       └── chinese_roberta_labor_extractor_v2/  # 训练好的模型(~391MB)
+├── config.py                          # 根目录配置兼容模块
+├── docker-compose.yml                 # PostgreSQL + Qdrant 容器(可选)
+├── test_e2e.py                        # 端到端测试脚本
+└── README.md
+```
+
+## 快速开始
+
+### 环境要求
+
+- Python 3.10+(推荐 Conda)
+- Node.js 18+
+- MySQL 8.0+
+- (可选)Ollama + Qwen 2.5 3B
+
+### 1. 数据库
+
+```sql
+CREATE DATABASE graduation CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+```
+
+配置 `backend/.env`:
+
+```env
+DATABASE_URL=mysql+pymysql://root:密码@127.0.0.1:3306/graduation?charset=utf8mb4
+USE_OLLAMA=true
+OLLAMA_BASE_URL=http://127.0.0.1:11434
+OLLAMA_MODEL_NAME=qwen2.5:3b
+```
+
+### 2. 安装依赖
+
+```bash
+# 后端
+cd backend
+pip install -r requirements.txt
+
+# NLP 服务
+cd nlp-service
+pip install -r requirements.txt
+
+# 前端
+cd frontend
+npm install
+```
+
+### 3. 启动服务
+
+```bash
+# 终端 1:后端
+cd backend
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
+
+# 终端 2:NLP 模型服务
+cd nlp-service
+uvicorn app.main:app --reload --host 0.0.0.0 --port 8001
+
+# 终端 3:前端
+cd frontend
+npm run dev -- --host 0.0.0.0 --port 5173
+```
+
+浏览器访问 **http://localhost:5173**
+
+### 4.(可选)启动 Ollama
+
+```bash
+ollama serve
+ollama pull qwen2.5:3b
+```
+
+## API 参考
+
+### 后端 API(:8000)
+
+| 方法 | 端点 | 说明 |
+|------|------|------|
+| GET | `/health` | 健康检查 |
+| POST | `/api/cases/upload` | 上传案件材料,触发自动抽取 |
+| GET | `/api/cases/{id}` | 获取案件详情 |
+| GET | `/api/cases/{id}/elements` | 获取结构化要素 |
+| PUT | `/api/cases/{id}/elements` | 更新要素(前端编辑后保存) |
+| GET | `/api/cases/{id}/portrait` | 获取案件画像 |
+| POST | `/api/cases/{id}/similar` | 获取相似案件(`{"top_k": 5}`) |
+| GET | `/api/evaluation/metrics` | 获取评估指标 |
+
+完整文档:**http://localhost:8000/docs**(Swagger 自动生成)
+
+### NLP 微服务 API(:8001)
+
+| 方法 | 端点 | 说明 |
+|------|------|------|
+| GET | `/health` | 模型状态检查 |
+| POST | `/extract` | BERT 模型要素抽取 |
+| POST | `/extract/compare` | 规则 vs 模型对比 |
+
+## 模型架构
+
+```
+Chinese RoBERTa (hfl/chinese-roberta-wwm-ext, ~102M)
+    ├── Token Classification Head (768 → 31) ─── NER(15 类实体 BIO 标签)
+    ├── Classification Heads (768 → 6, 768 → 4) ─── 案由/合同分类
+    ├── Span QA Head (768 → 2) ─── 事实片段抽取
+    └── Regression Head (768 → 128 → 64 → 1) ─── 金额预测
+
+联合损失:L = 0.40×L_NER + 0.30×L_CLS + 0.15×L_QA + 0.15×L_REG
+优化器:AdamW (lr=2e-5),FP16 混合精度,早停 patience=3
+```
+
+## 三层混合抽取策略
+
+| 层级 | 实现 | 负责字段 | 优势 |
+|------|------|---------|------|
+| Layer 1 规则层 | RuleBasedLaborExtractor(22 个正则函数) | 姓名、日期、金额、法条、案号 | 精确率高、速度快(0.02s) |
+| Layer 2 BERT 层 | ChineseRobertaMultiTask(4 任务头) | 案由分类、实体识别、片段抽取 | 语义理解强、召回率提升 12pp |
+| Layer 3 LLM 层 | Ollama Qwen 2.5 3B(零样本) | 仲裁请求、解除原因、自由文本 | 零样本泛化、覆盖极端变体 |
+
+三层通过 `HybridExtractor` 按字段类型路由,任一层不可用时自动降级至下层,核心链路不中断。
+
+## 配置说明
+
+`backend/.env` 主要配置项:
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `DATABASE_URL` | - | MySQL 连接字符串 |
+| `extractor_mode` | `rules` | 抽取模式:rules / bert / ollama / hybrid |
+| `use_ollama` | `false` | 启用 Ollama LLM 增强 |
+| `ollama_base_url` | `http://127.0.0.1:11434` | Ollama 服务地址 |
+| `ollama_model_name` | `qwen2.5:3b` | 使用的模型 |
+| `use_remote_nlp_service` | `false` | 使用远程 NLP 微服务 |
+| `bert_model_service_url` | `http://localhost:8001/extract` | BERT 服务地址 |
+| `use_vector_store` | `false` | 启用 Qdrant 向量检索 |
+
+## 模型训练
+
+### 数据准备
+
+```bash
+# 1. 清洗语料
+cd backend
+python tools/prepare_dataset.py
+
+# 2. 自动标注(规则抽取 → BIO 标签 + 分类标签)
+cd nlp-service/training
+PYTHONPATH="..;../../backend" python prepare_training_data.py
+
+# 3. 数据增强
+PYTHONPATH="..;../../backend" python augment_data.py
+```
+
+### 训练
+
+```bash
+cd nlp-service/training
+HF_ENDPOINT="https://hf-mirror.com" PYTHONPATH="..;../../backend" \
+    python train_bert.py \
+    --dataset ../../backend/data/augmented_dataset.json \
+    --output ../models/chinese_roberta_labor_extractor \
+    --epochs 10 \
+    --batch_size 4 \
+    --lr 2e-5
+```
+
+### 评估
+
+```bash
+cd nlp-service/training
+PYTHONPATH="..;../../backend" python evaluate.py \
+    --dataset ../../backend/data/augmented_dataset.json \
+    --output eval_results.json \
+    --methods rules,bert
+```
+
+## 端到端测试
+
+确保三服务已启动后运行:
+
+```bash
+python test_e2e.py
+```
+
+测试覆盖:健康检查 → 上传案件 → 获取要素 → 获取画像 → 相似案件匹配 → NLP 直接调用。
+
+## 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| 前端 | React 18 + Vite 5 + Recharts + Axios |
+| 后端 | FastAPI + SQLAlchemy + PyMySQL |
+| NLP 模型 | Chinese RoBERTa (hfl/chinese-roberta-wwm-ext) + Multi-Task |
+| LLM | Ollama + Qwen 2.5 3B(本地部署) |
+| 文档解析 | pypdf + python-docx + python-magic |
+| 中文分词 | jieba |
+| 向量检索 | Qdrant + sentence-transformers(可选) |
+| 数据库 | MySQL 8.0 |
+
+## 六案由要素体系
+
+| 案由类型 | 核心要素 |
+|----------|---------|
+| 劳动关系纠纷 | 劳动关系确认、未签合同双倍工资 |
+| 工伤保险待遇 | 伤残补助金、医疗费、停工留薪期工资 |
+| 追索劳动报酬 | 拖欠工资、加班费、高温津贴 |
+| 经济补偿金 | 协商解除补偿、代通知金 |
+| 赔偿金 | 违法解除赔偿金(2N) |
+| 生育保险待遇 | 生育津贴、生育医疗费 |
+
+## 已知限制
+
+- **训练数据**:当前增强后约 150 条样本,NER 标注稀疏,实体识别仍高度依赖规则层
+- **Ollama 依赖**:LLM 增强需本地运行 Ollama 服务,不可用时可回退至规则模式
+- **BERT 模型**:嵌套实体和边界模糊实体的预测不够稳定
+- **匹配性能**:相似案件使用线性扫描 O(N),大规模时建议启用 Qdrant 向量检索
+
+## 许可证
+
+本项目仅用于学术研究与毕业设计展示。

+ 7 - 0
backend/.env.example

@@ -0,0 +1,7 @@
+DATABASE_URL=mysql+pymysql://root:password@127.0.0.1:3306/graduation?charset=utf8mb4
+USE_VECTOR_STORE=false
+QDRANT_URL=http://localhost:6333
+QDRANT_COLLECTION=labor_case_vectors
+EMBEDDING_MODEL_NAME=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
+NLP_SERVICE_URL=http://localhost:8001/extract
+USE_REMOTE_NLP_SERVICE=false

+ 1 - 0
backend/app/__init__.py

@@ -0,0 +1 @@
+# Package marker

+ 286 - 0
backend/app/anj.py

@@ -0,0 +1,286 @@
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+try:
+    from app.extractor import parse_amount
+except ImportError:
+    from extractor import parse_amount  # type: ignore
+
+# 与 merge_dispute_template_fields 产出的扁平键一致,供 LLM 输出白名单过滤
+OLLAMA_DISPUTE_TEMPLATE_KEYS: frozenset[str] = frozenset(
+    {
+        "tmpl_primary_cause",
+        "gen_applicant_info",
+        "gen_respondent_info",
+        "gen_facts_and_reasons",
+        "lr1_pay_cycle",
+        "lr1_pay_amount",
+        "lr1_pay_form",
+        "lr1_si_joined",
+        "lr1_si_benefit_amount",
+        "lr1_contract_signed",
+        "lr1_open_ended_contract",
+        "lr1_double_wage_no_contract",
+        "lr1_relation_duration",
+        "wi_lr_contract_signed",
+        "wi_lr_relation_duration",
+        "wi_benefit_pay_time",
+        "wi_benefit_amount_total",
+        "wi_benefit_disability",
+        "wi_benefit_prosthetic",
+        "wi_benefit_medical_allowance",
+        "wi_benefit_travel",
+        "wi_benefit_rehab",
+        "wi_benefit_nursing",
+        "wi_benefit_meal",
+        "wi_benefit_pay_form",
+        "wi_si_benefit_amt",
+        "wi_si_joined",
+        "wi_recognize_ec",
+        "sr_pay_cycle",
+        "sr_claim_amount",
+        "sr_claim_deducted_pay",
+        "sr_claim_overtime_pay",
+        "sr_claim_living_allowance",
+        "sr_high_temp_allowance",
+        "sr_actual_pay_standard",
+        "sr_agreed_pay_standard",
+        "sr_annual_leave_pay",
+        "sr_unpaid_period",
+        "sr_overtime_amount",
+        "ec_avg_salary_12m",
+        "ec_claim_amount",
+        "ec_double_wage_part",
+        "ec_illegal_term_part",
+        "ec_illegal_probation_part",
+        "ec_extra_compensation_part",
+        "ec_notice_pay",
+        "ec_additional_damages",
+        "ec_contract_duration",
+        "ec_leave_reason",
+        "ec_leave_date",
+        "dm_claim_amount",
+        "dm_illegal_dismissal_damages",
+        "dm_contract_exists",
+        "dm_terminate_reason",
+        "dm_contract_continue",
+        "mi_claim_amount",
+        "mi_maternity_medical",
+        "mi_maternity_allowance_salary",
+        "mi_additional_damages",
+        "mi_travel_accommodation",
+        "mi_contract_continue",
+        "mi_terminate_reason",
+    }
+)
+
+_PRIMARY_CAUSE_CHOICES = (
+    "劳动关系纠纷类",
+    "工伤保险待遇纠纷",
+    "追索劳动报酬",
+    "经济补偿金纠纷",
+    "赔偿金纠纷",
+    "生育保险待遇纠纷",
+)
+
+_LLM_AMOUNT_KEYS: frozenset[str] = frozenset(
+    {
+        "lr1_pay_amount",
+        "lr1_si_benefit_amount",
+        "sr_claim_amount",
+        "sr_claim_deducted_pay",
+        "sr_claim_overtime_pay",
+        "sr_claim_living_allowance",
+        "sr_high_temp_allowance",
+        "sr_annual_leave_pay",
+        "sr_overtime_amount",
+        "wi_benefit_amount_total",
+        "wi_benefit_disability",
+        "wi_benefit_prosthetic",
+        "wi_benefit_medical_allowance",
+        "wi_benefit_travel",
+        "wi_benefit_rehab",
+        "wi_benefit_nursing",
+        "wi_benefit_meal",
+        "wi_si_benefit_amt",
+        "ec_avg_salary_12m",
+        "ec_claim_amount",
+        "ec_double_wage_part",
+        "ec_illegal_term_part",
+        "ec_illegal_probation_part",
+        "ec_extra_compensation_part",
+        "ec_notice_pay",
+        "ec_additional_damages",
+        "dm_claim_amount",
+        "dm_illegal_dismissal_damages",
+        "mi_claim_amount",
+        "mi_maternity_medical",
+        "mi_maternity_allowance_salary",
+        "mi_additional_damages",
+        "mi_travel_accommodation",
+    }
+)
+
+
+class OllamaClaimsExtractor:
+    """
+    将原来的 Flask+Ollama 脚本改造成“可被 FastAPI 直接调用的模块”。
+
+    只负责增强抽取:仲裁请求事项(claims.items)与汇总金额(claims.amount_total)。
+    其他字段仍由规则抽取器提供,避免 LLM 幻觉影响整体稳定性。
+    """
+
+    def __init__(self, ollama_host: str, model_name: str):
+        # 延迟导入:即使未安装 ollama,也不影响不启用该功能的运行
+        import ollama  # type: ignore
+
+        self.client = ollama.Client(host=ollama_host)
+        self.model_name = model_name
+
+    @staticmethod
+    def extract_content_between_sections(text: str) -> str:
+        pattern = r"(事实和理由详见).*"
+        cleaned_text = re.sub(pattern, "", text or "", flags=re.DOTALL)
+        return cleaned_text.strip() if cleaned_text else (text or "")
+
+    def extract_claims(self, text: str) -> dict[str, Any]:
+        """
+        返回标准结构:
+        {
+          "items": ["请求1", "请求2"],
+          "amount_total": 10000.0
+        }
+        """
+        extracted_content = self.extract_content_between_sections(text)
+
+        prompt = (
+            "请从以下劳动仲裁文本中抽取仲裁请求事项,并严格只返回 JSON(不要解释,不要 Markdown)。\n"
+            "返回格式:\n"
+            "{\n"
+            '  \"items\": [\"请求1\", \"请求2\"],\n'
+            '  \"amounts\": [8000, 2000]\n'
+            "}\n"
+            "要求:\n"
+            "- items 用中文完整表述每一项请求;如果没有请求,items 为空数组\n"
+            "- amounts 为与各请求对应的金额(元,数字),无法确定就返回空数组\n"
+            "- 不要编造文本中没有的信息\n\n"
+            f"{extracted_content}"
+        )
+
+        response = self.client.chat(
+            model=self.model_name,
+            messages=[
+                {"role": "system", "content": "你是一个法律文本信息抽取助手,只输出JSON。"},
+                {"role": "user", "content": prompt},
+            ],
+            stream=False,
+            options={"temperature": 0},
+        )
+        content = (response.get("message") or {}).get("content") or ""
+        content = content.strip()
+
+        # 兼容:有些模型会把 JSON 包在 ``` 里
+        content = re.sub(r"^```(?:json)?\s*", "", content)
+        content = re.sub(r"\s*```$", "", content)
+
+        try:
+            data = json.loads(content)
+        except Exception:
+            # 回退:直接把内容当成一条 items,并用正则解析金额
+            items = [line.strip() for line in re.split(r"[\n;;]+", content) if line.strip()]
+            amounts = [parse_amount(it) for it in items]
+            amounts = [a for a in amounts if a is not None]
+            return {"items": items[:10], "amount_total": sum(amounts) if amounts else None}
+
+        items = data.get("items") or []
+        items = [str(x).strip() for x in items if str(x).strip()]
+        amounts = data.get("amounts") or []
+        clean_amounts: list[float] = []
+        for a in amounts:
+            try:
+                clean_amounts.append(float(a))
+            except Exception:
+                continue
+        amount_total = sum(clean_amounts) if clean_amounts else None
+
+        # 若模型没给 amounts,则从 items 兜底解析
+        if amount_total is None and items:
+            parsed = [parse_amount(it) for it in items]
+            parsed = [p for p in parsed if p is not None]
+            amount_total = sum(parsed) if parsed else None
+
+        return {"items": items[:10], "amount_total": amount_total}
+
+    def extract_dispute_template_fields(self, text: str) -> dict[str, Any]:
+        """
+        使用与 extract_claims 相同的 Ollama 模型,抽取案由模板扁平字段(与 merge_dispute_template_fields 键兼容)。
+        在规则抽取之后由 main 合并进 elements,并用 refresh_derived_element_fields 重算层级表。
+        """
+        extracted_content = self.extract_content_between_sections(text or "")
+        body = extracted_content[:14000]
+
+        keys_hint = ", ".join(sorted(OLLAMA_DISPUTE_TEMPLATE_KEYS))
+        causes_hint = "、".join(_PRIMARY_CAUSE_CHOICES)
+
+        prompt = (
+            "你是劳动仲裁材料信息抽取助手。根据下列文书内容抽取要素,严格只返回一个 JSON 对象(不要解释,不要 Markdown)。\n\n"
+            f"tmpl_primary_cause 必须是以下案由之一:{causes_hint}\n\n"
+            "务必尽量填写:gen_applicant_info(申请人信息一行)、gen_respondent_info(被申请人信息一行)、"
+            "gen_facts_and_reasons(事实与理由,可较长)。金额用数字(元),无法确定用 null;是/否类用「是」或「否」。\n\n"
+            "其余键名必须来自下列英文键名(可省略或 null):\n"
+            f"{keys_hint}\n\n"
+            f"文书内容:\n{body}"
+        )
+
+        response = self.client.chat(
+            model=self.model_name,
+            messages=[
+                {"role": "system", "content": "你只输出合法 JSON 对象,键名使用英文 snake_case。"},
+                {"role": "user", "content": prompt},
+            ],
+            stream=False,
+            options={"temperature": 0},
+        )
+        content = (response.get("message") or {}).get("content") or ""
+        content = content.strip()
+        content = re.sub(r"^```(?:json)?\s*", "", content)
+        content = re.sub(r"\s*```$", "", content)
+
+        try:
+            data = json.loads(content)
+        except Exception:
+            return {}
+
+        if not isinstance(data, dict):
+            return {}
+
+        out: dict[str, Any] = {}
+        for k, v in data.items():
+            if k not in OLLAMA_DISPUTE_TEMPLATE_KEYS:
+                continue
+            if v is None:
+                continue
+            if isinstance(v, str) and not v.strip():
+                continue
+            if k == "tmpl_primary_cause":
+                s = str(v).strip()
+                if s in _PRIMARY_CAUSE_CHOICES:
+                    out[k] = s
+                continue
+            if isinstance(v, (int, float)) and not isinstance(v, bool):
+                out[k] = float(v)
+                continue
+            if isinstance(v, str):
+                vs = v.strip()
+                if k in _LLM_AMOUNT_KEYS:
+                    p = parse_amount(vs)
+                    out[k] = p if p is not None else vs
+                else:
+                    out[k] = vs
+                continue
+            out[k] = v
+
+        return out

+ 33 - 0
backend/app/config.py

@@ -0,0 +1,33 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+    app_name: str = "Labor Arbitration Backend"
+    # 开发期:使用本机 MySQL,避免依赖 Docker/PostgreSQL
+    # 示例:mysql+pymysql://root:password@127.0.0.1:3306/graduation?charset=utf8mb4
+    database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/graduation?charset=utf8mb4"
+
+    # 下面这些在你以后接入向量库和独立 NLP 服务时再启用
+    # 无 Docker / 无 Qdrant 时保持 False,相似案件走 MySQL 内简易文本相似度
+    use_vector_store: bool = False
+    qdrant_url: str = "http://localhost:6333"
+    qdrant_collection: str = "labor_case_vectors"
+    embedding_model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
+    nlp_service_url: str = "http://localhost:8001/extract"
+    use_remote_nlp_service: bool = False
+
+    # Ollama(可选):启用后使用 app/anj.py 中同一 Ollama 模型增强「仲裁请求 claims」与「案由模板扁平要素」
+    # (extract_dispute_template_fields),规则抽取仍先执行,LLM 结果覆盖非空字段。
+    use_ollama: bool = False
+    ollama_base_url: str = "http://127.0.0.1:11434"
+    ollama_model_name: str = "qwen2.5:3b"
+
+    # Extractor mode: "rules" | "bert" | "ollama" | "hybrid"
+    extractor_mode: str = "rules"
+    # BERT model service URL (nlp-service)
+    bert_model_service_url: str = "http://localhost:8001/extract"
+
+    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+
+
+settings = Settings()

+ 16 - 0
backend/app/db.py

@@ -0,0 +1,16 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import declarative_base, sessionmaker
+
+from app.config import settings
+
+engine = create_engine(settings.database_url, pool_pre_ping=True)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+Base = declarative_base()
+
+
+def get_db():
+    db = SessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()

+ 795 - 0
backend/app/extractor.py

@@ -0,0 +1,795 @@
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from datetime import date
+from pathlib import Path
+from typing import Any, Callable
+
+from app.hierarchy_extract import (
+    DEFAULT_CAUSE_TYPE,
+    build_elements_hierarchy_for_cause,
+    load_hierarchy_templates,
+)
+
+
+_WS = r"[ \t\u3000]*"
+
+
+def _clean_text(text: str) -> str:
+    if not text:
+        return ""
+    text = text.replace("\r\n", "\n").replace("\r", "\n")
+    text = re.sub(r"[ \t\u3000]+", " ", text)
+    return text
+
+
+def _first_group(pattern: str, text: str, flags: int = 0) -> str | None:
+    m = re.search(pattern, text, flags)
+    if not m:
+        return None
+    for i in range(1, (m.lastindex or 0) + 1):
+        g = m.group(i)
+        if g is not None and str(g).strip():
+            return str(g).strip()
+    return None
+
+
+def _all_matches(pattern: str, text: str, flags: int = 0, group: int = 0) -> list[str]:
+    out: list[str] = []
+    for m in re.finditer(pattern, text, flags):
+        out.append((m.group(group) if group else m.group(0)).strip())
+    return out
+
+
+_CN_NUM = {
+    "零": 0,
+    "〇": 0,
+    "一": 1,
+    "二": 2,
+    "两": 2,
+    "三": 3,
+    "四": 4,
+    "五": 5,
+    "六": 6,
+    "七": 7,
+    "八": 8,
+    "九": 9,
+}
+_CN_UNIT = {"十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000}
+
+
+def parse_cn_number(s: str) -> float | None:
+    """
+    解析常见中文金额(如:八千元、二万三千、十二万)为数字。
+    只覆盖毕业设计 MVP 常见写法;不处理复杂小数表达。
+    """
+    if not s:
+        return None
+    s = s.strip()
+    s = re.sub(r"[元整人民币¥\s]", "", s)
+    if not s:
+        return None
+
+    # 纯中文数字/单位
+    total = 0
+    section = 0
+    number = 0
+    has_any = False
+    for ch in s:
+        if ch in _CN_NUM:
+            number = _CN_NUM[ch]
+            has_any = True
+        elif ch in _CN_UNIT:
+            unit = _CN_UNIT[ch]
+            has_any = True
+            if unit >= 10000:
+                section = (section + (number or 0)) * unit
+                total += section
+                section = 0
+            else:
+                section += (number or 1) * unit
+            number = 0
+        else:
+            return None
+    return float(total + section + number) if has_any else None
+
+
+def parse_amount(text: str) -> float | None:
+    """
+    支持:8000元、8,000元、¥8000、八千元 等
+    """
+    if not text:
+        return None
+    text = text.strip()
+    # 数字 + 元
+    m = re.search(r"(?:¥|人民币)?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(?:\.[0-9]{1,2})?\s*元", text)
+    if m:
+        raw = m.group(1).replace(",", "")
+        try:
+            return float(raw)
+        except ValueError:
+            pass
+    # 中文金额
+    m2 = re.search(r"([零〇一二两三四五六七八九十百千万亿]+)\s*元", text)
+    if m2:
+        return parse_cn_number(m2.group(1))
+    return None
+
+
+def parse_date_any(s: str) -> str | None:
+    """
+    统一解析日期格式并输出 YYYY-MM-DD(字符串)。
+    支持:
+    - 2020年1月1日
+    - 2020.01.01
+    - 2020-01-01
+    - 2020/1/1
+    """
+    if not s:
+        return None
+    s = s.strip()
+    m = re.search(r"([12][0-9]{3})\s*[年./-]\s*([01]?[0-9])\s*[月./-]\s*([0-3]?[0-9])\s*(?:日)?", s)
+    if not m:
+        return None
+    y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
+    try:
+        _ = date(y, mo, d)
+    except ValueError:
+        return None
+    return f"{y:04d}-{mo:02d}-{d:02d}"
+
+
+CAUSE_KEYWORDS: list[tuple[str, list[str]]] = [
+    ("工资报酬", ["工资", "拖欠工资", "未支付工资", "工资差额", "薪资", "报酬"]),
+    ("经济补偿", ["经济补偿", "补偿金", "N+1", "补偿"]),
+    ("工伤待遇", ["工伤", "工伤待遇", "伤残", "一次性伤残", "医疗费", "停工留薪"]),
+    ("违法解除劳动合同", ["违法解除", "非法解除", "无故解除", "违法辞退", "解除劳动合同赔偿"]),
+    ("加班费", ["加班费", "加班工资", "延时加班", "休息日加班", "法定节假日加班", "加班"]),
+    ("未订立书面劳动合同", ["未签订劳动合同", "未订立书面劳动合同", "双倍工资", "二倍工资"]),
+    ("年休假", ["年休假", "未休年假", "带薪年休假"]),
+    ("社会保险", ["社会保险", "社保", "养老保险", "医疗保险", "失业保险", "未缴纳社保"]),
+]
+
+_SCHEMA_PATH = Path(__file__).resolve().parent.parent / "data" / "case_elements_schema.json"
+
+
+def load_case_elements_schema() -> dict[str, Any]:
+    if not _SCHEMA_PATH.is_file():
+        return {"groups": [], "field_labels": {}, "table_name": "", "version": ""}
+    try:
+        return json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
+    except Exception:
+        return {"groups": [], "field_labels": {}, "table_name": "", "version": ""}
+
+
+def _table_rows_for_ids(flat: dict[str, Any], labels: dict[str, str], field_ids: list[str], covered: set[str]) -> list[dict[str, Any]]:
+    rows: list[dict[str, Any]] = []
+    for fid in field_ids or []:
+        covered.add(fid)
+        rows.append(
+            {
+                "field_id": fid,
+                "field_label": labels.get(fid, fid),
+                "value": flat.get(fid),
+            }
+        )
+    return rows
+
+
+def _build_table_group_node(flat: dict[str, Any], labels: dict[str, str], g: dict[str, Any], covered: set[str]) -> dict[str, Any]:
+    """递归:支持 sub_groups(对应要素分解模板的一层/二层/三层结构)。"""
+    gid = g.get("id", "")
+    node: dict[str, Any] = {
+        "group_id": gid,
+        "group_label": g.get("label", gid),
+        "rows": _table_rows_for_ids(flat, labels, g.get("field_ids") or [], covered),
+        "sub_groups": [],
+    }
+    for sg in g.get("sub_groups") or []:
+        node["sub_groups"].append(_build_table_group_node(flat, labels, sg, covered))
+    return node
+
+
+def _count_table_rows(nodes: list[dict[str, Any]]) -> int:
+    n = 0
+    for node in nodes:
+        n += len(node.get("rows") or [])
+        n += _count_table_rows(node.get("sub_groups") or [])
+    return n
+
+
+def build_case_elements_table(flat: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
+    """
+    按 case_elements_schema.json 生成分组表;支持 sub_groups 嵌套。
+    schema 中每个 field_id 对应一行;抽取结果里存在但未声明的顶层字段追加「补充要素」。
+    """
+    labels = schema.get("field_labels") or {}
+    covered: set[str] = set()
+    groups_out: list[dict[str, Any]] = [_build_table_group_node(flat, labels, g, covered) for g in schema.get("groups") or []]
+
+    skip_keys = {"case_elements_table"}
+    extra_ids = sorted(k for k in flat.keys() if k not in skip_keys and k not in covered)
+    if extra_ids:
+        groups_out.append(
+            {
+                "group_id": "supplement",
+                "group_label": "补充要素",
+                "rows": [
+                    {
+                        "field_id": fid,
+                        "field_label": labels.get(fid, fid),
+                        "value": flat.get(fid),
+                    }
+                    for fid in extra_ids
+                ],
+                "sub_groups": [],
+            }
+        )
+
+    return {
+        "table_name": schema.get("table_name", "案件要素表"),
+        "version": schema.get("version", ""),
+        "groups": groups_out,
+        "field_count": _count_table_rows(groups_out),
+    }
+
+
+def detect_case_cause(text: str) -> str | None:
+    t = text or ""
+    for cause, keys in CAUSE_KEYWORDS:
+        if any(k in t for k in keys):
+            return cause
+    return None
+
+
+def extract_law_refs(text: str) -> list[str]:
+    t = text or ""
+    # 《劳动合同法》第xx条 / 《xx法》 / 劳动合同法第xx条
+    refs = set()
+    for item in _all_matches(r"《[^》]{2,30}》\s*第\s*[0-9一二三四五六七八九十百千]+\s*条", t):
+        refs.add(item)
+    for item in _all_matches(r"《[^》]{2,30}》", t):
+        refs.add(item)
+    for item in _all_matches(r"(劳动合同法|劳动争议调解仲裁法|工伤保险条例)\s*第\s*[0-9一二三四五六七八九十百千]+\s*条", t):
+        refs.add(item)
+    return sorted(refs)
+
+
+def extract_case_number(text: str) -> str | None:
+    t = text or ""
+    v = _first_group(r"(?:案号|案件编号|仲裁案号)[::]\s*([^\n,,。;;]{4,40})", t)
+    if v:
+        return v
+    m = re.search(r"劳人仲案字\s*\[[0-9]{4}\]\s*第\s*[0-9一二三四五六七八九十百千]+\s*号", t)
+    if m:
+        return m.group(0).strip()
+    m2 = re.search(r"\([12][0-9]{3}\)\s*第\s*[0-9]+\s*号", t)
+    return m2.group(0).strip() if m2 else None
+
+
+def extract_filing_date(text: str) -> str | None:
+    t = text or ""
+    raw = _first_group(r"(?:立案日期|立案时间)[::]\s*([^\n,,。;;]{4,24})", t)
+    return parse_date_any(raw) if raw else None
+
+
+def extract_arbitration_org(text: str) -> str | None:
+    t = text or ""
+    direct = _first_group(r"(?:仲裁机构|仲裁委员会)[::]\s*([^\n,,。;;]{4,60})", t)
+    if direct:
+        return direct
+    m = re.search(r"([\u4e00-\u9fff]{2,20}劳动(?:人事)?争议仲裁委员会)", t)
+    return m.group(1).strip() if m else None
+
+
+def extract_case_title(text: str) -> str | None:
+    t = text or ""
+    v = _first_group(r"(?:案件名称|标题)[::]\s*([^\n]{2,80})", t)
+    if v:
+        return v.strip()
+    return _first_group(r"案由[::]\s*([^\n,,。;;]{2,40})", t)
+
+
+def extract_applicant_name(text: str) -> str | None:
+    t = text or ""
+    return _first_group(r"(?:申请人|申请方|申请者)[::]" + _WS + r"([^\n,,。;;]{2,20})", t)
+
+
+def extract_applicant_type(text: str) -> str | None:
+    t = text or ""
+    if re.search(r"申请人[::].{0,30}(公司|有限|集团|厂|中心|委员会)", t):
+        return "用人单位(或用工单位)"
+    if re.search(r"申请人类型[::]\s*([^\n,,]{2,20})", t):
+        return _first_group(r"申请人类型[::]\s*([^\n,,]{2,20})", t)
+    name = extract_applicant_name(t) or ""
+    if len(name) <= 4 and not any(x in name for x in ["公司", "有限", "厂"]):
+        return "自然人"
+    return None
+
+
+def extract_employment_type(text: str) -> str | None:
+    t = text or ""
+    for label, keys in [
+        ("劳务派遣", ["劳务派遣", "派遣员工", "用工单位", "派遣单位"]),
+        ("非全日制用工", ["非全日制"]),
+        ("全日制用工", ["全日制", "标准工时"]),
+        ("劳务关系", ["劳务关系", "劳务协议"]),
+    ]:
+        if any(k in t for k in keys):
+            return label
+    return None
+
+
+def extract_respondent_name(text: str) -> str | None:
+    t = text or ""
+    return _first_group(r"(?:被申请人|被申请方|答辩人)[::]" + _WS + r"([^\n,,。;;]{2,40})", t)
+
+
+def extract_employer_nature(text: str) -> str | None:
+    t = text or ""
+    # 优先从“单位性质”字段取
+    direct = _first_group(r"(?:单位性质|用人单位性质)[::]" + _WS + r"(企业|事业单位|个人|个体工商户|民办非企业|机关)", t)
+    if direct:
+        if direct in ("个体工商户",):
+            return "个人"
+        return direct
+    # 根据名称后缀猜测
+    resp = extract_respondent_name(t) or ""
+    if any(k in resp for k in ["有限公司", "有限责任公司", "股份有限公司", "公司", "厂", "集团", "企业"]):
+        return "企业"
+    if any(k in resp for k in ["医院", "学校", "研究院", "事业单位", "中心", "局", "委员会"]):
+        return "事业单位"
+    return None
+
+
+def extract_worker_position(text: str) -> str | None:
+    t = text or ""
+    return _first_group(r"(?:岗位|职位|工种)[::]" + _WS + r"([^\n,,。;;]{2,20})", t)
+
+
+def extract_entry_date(text: str) -> str | None:
+    t = text or ""
+    raw = _first_group(r"(?:入职时间|入职日期|到岗时间|参加工作时间)[::]?\s*([^\n,,。;;]{4,20})", t)
+    if raw:
+        return parse_date_any(raw)
+    raw2 = _first_group(r"(?:于|在)\s*([12][0-9]{3}[年./-][01]?[0-9][月./-][0-3]?[0-9])\s*(?:入职|到岗|开始工作)", t)
+    return parse_date_any(raw2) if raw2 else None
+
+
+def extract_leave_date(text: str) -> str | None:
+    t = text or ""
+    raw = _first_group(r"(?:离职时间|离职日期|解除时间|终止时间)[::]?\s*([^\n,,。;;]{4,20})", t)
+    if raw:
+        return parse_date_any(raw)
+    raw2 = _first_group(r"(?:于|在)\s*([12][0-9]{3}[年./-][01]?[0-9][月./-][0-3]?[0-9])\s*(?:离职|解除劳动合同|解除)", t)
+    return parse_date_any(raw2) if raw2 else None
+
+
+def extract_month_salary(text: str) -> float | None:
+    t = text or ""
+    raw = _first_group(r"(?:月工资|月薪|工资标准)[::]?\s*([^\n,,。;;]{2,30})", t)
+    if raw:
+        amt = parse_amount(raw + "元" if "元" not in raw else raw)
+        if amt is not None:
+            return amt
+    # 兜底:出现“月工资8000元”这样的写法
+    m = re.search(r"(月工资|月薪|工资标准)" + _WS + r"(?:为|是|:)?\s*(?:人民币|¥)?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)\s*元", t)
+    if m:
+        return float(m.group(2).replace(",", ""))
+    return None
+
+
+def extract_overtime_desc(text: str) -> str | None:
+    t = text or ""
+    # 抓取包含“加班”的句子/条目(简化:按换行/句号切)
+    parts = re.split(r"[\n。;;]+", t)
+    hits = [p.strip() for p in parts if "加班" in p and len(p.strip()) >= 6]
+    if not hits:
+        return None
+    return ";".join(hits[:3])
+
+
+def extract_dispute_focus(text: str) -> str | None:
+    t = text or ""
+    return _first_group(r"(?:争议焦点)[::]\s*([^\n]{2,120})", t)
+
+
+def extract_work_duration_text(text: str) -> str | None:
+    t = text or ""
+    m = re.search(r"(?:工作年限|用工期间|劳动关系存续)[:: ]?\s*([^\n。;;]{4,40})", t)
+    if m:
+        return m.group(1).strip()
+    m2 = re.search(r"(?:自|从)[^\n]{0,30}(?:至|到)[^\n]{0,30}(?:止|共计)", t)
+    return m2.group(0).strip()[:80] if m2 else None
+
+
+def extract_contract_type(text: str) -> str | None:
+    t = text or ""
+    if "无固定期限" in t:
+        return "无固定期限劳动合同"
+    if "固定期限" in t and "劳动合同" in t:
+        return "固定期限劳动合同"
+    if "未签订" in t and "劳动合同" in t:
+        return "未订立书面劳动合同"
+    if "劳动合同" in t and "期限" in t:
+        return _first_group(r"(?:劳动合同|合同)(?:期限|类型)[::]\s*([^\n,,。;;]{2,30})", t)
+    return None
+
+
+def extract_injury_related(text: str) -> str | None:
+    t = text or ""
+    if "工伤" not in t:
+        return None
+    parts = re.split(r"[\n。;;]+", t)
+    hits = [p.strip() for p in parts if "工伤" in p and len(p.strip()) >= 6]
+    return ";".join(hits[:2]) if hits else "涉及工伤"
+
+
+def extract_social_insurance_hint(text: str) -> str | None:
+    t = text or ""
+    if not any(k in t for k in ["社保", "社会保险", "五险", "养老保险", "医疗保险", "失业保险", "公积金"]):
+        return None
+    parts = re.split(r"[\n。;;]+", t)
+    hits = [p.strip() for p in parts if any(k in p for k in ["社保", "社会保险", "五险", "未缴纳", "欠缴"]) and len(p.strip()) >= 6]
+    return ";".join(hits[:2]) if hits else "涉及社会保险争议"
+
+
+def extract_evidence_materials(text: str) -> list[str] | None:
+    t = text or ""
+    block = _first_group(
+        r"(?:证据(?:材料|清单)|附件|所附证据)[::]?\s*(.+?)(?:此致|仲裁请求|事实与理由|申请人[::]|$)",
+        t,
+        flags=re.S,
+    )
+    if not block:
+        lines = _all_matches(r"^\s*[0-9一二三四五六七八九十]+[.、]\s*([^\n]{2,60})", t, flags=re.M)
+        return lines[:15] if lines else None
+    lines = [ln.strip(" \t-—") for ln in re.split(r"[\n;;]+", block) if ln.strip()]
+    out = []
+    for ln in lines:
+        ln = re.sub(r"^\s*[((]?\s*[0-9一二三四五六七八九十]+\s*[))]?\s*[.、]?\s*", "", ln)
+        if len(ln) >= 2:
+            out.append(ln[:120])
+    return out[:15] if out else None
+
+
+def infer_claim_types(text: str, claims: dict[str, Any], case_cause: str | None) -> list[str]:
+    t = text or ""
+    types: set[str] = set()
+    if case_cause:
+        types.add(case_cause)
+    mapping = [
+        ("工资", "工资报酬"),
+        ("加班", "加班费"),
+        ("经济补偿", "经济补偿"),
+        ("赔偿金", "违法解除赔偿"),
+        ("工伤", "工伤待遇"),
+        ("年休假", "年休假"),
+        ("双倍工资", "未订立书面劳动合同"),
+        ("社保", "社会保险"),
+        ("未签合同", "未订立书面劳动合同"),
+    ]
+    for kw, lab in mapping:
+        if kw in t:
+            types.add(lab)
+    for it in (claims or {}).get("items") or []:
+        s = str(it)
+        for kw, lab in mapping:
+            if kw in s:
+                types.add(lab)
+    return sorted(types)
+
+
+def extract_termination_reason(text: str) -> str | None:
+    t = text or ""
+    direct = _first_group(r"(?:解除原因|解除理由|辞退原因|解除劳动合同原因)[::]" + _WS + r"([^\n。;;]{2,60})", t)
+    if direct:
+        return direct
+    # 兜底:包含“因……解除/辞退”
+    m = re.search(r"(?:因|由于)\s*([^\n。;;]{2,40})\s*(?:被)?(?:辞退|解除劳动合同|解除)", t)
+    if m:
+        return m.group(1).strip()
+    return None
+
+
+def extract_claims(text: str) -> dict[str, Any]:
+    """
+    返回:
+    - items: list[str]
+    - amount_total: float | None
+    """
+    t = text or ""
+    # 请求段落:仲裁请求/请求事项/请求如下
+    block = _first_group(r"(?:仲裁请求|请求事项|请求如下)[::]?\s*(.+?)(?:事实与理由|事实理由|此致|证据目录|$)", t, flags=re.S)
+    items: list[str] = []
+    if block:
+        lines = [ln.strip(" \t-—") for ln in re.split(r"[\n;;]+", block) if ln.strip()]
+        # 若是 1. 2. 3. 形式
+        norm: list[str] = []
+        for ln in lines:
+            ln = re.sub(r"^\s*[((]?\s*[0-9一二三四五六七八九十]+\s*[))]?\s*[.、]?\s*", "", ln)
+            if ln:
+                norm.append(ln)
+        items = norm[:10]
+    # 汇总金额:从 items 中找最大/累加(MVP:取最大或第一条)
+    amounts = []
+    for it in items:
+        a = parse_amount(it)
+        if a is not None:
+            amounts.append(a)
+    amount_total = sum(amounts) if amounts else None
+    return {"items": items, "amount_total": amount_total}
+
+
+def _yes_no_from_text(t: str, positive: list[str], negative: list[str]) -> str | None:
+    for p in positive:
+        if p in t:
+            return "是"
+    for n in negative:
+        if n in t:
+            return "否"
+    return None
+
+
+def _snippet_near_keywords(t: str, keywords: tuple[str, ...], width: int = 120) -> str | None:
+    for kw in keywords:
+        i = t.find(kw)
+        if i >= 0:
+            frag = t[max(0, i - 20) : i + width].replace("\n", " ").strip()
+            return frag[:200] if frag else None
+    return None
+
+
+def _amount_near_keywords(t: str, keywords: tuple[str, ...]) -> float | None:
+    for kw in keywords:
+        i = t.find(kw)
+        if i >= 0:
+            window = t[i : i + 100]
+            if (a := parse_amount(window)) is not None:
+                return a
+    return None
+
+
+def classify_template_primary_cause(text: str) -> str | None:
+    """对应《案件普遍的智能分解模板》顶层案由分支(由关键词路由,可多案由时取最先命中)。"""
+    t = text or ""
+    ordered = [
+        ("生育保险待遇纠纷", ("生育津贴", "生育医疗", "产假工资", "生育保险待遇", "产检", "流产假")),
+        ("赔偿金纠纷", ("违法解除劳动", "违法终止劳动", "违法辞退", "赔偿金", "2N", "二倍经济补偿")),
+        ("经济补偿金纠纷", ("经济补偿金", "代通知金", "N+1", "解除合同经济补偿", "离职补偿")),
+        ("追索劳动报酬", ("追索劳动报酬", "拖欠工资", "工资差额", "加班费", "高温津贴", "年休假工资", "未及时足额")),
+        ("工伤保险待遇纠纷", ("工伤保险待遇", "劳动能力鉴定", "一次性伤残", "停工留薪", "工伤认定", "工伤")),
+        ("劳动关系纠纷类", ("确认劳动关系", "未订立书面劳动合同", "未签订劳动合同", "二倍工资", "双倍工资")),
+    ]
+    for name, kws in ordered:
+        if any(k in t for k in kws):
+            return name
+    return None
+
+
+def merge_dispute_template_fields(text: str, base: dict[str, Any]) -> dict[str, Any]:
+    """
+    按「案件普遍的智能分解模板」补充扁平字段(规则/关键词,可与 NLP 模型替换)。
+    base 为已跑完基础 extractors 的结果,用于拼装通用要素。
+    """
+    t = _clean_text(text)
+    o: dict[str, Any] = {}
+
+    o["tmpl_primary_cause"] = classify_template_primary_cause(t)
+
+    ga: list[str] = []
+    if base.get("applicant_name"):
+        ga.append(f"申请人:{base['applicant_name']}")
+    if base.get("applicant_type"):
+        ga.append(f"类型:{base['applicant_type']}")
+    if base.get("worker_position"):
+        ga.append(f"岗位:{base['worker_position']}")
+    ph = _first_group(r"(?:手机|联系电话|电话)[::]?\s*([0-9\-\s]{7,22})", t)
+    if ph:
+        ga.append(f"联系方式:{ph.strip()}")
+    o["gen_applicant_info"] = ";".join(ga) if ga else None
+
+    gr: list[str] = []
+    if base.get("respondent_name"):
+        gr.append(f"被申请人:{base['respondent_name']}")
+    if base.get("employer_nature"):
+        gr.append(f"单位性质:{base['employer_nature']}")
+    o["gen_respondent_info"] = ";".join(gr) if gr else None
+
+    facts = _first_group(
+        r"(?:事实与理由|事实和理由)[::]?\s*(.+?)(?=仲裁请求|请求事项|此致|证据目录|$)",
+        t,
+        flags=re.S,
+    )
+    o["gen_facts_and_reasons"] = (facts.strip()[:4000] if facts else None) or _snippet_near_keywords(
+        t, ("事实", "理由", "入职", "离职", "工资"), 200
+    )
+
+    # --- 1 劳动关系纠纷类 ---
+    o["lr1_pay_cycle"] = _first_group(r"(?:工资发放|劳动报酬发放|支付周期)[::]?\s*([^\n。;]{2,40})", t) or _snippet_near_keywords(
+        t, ("按月", "每月", "月薪", "计件"), 30
+    )
+    ms = base.get("month_salary")
+    try:
+        ms_f = float(ms) if ms is not None and ms != "" else None
+    except (TypeError, ValueError):
+        ms_f = None
+    o["lr1_pay_amount"] = _amount_near_keywords(t, ("劳动报酬", "工资标准", "月工资", "月薪")) or ms_f
+    o["lr1_pay_form"] = _snippet_near_keywords(t, ("银行转账", "现金", "微信", "支付宝", "打卡"))
+
+    o["lr1_si_joined"] = _yes_no_from_text(
+        t,
+        ["已缴纳社保", "参加社会保险", "缴纳五险", "有社保"],
+        ["未缴纳社保", "未参加社会保险", "未缴社保", "无社保"],
+    ) or base.get("social_insurance_hint")
+    o["lr1_si_benefit_amount"] = _amount_near_keywords(t, ("保险待遇", "社保待遇", "补缴"))
+
+    o["lr1_contract_signed"] = _yes_no_from_text(
+        t,
+        ["签订劳动合同", "订立书面劳动合同", "有书面合同"],
+        ["未签订劳动合同", "未订立书面劳动合同", "无书面合同"],
+    )
+    o["lr1_open_ended_contract"] = "是" if ("无固定期限" in t or "无固定期" in t) else None
+    o["lr1_double_wage_no_contract"] = "是" if ("二倍工资" in t or "双倍工资" in t or "未签劳动合同" in t) else None
+    o["lr1_relation_duration"] = base.get("work_duration_text") or _snippet_near_keywords(
+        t, ("劳动关系", "用工期间", "入职", "离职"), 80
+    )
+
+    # --- 2 工伤保险待遇纠纷 ---
+    o["wi_lr_contract_signed"] = o.get("lr1_contract_signed")
+    o["wi_lr_relation_duration"] = base.get("work_duration_text") or o.get("lr1_relation_duration")
+    o["wi_benefit_pay_time"] = _first_group(r"(?:待遇发放|支付时间|发放时间)[::]?\s*([^\n。;]{2,40})", t)
+    o["wi_benefit_amount_total"] = _amount_near_keywords(t, ("工伤保险待遇", "工伤待遇", "一次性伤残", "补助金"))
+    o["wi_benefit_disability"] = _amount_near_keywords(t, ("伤残津贴", "伤残补助"))
+    o["wi_benefit_prosthetic"] = _amount_near_keywords(t, ("假肢", "辅助器具"))
+    o["wi_benefit_medical_allowance"] = _amount_near_keywords(t, ("医疗补助金", "医疗补助"))
+    o["wi_benefit_travel"] = _amount_near_keywords(t, ("交通费", "食宿费", "交通食宿"))
+    o["wi_benefit_rehab"] = _amount_near_keywords(t, ("康复费", "医疗费"))
+    o["wi_benefit_nursing"] = _amount_near_keywords(t, ("护理费",))
+    o["wi_benefit_meal"] = _amount_near_keywords(t, ("住院伙食", "伙食补助"))
+    o["wi_benefit_pay_form"] = o.get("lr1_pay_form")
+    o["wi_si_benefit_amt"] = o.get("lr1_si_benefit_amount")
+    o["wi_si_joined"] = o.get("lr1_si_joined")
+    o["wi_recognize_ec"] = _yes_no_from_text(
+        t,
+        ["认可经济补偿", "同意支付经济补偿", "支付经济补偿金"],
+        ["不认可经济补偿", "无需支付经济补偿", "不同意经济补偿"],
+    )
+
+    # --- 3 追索劳动报酬 ---
+    o["sr_pay_cycle"] = o.get("lr1_pay_cycle")
+    o["sr_claim_amount"] = _amount_near_keywords(t, ("主张金额", "请求金额", "仲裁请求")) or (base.get("claims") or {}).get(
+        "amount_total"
+    )
+    o["sr_claim_deducted_pay"] = _amount_near_keywords(t, ("克扣", "拖欠工资", "欠付工资"))
+    o["sr_claim_overtime_pay"] = _amount_near_keywords(t, ("加班费", "加班工资")) or (
+        parse_amount(base.get("overtime_desc") or "") if base.get("overtime_desc") else None
+    )
+    o["sr_claim_living_allowance"] = _amount_near_keywords(t, ("生活费", "待岗"))
+    o["sr_high_temp_allowance"] = _amount_near_keywords(t, ("高温津贴", "防暑降温"))
+    o["sr_actual_pay_standard"] = _snippet_near_keywords(t, ("实发工资", "实际支付", "实发"), 40)
+    o["sr_agreed_pay_standard"] = _snippet_near_keywords(t, ("约定工资", "合同约定工资", "月薪约定"), 40) or (
+        str(base["month_salary"]) if base.get("month_salary") is not None else None
+    )
+    o["sr_annual_leave_pay"] = _amount_near_keywords(t, ("年休假工资", "未休年假", "带薪年休假"))
+    o["sr_unpaid_period"] = _first_group(r"(?:欠付期间|未支付期间|拖欠期间)[::]?\s*([^\n。;]{2,60})", t) or _snippet_near_keywords(
+        t, ("至", "期间"), 50
+    )
+    o["sr_overtime_amount"] = o.get("sr_claim_overtime_pay")
+
+    # --- 4 经济补偿金纠纷 ---
+    o["ec_avg_salary_12m"] = _first_group(
+        r"(?:离职前\s*12\s*个月|十二个月).{0,12}(?:平均|月均)工资[::]?\s*([^\n,。;]{2,40})",
+        t,
+    ) or _amount_near_keywords(t, ("平均工资", "月均工资", "月平均工资"))
+    o["ec_claim_amount"] = o.get("sr_claim_amount")
+    o["ec_double_wage_part"] = _amount_near_keywords(t, ("二倍工资", "双倍工资", "未签劳动合同"))
+    o["ec_illegal_term_part"] = _amount_near_keywords(t, ("违法解除", "违法终止", "违法辞退"))
+    o["ec_illegal_probation_part"] = _amount_near_keywords(t, ("违法约定试用期", "试用期赔偿"))
+    o["ec_extra_compensation_part"] = _amount_near_keywords(t, ("额外补偿", "加付"))
+    o["ec_notice_pay"] = _amount_near_keywords(t, ("代通知金", "提前三十日"))
+    o["ec_additional_damages"] = _amount_near_keywords(t, ("加付赔偿金", "赔偿金50%"))
+    o["ec_contract_duration"] = base.get("work_duration_text")
+    o["ec_leave_reason"] = base.get("termination_reason")
+    o["ec_leave_date"] = base.get("leave_date")
+
+    # --- 5 赔偿金纠纷 ---
+    o["dm_claim_amount"] = o.get("sr_claim_amount")
+    o["dm_illegal_dismissal_damages"] = _amount_near_keywords(t, ("违法解除劳动", "违法辞退", "赔偿金", "2倍"))
+    o["dm_contract_exists"] = o.get("lr1_contract_signed")
+    o["dm_terminate_reason"] = base.get("termination_reason")
+    o["dm_contract_continue"] = _yes_no_from_text(t, ["继续履行劳动合同", "恢复劳动关系"], ["不继续履行", "解除劳动关系"])
+
+    # --- 6 生育保险待遇纠纷 ---
+    o["mi_claim_amount"] = o.get("sr_claim_amount")
+    o["mi_maternity_medical"] = _amount_near_keywords(t, ("生育医疗费", "生育医疗费用", "产检费"))
+    o["mi_maternity_allowance_salary"] = _amount_near_keywords(t, ("生育津贴", "产假工资"))
+    o["mi_additional_damages"] = o.get("ec_additional_damages")
+    o["mi_travel_accommodation"] = o.get("wi_benefit_travel")
+    o["mi_contract_continue"] = o.get("dm_contract_continue")
+    o["mi_terminate_reason"] = base.get("termination_reason")
+
+    return o
+
+
+def refresh_derived_element_fields(out: dict[str, Any], text: str) -> None:
+    """
+    在 merge_dispute_template_fields 或 LLM 补丁写入 out 之后,重算 primary_cause_type、elements_hierarchy、case_elements_table。
+    """
+    t = _clean_text(text)
+    templates = load_hierarchy_templates()
+    cause = out.get("tmpl_primary_cause") or classify_template_primary_cause(t) or DEFAULT_CAUSE_TYPE
+    if templates and cause not in templates:
+        cause = DEFAULT_CAUSE_TYPE
+    out["primary_cause_type"] = cause
+    out["tmpl_primary_cause"] = cause
+    if templates and cause in templates:
+        hier = build_elements_hierarchy_for_cause(cause, out)
+        if hier is not None:
+            out["elements_hierarchy"] = hier
+    schema = load_case_elements_schema()
+    out["case_elements_table"] = build_case_elements_table(out, schema)
+
+
+@dataclass
+class RuleBasedLaborExtractor:
+    """
+    MVP:基于规则/正则的劳动仲裁要素抽取器(后续可替换为 BERT 微调模型)。
+    要素分组与中文标签由 backend/data/case_elements_schema.json 定义,可替换为您自己的案件要素表。
+    """
+
+    extractors: list[tuple[str, Callable[[str], Any]]] | None = None
+    _schema: dict[str, Any] | None = None
+
+    def __post_init__(self) -> None:
+        self._schema = load_case_elements_schema()
+        if self.extractors is None:
+            self.extractors = [
+                ("case_number", extract_case_number),
+                ("filing_date", extract_filing_date),
+                ("arbitration_org", extract_arbitration_org),
+                ("case_title", extract_case_title),
+                ("applicant_name", extract_applicant_name),
+                ("applicant_type", extract_applicant_type),
+                ("respondent_name", extract_respondent_name),
+                ("employer_nature", extract_employer_nature),
+                ("worker_position", extract_worker_position),
+                ("employment_type", extract_employment_type),
+                ("case_cause", detect_case_cause),
+                ("dispute_focus", extract_dispute_focus),
+                ("entry_date", extract_entry_date),
+                ("leave_date", extract_leave_date),
+                ("work_duration_text", extract_work_duration_text),
+                ("month_salary", extract_month_salary),
+                ("overtime_desc", extract_overtime_desc),
+                ("termination_reason", extract_termination_reason),
+                ("contract_type", extract_contract_type),
+                ("injury_related", extract_injury_related),
+                ("social_insurance_hint", extract_social_insurance_hint),
+                ("claims", extract_claims),
+                ("law_refs", extract_law_refs),
+                ("evidence_materials", extract_evidence_materials),
+            ]
+
+    def extract(self, text: str) -> dict[str, Any]:
+        text = _clean_text(text)
+        out: dict[str, Any] = {}
+        for key, fn in self.extractors or []:
+            try:
+                out[key] = fn(text)
+            except Exception:
+                out[key] = None
+        try:
+            out["claim_types"] = infer_claim_types(text, out.get("claims") or {}, out.get("case_cause"))
+        except Exception:
+            out["claim_types"] = []
+        try:
+            out.update(merge_dispute_template_fields(text, out))
+        except Exception:
+            pass
+
+        refresh_derived_element_fields(out, text)
+        return out
+

+ 5 - 0
backend/app/extractors/__init__.py

@@ -0,0 +1,5 @@
+"""规则与专用抽取器子包。"""
+
+from app.extractors.rule_extractor import RuleBasedExtractor
+
+__all__ = ["RuleBasedExtractor"]

+ 98 - 0
backend/app/extractors/rule_extractor.py

@@ -0,0 +1,98 @@
+"""
+基于规则的劳动仲裁要素抽取(独立模块,供混合抽取器等复用)。
+案由六分类与 app.extractor.classify_template_primary_cause 一致。
+"""
+from __future__ import annotations
+
+import re
+from typing import Any
+
+# 支持包内导入与将 app 目录加入 PYTHONPATH 后的裸导入(与任务说明一致)
+try:
+    from app.extractor import parse_amount
+except ImportError:
+    from extractor import parse_amount  # type: ignore
+
+from app.extractor import (
+    classify_template_primary_cause,
+    extract_applicant_name,
+    extract_claims,
+    extract_contract_type,
+    extract_entry_date,
+    extract_law_refs,
+    extract_leave_date,
+    extract_month_salary,
+    extract_overtime_desc,
+    extract_respondent_name,
+    extract_termination_reason,
+    merge_dispute_template_fields,
+)
+from app.extractor import _amount_near_keywords as amount_near_keywords  # noqa: SLF001
+from app.extractor import _clean_text as clean_text  # noqa: SLF001
+from app.extractor import _yes_no_from_text  # noqa: SLF001
+
+_DEFAULT_CAUSE = "劳动关系纠纷类"
+
+
+class RuleBasedExtractor:
+    """规则抽取器:案由分类 + 通用/劳动合同/社保/法条 + 仲裁请求(规则版)。"""
+
+    def extract_all(self, text: str) -> dict[str, Any]:
+        t = clean_text(text or "")
+        primary_cause_type = classify_template_primary_cause(t) or _DEFAULT_CAUSE
+
+        applicant = extract_applicant_name(t)
+        respondent = extract_respondent_name(t)
+        entry_date = extract_entry_date(t)
+        leave_date = extract_leave_date(t)
+        month_salary_standard = extract_month_salary(t)
+        overtime_fact = extract_overtime_desc(t)
+        termination_reason = extract_termination_reason(t)
+
+        labor_contract_signed = _yes_no_from_text(
+            t,
+            ["签订劳动合同", "订立书面劳动合同", "有书面合同", "已签订劳动合同"],
+            ["未签订劳动合同", "未订立书面劳动合同", "无书面合同", "未签订书面劳动合同"],
+        )
+        contract_type = extract_contract_type(t)
+        double_wage_related = "是" if any(x in t for x in ("二倍工资", "双倍工资", "未签劳动合同")) else None
+
+        social_insurance_enrolled = _yes_no_from_text(
+            t,
+            ["已缴纳社保", "参加社会保险", "缴纳五险", "有社保", "已参保"],
+            ["未缴纳社保", "未参加社会保险", "未缴社保", "无社保", "未参保"],
+        )
+        social_insurance_amount = amount_near_keywords(t, ("保险待遇", "社保待遇", "补缴", "社会保险费"))
+        if social_insurance_amount is None:
+            for frag in re.split(r"[\n。;]+", t):
+                if any(k in frag for k in ("社保", "社会保险", "五险")):
+                    social_insurance_amount = parse_amount(frag)
+                    if social_insurance_amount is not None:
+                        break
+
+        law_refs = extract_law_refs(t)
+        claims = extract_claims(t)
+
+        base: dict[str, Any] = {
+            "primary_cause_type": primary_cause_type,
+            "applicant_name": applicant,
+            "respondent_name": respondent,
+            "entry_date": entry_date,
+            "leave_date": leave_date,
+            "month_salary_standard": month_salary_standard,
+            "overtime_fact": overtime_fact,
+            "termination_reason": termination_reason,
+            "labor_contract_signed": labor_contract_signed,
+            "contract_type": contract_type,
+            "double_wage_related": double_wage_related,
+            "social_insurance_enrolled": social_insurance_enrolled,
+            "social_insurance_amount": social_insurance_amount,
+            "law_refs": law_refs,
+            "claims": claims,
+        }
+        # 与现有模板扁平字段对齐,便于后续层级/画像复用
+        base.update(merge_dispute_template_fields(t, base))
+        base["primary_cause_type"] = base.get("tmpl_primary_cause") or primary_cause_type
+        base["tmpl_primary_cause"] = base["primary_cause_type"]
+        return base
+ 

+ 157 - 0
backend/app/hierarchy_extract.py

@@ -0,0 +1,157 @@
+"""
+按 case_elements_hierarchy.json 的层级结构,将规则抽取结果(merge_dispute_template_fields 等扁平字段)
+填入对应叶子节点;案由分类决定仅展示与填充该案由子树。
+"""
+from __future__ import annotations
+
+import copy
+import json
+from pathlib import Path
+from typing import Any
+
+_HIERARCHY_PATH = Path(__file__).resolve().parent.parent / "data" / "case_elements_hierarchy.json"
+
+# 叶子路径 "案由>一层>二层>…" -> merge_dispute_template_fields / extract 产出的英文 hint 键
+LEAF_PATH_TO_HINT_KEY: dict[str, str] = {
+    # —— 通用要素(各案由顶层,值来自同一组扁平字段)——
+    "劳动关系纠纷类>申请人信息": "gen_applicant_info",
+    "劳动关系纠纷类>被申请人信息": "gen_respondent_info",
+    "劳动关系纠纷类>事实与理由": "gen_facts_and_reasons",
+    "工伤保险待遇纠纷>申请人信息": "gen_applicant_info",
+    "工伤保险待遇纠纷>被申请人信息": "gen_respondent_info",
+    "工伤保险待遇纠纷>事实与理由": "gen_facts_and_reasons",
+    "经济补偿金纠纷>申请人信息": "gen_applicant_info",
+    "经济补偿金纠纷>被申请人信息": "gen_respondent_info",
+    "经济补偿金纠纷>事实与理由": "gen_facts_and_reasons",
+    "赔偿金纠纷>申请人信息": "gen_applicant_info",
+    "赔偿金纠纷>被申请人信息": "gen_respondent_info",
+    "赔偿金纠纷>事实与理由": "gen_facts_and_reasons",
+    "生育保险待遇纠纷>申请人信息": "gen_applicant_info",
+    "生育保险待遇纠纷>被申请人信息": "gen_respondent_info",
+    "生育保险待遇纠纷>事实与理由": "gen_facts_and_reasons",
+    # —— 劳动关系纠纷类 ——
+    "劳动关系纠纷类>劳动报酬>劳动报酬发放周期": "lr1_pay_cycle",
+    "劳动关系纠纷类>劳动报酬>劳动报酬金额": "lr1_pay_amount",
+    "劳动关系纠纷类>劳动报酬>劳动报酬发放形式": "lr1_pay_form",
+    "劳动关系纠纷类>社会保险>是否参加社会保险": "lr1_si_joined",
+    "劳动关系纠纷类>社会保险>保险待遇金额": "lr1_si_benefit_amount",
+    "劳动关系纠纷类>劳动关系>是否签订劳动合同>签订无固定限期劳动合同": "lr1_open_ended_contract",
+    "劳动关系纠纷类>劳动关系>是否签订劳动合同>未签订劳动合同的二倍工资": "lr1_double_wage_no_contract",
+    "劳动关系纠纷类>劳动关系>劳动关系存在时间": "lr1_relation_duration",
+    # —— 工伤保险待遇纠纷 ——
+    "工伤保险待遇纠纷>劳动关系>是否签订劳动合同": "wi_lr_contract_signed",
+    "工伤保险待遇纠纷>劳动关系>劳动关系存在时间": "wi_lr_relation_duration",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放时间>一次性伤残补助费": "wi_benefit_disability",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放时间>辅助器具费": "wi_benefit_prosthetic",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放时间>一次性医疗补助金": "wi_benefit_medical_allowance",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放时间>交通食宿费": "wi_benefit_travel",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放时间>工伤医疗/康复费用": "wi_benefit_rehab",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放时间>住院治疗期间护理费": "wi_benefit_nursing",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放时间>住院伙食补助费": "wi_benefit_meal",
+    "工伤保险待遇纠纷>保险待遇>保险待遇金额": "wi_benefit_amount_total",
+    "工伤保险待遇纠纷>保险待遇>保险待遇发放形式": "wi_benefit_pay_form",
+    "工伤保险待遇纠纷>保险待遇>是否认可经济补偿": "wi_recognize_ec",
+    "工伤保险待遇纠纷>社会保险>保险待遇金额": "wi_si_benefit_amt",
+    "工伤保险待遇纠纷>社会保险>是否参加社会保险": "wi_si_joined",
+    # —— 追索劳动报酬 ——
+    "追索劳动报酬>申请人信息": "gen_applicant_info",
+    "追索劳动报酬>被申请人信息": "gen_respondent_info",
+    "追索劳动报酬>事实与理由": "gen_facts_and_reasons",
+    "追索劳动报酬>劳动报酬>劳动报酬发放周期": "sr_pay_cycle",
+    "追索劳动报酬>劳动报酬>克扣劳动报酬>主张金额": "sr_claim_deducted_pay",
+    "追索劳动报酬>劳动报酬>克扣劳动报酬>生活费": "sr_claim_living_allowance",
+    "追索劳动报酬>劳动报酬>加班工资": "sr_claim_overtime_pay",
+    "追索劳动报酬>劳动报酬>实际支付工资标准": "sr_actual_pay_standard",
+    "追索劳动报酬>劳动报酬>高温津贴金额": "sr_high_temp_allowance",
+    "追索劳动报酬>劳动报酬>约定工资标准": "sr_agreed_pay_standard",
+    "追索劳动报酬>劳动报酬>带薪年休假工资": "sr_annual_leave_pay",
+    "追索劳动报酬>劳动报酬>未支付期间": "sr_unpaid_period",
+    "追索劳动报酬>劳动报酬>加班工资金额": "sr_overtime_amount",
+    # —— 经济补偿金纠纷 ——
+    "经济补偿金纠纷>经济补偿金>离职前12个月平均工资": "ec_avg_salary_12m",
+    "经济补偿金纠纷>经济补偿金>未签订劳动合同的二倍工资": "ec_double_wage_part",
+    "经济补偿金纠纷>经济补偿金>主张金额>违法解除劳动合同的赔偿金": "ec_illegal_term_part",
+    "经济补偿金纠纷>经济补偿金>主张金额>违法约定试用期的赔偿金": "ec_illegal_probation_part",
+    "经济补偿金纠纷>经济补偿金>代通知金": "ec_notice_pay",
+    "经济补偿金纠纷>经济补偿金>加付赔偿金": "ec_additional_damages",
+    "经济补偿金纠纷>经济补偿金>劳动合同存在时间": "ec_contract_duration",
+    "经济补偿金纠纷>劳动合同>离职原因": "ec_leave_reason",
+    "经济补偿金纠纷>劳动合同>离职时间": "ec_leave_date",
+    # —— 赔偿金纠纷 ——
+    "赔偿金纠纷>赔偿金>主张金额": "dm_claim_amount",
+    "赔偿金纠纷>赔偿金>违法解除劳动合同赔偿金": "dm_illegal_dismissal_damages",
+    "赔偿金纠纷>劳动合同>劳动合同是否存在": "dm_contract_exists",
+    "赔偿金纠纷>劳动合同>解除劳动合同原因": "dm_terminate_reason",
+    "赔偿金纠纷>劳动合同>劳动合同是否继续履行": "dm_contract_continue",
+    # —— 生育保险待遇纠纷 ——
+    "生育保险待遇纠纷>金额>主张金额>产假工资/生育津贴": "mi_maternity_allowance_salary",
+    "生育保险待遇纠纷>金额>主张金额>生育医疗费用": "mi_maternity_medical",
+    "生育保险待遇纠纷>金额>加付赔偿金": "mi_additional_damages",
+    "生育保险待遇纠纷>金额>交通食宿费": "mi_travel_accommodation",
+    "生育保险待遇纠纷>劳动合同>劳动合同是否继续履行": "mi_contract_continue",
+    "生育保险待遇纠纷>劳动合同>劳动合同解除原因": "mi_terminate_reason",
+}
+
+DEFAULT_CAUSE_TYPE = "劳动关系纠纷类"
+
+
+def load_hierarchy_templates() -> dict[str, Any]:
+    if not _HIERARCHY_PATH.is_file():
+        return {}
+    try:
+        return json.loads(_HIERARCHY_PATH.read_text(encoding="utf-8"))
+    except Exception:
+        return {}
+
+
+def _path_key(path: tuple[str, ...]) -> str:
+    return ">".join(path)
+
+
+def _leaf_value(path: tuple[str, ...], hints: dict[str, Any]) -> Any:
+    key = _path_key(path)
+    hk = LEAF_PATH_TO_HINT_KEY.get(key)
+    if hk and hk in hints:
+        v = hints[hk]
+        if v is not None and v != "":
+            return v
+    return None
+
+
+def fill_hierarchy_node(node: Any, path: tuple[str, ...], hints: dict[str, Any]) -> Any:
+    if node is None:
+        return _leaf_value(path, hints)
+    if isinstance(node, dict):
+        return {k: fill_hierarchy_node(v, path + (k,), hints) for k, v in node.items()}
+    return node
+
+
+def build_elements_hierarchy_for_cause(cause_type: str, hints: dict[str, Any]) -> dict[str, Any] | None:
+    templates = load_hierarchy_templates()
+    if cause_type not in templates:
+        return None
+    subtree = copy.deepcopy(templates[cause_type])
+    return fill_hierarchy_node(subtree, (cause_type,), hints)
+
+
+def count_hierarchy_leaves(obj: Any) -> int:
+    if obj is None:
+        return 1
+    if isinstance(obj, dict):
+        if not obj:
+            return 0
+        return sum(count_hierarchy_leaves(v) for v in obj.values())
+    return 0
+
+
+def flatten_hierarchy_for_preview(obj: Any, path: tuple[str, ...] = ()) -> list[dict[str, Any]]:
+    """扁平行:供 case_elements_table 简要预览。"""
+    rows: list[dict[str, Any]] = []
+    if obj is None:
+        rows.append({"path": list(path), "value": None})
+    elif isinstance(obj, dict):
+        for k, v in obj.items():
+            rows.extend(flatten_hierarchy_for_preview(v, path + (k,)))
+    else:
+        rows.append({"path": list(path), "value": obj})
+    return rows

+ 1107 - 0
backend/app/main.py

@@ -0,0 +1,1107 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import traceback
+from datetime import datetime
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Any
+from uuid import uuid4
+
+import magic
+from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, UploadFile
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse, Response
+from pydantic import BaseModel, Field
+from sqlalchemy import func
+from sqlalchemy.orm import Session
+
+from app.db import Base, engine, get_db
+from app.config import settings
+from app.extractor import (
+    RuleBasedLaborExtractor,
+    build_case_elements_table,
+    load_case_elements_schema,
+    merge_dispute_template_fields,
+    refresh_derived_element_fields,
+)
+from app.migrate import ensure_mysql_schema
+from app.models import Case, CaseElementsVersion, CaseFile, ProcessingTask
+from app.services.document_parser import DocumentParser
+from app.services.hybrid_extractor import HybridExtractor
+from app.services.complexity_classifier import classify_complexity
+from app.services.portrait_generator import generate_portrait
+from app.services.risk_predictor import assess_risk
+
+app = FastAPI(title="Labor Arbitration Backend", version="0.1.0")
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# 开发期轻量迁移(避免你改模型后 MySQL 表不同步导致 500)
+ensure_mysql_schema(engine)
+Base.metadata.create_all(bind=engine)
+extractor = RuleBasedLaborExtractor()
+hybrid_extractor = HybridExtractor()
+_ollama_claims = None
+if settings.use_ollama:
+    try:
+        from app.anj import OllamaClaimsExtractor
+
+        _ollama_claims = OllamaClaimsExtractor(settings.ollama_base_url, settings.ollama_model_name)
+    except Exception:
+        _ollama_claims = None
+
+
+@app.exception_handler(Exception)
+async def global_exception_handler(request, exc: Exception):
+    return JSONResponse(
+        status_code=500,
+        content={"detail": "服务器内部错误", "error": str(exc), "trace": traceback.format_exc()[:4000]},
+    )
+
+
+def _upload_dir() -> Path:
+    p = Path(os.getenv("UPLOAD_DIR", "uploads"))
+    p.mkdir(parents=True, exist_ok=True)
+    return p
+
+
+# 入库全文上限(MySQL LONGTEXT 足够;此处防止极端大文件占满内存)
+_MAX_STORED_APPLICATION_TEXT = 200_000
+# 接口返回给前端的最大长度(避免 JSON 过大)
+_MAX_API_APPLICATION_TEXT = 50_000
+
+
+def _sync_case_elements_table(elements: dict[str, Any], *, rebuild_hierarchy: bool = True) -> None:
+    """扁平要素定稿后再生成分组表;可选按扁平字段重算案由层级树(避免 PUT 仅改层级时被覆盖)。"""
+    elements["case_elements_table"] = build_case_elements_table(elements, load_case_elements_schema())
+    if not rebuild_hierarchy:
+        return
+    from app.hierarchy_extract import build_elements_hierarchy_for_cause, load_hierarchy_templates
+
+    cause = elements.get("primary_cause_type")
+    tmpl = load_hierarchy_templates()
+    if cause and tmpl.get(cause):
+        h = build_elements_hierarchy_for_cause(cause, elements)
+        if h is not None:
+            elements["elements_hierarchy"] = h
+
+
+def _normalize_case_name(name: str) -> str:
+    return (name or "").replace("\u3000", " ").strip()
+
+
+def _material_fingerprint_from_file_contents(contents: list[bytes]) -> str:
+    if not contents:
+        return hashlib.sha256(b"").hexdigest()
+    digests = sorted(hashlib.sha256(c).hexdigest() for c in contents)
+    return hashlib.sha256("|".join(digests).encode("utf-8")).hexdigest()
+
+
+def _normalized_text_fingerprint(merged_stored: str) -> str:
+    """空白规范化后哈希,减轻解析换行/空格差异导致的重复漏判。"""
+    slice_ = (merged_stored or "")[:_MAX_STORED_APPLICATION_TEXT]
+    norm = re.sub(r"[\s\u3000]+", " ", slice_.strip())
+    return hashlib.sha256(norm.encode("utf-8", errors="ignore")).hexdigest()
+
+
+def _merged_text_from_prepared(prepared: list[tuple[UploadFile, bytes, str]]) -> str:
+    chunks: list[str] = []
+    for f, content, _ in prepared:
+        suffix = Path(f.filename).suffix.lower() or ".txt"
+        tmp = NamedTemporaryFile(delete=False, suffix=suffix)
+        try:
+            tmp.write(content)
+            tmp.close()
+            chunks.append(DocumentParser.parse_file(tmp.name))
+        finally:
+            try:
+                Path(tmp.name).unlink(missing_ok=True)
+            except OSError:
+                pass
+    return "\n\n".join(t for t in chunks if t.strip())
+
+
+def _find_existing_duplicate_case(
+    db: Session, norm_name: str, fingerprint: str, merged_stored: str
+) -> Case | None:
+    """
+    去重顺序:
+    1) 同名 + 材料指纹一致,或同名 + 规范化正文哈希一致(兼容旧行无指纹)
+    2) 任意案件材料指纹一致(材料相同即视为重复,不强制名称一致)
+    3) 最近若干条有正文的案件中,规范化正文哈希一致(无指纹旧数据)
+    """
+    text_h = _normalized_text_fingerprint(merged_stored)
+
+    if norm_name:
+        cands_name = db.query(Case).filter(func.trim(Case.case_name) == norm_name).all()
+        for c in cands_name:
+            mf = (c.material_fingerprint or "").strip()
+            if mf and mf == fingerprint:
+                return c
+            app_raw = c.application_text or ""
+            if app_raw and _normalized_text_fingerprint(app_raw) == text_h:
+                return c
+
+    row = db.query(Case).filter(Case.material_fingerprint == fingerprint).filter(Case.material_fingerprint.isnot(None)).first()
+    if row:
+        return row
+
+    # 仅「同名 + 正文哈希一致」才命中,避免不同案件名称因正文规范化后相同而误命中旧案(导致前端一直显示首次抽取结果)
+    recent = (
+        db.query(Case)
+        .filter(Case.application_text.isnot(None))
+        .filter(Case.application_text != "")
+        .order_by(Case.id.desc())
+        .limit(400)
+        .all()
+    )
+    for c in recent:
+        if _normalize_case_name(c.case_name or "") != norm_name:
+            continue
+        if _normalized_text_fingerprint(c.application_text or "") == text_h:
+            return c
+    return None
+
+
+def _allowed(content_type: str, filename: str) -> bool:
+    ext = Path(filename).suffix.lower()
+    if ext in {".pdf", ".docx", ".txt"}:
+        return True
+    if content_type in {
+        "application/pdf",
+        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+        "text/plain",
+    }:
+        return True
+    return False
+
+
+def _jaccard(a: set[str], b: set[str]) -> float:
+    if not a or not b:
+        return 0.0
+    inter = len(a & b)
+    union = len(a | b)
+    return inter / union if union else 0.0
+
+
+def _case_tag_set(elements: dict[str, Any]) -> set[str]:
+    tags = set()
+    for k in ["case_cause", "employer_nature", "worker_position", "applicant_name", "respondent_name"]:
+        v = elements.get(k)
+        if v:
+            tags.add(str(v))
+    for law in (elements.get("law_refs") or []):
+        tags.add(str(law))
+    claims = elements.get("claims") or {}
+    for item in (claims.get("items") or []):
+        if item:
+            tags.add(str(item)[:20])
+    for ct in elements.get("claim_types") or []:
+        if ct:
+            tags.add(str(ct))
+    return tags
+
+
+def _elements_brief(elements: dict[str, Any] | None) -> dict[str, Any]:
+    el = elements or {}
+    claims = el.get("claims") or {}
+    return {
+        "case_number": el.get("case_number"),
+        "arbitration_org": el.get("arbitration_org"),
+        "applicant_name": el.get("applicant_name"),
+        "respondent_name": el.get("respondent_name"),
+        "employer_nature": el.get("employer_nature"),
+        "worker_position": el.get("worker_position"),
+        "case_cause": el.get("case_cause"),
+        "claim_types": el.get("claim_types"),
+        "entry_date": el.get("entry_date"),
+        "leave_date": el.get("leave_date"),
+        "month_salary": el.get("month_salary"),
+        "claims_amount_total": claims.get("amount_total"),
+        "claims_items_preview": (claims.get("items") or [])[:5],
+        "law_refs": (el.get("law_refs") or [])[:8],
+    }
+
+
+def _portrait_scores(portrait: dict[str, Any] | None) -> dict[str, Any]:
+    p = portrait or {}
+    scores = p.get("scores") or {}
+    return {
+        "legal": scores.get("legal"),
+        "fact": scores.get("fact"),
+        "risk": scores.get("risk"),
+        "risk_level": p.get("risk_level"),
+    }
+
+_SIM_WEIGHT_LEGAL = 0.45
+_SIM_WEIGHT_FACT = 0.35
+_SIM_WEIGHT_RISK = 0.20
+
+
+def _subscore(p: dict[str, Any] | None, dim: str, name: str) -> float | None:
+    if not p:
+        return None
+    d = (p.get(dim) or {}).get("subscores") or {}
+    v = d.get(name)
+    try:
+        return float(v) if v is not None else None
+    except (TypeError, ValueError):
+        return None
+
+
+def _sim_0_100(a: float | None, b: float | None) -> float | None:
+    if a is None or b is None:
+        return None
+    return max(0.0, 100.0 - abs(float(a) - float(b)))
+
+
+def _weighted_similarity_from_portraits(base_p: dict[str, Any] | None, sim_p: dict[str, Any] | None) -> dict[str, Any]:
+    """
+    计算法律/事实/风险三维度加权相似度(0-100 越高越相似),并返回 breakdown。
+    - 法律维度:争议焦点、法律适用/法条引用(用画像 subscores 近似)
+    - 事实维度:证据完备度、事实清晰度
+    - 风险维度:诉求支持可能性、矛盾激化程度
+    """
+    legal_sim = None
+    ls1 = _sim_0_100(_subscore(base_p, "legal_dimension", "争议焦点明确度"), _subscore(sim_p, "legal_dimension", "争议焦点明确度"))
+    ls2 = _sim_0_100(_subscore(base_p, "legal_dimension", "法律条款引用充分度"), _subscore(sim_p, "legal_dimension", "法律条款引用充分度"))
+    legal_parts = [x for x in (ls1, ls2) if x is not None]
+    if legal_parts:
+        legal_sim = sum(legal_parts) / len(legal_parts)
+
+    fact_sim = None
+    fs1 = _sim_0_100(_subscore(base_p, "fact_dimension", "证据完备度(近似)"), _subscore(sim_p, "fact_dimension", "证据完备度(近似)"))
+    fs2 = _sim_0_100(_subscore(base_p, "fact_dimension", "事实描述清晰度"), _subscore(sim_p, "fact_dimension", "事实描述清晰度"))
+    fact_parts = [x for x in (fs1, fs2) if x is not None]
+    if fact_parts:
+        fact_sim = sum(fact_parts) / len(fact_parts)
+
+    risk_sim = None
+    rs1 = _sim_0_100(
+        _subscore(base_p, "risk_dimension", "诉求支持可能性(规则近似)"),
+        _subscore(sim_p, "risk_dimension", "诉求支持可能性(规则近似)"),
+    )
+    rs2 = _sim_0_100(
+        _subscore(base_p, "risk_dimension", "矛盾激化程度(规则近似)"),
+        _subscore(sim_p, "risk_dimension", "矛盾激化程度(规则近似)"),
+    )
+    risk_parts = [x for x in (rs1, rs2) if x is not None]
+    if risk_parts:
+        risk_sim = sum(risk_parts) / len(risk_parts)
+
+    breakdown: list[dict[str, Any]] = [
+        {"dimension": "法律维度", "weight": _SIM_WEIGHT_LEGAL, "similarity": legal_sim},
+        {"dimension": "事实维度", "weight": _SIM_WEIGHT_FACT, "similarity": fact_sim},
+        {"dimension": "风险维度", "weight": _SIM_WEIGHT_RISK, "similarity": risk_sim},
+    ]
+
+    total_w = 0.0
+    total = 0.0
+    for r in breakdown:
+        sim = r.get("similarity")
+        if sim is None:
+            continue
+        w = float(r["weight"])
+        total_w += w
+        total += w * float(sim)
+    overall = (total / total_w) if total_w > 0 else None
+    return {"overall": overall, "breakdown": breakdown}
+
+
+def _comparison_with_current(base_el: dict[str, Any], sim_el: dict[str, Any]) -> list[dict[str, Any]]:
+    rows: list[tuple[str, str, str]] = [
+        ("案件编号", "case_number", "case_number"),
+        ("仲裁机构", "arbitration_org", "arbitration_org"),
+        ("案由类型", "case_cause", "case_cause"),
+        ("申请人", "applicant_name", "applicant_name"),
+        ("被申请人", "respondent_name", "respondent_name"),
+        ("单位性质", "employer_nature", "employer_nature"),
+        ("岗位", "worker_position", "worker_position"),
+        ("入职日期", "entry_date", "entry_date"),
+        ("离职日期", "leave_date", "leave_date"),
+        ("月工资标准", "month_salary", "month_salary"),
+    ]
+    out: list[dict[str, Any]] = []
+    for label, key, _ in rows:
+        a, b = base_el.get(key), sim_el.get(key)
+        out.append(
+            {
+                "dimension": label,
+                "current": a,
+                "similar": b,
+                "aligned": a is not None and b is not None and str(a) == str(b),
+            }
+        )
+    bc = (base_el.get("claims") or {}).get("amount_total")
+    sc = (sim_el.get("claims") or {}).get("amount_total")
+    amt_aligned = False
+    if bc is not None and sc is not None:
+        try:
+            amt_aligned = float(bc) == float(sc)
+        except (TypeError, ValueError):
+            amt_aligned = str(bc) == str(sc)
+    out.append(
+        {
+            "dimension": "请求金额合计",
+            "current": bc,
+            "similar": sc,
+            "aligned": amt_aligned,
+        }
+    )
+    return out
+
+
+def _flatten_hierarchy_values(elements: dict[str, Any] | None) -> dict[str, Any]:
+    """
+    将 elements.elements_hierarchy 扁平化为 { \"一层 / 二层 / 三层\": value },仅保留有内容的叶子。
+    """
+    el = elements or {}
+    root = el.get("elements_hierarchy") or {}
+    schema = load_case_elements_schema()
+    field_labels: dict[str, str] = schema.get("field_labels") or {}
+    out: dict[tuple[str, ...], Any] = {}
+
+    _COMMON_SEG_CN = {
+        "name": "姓名",
+        "gender": "性别",
+        "birthday": "出生日期",
+        "birthdate": "出生日期",
+        "age": "年龄",
+        "phone": "联系电话",
+        "mobile": "联系电话",
+        "tel": "联系电话",
+        "residence": "住所地",
+        "address": "地址",
+        "identity_number": "身份证号",
+        "id_number": "身份证号",
+        "idcard": "身份证号",
+        "nationality": "国籍/民族",
+        "nation": "民族",
+        "company": "单位名称",
+        "company_name": "单位名称",
+        "legal_representative": "法定代表人",
+        "representative": "代表人",
+        "position": "职务/岗位",
+    }
+
+    def cn_seg(seg: str) -> str:
+        s = (seg or "").strip()
+        if not s:
+            return s
+        # 若是已是中文则直接返回
+        if re.search(r"[\u4e00-\u9fff]", s):
+            return s
+        if s in _COMMON_SEG_CN:
+            return _COMMON_SEG_CN[s]
+        # 扁平字段英文 key -> 中文 label(来自 schema)
+        return field_labels.get(s, s)
+
+    def walk(node: Any, path: list[str]) -> None:
+        if isinstance(node, dict):
+            for k, v in node.items():
+                walk(v, path + [cn_seg(str(k))])
+            return
+        if node is None:
+            return
+        if isinstance(node, str) and not node.strip():
+            return
+        if not path:
+            return
+        out[tuple(path)] = node
+
+    walk(root, [])
+    return out
+
+
+def _hierarchy_comparison_with_current(base_el: dict[str, Any], sim_el: dict[str, Any]) -> list[dict[str, Any]]:
+    a = _flatten_hierarchy_values(base_el)
+    b = _flatten_hierarchy_values(sim_el)
+    keys = sorted(set(a.keys()) | set(b.keys()))
+    out: list[dict[str, Any]] = []
+    for k in keys:
+        va = a.get(k)
+        vb = b.get(k)
+        if va is None and vb is None:
+            continue
+        aligned = va is not None and vb is not None and str(va) == str(vb)
+        out.append({"path_parts": list(k), "current": va, "similar": vb, "aligned": aligned})
+    return out
+
+
+def _build_similar_case_detail(base_el: dict[str, Any], base_portrait: dict[str, Any] | None, row: Case, score: float) -> dict[str, Any]:
+    sim_el = row.elements or {}
+    full_text = row.application_text or ""
+    api_text = full_text[:_MAX_API_APPLICATION_TEXT] if full_text else ""
+    wsim = _weighted_similarity_from_portraits(base_portrait or {}, row.portrait or {})
+    return {
+        "case_id": row.id,
+        "case_name": row.case_name,
+        "score": round(float(score), 6),
+        "similarity_percent": round(float(score) * 100, 2),
+        "weighted_similarity_percent": (round(float(wsim["overall"]), 2) if wsim.get("overall") is not None else None),
+        "weighted_similarity_breakdown": wsim.get("breakdown") or [],
+        "application_text": api_text,
+        "application_truncated": len(full_text) > len(api_text),
+        "application_preview": (full_text[:320] + "…") if len(full_text) > 320 else full_text,
+        "ruling_result": row.ruling_result
+        or "暂无裁决结论(可在数据库为该案件录入 ruling_result,或后续对接裁决书解析模块)",
+        "arbitration_org": row.arbitration_org or "未录入仲裁机构(可在数据库为该案件录入 arbitration_org)",
+        "elements_brief": _elements_brief(sim_el),
+        "portrait_scores": _portrait_scores(row.portrait or {}),
+        "comparison_with_current": _comparison_with_current(base_el, sim_el),
+        "hierarchy_comparison_with_current": _hierarchy_comparison_with_current(base_el, sim_el),
+    }
+
+
+# -----------------------------
+# Pydantic Models(按接口定义)
+# -----------------------------
+
+
+class TaskResponse(BaseModel):
+    task_id: str
+    status: str
+    progress: int
+    message: str | None = None
+
+
+class ElementsResponse(BaseModel):
+    case_id: int
+    elements: dict[str, Any]
+    version: int
+
+
+class ElementsUpdateRequest(BaseModel):
+    updated_by: str | None = None
+    patch: dict[str, Any] = Field(default_factory=dict, description="部分字段更新:只传要改的字段")
+
+
+class PortraitResponse(BaseModel):
+    case_id: int
+    portrait: dict[str, Any]
+    complexity_level: dict[str, Any]
+    risk_assessment: dict[str, Any]
+
+
+class SimilarRequest(BaseModel):
+    top_k: int = 10
+
+
+class SimilarCaseDetailResponse(BaseModel):
+    case_id: int
+    case_name: str
+    score: float
+    similarity_percent: float
+    application_text: str = ""
+    application_truncated: bool = False
+    application_preview: str = ""
+    ruling_result: str = ""
+    arbitration_org: str = ""
+    elements_brief: dict[str, Any] = Field(default_factory=dict)
+    portrait_scores: dict[str, Any] = Field(default_factory=dict)
+    comparison_with_current: list[dict[str, Any]] = Field(default_factory=list)
+    hierarchy_comparison_with_current: list[dict[str, Any]] = Field(default_factory=list)
+    weighted_similarity_percent: float | None = None
+    weighted_similarity_breakdown: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class SimilarResponse(BaseModel):
+    case_id: int
+    items: list[SimilarCaseDetailResponse]
+
+
+class MetricsResponse(BaseModel):
+    metrics: dict[str, Any]
+
+
+class UploadCompatResponse(BaseModel):
+    """
+    兼容旧前端(React 版)用的同步返回格式:
+    - extracted_elements: 规则/模型抽取结果
+    - case_profile: 画像数据(含 complexity_level/risk_assessment 等)
+    - similar_cases: 相似案件列表(Jaccard + 类案详情)
+    """
+
+    case_id: int
+    case_name: str
+    extracted_elements: dict[str, Any]
+    case_profile: dict[str, Any]
+    similar_cases: list[dict[str, Any]]
+    current_application_text: str = ""
+    current_application_truncated: bool = False
+    current_elements_brief: dict[str, Any] = Field(default_factory=dict)
+    reused_existing: bool = Field(False, description="True 表示命中已有案件,未新建 cases 行")
+
+
+def _build_upload_compat_response(db: Session, case: Case, *, reused_existing: bool) -> UploadCompatResponse:
+    elements = case.elements or {}
+    base_tags = _case_tag_set(elements)
+    rows = db.query(Case).filter(Case.id != case.id).all()
+    scored: list[tuple[float, Case]] = []
+    for row in rows:
+        s = _jaccard(base_tags, _case_tag_set(row.elements or {}))
+        scored.append((s, row))
+    scored.sort(key=lambda x: x[0], reverse=True)
+    similar_cases = [_build_similar_case_detail(elements, case.portrait or {}, r, s) for s, r in scored[:10] if s > 0]
+
+    full_text = case.application_text or ""
+    cur_api_text = full_text[:_MAX_API_APPLICATION_TEXT]
+    return UploadCompatResponse(
+        case_id=case.id,
+        case_name=case.case_name,
+        extracted_elements=elements,
+        case_profile=case.portrait or {},
+        similar_cases=similar_cases,
+        current_application_text=cur_api_text,
+        current_application_truncated=len(full_text) > len(cur_api_text),
+        current_elements_brief=_elements_brief(elements),
+        reused_existing=reused_existing,
+    )
+
+
+@app.get("/health")
+def health_check():
+    return {"status": "ok"}
+
+
+@app.post("/api/cases/extract")
+async def extract_case(file: UploadFile = File(...)):
+    """
+    上传单文件(.txt / .pdf / .docx),解析全文后使用混合抽取器返回 JSON 要素。
+    """
+    suffix = Path(file.filename or "").suffix.lower()
+    if suffix not in (".txt", ".pdf", ".docx"):
+        raise HTTPException(status_code=400, detail="仅支持 .txt、.pdf、.docx 格式")
+
+    content = await file.read()
+    if not content:
+        raise HTTPException(status_code=400, detail="空文件")
+
+    tmp_path: str | None = None
+    try:
+        tmp = NamedTemporaryFile(delete=False, suffix=suffix)
+        tmp_path = tmp.name
+        tmp.write(content)
+        tmp.close()
+        text = DocumentParser.parse_file(tmp_path)
+    except Exception as e:
+        raise HTTPException(status_code=400, detail=f"文件解析失败:{e}") from e
+    finally:
+        if tmp_path:
+            try:
+                Path(tmp_path).unlink(missing_ok=True)
+            except OSError:
+                pass
+
+    if not (text or "").strip():
+        raise HTTPException(status_code=400, detail="未解析到有效文本")
+
+    return hybrid_extractor.extract(text)
+
+
+@app.post("/api/cases/upload", response_model=UploadCompatResponse)
+async def upload_case_compat(
+    case_name: str = Form(...),
+    files: list[UploadFile] = File(...),
+    extractor_mode: str = Form("rules"),
+    db: Session = Depends(get_db),
+):
+    """
+    兼容接口:旧前端会调用 /api/cases/upload 并期待立即返回抽取结果。
+    新版推荐走:/api/cases/{case_id}/files + /api/tasks/{task_id} 轮询。
+    extractor_mode: rules / bert / ollama / hybrid
+    """
+    if not files:
+        raise HTTPException(status_code=400, detail="未上传文件")
+
+    upload_dir = _upload_dir()
+    norm_name = _normalize_case_name(case_name)
+    if not norm_name:
+        raise HTTPException(status_code=400, detail="案件名称不能为空")
+
+    prepared: list[tuple[UploadFile, bytes, str]] = []
+    for f in files:
+        content = await f.read()
+        guessed = magic.from_buffer(content[:2048], mime=True) if content else ""
+        if not _allowed(guessed, f.filename):
+            raise HTTPException(status_code=400, detail=f"不支持的文件类型:{f.filename}({guessed})")
+        prepared.append((f, content, guessed))
+
+    merged_full = _merged_text_from_prepared(prepared)
+    if not merged_full.strip():
+        raise HTTPException(status_code=400, detail="未读取到有效文本")
+
+    merged_stored = merged_full[:_MAX_STORED_APPLICATION_TEXT]
+    fingerprint = _material_fingerprint_from_file_contents([b for _, b, _ in prepared])
+
+    text_h = _normalized_text_fingerprint(merged_stored)
+    case = _find_existing_duplicate_case(db, norm_name, fingerprint, merged_stored)
+    # 补充:同名 + 正文一致即视为重复
+    if not case and norm_name:
+        cands = db.query(Case).filter(func.trim(Case.case_name) == norm_name).filter(Case.application_text.isnot(None)).filter(Case.application_text != "").all()
+        for c in cands:
+            if _normalized_text_fingerprint(c.application_text or "") == text_h:
+                case = c
+                break
+    if case:
+        # 案件已存在:直接返回已有数据,跳过模型抽取
+        case.case_name = norm_name
+        # 清理旧文件并保存新文件
+        old_files = db.query(CaseFile).filter(CaseFile.case_id == case.id).all()
+        for cf in old_files:
+            try:
+                Path(cf.storage_path).unlink(missing_ok=True)
+            except OSError:
+                pass
+        db.query(CaseFile).filter(CaseFile.case_id == case.id).delete(synchronize_session=False)
+        db.commit()
+        for f, content, guessed in prepared:
+            suffix = Path(f.filename).suffix.lower()
+            storage = upload_dir / f"{case.id}_{uuid4().hex}{suffix}"
+            storage.write_bytes(content)
+            db.add(CaseFile(case_id=case.id, filename=f.filename, content_type=guessed, storage_path=str(storage), size_bytes=len(content)))
+        db.commit()
+        case.material_fingerprint = fingerprint
+        case.application_text = merged_stored
+        db.commit()
+        return _build_upload_compat_response(db, case, reused_existing=False)
+
+    # 新案件:走完整抽取流程
+    case = Case(case_name=norm_name, elements={}, portrait={}, material_fingerprint=fingerprint)
+    db.add(case)
+    db.commit()
+    db.refresh(case)
+
+    for f, content, guessed in prepared:
+        suffix = Path(f.filename).suffix.lower()
+        storage = upload_dir / f"{case.id}_{uuid4().hex}{suffix}"
+        storage.write_bytes(content)
+        db.add(CaseFile(case_id=case.id, filename=f.filename, content_type=guessed, storage_path=str(storage), size_bytes=len(content)))
+    db.commit()
+
+    merged = merged_full
+
+    hybrid_extractor.mode = extractor_mode
+    if extractor_mode == "rules":
+        elements = extractor.extract(merged)
+    else:
+        elements = hybrid_extractor.extract(merged)
+
+    if _ollama_claims is not None and extractor_mode in ("ollama", "hybrid"):
+        try:
+            elements["claims"] = _ollama_claims.extract_claims(merged)
+            elements.update(merge_dispute_template_fields(merged, elements))
+        except Exception:
+            pass
+        try:
+            patch = _ollama_claims.extract_dispute_template_fields(merged)
+            for k, v in (patch or {}).items():
+                if v is None:
+                    continue
+                if isinstance(v, str) and not str(v).strip():
+                    continue
+                elements[k] = v
+            if elements.get("tmpl_primary_cause"):
+                elements["primary_cause_type"] = elements["tmpl_primary_cause"]
+        except Exception:
+            pass
+        refresh_derived_element_fields(elements, merged)
+
+    portrait = generate_portrait(elements, raw_text=merged, evidence_count=len(files))
+    complexity = classify_complexity(elements, evidence_count=len(files))
+    risk = assess_risk(elements)
+
+    latest_version = (
+        db.query(CaseElementsVersion)
+        .filter(CaseElementsVersion.case_id == case.id)
+        .order_by(CaseElementsVersion.version.desc())
+        .first()
+    )
+    next_ver = (latest_version.version + 1) if latest_version else 1
+    db.add(CaseElementsVersion(case_id=case.id, version=next_ver, elements=elements, updated_by="system"))
+
+    case.elements = elements
+    case.portrait = {**portrait, "complexity_level": complexity, "risk_assessment": risk}
+    case.application_text = merged_stored
+    case.material_fingerprint = fingerprint
+    _update_case_index_fields(case, elements, portrait, risk.get("level"))
+    db.commit()
+
+    return _build_upload_compat_response(db, case, reused_existing=False)
+
+
+def _create_or_get_case(db: Session, case_id: int) -> Case:
+    case = db.query(Case).filter(Case.id == case_id).first()
+    if case:
+        return case
+    # 若前端先创建 case_id 再上传文件,这里兜底自动创建
+    case = Case(id=case_id, case_name=f"案件{case_id}", elements={}, portrait={})
+    db.add(case)
+    db.commit()
+    db.refresh(case)
+    return case
+
+
+def _update_case_index_fields(case: Case, elements: dict[str, Any], portrait: dict[str, Any], risk_level: str | None) -> None:
+    case.applicant_name = elements.get("applicant_name")
+    case.respondent_name = elements.get("respondent_name")
+    case.case_cause = elements.get("case_cause")
+    case.risk_level = risk_level or portrait.get("risk_level")
+
+
+def _process_case_task(task_id: str, case_id: int) -> None:
+    """
+    后台任务:解析文件 -> 合并文本 -> 规则抽取 -> 写入 cases.elements + 版本历史 -> 画像/复杂度/风险
+    """
+    from app.db import SessionLocal
+
+    db = SessionLocal()
+    try:
+        task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
+        if not task:
+            return
+        task.status = "RUNNING"
+        task.progress = 5
+        task.message = "解析文件中"
+        db.commit()
+
+        case = db.query(Case).filter(Case.id == case_id).first()
+        if not case:
+            task.status = "FAILED"
+            task.error = "案件不存在"
+            task.progress = 100
+            db.commit()
+            return
+
+        files = db.query(CaseFile).filter(CaseFile.case_id == case_id).all()
+        texts: list[str] = []
+        for f in files:
+            try:
+                texts.append(DocumentParser.parse_file(f.storage_path))
+            except Exception:
+                continue
+        merged = "\n\n".join([t for t in texts if t.strip()])
+
+        task.progress = 45
+        task.message = "要素抽取中"
+        db.commit()
+
+        elements = extractor.extract(merged)
+        if _ollama_claims is not None:
+            try:
+                elements["claims"] = _ollama_claims.extract_claims(merged)
+                elements.update(merge_dispute_template_fields(merged, elements))
+            except Exception:
+                pass
+            try:
+                patch = _ollama_claims.extract_dispute_template_fields(merged)
+                for k, v in (patch or {}).items():
+                    if v is None:
+                        continue
+                    if isinstance(v, str) and not str(v).strip():
+                        continue
+                    elements[k] = v
+                if elements.get("tmpl_primary_cause"):
+                    elements["primary_cause_type"] = elements["tmpl_primary_cause"]
+            except Exception:
+                pass
+            refresh_derived_element_fields(elements, merged)
+
+        task.progress = 70
+        task.message = "构建画像中"
+        db.commit()
+
+        portrait = generate_portrait(elements, raw_text=merged, evidence_count=len(files))
+        complexity = classify_complexity(elements, evidence_count=len(files))
+        risk = assess_risk(elements)
+
+        # 保存 elements 版本
+        latest_version = (
+            db.query(CaseElementsVersion)
+            .filter(CaseElementsVersion.case_id == case_id)
+            .order_by(CaseElementsVersion.version.desc())
+            .first()
+        )
+        next_ver = (latest_version.version + 1) if latest_version else 1
+        db.add(CaseElementsVersion(case_id=case_id, version=next_ver, elements=elements, updated_by="system"))
+
+        merged_stored = merged[:_MAX_STORED_APPLICATION_TEXT]
+        case.elements = elements
+        case.application_text = merged_stored
+        case.portrait = {
+            **portrait,
+            "complexity_level": complexity,
+            "risk_assessment": risk,
+        }
+        _update_case_index_fields(case, elements, portrait, risk.get("level"))
+
+        contents: list[bytes] = []
+        for cf in files:
+            try:
+                contents.append(Path(cf.storage_path).read_bytes())
+            except OSError:
+                continue
+        if contents:
+            case.material_fingerprint = _material_fingerprint_from_file_contents(contents)
+
+        db.commit()
+
+        task.status = "SUCCEEDED"
+        task.progress = 100
+        task.message = "处理完成"
+        db.commit()
+    except Exception as e:
+        try:
+            task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
+            if task:
+                task.status = "FAILED"
+                task.progress = 100
+                task.error = f"{e}\n{traceback.format_exc()}"
+                db.commit()
+        finally:
+            pass
+    finally:
+        db.close()
+
+
+@app.post("/api/cases/{case_id}/files", response_model=TaskResponse)
+async def upload_case_files(
+    case_id: int,
+    background: BackgroundTasks,
+    files: list[UploadFile] = File(...),
+    db: Session = Depends(get_db),
+):
+    case = _create_or_get_case(db, case_id)
+
+    if not files:
+        raise HTTPException(status_code=400, detail="未上传文件")
+
+    upload_dir = _upload_dir()
+    saved = 0
+    for f in files:
+        content = await f.read()
+        guessed = magic.from_buffer(content[:2048], mime=True) if content else ""
+        if not _allowed(guessed, f.filename):
+            raise HTTPException(status_code=400, detail=f"不支持的文件类型:{f.filename}({guessed})")
+
+        suffix = Path(f.filename).suffix.lower()
+        storage = upload_dir / f"{case_id}_{uuid4().hex}{suffix}"
+        storage.write_bytes(content)
+        db.add(
+            CaseFile(
+                case_id=case.id,
+                filename=f.filename,
+                content_type=guessed,
+                storage_path=str(storage),
+                size_bytes=len(content),
+            )
+        )
+        saved += 1
+    db.commit()
+
+    task_id = uuid4().hex
+    task = ProcessingTask(id=task_id, case_id=case.id, status="PENDING", progress=0, message=f"已接收{saved}个文件")
+    db.add(task)
+    db.commit()
+
+    background.add_task(_process_case_task, task_id, case.id)
+    return TaskResponse(task_id=task_id, status=task.status, progress=task.progress, message=task.message)
+
+
+@app.get("/api/tasks/{task_id}", response_model=TaskResponse)
+def get_task(task_id: str, db: Session = Depends(get_db)):
+    task = db.query(ProcessingTask).filter(ProcessingTask.id == task_id).first()
+    if not task:
+        raise HTTPException(status_code=404, detail="任务不存在")
+    return TaskResponse(task_id=task.id, status=task.status, progress=task.progress, message=task.message or task.error)
+
+
+@app.delete("/api/cases/{case_id}", status_code=204)
+def delete_case(case_id: int, db: Session = Depends(get_db)):
+    """
+    删除案件及其关联数据。此前未暴露该接口,且 processing_tasks 外键指向 cases,
+    直接在库里删 cases 行可能被外键拒绝。
+    """
+    case = db.query(Case).filter(Case.id == case_id).first()
+    if not case:
+        raise HTTPException(status_code=404, detail="案件不存在")
+
+    files = db.query(CaseFile).filter(CaseFile.case_id == case_id).all()
+    for cf in files:
+        try:
+            Path(cf.storage_path).unlink(missing_ok=True)
+        except OSError:
+            pass
+
+    # 必须先删子表行:MySQL 外键默认 RESTRICT,否则会报 1451(case_elements_versions 等)
+    db.query(CaseElementsVersion).filter(CaseElementsVersion.case_id == case_id).delete(synchronize_session=False)
+    db.query(CaseFile).filter(CaseFile.case_id == case_id).delete(synchronize_session=False)
+    db.query(ProcessingTask).filter(ProcessingTask.case_id == case_id).delete(synchronize_session=False)
+    db.query(Case).filter(Case.id == case_id).delete(synchronize_session=False)
+    db.commit()
+    return Response(status_code=204)
+
+
+@app.get("/api/cases/{case_id}/elements", response_model=ElementsResponse)
+def get_elements(case_id: int, db: Session = Depends(get_db)):
+    case = db.query(Case).filter(Case.id == case_id).first()
+    if not case:
+        raise HTTPException(status_code=404, detail="案件不存在")
+    latest_version = (
+        db.query(CaseElementsVersion)
+        .filter(CaseElementsVersion.case_id == case_id)
+        .order_by(CaseElementsVersion.version.desc())
+        .first()
+    )
+    ver = latest_version.version if latest_version else 0
+    return ElementsResponse(case_id=case.id, elements=case.elements or {}, version=ver)
+
+
+@app.put("/api/cases/{case_id}/elements", response_model=ElementsResponse)
+def update_elements(case_id: int, payload: ElementsUpdateRequest, db: Session = Depends(get_db)):
+    case = db.query(Case).filter(Case.id == case_id).first()
+    if not case:
+        raise HTTPException(status_code=404, detail="案件不存在")
+
+    patch = payload.patch or {}
+    current = dict(case.elements or {})
+    current.update(patch)
+    # PATCH 含 elements_hierarchy 时保留用户编辑的层级,不根据扁平字段重算覆盖
+    _sync_case_elements_table(current, rebuild_hierarchy="elements_hierarchy" not in patch)
+
+    latest_version = (
+        db.query(CaseElementsVersion)
+        .filter(CaseElementsVersion.case_id == case_id)
+        .order_by(CaseElementsVersion.version.desc())
+        .first()
+    )
+    next_ver = (latest_version.version + 1) if latest_version else 1
+    db.add(CaseElementsVersion(case_id=case_id, version=next_ver, elements=current, updated_by=payload.updated_by))
+
+    # 更新画像缓存(编辑后即时刷新)
+    files_cnt = db.query(CaseFile).filter(CaseFile.case_id == case_id).count()
+    portrait = generate_portrait(current, raw_text="", evidence_count=files_cnt)
+    complexity = classify_complexity(current, evidence_count=files_cnt)
+    risk = assess_risk(current)
+    case.elements = current
+    case.portrait = {**portrait, "complexity_level": complexity, "risk_assessment": risk}
+    _update_case_index_fields(case, current, portrait, risk.get("level"))
+    db.commit()
+
+    return ElementsResponse(case_id=case.id, elements=case.elements or {}, version=next_ver)
+
+
+@app.get("/api/cases/{case_id}/portrait", response_model=PortraitResponse)
+def get_portrait(case_id: int, db: Session = Depends(get_db)):
+    case = db.query(Case).filter(Case.id == case_id).first()
+    if not case:
+        raise HTTPException(status_code=404, detail="案件不存在")
+    portrait = case.portrait or {}
+    complexity = portrait.get("complexity_level") or {}
+    risk = portrait.get("risk_assessment") or {}
+    return PortraitResponse(case_id=case.id, portrait=portrait, complexity_level=complexity, risk_assessment=risk)
+
+
+@app.post("/api/cases/{case_id}/similar", response_model=SimilarResponse)
+def similar_cases(case_id: int, payload: SimilarRequest, db: Session = Depends(get_db)):
+    base = db.query(Case).filter(Case.id == case_id).first()
+    if not base:
+        raise HTTPException(status_code=404, detail="案件不存在")
+    base_el = base.elements or {}
+    base_portrait = base.portrait or {}
+    base_tags = _case_tag_set(base_el)
+
+    rows = db.query(Case).filter(Case.id != case_id).all()
+    scored: list[tuple[float, Case]] = []
+    for row in rows:
+        s = _jaccard(base_tags, _case_tag_set(row.elements or {}))
+        scored.append((s, row))
+    scored.sort(key=lambda x: x[0], reverse=True)
+    top_k = max(1, min(50, payload.top_k))
+    items = [
+        SimilarCaseDetailResponse(**_build_similar_case_detail(base_el, base_portrait, r, s))
+        for s, r in scored[:top_k]
+        if s > 0
+    ]
+    return SimilarResponse(case_id=case_id, items=items)
+
+
+@app.get("/api/evaluation/metrics", response_model=MetricsResponse)
+def get_eval_metrics(db: Session = Depends(get_db)):
+    """
+    评估页指标:
+    1) 要素抽取:Precision/Recall/F1(demo 样例;可替换为真实标注集)
+    2) 画像完整性/有效性:基于数据库历史案件的 portrait 字段统计
+    """
+    # 兼容以 `uvicorn app.main:app` 启动时的模块路径:此时并不存在顶层包名 backend
+    from tools.evaluate_extractor import demo_samples, run_eval  # type: ignore
+
+    extraction_report = run_eval(demo_samples())
+
+    rows = db.query(Case).all()
+    total = len(rows)
+    with_portrait = 0
+    with_scores = 0
+    with_subscores = 0
+    with_keywords = 0
+    with_tags = 0
+    with_risk_assessment = 0
+    with_complexity = 0
+
+    for c in rows:
+        p = c.portrait or {}
+        if p:
+            with_portrait += 1
+        scores = (p.get("scores") or {}) if isinstance(p, dict) else {}
+        if isinstance(scores, dict) and any(scores.get(k) is not None for k in ("legal", "fact", "risk")):
+            with_scores += 1
+        # subscores:三个维度任一包含 subscores 即认为完整
+        ok_sub = False
+        for dim in ("legal_dimension", "fact_dimension", "risk_dimension"):
+            d = (p.get(dim) or {}) if isinstance(p, dict) else {}
+            subs = d.get("subscores") if isinstance(d, dict) else None
+            if isinstance(subs, dict) and len(subs) > 0:
+                ok_sub = True
+        if ok_sub:
+            with_subscores += 1
+        kws = p.get("keywords") if isinstance(p, dict) else None
+        if isinstance(kws, list) and len(kws) > 0:
+            with_keywords += 1
+        tags = p.get("tags") if isinstance(p, dict) else None
+        if isinstance(tags, list) and len(tags) > 0:
+            with_tags += 1
+        if isinstance(p.get("risk_assessment"), dict):
+            with_risk_assessment += 1
+        if isinstance(p.get("complexity_level"), dict):
+            with_complexity += 1
+
+    def ratio(x: int) -> float:
+        return round((x / total) if total else 0.0, 4)
+
+    portrait_report = {
+        "total_cases": total,
+        "has_portrait": {"count": with_portrait, "ratio": ratio(with_portrait)},
+        "has_scores": {"count": with_scores, "ratio": ratio(with_scores)},
+        "has_subscores": {"count": with_subscores, "ratio": ratio(with_subscores)},
+        "has_keywords": {"count": with_keywords, "ratio": ratio(with_keywords)},
+        "has_tags": {"count": with_tags, "ratio": ratio(with_tags)},
+        "has_risk_assessment": {"count": with_risk_assessment, "ratio": ratio(with_risk_assessment)},
+        "has_complexity_level": {"count": with_complexity, "ratio": ratio(with_complexity)},
+    }
+
+    metrics = {
+        "extraction_prf": extraction_report,
+        "portrait_quality": portrait_report,
+        "weights": {"legal": _SIM_WEIGHT_LEGAL, "fact": _SIM_WEIGHT_FACT, "risk": _SIM_WEIGHT_RISK},
+    }
+    return MetricsResponse(metrics=metrics)

+ 148 - 0
backend/app/migrate.py

@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from sqlalchemy import text
+from sqlalchemy.engine import Engine
+
+
+def _table_exists(engine: Engine, table: str) -> bool:
+    with engine.connect() as conn:
+        r = conn.execute(text("SHOW TABLES LIKE :t"), {"t": table}).fetchone()
+        return r is not None
+
+
+def _column_exists(engine: Engine, table: str, column: str) -> bool:
+    with engine.connect() as conn:
+        r = conn.execute(text(f"SHOW COLUMNS FROM `{table}` LIKE :c"), {"c": column}).fetchone()
+        return r is not None
+
+
+def _add_column(engine: Engine, table: str, ddl: str) -> None:
+    with engine.connect() as conn:
+        conn.execute(text(f"ALTER TABLE `{table}` ADD COLUMN {ddl}"))
+        conn.commit()
+
+
+def _column_info(engine: Engine, table: str, column: str) -> dict | None:
+    with engine.connect() as conn:
+        row = conn.execute(text(f"SHOW COLUMNS FROM `{table}` LIKE :c"), {"c": column}).mappings().fetchone()
+        return dict(row) if row else None
+
+
+def _modify_column(engine: Engine, table: str, ddl: str) -> None:
+    with engine.connect() as conn:
+        conn.execute(text(f"ALTER TABLE `{table}` MODIFY COLUMN {ddl}"))
+        conn.commit()
+
+
+def ensure_mysql_schema(engine: Engine) -> None:
+    """
+    轻量迁移(不依赖 Alembic):
+    - 若数据库已有旧版 cases 表,则自动补齐新版所需列
+    - 新表(case_files/case_elements_versions/processing_tasks)由 create_all 创建;若表已存在则跳过
+
+    说明:此方法适用于毕业设计原型开发期;正式环境建议使用 Alembic 管理迁移。
+    """
+    if not _table_exists(engine, "cases"):
+        return
+
+    # 旧版 cases 表字段:id, case_name, source_files, raw_text, extracted_elements, case_profile, created_at
+    # 新版需要字段:case_no, elements, portrait, applicant_name, respondent_name, case_cause, risk_level
+    additions = [
+        ("case_no", "`case_no` VARCHAR(64) NULL"),
+        ("elements", "`elements` JSON NOT NULL"),
+        ("portrait", "`portrait` JSON NOT NULL"),
+        ("applicant_name", "`applicant_name` VARCHAR(64) NULL"),
+        ("respondent_name", "`respondent_name` VARCHAR(128) NULL"),
+        ("case_cause", "`case_cause` VARCHAR(64) NULL"),
+        ("risk_level", "`risk_level` VARCHAR(16) NULL"),
+        ("application_text", "`application_text` LONGTEXT NULL"),
+        ("ruling_result", "`ruling_result` TEXT NULL"),
+        ("arbitration_org", "`arbitration_org` VARCHAR(255) NULL"),
+        ("created_at", "`created_at` DATETIME NULL"),
+        ("material_fingerprint", "`material_fingerprint` VARCHAR(64) NULL"),
+    ]
+
+    for col, ddl in additions:
+        if not _column_exists(engine, "cases", col):
+            _add_column(engine, "cases", ddl)
+
+    if _column_exists(engine, "cases", "material_fingerprint"):
+        with engine.connect() as conn:
+            idx = conn.execute(
+                text(
+                    "SELECT 1 FROM information_schema.statistics "
+                    "WHERE table_schema = DATABASE() AND table_name = 'cases' AND index_name = 'ix_cases_material_fp'"
+                )
+            ).fetchone()
+            if not idx:
+                try:
+                    conn.execute(text("CREATE INDEX `ix_cases_material_fp` ON `cases` (`material_fingerprint`)"))
+                    conn.commit()
+                except Exception:
+                    conn.rollback()
+
+    # 旧表约束放宽:避免旧必填字段导致新插入失败
+    # 常见旧字段:source_files/raw_text/extracted_elements/case_profile 可能为 NOT NULL 且无默认值
+    relax = [
+        ("source_files", "`source_files` JSON NULL"),
+        ("raw_text", "`raw_text` LONGTEXT NULL"),
+        ("extracted_elements", "`extracted_elements` JSON NULL"),
+        ("case_profile", "`case_profile` JSON NULL"),
+    ]
+    for col, ddl in relax:
+        info = _column_info(engine, "cases", col)
+        if not info:
+            continue
+        # info: Field, Type, Null, Key, Default, Extra
+        if str(info.get("Null", "")).upper() == "NO" and info.get("Default") is None:
+            _modify_column(engine, "cases", ddl)
+
+    # 兼容旧字段:如果旧表里没有 elements/portrait,但有 extracted_elements/case_profile,
+    # 这里不做数据迁移(避免 JSON 兼容差异);后续写入会以新字段为主。
+
+    _ensure_case_child_fks_cascade(engine)
+
+
+def _ensure_case_child_fks_cascade(engine: Engine) -> None:
+    """
+    将 case_files / case_elements_versions / processing_tasks 指向 cases.id 的外键改为 ON DELETE CASCADE,
+    避免手工或 ORM 删除父行时出现 Error 1451。
+    """
+    child_tables = ("case_files", "case_elements_versions", "processing_tasks")
+    with engine.connect() as conn:
+        for table in child_tables:
+            if not _table_exists(engine, table):
+                continue
+            rows = conn.execute(
+                text(
+                    """
+                    SELECT rc.CONSTRAINT_NAME, rc.DELETE_RULE
+                    FROM information_schema.REFERENTIAL_CONSTRAINTS rc
+                    INNER JOIN information_schema.KEY_COLUMN_USAGE kcu
+                        ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
+                        AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
+                        AND rc.TABLE_NAME = kcu.TABLE_NAME
+                    WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
+                      AND rc.TABLE_NAME = :tbl
+                      AND kcu.REFERENCED_TABLE_NAME = 'cases'
+                      AND kcu.REFERENCED_COLUMN_NAME = 'id'
+                    """
+                ),
+                {"tbl": table},
+            ).fetchall()
+            for row in rows:
+                cname, rule = row[0], str(row[1] or "").upper()
+                if rule == "CASCADE":
+                    continue
+                try:
+                    conn.execute(text(f"ALTER TABLE `{table}` DROP FOREIGN KEY `{cname}`"))
+                    conn.execute(
+                        text(
+                            f"ALTER TABLE `{table}` ADD CONSTRAINT `{cname}` "
+                            f"FOREIGN KEY (`case_id`) REFERENCES `cases` (`id`) ON DELETE CASCADE"
+                        )
+                    )
+                    conn.commit()
+                except Exception:
+                    conn.rollback()
+

+ 76 - 0
backend/app/models.py

@@ -0,0 +1,76 @@
+from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+
+from app.db import Base
+
+
+class Case(Base):
+    __tablename__ = "cases"
+
+    id = Column(Integer, primary_key=True, index=True)
+    case_no = Column(String(64), nullable=True, index=True)
+    case_name = Column(String(255), nullable=False, index=True)
+    # 多文件字节 SHA256 排序后摘要;用于去重
+    material_fingerprint = Column(String(64), nullable=True, index=True)
+
+    # 当前最新要素(可编辑)
+    elements = Column(JSON, nullable=False, default=dict)
+    # 最新画像缓存
+    portrait = Column(JSON, nullable=False, default=dict)
+
+    applicant_name = Column(String(64), nullable=True, index=True)
+    respondent_name = Column(String(128), nullable=True, index=True)
+    case_cause = Column(String(64), nullable=True, index=True)
+    risk_level = Column(String(16), nullable=True, index=True)
+
+    # 合并后的申请书/材料全文(用于类案展示与对比;大文本可截断存储)
+    application_text = Column(Text, nullable=True)
+    # 历史类案可录入:裁决结论、仲裁机构(无数据时前端展示占位说明)
+    ruling_result = Column(Text, nullable=True)
+    arbitration_org = Column(String(255), nullable=True)
+
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+    files = relationship("CaseFile", back_populates="case", cascade="all, delete-orphan")
+    element_versions = relationship("CaseElementsVersion", back_populates="case", cascade="all, delete-orphan")
+
+
+class CaseFile(Base):
+    __tablename__ = "case_files"
+
+    id = Column(Integer, primary_key=True)
+    case_id = Column(Integer, ForeignKey("cases.id", ondelete="CASCADE"), nullable=False, index=True)
+    filename = Column(String(255), nullable=False)
+    content_type = Column(String(128), nullable=True)
+    storage_path = Column(String(512), nullable=False)
+    size_bytes = Column(Integer, nullable=True)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+    case = relationship("Case", back_populates="files")
+
+
+class CaseElementsVersion(Base):
+    __tablename__ = "case_elements_versions"
+
+    id = Column(Integer, primary_key=True)
+    case_id = Column(Integer, ForeignKey("cases.id", ondelete="CASCADE"), nullable=False, index=True)
+    version = Column(Integer, nullable=False)
+    elements = Column(JSON, nullable=False)
+    updated_by = Column(String(64), nullable=True)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now())
+
+    case = relationship("Case", back_populates="element_versions")
+
+
+class ProcessingTask(Base):
+    __tablename__ = "processing_tasks"
+
+    id = Column(String(64), primary_key=True)
+    case_id = Column(Integer, ForeignKey("cases.id", ondelete="CASCADE"), nullable=False, index=True)
+    status = Column(String(16), nullable=False, default="PENDING")  # PENDING/RUNNING/SUCCEEDED/FAILED
+    progress = Column(Integer, nullable=False, default=0)  # 0-100
+    message = Column(String(255), nullable=True)
+    error = Column(Text, nullable=True)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

+ 16 - 0
backend/app/schemas.py

@@ -0,0 +1,16 @@
+from typing import Any
+
+from pydantic import BaseModel
+
+
+class CaseResponse(BaseModel):
+    case_id: int
+    case_name: str
+    extracted_elements: dict[str, Any]
+    case_profile: dict[str, Any]
+    similar_cases: list[dict[str, Any]]
+
+
+class SimilarCaseResponse(BaseModel):
+    case_id: int
+    similar_cases: list[dict[str, Any]]

+ 1 - 0
backend/app/services/__init__.py

@@ -0,0 +1 @@
+# Package marker

+ 50 - 0
backend/app/services/complexity_classifier.py

@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+def classify_complexity(elements: dict[str, Any], evidence_count: int = 0) -> dict[str, Any]:
+    """
+    简单/中等/复杂:
+    - 要素数量:<5 简单;5-10 中等;>10 复杂
+    - 事实清晰度:入/离职时间完整、加班事实描述是否具体
+    - 证据完备度:证据材料数量(用 evidence_count 近似)
+    """
+    present = 0
+    for k, v in (elements or {}).items():
+        if v is None:
+            continue
+        if isinstance(v, str) and not v.strip():
+            continue
+        if isinstance(v, (list, dict)) and len(v) == 0:
+            continue
+        present += 1
+
+    entry_ok = bool(elements.get("entry_date"))
+    leave_ok = bool(elements.get("leave_date"))
+    overtime_desc = elements.get("overtime_desc") or ""
+    overtime_ok = len(str(overtime_desc).strip()) >= 10
+
+    clarity = 0
+    clarity += 1 if entry_ok else 0
+    clarity += 1 if leave_ok else 0
+    clarity += 1 if overtime_ok else 0
+
+    evidence_ok = evidence_count >= 3
+
+    # 规则融合
+    if present < 5 and clarity <= 1 and evidence_count <= 1:
+        level = "简单"
+    elif present > 10 or clarity == 3 or evidence_ok:
+        level = "复杂" if (present > 10 and (clarity >= 2 or evidence_ok)) else "中等"
+    else:
+        level = "中等"
+
+    reasons = {
+        "elements_count": present,
+        "timeline_complete": entry_ok and leave_ok,
+        "overtime_desc_specific": overtime_ok,
+        "evidence_count": evidence_count,
+    }
+    return {"level": level, "reasons": reasons}
+

+ 21 - 0
backend/app/services/document_parser.py

@@ -0,0 +1,21 @@
+from pathlib import Path
+
+from docx import Document
+from pypdf import PdfReader
+
+
+class DocumentParser:
+    @staticmethod
+    def parse_file(file_path: str) -> str:
+        path = Path(file_path)
+        suffix = path.suffix.lower()
+        if suffix == ".txt":
+            return path.read_text(encoding="utf-8", errors="ignore")
+        if suffix == ".docx":
+            doc = Document(file_path)
+            return "\n".join([p.text for p in doc.paragraphs if p.text.strip()])
+        if suffix == ".pdf":
+            reader = PdfReader(file_path)
+            pages = [page.extract_text() or "" for page in reader.pages]
+            return "\n".join(pages)
+        return path.read_text(encoding="utf-8", errors="ignore")

+ 190 - 0
backend/app/services/hybrid_extractor.py

@@ -0,0 +1,190 @@
+"""
+Hybrid extractor with mode switching.
+Supports: rules, bert, ollama, hybrid (rules + BERT + Ollama with field-type routing).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import requests
+
+from app.config import settings
+from app.extractors.rule_extractor import RuleBasedExtractor
+
+
+class HybridExtractor:
+    """
+    Multi-mode extractor for labor arbitration case elements.
+
+    Modes:
+      - "rules": Rule-based regex extraction only (fast, no GPU needed)
+      - "bert": BERT multi-task model via nlp-service (deep learning)
+      - "ollama": Qwen 2.5 via Ollama (LLM zero-shot)
+      - "hybrid": Rules for structured fields + BERT for entities + Ollama for free text
+    """
+
+    def __init__(self, mode: str | None = None):
+        self._mode = mode or settings.extractor_mode or "rules"
+        self._rule = RuleBasedExtractor()
+        self._ollama = None
+
+        # Init Ollama if needed
+        if self._mode in ("ollama", "hybrid") and settings.use_ollama:
+            try:
+                from app.anj import OllamaClaimsExtractor
+                self._ollama = OllamaClaimsExtractor(
+                    settings.ollama_base_url,
+                    settings.ollama_model_name,
+                )
+            except Exception:
+                self._ollama = None
+
+    def extract(self, text: str) -> dict[str, Any]:
+        if self._mode == "rules":
+            return self._extract_rules(text)
+        elif self._mode == "bert":
+            return self._extract_bert(text)
+        elif self._mode == "ollama":
+            return self._extract_ollama(text)
+        elif self._mode == "hybrid":
+            return self._extract_hybrid(text)
+        else:
+            return self._extract_rules(text)
+
+    def _extract_rules(self, text: str) -> dict[str, Any]:
+        return self._rule.extract_all(text)
+
+    def _extract_bert(self, text: str) -> dict[str, Any]:
+        """Call the nlp-service BERT model for extraction."""
+        try:
+            resp = requests.post(
+                settings.bert_model_service_url,
+                json={"text": text},
+                timeout=60,
+            )
+            resp.raise_for_status()
+            raw = resp.json()
+            # Flatten NLP nested response to backend flat format
+            flat = {}
+            parties = raw.get("parties", {})
+            for k, v in parties.items():
+                if v is not None:
+                    flat[k] = v
+            facts = raw.get("facts", {})
+            for k, v in facts.items():
+                if v is not None:
+                    flat[k] = v
+            if raw.get("case_cause", {}).get("type"):
+                flat["case_cause"] = raw["case_cause"]["type"]
+            if raw.get("tmpl_primary_cause"):
+                flat["primary_cause_type"] = raw.get("tmpl_primary_cause")
+            if raw.get("claims", {}).get("amount_total") is not None:
+                flat["claims"] = raw["claims"]
+            if raw.get("contract_type"):
+                flat["contract_type"] = raw.get("contract_type")
+            if raw.get("law_refs"):
+                flat["law_refs"] = raw["law_refs"]
+            return flat
+        except Exception:
+            # Fallback to rules
+            return self._extract_rules(text)
+
+    def _extract_ollama(self, text: str) -> dict[str, Any]:
+        """Use Ollama LLM for extraction, with rule fallback for structured fields."""
+        base = self._rule.extract_all(text)
+
+        if self._ollama is None:
+            return base
+
+        try:
+            # Enhance claims with Ollama
+            base["claims"] = self._ollama.extract_claims(text or "")
+        except Exception:
+            pass
+
+        try:
+            # Enhance template fields with Ollama
+            template_fields = self._ollama.extract_dispute_template_fields(text or "")
+            for k, v in template_fields.items():
+                if v is not None and v != "":
+                    base[k] = v
+        except Exception:
+            pass
+
+        return base
+
+    def _extract_hybrid(self, text: str) -> dict[str, Any]:
+        """
+        Hybrid extraction with field-type routing:
+        - Structured fields (case_number, dates, amounts): rules
+        - Entity fields (names, positions): BERT model
+        - Free text fields (facts, claims): Ollama LLM
+        """
+        # Start with rules for structured fields
+        base = self._rule.extract_all(text)
+
+        # Try BERT for entity fields
+        try:
+            resp = requests.post(
+                settings.bert_model_service_url,
+                json={"text": text},
+                timeout=60,
+            )
+            if resp.status_code == 200:
+                bert_result = resp.json()
+                # Merge entity fields from BERT
+                entity_fields = [
+                    "applicant_name", "respondent_name", "worker_position",
+                    "entry_date", "leave_date", "filing_date",
+                    "termination_reason", "arbitration_org",
+                ]
+                bert_parties = bert_result.get("parties", {})
+                for field in entity_fields:
+                    if field in bert_parties and bert_parties[field]:
+                        base[field] = bert_parties[field]
+
+                bert_facts = bert_result.get("facts", {})
+                for field in entity_fields:
+                    if field in bert_facts and bert_facts[field]:
+                        base[field] = bert_facts[field]
+
+                # Use BERT case cause if confident
+                bert_cause = bert_result.get("case_cause", {}).get("type")
+                if bert_cause and bert_cause != "劳动争议":
+                    base["case_cause"] = bert_cause
+        except Exception:
+            pass
+
+        # Try Ollama for free text fields
+        if self._ollama is not None:
+            try:
+                base["claims"] = self._ollama.extract_claims(text or "")
+            except Exception:
+                pass
+            try:
+                template_fields = self._ollama.extract_dispute_template_fields(text or "")
+                for k, v in template_fields.items():
+                    if v is not None and v != "":
+                        base[k] = v
+            except Exception:
+                pass
+
+        return base
+
+    @property
+    def mode(self) -> str:
+        return self._mode
+
+    @mode.setter
+    def mode(self, value: str):
+        self._mode = value
+        if value in ("ollama", "hybrid") and self._ollama is None and settings.use_ollama:
+            try:
+                from app.anj import OllamaClaimsExtractor
+                self._ollama = OllamaClaimsExtractor(
+                    settings.ollama_base_url,
+                    settings.ollama_model_name,
+                )
+            except Exception:
+                pass

+ 45 - 0
backend/app/services/nlp_client.py

@@ -0,0 +1,45 @@
+import re
+from typing import Any
+
+import requests
+
+from app.config import settings
+
+
+class NLPClient:
+    def extract(self, text: str) -> dict[str, Any]:
+        if settings.use_remote_nlp_service:
+            return self._extract_remote(text)
+        return self._extract_local_rule_based(text)
+
+    def _extract_remote(self, text: str) -> dict[str, Any]:
+        payload = {"text": text}
+        response = requests.post(settings.nlp_service_url, json=payload, timeout=60)
+        response.raise_for_status()
+        return response.json()
+
+    def _extract_local_rule_based(self, text: str) -> dict[str, Any]:
+        cause = "工资报酬争议" if "工资" in text else "劳动争议"
+        entry_match = re.search(r"(入职|到岗)[::]?\s*([0-9]{4}[./-][0-9]{1,2}[./-][0-9]{1,2})", text)
+        leave_match = re.search(r"(离职|解除)[::]?\s*([0-9]{4}[./-][0-9]{1,2}[./-][0-9]{1,2})", text)
+        amount_match = re.search(r"([0-9]{3,8}(?:\.[0-9]{1,2})?)\s*元", text)
+        law_refs = re.findall(r"《[^》]+》", text)
+
+        return {
+            "parties": {
+                "employer_nature": "企业",
+                "worker_position": "待识别",
+            },
+            "case_cause": {"type": cause},
+            "facts": {
+                "entry_date": entry_match.group(2) if entry_match else None,
+                "leave_date": leave_match.group(2) if leave_match else None,
+                "overtime": "加班" in text,
+                "termination_reason": "待识别",
+            },
+            "claims": {
+                "amount": float(amount_match.group(1)) if amount_match else None,
+                "types": ["工资", "经济补偿"] if "补偿" in text else ["工资"],
+            },
+            "laws": list(set(law_refs)),
+        }

+ 156 - 0
backend/app/services/portrait_generator.py

@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import math
+import re
+from typing import Any
+
+
+def _clamp(v: float, lo: float = 0, hi: float = 100) -> int:
+    return int(max(lo, min(hi, v)))
+
+
+def _has(v: Any) -> bool:
+    if v is None:
+        return False
+    if isinstance(v, str):
+        return bool(v.strip())
+    if isinstance(v, (list, dict)):
+        return len(v) > 0
+    return True
+
+
+def _keyword_tags(elements: dict[str, Any]) -> list[dict[str, Any]]:
+    """
+    多级标签体系(MVP):一级维度 + 二级标签 + 具体值
+    """
+    cause = elements.get("case_cause")
+    tags: list[dict[str, Any]] = []
+    if cause:
+        tags.append(
+            {
+                "level1": "法律维度",
+                "level2": "争议焦点类型",
+                "value": str(cause),
+            }
+        )
+    if elements.get("overtime_desc"):
+        tags.append({"level1": "事实维度", "level2": "关键事实", "value": "加班事实"})
+    if elements.get("termination_reason"):
+        tags.append({"level1": "事实维度", "level2": "解除情形", "value": "已识别解除原因"})
+    return tags
+
+
+def _extract_keywords(text: str, top_k: int = 15) -> list[dict[str, Any]]:
+    """
+    用 jieba 分词提取高频关键词,返回连贯的中文词语。
+    """
+    if not text:
+        return []
+    try:
+        import jieba
+        # 添加法律领域自定义词,避免复合词被切分
+        for w in ["劳动合同", "经济补偿", "赔偿金", "拖欠工资", "违法解除",
+                   "加班费", "工伤认定", "社会保险", "住房公积金",
+                   "解除劳动合同", "双倍工资", "违法辞退", "劳动仲裁",
+                   "科技有限公司", "有限公司", "劳动争议", "仲裁委员会",
+                   "年终奖", "二倍工资"]:
+            jieba.add_word(w)
+        words = jieba.cut(text[:12000])
+        toks = [w.strip() for w in words if len(w.strip()) >= 2]
+    except ImportError:
+        toks = re.findall(r"[一-鿿]{2,6}|[A-Za-z]{3,}", text[:12000])
+
+    stop = {
+        "申请人", "被申请人", "仲裁", "请求", "事实", "理由", "事项",
+        "劳动", "争议", "委员会", "申请", "本案", "仲裁庭",
+        "经审理", "查明", "如下", "予以", "认定",
+        "支持", "驳回", "维持", "的", "了", "在", "和",
+        "是", "不", "与", "及", "年", "月", "日", "元",
+        "支付", "公司", "人民", "法院", "原告", "被告",
+    }
+    freq: dict[str, int] = {}
+    for t in toks:
+        if t in stop or len(t) <= 1:
+            continue
+        freq[t] = freq.get(t, 0) + 1
+    items = sorted(freq.items(), key=lambda x: x[1], reverse=True)[:top_k]
+    return [{"name": k, "value": v} for k, v in items]
+
+def generate_portrait(elements: dict[str, Any], raw_text: str = "", evidence_count: int = 0) -> dict[str, Any]:
+    laws = elements.get("law_refs") or []
+    claims = elements.get("claims") or {}
+    claim_items = claims.get("items") or []
+
+    # 法律维度:争议焦点明确度 / 法条引用准确性(用“有无+数量”粗略替代) / 证据法律效力(用 evidence_count 近似)
+    dispute_focus = 60 + (20 if _has(elements.get("case_cause")) else 0) + (10 if len(claim_items) >= 2 else 0)
+    law_ref_score = 40 + min(40, 10 * len(laws))
+    evidence_legal_power = 30 + min(50, evidence_count * 10)
+    legal_score = _clamp(dispute_focus * 0.4 + law_ref_score * 0.35 + evidence_legal_power * 0.25)
+
+    # 事实维度:时间线完整性 / 事实清晰度 / 证据完备度
+    timeline_complete = 30 + (35 if _has(elements.get("entry_date")) else 0) + (35 if _has(elements.get("leave_date")) else 0)
+    fact_clear = 40 + (25 if _has(elements.get("termination_reason")) else 0) + (20 if _has(elements.get("overtime_desc")) else 0)
+    evidence_complete = 20 + min(60, evidence_count * 12)
+    fact_score = _clamp(timeline_complete * 0.35 + fact_clear * 0.4 + evidence_complete * 0.25)
+
+    # 风险维度:诉求支持可能性 / 矛盾激化程度
+    amount_total = claims.get("amount_total")
+    month_salary = elements.get("month_salary") or 0
+    support_prob = 60
+    if amount_total and month_salary:
+        ratio = amount_total / max(month_salary, 1)
+        if ratio > 12:
+            support_prob -= 20
+        elif ratio > 6:
+            support_prob -= 10
+        else:
+            support_prob += 5
+    if elements.get("case_cause") == "违法解除劳动合同" and not _has(elements.get("termination_reason")):
+        support_prob -= 10
+    support_prob = max(5, min(95, support_prob))
+
+    escalation = 40 + min(40, len(claim_items) * 8) + (10 if (amount_total or 0) > 50000 else 0)
+    risk_score = _clamp((100 - support_prob) * 0.6 + escalation * 0.4)
+
+    # KPI:风险等级(给前端标签)
+    risk_level = "低"
+    if risk_score >= 70:
+        risk_level = "高"
+    elif risk_score >= 45:
+        risk_level = "中"
+
+    portrait = {
+        "scores": {
+            "legal": legal_score,
+            "fact": fact_score,
+            "risk": risk_score,
+        },
+        "legal_dimension": {
+            "score": legal_score,
+            "subscores": {
+                "争议焦点明确度": _clamp(dispute_focus),
+                "法律条款引用充分度": _clamp(law_ref_score),
+                "证据法律效力(近似)": _clamp(evidence_legal_power),
+            },
+        },
+        "fact_dimension": {
+            "score": fact_score,
+            "subscores": {
+                "时间线完整性": _clamp(timeline_complete),
+                "事实描述清晰度": _clamp(fact_clear),
+                "证据完备度(近似)": _clamp(evidence_complete),
+            },
+        },
+        "risk_dimension": {
+            "score": risk_score,
+            "subscores": {
+                "诉求支持可能性(规则近似)": _clamp(support_prob),
+                "矛盾激化程度(规则近似)": _clamp(escalation),
+            },
+        },
+        "tags": _keyword_tags(elements),
+        "keywords": _extract_keywords(raw_text),
+        "risk_level": risk_level,
+    }
+    return portrait
+

+ 33 - 0
backend/app/services/profile_builder.py

@@ -0,0 +1,33 @@
+from typing import Any
+
+
+class CaseProfileBuilder:
+    @staticmethod
+    def build_profile(elements: dict[str, Any]) -> dict[str, Any]:
+        cause = elements.get("case_cause", {})
+        facts = elements.get("facts", {})
+        claims = elements.get("claims", {})
+        laws = elements.get("laws", [])
+
+        profile = {
+            "legal_dimension": {
+                "dispute_focus": cause.get("type", "未识别"),
+                "laws_count": len(laws),
+                "laws": laws,
+            },
+            "fact_dimension": {
+                "fact_clarity_score": 70 if facts else 30,
+                "evidence_completeness_score": 65,
+                "key_facts": facts,
+            },
+            "risk_dimension": {
+                "claim_support_probability": 0.62 if claims else 0.35,
+                "conflict_escalation_level": "中" if facts else "高",
+            },
+            "tags": [
+                cause.get("type", "劳动争议"),
+                "工资争议" if "工资" in str(cause) else "综合争议",
+                "证据待补强" if len(laws) < 2 else "法条较充分",
+            ],
+        }
+        return profile

+ 45 - 0
backend/app/services/risk_predictor.py

@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+def assess_risk(elements: dict[str, Any]) -> dict[str, Any]:
+    """
+    输出:
+    - level: 高/中/低
+    - factors: list[str]
+    """
+    factors: list[str] = []
+    cause = elements.get("case_cause")
+    termination_reason = elements.get("termination_reason")
+    month_salary = elements.get("month_salary") or 0
+    claims = elements.get("claims") or {}
+    amount_total = claims.get("amount_total") or 0
+    overtime_desc = elements.get("overtime_desc") or ""
+
+    # 规则示例:违法解除但原因为空 -> 风险升高
+    if cause == "违法解除劳动合同" and not termination_reason:
+        factors.append("案由为违法解除劳动合同但解除原因字段为空,争议事实支撑不足")
+
+    # 请求金额超过月工资12倍 -> 支持可能性降低
+    if month_salary and amount_total and amount_total > 12 * month_salary:
+        factors.append("请求金额超过月工资标准的12倍,诉求支持可能性降低")
+
+    # 加班事实存在但缺少具体记录 -> 证据不足
+    if "加班" in str(overtime_desc) and len(str(overtime_desc).strip()) < 15:
+        factors.append("加班事实存在但缺乏具体加班时间/记录,可能被认定证据不足")
+
+    # 法条引用缺乏
+    law_refs = elements.get("law_refs") or []
+    if cause and len(law_refs) == 0:
+        factors.append("未识别到法律条款引用,法律支撑可能不足(可人工补充)")
+
+    # 风险
+        # >= 3:
+        level = "高"
+    elif len(factors) >= 1:
+        level = "中"
+    else:
+        level = "低"
+    return {"level": level, "factors": factors}
+

+ 38 - 0
backend/app/services/similar_cases_local.py

@@ -0,0 +1,38 @@
+import re
+
+from sqlalchemy.orm import Session
+
+from app.models import CaseRecord
+
+
+def _tokens(text: str) -> set[str]:
+    if not text:
+        return set()
+    chunk = text[:8000]
+    return set(re.findall(r"[\w\u4e00-\u9fff]+", chunk))
+
+
+def _jaccard(a: set[str], b: set[str]) -> float:
+    if not a or not b:
+        return 0.0
+    inter = len(a & b)
+    union = len(a | b)
+    return inter / union if union else 0.0
+
+
+def find_similar(db: Session, exclude_case_id: int, query_text: str, limit: int = 5) -> list[dict]:
+    """
+    不依赖向量库:用词集合 Jaccard 与库里历史案件粗排相似度(适合毕业设计演示)。
+    """
+    q = _tokens(query_text)
+    rows = db.query(CaseRecord).filter(CaseRecord.id != exclude_case_id).all()
+    scored: list[tuple[float, CaseRecord]] = []
+    for row in rows:
+        s = _jaccard(q, _tokens(row.raw_text or ""))
+        scored.append((s, row))
+    scored.sort(key=lambda x: x[0], reverse=True)
+    return [
+        {"score": float(s), "case_id": row.id, "case_name": row.case_name}
+        for s, row in scored[:limit]
+        if s > 0
+    ]

+ 55 - 0
backend/app/services/vector_store.py

@@ -0,0 +1,55 @@
+from typing import Any
+from uuid import uuid4
+
+from qdrant_client import QdrantClient
+from qdrant_client.http import models as qmodels
+from sentence_transformers import SentenceTransformer
+
+from app.config import settings
+
+
+class VectorStore:
+    def __init__(self) -> None:
+        self.client = QdrantClient(url=settings.qdrant_url)
+        self.embedder = SentenceTransformer(settings.embedding_model_name)
+        self.collection = settings.qdrant_collection
+        self._ensure_collection()
+
+    def _ensure_collection(self) -> None:
+        collections = self.client.get_collections().collections
+        exists = any(c.name == self.collection for c in collections)
+        if not exists:
+            self.client.create_collection(
+                collection_name=self.collection,
+                vectors_config=qmodels.VectorParams(size=384, distance=qmodels.Distance.COSINE),
+            )
+
+    def add_case_vector(self, case_id: int, text: str, metadata: dict[str, Any]) -> None:
+        vector = self.embedder.encode(text).tolist()
+        self.client.upsert(
+            collection_name=self.collection,
+            points=[
+                qmodels.PointStruct(
+                    id=str(uuid4()),
+                    vector=vector,
+                    payload={"case_id": case_id, **metadata},
+                )
+            ],
+        )
+
+    def search_similar(self, text: str, limit: int = 5) -> list[dict[str, Any]]:
+        query_vector = self.embedder.encode(text).tolist()
+        hits = self.client.search(
+            collection_name=self.collection,
+            query_vector=query_vector,
+            limit=limit,
+            with_payload=True,
+        )
+        return [
+            {
+                "score": hit.score,
+                "case_id": hit.payload.get("case_id") if hit.payload else None,
+                "case_name": hit.payload.get("case_name") if hit.payload else None,
+            }
+            for hit in hits
+        ]

+ 17 - 0
backend/config.py

@@ -0,0 +1,17 @@
+"""
+兼容说明(避免 werkzeug ImportStringError: 'config' not found)
+
+部分工具/教程会使用「导入字符串」指向顶层模块 ``config``(例如旧版 Flask CLI、Celery ``-A config`` 等)。
+本毕业设计**主后端是 FastAPI**,正式配置在 ``app.config``(模块路径 ``app.config``)。
+
+正确启动后端(在 ``backend`` 目录下)::
+
+    uvicorn app.main:app --reload --port 8000
+
+本文件仅提供可被 ``import config`` 加载的占位模块;若你需要 Flask/Celery,请在本文件中自行实现
+``create_app`` / ``celery`` 等对象,或把环境变量里的入口改成 ``app.main:app``(FastAPI 不适用 flask run)。
+"""
+
+from app.config import settings
+
+__all__ = ["settings"]

File diff ditekan karena terlalu besar
+ 4854 - 0
backend/data/augmented_dataset.json


+ 120 - 0
backend/data/case_elements_hierarchy.json

@@ -0,0 +1,120 @@
+{
+  "劳动关系纠纷类": {
+    "申请人信息": null,
+    "被申请人信息": null,
+    "事实与理由": null,
+    "劳动报酬": {
+      "劳动报酬发放周期": null,
+      "劳动报酬金额": null,
+      "劳动报酬发放形式": null
+    },
+    "社会保险": {
+      "是否参加社会保险": null,
+      "保险待遇金额": null
+    },
+    "劳动关系": {
+      "是否签订劳动合同": {
+        "签订无固定限期劳动合同": null,
+        "未签订劳动合同的二倍工资": null
+      },
+      "劳动关系存在时间": null
+    }
+  },
+  "工伤保险待遇纠纷": {
+    "申请人信息": null,
+    "被申请人信息": null,
+    "事实与理由": null,
+    "劳动关系": {
+      "是否签订劳动合同": null,
+      "劳动关系存在时间": null
+    },
+    "保险待遇": {
+      "保险待遇发放时间": {
+        "一次性伤残补助费": null,
+        "辅助器具费": null,
+        "一次性医疗补助金": null,
+        "交通食宿费": null,
+        "工伤医疗/康复费用": null,
+        "住院治疗期间护理费": null,
+        "住院伙食补助费": null
+      },
+      "保险待遇金额": null,
+      "保险待遇发放形式": null,
+      "是否认可经济补偿": null
+    },
+    "社会保险": {
+      "保险待遇金额": null,
+      "是否参加社会保险": null
+    }
+  },
+  "追索劳动报酬": {
+    "申请人信息": null,
+    "被申请人信息": null,
+    "事实与理由": null,
+    "劳动报酬": {
+      "劳动报酬发放周期": null,
+      "克扣劳动报酬": {
+        "主张金额": null,
+        "生活费": null
+      },
+      "加班工资": null,
+      "实际支付工资标准": null,
+      "高温津贴金额": null,
+      "约定工资标准": null,
+      "带薪年休假工资": null,
+      "未支付期间": null,
+      "加班工资金额": null
+    }
+  },
+  "经济补偿金纠纷": {
+    "申请人信息": null,
+    "被申请人信息": null,
+    "事实与理由": null,
+    "经济补偿金": {
+      "离职前12个月平均工资": null,
+      "未签订劳动合同的二倍工资": null,
+      "主张金额": {
+        "违法解除劳动合同的赔偿金": null,
+        "违法约定试用期的赔偿金": null
+      },
+      "代通知金": null,
+      "加付赔偿金": null,
+      "劳动合同存在时间": null
+    },
+    "劳动合同": {
+      "离职原因": null,
+      "离职时间": null
+    }
+  },
+  "赔偿金纠纷": {
+    "申请人信息": null,
+    "被申请人信息": null,
+    "事实与理由": null,
+    "赔偿金": {
+      "主张金额": null,
+      "违法解除劳动合同赔偿金": null
+    },
+    "劳动合同": {
+      "劳动合同是否存在": null,
+      "解除劳动合同原因": null,
+      "劳动合同是否继续履行": null
+    }
+  },
+  "生育保险待遇纠纷": {
+    "申请人信息": null,
+    "被申请人信息": null,
+    "事实与理由": null,
+    "金额": {
+      "主张金额": {
+        "产假工资/生育津贴": null,
+        "生育医疗费用": null
+      },
+      "加付赔偿金": null,
+      "交通食宿费": null
+    },
+    "劳动合同": {
+      "劳动合同是否继续履行": null,
+      "劳动合同解除原因": null
+    }
+  }
+}

+ 278 - 0
backend/data/case_elements_schema.json

@@ -0,0 +1,278 @@
+{
+  "table_name": "案件普遍的智能分解模板(要素抽取)",
+  "version": "2.0",
+  "description": "对齐《案件普遍的智能分解模板》:案由—通用要素—各案由下一层/二层/三层要素;field_id 与 extractor.merge_dispute_template_fields 及基础抽取器对应。",
+  "groups": [
+    {
+      "id": "procedure",
+      "label": "案件程序信息",
+      "field_ids": [
+        "case_number",
+        "filing_date",
+        "arbitration_org",
+        "case_title",
+        "case_cause",
+        "dispute_focus",
+        "claim_types"
+      ]
+    },
+    {
+      "id": "cause_root",
+      "label": "案由(模板大类)",
+      "field_ids": ["tmpl_primary_cause"]
+    },
+    {
+      "id": "common",
+      "label": "通用要素",
+      "field_ids": ["gen_applicant_info", "gen_respondent_info", "gen_facts_and_reasons"]
+    },
+    {
+      "id": "lr1",
+      "label": "1. 劳动关系纠纷类",
+      "sub_groups": [
+        {
+          "id": "lr1_1",
+          "label": "1.1 劳动报酬",
+          "field_ids": ["lr1_pay_cycle", "lr1_pay_amount", "lr1_pay_form"]
+        },
+        {
+          "id": "lr1_2",
+          "label": "1.2 社会保险",
+          "field_ids": ["lr1_si_joined", "lr1_si_benefit_amount"]
+        },
+        {
+          "id": "lr1_3",
+          "label": "1.3 劳动关系",
+          "field_ids": [
+            "lr1_contract_signed",
+            "lr1_open_ended_contract",
+            "lr1_double_wage_no_contract",
+            "lr1_relation_duration"
+          ]
+        }
+      ]
+    },
+    {
+      "id": "wi",
+      "label": "2. 工伤保险待遇纠纷",
+      "sub_groups": [
+        {
+          "id": "wi_1",
+          "label": "2.1 劳动关系",
+          "field_ids": ["wi_lr_contract_signed", "wi_lr_relation_duration"]
+        },
+        {
+          "id": "wi_2",
+          "label": "2.2 保险待遇",
+          "field_ids": [
+            "wi_benefit_pay_time",
+            "wi_benefit_amount_total",
+            "wi_benefit_disability",
+            "wi_benefit_prosthetic",
+            "wi_benefit_medical_allowance",
+            "wi_benefit_travel",
+            "wi_benefit_rehab",
+            "wi_benefit_nursing",
+            "wi_benefit_meal",
+            "wi_benefit_pay_form"
+          ]
+        },
+        {
+          "id": "wi_4",
+          "label": "2.4 社会保险",
+          "field_ids": ["wi_si_benefit_amt", "wi_si_joined"]
+        }
+      ]
+    },
+    {
+      "id": "sr",
+      "label": "3. 追索劳动报酬",
+      "sub_groups": [
+        {
+          "id": "sr_1",
+          "label": "3.1 劳动报酬",
+          "field_ids": [
+            "sr_pay_cycle",
+            "sr_claim_amount",
+            "sr_claim_deducted_pay",
+            "sr_claim_overtime_pay",
+            "sr_claim_living_allowance",
+            "sr_high_temp_allowance",
+            "sr_actual_pay_standard",
+            "sr_agreed_pay_standard",
+            "sr_annual_leave_pay",
+            "sr_unpaid_period",
+            "sr_overtime_amount"
+          ]
+        }
+      ]
+    },
+    {
+      "id": "ec",
+      "label": "4. 经济补偿金纠纷",
+      "sub_groups": [
+        {
+          "id": "ec_1",
+          "label": "4.1 经济补偿金",
+          "field_ids": [
+            "ec_avg_salary_12m",
+            "ec_claim_amount",
+            "ec_double_wage_part",
+            "ec_illegal_term_part",
+            "ec_illegal_probation_part",
+            "ec_extra_compensation_part",
+            "ec_notice_pay",
+            "ec_additional_damages"
+          ]
+        },
+        {
+          "id": "ec_2",
+          "label": "4.2 劳动合同",
+          "field_ids": ["ec_contract_duration", "ec_leave_reason", "ec_leave_date"]
+        }
+      ]
+    },
+    {
+      "id": "dm",
+      "label": "5. 赔偿金纠纷",
+      "sub_groups": [
+        {
+          "id": "dm_1",
+          "label": "5.1 赔偿金",
+          "field_ids": ["dm_claim_amount", "dm_illegal_dismissal_damages"]
+        },
+        {
+          "id": "dm_2",
+          "label": "5.2 劳动合同",
+          "field_ids": ["dm_contract_exists", "dm_terminate_reason", "dm_contract_continue"]
+        }
+      ]
+    },
+    {
+      "id": "mi",
+      "label": "6. 生育保险待遇纠纷",
+      "sub_groups": [
+        {
+          "id": "mi_1",
+          "label": "6.1 金额",
+          "field_ids": [
+            "mi_claim_amount",
+            "mi_maternity_medical",
+            "mi_maternity_allowance_salary",
+            "mi_additional_damages",
+            "mi_travel_accommodation"
+          ]
+        },
+        {
+          "id": "mi_2",
+          "label": "6.2 劳动合同",
+          "field_ids": ["mi_contract_continue", "mi_terminate_reason"]
+        }
+      ]
+    },
+    {
+      "id": "other_extract",
+      "label": "其它程序与材料要素",
+      "field_ids": [
+        "employment_type",
+        "worker_position",
+        "entry_date",
+        "leave_date",
+        "work_duration_text",
+        "month_salary",
+        "overtime_desc",
+        "termination_reason",
+        "contract_type",
+        "injury_related",
+        "social_insurance_hint",
+        "claims",
+        "law_refs",
+        "evidence_materials"
+      ]
+    }
+  ],
+  "field_labels": {
+    "case_number": "案件编号/案号",
+    "filing_date": "立案日期",
+    "arbitration_org": "仲裁机构",
+    "case_title": "案件标题",
+    "case_cause": "案由类型(关键词归纳)",
+    "dispute_focus": "争议焦点",
+    "claim_types": "诉求类型(归纳)",
+    "tmpl_primary_cause": "案由(模板六大类)",
+    "gen_applicant_info": "申请人信息(通用)",
+    "gen_respondent_info": "被申请人信息(通用)",
+    "gen_facts_and_reasons": "事实与理由(通用)",
+    "lr1_pay_cycle": "1.1.1 劳动报酬发放周期",
+    "lr1_pay_amount": "1.1.2 劳动报酬金额",
+    "lr1_pay_form": "1.1.3 劳动报酬发放形式",
+    "lr1_si_joined": "1.2.1 是否参加社会保险",
+    "lr1_si_benefit_amount": "1.2.2 保险待遇金额",
+    "lr1_contract_signed": "1.3.1 是否签订劳动合同",
+    "lr1_open_ended_contract": "1.3.1.1 无固定期限劳动合同",
+    "lr1_double_wage_no_contract": "1.3.1.2 未签书面合同二倍工资",
+    "lr1_relation_duration": "1.3.2 劳动关系存在时间",
+    "wi_lr_contract_signed": "2.1.1 是否签订劳动合同",
+    "wi_lr_relation_duration": "2.1.2 劳动关系存在时间",
+    "wi_benefit_pay_time": "2.2.1 保险待遇发放时间",
+    "wi_benefit_amount_total": "2.2.2 保险待遇金额(合计)",
+    "wi_benefit_disability": "2.2.2.1 伤残津贴/伤残相关待遇",
+    "wi_benefit_prosthetic": "2.2.2.2 假肢等辅助器具费",
+    "wi_benefit_medical_allowance": "2.2.2.3 医疗补助金",
+    "wi_benefit_travel": "2.2.2.4 交通食宿费",
+    "wi_benefit_rehab": "2.2.2.5 医疗/康复费",
+    "wi_benefit_nursing": "2.2.2.6 护理费",
+    "wi_benefit_meal": "2.2.2.7 住院伙食补助",
+    "wi_benefit_pay_form": "2.2.3 保险待遇发放形式",
+    "wi_si_benefit_amt": "2.4.1 保险待遇金额",
+    "wi_si_joined": "2.4.2 是否参加社会保险",
+    "sr_pay_cycle": "3.1.1 劳动报酬发放周期",
+    "sr_claim_amount": "3.1.2 主张金额(合计)",
+    "sr_claim_deducted_pay": "3.1.2.1 克扣/拖欠工资",
+    "sr_claim_overtime_pay": "3.1.2.2 加班费",
+    "sr_claim_living_allowance": "3.1.2.3 生活费等",
+    "sr_high_temp_allowance": "3.1.3 高温津贴金额",
+    "sr_actual_pay_standard": "3.1.4 实际支付工资标准",
+    "sr_agreed_pay_standard": "3.1.5 约定工资标准",
+    "sr_annual_leave_pay": "3.1.6 带薪年休假工资",
+    "sr_unpaid_period": "3.1.7 未支付期间",
+    "sr_overtime_amount": "3.1.8 加班工资金额",
+    "ec_avg_salary_12m": "4.1.1 离职前12个月平均工资",
+    "ec_claim_amount": "4.1.2 主张金额(经济补偿相关)",
+    "ec_double_wage_part": "4.1.2.1 未签合同二倍工资部分",
+    "ec_illegal_term_part": "4.1.2.2 违法解除/终止补偿",
+    "ec_illegal_probation_part": "4.1.2.3 违法试用期相关",
+    "ec_extra_compensation_part": "4.1.2.4 其它补偿分项",
+    "ec_notice_pay": "4.1.3 代通知金",
+    "ec_additional_damages": "4.1.4 加付赔偿金",
+    "ec_contract_duration": "4.2.1 劳动合同存续/用工期间",
+    "ec_leave_reason": "4.2.2 离职原因",
+    "ec_leave_date": "4.2.3 离职时间",
+    "dm_claim_amount": "5.1.1 主张金额",
+    "dm_illegal_dismissal_damages": "5.1.2 违法解除劳动合同赔偿金",
+    "dm_contract_exists": "5.2.1 劳动合同是否存在",
+    "dm_terminate_reason": "5.2.2 解除劳动合同原因",
+    "dm_contract_continue": "5.2.3 是否继续履行劳动合同",
+    "mi_claim_amount": "6.1.1 主张金额(生育待遇)",
+    "mi_maternity_medical": "6.1.1.1 生育医疗费",
+    "mi_maternity_allowance_salary": "6.1.1.2 生育津贴/产假工资",
+    "mi_additional_damages": "6.1.2 加付赔偿金",
+    "mi_travel_accommodation": "6.1.3 交通食宿费",
+    "mi_contract_continue": "6.2.1 劳动合同是否继续履行",
+    "mi_terminate_reason": "6.2.2 劳动合同解除原因",
+    "employment_type": "用工形式",
+    "worker_position": "劳动者岗位",
+    "entry_date": "入职时间",
+    "leave_date": "离职时间",
+    "work_duration_text": "工作年限/用工期间",
+    "month_salary": "月工资标准",
+    "overtime_desc": "加班事实描述",
+    "termination_reason": "解除/离职原因",
+    "contract_type": "劳动合同类型/期限",
+    "injury_related": "工伤相关说明",
+    "social_insurance_hint": "社会保险相关说明",
+    "claims": "仲裁请求事项及金额(结构化)",
+    "law_refs": "法律依据条款",
+    "evidence_materials": "证据材料提示"
+  }
+}

File diff ditekan karena terlalu besar
+ 174 - 0
backend/data/raw_corpus.json


File diff ditekan karena terlalu besar
+ 41007 - 0
backend/data/training_dataset.json


+ 4 - 0
backend/requirements-vector.txt

@@ -0,0 +1,4 @@
+# 启用 USE_VECTOR_STORE=true 时再安装(需要本机或 Docker 中的 Qdrant)
+-r requirements.txt
+qdrant-client
+sentence-transformers

+ 11 - 0
backend/requirements.txt

@@ -0,0 +1,11 @@
+fastapi
+uvicorn[standard]
+python-multipart
+sqlalchemy
+pymysql
+pydantic-settings
+requests
+python-docx
+pypdf
+python-magic-bin
+ollama

+ 47 - 0
backend/tools/evaluate_extraction.py

@@ -0,0 +1,47 @@
+import json
+from pathlib import Path
+
+
+def safe_set(value):
+    if value is None:
+        return set()
+    if isinstance(value, list):
+        return set(map(str, value))
+    return {str(value)}
+
+
+def prf1(tp: int, fp: int, fn: int):
+    p = tp / (tp + fp) if tp + fp else 0.0
+    r = tp / (tp + fn) if tp + fn else 0.0
+    f1 = 2 * p * r / (p + r) if p + r else 0.0
+    return p, r, f1
+
+
+def evaluate(dataset_path: str):
+    data = json.loads(Path(dataset_path).read_text(encoding="utf-8"))
+    # 数据格式示例:
+    # [{"gold": {"laws": [...]}, "pred": {"laws": [...]}}]
+    total_tp, total_fp, total_fn = 0, 0, 0
+    for item in data:
+        gold = safe_set(item["gold"].get("laws", []))
+        pred = safe_set(item["pred"].get("laws", []))
+        total_tp += len(gold & pred)
+        total_fp += len(pred - gold)
+        total_fn += len(gold - pred)
+    p, r, f1 = prf1(total_tp, total_fp, total_fn)
+    print(
+        json.dumps(
+            {"precision": round(p, 4), "recall": round(r, 4), "f1": round(f1, 4)},
+            ensure_ascii=False,
+            indent=2,
+        )
+    )
+
+
+if __name__ == "__main__":
+    import argparse
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--dataset", required=True, help="标注评估集 json 路径")
+    args = parser.parse_args()
+    evaluate(args.dataset)

+ 269 - 0
backend/tools/evaluate_extractor.py

@@ -0,0 +1,269 @@
+from __future__ import annotations
+
+import json
+from collections import defaultdict
+from dataclasses import dataclass
+from typing import Any
+
+from app.extractor import RuleBasedLaborExtractor
+
+
+def _norm(v: Any) -> Any:
+    if v is None:
+        return None
+    if isinstance(v, str):
+        return v.strip()
+    return v
+
+
+def _as_set(v: Any) -> set[str]:
+    if v is None:
+        return set()
+    if isinstance(v, list):
+        return set([str(x).strip() for x in v if str(x).strip()])
+    return {str(v).strip()}
+
+
+@dataclass
+class FieldPRF:
+    tp: int = 0
+    fp: int = 0
+    fn: int = 0
+
+    def add(self, gold: Any, pred: Any) -> None:
+        gset = _as_set(gold)
+        pset = _as_set(pred)
+        self.tp += len(gset & pset)
+        self.fp += len(pset - gset)
+        self.fn += len(gset - pset)
+
+    def metrics(self) -> dict[str, float]:
+        p = self.tp / (self.tp + self.fp) if (self.tp + self.fp) else 0.0
+        r = self.tp / (self.tp + self.fn) if (self.tp + self.fn) else 0.0
+        f1 = 2 * p * r / (p + r) if (p + r) else 0.0
+        return {"precision": round(p, 4), "recall": round(r, 4), "f1": round(f1, 4)}
+
+
+def run_eval(samples: list[dict[str, Any]]) -> dict[str, Any]:
+    extractor = RuleBasedLaborExtractor()
+    stats: dict[str, FieldPRF] = defaultdict(FieldPRF)
+
+    for s in samples:
+        pred = extractor.extract(s["text"])
+        gold = s["gold"]
+        for field in gold.keys():
+            if field not in pred:
+                continue
+            if field == "claims":
+                # claims 是结构体:只评 items 和 amount_total
+                stats["claims.items"].add(gold["claims"].get("items"), pred["claims"].get("items"))
+                stats["claims.amount_total"].add(gold["claims"].get("amount_total"), pred["claims"].get("amount_total"))
+            else:
+                stats[field].add(_norm(gold.get(field)), _norm(pred.get(field)))
+
+    report = {k: v.metrics() | {"tp": v.tp, "fp": v.fp, "fn": v.fn} for k, v in sorted(stats.items())}
+    # micro 平均(合计)
+    total = FieldPRF()
+    for v in stats.values():
+        total.tp += v.tp
+        total.fp += v.fp
+        total.fn += v.fn
+    report["_micro_avg"] = total.metrics() | {"tp": total.tp, "fp": total.fp, "fn": total.fn}
+    return report
+
+
+def demo_samples() -> list[dict[str, Any]]:
+    """
+    至少 10 条模拟样例(可替换为你的真实标注集)。
+    """
+    return [
+        {
+            "text": "申请人:张三\n被申请人:北京某某有限公司\n单位性质:企业\n岗位:销售\n入职时间:2020年1月1日\n离职时间:2022-03-01\n月工资:8000元\n仲裁请求:1. 支付拖欠工资8000元;2. 支付加班费2000元。\n依据《劳动合同法》第三十条。",
+            "gold": {
+                "applicant_name": "张三",
+                "respondent_name": "北京某某有限公司",
+                "employer_nature": "企业",
+                "worker_position": "销售",
+                "case_cause": "工资报酬",
+                "entry_date": "2020-01-01",
+                "leave_date": "2022-03-01",
+                "month_salary": 8000.0,
+                "overtime_desc": "支付加班费2000元",
+                "termination_reason": None,
+                "claims": {"items": ["支付拖欠工资8000元", "支付加班费2000元"], "amount_total": 10000.0},
+                "law_refs": ["《劳动合同法》第三十条", "《劳动合同法》"],
+            },
+        },
+        {
+            "text": "申请人:李四,被申请人:上海某医院。岗位:护士。于2020.02.02入职,于2021.12.31解除。请求经济补偿八千元。引用《劳动合同法》。",
+            "gold": {
+                "applicant_name": "李四",
+                "respondent_name": "上海某医院",
+                "employer_nature": "事业单位",
+                "worker_position": "护士",
+                "case_cause": "经济补偿",
+                "entry_date": "2020-02-02",
+                "leave_date": "2021-12-31",
+                "month_salary": None,
+                "overtime_desc": None,
+                "termination_reason": None,
+                "claims": {"items": [], "amount_total": None},
+                "law_refs": ["《劳动合同法》"],
+            },
+        },
+        {
+            "text": "申请人:王五\n被申请人:某某科技股份有限公司\n入职日期2021/6/1,离职日期2023/1/15。\n月薪为8,000元。\n因绩效不达标被辞退。\n请求:违法解除劳动合同赔偿金96000元。\n依据劳动合同法第八十七条。",
+            "gold": {
+                "applicant_name": "王五",
+                "respondent_name": "某某科技股份有限公司",
+                "employer_nature": "企业",
+                "worker_position": None,
+                "case_cause": "违法解除劳动合同",
+                "entry_date": "2021-06-01",
+                "leave_date": "2023-01-15",
+                "month_salary": 8000.0,
+                "overtime_desc": None,
+                "termination_reason": "绩效不达标",
+                "claims": {"items": ["违法解除劳动合同赔偿金96000元"], "amount_total": 96000.0},
+                "law_refs": ["劳动合同法第八十七条"],
+            },
+        },
+        {
+            "text": "申请人:赵六\n被申请人:某个体工商户\n单位性质:个体工商户\n岗位:厨师\n入职时间:2019-5-10\n离职时间:2020-6-1\n主张加班费3000元,休息日加班无调休。\n法律依据:《劳动争议调解仲裁法》。",
+            "gold": {
+                "applicant_name": "赵六",
+                "respondent_name": "某个体工商户",
+                "employer_nature": "个人",
+                "worker_position": "厨师",
+                "case_cause": "加班费",
+                "entry_date": "2019-05-10",
+                "leave_date": "2020-06-01",
+                "month_salary": None,
+                "overtime_desc": "主张加班费3000元,休息日加班无调休",
+                "termination_reason": None,
+                "claims": {"items": [], "amount_total": None},
+                "law_refs": ["《劳动争议调解仲裁法》"],
+            },
+        },
+        {
+            "text": "申请人:孙七\n被申请人:某某有限公司\n工伤待遇:一次性伤残补助金50000元。\n于2022年7月7日入职,2023年7月7日离职。\n依据《工伤保险条例》。",
+            "gold": {
+                "applicant_name": "孙七",
+                "respondent_name": "某某有限公司",
+                "employer_nature": "企业",
+                "worker_position": None,
+                "case_cause": "工伤待遇",
+                "entry_date": "2022-07-07",
+                "leave_date": "2023-07-07",
+                "month_salary": None,
+                "overtime_desc": None,
+                "termination_reason": None,
+                "claims": {"items": [], "amount_total": None},
+                "law_refs": ["《工伤保险条例》"],
+            },
+        },
+        # 其余 5 条:覆盖不同日期/金额/案由写法
+        {
+            "text": "申请人:周八 被申请人:某某集团有限公司 岗位:工程师 入职:2020年12月5日 离职:2022年2月1日 月薪12000元 请求加班费10000元。",
+            "gold": {
+                "applicant_name": "周八",
+                "respondent_name": "某某集团有限公司",
+                "employer_nature": "企业",
+                "worker_position": "工程师",
+                "case_cause": "加班费",
+                "entry_date": "2020-12-05",
+                "leave_date": "2022-02-01",
+                "month_salary": 12000.0,
+                "overtime_desc": "请求加班费10000元",
+                "termination_reason": None,
+                "claims": {"items": [], "amount_total": None},
+                "law_refs": [],
+            },
+        },
+        {
+            "text": "申请人:钱九\n被申请人:某某学校\n单位性质:事业单位\n请求:支付拖欠工资二万元。\n入职时间2021.01.01,解除时间2021.06.30。",
+            "gold": {
+                "applicant_name": "钱九",
+                "respondent_name": "某某学校",
+                "employer_nature": "事业单位",
+                "worker_position": None,
+                "case_cause": "工资报酬",
+                "entry_date": "2021-01-01",
+                "leave_date": "2021-06-30",
+                "month_salary": None,
+                "overtime_desc": None,
+                "termination_reason": None,
+                "claims": {"items": ["支付拖欠工资二万元"], "amount_total": 20000.0},
+                "law_refs": [],
+            },
+        },
+        {
+            "text": "申请人:吴十\n被申请人:某某有限责任公司\n解除原因:未签订劳动合同。\n请求经济补偿12000元。\n法律条款:《劳动合同法》第四十六条。",
+            "gold": {
+                "applicant_name": "吴十",
+                "respondent_name": "某某有限责任公司",
+                "employer_nature": "企业",
+                "worker_position": None,
+                "case_cause": "经济补偿",
+                "entry_date": None,
+                "leave_date": None,
+                "month_salary": None,
+                "overtime_desc": None,
+                "termination_reason": "未签订劳动合同",
+                "claims": {"items": ["经济补偿12000元"], "amount_total": 12000.0},
+                "law_refs": ["《劳动合同法》第四十六条", "《劳动合同法》"],
+            },
+        },
+        {
+            "text": "申请人:郑十一\n被申请人:某某公司\n岗位:司机\n加班事实:长期每天加班至22点,未支付加班工资。\n请求加班费三千元。\n",
+            "gold": {
+                "applicant_name": "郑十一",
+                "respondent_name": "某某公司",
+                "employer_nature": "企业",
+                "worker_position": "司机",
+                "case_cause": "加班费",
+                "entry_date": None,
+                "leave_date": None,
+                "month_salary": None,
+                "overtime_desc": "加班事实:长期每天加班至22点,未支付加班工资",
+                "termination_reason": None,
+                "claims": {"items": ["加班费三千元"], "amount_total": 3000.0},
+                "law_refs": [],
+            },
+        },
+        {
+            "text": "申请人:冯十二\n被申请人:某某厂\n请求:支付工资差额5000元;支付经济补偿5000元。\n依据《劳动合同法》。",
+            "gold": {
+                "applicant_name": "冯十二",
+                "respondent_name": "某某厂",
+                "employer_nature": "企业",
+                "worker_position": None,
+                "case_cause": "工资报酬",
+                "entry_date": None,
+                "leave_date": None,
+                "month_salary": None,
+                "overtime_desc": None,
+                "termination_reason": None,
+                "claims": {"items": ["支付工资差额5000元", "支付经济补偿5000元"], "amount_total": 10000.0},
+                "law_refs": ["《劳动合同法》"],
+            },
+        },
+    ]
+
+
+if __name__ == "__main__":
+    # 可选:用 --dataset 读取你的真实标注集(JSON)
+    import argparse
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--dataset", default="", help="可选:标注集 JSON 文件路径")
+    args = parser.parse_args()
+
+    if args.dataset:
+        samples = json.loads(open(args.dataset, "r", encoding="utf-8").read())
+    else:
+        samples = demo_samples()
+
+    report = run_eval(samples)
+    print(json.dumps(report, ensure_ascii=False, indent=2))
+

+ 122 - 0
backend/tools/prepare_dataset.py

@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""
+Data cleaning and preparation for labor arbitration case corpus.
+Reads uploaded case files, filters non-labor cases, sanitizes privacy markers,
+and outputs a cleaned JSON corpus.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+UPLOADS_DIR = Path(__file__).resolve().parent.parent / "uploads"
+OUTPUT_PATH = Path(__file__).resolve().parent.parent / "data" / "raw_corpus.json"
+
+# Keywords that indicate a labor arbitration case (must have at least one)
+LABOR_KEYWORDS = [
+    "申请人", "被申请人", "劳动关系", "工资", "加班",
+    "劳动合同", "辞退", "解除劳动", "经济补偿", "工伤",
+    "仲裁请求", "请求事项", "劳动争议", "社保", "仲裁委员会",
+]
+
+# Keywords that suggest a non-labor case (civil loan, contract dispute, etc.)
+NON_LABOR_KEYWORDS = [
+    "借款", "欠条", "民间借贷", "买卖合同", "租赁合同",
+    "房屋买卖", "知识产权", "侵犯商标",
+]
+
+
+def _clean_privacy(text: str) -> str:
+    """Replace privacy-sensitive patterns with placeholders."""
+    # Base64-like identity hashes (long alphanumeric/+//= strings)
+    text = re.sub(r"[A-Za-z0-9+/]{30,}={0,3}", "[ID_HASH]", text)
+
+    # Chinese ID card numbers (18 digits, possibly with X at end)
+    text = re.sub(r"\b[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b", "[ID_NUM]", text)
+
+    # Phone numbers
+    text = re.sub(r"1[3-9]\d{9}", "[PHONE]", text)
+
+    # Generic XX placeholder (keep as is - already anonymized)
+    # text = re.sub(r"X{2,}", "[REDACTED]", text)
+
+    return text
+
+
+def _is_labor_case(text: str) -> bool:
+    """Check if the text appears to be a labor arbitration case."""
+    labor_score = sum(1 for kw in LABOR_KEYWORDS if kw in text)
+    non_labor_score = sum(1 for kw in NON_LABOR_KEYWORDS if kw in text)
+    return labor_score >= 2 and non_labor_score == 0
+
+
+def _normalize_text(text: str) -> str:
+    """Normalize whitespace and encoding."""
+    text = text.replace("\r\n", "\n").replace("\r", "\n")
+    # Normalize multiple newlines
+    text = re.sub(r"\n{3,}", "\n\n", text)
+    # Strip trailing/leading whitespace
+    text = text.strip()
+    return text
+
+
+def prepare_corpus() -> list[dict]:
+    """Main data preparation function."""
+    if not UPLOADS_DIR.exists():
+        raise FileNotFoundError(f"Uploads directory not found: {UPLOADS_DIR}")
+
+    OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+    corpus = []
+    skipped = []
+    files = sorted(UPLOADS_DIR.glob("*.txt"))
+
+    for idx, filepath in enumerate(files):
+        text = filepath.read_text(encoding="utf-8")
+        text = _normalize_text(text)
+
+        if not text.strip():
+            skipped.append({"file": filepath.name, "reason": "empty"})
+            continue
+
+        if not _is_labor_case(text):
+            skipped.append({"file": filepath.name, "reason": "non-labor"})
+            continue
+
+        text = _clean_privacy(text)
+
+        # Extract case ID from filename (e.g., "10_xxx.txt" -> 10, or "364-014-2022-0001.txt")
+        try:
+            case_id = int(filepath.stem.split("_")[0])
+        except ValueError:
+            case_id = idx + 1000  # fallback for numeric filenames without underscore prefix
+
+        corpus.append({
+            "case_id": case_id,
+            "file": filepath.name,
+            "text": text,
+        })
+
+    # Save corpus
+    output = {
+        "total_files": len(files),
+        "valid_cases": len(corpus),
+        "skipped": skipped,
+        "cases": corpus,
+    }
+    OUTPUT_PATH.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    print(f"Processed {len(files)} files:")
+    print(f"  Valid labor cases: {len(corpus)}")
+    print(f"  Skipped: {len(skipped)}")
+    for s in skipped:
+        print(f"    - {s['file']}: {s['reason']}")
+    print(f"Output saved to: {OUTPUT_PATH}")
+
+    return corpus
+
+
+if __name__ == "__main__":
+    prepare_corpus()

+ 24 - 0
config.py

@@ -0,0 +1,24 @@
+"""
+工具链兼容:werkzeug.import_string('config') 会在「当前工作目录」下找名为 config 的模块。
+
+若你在项目根目录 ``second_type/`` 运行 Flask/Celery 等,默认找不到 ``backend/config.py``。
+本文件把 ``backend`` 加入 sys.path 后转发到 ``app.config``。
+
+本毕业设计后端请用 FastAPI 启动(在 backend 目录)::
+
+    uvicorn app.main:app --reload --port 8000
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_backend = Path(__file__).resolve().parent / "backend"
+_backend_str = str(_backend)
+if _backend_str not in sys.path:
+    sys.path.insert(0, _backend_str)
+
+from app.config import settings  # noqa: E402
+
+__all__ = ["settings"]

+ 27 - 0
docker-compose.yml

@@ -0,0 +1,27 @@
+version: "3.9"
+
+services:
+  postgres:
+    image: postgres:16
+    container_name: labor_case_postgres
+    environment:
+      POSTGRES_USER: postgres
+      POSTGRES_PASSWORD: postgres
+      POSTGRES_DB: labor_cases
+    ports:
+      - "5432:5432"
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+
+  qdrant:
+    image: qdrant/qdrant:v1.11.3
+    container_name: labor_case_qdrant
+    ports:
+      - "6333:6333"
+      - "6334:6334"
+    volumes:
+      - qdrant_data:/qdrant/storage
+
+volumes:
+  postgres_data:
+  qdrant_data:

+ 12 - 0
frontend/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>劳动仲裁案件画像系统</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.jsx"></script>
+  </body>
+  </html>

+ 2338 - 0
frontend/package-lock.json

@@ -0,0 +1,2338 @@
+{
+  "name": "labor-case-frontend",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "labor-case-frontend",
+      "version": "0.1.0",
+      "dependencies": {
+        "axios": "^1.7.7",
+        "react": "^18.3.1",
+        "react-dom": "^18.3.1",
+        "recharts": "^2.12.7"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-react": "^4.3.3",
+        "vite": "^5.4.8"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
+      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz",
+      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
+      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helpers": "^7.28.6",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.29.1",
+      "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
+      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz",
+      "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz",
+      "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
+      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+      "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+      "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+      "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+      "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+      "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+      "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+      "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+      "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+      "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+      "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+      "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+      "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+      "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+      "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+      "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+      "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+      "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+      "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+      "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+      "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+      "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+      "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+      "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+      "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+      "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
+    "node_modules/@types/d3-array": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz",
+      "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-ease": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+      "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-path": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz",
+      "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-scale": {
+      "version": "4.0.9",
+      "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+      "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-time": "*"
+      }
+    },
+    "node_modules/@types/d3-shape": {
+      "version": "3.1.8",
+      "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+      "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-path": "*"
+      }
+    },
+    "node_modules/@types/d3-time": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz",
+      "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-timer": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+      "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.0",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.27",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.17.0"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.14.0.tgz",
+      "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.11",
+        "form-data": "^4.0.5",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.13",
+      "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz",
+      "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001784",
+      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz",
+      "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
+      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js-light": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+      "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+      "license": "MIT"
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dom-helpers": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz",
+      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.8.7",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.331",
+      "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
+      "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+      "license": "MIT"
+    },
+    "node_modules/fast-equals": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmmirror.com/fast-equals/-/fast-equals-5.4.0.tgz",
+      "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.37",
+      "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz",
+      "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.8",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
+      "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/prop-types/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz",
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.2"
+      },
+      "peerDependencies": {
+        "react": "^18.3.1"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
+      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+      "license": "MIT"
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
+      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-smooth": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmmirror.com/react-smooth/-/react-smooth-4.0.4.tgz",
+      "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-equals": "^5.0.1",
+        "prop-types": "^15.8.1",
+        "react-transition-group": "^4.4.5"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/react-transition-group": {
+      "version": "4.4.5",
+      "resolved": "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz",
+      "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@babel/runtime": "^7.5.5",
+        "dom-helpers": "^5.0.1",
+        "loose-envify": "^1.4.0",
+        "prop-types": "^15.6.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.6.0",
+        "react-dom": ">=16.6.0"
+      }
+    },
+    "node_modules/recharts": {
+      "version": "2.15.4",
+      "resolved": "https://registry.npmmirror.com/recharts/-/recharts-2.15.4.tgz",
+      "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+      "license": "MIT",
+      "dependencies": {
+        "clsx": "^2.0.0",
+        "eventemitter3": "^4.0.1",
+        "lodash": "^4.17.21",
+        "react-is": "^18.3.1",
+        "react-smooth": "^4.0.4",
+        "recharts-scale": "^0.4.4",
+        "tiny-invariant": "^1.3.1",
+        "victory-vendor": "^36.6.8"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/recharts-scale": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmmirror.com/recharts-scale/-/recharts-scale-0.4.5.tgz",
+      "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+      "license": "MIT",
+      "dependencies": {
+        "decimal.js-light": "^2.4.1"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.60.1",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz",
+      "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.1",
+        "@rollup/rollup-android-arm64": "4.60.1",
+        "@rollup/rollup-darwin-arm64": "4.60.1",
+        "@rollup/rollup-darwin-x64": "4.60.1",
+        "@rollup/rollup-freebsd-arm64": "4.60.1",
+        "@rollup/rollup-freebsd-x64": "4.60.1",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+        "@rollup/rollup-linux-arm64-musl": "4.60.1",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+        "@rollup/rollup-linux-loong64-musl": "4.60.1",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+        "@rollup/rollup-linux-x64-gnu": "4.60.1",
+        "@rollup/rollup-linux-x64-musl": "4.60.1",
+        "@rollup/rollup-openbsd-x64": "4.60.1",
+        "@rollup/rollup-openharmony-arm64": "4.60.1",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+        "@rollup/rollup-win32-x64-gnu": "4.60.1",
+        "@rollup/rollup-win32-x64-msvc": "4.60.1",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.23.2",
+      "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz",
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tiny-invariant": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+      "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+      "license": "MIT"
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/victory-vendor": {
+      "version": "36.9.2",
+      "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-36.9.2.tgz",
+      "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+      "license": "MIT AND ISC",
+      "dependencies": {
+        "@types/d3-array": "^3.0.3",
+        "@types/d3-ease": "^3.0.0",
+        "@types/d3-interpolate": "^3.0.1",
+        "@types/d3-scale": "^4.0.2",
+        "@types/d3-shape": "^3.1.0",
+        "@types/d3-time": "^3.0.0",
+        "@types/d3-timer": "^3.0.0",
+        "d3-array": "^3.1.6",
+        "d3-ease": "^3.0.1",
+        "d3-interpolate": "^3.0.1",
+        "d3-scale": "^4.0.2",
+        "d3-shape": "^3.1.0",
+        "d3-time": "^3.0.0",
+        "d3-timer": "^3.0.1"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    }
+  }
+}

+ 21 - 0
frontend/package.json

@@ -0,0 +1,21 @@
+{
+  "name": "labor-case-frontend",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.7.7",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "recharts": "^2.12.7"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-react": "^4.3.3",
+    "vite": "^5.4.8"
+  }
+}

+ 1172 - 0
frontend/src/App.jsx

@@ -0,0 +1,1172 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+  BarChart,
+  Bar,
+  XAxis,
+  YAxis,
+  Tooltip,
+  ResponsiveContainer,
+  Legend,
+} from "recharts";
+import { getEvaluationMetrics, uploadCase, updateCaseElements } from "./api";
+
+/** 合并画像关键词与要素,生成标签云数据 { name, value },仅保留最重要的连贯词条 */
+function buildTagCloudItems(profile, elements) {
+  const raw = profile?.keywords;
+  if (Array.isArray(raw) && raw.length > 0) {
+    // 使用 jieba 分词后的关键词,限制显示数量
+    return raw.slice(0, 12).map((k) => ({
+      name: String(k.name ?? k),
+      value: Number(k.value) || 1,
+    }));
+  }
+  // 回退:用要素字段构造简单标签(无 jieba 时)
+  const items = [];
+  const push = (name, w) => {
+    if (name == null || String(name).trim() === "") return;
+    items.push({ name: String(name).trim(), value: w });
+  };
+  if (elements?.primary_cause_type) push(elements.primary_cause_type, 13);
+  if (elements?.case_cause) push(elements.case_cause, 12);
+  if (elements?.applicant_name) push(elements.applicant_name, 10);
+  if (elements?.respondent_name) push(elements.respondent_name, 10);
+  if (elements?.worker_position) push(elements.worker_position, 8);
+  if (elements?.entry_date) push(`入职 ${elements.entry_date}`, 7);
+  if (elements?.leave_date) push(`离职 ${elements.leave_date}`, 7);
+  if (elements?.month_salary != null) push(`月薪 ${elements.month_salary}`, 9);
+  const claims = elements?.claims || {};
+  if (claims.amount_total != null) push(`请求合计 ${claims.amount_total} 元`, 11);
+  (elements?.law_refs || []).slice(0, 3).forEach((law, i) => push(law, 5 + i * 0.5));
+  return items.slice(0, 12);
+}
+
+/** 简易关系图:节点 + 边(供 SVG 绘制) */
+function buildRelationGraph(elements, caseTitle) {
+  const hubId = "hub";
+  const rawTitle = (caseTitle || "本案").trim();
+  const hubLabel = rawTitle.length > 12 ? `${rawTitle.slice(0, 12)}…` : rawTitle;
+  const nodes = [{ id: hubId, label: hubLabel, type: "hub" }];
+  const edges = [];
+
+  const add = (id, label, type) => {
+    if (!label) return;
+    const text = String(label).length > 18 ? `${String(label).slice(0, 18)}…` : String(label);
+    nodes.push({ id, label: text, type });
+    edges.push({ from: hubId, to: id });
+  };
+
+  add("caseno", elements?.case_number && `案号:${elements.case_number}`, "basic");
+  add("org", elements?.arbitration_org && `仲裁委:${String(elements.arbitration_org).slice(0, 14)}${String(elements.arbitration_org).length > 14 ? "…" : ""}`, "basic");
+  add("applicant", elements?.applicant_name && `申请人:${elements.applicant_name}`, "party");
+  add("respondent", elements?.respondent_name && `被申请人:${elements.respondent_name}`, "party");
+  add("cause", elements?.case_cause && `案由:${elements.case_cause}`, "legal");
+  add("salary", elements?.month_salary != null && `工资标准:${elements.month_salary} 元/月`, "fact");
+  add("entry", elements?.entry_date && `入职:${elements.entry_date}`, "fact");
+  add("leave", elements?.leave_date && `离职:${elements.leave_date}`, "fact");
+  add("term", elements?.termination_reason && `解除:${elements.termination_reason}`, "fact");
+  add("ot", elements?.overtime_desc && "加班事实", "fact");
+
+  const claims = elements?.claims || {};
+  const items = claims.items || [];
+  if (items.length) {
+    const summary = items.length === 1 ? items[0] : `仲裁请求 ${items.length} 项`;
+    add("claims", summary, "claim");
+  } else if (claims.amount_total != null) {
+    add("claims", `请求金额:${claims.amount_total} 元`, "claim");
+  }
+
+  const laws = (elements?.law_refs || []).slice(0, 6);
+  laws.forEach((law, i) => {
+    const id = `law-${i}`;
+    const short = String(law).length > 22 ? `${String(law).slice(0, 22)}…` : String(law);
+    nodes.push({ id, label: short, type: "law" });
+    edges.push({ from: hubId, to: id });
+  });
+
+  return { nodes, edges };
+}
+
+function CaseRelationGraph({ elements, caseTitle }) {
+  const { nodes, edges } = useMemo(
+    () => buildRelationGraph(elements || {}, caseTitle),
+    [elements, caseTitle]
+  );
+
+  const W = 420;
+  const H = 300;
+  const cx = W / 2;
+  const cy = H / 2 - 6;
+  const rHub = 52;
+  const orbit = 118;
+
+  const hub = nodes.find((n) => n.type === "hub");
+  const satellites = nodes.filter((n) => n.type !== "hub");
+  const nSat = Math.max(satellites.length, 1);
+
+  const positions = {};
+  if (hub) positions[hub.id] = { x: cx, y: cy };
+
+  satellites.forEach((node, i) => {
+    const angle = (2 * Math.PI * i) / nSat - Math.PI / 2;
+    positions[node.id] = {
+      x: cx + orbit * Math.cos(angle),
+      y: cy + orbit * Math.sin(angle) * 0.92,
+    };
+  });
+
+  const colors = {
+    hub: "#1d4ed8",
+    basic: "#475569",
+    party: "#7c3aed",
+    legal: "#059669",
+    fact: "#d97706",
+    claim: "#dc2626",
+    law: "#0d9488",
+  };
+
+  return (
+    <svg viewBox={`0 0 ${W} ${H}`} className="relation-svg" role="img" aria-label="案件要素关系图">
+      {edges.map((e, i) => {
+        const p0 = positions[e.from];
+        const p1 = positions[e.to];
+        if (!p0 || !p1) return null;
+        return (
+          <line
+            key={`e-${i}`}
+            x1={p0.x}
+            y1={p0.y}
+            x2={p1.x}
+            y2={p1.y}
+            className="relation-edge"
+          />
+        );
+      })}
+      {nodes.map((node) => {
+        const p = positions[node.id];
+        if (!p) return null;
+        const isHub = node.type === "hub";
+        const rw = isHub ? rHub : Math.min(168, 20 + node.label.length * 12);
+        const rh = isHub ? rHub : 30;
+        const fill = colors[node.type] || "#64748b";
+        return (
+          <g key={node.id} transform={`translate(${p.x}, ${p.y})`}>
+            <rect
+              x={-rw / 2}
+              y={-rh / 2}
+              width={rw}
+              height={rh}
+              rx={isHub ? rHub : 8}
+              ry={isHub ? rHub : 8}
+              fill={fill}
+              fillOpacity={isHub ? 0.95 : 0.88}
+              stroke="#fff"
+              strokeWidth={2}
+            />
+            <text
+              x={0}
+              y={isHub ? 3 : 4}
+              textAnchor="middle"
+              className={isHub ? "relation-hub-text" : "relation-node-text"}
+              fill="#fff"
+            >
+              {node.label.length > 10 && isHub ? `${node.label.slice(0, 10)}…` : node.label.length > 14 && !isHub ? `${node.label.slice(0, 14)}…` : node.label}
+            </text>
+          </g>
+        );
+      })}
+    </svg>
+  );
+}
+
+const CLOUD_HUES = [220, 200, 160, 280, 340, 25, 145, 265];
+
+function TagCloud({ items }) {
+  // 仅显示前 12 个最重要的连贯词条
+  const display = items.slice(0, 12);
+  const maxV = Math.max(...display.map((x) => x.value), 1);
+  return (
+    <div className="tag-cloud" role="list">
+      {display.map((item, idx) => {
+        const scale = 0.65 + (item.value / maxV) * 1.15;
+        const hue = CLOUD_HUES[idx % CLOUD_HUES.length];
+        return (
+          <span
+            key={`${item.name}-${idx}`}
+            className="tag-cloud-item"
+            style={{
+              fontSize: `${12 * scale}px`,
+              color: `hsl(${hue} 55% 32%)`,
+              backgroundColor: `hsl(${hue} 45% 94%)`,
+            }}
+            role="listitem"
+            title={`权重 ${item.value}`}
+          >
+            {item.name}
+          </span>
+        );
+      })}
+    </div>
+  );
+}
+
+function formatElementCellValue(v) {
+  if (v == null || v === "") return "—";
+  if (Array.isArray(v)) {
+    if (v.length === 0) return "(无)";
+    if (typeof v[0] === "object") return JSON.stringify(v, null, 2);
+    return v.join(";");
+  }
+  if (typeof v === "object") return JSON.stringify(v, null, 2);
+  return String(v);
+}
+
+function ElementRowsTable({ rows }) {
+  if (!rows?.length) return null;
+  return (
+    <div className="elements-table-scroll">
+      <table className="elements-table">
+        <thead>
+          <tr>
+            <th className="col-label">要素名称</th>
+            <th className="col-value">抽取结果</th>
+          </tr>
+        </thead>
+        <tbody>
+          {rows.map((row) => (
+            <tr key={row.field_id}>
+              <td className="col-label">{row.field_label}</td>
+              <td className="col-value">
+                <pre className="elements-cell-pre">{formatElementCellValue(row.value)}</pre>
+              </td>
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+}
+
+/** 递归:支持后端 sub_groups(案由模板多层结构) */
+function ElementsTableGroup({ node, depth }) {
+  const subs = node.sub_groups || [];
+  const hasRows = (node.rows || []).length > 0;
+  const hasSubs = subs.length > 0;
+  if (!hasRows && !hasSubs) return null;
+
+  const Title = depth === 0 ? "h3" : depth === 1 ? "h4" : "h5";
+  const titleClass =
+    depth === 0 ? "element-group-title" : depth === 1 ? "element-subgroup-title" : "element-subgroup-title-3";
+
+  return (
+    <div className={depth === 0 ? "element-group-block" : "element-subgroup-block"}>
+      {node.group_label && (hasRows || hasSubs) && (
+        <Title className={titleClass}>{node.group_label}</Title>
+      )}
+      <ElementRowsTable rows={node.rows} />
+      {subs.map((sg) => (
+        <ElementsTableGroup key={sg.group_id} node={sg} depth={depth + 1} />
+      ))}
+    </div>
+  );
+}
+
+/** 将层级对象压平为「路径 + 叶子值」行(仅叶子可编辑) */
+function flattenHierarchyRows(obj, prefix = []) {
+  if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
+    return prefix.length ? [{ path: prefix, value: obj }] : [];
+  }
+  const keys = Object.keys(obj);
+  if (keys.length === 0) {
+    return prefix.length ? [{ path: prefix, value: null }] : [];
+  }
+  const rows = [];
+  for (const k of keys) {
+    const v = obj[k];
+    if (v !== null && typeof v === "object" && !Array.isArray(v)) {
+      rows.push(...flattenHierarchyRows(v, [...prefix, k]));
+    } else {
+      rows.push({ path: [...prefix, k], value: v });
+    }
+  }
+  return rows;
+}
+
+function setHierarchyAtPath(root, path, rawValue) {
+  const next = JSON.parse(JSON.stringify(root));
+  let cur = next;
+  for (let i = 0; i < path.length - 1; i++) {
+    const p = path[i];
+    if (cur[p] === undefined || cur[p] === null || typeof cur[p] !== "object") cur[p] = {};
+    cur = cur[p];
+  }
+  const leaf = path[path.length - 1];
+  const v = rawValue === "" ? null : rawValue;
+  cur[leaf] = v;
+  return next;
+}
+
+const HIER_COMMON_FIELD_KEYS = ["申请人信息", "被申请人信息", "事实与理由"];
+
+function splitHierarchyRows(rows) {
+  const common = [];
+  const specific = [];
+  const commonSet = new Set(HIER_COMMON_FIELD_KEYS);
+  for (const r of rows) {
+    // 通用要素:不仅包含顶层叶子(如“事实与理由”),也包含其下结构化子项(如 申请人信息.name)
+    if (r.path.length >= 1 && commonSet.has(r.path[0])) common.push(r);
+    else specific.push(r);
+  }
+  common.sort(
+    (a, b) => {
+      const da = HIER_COMMON_FIELD_KEYS.indexOf(a.path[0]);
+      const db = HIER_COMMON_FIELD_KEYS.indexOf(b.path[0]);
+      if (da !== db) return da - db;
+      // 同一通用要素下:按子项名排序,保持稳定展示
+      return (a.path[1] || "").localeCompare(b.path[1] || "");
+    }
+  );
+  return { common, specific };
+}
+
+function hierarchyPathLabel(path) {
+  return path.filter(Boolean).join(",");
+}
+
+function commonSubfieldCn(seg) {
+  const s = (seg || "").trim();
+  if (!s) return "";
+  if (/[\u4e00-\u9fff]/.test(s)) return s;
+  const map = {
+    name: "姓名",
+    gender: "性别",
+    birthday: "出生日期",
+    birthdate: "出生日期",
+    age: "年龄",
+    phone: "联系电话",
+    mobile: "联系电话",
+    tel: "联系电话",
+    residence: "住所地",
+    address: "地址",
+    identity_number: "身份证号",
+    id_number: "身份证号",
+    idcard: "身份证号",
+    nationality: "国籍/民族",
+    nation: "民族",
+    company: "单位名称",
+    company_name: "单位名称",
+    legal_representative: "法定代表人",
+    representative: "代表人",
+    position: "职务/岗位",
+  };
+  return map[s] || s;
+}
+
+function HierarchyValueInput({ hierarchy, row, onChange }) {
+  const label = hierarchyPathLabel(row.path);
+  const val = row.value === null || row.value === undefined ? "" : String(row.value);
+  const isFacts = row.path.length === 1 && row.path[0] === "事实与理由";
+  if (isFacts) {
+    return (
+      <textarea
+        className="hier-input hier-textarea"
+        rows={5}
+        value={val}
+        onChange={(e) => onChange(setHierarchyAtPath(hierarchy, row.path, e.target.value))}
+        aria-label={label}
+      />
+    );
+  }
+  return (
+    <input
+      type="text"
+      className="hier-input"
+      value={val}
+      onChange={(e) => onChange(setHierarchyAtPath(hierarchy, row.path, e.target.value))}
+      aria-label={label}
+    />
+  );
+}
+
+const LEVEL_NUM_HAN = ["一", "二", "三", "四", "五", "六", "七", "八"];
+
+function maxPathDepth(rows) {
+  let m = 0;
+  for (const r of rows) m = Math.max(m, r.path.length);
+  return Math.max(m, 1);
+}
+
+/** 本案案由下层级要素:多列表格,一层/二层/三层分列,无路径箭头 */
+function HierarchyCauseLevelsTable({ hierarchy, specific, onChange }) {
+  const depth = useMemo(() => maxPathDepth(specific), [specific]);
+  const levelHeaders = useMemo(
+    () =>
+      Array.from({ length: depth }, (_, i) => {
+        const n = LEVEL_NUM_HAN[i] ?? String(i + 1);
+        return `${n}层要素`;
+      }),
+    [depth]
+  );
+
+  if (specific.length === 0) {
+    return <p className="empty-inline">本案案由下暂无可列出的层级字段。</p>;
+  }
+
+  return (
+    <table className="elements-table hierarchy-edit-table hierarchy-levels-table">
+      <thead>
+        <tr>
+          {levelHeaders.map((h, i) => (
+            <th key={i} className="col-hier-level">
+              {h}
+            </th>
+          ))}
+          <th className="col-value">抽取结果(可编辑)</th>
+        </tr>
+      </thead>
+      <tbody>
+        {specific.map((r) => (
+          <tr key={r.path.join("/")}>
+            {Array.from({ length: depth }, (_, i) => (
+              <td key={i} className="col-hier-level">
+                {r.path[i] != null && r.path[i] !== "" ? r.path[i] : ""}
+              </td>
+            ))}
+            <td className="col-value">
+              <HierarchyValueInput hierarchy={hierarchy} row={r} onChange={onChange} />
+            </td>
+          </tr>
+        ))}
+      </tbody>
+    </table>
+  );
+}
+
+/** 按案由层级 JSON 的可编辑表格:通用要素为两列表;案由下层级要素为分层多列表(无箭头) */
+function HierarchyElementsEditor({ hierarchy, primaryCause, onChange }) {
+  const rows = useMemo(() => flattenHierarchyRows(hierarchy || {}), [hierarchy]);
+  const { common, specific } = useMemo(() => splitHierarchyRows(rows), [rows]);
+  return (
+    <div className="hierarchy-editor-wrap">
+      <p className="hier-cause-banner">
+        <span className="hier-cause-label">识别案由类型</span>
+        <strong>{primaryCause || "—"}</strong>
+        <span className="hier-cause-hint">
+          通用要素为两列表;本案案由下的层级要素按「一层 / 二层 / 三层…」分列展示,可直接修改后保存。
+        </span>
+      </p>
+
+      <h4 className="hier-subtable-title">一、通用要素</h4>
+      <div className="elements-table-scroll">
+        <table className="elements-table hierarchy-edit-table">
+          <thead>
+            <tr>
+              <th className="col-hier-path">要素名称</th>
+              <th className="col-hier-sub">子项</th>
+              <th className="col-value">抽取结果(可编辑)</th>
+            </tr>
+          </thead>
+          <tbody>
+            {common.map((r) => (
+              <tr key={r.path.join("/")}>
+                <td className="col-hier-path">{r.path[0]}</td>
+                <td className="col-hier-sub">{r.path.length >= 2 ? commonSubfieldCn(r.path[1]) : ""}</td>
+                <td className="col-value">
+                  <HierarchyValueInput hierarchy={hierarchy} row={r} onChange={onChange} />
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      </div>
+
+      <h4 className="hier-subtable-title">二、本案案由下的层级要素</h4>
+      <div className="elements-table-scroll">
+        <HierarchyCauseLevelsTable hierarchy={hierarchy} specific={specific} onChange={onChange} />
+      </div>
+    </div>
+  );
+}
+
+/** 按后端 case_elements_table 分组表格展示;无结构时扁平展示 */
+function ElementsTable({ extracted }) {
+  const table = extracted?.case_elements_table;
+  if (table?.groups?.length) {
+    return (
+      <div className="elements-table-wrap">
+        {(table.table_name || table.version || table.field_count != null) && (
+          <p className="elements-table-meta">
+            {table.table_name}
+            {table.version ? ` · schema ${table.version}` : ""}
+            {typeof table.field_count === "number" ? ` · 共 ${table.field_count} 项要素` : ""}
+          </p>
+        )}
+        {table.groups.map((g) => (
+          <ElementsTableGroup key={g.group_id} node={g} depth={0} />
+        ))}
+      </div>
+    );
+  }
+
+  const flat = { ...(extracted || {}) };
+  delete flat.case_elements_table;
+  const entries = Object.entries(flat).sort(([a], [b]) => a.localeCompare(b));
+  if (entries.length === 0) {
+    return <p className="empty-inline">无要素数据</p>;
+  }
+  return (
+    <div className="elements-table-scroll">
+      <table className="elements-table elements-table-flat">
+        <thead>
+          <tr>
+            <th className="col-label">字段</th>
+            <th className="col-value">抽取结果</th>
+          </tr>
+        </thead>
+        <tbody>
+          {entries.map(([k, v]) => (
+            <tr key={k}>
+              <td className="col-label"><code>{k}</code></td>
+              <td className="col-value">
+                <pre className="elements-cell-pre">{formatElementCellValue(v)}</pre>
+              </td>
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+}
+
+function App() {
+  const [caseName, setCaseName] = useState("");
+  const [files, setFiles] = useState([]);
+  const [result, setResult] = useState(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState("");
+  const [openSimilarId, setOpenSimilarId] = useState(null);
+  const [showCurrentText, setShowCurrentText] = useState(false);
+  const [similarTopK, setSimilarTopK] = useState(5);
+  const [evalLoading, setEvalLoading] = useState(false);
+  const [evalError, setEvalError] = useState("");
+  const [evalMetrics, setEvalMetrics] = useState(null);
+  /** elements | portrait | similar */
+  const [mainSection, setMainSection] = useState("elements");
+  /** 可编辑的案由层级要素(与 extracted_elements.elements_hierarchy 同步) */
+  const [hierarchyDraft, setHierarchyDraft] = useState(null);
+  const [hierarchySaveMsg, setHierarchySaveMsg] = useState("");
+  const lastHierarchyCaseIdRef = useRef(null);
+
+  const normalizeHierarchyForUI = (h) => {
+    if (!h || typeof h !== "object" || Array.isArray(h)) return h;
+    // 兼容旧数据:层级树可能缺少通用要素键(申请人信息/被申请人信息/事实与理由)
+    const next = JSON.parse(JSON.stringify(h));
+    for (const k of HIER_COMMON_FIELD_KEYS) {
+      if (!(k in next)) next[k] = null;
+    }
+    return next;
+  };
+
+  useEffect(() => {
+    if (!result) {
+      setHierarchyDraft(null);
+      lastHierarchyCaseIdRef.current = null;
+      setHierarchySaveMsg("");
+      return;
+    }
+    const cid = result.case_id;
+    if (lastHierarchyCaseIdRef.current !== cid) {
+      setHierarchySaveMsg("");
+      lastHierarchyCaseIdRef.current = cid;
+    }
+    const h = result.extracted_elements?.elements_hierarchy;
+    if (h) {
+      try {
+        setHierarchyDraft(normalizeHierarchyForUI(h));
+      } catch {
+        setHierarchyDraft(null);
+      }
+    } else {
+      setHierarchyDraft(null);
+    }
+  }, [result]);
+
+  const handleSaveHierarchy = async () => {
+    if (!result?.case_id || !hierarchyDraft) return;
+    setHierarchySaveMsg("");
+    try {
+      await updateCaseElements(result.case_id, { elements_hierarchy: hierarchyDraft });
+      setResult({
+        ...result,
+        extracted_elements: { ...result.extracted_elements, elements_hierarchy: hierarchyDraft },
+      });
+      setHierarchySaveMsg("已保存到服务器。");
+    } catch (err) {
+      setHierarchySaveMsg(err?.response?.data?.detail || "保存失败,请检查后端。");
+    }
+  };
+
+  const handleSubmit = async (e) => {
+    e.preventDefault();
+    if (!caseName || files.length === 0) {
+      setError("请填写案件名称并上传材料。");
+      return;
+    }
+    setError("");
+    setLoading(true);
+    try {
+      const data = await uploadCase(caseName, files);
+      setResult(data);
+      setMainSection("elements");
+    } catch (err) {
+      setError(err?.response?.data?.detail || "上传失败,请检查后端服务。");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const hasResult = result != null;
+  const profile = result?.case_profile || {};
+  const elements = result?.extracted_elements || {};
+  const tagCloudItems = useMemo(
+    () => buildTagCloudItems(profile, elements),
+    [profile, elements]
+  );
+  const similarList = result?.similar_cases || [];
+  const currentAppText = result?.current_application_text || "";
+  const currentBrief = result?.current_elements_brief || {};
+
+  const normalizedSimilar = similarList.map((item) => ({
+    ...item,
+    similarity_percent: (() => {
+      // 优先展示三维度加权相似度(后端 weighted_similarity_percent),无则回退 Jaccard
+      if (item.weighted_similarity_percent != null) return Number(item.weighted_similarity_percent);
+      if (item.similarity_percent != null) return Number(item.similarity_percent);
+      if (item.score != null) return Math.round(Number(item.score) * 10000) / 100;
+      return 0;
+    })(),
+  }));
+
+  const shownSimilar = useMemo(() => {
+    const k = Math.max(1, Math.min(50, Number(similarTopK) || 5));
+    return [...normalizedSimilar].sort((a, b) => (b.similarity_percent || 0) - (a.similarity_percent || 0)).slice(0, k);
+  }, [normalizedSimilar, similarTopK]);
+
+  const similarBarData = shownSimilar.map((item, idx) => ({
+    name: (item.case_name || `案件${item.case_id}`).slice(0, 10) + (item.case_name?.length > 10 ? "…" : ""),
+    fullName: item.case_name || `案件 #${item.case_id}`,
+    percent: item.similarity_percent,
+    id: item.case_id ?? idx,
+  }));
+
+  const portraitCompareData = (sim) => {
+    const cur = profile?.scores || {};
+    const s = sim?.portrait_scores || {};
+    return [
+      { dim: "法律维度", 当前: cur.legal ?? 0, 类案: s.legal ?? 0 },
+      { dim: "事实维度", 当前: cur.fact ?? 0, 类案: s.fact ?? 0 },
+      { dim: "风险维度", 当前: cur.risk ?? 0, 类案: s.risk ?? 0 },
+    ];
+  };
+
+  const loadEvaluation = async () => {
+    setEvalError("");
+    setEvalLoading(true);
+    try {
+      const data = await getEvaluationMetrics();
+      setEvalMetrics(data?.metrics || null);
+    } catch (e) {
+      setEvalError(e?.response?.data?.detail || "获取评估指标失败,请检查后端。");
+    } finally {
+      setEvalLoading(false);
+    }
+  };
+
+  return (
+    <div className="container">
+      <header className="page-header">
+        <div>
+          <h1>劳动仲裁案件要素抽取与画像构建系统</h1>
+          <p className="subtitle">上传多源材料,一键生成结构化要素、标签云与要素关系图,并支持相似案件推荐。</p>
+        </div>
+      </header>
+      <form className="card" onSubmit={handleSubmit}>
+        <label>案件名称</label>
+        <input value={caseName} onChange={(e) => setCaseName(e.target.value)} placeholder="例如:张某诉某公司工资争议" />
+        <label>上传材料(可多选)</label>
+        <input type="file" multiple onChange={(e) => setFiles(e.target.files)} />
+        <button type="submit" disabled={loading}>{loading ? "处理中..." : "开始分析"}</button>
+      </form>
+
+      {error && <div className="error">{error}</div>}
+
+      <nav className="main-nav" aria-label="案件分析导航">
+        <button
+          type="button"
+          className={`main-nav-item${mainSection === "elements" ? " is-active" : ""}`}
+          onClick={() => setMainSection("elements")}
+        >
+          要素抽取结果
+        </button>
+        <button
+          type="button"
+          className={`main-nav-item${mainSection === "portrait" ? " is-active" : ""}`}
+          onClick={() => setMainSection("portrait")}
+        >
+          案件画像
+          <span className="main-nav-sub">标签云 · 关系图</span>
+        </button>
+        <button
+          type="button"
+          className={`main-nav-item${mainSection === "similar" ? " is-active" : ""}`}
+          onClick={() => setMainSection("similar")}
+        >
+          相似案件推荐
+        </button>
+        <button
+          type="button"
+          className={`main-nav-item${mainSection === "evaluation" ? " is-active" : ""}`}
+          onClick={() => {
+            setMainSection("evaluation");
+            if (!evalMetrics && !evalLoading) loadEvaluation();
+          }}
+        >
+          系统评估
+          <span className="main-nav-sub">准确率 · 召回率 · 画像质量</span>
+        </button>
+      </nav>
+
+      <div className="main-panel">
+        {mainSection === "elements" && (
+          <div className="card">
+            <h2>要素抽取结果</h2>
+            {hasResult ? (
+              <>
+                {result.reused_existing && (
+                  <p className="upload-reuse-hint" role="status">
+                    本次上传命中库中已有案件(材料指纹或同名同正文)。系统已按最新上传材料重新抽取并覆盖该案件的要素与画像(会生成新的历史版本)。
+                  </p>
+                )}
+                {hierarchyDraft && result?.extracted_elements?.elements_hierarchy ? (
+                  <>
+                    <HierarchyElementsEditor
+                      hierarchy={hierarchyDraft}
+                      primaryCause={
+                        result.extracted_elements.primary_cause_type ||
+                        result.extracted_elements.tmpl_primary_cause
+                      }
+                      onChange={setHierarchyDraft}
+                    />
+                    <div className="hier-save-row">
+                      <button type="button" className="btn-save-hierarchy" onClick={handleSaveHierarchy}>
+                        保存层级要素修改
+                      </button>
+                      {hierarchySaveMsg && <span className="hier-save-msg">{hierarchySaveMsg}</span>}
+                    </div>
+                    <details className="hier-flat-fallback">
+                      <summary>查看其它扁平字段与程序要素(补充)</summary>
+                      <ElementsTable extracted={result.extracted_elements} />
+                    </details>
+                  </>
+                ) : (
+                  <ElementsTable extracted={result.extracted_elements} />
+                )}
+              </>
+            ) : (
+              <pre className="empty-placeholder">尚未分析。请填写案件名称、上传材料后点击「开始分析」,结构化要素将以表格形式显示在此处。</pre>
+            )}
+          </div>
+        )}
+
+        {mainSection === "portrait" && (
+          <div className="card portrait-tab-card">
+            <h2>案件画像</h2>
+            <p className="portrait-tab-intro">标签云与关系图在同一页呈现,便于仲裁员快速把握核心特征与要素关联。</p>
+            <div className="grid portrait-inner-grid">
+              <div className="portrait-inner-block">
+                <h3 className="portrait-inner-title">案件特征标签云</h3>
+                <p className="portrait-caption">字号大小反映要素在材料中的突出程度;法条、案由与请求事项会优先展示。</p>
+                {hasResult && tagCloudItems.length > 0 ? (
+                  <TagCloud items={tagCloudItems} />
+                ) : hasResult ? (
+                  <div className="chart-placeholder" style={{ minHeight: 260 }}>
+                    <p>暂无可视化关键词,请检查材料是否包含足够结构化信息。</p>
+                  </div>
+                ) : (
+                  <div className="chart-placeholder" style={{ minHeight: 260 }}>
+                    <p>分析完成后,将在此以标签云形式集中展示案由、当事人、请求、法条与关键事实等核心特征。</p>
+                  </div>
+                )}
+              </div>
+              <div className="portrait-inner-block">
+                <h3 className="portrait-inner-title">案件要素关系图</h3>
+                <p className="portrait-caption">以本案为中心连接申请人、被申请人、案由、事实与法律依据,便于快速把握争议结构。</p>
+                {hasResult ? (
+                  <div className="relation-wrap">
+                    <CaseRelationGraph elements={elements} caseTitle={result?.case_name || "本案"} />
+                  </div>
+                ) : (
+                  <div className="chart-placeholder" style={{ minHeight: 260 }}>
+                    <p>分析完成后,将在此展示要素之间的关联关系(辐射式结构)。</p>
+                  </div>
+                )}
+              </div>
+            </div>
+          </div>
+        )}
+
+        {mainSection === "similar" && (
+          <div className="card similar-section">
+            <h2>相似案件推荐</h2>
+            {!hasResult && (
+              <div className="chart-placeholder" style={{ height: 260 }}>
+                <p>
+                  尚未分析。请填写案件名称、上传材料后点击「开始分析」,将在此展示相似度排行、类案要素对比、画像分对比、裁决结论与仲裁机构等信息。
+                </p>
+              </div>
+            )}
+
+            {hasResult && (
+              <p className="similar-hint">
+                基于要素标签 Jaccard 相似度匹配历史案件;类案裁决结论与仲裁机构可在数据库字段 <code>ruling_result</code>、<code>arbitration_org</code>{" "}
+                中录入后自动展示。
+              </p>
+            )}
+
+            {hasResult && normalizedSimilar.length > 0 && (
+              <>
+                <div className="similar-controls" role="group" aria-label="相似案件显示控制">
+                  <label className="similar-control-label">
+                    显示数量(Top K)
+                    <select value={similarTopK} onChange={(e) => setSimilarTopK(Number(e.target.value))}>
+                      <option value={3}>3</option>
+                      <option value={5}>5</option>
+                      <option value={10}>10</option>
+
+                    </select>
+                  </label>
+                </div>
+                <h3 className="similar-subtitle">相似度排行(可视化)</h3>
+            <div className="similar-chart-wrap">
+              <ResponsiveContainer width="100%" height={Math.max(200, shownSimilar.length * 48)}>
+                <BarChart layout="vertical" data={similarBarData} margin={{ left: 8, right: 16 }}>
+                  <XAxis type="number" domain={[0, 100]} unit="%" />
+                  <YAxis type="category" dataKey="name" width={88} tick={{ fontSize: 12 }} />
+                  <Tooltip
+                    formatter={(v) => [`${v}%`, "相似度"]}
+                    labelFormatter={(_, p) => p?.[0]?.payload?.fullName || ""}
+                  />
+                  <Bar dataKey="percent" name="相似度(%)" fill="#6366f1" radius={[0, 6, 6, 0]} />
+                </BarChart>
+              </ResponsiveContainer>
+            </div>
+
+            <h3 className="similar-subtitle">本案申请书(合并材料全文)</h3>
+            {(currentBrief.case_cause || currentBrief.applicant_name) && (
+              <div className="brief-chips">
+                {currentBrief.case_cause && <span className="tag">案由:{currentBrief.case_cause}</span>}
+                {currentBrief.applicant_name && <span className="tag">申请人:{currentBrief.applicant_name}</span>}
+                {currentBrief.respondent_name && <span className="tag">被申请人:{currentBrief.respondent_name}</span>}
+                {currentBrief.claims_amount_total != null && (
+                  <span className="tag">请求金额合计:{String(currentBrief.claims_amount_total)}</span>
+                )}
+              </div>
+            )}
+            <button type="button" className="link-btn" onClick={() => setShowCurrentText(!showCurrentText)}>
+              {showCurrentText ? "收起全文" : "展开完整申请书/材料文本"}
+            </button>
+            {result?.current_application_truncated && (
+              <span className="trunc-note">(接口已截断展示超长文本,完整内容已入库)</span>
+            )}
+            {showCurrentText && currentAppText && (
+              <pre className="application-block">{currentAppText}</pre>
+            )}
+            {!currentAppText && hasResult && (
+              <p className="empty-inline">暂无全文(请使用最新后端上传接口以保存 application_text)。</p>
+            )}
+
+            <h3 className="similar-subtitle">类案详情与对比</h3>
+            <div className="similar-cards">
+              {shownSimilar.map((item, idx) => {
+                const isOpen = openSimilarId === item.case_id;
+                return (
+                  <div className="similar-card" key={`${item.case_id}-${idx}`}>
+                    <div className="similar-card-head">
+                      <div>
+                        <div className="similar-title">{item.case_name || `案件 #${item.case_id}`}</div>
+                        <div className="similar-meta">
+                      案件ID {item.case_id} · 约 {item.similarity_percent}%(三维度加权)
+                        </div>
+                      </div>
+                      <div className="similar-progress-wrap">
+                        <div className="similar-progress" style={{ width: `${Math.min(100, item.similarity_percent)}%` }} />
+                      </div>
+                      <button
+                        type="button"
+                        className="link-btn"
+                        onClick={() => setOpenSimilarId(isOpen ? null : item.case_id)}
+                      >
+                        {isOpen ? "收起详情" : "展开详情"}
+                      </button>
+                    </div>
+
+                    {isOpen && (
+                      <div className="similar-card-body">
+                        <div className="similar-compare-stack">
+                          <h4>要素摘要(类案)</h4>
+                          <pre className="brief-pre">{JSON.stringify(item.elements_brief || {}, null, 2)}</pre>
+
+                          <h4 style={{ marginTop: 16 }}>与本案对比(加权相似度 + 层级要素)</h4>
+
+                          <h4 style={{ marginTop: 12 }}>三维度加权相似度(法律/事实/风险)</h4>
+                          <div className="compare-table-wrap">
+                            <table className="compare-table">
+                              <thead>
+                                <tr>
+                                  <th>维度</th>
+                                  <th>权重</th>
+                                  <th>相似度</th>
+                                  <th>加权贡献</th>
+                                </tr>
+                              </thead>
+                              <tbody>
+                                {(item.weighted_similarity_breakdown || []).length ? (
+                                  <>
+                                    {(item.weighted_similarity_breakdown || []).map((r, idx) => {
+                                      const sim = r.similarity == null ? null : Number(r.similarity);
+                                      const w = r.weight == null ? 0 : Number(r.weight);
+                                      const contrib = sim == null ? null : sim * w;
+                                      return (
+                                        <tr key={idx} className={sim != null && sim >= 85 ? "row-match" : ""}>
+                                          <td>{r.dimension}</td>
+                                          <td>{(w * 100).toFixed(0)}%</td>
+                                          <td>{sim == null ? "—" : sim.toFixed(1)}</td>
+                                          <td>{contrib == null ? "—" : contrib.toFixed(1)}</td>
+                                        </tr>
+                                      );
+                                    })}
+                                    <tr className="row-match">
+                                      <td colSpan={2}><strong>总体(加权)</strong></td>
+                                      <td colSpan={2}>
+                                        <strong>
+                                          {item.weighted_similarity_percent == null ? "—" : item.weighted_similarity_percent.toFixed(1)}
+                                        </strong>
+                                      </td>
+                                    </tr>
+                                  </>
+                                ) : (
+                                  <tr>
+                                    <td colSpan={4} style={{ color: "#64748b" }}>
+                                      暂无画像细分指标,无法计算加权相似度。
+                                    </td>
+                                  </tr>
+                                )}
+                              </tbody>
+                            </table>
+                          </div>
+
+                          <h4 style={{ marginTop: 16 }}>层级要素对比(仅展示有内容的要素)</h4>
+                          <div className="compare-table-wrap">
+                            <table className="compare-table">
+                              <thead>
+                                <tr>
+                                  <th style={{ width: "18%" }}>一层要素</th>
+                                  <th style={{ width: "18%" }}>二层要素</th>
+                                  <th style={{ width: "18%" }}>三层要素</th>
+                                  <th style={{ width: "18%" }}>四层要素</th>
+                                  <th style={{ width: "14%" }}>本案</th>
+                                  <th style={{ width: "14%" }}>类案</th>
+                                  <th style={{ width: "6%" }}>一致</th>
+                                </tr>
+                              </thead>
+                              <tbody>
+                                {(item.hierarchy_comparison_with_current || []).length ? (
+                                  (item.hierarchy_comparison_with_current || []).map((row, i) => (
+                                    <tr key={i} className={row.aligned ? "row-match" : ""}>
+                                      <td>{row.path_parts?.[0] ?? ""}</td>
+                                      <td>{row.path_parts?.[1] ?? ""}</td>
+                                      <td>{row.path_parts?.[2] ?? ""}</td>
+                                      <td>{row.path_parts?.[3] ?? ""}</td>
+                                      <td>{row.current == null ? "—" : String(row.current)}</td>
+                                      <td>{row.similar == null ? "—" : String(row.similar)}</td>
+                                      <td>{row.aligned ? "✓" : "—"}</td>
+                                    </tr>
+                                  ))
+                                ) : (
+                                  <tr>
+                                    <td colSpan={7} style={{ color: "#64748b" }}>
+                                      暂无可对比的层级要素(本案与类案层级字段均为空)。
+                                    </td>
+                                  </tr>
+                                )}
+                              </tbody>
+                            </table>
+                          </div>
+                        </div>
+
+                        <h4>画像分对比(本案 vs 类案)</h4>
+                        <div className="portrait-compare-chart">
+                          <ResponsiveContainer width="100%" height={220}>
+                            <BarChart data={portraitCompareData(item)}>
+                              <XAxis dataKey="dim" tick={{ fontSize: 11 }} />
+                              <YAxis domain={[0, 100]} />
+                              <Tooltip />
+                              <Legend />
+                              <Bar dataKey="当前" fill="#2563eb" radius={[4, 4, 0, 0]} />
+                              <Bar dataKey="类案" fill="#a855f7" radius={[4, 4, 0, 0]} />
+                            </BarChart>
+                          </ResponsiveContainer>
+                        </div>
+
+                        <div className="ruling-grid">
+                          <div>
+                            <h4>类案裁决结论</h4>
+                            <p className="ruling-text">{item.ruling_result}</p>
+                          </div>
+                          <div>
+                            <h4>仲裁机构</h4>
+                            <p className="ruling-text">{item.arbitration_org}</p>
+                          </div>
+                        </div>
+
+                        <h4>类案申请书/材料全文</h4>
+                        <p className="preview-text">{item.application_preview || "(无已存储全文)"}</p>
+                        {item.application_text ? (
+                          <pre className="application-block">{item.application_text}</pre>
+                        ) : (
+                          <p className="empty-inline">该类案未保存 application_text(旧数据需重新上传一次材料)。</p>
+                        )}
+                        {item.application_truncated && (
+                          <span className="trunc-note">展示已截断,完整内容以数据库为准。</span>
+                        )}
+                      </div>
+                    )}
+                  </div>
+                );
+              })}
+            </div>
+          </>
+        )}
+
+        {hasResult && normalizedSimilar.length === 0 && (
+          <div className="chart-placeholder" style={{ height: 220 }}>
+            <p>
+              暂无相似案件(可先导入更多历史案件后再分析)。有匹配类案后,将在此显示相似度条形图、对比表与类案全文等信息。
+            </p>
+          </div>
+        )}
+      </div>
+        )}
+
+        {mainSection === "evaluation" && (
+          <div className="card">
+            <h2>系统评估(基于历史案件数据)</h2>
+            <p className="similar-hint">
+              本页展示:要素抽取的 Precision/Recall/F1(当前为 demo 标注集,可替换为真实数据集),以及画像构建的完整性与有效性统计(基于数据库历史案件 portrait 字段)。
+            </p>
+            <div className="similar-controls" style={{ justifyContent: "space-between" }}>
+              <button type="button" className="btn-save-hierarchy" onClick={loadEvaluation} disabled={evalLoading}>
+                {evalLoading ? "加载中..." : "刷新评估结果"}
+              </button>
+              {evalError && <span style={{ color: "#b91c1c", fontSize: 13 }}>{evalError}</span>}
+            </div>
+
+            {!evalMetrics ? (
+              <p className="empty-inline">暂无评估数据。</p>
+            ) : (
+              <>
+                <h3 className="similar-subtitle">一、要素抽取指标(Precision / Recall / F1)</h3>
+                <div className="compare-table-wrap">
+                  <table className="compare-table">
+                    <thead>
+                      <tr>
+                        <th>字段</th>
+                        <th>Precision</th>
+                        <th>Recall</th>
+                        <th>F1</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {Object.entries(evalMetrics.extraction_prf || {})
+                        .filter(([k]) => k !== "_micro_avg")
+                        .slice(0, 40)
+                        .map(([k, v]) => (
+                          <tr key={k}>
+                            <td>{k}</td>
+                            <td>{v?.precision ?? "—"}</td>
+                            <td>{v?.recall ?? "—"}</td>
+                            <td>{v?.f1 ?? "—"}</td>
+                          </tr>
+                        ))}
+                      {evalMetrics.extraction_prf?._micro_avg && (
+                        <tr className="row-match">
+                          <td><strong>_micro_avg</strong></td>
+                          <td><strong>{evalMetrics.extraction_prf._micro_avg.precision}</strong></td>
+                          <td><strong>{evalMetrics.extraction_prf._micro_avg.recall}</strong></td>
+                          <td><strong>{evalMetrics.extraction_prf._micro_avg.f1}</strong></td>
+                        </tr>
+                      )}
+                    </tbody>
+                  </table>
+                </div>
+
+                <h3 className="similar-subtitle">二、画像构建完整性与有效性</h3>
+                <div className="compare-table-wrap">
+                  <table className="compare-table">
+                    <thead>
+                      <tr>
+                        <th>指标</th>
+                        <th>覆盖数</th>
+                        <th>覆盖率</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr>
+                        <td>历史案件总数</td>
+                        <td>{evalMetrics.portrait_quality?.total_cases ?? 0}</td>
+                        <td>—</td>
+                      </tr>
+                      {[
+                        ["has_portrait", "portrait 已生成"],
+                        ["has_scores", "scores 已生成(legal/fact/risk)"],
+                        ["has_subscores", "subscores 已生成(维度细分)"],
+                        ["has_keywords", "keywords 已生成(词云)"],
+                        ["has_tags", "tags 已生成(多级标签)"],
+                        ["has_risk_assessment", "risk_assessment 已生成"],
+                        ["has_complexity_level", "complexity_level 已生成"],
+                      ].map(([key, label]) => (
+                        <tr key={key}>
+                          <td>{label}</td>
+                          <td>{evalMetrics.portrait_quality?.[key]?.count ?? 0}</td>
+                          <td>{evalMetrics.portrait_quality?.[key]?.ratio != null ? `${(evalMetrics.portrait_quality[key].ratio * 100).toFixed(1)}%` : "—"}</td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+
+                <h3 className="similar-subtitle">三、相似度权重(当前配置)</h3>
+                <div className="compare-table-wrap">
+                  <table className="compare-table">
+                    <thead>
+                      <tr>
+                        <th>维度</th>
+                        <th>权重</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      <tr><td>法律维度</td><td>{evalMetrics.weights?.legal ?? "—"}</td></tr>
+                      <tr><td>事实维度</td><td>{evalMetrics.weights?.fact ?? "—"}</td></tr>
+                      <tr><td>风险维度</td><td>{evalMetrics.weights?.risk ?? "—"}</td></tr>
+                    </tbody>
+                  </table>
+                </div>
+              </>
+            )}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export default App;

+ 32 - 0
frontend/src/api.js

@@ -0,0 +1,32 @@
+import axios from "axios";
+
+const api = axios.create({
+  baseURL: "http://localhost:8000",
+});
+
+export async function uploadCase(caseName, files, extractorMode = "rules") {
+  const formData = new FormData();
+  formData.append("case_name", caseName);
+  formData.append("extractor_mode", extractorMode);
+  Array.from(files).forEach((f) => formData.append("files", f));
+  const { data } = await api.post("/api/cases/upload", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data;
+}
+
+export async function getCase(caseId) {
+  const { data } = await api.get(`/api/cases/${caseId}`);
+  return data;
+}
+
+/** 合并 patch 到案件 elements(含 elements_hierarchy) */
+export async function updateCaseElements(caseId, patch) {
+  const { data } = await api.put(`/api/cases/${caseId}/elements`, { patch });
+  return data;
+}
+
+export async function getEvaluationMetrics() {
+  const { data } = await api.get("/api/evaluation/metrics");
+  return data;
+}

+ 10 - 0
frontend/src/main.jsx

@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./styles.css";
+
+ReactDOM.createRoot(document.getElementById("root")).render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>
+);

+ 861 - 0
frontend/src/styles.css

@@ -0,0 +1,861 @@
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  font-family: "Microsoft YaHei", Arial, sans-serif;
+  background: radial-gradient(1200px 500px at 20% 0%, #e0e7ff 0%, rgba(224, 231, 255, 0) 60%),
+    radial-gradient(900px 500px at 85% 10%, #dcfce7 0%, rgba(220, 252, 231, 0) 55%),
+    #f6f7fb;
+  color: #111827;
+}
+
+.container {
+  max-width: 1100px;
+  margin: 20px auto;
+  padding: 0 16px 30px;
+}
+
+h1 {
+  font-size: 28px;
+  margin: 0;
+  letter-spacing: 0.2px;
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 8px 2px 4px;
+}
+
+.subtitle {
+  margin: 6px 0 0;
+  color: #475569;
+  font-size: 14px;
+  line-height: 1.6;
+}
+
+.card {
+  background: #fff;
+  border-radius: 10px;
+  padding: 16px;
+  margin-top: 16px;
+  border: 1px solid rgba(15, 23, 42, 0.06);
+  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
+}
+
+label {
+  display: block;
+  margin: 8px 0 4px;
+  font-weight: 600;
+  color: #0f172a;
+}
+
+input {
+  width: 100%;
+  margin-bottom: 10px;
+  padding: 10px;
+  border: 1px solid #d1d5db;
+  border-radius: 8px;
+  background: #fff;
+  outline: none;
+  transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+input:focus {
+  border-color: rgba(37, 99, 235, 0.55);
+  box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.14);
+}
+
+input[type="file"] {
+  padding: 8px;
+  background: #f8fafc;
+}
+
+button {
+  padding: 10px 16px;
+  border: none;
+  border-radius: 8px;
+  background: #2563eb;
+  color: #fff;
+  cursor: pointer;
+  font-weight: 600;
+  transition: transform 0.05s ease, background 0.15s ease, box-shadow 0.15s ease;
+  box-shadow: 0 10px 18px rgba(37, 99, 235, 0.18);
+}
+
+button:hover:not(:disabled) {
+  background: #1d4ed8;
+}
+
+button:active:not(:disabled) {
+  transform: translateY(1px);
+}
+
+button:disabled {
+  background: #9ca3af;
+  box-shadow: none;
+}
+
+.grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+}
+
+.tags {
+  margin-bottom: 8px;
+}
+
+.tag {
+  display: inline-block;
+  margin-right: 8px;
+  margin-bottom: 8px;
+  background: #e0e7ff;
+  color: #3730a3;
+  border-radius: 999px;
+  padding: 6px 12px;
+  font-size: 13px;
+  border: 1px solid rgba(55, 48, 163, 0.12);
+}
+
+.error {
+  margin-top: 10px;
+  color: #b91c1c;
+  background: #fef2f2;
+  border: 1px solid rgba(185, 28, 28, 0.15);
+  padding: 10px 12px;
+  border-radius: 10px;
+}
+
+/* 主导航:要素 / 画像 / 类案 */
+.main-nav {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 18px;
+  padding: 6px;
+  background: #fff;
+  border-radius: 12px;
+  border: 1px solid rgba(15, 23, 42, 0.08);
+  box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
+}
+
+.main-nav-item {
+  flex: 1 1 140px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 2px;
+  padding: 12px 14px;
+  border-radius: 10px;
+  border: 1px solid transparent;
+  background: #f8fafc;
+  color: #334155;
+  font-size: 14px;
+  font-weight: 600;
+  cursor: pointer;
+  box-shadow: none;
+  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
+}
+
+.main-nav-item:hover:not(:disabled) {
+  background: #eef2ff;
+  color: #1e3a8a;
+  border-color: rgba(99, 102, 241, 0.2);
+}
+
+.main-nav-item.is-active {
+  background: linear-gradient(180deg, #eef2ff 0%, #e0e7ff 100%);
+  color: #1e3a8a;
+  border-color: rgba(79, 70, 229, 0.35);
+  box-shadow: 0 4px 14px rgba(79, 70, 229, 0.12);
+}
+
+.main-nav-sub {
+  display: block;
+  font-size: 11px;
+  font-weight: 500;
+  color: #64748b;
+}
+
+.main-nav-item.is-active .main-nav-sub {
+  color: #4338ca;
+}
+
+.main-panel {
+  margin-top: 0;
+}
+
+.main-panel > .card {
+  margin-top: 14px;
+}
+
+/* 案件画像 Tab:内嵌两栏 */
+.portrait-tab-card h2 {
+  margin-top: 0;
+}
+
+.portrait-tab-intro {
+  margin: 0 0 14px;
+  font-size: 13px;
+  color: #64748b;
+  line-height: 1.55;
+}
+
+.portrait-inner-grid {
+  margin-top: 4px;
+}
+
+.portrait-inner-block {
+  background: #fafbff;
+  border-radius: 10px;
+  padding: 14px;
+  border: 1px solid rgba(15, 23, 42, 0.06);
+}
+
+.portrait-inner-title {
+  font-size: 15px;
+  margin: 0 0 6px;
+  color: #0f172a;
+}
+
+.portrait-inner-block .portrait-caption {
+  margin: 0 0 12px;
+  font-size: 13px;
+  color: #64748b;
+  line-height: 1.55;
+}
+
+/* 要素表格 */
+.elements-table-meta {
+  font-size: 12px;
+  color: #64748b;
+  margin: 0 0 12px;
+}
+
+.element-group-block + .element-group-block {
+  margin-top: 20px;
+}
+
+.element-group-title {
+  font-size: 15px;
+  margin: 0 0 10px;
+  color: #0f172a;
+  padding-bottom: 6px;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.element-subgroup-block {
+  margin-top: 12px;
+  margin-left: 0;
+  padding-left: 12px;
+  border-left: 3px solid #e0e7ff;
+}
+
+.element-subgroup-title {
+  font-size: 14px;
+  margin: 10px 0 8px;
+  color: #334155;
+  font-weight: 600;
+}
+
+.element-subgroup-title-3 {
+  font-size: 13px;
+  margin: 8px 0 6px;
+  color: #475569;
+  font-weight: 600;
+}
+
+.elements-table-scroll {
+  overflow-x: auto;
+  border-radius: 8px;
+  border: 1px solid rgba(15, 23, 42, 0.08);
+}
+
+.elements-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 13px;
+  background: #fff;
+}
+
+.elements-table thead th {
+  text-align: left;
+  background: #f1f5f9;
+  font-weight: 600;
+  color: #0f172a;
+  padding: 10px 12px;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.elements-table tbody td {
+  padding: 10px 12px;
+  border-bottom: 1px solid #f1f5f9;
+  vertical-align: top;
+}
+
+.elements-table tbody tr:last-child td {
+  border-bottom: none;
+}
+
+.elements-table .col-label {
+  width: 28%;
+  min-width: 120px;
+  color: #475569;
+  font-weight: 500;
+}
+
+.elements-table .col-value {
+  color: #111827;
+}
+
+.elements-cell-pre {
+  margin: 0;
+  padding: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  background: transparent;
+  color: inherit;
+  border: none;
+  font-family: inherit;
+  font-size: 13px;
+  line-height: 1.5;
+}
+
+.elements-table-flat code {
+  font-size: 12px;
+  background: #f1f5f9;
+  padding: 2px 6px;
+  border-radius: 4px;
+}
+
+pre {
+  white-space: pre-wrap;
+  background: #0b1220;
+  color: #e5e7eb;
+  border-radius: 8px;
+  padding: 12px;
+  overflow-x: auto;
+  border: 1px solid rgba(148, 163, 184, 0.18);
+}
+
+pre.empty-placeholder {
+  background: #f1f5f9;
+  color: #64748b;
+  border: 1px dashed #cbd5e1;
+  min-height: 120px;
+}
+
+.chart-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #f8fafc;
+  border: 1px dashed #cbd5e1;
+  border-radius: 8px;
+  padding: 16px;
+  color: #64748b;
+  font-size: 14px;
+  text-align: center;
+}
+
+.chart-placeholder p {
+  margin: 0;
+  max-width: 320px;
+  line-height: 1.5;
+}
+
+.empty-inline {
+  color: #94a3b8;
+  font-size: 14px;
+}
+
+.similar-list {
+  margin: 0;
+  padding-left: 20px;
+}
+
+.similar-list li {
+  padding: 6px 0;
+  border-bottom: 1px dashed rgba(148, 163, 184, 0.35);
+}
+
+.similar-list li:last-child {
+  border-bottom: none;
+}
+
+.empty-list-item {
+  list-style: none;
+  margin-left: -20px;
+  color: #64748b;
+  font-size: 14px;
+}
+
+.similar-section .similar-hint {
+  color: #64748b;
+  font-size: 13px;
+  line-height: 1.6;
+  margin: 0 0 12px;
+}
+
+.similar-section code {
+  font-size: 12px;
+  background: #f1f5f9;
+  padding: 1px 6px;
+  border-radius: 4px;
+}
+
+.similar-controls {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
+  margin: 6px 0 12px;
+}
+
+.similar-control-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 13px;
+  color: #475569;
+  font-weight: 600;
+}
+
+.similar-control-label select {
+  padding: 6px 10px;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  background: #fff;
+  font-size: 13px;
+}
+
+.similar-subtitle {
+  font-size: 16px;
+  margin: 18px 0 10px;
+  color: #0f172a;
+}
+
+.similar-chart-wrap {
+  background: #fafafa;
+  border-radius: 10px;
+  padding: 8px 4px;
+  border: 1px solid rgba(15, 23, 42, 0.06);
+}
+
+.link-btn {
+  background: none;
+  border: none;
+  color: #2563eb;
+  font-weight: 600;
+  cursor: pointer;
+  padding: 0;
+  margin: 8px 8px 8px 0;
+  box-shadow: none;
+}
+
+.link-btn:hover:not(:disabled) {
+  text-decoration: underline;
+  background: none;
+  color: #1d4ed8;
+}
+
+.trunc-note {
+  font-size: 12px;
+  color: #94a3b8;
+  margin-left: 6px;
+}
+
+.application-block {
+  max-height: 360px;
+  overflow: auto;
+  margin-top: 10px;
+  font-size: 13px;
+  line-height: 1.55;
+}
+
+.brief-chips {
+  margin: 8px 0;
+}
+
+.similar-cards {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.similar-card {
+  border: 1px solid rgba(15, 23, 42, 0.08);
+  border-radius: 12px;
+  padding: 12px 14px;
+  background: #fafbff;
+}
+
+.similar-card-head {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px;
+}
+
+.similar-title {
+  font-weight: 700;
+  color: #0f172a;
+}
+
+.similar-meta {
+  font-size: 12px;
+  color: #64748b;
+  margin-top: 2px;
+}
+
+.similar-progress-wrap {
+  flex: 1;
+  min-width: 120px;
+  height: 8px;
+  background: #e2e8f0;
+  border-radius: 999px;
+  overflow: hidden;
+}
+
+.similar-progress {
+  height: 100%;
+  background: linear-gradient(90deg, #6366f1, #8b5cf6);
+  border-radius: 999px;
+  transition: width 0.25s ease;
+}
+
+.similar-card-body {
+  margin-top: 14px;
+  padding-top: 14px;
+  border-top: 1px dashed rgba(148, 163, 184, 0.45);
+}
+
+.two-col {
+  display: grid;
+  grid-template-columns: 1fr 1.2fr;
+  gap: 16px;
+}
+
+@media (max-width: 900px) {
+  .two-col {
+    grid-template-columns: 1fr;
+  }
+}
+
+.brief-pre {
+  font-size: 12px;
+  max-height: 220px;
+  overflow: auto;
+}
+
+.compare-table-wrap {
+  overflow-x: auto;
+}
+
+.compare-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 12px;
+}
+
+.similar-compare-stack {
+  display: block;
+}
+
+.similar-compare-stack .brief-pre {
+  max-height: 180px;
+}
+
+.compare-table td,
+.compare-table th {
+  word-break: break-word;
+}
+
+.compare-table th,
+.compare-table td {
+  border: 1px solid #e2e8f0;
+  padding: 8px 10px;
+  text-align: left;
+  vertical-align: top;
+}
+
+.compare-table th {
+  background: #f1f5f9;
+  font-weight: 600;
+}
+
+.compare-table tr.row-match td {
+  background: rgba(16, 185, 129, 0.08);
+}
+
+.portrait-compare-chart {
+  margin: 12px 0;
+}
+
+.ruling-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 14px;
+  margin: 12px 0;
+}
+
+@media (max-width: 900px) {
+  .ruling-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+.ruling-text {
+  margin: 6px 0 0;
+  font-size: 13px;
+  line-height: 1.55;
+  color: #334155;
+  white-space: pre-wrap;
+}
+
+.preview-text {
+  font-size: 13px;
+  color: #475569;
+  line-height: 1.5;
+  margin: 6px 0 8px;
+}
+
+.portrait-card .portrait-caption {
+  margin: 0 0 12px;
+  font-size: 13px;
+  color: #64748b;
+  line-height: 1.55;
+}
+
+.tag-cloud {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: center;
+  gap: 10px 14px;
+  min-height: 240px;
+  padding: 12px 8px;
+  background: linear-gradient(180deg, #fafbff 0%, #f8fafc 100%);
+  border-radius: 10px;
+  border: 1px dashed rgba(99, 102, 241, 0.25);
+}
+
+.tag-cloud-item {
+  display: inline-block;
+  font-weight: 600;
+  line-height: 1.35;
+  padding: 6px 12px;
+  border-radius: 999px;
+  border: 1px solid rgba(15, 23, 42, 0.08);
+  transition: transform 0.12s ease;
+}
+
+.tag-cloud-item:hover {
+  transform: scale(1.04);
+  z-index: 1;
+}
+
+.relation-wrap {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  min-height: 260px;
+  padding: 8px;
+  background: #f8fafc;
+  border-radius: 10px;
+  border: 1px solid rgba(15, 23, 42, 0.06);
+}
+
+.relation-svg {
+  width: 100%;
+  max-width: 440px;
+  height: auto;
+}
+
+.relation-edge {
+  stroke: #94a3b8;
+  stroke-width: 1.5;
+  stroke-dasharray: 4 3;
+}
+
+.relation-node-text {
+  font-size: 11px;
+  font-weight: 600;
+  pointer-events: none;
+}
+
+.relation-hub-text {
+  font-size: 10px;
+  font-weight: 700;
+  pointer-events: none;
+}
+
+@media (max-width: 900px) {
+  .grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+/* 案由层级可编辑表格 */
+.hierarchy-editor-wrap {
+  margin-top: 4px;
+}
+
+.hier-cause-banner {
+  margin: 0 0 14px;
+  font-size: 14px;
+  line-height: 1.6;
+  color: #334155;
+}
+
+.hier-cause-label {
+  margin-right: 8px;
+  color: #64748b;
+}
+
+.hier-cause-hint {
+  display: block;
+  margin-top: 6px;
+  font-size: 12px;
+  color: #94a3b8;
+}
+
+.hierarchy-edit-table .col-hier-path {
+  width: 42%;
+  min-width: 200px;
+  color: #475569;
+  font-size: 13px;
+  line-height: 1.45;
+  vertical-align: middle;
+}
+
+.hierarchy-edit-table .col-hier-sub {
+  width: 18%;
+  min-width: 120px;
+  color: #64748b;
+  font-size: 13px;
+  line-height: 1.45;
+  vertical-align: middle;
+}
+
+.hier-subtable-title {
+  margin: 18px 0 10px;
+  font-size: 15px;
+  font-weight: 700;
+  color: #0f172a;
+}
+
+.hier-subtable-title:first-of-type {
+  margin-top: 4px;
+}
+
+.hierarchy-levels-table .col-hier-level {
+  min-width: 120px;
+  max-width: 220px;
+  color: #475569;
+  font-size: 13px;
+  line-height: 1.45;
+  vertical-align: middle;
+  word-break: break-word;
+}
+
+.hierarchy-levels-table thead th.col-hier-level {
+  background: #f8fafc;
+  font-weight: 600;
+  color: #334155;
+}
+
+.hier-input {
+  width: 100%;
+  box-sizing: border-box;
+  padding: 8px 10px;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  font-size: 13px;
+  background: #fff;
+}
+
+.hier-input:focus {
+  outline: none;
+  border-color: rgba(37, 99, 235, 0.55);
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+}
+
+.hier-save-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-top: 14px;
+  flex-wrap: wrap;
+}
+
+.btn-save-hierarchy {
+  padding: 8px 16px;
+  border-radius: 8px;
+  border: 1px solid #2563eb;
+  background: #eff6ff;
+  color: #1d4ed8;
+  font-weight: 600;
+  cursor: pointer;
+  box-shadow: none;
+}
+
+.btn-save-hierarchy:hover {
+  background: #dbeafe;
+}
+
+.hier-save-msg {
+  font-size: 13px;
+  color: #059669;
+}
+
+.hier-flat-fallback {
+  margin-top: 20px;
+  padding: 10px 12px;
+  background: #f8fafc;
+  border-radius: 8px;
+  border: 1px dashed #cbd5e1;
+}
+
+.hier-flat-fallback summary {
+  cursor: pointer;
+  font-weight: 600;
+  color: #475569;
+}
+
+.upload-reuse-hint {
+  margin: 0 0 14px;
+  padding: 10px 12px;
+  font-size: 13px;
+  line-height: 1.55;
+  color: #92400e;
+  background: #fffbeb;
+  border: 1px solid #fcd34d;
+  border-radius: 8px;
+}
+
+.hierarchy-edit-table .hier-section-head td {
+  background: #f1f5f9;
+  font-weight: 700;
+  font-size: 13px;
+  color: #334155;
+  padding: 10px 12px;
+  border-top: 1px solid #e2e8f0;
+}
+
+.hierarchy-edit-table tbody tr.hier-section-head:first-child td {
+  border-top: none;
+}
+
+.hier-textarea {
+  resize: vertical;
+  min-height: 88px;
+  line-height: 1.5;
+  font-family: inherit;
+}

+ 9 - 0
frontend/vite.config.js

@@ -0,0 +1,9 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    port: 5173,
+  },
+});

+ 1 - 0
nlp-service/app/__init__.py

@@ -0,0 +1 @@
+# Package marker

+ 66 - 0
nlp-service/app/main.py

@@ -0,0 +1,66 @@
+"""
+NLP microservice for labor arbitration element extraction.
+Serves the fine-tuned Chinese RoBERTa multi-task model.
+"""
+
+from fastapi import FastAPI
+
+from app.schemas import ExtractRequest
+from app.services.extractor import ChineseLegalElementExtractor
+
+app = FastAPI(title="Labor NLP Service", version="0.2.0")
+
+# Initialize extractor (loads model on first use)
+_extractor: ChineseLegalElementExtractor | None = None
+_model_available: bool | None = None
+
+
+def get_extractor() -> ChineseLegalElementExtractor:
+    global _extractor
+    if _extractor is None:
+        _extractor = ChineseLegalElementExtractor()
+    return _extractor
+
+
+@app.get("/health")
+def health():
+    global _model_available
+    ext = get_extractor()
+    if _model_available is None:
+        _model_available = ext._use_model
+    return {
+        "status": "ok",
+        "model_available": _model_available,
+        "mode": "bert_multi_task" if _model_available else "rule_fallback",
+    }
+
+
+@app.post("/extract")
+def extract_elements(payload: ExtractRequest):
+    ext = get_extractor()
+    result = ext.extract(payload.text)
+    return result
+
+
+@app.post("/extract/compare")
+def extract_compare(payload: ExtractRequest):
+    """Return extraction results with both model and rule-based methods."""
+    ext = get_extractor()
+    model_result = None
+    rule_result = None
+
+    # Try model extraction
+    if ext._use_model:
+        try:
+            model_result = ext._extract_with_model(payload.text)
+        except Exception:
+            pass
+
+    # Rule-based extraction
+    rule_result = ext._extract_with_rules(payload.text)
+
+    return {
+        "model_result": model_result,
+        "rule_result": rule_result,
+        "model_available": ext._use_model,
+    }

+ 5 - 0
nlp-service/app/schemas.py

@@ -0,0 +1,5 @@
+from pydantic import BaseModel
+
+
+class ExtractRequest(BaseModel):
+    text: str

+ 1 - 0
nlp-service/app/services/__init__.py

@@ -0,0 +1 @@
+# Package marker

+ 303 - 0
nlp-service/app/services/bert_multi_task_model.py

@@ -0,0 +1,303 @@
+"""
+Chinese RoBERTa Multi-Task Model for Labor Arbitration Element Extraction.
+
+Architecture:
+  Chinese RoBERTa (hfl/chinese-roberta-wwm-ext)
+    ├── Token Classification Head → BIO NER (31 labels)
+    ├── Sequence Classification Heads → case cause, contract type, etc.
+    ├── Span QA Head → text span extraction
+    └── Numeric Regression Head → amount prediction
+
+Joint loss: L = λ₁×L_NER + λ₂×L_CLS + λ₃×L_QA + λ₄×L_REG
+"""
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+# NOTE: transformers must be imported BEFORE torch on Windows to avoid segfault
+from transformers import AutoConfig, AutoModel, AutoTokenizer
+from transformers.modeling_outputs import TokenClassifierOutput
+
+import torch
+import torch.nn as nn
+
+
+class ChineseRobertaMultiTask(nn.Module):
+    """Multi-task model based on hfl/chinese-roberta-wwm-ext for
+    joint NER, classification, span extraction, and numeric regression."""
+
+    def __init__(
+        self,
+        model_name: str = "hfl/chinese-roberta-wwm-ext",
+        num_ner_labels: int = 31,
+        num_cause_classes: int = 6,
+        num_contract_classes: int = 4,
+        num_employment_classes: int = 5,
+        num_bool_fields: int = 9,
+        hidden_dropout_prob: float = 0.1,
+        task_weights: Optional[list[float]] = None,
+    ):
+        super().__init__()
+
+        # Shared encoder
+        self.config = AutoConfig.from_pretrained(model_name)
+        self.bert = AutoModel.from_pretrained(model_name)
+        self.hidden_size = self.config.hidden_size
+        self.dropout = nn.Dropout(hidden_dropout_prob)
+
+        # ---- NER Head (token-level BIO classification) ----
+        self.ner_head = nn.Linear(self.hidden_size, num_ner_labels)
+        self.num_ner_labels = num_ner_labels
+
+        # ---- Classification Head: case cause (6 classes) ----
+        self.cause_classifier = nn.Linear(self.hidden_size, num_cause_classes)
+        self.num_cause_classes = num_cause_classes
+
+        # ---- Classification Head: contract type (4 classes) ----
+        self.contract_classifier = nn.Linear(self.hidden_size, num_contract_classes)
+        self.num_contract_classes = num_contract_classes
+
+        # ---- Classification Head: employment type (5 classes) ----
+        self.employment_classifier = nn.Linear(self.hidden_size, num_employment_classes)
+        self.num_employment_classes = num_employment_classes
+
+        # ---- Classification Head: binary/ternary fields (9 fields × 3 classes) ----
+        self.bool_classifiers = nn.ModuleList([
+            nn.Linear(self.hidden_size, 3) for _ in range(num_bool_fields)
+        ])
+        self.num_bool_fields = num_bool_fields
+
+        # ---- Span QA Head (start/end logits) ----
+        self.qa_head = nn.Linear(self.hidden_size, 2)  # start, end
+
+        # ---- Numeric Regression Head ----
+        self.reg_head = nn.Sequential(
+            nn.Linear(self.hidden_size, 128),
+            nn.GELU(),
+            nn.Dropout(0.1),
+            nn.Linear(128, 64),
+            nn.GELU(),
+            nn.Dropout(0.1),
+            nn.Linear(64, 1),  # single scalar output
+        )
+
+        # Task weights for joint training
+        self.task_weights = task_weights or [0.40, 0.25, 0.15, 0.10, 0.10]  # ner, cause, contract, employment, qa
+
+        # Initialize weights
+        self._init_weights()
+
+    def _init_weights(self):
+        """Initialize classifier heads with normal distribution."""
+        for module in [self.ner_head, self.cause_classifier, self.contract_classifier,
+                       self.employment_classifier, self.qa_head]:
+            module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
+            if module.bias is not None:
+                module.bias.data.zero_()
+        for classifier in self.bool_classifiers:
+            classifier.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
+            classifier.bias.data.zero_()
+
+    def forward(
+        self,
+        input_ids: torch.Tensor,
+        attention_mask: torch.Tensor,
+        token_type_ids: Optional[torch.Tensor] = None,
+        ner_labels: Optional[torch.Tensor] = None,
+        cause_label: Optional[torch.Tensor] = None,
+        contract_label: Optional[torch.Tensor] = None,
+        employment_label: Optional[torch.Tensor] = None,
+        bool_labels: Optional[torch.Tensor] = None,  # shape: [batch, 9]
+        qa_start_labels: Optional[torch.Tensor] = None,
+        qa_end_labels: Optional[torch.Tensor] = None,
+        reg_values: Optional[torch.Tensor] = None,
+        reg_mask: Optional[torch.Tensor] = None,  # 1 where reg target is valid
+    ) -> dict[str, Any]:
+        """
+        Forward pass with optional multi-task loss computation.
+        When labels are provided, returns (loss, logits_dict).
+        When labels are None, returns logits_dict only.
+        """
+        # Shared encoder
+        outputs = self.bert(
+            input_ids=input_ids,
+            attention_mask=attention_mask,
+            token_type_ids=token_type_ids,
+        )
+        sequence_output = outputs.last_hidden_state  # [batch, seq_len, hidden]
+        pooled_output = outputs.pooler_output         # [batch, hidden]
+
+        loss = torch.tensor(0.0, device=input_ids.device, dtype=sequence_output.dtype)
+        num_active_tasks = 0
+
+        # ---- NER logits ----
+        ner_logits = self.ner_head(self.dropout(sequence_output))  # [batch, seq, 31]
+
+        if ner_labels is not None:
+            loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
+            ner_loss = loss_fct(
+                ner_logits.view(-1, self.num_ner_labels),
+                ner_labels.view(-1),
+            )
+            loss = loss + self.task_weights[0] * ner_loss
+            num_active_tasks += 1
+
+        # ---- Case cause classification ----
+        cause_logits = self.cause_classifier(self.dropout(pooled_output))  # [batch, 6]
+
+        if cause_label is not None:
+            cause_loss = nn.CrossEntropyLoss()(cause_logits, cause_label)
+            loss = loss + self.task_weights[1] * cause_loss
+            num_active_tasks += 1
+
+        # ---- Contract type classification ----
+        contract_logits = self.contract_classifier(self.dropout(pooled_output))
+
+        if contract_label is not None:
+            contract_loss = nn.CrossEntropyLoss()(contract_logits, contract_label)
+            loss = loss + self.task_weights[2] * contract_loss
+            num_active_tasks += 1
+
+        # ---- Employment type classification ----
+        employment_logits = self.employment_classifier(self.dropout(pooled_output))
+
+        if employment_label is not None:
+            employment_loss = nn.CrossEntropyLoss()(employment_logits, employment_label)
+            loss = loss + self.task_weights[3] * employment_loss
+            num_active_tasks += 1
+
+        # ---- Boolean field classification ----
+        bool_logits_list = []
+        bool_loss_total = torch.tensor(0.0, device=input_ids.device, dtype=sequence_output.dtype)
+        for i, classifier in enumerate(self.bool_classifiers):
+            logits = classifier(self.dropout(pooled_output))  # [batch, 3]
+            bool_logits_list.append(logits)
+            if bool_labels is not None and i < bool_labels.size(1):
+                labels_i = bool_labels[:, i].long()
+                mask_i = (labels_i >= 0)
+                if mask_i.any():
+                    bool_loss_total = bool_loss_total + nn.CrossEntropyLoss()(
+                        logits[mask_i], labels_i[mask_i]
+                    ) / self.num_bool_fields
+
+        if bool_labels is not None:
+            loss = loss + self.task_weights[4] * bool_loss_total
+            num_active_tasks += 1
+
+        # ---- Span QA ----
+        qa_logits = self.qa_head(self.dropout(sequence_output))  # [batch, seq, 2]
+        start_logits, end_logits = qa_logits[..., 0], qa_logits[..., 1]
+
+        if qa_start_labels is not None and qa_end_labels is not None:
+            qa_loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
+            start_loss = qa_loss_fct(start_logits, qa_start_labels)
+            end_loss = qa_loss_fct(end_logits, qa_end_labels)
+            qa_loss = (start_loss + end_loss) / 2.0
+            loss = loss + 0.10 * qa_loss  # relatively small weight for QA
+
+        # ---- Numeric regression ----
+        reg_preds = self.reg_head(self.dropout(pooled_output)).squeeze(-1)  # [batch]
+
+        if reg_values is not None:
+            if reg_mask is not None:
+                mask = reg_mask.bool()
+                if mask.any():
+                    reg_loss = nn.MSELoss()(reg_preds[mask], reg_values[mask])
+                    loss = loss + 0.05 * reg_loss  # small weight for regression
+            else:
+                reg_loss = nn.MSELoss()(reg_preds, reg_values)
+                loss = loss + 0.05 * reg_loss
+
+        return {
+            "loss": loss if num_active_tasks > 0 else None,
+            "ner_logits": ner_logits,
+            "cause_logits": cause_logits,
+            "contract_logits": contract_logits,
+            "employment_logits": employment_logits,
+            "bool_logits": bool_logits_list,
+            "qa_start_logits": start_logits,
+            "qa_end_logits": end_logits,
+            "reg_preds": reg_preds,
+        }
+
+    @classmethod
+    def from_pretrained(cls, model_path: str) -> "ChineseRobertaMultiTask":
+        """Load a trained model from disk."""
+        state_dict = torch.load(
+            f"{model_path}/pytorch_model.bin",
+            map_location="cpu",
+            weights_only=True,
+        )
+        config = json.loads(
+            open(f"{model_path}/model_config.json", encoding="utf-8").read()
+        )
+        model = cls(
+            model_name=config.get("model_name", "hfl/chinese-roberta-wwm-ext"),
+            num_ner_labels=config.get("num_ner_labels", 31),
+            num_cause_classes=config.get("num_cause_classes", 6),
+            num_contract_classes=config.get("num_contract_classes", 4),
+            num_employment_classes=config.get("num_employment_classes", 5),
+            num_bool_fields=config.get("num_bool_fields", 9),
+        )
+        model.load_state_dict(state_dict, strict=False)
+        return model
+
+    def save_pretrained(self, save_path: str):
+        """Save model weights and config."""
+        import os, json
+        os.makedirs(save_path, exist_ok=True)
+        torch.save(self.state_dict(), f"{save_path}/pytorch_model.bin")
+        config = {
+            "model_name": "hfl/chinese-roberta-wwm-ext",
+            "num_ner_labels": self.num_ner_labels,
+            "num_cause_classes": self.num_cause_classes,
+            "num_contract_classes": self.num_contract_classes,
+            "num_employment_classes": self.num_employment_classes,
+            "num_bool_fields": self.num_bool_fields,
+        }
+        with open(f"{save_path}/model_config.json", "w", encoding="utf-8") as f:
+            json.dump(config, f, ensure_ascii=False, indent=2)
+
+    @torch.no_grad()
+    def predict(
+        self,
+        input_ids: torch.Tensor,
+        attention_mask: torch.Tensor,
+        tokenizer: Optional[Any] = None,
+    ) -> dict[str, Any]:
+        """Inference mode: extract elements from text."""
+        self.eval()
+        outputs = self.forward(input_ids=input_ids, attention_mask=attention_mask)
+
+        # NER spans
+        ner_preds = torch.argmax(outputs["ner_logits"], dim=-1)  # [batch, seq]
+
+        # Cause prediction
+        cause_pred = torch.argmax(outputs["cause_logits"], dim=-1)  # [batch]
+
+        # Contract prediction
+        contract_pred = torch.argmax(outputs["contract_logits"], dim=-1)
+
+        # Employment prediction
+        employment_pred = torch.argmax(outputs["employment_logits"], dim=-1)
+
+        # Boolean predictions
+        bool_preds = torch.stack([
+            torch.argmax(logits, dim=-1) for logits in outputs["bool_logits"]
+        ], dim=-1)  # [batch, 9]
+
+        # Regression predictions
+        reg_vals = outputs["reg_preds"]  # [batch]
+
+        return {
+            "ner_predictions": ner_preds.cpu().tolist(),
+            "cause_predictions": cause_pred.cpu().tolist(),
+            "contract_predictions": contract_pred.cpu().tolist(),
+            "employment_predictions": employment_pred.cpu().tolist(),
+            "bool_predictions": bool_preds.cpu().tolist(),
+            "reg_predictions": reg_vals.cpu().tolist(),
+        }
+
+
+import json

+ 312 - 0
nlp-service/app/services/extractor.py

@@ -0,0 +1,312 @@
+"""
+Chinese Legal Element Extractor for Labor Arbitration.
+Uses a fine-tuned Chinese RoBERTa multi-task model (or falls back to rules).
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any, Optional
+
+# NOTE: must be imported before torch on Windows
+import transformers  # noqa: F401
+
+import torch
+
+import os as _os
+DEFAULT_MODEL_PATH = _os.environ.get(
+    "NLP_MODEL_PATH",
+    _os.path.join(_os.path.dirname(__file__), "..", "..", "models", "chinese_roberta_labor_extractor_v2"),
+)
+# Fallback to absolute path if relative doesn't exist
+if not _os.path.isdir(DEFAULT_MODEL_PATH):
+    DEFAULT_MODEL_PATH = "D:/Graduation Project/second_type/nlp-service/models/chinese_roberta_labor_extractor_v2"
+
+# Fallback rule-based extraction patterns (used when model is not available)
+_CAUSE_PATTERNS = [
+    ("生育保险待遇纠纷", ["生育津贴", "生育医疗", "产假工资", "生育保险待遇"]),
+    ("赔偿金纠纷", ["违法解除劳动", "违法终止劳动", "违法辞退", "赔偿金", "2N"]),
+    ("经济补偿金纠纷", ["经济补偿金", "代通知金", "N+1", "离职补偿"]),
+    ("追索劳动报酬", ["追索劳动报酬", "拖欠工资", "工资差额", "加班费", "高温津贴"]),
+    ("工伤保险待遇纠纷", ["工伤保险待遇", "一次性伤残", "停工留薪", "工伤认定"]),
+    ("劳动关系纠纷类", ["确认劳动关系", "未订立书面劳动合同", "二倍工资", "双倍工资"]),
+]
+
+
+class ChineseLegalElementExtractor:
+    """
+    Element extraction for labor arbitration case texts.
+    Uses the trained Chinese RoBERTa multi-task model when available,
+    falling back to rule-based extraction otherwise.
+    """
+
+    def __init__(self, model_path: Optional[str] = None):
+        self._model = None
+        self._tokenizer = None
+        self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+        try:
+            from .bert_multi_task_model import ChineseRobertaMultiTask
+            from transformers import AutoTokenizer
+
+            path = model_path or DEFAULT_MODEL_PATH
+            self._model = ChineseRobertaMultiTask.from_pretrained(path)
+            self._model.to(self._device)
+            self._model.eval()
+
+            model_config_path = f"{path}/model_config.json"
+            import json, os
+            if os.path.exists(model_config_path):
+                with open(model_config_path, encoding="utf-8") as f:
+                    config = json.load(f)
+                model_name = config.get("model_name", "hfl/chinese-roberta-wwm-ext")
+            else:
+                model_name = "hfl/chinese-roberta-wwm-ext"
+
+            self._tokenizer = AutoTokenizer.from_pretrained(
+                path if os.path.exists(f"{path}/vocab.txt") else model_name
+            )
+            self._use_model = True
+        except Exception as e:
+            import traceback
+            print(f"[NLP] Model load failed: {e}")
+            traceback.print_exc()
+            self._use_model = False
+
+    def extract(self, text: str) -> dict[str, Any]:
+        if self._use_model and self._model is not None:
+            return self._extract_with_model(text)
+        return self._extract_with_rules(text)
+
+    @torch.no_grad()
+    def _extract_with_model(self, text: str) -> dict[str, Any]:
+        # Use rules for entity spans (reliable) + model for classification
+        rule_result = self._extract_with_rules(text)
+
+        encoding = self._tokenizer(
+            text,
+            truncation=True,
+            max_length=512,
+            padding="max_length",
+            return_tensors="pt",
+        )
+        input_ids = encoding["input_ids"].to(self._device)
+        attention_mask = encoding["attention_mask"].to(self._device)
+
+        preds = self._model.predict(input_ids, attention_mask, self._tokenizer)
+
+        cause_classes = [
+            "劳动关系纠纷类", "工伤保险待遇纠纷", "追索劳动报酬",
+            "经济补偿金纠纷", "赔偿金纠纷", "生育保险待遇纠纷",
+        ]
+        contract_classes = [
+            "无固定期限劳动合同", "固定期限劳动合同",
+            "未订立书面劳动合同", "未知",
+        ]
+
+        cause_idx = preds["cause_predictions"][0] if preds["cause_predictions"] else 0
+        contract_idx = preds["contract_predictions"][0] if preds["contract_predictions"] else 3
+
+        # Decode NER spans as supplement to rules
+        ner_spans = self._decode_ner_spans(
+            input_ids[0].cpu().tolist(),
+            preds["ner_predictions"][0] if preds["ner_predictions"] else [],
+        )
+
+        # Merge: model classification + rule-based entities
+        result = {
+            "parties": {
+                "applicant_name": (
+                    ner_spans.get("APPLICANT_NAME")
+                    or rule_result.get("parties", {}).get("applicant_name")
+                ),
+                "respondent_name": (
+                    ner_spans.get("RESPONDENT_NAME")
+                    or rule_result.get("parties", {}).get("respondent_name")
+                ),
+                "worker_position": (
+                    ner_spans.get("WORKER_POSITION")
+                    or rule_result.get("parties", {}).get("worker_position")
+                ),
+                "arbitration_org": (
+                    ner_spans.get("ARBITRATION_ORG")
+                    or rule_result.get("parties", {}).get("arbitration_org")
+                ),
+            },
+            "case_cause": {"type": cause_classes[cause_idx]},
+            "tmpl_primary_cause": cause_classes[cause_idx],
+            "facts": {
+                "entry_date": (
+                    ner_spans.get("ENTRY_DATE")
+                    or rule_result.get("facts", {}).get("entry_date")
+                ),
+                "leave_date": (
+                    ner_spans.get("LEAVE_DATE")
+                    or rule_result.get("facts", {}).get("leave_date")
+                ),
+                "filing_date": (
+                    ner_spans.get("FILING_DATE")
+                    or rule_result.get("facts", {}).get("filing_date")
+                ),
+                "month_salary": (
+                    ner_spans.get("MONTH_SALARY")
+                    or rule_result.get("facts", {}).get("month_salary")
+                ),
+                "work_duration_text": (
+                    ner_spans.get("WORK_DURATION")
+                    or rule_result.get("facts", {}).get("work_duration_text")
+                ),
+                "overtime_desc": (
+                    ner_spans.get("OVERTIME_DESC")
+                    or rule_result.get("facts", {}).get("overtime_desc")
+                ),
+                "termination_reason": (
+                    ner_spans.get("TERMINATION_REASON")
+                    or rule_result.get("facts", {}).get("termination_reason")
+                ),
+            },
+            "claims": {
+                "amount_total": (
+                    ner_spans.get("CLAIM_AMOUNT")
+                    or rule_result.get("claims", {}).get("amount_total")
+                ),
+            },
+            "contract_type": contract_classes[contract_idx],
+            "law_refs": rule_result.get("law_refs", []),
+            "evidence_materials": rule_result.get("evidence_materials", []),
+        }
+        return result
+
+    def _decode_ner_spans(
+        self,
+        input_ids: list[int],
+        ner_labels: list[int],
+    ) -> dict[str, Any]:
+        """Decode BIO NER predictions into span text using the tokenizer."""
+        if self._tokenizer is None:
+            return {}
+
+        # Inline BIO decoding (avoids dependency on training/bio_schema.py)
+        _ENTITY_TYPES = [
+            "APPLICANT_NAME", "RESPONDENT_NAME", "ENTRY_DATE", "LEAVE_DATE",
+            "FILING_DATE", "MONTH_SALARY", "CLAIM_AMOUNT", "WORKER_POSITION",
+            "ARBITRATION_ORG", "LAW_REF", "CASE_NUMBER", "EVIDENCE",
+            "TERMINATION_REASON", "OVERTIME_DESC", "WORK_DURATION",
+        ]
+        _id_to_label = {0: "O"}
+        _idx = 1
+        for et in _ENTITY_TYPES:
+            _id_to_label[_idx] = f"B-{et}"
+            _id_to_label[_idx + 1] = f"I-{et}"
+            _idx += 2
+
+        # Convert token IDs back to tokens
+        tokens = self._tokenizer.convert_ids_to_tokens(input_ids)
+        tokens = [t for t in tokens if t not in ("[PAD]", "[CLS]", "[SEP]")]
+
+        # Skip [CLS] label
+        n_skip = len(input_ids) - len(tokens)
+        ner_labels = ner_labels[n_skip:n_skip + len(tokens)]
+        ner_labels = ner_labels[:len(tokens)]
+        while len(ner_labels) < len(tokens):
+            ner_labels.append(0)
+
+        # Decode BIO → entity spans (inline logic)
+        spans: list[dict[str, Any]] = []
+        current_entity = None
+        current_tokens: list[str] = []
+        for i, (token, label_id) in enumerate(zip(tokens, ner_labels)):
+            label = _id_to_label.get(label_id, "O")
+            if label.startswith("B-"):
+                if current_entity is not None:
+                    spans.append({
+                        "entity": current_entity,
+                        "text": "".join(current_tokens),
+                    })
+                current_entity = label[2:]
+                current_tokens = [token]
+            elif label.startswith("I-") and current_entity == label[2:]:
+                current_tokens.append(token)
+            else:
+                if current_entity is not None:
+                    spans.append({
+                        "entity": current_entity,
+                        "text": "".join(current_tokens),
+                    })
+                current_entity = None
+                current_tokens = []
+        if current_entity is not None:
+            spans.append({"entity": current_entity, "text": "".join(current_tokens)})
+
+        result: dict[str, list[str]] = {}
+        for span in spans:
+            text = span["text"].replace("##", "")
+            if span["entity"] not in result:
+                result[span["entity"]] = []
+            result[span["entity"]].append(text)
+
+        return {k: ";".join(v) for k, v in result.items()}
+
+    @staticmethod
+    def _extract_with_rules(text: str) -> dict[str, Any]:
+        """Fallback: rule-based extraction for when model is not available."""
+        if not text or not text.strip():
+            return {
+                "parties": {},
+                "case_cause": {"type": "劳动争议"},
+                "facts": {},
+                "claims": {},
+                "law_refs": [],
+            }
+
+        # Detect cause type
+        cause_type = "劳动争议"
+        for name, keywords in _CAUSE_PATTERNS:
+            if any(kw in text for kw in keywords):
+                cause_type = name
+                break
+
+        # Extract dates
+        date_pattern = r"([12][0-9]{3})[年./-]([01]?[0-9])[月./-]([0-3]?[0-9])[日]?"
+        entry_match = re.search(r"(入职|到岗).{0,10}" + date_pattern, text)
+        leave_match = re.search(r"(离职|解除|终止).{0,10}" + date_pattern, text)
+
+        # Extract amounts
+        amount_match = re.search(r"([0-9]{3,8}(?:\.[0-9]{1,2})?)\s*元", text)
+        salary_match = re.search(r"(月工资|月薪).{0,5}([0-9]{3,6})\s*元", text)
+
+        # Extract legal references
+        law_refs = re.findall(r"《[^》]{2,30}》", text)
+
+        # Extract entity names
+        applicant = re.search(r"申请人[::]\s*([^\n,,。;]{2,20})", text)
+        respondent = re.search(r"被申请人[::]\s*([^\n,,。;]{2,40})", text)
+        position = re.search(r"(岗位|职位|工种)[::]?\s*([^\n,,。;]{2,20})", text)
+
+        return {
+            "parties": {
+                "applicant_name": applicant.group(1).strip() if applicant else None,
+                "respondent_name": respondent.group(1).strip() if respondent else None,
+                "worker_position": position.group(2).strip() if position else None,
+            },
+            "case_cause": {"type": cause_type},
+            "facts": {
+                "entry_date": _format_date(entry_match) if entry_match else None,
+                "leave_date": _format_date(leave_match) if leave_match else None,
+                "month_salary": float(salary_match.group(2)) if salary_match else None,
+                "overtime_desc": "含加班" if "加班" in text else None,
+                "termination_reason": None,
+            },
+            "claims": {
+                "amount_total": float(amount_match.group(1)) if amount_match else None,
+            },
+            "law_refs": list(set(law_refs)) if law_refs else [],
+        }
+
+
+def _format_date(match: re.Match) -> str | None:
+    try:
+        y, m, d = int(match.group(1)), int(match.group(2)), int(match.group(3))
+        return f"{y:04d}-{m:02d}-{d:02d}"
+    except (IndexError, ValueError):
+        return None

+ 53 - 0
nlp-service/app/services/model_loader.py

@@ -0,0 +1,53 @@
+"""
+Singleton model loader for GPU memory-efficient inference.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Optional
+
+# NOTE: must be imported before torch on Windows
+import transformers  # noqa: F401
+
+import torch
+
+from .bert_multi_task_model import ChineseRobertaMultiTask
+
+
+class ModelLoader:
+    """Thread-safe singleton for loading the BERT multi-task model once."""
+
+    _instance: Optional["ModelLoader"] = None
+    _model: Optional[ChineseRobertaMultiTask] = None
+    _model_path: str = ""
+
+    def __new__(cls) -> "ModelLoader":
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+        return cls._instance
+
+    def get_model(self, model_path: str | None = None) -> ChineseRobertaMultiTask:
+        path = model_path or os.environ.get(
+            "MODEL_PATH",
+            os.path.join(os.path.dirname(__file__), "..", "..", "models", "chinese_roberta_labor_extractor"),
+        )
+
+        if self._model is not None and path == self._model_path:
+            return self._model
+
+        self._model_path = path
+        self._model = ChineseRobertaMultiTask.from_pretrained(path)
+        self._model.eval()
+
+        if torch.cuda.is_available():
+            self._model.cuda()
+
+        return self._model
+
+    def clear(self):
+        """Release model from memory."""
+        self._model = None
+        self._model_path = ""
+        if torch.cuda.is_available():
+            torch.cuda.empty_cache()

+ 24 - 0
nlp-service/requirements.txt

@@ -0,0 +1,24 @@
+# Core serving
+fastapi>=0.104.0
+uvicorn[standard]
+pydantic>=2.0
+
+# Model inference
+torch>=2.1.0
+transformers>=4.36.0
+
+# Training
+datasets>=2.14.0
+accelerate>=0.25.0
+peft>=0.7.0
+trl>=0.7.0
+evaluate>=0.4.0
+seqeval>=1.2.5
+scikit-learn>=1.3.0
+tqdm>=4.66.0
+
+# Chinese tokenizer support
+sentencepiece>=0.1.99
+
+# Optional: Qwen LoRA training acceleration
+# pip install unsloth

+ 1 - 0
nlp-service/training/__init__.py

@@ -0,0 +1 @@
+# Training utilities for labor arbitration NLP models

+ 289 - 0
nlp-service/training/augment_data.py

@@ -0,0 +1,289 @@
+"""
+Data augmentation for labor arbitration training data.
+Strategies:
+  1. Synonym replacement (legal domain vocabulary)
+  2. Entity masking and variation
+  3. Qwen synthetic case generation via Ollama
+"""
+
+from __future__ import annotations
+
+import json
+import random
+import re
+import sys
+from pathlib import Path
+from typing import Any
+from copy import deepcopy
+
+BACKEND = Path(__file__).resolve().parent.parent.parent / "backend"
+sys.path.insert(0, str(BACKEND))
+
+TRAINING_DATASET = BACKEND / "data" / "training_dataset.json"
+OUTPUT = BACKEND / "data" / "augmented_dataset.json"
+
+# Legal domain synonym groups
+SYNONYM_MAP = {
+    "工资": ["薪资", "劳动报酬", "薪酬", "工钱"],
+    "加班": ["超时工作", "延时工作", "延长工作时间"],
+    "辞退": ["解雇", "开除", "解除劳动合同"],
+    "经济补偿": ["经济赔偿金", "补偿款", "经济补助"],
+    "仲裁": ["劳动仲裁", "争议仲裁", "调解仲裁"],
+    "申请人": ["申请方", "申请人方", "申诉人"],
+    "被申请人": ["被申请方", "被诉人", "答辩方"],
+    "劳动合同": ["劳动协议", "用工合同", "聘用合同"],
+    "拖欠": ["欠付", "未支付", "拖欠支付", "未付"],
+    "社保": ["社会保险", "五险", "社会保险费"],
+    "工伤": ["因工受伤", "工作伤害", "职业伤害"],
+    "劳动者": ["员工", "职工", "工人", "雇员"],
+    "用人单位": ["雇主", "公司", "企业", "用工单位"],
+    "月薪": ["月工资", "月收入", "月均工资"],
+    "入职": ["到岗", "入职工", "开始工作", "参加工作"],
+    "离职": ["离开公司", "辞职", "解除劳动关系"],
+}
+
+# Company name templates
+COMPANY_TEMPLATES = [
+    "XX市{name}有限公司",
+    "XX省{name}{industry}有限公司",
+    "{name}科技(XX)有限公司",
+    "XX市{name}{industry}厂",
+    "XX新区{name}实业有限公司",
+    "{name}(中国)有限公司XX分公司",
+]
+
+# Person name pool
+SURNAMES = ["张", "李", "王", "刘", "陈", "杨", "赵", "黄", "周", "吴", "徐", "孙", "马", "朱", "胡"]
+GIVEN_NAMES = ["明", "华", "强", "伟", "芳", "丽", "敏", "静", "勇", "军", "磊", "洋", "涛", "鑫", "宇"]
+
+# Case cause templates for Qwen generation
+CAUSE_PROMPT_TEMPLATES = {
+    "劳动关系纠纷类": "确认劳动关系的存在,涉及未签订书面劳动合同,请求二倍工资差额",
+    "工伤保险待遇纠纷": "在工作中受伤,涉及工伤认定、劳动能力鉴定及一次性伤残补助金等工伤保险待遇",
+    "追索劳动报酬": "用人单位拖欠工资、加班费、高温津贴等劳动报酬,要求支付拖欠款项",
+    "经济补偿金纠纷": "因用人单位未依法缴纳社保或未及时足额支付工资,劳动者提出解除劳动合同并要求经济补偿金",
+    "赔偿金纠纷": "用人单位违法解除或终止劳动合同,要求支付违法解除劳动合同赔偿金(2N)",
+    "生育保险待遇纠纷": "女职工因生育未能享受生育津贴和生育医疗费用,要求用人单位支付生育保险待遇",
+}
+
+
+def synonym_replace(text: str, probability: float = 0.3) -> str:
+    """Randomly replace legal terms with synonyms."""
+    result = list(text)
+    for match in re.finditer(r"[一-鿿]{2,6}", text):
+        word = match.group(0)
+        if word in SYNONYM_MAP and random.random() < probability:
+            replacement = random.choice(SYNONYM_MAP[word])
+            result[match.start():match.end()] = list(replacement)
+    return "".join(result)
+
+
+def mask_entity_variations(text: str) -> list[str]:
+    """
+    Generate 2 entity-masked variants of the same text.
+    Replaces names/dates/amounts with different but realistic values.
+    """
+    variants = []
+    for _ in range(2):
+        variant = text
+
+        # Replace company names
+        variant = re.sub(
+            r"XX[市省]?\S{0,8}(?:有限|责任|实业|科技|贸易|工程|装饰)?(?:公司|厂|企业|集团)",
+            lambda m: random.choice(COMPANY_TEMPLATES).format(
+                name=random.choice(["恒达", "鑫源", "瑞丰", "永盛", "华泰", "新锐", "博远"]),
+                industry=random.choice(["电子", "机械", "纺织", "餐饮", "物流", "建筑", "软件"]),
+            ),
+            variant,
+        )
+
+        # Replace XX placeholders with realistic names
+        while "XX" in variant:
+            name = random.choice(SURNAMES) + random.choice(GIVEN_NAMES)
+            variant = variant.replace("XX", name, 1)
+
+        # Replace dates within reasonable range
+        def date_shifter(m: re.Match) -> str:
+            year = int(m.group(1))
+            month = int(m.group(2))
+            day = int(m.group(3))
+            year += random.choice([-1, 0, 1])
+            month = max(1, min(12, month + random.choice([-1, 0, 1])))
+            day = max(1, min(28, day + random.choice([-2, -1, 0, 1, 2])))
+            return f"{year}年{month}月{day}日"
+
+        variant = re.sub(
+            r"([12][0-9]{3})[年./-]([01]?[0-9])[月./-]([0-3]?[0-9])[日]?",
+            date_shifter,
+            variant,
+        )
+
+        variants.append(variant)
+
+    return variants
+
+
+def generate_synthetic_cases_with_qwen(
+    num_per_cause: int = 5,
+    ollama_host: str = "http://localhost:11434",
+    ollama_model: str = "qwen2.5:3b",
+) -> list[dict]:
+    """
+    Use Ollama Qwen to generate synthetic labor arbitration case texts.
+    Generates num_per_cause cases for each of the 6 cause types.
+    """
+    try:
+        import ollama
+    except ImportError:
+        print("  [SKIP] ollama package not installed, skipping synthetic generation")
+        return []
+
+    client = ollama.Client(host=ollama_host)
+    synthetic_cases = []
+
+    for cause_type, description in CAUSE_PROMPT_TEMPLATES.items():
+        for i in range(num_per_cause):
+            prompt = f"""请生成一个虚构的中国劳动仲裁案件申请书文本,要求如下:
+
+案由类型:{cause_type}
+案件特征:{description}
+
+请按以下格式输出完整的仲裁申请书:
+
+申请人:[虚构姓名],[性别],[出生年份]年出生,住址[虚构地址]。
+被申请人:[虚构公司名称],住所[虚构地址]。
+
+请求事项:
+1、[仲裁请求1]
+2、[仲裁请求2]
+3、[仲裁请求3,可选]
+
+事实与理由:
+[包含入职时间、工作岗位、工资标准、争议发生经过的事实描述,不少于200字]
+
+注意:所有信息必须虚构,不能出现真实人名地名。金额使用合理范围(月薪3000-15000元,总请求金额在5000-500000元之间)。日期使用2018-2024年之间的日期。"""
+
+            try:
+                response = client.chat(
+                    model=ollama_model,
+                    messages=[
+                        {"role": "system", "content": "你是一个法律文书生成助手,只输出仲裁申请书正文,不输出其他内容。"},
+                        {"role": "user", "content": prompt},
+                    ],
+                    options={"temperature": 0.8, "num_predict": 2048},
+                )
+                generated_text = response["message"]["content"].strip()
+                # Remove markdown code fences if present
+                generated_text = re.sub(r"^```[^\n]*\n?", "", generated_text)
+                generated_text = re.sub(r"\n```$", "", generated_text)
+
+                if len(generated_text) >= 100:
+                    synthetic_cases.append({
+                        "text": generated_text,
+                        "cause_type": cause_type,
+                        "source": "qwen_synthetic",
+                    })
+                    print(f"  Generated: {cause_type} #{i+1} ({len(generated_text)} chars)")
+            except Exception as e:
+                print(f"  [ERROR] Failed to generate {cause_type} #{i+1}: {e}")
+                continue
+
+    print(f"  Total synthetic cases generated: {len(synthetic_cases)}")
+    return synthetic_cases
+
+
+def augment_dataset(
+    include_synthetic: bool = True,
+    synthetic_per_cause: int = 5,
+) -> dict:
+    """Main augmentation function."""
+    if not TRAINING_DATASET.exists():
+        sys.exit(f"Training dataset not found: {TRAINING_DATASET}")
+
+    data = json.loads(TRAINING_DATASET.read_text(encoding="utf-8"))
+    dataset = data["dataset"]
+
+    augmented = []
+    orig_ids: set[int] = set()
+
+    for case in dataset:
+        orig_ids.add(case["case_id"])
+        augmented.append(case)  # Keep original
+
+        text = case["text"]
+
+        # Strategy 1: Synonym replacement (3x per case)
+        for _ in range(3):
+            variant_text = synonym_replace(text, probability=0.25)
+            if variant_text != text and len(variant_text) >= 100:
+                augmented.append({
+                    **{k: v for k, v in case.items() if k not in ("text", "tokens", "bio_labels", "bio_label_names", "case_id")},
+                    "case_id": case["case_id"] * 1000 + len(augmented),
+                    "text": variant_text,
+                    "tokens": [],  # will be re-tokenized during training
+                    "bio_labels": [],
+                    "bio_label_names": [],
+                    "source": "synonym_aug",
+                    "orig_case_id": case["case_id"],
+                })
+
+        # Strategy 2: Entity-masked variations (2x per case)
+        for variant_text in mask_entity_variations(text):
+            if variant_text != text and len(variant_text) >= 100:
+                augmented.append({
+                    **{k: v for k, v in case.items() if k not in ("text", "tokens", "bio_labels", "bio_label_names", "case_id")},
+                    "case_id": case["case_id"] * 1000 + len(augmented),
+                    "text": variant_text,
+                    "tokens": [],
+                    "bio_labels": [],
+                    "bio_label_names": [],
+                    "source": "entity_mask_aug",
+                    "orig_case_id": case["case_id"],
+                })
+
+    print(f"  Original: {len(orig_ids)} cases")
+    print(f"  After augmentation: {len(augmented)} cases")
+
+    # Strategy 3: Qwen synthetic cases
+    if include_synthetic:
+        synthetic = generate_synthetic_cases_with_qwen(
+            num_per_cause=synthetic_per_cause,
+        )
+        for i, syn in enumerate(synthetic):
+            max_id = max(orig_ids) if orig_ids else 0
+            synthetic_id = (max_id + 1) * 1000 + i
+            augmented.append({
+                "case_id": synthetic_id,
+                "file": f"synthetic_{i}.txt",
+                "text": syn["text"],
+                "tokens": [],
+                "bio_labels": [],
+                "bio_label_names": [],
+                "classification_labels": {
+                    "case_cause": [
+                        "劳动关系纠纷类", "工伤保险待遇纠纷",
+                        "追索劳动报酬", "经济补偿金纠纷",
+                        "赔偿金纠纷", "生育保险待遇纠纷",
+                    ].index(syn["cause_type"]),
+                },
+                "numeric_labels": {},
+                "rule_elements": {},
+                "source": "qwen_synthetic",
+                "cause_type": syn["cause_type"],
+            })
+
+    # Save augmented dataset
+    output = {
+        **{k: v for k, v in data.items() if k != "dataset"},
+        "augmented": True,
+        "total_augmented_cases": len(augmented),
+        "dataset": augmented,
+    }
+    OUTPUT.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"\nAugmented dataset saved: {OUTPUT}")
+    print(f"Total: {len(augmented)} cases")
+    return output
+
+
+if __name__ == "__main__":
+    augment_dataset(include_synthetic=True, synthetic_per_cause=5)

+ 129 - 0
nlp-service/training/bio_schema.py

@@ -0,0 +1,129 @@
+"""
+BIO tagging schema for labor arbitration case element extraction.
+15 entity types, 31 labels total (15 x B/I + O).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+# Entity types with their BIO prefixes
+ENTITY_TYPES = [
+    "APPLICANT_NAME",       # 申请人姓名
+    "RESPONDENT_NAME",      # 被申请人名称
+    "ENTRY_DATE",           # 入职日期
+    "LEAVE_DATE",           # 离职日期
+    "FILING_DATE",          # 立案日期
+    "MONTH_SALARY",         # 月工资标准
+    "CLAIM_AMOUNT",         # 主张金额
+    "WORKER_POSITION",      # 劳动者岗位
+    "ARBITRATION_ORG",      # 仲裁机构
+    "LAW_REF",              # 法律条款引用
+    "CASE_NUMBER",          # 案号
+    "EVIDENCE",             # 证据材料
+    "TERMINATION_REASON",   # 解除原因
+    "OVERTIME_DESC",        # 加班描述
+    "WORK_DURATION",        # 工作年限
+]
+
+# Build label list: O + B-ENTITY + I-ENTITY for each type
+LABELS = ["O"]
+LABEL2ID = {"O": 0}
+for entity in ENTITY_TYPES:
+    b_label = f"B-{entity}"
+    i_label = f"I-{entity}"
+    LABELS.append(b_label)
+    LABELS.append(i_label)
+    LABEL2ID[b_label] = len(LABEL2ID)
+    LABEL2ID[i_label] = len(LABEL2ID)
+
+ID2LABEL = {v: k for k, v in LABEL2ID.items()}
+NUM_LABELS = len(LABELS)
+
+
+def get_entity_spans_from_bio(
+    tokens: list[str],
+    labels: list[int],
+) -> list[dict[str, Any]]:
+    """Convert BIO tag sequence back to entity spans."""
+    spans = []
+    current_entity = None
+    current_tokens = []
+    current_start = -1
+
+    for i, (token, label_id) in enumerate(zip(tokens, labels)):
+        label = ID2LABEL.get(label_id, "O")
+        if label.startswith("B-"):
+            if current_entity is not None:
+                spans.append({
+                    "entity": current_entity,
+                    "text": "".join(current_tokens),
+                    "start": current_start,
+                    "end": i,
+                })
+            current_entity = label[2:]
+            current_tokens = [token]
+            current_start = i
+        elif label.startswith("I-") and current_entity == label[2:]:
+            current_tokens.append(token)
+        else:
+            if current_entity is not None:
+                spans.append({
+                    "entity": current_entity,
+                    "text": "".join(current_tokens),
+                    "start": current_start,
+                    "end": i,
+                })
+            current_entity = None
+            current_tokens = []
+            current_start = -1
+
+    if current_entity is not None:
+        spans.append({
+            "entity": current_entity,
+            "text": "".join(current_tokens),
+            "start": current_start,
+            "end": len(tokens),
+        })
+
+    return spans
+
+
+def spans_to_bio(
+    tokens: list[str],
+    spans: list[dict[str, Any]],
+) -> list[int]:
+    """Convert entity spans to BIO tag sequence."""
+    labels = ["O"] * len(tokens)
+    for span in spans:
+        entity = span["entity"]
+        start = span["start"]
+        end = span["end"]
+        b_key = f"B-{entity}"
+        i_key = f"I-{entity}"
+        if b_key in LABEL2ID:
+            labels[start] = b_key
+            for i in range(start + 1, min(end, len(tokens))):
+                labels[i] = i_key
+    return [LABEL2ID[l] for l in labels]
+
+
+# Mapping from entity types to flat schema field keys (for converting
+# extracted spans into the element dictionary expected by the backend)
+ENTITY_TO_FIELD = {
+    "APPLICANT_NAME": "applicant_name",
+    "RESPONDENT_NAME": "respondent_name",
+    "ENTRY_DATE": "entry_date",
+    "LEAVE_DATE": "leave_date",
+    "FILING_DATE": "filing_date",
+    "MONTH_SALARY": "month_salary",
+    "CLAIM_AMOUNT": "claims",  # merges into claims.amount_total
+    "WORKER_POSITION": "worker_position",
+    "ARBITRATION_ORG": "arbitration_org",
+    "LAW_REF": "law_refs",     # list field
+    "CASE_NUMBER": "case_number",
+    "EVIDENCE": "evidence_materials",  # list field
+    "TERMINATION_REASON": "termination_reason",
+    "OVERTIME_DESC": "overtime_desc",
+    "WORK_DURATION": "work_duration_text",
+}

+ 379 - 0
nlp-service/training/evaluate.py

@@ -0,0 +1,379 @@
+#!/usr/bin/env python3
+"""
+Unified evaluation framework for labor arbitration element extraction.
+Compares: rules, BERT model, Ollama zero-shot, hybrid.
+Produces thesis-ready metrics tables and per-field breakdowns.
+
+Usage:
+  cd nlp-service/training
+  PYTHONPATH="..;../../backend" python evaluate.py \
+      --dataset ../../backend/data/augmented_dataset.json \
+      --output ../../backend/data/eval_results.json
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections import defaultdict
+from pathlib import Path
+from typing import Any
+
+BACKEND = Path(__file__).resolve().parent.parent.parent / "backend"
+sys.path.insert(0, str(BACKEND))
+
+from sklearn.metrics import (
+    classification_report,
+    confusion_matrix,
+    mean_absolute_error,
+    mean_squared_error,
+)
+
+
+def _as_set(value: Any) -> set:
+    """Convert a value to a set for comparison."""
+    if value is None:
+        return set()
+    if isinstance(value, list):
+        return set(str(v).strip() for v in value if v)
+    if isinstance(value, dict):
+        # For claims dict, extract items and amount_total
+        items = set()
+        if "items" in value:
+            items.update(str(v).strip() for v in value["items"] if v)
+        if "amount_total" in value and value["amount_total"] is not None:
+            items.add(str(value["amount_total"]))
+        return items
+    s = str(value).strip()
+    return {s} if s else set()
+
+
+def _as_float(value: Any) -> float | None:
+    """Convert a value to float for numeric comparison."""
+    if value is None:
+        return None
+    try:
+        return float(value)
+    except (ValueError, TypeError):
+        return None
+
+
+def compute_field_f1(gold: Any, pred: Any) -> dict[str, float]:
+    """Compute P/R/F1 for a single field using set comparison."""
+    gold_set = _as_set(gold)
+    pred_set = _as_set(pred)
+
+    tp = len(gold_set & pred_set)
+    fp = len(pred_set - gold_set)
+    fn = len(gold_set - pred_set)
+
+    precision = tp / max(tp + fp, 1)
+    recall = tp / max(tp + fn, 1)
+    f1 = 2 * precision * recall / max(precision + recall, 1e-8)
+
+    return {"precision": precision, "recall": recall, "f1": f1, "tp": tp, "fp": fp, "fn": fn}
+
+
+def evaluate_all_fields(
+    golds: list[dict],
+    preds: list[dict],
+    field_names: list[str],
+) -> dict[str, Any]:
+    """Evaluate all fields across all cases. Returns per-field and micro-average metrics."""
+    per_field: dict[str, dict] = defaultdict(lambda: {"tp": 0, "fp": 0, "fn": 0})
+
+    for gold, pred in zip(golds, preds):
+        for field in field_names:
+            g = gold.get(field)
+            p = pred.get(field)
+            scores = compute_field_f1(g, p)
+            for k in ("tp", "fp", "fn"):
+                per_field[field][k] += scores[k]
+
+    # Compute per-field metrics
+    results = {}
+    total_tp = total_fp = total_fn = 0
+    for field, counts in per_field.items():
+        p = counts["tp"] / max(counts["tp"] + counts["fp"], 1)
+        r = counts["tp"] / max(counts["tp"] + counts["fn"], 1)
+        f1 = 2 * p * r / max(p + r, 1e-8)
+        results[field] = {"precision": round(p, 4), "recall": round(r, 4), "f1": round(f1, 4)}
+        total_tp += counts["tp"]
+        total_fp += counts["fp"]
+        total_fn += counts["fn"]
+
+    # Micro average
+    micro_p = total_tp / max(total_tp + total_fp, 1)
+    micro_r = total_tp / max(total_tp + total_fn, 1)
+    micro_f1 = 2 * micro_p * micro_r / max(micro_p + micro_r, 1e-8)
+
+    return {
+        "per_field": results,
+        "micro_avg": {
+            "precision": round(micro_p, 4),
+            "recall": round(micro_r, 4),
+            "f1": round(micro_f1, 4),
+        },
+    }
+
+
+def evaluate_numeric_fields(
+    golds: list[dict],
+    preds: list[dict],
+    num_field_names: list[str],
+) -> dict[str, dict[str, float]]:
+    """Evaluate numeric fields with MAE and RMSE."""
+    results = {}
+    for field in num_field_names:
+        y_true = []
+        y_pred = []
+        for gold, pred in zip(golds, preds):
+            g = _as_float(gold.get(field))
+            p = _as_float(pred.get(field))
+            if g is not None and p is not None:
+                y_true.append(g)
+                y_pred.append(p)
+
+        if y_true:
+            results[field] = {
+                "mae": round(mean_absolute_error(y_true, y_pred), 2),
+                "rmse": round(mean_squared_error(y_true, y_pred) ** 0.5, 2),
+                "count": len(y_true),
+            }
+
+    return results
+
+
+def evaluate_cause_accuracy(
+    golds: list[dict],
+    preds: list[dict],
+) -> dict[str, Any]:
+    """Evaluate case cause classification accuracy."""
+    cause_keys = ["tmpl_primary_cause", "primary_cause_type", "case_cause"]
+
+    y_true_strs = []
+    y_pred_strs = []
+    for gold, pred in zip(golds, preds):
+        g = None
+        p = None
+        for k in cause_keys:
+            g = g or gold.get(k)
+            p = p or pred.get(k)
+        if isinstance(g, dict):
+            g = g.get("type")
+        if isinstance(p, dict):
+            p = p.get("type")
+        y_true_strs.append(str(g) if g else "未知")
+        y_pred_strs.append(str(p) if p else "未知")
+
+    labels = sorted(set(y_true_strs + y_pred_strs))
+    accuracy = sum(1 for t, p in zip(y_true_strs, y_pred_strs) if t == p) / max(len(y_true_strs), 1)
+
+    return {
+        "accuracy": round(accuracy, 4),
+        "labels": labels,
+        "per_label": classification_report(
+            y_true_strs, y_pred_strs, labels=labels,
+            output_dict=True, zero_division=0,
+        ),
+    }
+
+
+def run_full_evaluation(
+    test_cases: list[dict],
+    extraction_methods: dict[str, callable],
+    field_names: list[str],
+    numeric_field_names: list[str] | None = None,
+) -> dict[str, Any]:
+    """
+    Run full evaluation comparing all extraction methods.
+
+    Args:
+        test_cases: List of dicts with 'text' and 'rule_elements' (gold labels)
+        extraction_methods: Dict of method_name -> extractor callable
+        field_names: Field names to evaluate
+        numeric_field_names: Subset of fields that are numeric
+    """
+    if numeric_field_names is None:
+        numeric_field_names = ["month_salary"]
+
+    results = {}
+
+    for method_name, extract_fn in extraction_methods.items():
+        print(f"\n{'='*60}")
+        print(f"Evaluating: {method_name}")
+        print(f"{'='*60}")
+
+        preds = []
+        for case in test_cases:
+            try:
+                pred = extract_fn(case["text"])
+                preds.append(pred)
+            except Exception as e:
+                print(f"  ERROR on case {case.get('case_id', '?')}: {e}")
+                preds.append({})
+
+        golds = [case.get("rule_elements", {}) for case in test_cases]
+
+        # Field-level F1
+        field_results = evaluate_all_fields(golds, preds, field_names)
+        print(f"  Micro-F1: {field_results['micro_avg']['f1']:.4f}")
+
+        # Numeric evaluation
+        num_results = evaluate_numeric_fields(golds, preds, numeric_field_names)
+        if num_results:
+            print(f"  Numeric fields evaluated: {list(num_results.keys())}")
+
+        # Cause accuracy
+        cause_results = evaluate_cause_accuracy(golds, preds)
+        print(f"  Cause accuracy: {cause_results['accuracy']:.4f}")
+
+        results[method_name] = {
+            "field_metrics": field_results,
+            "numeric_metrics": num_results,
+            "cause_accuracy": cause_results,
+        }
+
+    # Summary comparison table
+    summary = {}
+    for method, res in results.items():
+        summary[method] = {
+            "micro_f1": res["field_metrics"]["micro_avg"]["f1"],
+            "micro_precision": res["field_metrics"]["micro_avg"]["precision"],
+            "micro_recall": res["field_metrics"]["micro_avg"]["recall"],
+            "cause_accuracy": res["cause_accuracy"]["accuracy"],
+        }
+        # Average MAE for numeric fields
+        maes = [v["mae"] for v in res["numeric_metrics"].values() if "mae" in v]
+        if maes:
+            summary[method]["avg_mae"] = round(sum(maes) / len(maes), 2)
+
+    results["_summary"] = summary
+
+    print(f"\n{'='*60}")
+    print("SUMMARY COMPARISON TABLE")
+    print(f"{'='*60}")
+    print(f"{'Method':<20} {'Micro-F1':>10} {'Precision':>10} {'Recall':>10} {'Cause Acc':>10} {'Avg MAE':>10}")
+    print("-" * 65)
+    for method, s in summary.items():
+        print(f"{method:<20} {s['micro_f1']:>10.4f} {s['micro_precision']:>10.4f} "
+              f"{s['micro_recall']:>10.4f} {s['cause_accuracy']:>10.4f} "
+              f"{s.get('avg_mae', 0):>10.2f}")
+
+    return results
+
+
+# Default key field names to evaluate
+DEFAULT_FIELDS = [
+    "applicant_name", "respondent_name", "employer_nature",
+    "worker_position", "entry_date", "leave_date",
+    "month_salary", "case_cause", "contract_type",
+    "termination_reason", "employment_type",
+    "overtime_desc", "work_duration_text",
+    "law_refs", "evidence_materials", "claims",
+]
+
+DEFAULT_NUMERIC_FIELDS = [
+    "month_salary", "lr1_pay_amount", "sr_claim_amount",
+    "ec_claim_amount", "dm_claim_amount",
+]
+
+
+def load_test_cases(dataset_path: str) -> list[dict]:
+    """Load test cases from dataset (only original, non-augmented cases)."""
+    with open(dataset_path, encoding="utf-8") as f:
+        data = json.load(f)
+
+    return [c for c in data["dataset"] if "source" not in c]
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Evaluate extraction methods")
+    parser.add_argument("--dataset", required=True, help="Path to dataset JSON")
+    parser.add_argument("--output", default="eval_results.json", help="Output path for results")
+    parser.add_argument("--methods", default="rules", help="Comma-separated: rules,bert,ollama,hybrid")
+    args = parser.parse_args()
+
+    # Load test cases (originals only)
+    test_cases = load_test_cases(args.dataset)
+    print(f"Loaded {len(test_cases)} test cases")
+
+    # Build extraction methods
+    methods: dict[str, callable] = {}
+    method_names = [m.strip() for m in args.methods.split(",")]
+
+    if "rules" in method_names:
+        from app.extractor import RuleBasedLaborExtractor
+        rule_ext = RuleBasedLaborExtractor()
+        methods["rules"] = rule_ext.extract
+
+    if "bert" in method_names or "hybrid" in method_names:
+        import requests
+
+        def bert_extract(text: str) -> dict:
+            try:
+                resp = requests.post(
+                    "http://localhost:8001/extract",
+                    json={"text": text},
+                    timeout=60,
+                )
+                if resp.status_code == 200:
+                    return resp.json()
+            except Exception:
+                pass
+            # Fallback to rules
+            from app.extractor import RuleBasedLaborExtractor
+            return RuleBasedLaborExtractor().extract(text)
+
+        methods["bert"] = bert_extract
+
+    if "ollama" in method_names or "hybrid" in method_names:
+        try:
+            from app.anj import OllamaClaimsExtractor
+            from app.config import settings
+            ollama_ext = OllamaClaimsExtractor(
+                settings.ollama_base_url,
+                settings.ollama_model_name,
+            )
+            from app.extractor import RuleBasedLaborExtractor
+            rule = RuleBasedLaborExtractor()
+
+            def ollama_extract(text: str) -> dict:
+                base = rule.extract(text)
+                try:
+                    base["claims"] = ollama_ext.extract_claims(text)
+                    template = ollama_ext.extract_dispute_template_fields(text)
+                    for k, v in template.items():
+                        if v is not None and v != "":
+                            base[k] = v
+                except Exception:
+                    pass
+                return base
+
+            methods["ollama"] = ollama_extract
+        except Exception as e:
+            print(f"  [SKIP] Ollama not available: {e}")
+
+    # Run evaluation
+    results = run_full_evaluation(
+        test_cases,
+        methods,
+        DEFAULT_FIELDS,
+        DEFAULT_NUMERIC_FIELDS,
+    )
+
+    # Save results
+    output_path = Path(args.output)
+    if not output_path.is_absolute():
+        output_path = Path(__file__).resolve().parent / args.output
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    output_path.write_text(
+        json.dumps(results, ensure_ascii=False, indent=2, default=str),
+        encoding="utf-8",
+    )
+    print(f"\nResults saved to: {output_path}")
+
+
+if __name__ == "__main__":
+    main()

+ 361 - 0
nlp-service/training/prepare_training_data.py

@@ -0,0 +1,361 @@
+"""
+Auto-labeling pipeline: runs the rule-based extractor on all cleaned cases,
+converts extracted fields to BIO token-level labels plus classification labels,
+and outputs a training dataset ready for model training.
+
+Run from project root:
+  python -m nlp-service.training.prepare_training_data
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Any
+
+# Ensure backend is on path
+BACKEND = Path(__file__).resolve().parent.parent.parent / "backend"
+sys.path.insert(0, str(BACKEND))
+
+from app.extractor import (
+    RuleBasedLaborExtractor,
+    _clean_text,
+    parse_amount,
+)
+
+from bio_schema import (
+    ENTITY_TYPES,
+    ENTITY_TO_FIELD,
+    LABEL2ID,
+    NUM_LABELS,
+)
+
+RAW_CORPUS = BACKEND / "data" / "raw_corpus.json"
+OUTPUT = BACKEND / "data" / "training_dataset.json"
+
+CASE_CAUSE_CLASSES = [
+    "劳动关系纠纷类",
+    "工伤保险待遇纠纷",
+    "追索劳动报酬",
+    "经济补偿金纠纷",
+    "赔偿金纠纷",
+    "生育保险待遇纠纷",
+]
+
+CONTRACT_TYPE_CLASSES = [
+    "无固定期限劳动合同",
+    "固定期限劳动合同",
+    "未订立书面劳动合同",
+    "未知",
+]
+
+EMPLOYMENT_TYPE_CLASSES = [
+    "劳务派遣",
+    "非全日制用工",
+    "全日制用工",
+    "劳务关系",
+    "未知",
+]
+
+
+def _find_span_position(text: str, value: str) -> tuple[int, int] | None:
+    """Find the character position of a value in the text. Returns (start, end)."""
+    if not value or not text:
+        return None
+    idx = text.find(str(value))
+    if idx >= 0:
+        return idx, idx + len(str(value))
+    return None
+
+
+def _find_spans_for_list(text: str, values: list[str]) -> list[tuple[int, int, str]]:
+    """Find character positions for a list of values. Returns [(start, end, value), ...]."""
+    spans = []
+    for val in values:
+        pos = _find_span_position(text, val)
+        if pos:
+            spans.append((pos[0], pos[1], val))
+    return spans
+
+
+def _char_spans_to_token_bio(
+    text: str,
+    char_spans: list[tuple[int, int, str, str]],  # (start, end, value, entity_type)
+) -> tuple[list[str], list[int]]:
+    """
+    Convert character-level entity spans to token-level BIO labels.
+    Uses a simple character-level tokenization approach (since Chinese
+    has no natural word boundaries, we split by whitespace and then by
+    individual characters within each segment).
+    """
+    # Tokenize: keep characters together, split on whitespace
+    # For Chinese text, tokens will be individual Chinese characters
+    # with multi-character sequences kept together when they form words
+    # between whitespace boundaries
+    tokens: list[str] = []
+    token_char_starts: list[int] = []
+    token_char_ends: list[int] = []
+
+    for m in re.finditer(r"[^\s]+|\s+", text):
+        word = m.group(0)
+        if word.strip():
+            # For Chinese, split each character as a token
+            for i, ch in enumerate(word):
+                tokens.append(ch)
+                token_char_starts.append(m.start() + i)
+                token_char_ends.append(m.start() + i + 1)
+        else:
+            # Keep whitespace as single token
+            tokens.append(word)
+            token_char_starts.append(m.start())
+            token_char_ends.append(m.end())
+
+    # Initialize all labels as O
+    labels = [LABEL2ID["O"]] * len(tokens)
+
+    # Mark BIO labels based on character spans
+    for char_start, char_end, _value, entity_type in char_spans:
+        b_key = f"B-{entity_type}"
+        i_key = f"I-{entity_type}"
+        if b_key not in LABEL2ID:
+            continue
+
+        first_token_idx = None
+        for ti, (ts, te) in enumerate(zip(token_char_starts, token_char_ends)):
+            if ts >= char_start and te <= char_end:
+                if first_token_idx is None:
+                    first_token_idx = ti
+                    labels[ti] = LABEL2ID[b_key]
+                else:
+                    labels[ti] = LABEL2ID[i_key]
+
+    return tokens, labels
+
+
+def _extract_spans_from_elements(text: str, elements: dict[str, Any]) -> list[tuple[int, int, str, str]]:
+    """
+    Convert extracted elements dictionary into character-level entity spans.
+    Returns list of (char_start, char_end, value_string, entity_type).
+    """
+    char_spans: list[tuple[int, int, str, str]] = []
+
+    # Direct string fields
+    str_fields = [
+        ("applicant_name", "APPLICANT_NAME"),
+        ("respondent_name", "RESPONDENT_NAME"),
+        ("entry_date", "ENTRY_DATE"),
+        ("leave_date", "LEAVE_DATE"),
+        ("filing_date", "FILING_DATE"),
+        ("arbitration_org", "ARBITRATION_ORG"),
+        ("worker_position", "WORKER_POSITION"),
+        ("case_number", "CASE_NUMBER"),
+        ("termination_reason", "TERMINATION_REASON"),
+        ("overtime_desc", "OVERTIME_DESC"),
+        ("work_duration_text", "WORK_DURATION"),
+    ]
+
+    for field, entity_type in str_fields:
+        val = elements.get(field)
+        if val and isinstance(val, str) and val.strip():
+            pos = _find_span_position(text, val.strip())
+            if pos:
+                char_spans.append((pos[0], pos[1], val.strip(), entity_type))
+
+    # Numeric fields (need to convert to string for span matching)
+    num_fields = [
+        ("month_salary", "MONTH_SALARY"),
+    ]
+    for field, entity_type in num_fields:
+        val = elements.get(field)
+        if val is not None and val != "":
+            val_str = str(int(val)) if isinstance(val, float) and val == int(val) else str(val)
+            pos = _find_span_position(text, val_str)
+            if pos:
+                char_spans.append((pos[0], pos[1], val_str, entity_type))
+
+    # List fields (law_refs, evidence_materials)
+    law_refs = elements.get("law_refs") or []
+    for law in law_refs:
+        pos = _find_span_position(text, law)
+        if pos:
+            char_spans.append((pos[0], pos[1], law, "LAW_REF"))
+
+    evidence = elements.get("evidence_materials") or []
+    for ev in evidence:
+        pos = _find_span_position(text, ev)
+        if pos:
+            char_spans.append((pos[0], pos[1], ev, "EVIDENCE"))
+
+    # Claims amounts
+    claims = elements.get("claims") or {}
+    if isinstance(claims, dict):
+        amount = claims.get("amount_total")
+        if amount is not None:
+            amount_str = str(int(amount)) if isinstance(amount, float) else str(amount)
+            pos = _find_span_position(text, amount_str)
+            if pos:
+                char_spans.append((pos[0], pos[1], amount_str, "CLAIM_AMOUNT"))
+
+    return char_spans
+
+
+def _extract_classification_labels(elements: dict[str, Any]) -> dict[str, int]:
+    """Convert element fields to classification label indices."""
+    labels = {}
+
+    # Primary cause type (6 classes)
+    cause = elements.get("tmpl_primary_cause") or elements.get("primary_cause_type") or "劳动关系纠纷类"
+    if cause in CASE_CAUSE_CLASSES:
+        labels["case_cause"] = CASE_CAUSE_CLASSES.index(cause)
+    else:
+        labels["case_cause"] = 0
+
+    # Contract type (4 classes)
+    ct = elements.get("contract_type") or "未知"
+    if ct in CONTRACT_TYPE_CLASSES:
+        labels["contract_type"] = CONTRACT_TYPE_CLASSES.index(ct)
+    else:
+        labels["contract_type"] = 3
+
+    # Employment type (5 classes)
+    et = elements.get("employment_type") or "未知"
+    if et in EMPLOYMENT_TYPE_CLASSES:
+        labels["employment_type"] = EMPLOYMENT_TYPE_CLASSES.index(et)
+    else:
+        labels["employment_type"] = 4
+
+    # Binary/ternary fields
+    binary_fields = {
+        "lr1_contract_signed": {"是": 0, "否": 1, None: 2},
+        "lr1_open_ended_contract": {"是": 0, "否": 1, None: 2},
+        "lr1_double_wage_no_contract": {"是": 0, "否": 1, None: 2},
+        "lr1_si_joined": {"是": 0, "否": 1, None: 2},
+        "wi_si_joined": {"是": 0, "否": 1, None: 2},
+        "wi_recognize_ec": {"是": 0, "否": 1, None: 2},
+        "dm_contract_exists": {"是": 0, "否": 1, None: 2},
+        "dm_contract_continue": {"是": 0, "否": 1, None: 2},
+        "mi_contract_continue": {"是": 0, "否": 1, None: 2},
+    }
+    for field, mapping in binary_fields.items():
+        raw = elements.get(field)
+        labels[f"bool_{field}"] = mapping.get(raw, 2)
+
+    return labels
+
+
+def _extract_numeric_labels(elements: dict[str, Any]) -> dict[str, float | None]:
+    """Extract numeric field values for regression targets."""
+    amount_keys = [
+        "month_salary",
+        "lr1_pay_amount", "lr1_si_benefit_amount",
+        "wi_benefit_amount_total", "wi_benefit_disability",
+        "wi_benefit_prosthetic", "wi_benefit_medical_allowance",
+        "wi_benefit_travel", "wi_benefit_rehab", "wi_benefit_nursing",
+        "wi_benefit_meal", "wi_si_benefit_amt",
+        "sr_claim_amount", "sr_claim_deducted_pay",
+        "sr_claim_overtime_pay", "sr_claim_living_allowance",
+        "sr_high_temp_allowance", "sr_annual_leave_pay",
+        "sr_overtime_amount",
+        "ec_avg_salary_12m", "ec_claim_amount",
+        "ec_double_wage_part", "ec_illegal_term_part",
+        "ec_illegal_probation_part", "ec_extra_compensation_part",
+        "ec_notice_pay", "ec_additional_damages",
+        "dm_claim_amount", "dm_illegal_dismissal_damages",
+        "mi_claim_amount", "mi_maternity_medical",
+        "mi_maternity_allowance_salary", "mi_additional_damages",
+        "mi_travel_accommodation",
+    ]
+
+    nums: dict[str, float | None] = {}
+    for key in amount_keys:
+        val = elements.get(key)
+        if val is not None and val != "":
+            try:
+                nums[key] = float(val)
+            except (ValueError, TypeError):
+                nums[key] = None
+        else:
+            nums[key] = None
+
+    return nums
+
+
+def generate_training_dataset() -> dict:
+    """Main function: load corpus, auto-label, output training dataset."""
+    if not RAW_CORPUS.exists():
+        sys.exit(f"Raw corpus not found. Run prepare_dataset.py first.\n  Missing: {RAW_CORPUS}")
+
+    corpus = json.loads(RAW_CORPUS.read_text(encoding="utf-8"))
+    cases = corpus["cases"]
+
+    if not cases:
+        sys.exit("No valid cases in corpus.")
+
+    extractor = RuleBasedLaborExtractor()
+    dataset = []
+
+    for case in cases:
+        text = case["text"]
+        cleaned = _clean_text(text)
+
+        # Run rule-based extraction
+        try:
+            elements = extractor.extract(cleaned)
+        except Exception as e:
+            print(f"  ERROR extracting case {case['case_id']}: {e}")
+            elements = {}
+
+        # Generate BIO spans
+        char_spans = _extract_spans_from_elements(cleaned, elements)
+        tokens, bio_labels = _char_spans_to_token_bio(cleaned, char_spans)
+
+        # Generate classification labels
+        cls_labels = _extract_classification_labels(elements)
+
+        # Generate numeric labels
+        num_labels = _extract_numeric_labels(elements)
+
+        dataset.append({
+            "case_id": case["case_id"],
+            "file": case["file"],
+            "text": cleaned,
+            "tokens": tokens,
+            "bio_labels": bio_labels,
+            "bio_label_names": [
+                {v: k for k, v in LABEL2ID.items()}.get(l, "O")
+                for l in bio_labels
+            ],
+            "classification_labels": cls_labels,
+            "numeric_labels": num_labels,
+            "rule_elements": elements,
+        })
+
+        # Count entities found
+        n_entities = sum(1 for l in bio_labels if l != LABEL2ID["O"])
+        print(f"  Case {case['case_id']:>3}: {len(tokens):>5} tokens, "
+              f"{len(char_spans):>3} spans, {n_entities:>4} labeled entities")
+
+    # Save dataset
+    output = {
+        "version": "1.0",
+        "total_cases": len(dataset),
+        "label_schema": {
+            "num_bio_labels": NUM_LABELS,
+            "bio_label_to_id": LABEL2ID,
+            "case_cause_classes": CASE_CAUSE_CLASSES,
+            "contract_type_classes": CONTRACT_TYPE_CLASSES,
+            "employment_type_classes": EMPLOYMENT_TYPE_CLASSES,
+        },
+        "dataset": dataset,
+    }
+
+    OUTPUT.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
+    print(f"\nTraining dataset saved: {OUTPUT}")
+    print(f"Total cases: {len(dataset)}")
+    return output
+
+
+if __name__ == "__main__":
+    generate_training_dataset()

+ 386 - 0
nlp-service/training/train_bert.py

@@ -0,0 +1,386 @@
+#!/usr/bin/env python3
+"""
+Training script for Chinese RoBERTa Multi-Task Labor Arbitration Extractor.
+
+Usage:
+  cd nlp-service/training
+  PYTHONPATH="..;../../backend" python train_bert.py \
+      --dataset ../../backend/data/augmented_dataset.json \
+      --output ../models/chinese_roberta_labor_extractor \
+      --epochs 10 --batch_size 8 --lr 2e-5
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import random
+import sys
+from pathlib import Path
+from typing import Any
+
+# NOTE: transformers before torch on Windows
+from transformers import (
+    AutoTokenizer,
+    get_linear_schedule_with_warmup,
+)
+
+import torch
+import torch.nn as nn
+from torch.utils.data import Dataset, DataLoader
+from tqdm import tqdm
+
+# Add parent dirs for imports
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+from app.services.bert_multi_task_model import ChineseRobertaMultiTask
+from training.bio_schema import LABEL2ID, NUM_LABELS
+
+
+class LaborCaseDataset(Dataset):
+    """PyTorch Dataset for labor arbitration case element extraction."""
+
+    def __init__(
+        self,
+        cases: list[dict],
+        tokenizer: AutoTokenizer,
+        max_length: int = 512,
+        for_training: bool = True,
+    ):
+        self.cases = cases
+        self.tokenizer = tokenizer
+        self.max_length = max_length
+        self.for_training = for_training
+
+    def __len__(self) -> int:
+        return len(self.cases)
+
+    def __getitem__(self, idx: int) -> dict[str, Any]:
+        case = self.cases[idx]
+        text = case["text"]
+        cls_labels = case.get("classification_labels", {})
+        num_labels = case.get("numeric_labels", {})
+
+        # Tokenize
+        encoding = self.tokenizer(
+            text,
+            truncation=True,
+            padding="max_length",
+            max_length=self.max_length,
+            return_tensors="pt",
+            return_offsets_mapping=True,
+        )
+
+        input_ids = encoding["input_ids"].squeeze(0)          # [seq_len]
+        attention_mask = encoding["attention_mask"].squeeze(0)  # [seq_len]
+        offset_mapping = encoding["offset_mapping"].squeeze(0)   # [seq_len, 2]
+
+        result = {
+            "input_ids": input_ids,
+            "attention_mask": attention_mask,
+            "case_id": case.get("case_id", 0),
+        }
+
+        if self.for_training:
+            # BIO labels: align character-level labels to subword tokens
+            bio_labels = case.get("bio_labels", [])
+            tokens = case.get("tokens", [])
+
+            if bio_labels and tokens:
+                token_labels = self._align_bio_to_tokens(
+                    bio_labels, tokens, encoding, offset_mapping
+                )
+            else:
+                token_labels = [-100] * self.max_length
+
+            result["ner_labels"] = torch.tensor(token_labels, dtype=torch.long)
+
+            # Classification labels
+            result["cause_label"] = torch.tensor(
+                cls_labels.get("case_cause", 0), dtype=torch.long
+            )
+            result["contract_label"] = torch.tensor(
+                cls_labels.get("contract_type", 3), dtype=torch.long
+            )
+            result["employment_label"] = torch.tensor(
+                cls_labels.get("employment_type", 4), dtype=torch.long
+            )
+
+            # Boolean labels (9 fields)
+            bool_field_keys = [
+                "bool_lr1_contract_signed", "bool_lr1_open_ended_contract",
+                "bool_lr1_double_wage_no_contract", "bool_lr1_si_joined",
+                "bool_wi_si_joined", "bool_wi_recognize_ec",
+                "bool_dm_contract_exists", "bool_dm_contract_continue",
+                "bool_mi_contract_continue",
+            ]
+            bool_vals = [cls_labels.get(k, 2) for k in bool_field_keys]
+            result["bool_labels"] = torch.tensor(bool_vals, dtype=torch.long)
+
+            # Numeric labels: use first numeric field (month_salary) as primary target
+            reg_val = num_labels.get("month_salary", None)
+            result["reg_value"] = torch.tensor(
+                float(reg_val) if reg_val is not None else -1.0,
+                dtype=torch.float,
+            )
+            result["reg_mask"] = torch.tensor(
+                1.0 if reg_val is not None else 0.0, dtype=torch.float
+            )
+
+        return result
+
+    def _align_bio_to_tokens(
+        self,
+        char_bio_labels: list[int],
+        char_tokens: list[str],
+        encoding: Any,
+        offset_mapping: torch.Tensor,
+    ) -> list[int]:
+        """
+        Align character-level BIO labels to subword token labels.
+        For each subword token, find the corresponding character position
+        and use the BIO label from that character.
+        """
+        # Build a mapping from char position → BIO label
+        char_to_label: dict[int, int] = {}
+        char_pos = 0
+        for token, label in zip(char_tokens, char_bio_labels):
+            for _ in token:
+                char_to_label[char_pos] = label
+                char_pos += 1
+
+        # Map subword tokens to BIO labels based on their char offset
+        token_labels = []
+        for i, (start, end) in enumerate(offset_mapping.tolist()):
+            if i >= self.max_length:
+                break
+            if start == 0 and end == 0:
+                # Special tokens ([CLS], [SEP], [PAD])
+                token_labels.append(-100)
+            elif start >= char_pos:
+                # Token is beyond our character annotations
+                token_labels.append(-100)
+            else:
+                # Use label from the first character of this token
+                label = char_to_label.get(start, 0)  # 0 = O
+                token_labels.append(label)
+
+        # Pad to max_length
+        while len(token_labels) < self.max_length:
+            token_labels.append(-100)
+
+        return token_labels[:self.max_length]
+
+
+def collate_batch(batch: list[dict]) -> dict[str, torch.Tensor]:
+    """Custom collate function for multi-task training."""
+    return {
+        "input_ids": torch.stack([b["input_ids"] for b in batch]),
+        "attention_mask": torch.stack([b["attention_mask"] for b in batch]),
+        "ner_labels": torch.stack([b["ner_labels"] for b in batch]),
+        "cause_label": torch.stack([b["cause_label"] for b in batch]),
+        "contract_label": torch.stack([b["contract_label"] for b in batch]),
+        "employment_label": torch.stack([b["employment_label"] for b in batch]),
+        "bool_labels": torch.stack([b["bool_labels"] for b in batch]),
+        "reg_values": torch.stack([b["reg_value"] for b in batch]),
+        "reg_mask": torch.stack([b["reg_mask"] for b in batch]),
+    }
+
+
+def train_epoch(
+    model: ChineseRobertaMultiTask,
+    dataloader: DataLoader,
+    optimizer: torch.optim.Optimizer,
+    scheduler: Any,
+    device: torch.device,
+    epoch: int,
+) -> float:
+    """Train one epoch. Returns average loss."""
+    model.train()
+    total_loss = 0.0
+    num_batches = 0
+
+    pbar = tqdm(dataloader, desc=f"Epoch {epoch}")
+    for batch in pbar:
+        batch = {k: v.to(device) for k, v in batch.items()}
+
+        optimizer.zero_grad()
+        outputs = model(**batch)
+        loss = outputs["loss"]
+
+        if loss is not None:
+            loss.backward()
+            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
+            optimizer.step()
+            scheduler.step()
+
+            total_loss += loss.item()
+            num_batches += 1
+            pbar.set_postfix({"loss": f"{loss.item():.4f}"})
+
+    return total_loss / max(num_batches, 1)
+
+
+@torch.no_grad()
+def evaluate(
+    model: ChineseRobertaMultiTask,
+    dataloader: DataLoader,
+    device: torch.device,
+) -> dict[str, float]:
+    """Evaluate on dev set. Returns per-task loss."""
+    model.eval()
+    total_loss = 0.0
+    num_batches = 0
+    cause_correct = 0
+    cause_total = 0
+
+    for batch in tqdm(dataloader, desc="Eval"):
+        batch = {k: v.to(device) for k, v in batch.items()}
+        outputs = model(**batch)
+        loss = outputs["loss"]
+
+        if loss is not None:
+            total_loss += loss.item()
+            num_batches += 1
+
+        # Cause accuracy
+        cause_preds = torch.argmax(outputs["cause_logits"], dim=-1)
+        cause_correct += (cause_preds == batch["cause_label"]).sum().item()
+        cause_total += batch["cause_label"].size(0)
+
+    return {
+        "loss": total_loss / max(num_batches, 1),
+        "cause_accuracy": cause_correct / max(cause_total, 1),
+    }
+
+
+def create_data_splits(dataset_path: str, train_ratio: float = 0.7, dev_ratio: float = 0.15):
+    """Split dataset into train/dev/test."""
+    with open(dataset_path, encoding="utf-8") as f:
+        data = json.load(f)
+
+    cases = data["dataset"]
+
+    # Separate original vs augmented
+    originals = [c for c in cases if "source" not in c]
+    augmenteds = [c for c in cases if "source" in c]
+
+    random.shuffle(originals)
+    random.shuffle(augmenteds)
+
+    n_orig = len(originals)
+    n_train_orig = max(1, int(n_orig * train_ratio))
+    n_dev_orig = max(1, int(n_orig * dev_ratio))
+
+    train_cases = originals[:n_train_orig] + augmenteds
+    dev_cases = originals[n_train_orig:n_train_orig + n_dev_orig]
+    test_cases = originals[n_train_orig + n_dev_orig:]
+
+    print(f"Split: train={len(train_cases)} ({n_train_orig} orig + {len(augmenteds)} aug), "
+          f"dev={len(dev_cases)}, test={len(test_cases)}")
+
+    return train_cases, dev_cases, test_cases
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Train BERT multi-task extractor")
+    parser.add_argument("--dataset", required=True, help="Path to augmented_dataset.json")
+    parser.add_argument("--output", required=True, help="Output directory for trained model")
+    parser.add_argument("--epochs", type=int, default=10, help="Number of training epochs")
+    parser.add_argument("--batch_size", type=int, default=8, help="Training batch size")
+    parser.add_argument("--lr", type=float, default=2e-5, help="Learning rate")
+    parser.add_argument("--max_length", type=int, default=512, help="Max sequence length")
+    parser.add_argument("--model_name", default="hfl/chinese-roberta-wwm-ext",
+                        help="Pretrained model name or path")
+    parser.add_argument("--patience", type=int, default=3, help="Early stopping patience")
+    parser.add_argument("--gradient_accumulation", type=int, default=2,
+                        help="Gradient accumulation steps")
+    parser.add_argument("--warmup_ratio", type=float, default=0.1, help="Warmup ratio")
+    parser.add_argument("--no_cuda", action="store_true", help="Disable CUDA")
+    args = parser.parse_args()
+
+    # Device
+    device = torch.device("cuda" if torch.cuda.is_available() and not args.no_cuda else "cpu")
+    print(f"Using device: {device}")
+
+    # Load and split data
+    train_cases, dev_cases, test_cases = create_data_splits(args.dataset)
+
+    # Tokenizer
+    tokenizer = AutoTokenizer.from_pretrained(args.model_name)
+    print(f"Loaded tokenizer: {args.model_name}")
+
+    # Datasets
+    train_dataset = LaborCaseDataset(train_cases, tokenizer, args.max_length, for_training=True)
+    dev_dataset = LaborCaseDataset(dev_cases, tokenizer, args.max_length, for_training=True)
+    test_dataset = LaborCaseDataset(test_cases, tokenizer, args.max_length, for_training=True)
+
+    train_loader = DataLoader(
+        train_dataset, batch_size=args.batch_size, shuffle=True,
+        collate_fn=collate_batch, num_workers=0,
+    )
+    dev_loader = DataLoader(
+        dev_dataset, batch_size=args.batch_size, shuffle=False,
+        collate_fn=collate_batch, num_workers=0,
+    )
+
+    # Model
+    model = ChineseRobertaMultiTask(model_name=args.model_name)
+    model.to(device)
+    print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
+
+    # Optimizer
+    no_decay = ["bias", "LayerNorm.weight"]
+    optimizer_grouped = [
+        {
+            "params": [p for n, p in model.named_parameters()
+                       if not any(nd in n for nd in no_decay)],
+            "weight_decay": 0.01,
+        },
+        {
+            "params": [p for n, p in model.named_parameters()
+                       if any(nd in n for nd in no_decay)],
+            "weight_decay": 0.0,
+        },
+    ]
+    optimizer = torch.optim.AdamW(optimizer_grouped, lr=args.lr)
+
+    # Scheduler
+    total_steps = len(train_loader) * args.epochs // args.gradient_accumulation
+    warmup_steps = int(total_steps * args.warmup_ratio)
+    scheduler = get_linear_schedule_with_warmup(
+        optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps,
+    )
+
+    # Training loop
+    best_dev_loss = float("inf")
+    patience_counter = 0
+
+    for epoch in range(1, args.epochs + 1):
+        train_loss = train_epoch(model, train_loader, optimizer, scheduler, device, epoch)
+        dev_metrics = evaluate(model, dev_loader, device)
+
+        print(f"Epoch {epoch:>2}: train_loss={train_loss:.4f}, "
+              f"dev_loss={dev_metrics['loss']:.4f}, "
+              f"dev_cause_acc={dev_metrics['cause_accuracy']:.3f}")
+
+        # Early stopping
+        if dev_metrics["loss"] < best_dev_loss:
+            best_dev_loss = dev_metrics["loss"]
+            patience_counter = 0
+            os.makedirs(args.output, exist_ok=True)
+            model.save_pretrained(args.output)
+            tokenizer.save_pretrained(args.output)
+            print(f"  -> Saved best model to {args.output}")
+        else:
+            patience_counter += 1
+            if patience_counter >= args.patience:
+                print(f"Early stopping at epoch {epoch}")
+                break
+
+    print(f"\nTraining complete. Best dev loss: {best_dev_loss:.4f}")
+    print(f"Model saved to: {args.output}")
+
+
+if __name__ == "__main__":
+    main()

+ 110 - 0
nlp-service/training/train_with_split.py

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""Train BERT multi-task model with explicit 90/10 train/test split on real case data."""
+
+from __future__ import annotations
+
+import json
+import os
+import random
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+# NOTE: transformers must import before torch on Windows
+from transformers import AutoTokenizer, get_linear_schedule_with_warmup
+import torch
+from torch.utils.data import DataLoader
+from tqdm import tqdm
+
+from app.services.bert_multi_task_model import ChineseRobertaMultiTask
+from train_bert import LaborCaseDataset, collate_batch, train_epoch, evaluate
+
+DATASET = Path(__file__).resolve().parent.parent.parent / "backend" / "data" / "training_dataset.json"
+OUTPUT = Path(__file__).resolve().parent.parent / "models" / "chinese_roberta_labor_extractor_v2"
+MODEL_NAME = "hfl/chinese-roberta-wwm-ext"
+EPOCHS = 10
+BATCH_SIZE = 4
+LR = 2e-5
+MAX_LENGTH = 512
+PATIENCE = 3
+TEST_RATIO = 0.1  # 10% for test
+
+random.seed(42)
+torch.manual_seed(42)
+
+device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+print(f"Device: {device}")
+
+# Load data
+with open(DATASET, encoding="utf-8") as f:
+    data = json.load(f)
+
+cases = data["dataset"]
+random.shuffle(cases)
+
+n_test = max(1, int(len(cases) * TEST_RATIO))
+n_train = len(cases) - n_test
+
+train_cases = cases[:n_train]
+test_cases = cases[n_train:]
+
+print(f"Total: {len(cases)}, Train: {n_train}, Test: {n_test}")
+
+# Tokenizer
+tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
+
+# Datasets
+train_ds = LaborCaseDataset(train_cases, tokenizer, MAX_LENGTH, for_training=True)
+test_ds = LaborCaseDataset(test_cases, tokenizer, MAX_LENGTH, for_training=True)
+
+train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,
+                          collate_fn=collate_batch, num_workers=0)
+test_loader = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False,
+                         collate_fn=collate_batch, num_workers=0)
+
+# Model
+model = ChineseRobertaMultiTask(model_name=MODEL_NAME)
+model.to(device)
+print(f"Model params: {sum(p.numel() for p in model.parameters()):,}")
+
+# Optimizer
+no_decay = ["bias", "LayerNorm.weight"]
+opt_params = [
+    {"params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
+     "weight_decay": 0.01},
+    {"params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
+     "weight_decay": 0.0},
+]
+optimizer = torch.optim.AdamW(opt_params, lr=LR)
+total_steps = len(train_loader) * EPOCHS
+scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=int(total_steps * 0.1),
+                                            num_training_steps=total_steps)
+
+# Training
+best_loss = float("inf")
+patience_cnt = 0
+
+for epoch in range(1, EPOCHS + 1):
+    train_loss = train_epoch(model, train_loader, optimizer, scheduler, device, epoch)
+    dev_metrics = evaluate(model, test_loader, device)
+
+    print(f"Epoch {epoch:>2}: train_loss={train_loss:.4f}, "
+          f"test_loss={dev_metrics['loss']:.4f}, "
+          f"test_cause_acc={dev_metrics['cause_accuracy']:.3f}")
+
+    if dev_metrics["loss"] < best_loss:
+        best_loss = dev_metrics["loss"]
+        patience_cnt = 0
+        os.makedirs(OUTPUT, exist_ok=True)
+        model.save_pretrained(str(OUTPUT))
+        tokenizer.save_pretrained(str(OUTPUT))
+        print(f"  -> Saved to {OUTPUT}")
+    else:
+        patience_cnt += 1
+        if patience_cnt >= PATIENCE:
+            print(f"Early stop at epoch {epoch}")
+            break
+
+print(f"\nDone. Best test loss: {best_loss:.4f}")
+print(f"Model: {OUTPUT}")

+ 213 - 0
test_e2e.py

@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+"""End-to-end test for labor arbitration system: upload -> extract -> portrait -> similar"""
+
+import json
+import requests
+import sys
+
+BASE = "http://localhost:8000"
+NLP = "http://localhost:8001"
+
+
+def test_health():
+    print("=== 0. Health Check ===")
+    for name, url in [("Backend", f"{BASE}/health"), ("NLP", f"{NLP}/health")]:
+        try:
+            r = requests.get(url, timeout=5)
+            print(f"  {name}: {r.json()}")
+        except Exception as e:
+            print(f"  {name}: ERROR - {e}")
+            return False
+    return True
+
+
+def test_upload():
+    print("\n=== 1. Upload Case ===")
+    text = (
+        "申请人:张明,男,汉族,1990年5月15日出生。\n"
+        "被申请人:北京恒达科技有限公司,住所北京市海淀区中关村大街1号。\n\n"
+        "请求事项:\n"
+        "1、请求支付拖欠工资8000元。\n"
+        "2、请求支付违法解除劳动合同赔偿金24000元。\n\n"
+        "事实与理由:申请人于2020年3月1日入职被申请人处,担任软件工程师,"
+        "月工资12000元。2023年6月被申请人无故辞退申请人,"
+        "且拖欠2023年5月、6月工资合计8000元。\n\n"
+        "依据《劳动合同法》第四十七条、第八十七条规定。"
+    )
+    files = {"files": ("test_case.txt", text.encode("utf-8"), "text/plain")}
+    data = {"case_name": "张明诉北京恒达科技劳动仲裁案"}
+
+    try:
+        r = requests.post(f"{BASE}/api/cases/upload", files=files, data=data, timeout=60)
+        result = r.json()
+        print(f"  Status: {r.status_code}")
+
+        # Extract case_id from various possible locations
+        case_id = result.get("id") or result.get("case_id")
+        if not case_id and "case" in result:
+            case_id = result["case"].get("id")
+        if not case_id:
+            case_id = result.get("case_id_from_task")
+
+        print(f"  Case ID: {case_id}")
+
+        if "elements" in result:
+            el = result["elements"]
+            print(f"  Case Cause: {el.get('case_cause', 'N/A')}")
+            print(f"  Applicant: {el.get('applicant_name', 'N/A')}")
+            print(f"  Respondent: {el.get('respondent_name', 'N/A')}")
+
+        return case_id
+    except Exception as e:
+        print(f"  ERROR: {e}")
+        import traceback
+        traceback.print_exc()
+        return None
+
+
+def test_elements(case_id):
+    print(f"\n=== 2. Get Elements (case {case_id}) ===")
+    try:
+        r = requests.get(f"{BASE}/api/cases/{case_id}/elements", timeout=30)
+        result = r.json()
+        print(f"  Status: {r.status_code}")
+
+        # Show top-level fields
+        show_keys = [
+            "case_cause", "applicant_name", "respondent_name",
+            "entry_date", "leave_date", "month_salary",
+            "worker_position", "contract_type", "law_refs",
+            "primary_cause_type",
+        ]
+        for k in show_keys:
+            v = result.get(k, "N/A")
+            print(f"  {k}: {v}")
+
+        # Show claims
+        claims = result.get("claims", {})
+        if claims:
+            print(f"  claims: {claims}")
+
+        # Check element table
+        table = result.get("case_elements_table", {})
+        if table:
+            print(f"  Element table: {table.get('field_count', 0)} fields")
+        return result
+    except Exception as e:
+        print(f"  ERROR: {e}")
+        return None
+
+
+def test_portrait(case_id):
+    print(f"\n=== 3. Get Portrait (case {case_id}) ===")
+    try:
+        r = requests.get(f"{BASE}/api/cases/{case_id}/portrait", timeout=30)
+        result = r.json()
+        print(f"  Status: {r.status_code}")
+
+        if "legal_score" in result:
+            print(f"  Legal Score: {result['legal_score']}/100")
+        if "fact_score" in result:
+            print(f"  Fact Score: {result['fact_score']}/100")
+        if "risk_score" in result:
+            print(f"  Risk Score: {result['risk_score']}/100")
+        if "risk_level" in result:
+            print(f"  Risk Level: {result['risk_level']}")
+        if "tags" in result:
+            print(f"  Tags: {result['tags'][:5] if isinstance(result['tags'], list) else result['tags']}")
+        if "keywords" in result:
+            kws = result["keywords"]
+            if isinstance(kws, list):
+                print(f"  Keywords (top 10): {[kw['word'] for kw in kws[:10]]}")
+        return result
+    except Exception as e:
+        print(f"  ERROR: {e}")
+        return None
+
+
+def test_similar(case_id):
+    print(f"\n=== 4. Similar Cases (case {case_id}) ===")
+    try:
+        r = requests.post(
+            f"{BASE}/api/cases/{case_id}/similar",
+            json={"top_k": 3},
+            timeout=30,
+        )
+        result = r.json()
+        print(f"  Status: {r.status_code}")
+
+        if isinstance(result, list):
+            print(f"  Found {len(result)} similar cases")
+            for i, case in enumerate(result[:3]):
+                sim = case.get("similarity", case.get("score", 0))
+                name = case.get("case_name", case.get("name", "unknown"))
+                print(f"  [{i+1}] {name} (similarity: {sim})")
+        elif isinstance(result, dict):
+            cases = result.get("cases", result.get("results", []))
+            print(f"  Found {len(cases)} similar cases")
+        return result
+    except Exception as e:
+        print(f"  ERROR: {e}")
+        return None
+
+
+def test_nlp_service():
+    print("\n=== 5. NLP Service Direct Test ===")
+    text = (
+        "申请人:张明,男,汉族,1990年5月15日出生。"
+        "被申请人:北京恒达科技有限公司,住所北京市海淀区中关村大街1号。"
+        "请求事项:1、请求支付拖欠工资8000元。"
+        "2、请求支付违法解除劳动合同赔偿金24000元。"
+        "事实与理由:申请人于2020年3月1日入职被申请人处,担任软件工程师,"
+        "月工资12000元。2023年6月被申请人无故辞退申请人。"
+        "依据《劳动合同法》第四十七条、第八十七条规定。"
+    )
+    try:
+        r = requests.post(f"{NLP}/extract", json={"text": text}, timeout=60)
+        result = r.json()
+        print(f"  Status: {r.status_code}")
+        cause = result.get("case_cause", {}).get("type", "N/A")
+        print(f"  Case Cause: {cause}")
+        parties = result.get("parties", {})
+        print(f"  Applicant: {parties.get('applicant_name', 'N/A')}")
+        print(f"  Respondent: {parties.get('respondent_name', 'N/A')}")
+        facts = result.get("facts", {})
+        print(f"  Entry Date: {facts.get('entry_date', 'N/A')}")
+        print(f"  Month Salary: {facts.get('month_salary', 'N/A')}")
+        return result
+    except Exception as e:
+        print(f"  ERROR: {e}")
+        return None
+
+
+def main():
+    print("=" * 60)
+    print("LABOR ARBITRATION SYSTEM - E2E TEST")
+    print("=" * 60)
+
+    if not test_health():
+        print("\n[FAIL] Services not running!")
+        print("Start them first:")
+        print("  cd backend && uvicorn app.main:app --port 8000")
+        print("  cd nlp-service && uvicorn app.main:app --port 8001")
+        sys.exit(1)
+
+    case_id = test_upload()
+    if case_id:
+        test_elements(case_id)
+        test_portrait(case_id)
+        test_similar(case_id)
+
+    test_nlp_service()
+
+    print("\n" + "=" * 60)
+    print("E2E TEST COMPLETE")
+    print("=" * 60)
+    print(f"Frontend: http://localhost:5173")
+    print(f"Backend:  http://localhost:8000")
+    print(f"NLP:      http://localhost:8001")
+    print(f"API Docs: http://localhost:8000/docs")
+
+
+if __name__ == "__main__":
+    main()

+ 29 - 0
案例文件/万涛案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:万涛,男,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:温州鞋业公司有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资11000元。
+2、支付加班费5500元。
+
+事实与理由:
+
+申请人于2020-04-10入职被申请人处,担任设计员一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币5500元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工作期间长期加班但被申请人未依法足额支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人拖欠工资后申请人被迫离职。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤打卡记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):万涛
+日期:2023-03-15

+ 29 - 0
案例文件/何伟案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:何伟,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:长沙恒泰房地产开发有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金120000元。
+2、支付绩效奖金60000元。
+
+事实与理由:
+
+申请人于2018-03-01入职被申请人处,在项目经理岗位工作,月工资为人民币20000元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-09-30。
+
+直至项目结束,公司业务调整。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第四十六条、《劳动合同法》第四十七条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作5年,月平均工资为人民币20000元,依据法律规定应获得经济补偿金共计人民币180000元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、项目终止通知、绩效考核记录、工资银行流水等。
+
+此致
+长沙市岳麓区劳动人事争议仲裁委员会
+
+申请人(签名):何伟
+日期:2023-09-15

+ 29 - 0
案例文件/余伟案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:余伟,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:南昌电子制造有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资16400元。
+2、支付加班费8200元。
+
+事实与理由:
+
+申请人于2020-04-10入职被申请人处,担任质检工程师一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币8200元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工作期间长期加班但被申请人未依法足额支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人拖欠工资后申请人被迫离职。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤打卡记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):余伟
+日期:2023-03-15

+ 26 - 0
案例文件/侯静案.txt

@@ -0,0 +1,26 @@
+劳动仲裁申请书
+
+申请人:侯静,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:芜湖汽车配件有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付违法解除赔偿金48000元。
+
+事实与理由:
+
+申请人于2020-04-10入职被申请人处,担任仓库主管,月工资标准为人民币8000元。工作期间,申请人表现良好,认真完成各项工作任务,从未出现严重违反公司规章制度或不能胜任工作的情形,被申请人也从未对申请人的工作表现提出过任何书面警告或批评。
+
+直至被用人单位无正当理由违法辞退。被申请人在没有法定事由、也未履行法定程序的情况下,单方面解除了与申请人之间的劳动合同。被申请人的行为属于违法解除劳动合同。
+
+根据《中华人民共和国劳动合同法》第四十八条规定:"用人单位违反本法规定解除或者终止劳动合同,劳动者要求继续履行劳动合同的,用人单位应当继续履行;劳动者不要求继续履行劳动合同或者劳动合同已经不能继续履行的,用人单位应当依照本法第八十七条规定支付赔偿金。"第八十七条规定:"用人单位违反本法规定解除或者终止劳动合同的,应当依照本法第四十七条规定的经济补偿标准的二倍向劳动者支付赔偿金。"
+
+《劳动合同法》第四十八条、《劳动合同法》第八十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的违法解除行为严重侵害了申请人的劳动权益,给申请人造成了重大经济损失和精神损害。为维护申请人的合法权益,根据《中华人民共和国劳动争议调解仲裁法》的规定,申请人特向贵仲裁委员会提起仲裁申请,恳请贵委依法裁决。
+
+申请人提供的证据材料包括:劳动合同、违法解除通知、工资记录、社保缴费记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):侯静
+日期:2023-06-15

+ 29 - 0
案例文件/兰静案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:兰静,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:襄樊纺织企业有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资8400元。
+2、支付加班费4200元。
+
+事实与理由:
+
+申请人于2020-01-10入职被申请人处,担任纺纱工一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币4200元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工作期间长期加班但被申请人未依法足额支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人拖欠工资后申请人被迫离职。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤打卡记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):兰静
+日期:2023-06-15

+ 29 - 0
案例文件/冯涛案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:冯涛,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:银川建筑施工有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资14000元。
+2、支付加班费7000元。
+
+事实与理由:
+
+申请人于2020-06-10入职被申请人处,担任电工一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币7000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工作期间长期加班但被申请人未依法足额支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人拖欠工资后申请人被迫离职。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤打卡记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):冯涛
+日期:2023-05-15

+ 30 - 0
案例文件/刘强案.txt

@@ -0,0 +1,30 @@
+劳动仲裁申请书
+
+申请人:刘强,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:佛山南海陶瓷厂,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付一次性伤残补助金49000元。
+2、支付停工留薪期工资21000元。
+3、支付医疗费15000元。
+
+事实与理由:
+
+申请人于2021-02-15入职被申请人处,在窑炉操作工岗位工作,月工资人民币7000元,双方建立了合法的劳动关系。申请人在职期间,被申请人依法为申请人缴纳了工伤保险费用。
+
+申请人在工作期间因工作原因受伤/患职业病,经XX医院诊断为:XX损伤。事故发生后,申请人及时向被申请人报告了伤情,被申请人也知晓此事。经人力资源和社会保障局依法认定,申请人所受伤害为工伤(工伤认定书编号:XXXXXX)。经劳动能力鉴定委员会鉴定,申请人劳动功能障碍程度为XX级伤残。
+
+然而,申请人受伤后,被申请人未按照《工伤保险条例》的规定向申请人足额支付各项工伤保险待遇,包括一次性伤残补助金、停工留薪期工资、医疗费等。申请人多次与被申请人就工伤待遇问题进行沟通协商,但被申请人以"公司正在走流程""等保险公司赔付"等理由一直推诿拖延,致使申请人的合法权益长期得不到保障。
+
+根据《工伤保险条例》第十七条、第三十三条、第三十七条等相关规定,用人单位应当及时为受伤职工申请工伤认定并依法支付各项工伤保险待遇。《工伤保险条例》第十七条、《工伤保险条例》第三十三条、《工伤保险条例》第三十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已违反上述法律规定。
+
+为维护自身合法权益,申请人根据《中华人民共和国劳动争议调解仲裁法》的规定提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决。
+
+证据材料:劳动合同、工伤认定决定书、劳动能力鉴定结论、医疗费发票、医院病历等。
+
+此致
+佛山市南海区劳动人事争议仲裁委员会
+
+申请人(签名):刘强
+日期:2023-03-15

+ 28 - 0
案例文件/刘洋案.txt

@@ -0,0 +1,28 @@
+劳动仲裁申请书
+
+申请人:刘洋,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:天津天汽汽车制造有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金42250元。
+
+事实与理由:
+
+申请人于2017-06-01入职被申请人处,在装配工岗位工作,月工资为人民币6500元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-08-31。
+
+直至工厂搬迁至外省,劳动者不同意随迁。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第四十条、《劳动合同法》第四十六条、《劳动合同法》第四十七条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作6年,月平均工资为人民币6500元,依据法律规定应获得经济补偿金共计人民币42250元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、工厂搬迁通知、不同意随迁声明、工资发放记录等。
+
+此致
+天津市滨海新区劳动人事争议仲裁委员会
+
+申请人(签名):刘洋
+日期:2023-08-15

+ 29 - 0
案例文件/史强案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:史强,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:洛阳机械加工有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付一次性伤残补助金50400元。
+2、支付医疗费14400元。
+
+事实与理由:
+
+申请人于2020-03-10入职被申请人处,在数控操作工岗位工作,月工资人民币7200元,双方建立了合法的劳动关系。申请人在职期间,被申请人依法为申请人缴纳了工伤保险费用。
+
+申请人在工作期间因工作原因受伤/患职业病,经XX医院诊断为:XX损伤。事故发生后,申请人及时向被申请人报告了伤情,被申请人也知晓此事。经人力资源和社会保障局依法认定,申请人所受伤害为工伤(工伤认定书编号:XXXXXX)。经劳动能力鉴定委员会鉴定,申请人劳动功能障碍程度为XX级伤残。
+
+然而,申请人受伤后,被申请人未按照《工伤保险条例》的规定向申请人足额支付各项工伤保险待遇,包括一次性伤残补助金、停工留薪期工资、医疗费等。申请人多次与被申请人就工伤待遇问题进行沟通协商,但被申请人以"公司正在走流程""等保险公司赔付"等理由一直推诿拖延,致使申请人的合法权益长期得不到保障。
+
+根据《工伤保险条例》第十七条、第三十三条、第三十七条等相关规定,用人单位应当及时为受伤职工申请工伤认定并依法支付各项工伤保险待遇。《工伤保险条例》第十七条、《工伤保险条例》第三十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已违反上述法律规定。
+
+为维护自身合法权益,申请人根据《中华人民共和国劳动争议调解仲裁法》的规定提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决。
+
+证据材料:劳动合同、工伤认定决定书、劳动能力鉴定结论、医疗费发票等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):史强
+日期:2023-05-15

+ 28 - 0
案例文件/吴敏案.txt

@@ -0,0 +1,28 @@
+劳动仲裁申请书
+
+申请人:吴敏,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:武汉宏达建筑装饰工程有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付加班工资42000元。
+
+事实与理由:
+
+申请人于2020-02-20入职被申请人处,担任施工员一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币8000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工地施工期间每天工作超过10小时,被申请人从未支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动法》第四十一条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、施工日志、考勤记录、工资银行转账记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+武汉市洪山区劳动人事争议仲裁委员会
+
+申请人(签名):吴敏
+日期:2023-07-15

+ 30 - 0
案例文件/周磊案.txt

@@ -0,0 +1,30 @@
+劳动仲裁申请书
+
+申请人:周磊,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:成都蜀味餐饮管理有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资18000元。
+2、支付加班费35000元。
+3、支付经济补偿金36000元。
+
+事实与理由:
+
+申请人于2019-05-10入职被申请人处,担任厨师长一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币9000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,节假日及周末加班未支付加班费,累计加班120天。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人长期拖欠工资。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动法》第四十四条、《劳动合同法》第四十六条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、排班表、工资签收单、同事证人证言等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+成都市武侯区劳动人事争议仲裁委员会
+
+申请人(签名):周磊
+日期:2023-02-15

+ 29 - 0
案例文件/唐芳案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:唐芳,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:昆明建工集团,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付一次性伤残补助金63000元。
+2、支付护理费18000元。
+
+事实与理由:
+
+申请人于2022-03-01入职被申请人处,在架子工岗位工作,月工资人民币9000元,双方建立了合法的劳动关系。申请人在职期间,被申请人依法为申请人缴纳了工伤保险费用。
+
+申请人在工作期间因工作原因受伤/患职业病,经XX医院诊断为:XX损伤。事故发生后,申请人及时向被申请人报告了伤情,被申请人也知晓此事。经人力资源和社会保障局依法认定,申请人所受伤害为工伤(工伤认定书编号:XXXXXX)。经劳动能力鉴定委员会鉴定,申请人劳动功能障碍程度为XX级伤残。
+
+然而,申请人受伤后,被申请人未按照《工伤保险条例》的规定向申请人足额支付各项工伤保险待遇,包括一次性伤残补助金、停工留薪期工资、医疗费等。申请人多次与被申请人就工伤待遇问题进行沟通协商,但被申请人以"公司正在走流程""等保险公司赔付"等理由一直推诿拖延,致使申请人的合法权益长期得不到保障。
+
+根据《工伤保险条例》第十七条、第三十三条、第三十七条等相关规定,用人单位应当及时为受伤职工申请工伤认定并依法支付各项工伤保险待遇。《工伤保险条例》第三十四条、《工伤保险条例》第三十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已违反上述法律规定。
+
+为维护自身合法权益,申请人根据《中华人民共和国劳动争议调解仲裁法》的规定提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决。
+
+证据材料:劳动合同、工伤认定书、伤残鉴定报告、医院费用清单等。
+
+此致
+昆明市官渡区劳动人事争议仲裁委员会
+
+申请人(签名):唐芳
+日期:2023-01-15

+ 29 - 0
案例文件/孙伟案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:孙伟,男,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:重庆快捷物流运输有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付加班工资55000元。
+2、支付高温津贴6000元。
+
+事实与理由:
+
+申请人于2018-11-05入职被申请人处,担任货运司机一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币7500元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,长途运输任务频繁,每月平均加班80小时,被申请人从未支付加班费。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动法》第四十四条、《防暑降温措施管理办法》明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、运输任务单、GPS行车记录、工资银行卡流水等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+重庆市渝北区劳动人事争议仲裁委员会
+
+申请人(签名):孙伟
+日期:2023-04-15

+ 28 - 0
案例文件/孟丽案.txt

@@ -0,0 +1,28 @@
+劳动仲裁申请书
+
+申请人:孟丽,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:桂林旅游管理有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金18000元。
+
+事实与理由:
+
+申请人于2020-06-10入职被申请人处,在景区讲解员岗位工作,月工资为人民币4500元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-08-15。
+
+直至用人单位提出协商解除劳动合同。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第四十六条、《劳动合同法》第四十七条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作3年,月平均工资为人民币4500元,依据法律规定应获得经济补偿金共计人民币18000元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、解除劳动关系证明、工资发放记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):孟丽
+日期:2023-08-15

+ 27 - 0
案例文件/宋涛案.txt

@@ -0,0 +1,27 @@
+劳动仲裁申请书
+
+申请人:宋涛,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:东莞新科电子厂,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付违法解除赔偿金66000元。
+2、支付工伤待遇45000元。
+
+事实与理由:
+
+申请人于2017-12-01入职被申请人处,担任生产线组长,月工资标准为人民币6000元。工作期间,申请人表现良好,认真完成各项工作任务,从未出现严重违反公司规章制度或不能胜任工作的情形,被申请人也从未对申请人的工作表现提出过任何书面警告或批评。
+
+直至发生工伤后被用人单位违法解除。被申请人在没有法定事由、也未履行法定程序的情况下,单方面解除了与申请人之间的劳动合同。被申请人的行为属于违法解除劳动合同。
+
+根据《中华人民共和国劳动合同法》第四十八条规定:"用人单位违反本法规定解除或者终止劳动合同,劳动者要求继续履行劳动合同的,用人单位应当继续履行;劳动者不要求继续履行劳动合同或者劳动合同已经不能继续履行的,用人单位应当依照本法第八十七条规定支付赔偿金。"第八十七条规定:"用人单位违反本法规定解除或者终止劳动合同的,应当依照本法第四十七条规定的经济补偿标准的二倍向劳动者支付赔偿金。"
+
+《劳动合同法》第四十二条、《工伤保险条例》第十七条、《劳动合同法》第八十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的违法解除行为严重侵害了申请人的劳动权益,给申请人造成了重大经济损失和精神损害。为维护申请人的合法权益,根据《中华人民共和国劳动争议调解仲裁法》的规定,申请人特向贵仲裁委员会提起仲裁申请,恳请贵委依法裁决。
+
+申请人提供的证据材料包括:劳动合同、工伤认定决定书、解除通知、工资记录等。
+
+此致
+东莞市长安镇劳动人事争议仲裁委员会
+
+申请人(签名):宋涛
+日期:2023-05-15

+ 28 - 0
案例文件/尤芳案.txt

@@ -0,0 +1,28 @@
+劳动仲裁申请书
+
+申请人:尤芳,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:三亚酒店管理有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金23200元。
+
+事实与理由:
+
+申请人于2020-02-10入职被申请人处,在前台主管岗位工作,月工资为人民币5800元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-04-15。
+
+直至用人单位提出协商解除劳动合同。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第四十六条、《劳动合同法》第四十七条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作3年,月平均工资为人民币5800元,依据法律规定应获得经济补偿金共计人民币23200元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、解除劳动关系证明、工资发放记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):尤芳
+日期:2023-04-15

+ 26 - 0
案例文件/崔静案.txt

@@ -0,0 +1,26 @@
+劳动仲裁申请书
+
+申请人:崔静,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:贵阳信息技术有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付违法解除赔偿金66000元。
+
+事实与理由:
+
+申请人于2020-01-10入职被申请人处,担任测试工程师,月工资标准为人民币11000元。工作期间,申请人表现良好,认真完成各项工作任务,从未出现严重违反公司规章制度或不能胜任工作的情形,被申请人也从未对申请人的工作表现提出过任何书面警告或批评。
+
+直至被用人单位无正当理由违法辞退。被申请人在没有法定事由、也未履行法定程序的情况下,单方面解除了与申请人之间的劳动合同。被申请人的行为属于违法解除劳动合同。
+
+根据《中华人民共和国劳动合同法》第四十八条规定:"用人单位违反本法规定解除或者终止劳动合同,劳动者要求继续履行劳动合同的,用人单位应当继续履行;劳动者不要求继续履行劳动合同或者劳动合同已经不能继续履行的,用人单位应当依照本法第八十七条规定支付赔偿金。"第八十七条规定:"用人单位违反本法规定解除或者终止劳动合同的,应当依照本法第四十七条规定的经济补偿标准的二倍向劳动者支付赔偿金。"
+
+《劳动合同法》第四十八条、《劳动合同法》第八十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的违法解除行为严重侵害了申请人的劳动权益,给申请人造成了重大经济损失和精神损害。为维护申请人的合法权益,根据《中华人民共和国劳动争议调解仲裁法》的规定,申请人特向贵仲裁委员会提起仲裁申请,恳请贵委依法裁决。
+
+申请人提供的证据材料包括:劳动合同、违法解除通知、工资记录、社保缴费记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):崔静
+日期:2023-06-15

+ 29 - 0
案例文件/常磊案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:常磊,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:大同煤矿企业有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资18000元。
+2、支付加班费9000元。
+
+事实与理由:
+
+申请人于2020-09-10入职被申请人处,担任采掘工一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币9000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工作期间长期加班但被申请人未依法足额支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人拖欠工资后申请人被迫离职。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤打卡记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):常磊
+日期:2023-05-15

+ 29 - 0
案例文件/张明案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:张明,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:北京恒达科技有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资8000元。
+2、支付违法解除劳动合同赔偿金24000元。
+
+事实与理由:
+
+申请人于2020-03-01入职被申请人处,担任软件工程师一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币12000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,2023年5月至6月期间正常出勤,被申请人拖欠工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至2023年6月被申请人无故辞退申请人。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第四十七条、《劳动合同法》第八十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤记录、解除通知等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+北京市海淀区劳动人事争议仲裁委员会
+
+申请人(签名):张明
+日期:2023-06-15

+ 29 - 0
案例文件/彭涛案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:彭涛,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:长春汽车零部件有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资11000元。
+2、支付加班费5500元。
+
+事实与理由:
+
+申请人于2020-05-10入职被申请人处,担任操作工一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币5500元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工作期间长期加班但被申请人未依法足额支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人拖欠工资后申请人被迫离职。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤打卡记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):彭涛
+日期:2023-07-15

+ 29 - 0
案例文件/徐静案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:徐静,女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:青岛海丰贸易有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付生育津贴26000元。
+2、支付生育医疗费用8000元。
+
+事实与理由:
+
+申请人于2021-09-01入职被申请人处,在行政专员岗位工作,月工资为人民币6500元。双方签订了书面劳动合同,被申请人依法为申请人缴纳了包括生育保险在内的各项社会保险费用。在职期间,申请人认真履行职责,从未发生违纪违规行为。
+
+申请人在职期间怀孕,于20XX年XX月XX日在XX医院生育一名婴儿。根据国家相关法律法规的规定,申请人依法享有产假及生育保险待遇。申请人在产假前已按照被申请人的规定办理了产假审批手续,并提交了相关证明材料。申请人产假期间,被申请人未按照法律规定向申请人足额发放生育津贴,也未报销申请人的生育医疗费用。
+
+直至产假结束后被调岗降薪。
+
+根据《女职工劳动保护特别规定》第八条规定:"女职工产假期间的生育津贴,对已经参加生育保险的,按照用人单位上年度职工月平均工资的标准由生育保险基金支付;对未参加生育保险的,按照女职工产假前工资的标准由用人单位支付。女职工生育或者流产的医疗费用,按照生育保险规定的项目和标准,对已经参加生育保险的,由生育保险基金支付;对未参加生育保险的,由用人单位支付。"《中华人民共和国社会保险法》第五十三条规定了用人单位应当为职工缴纳生育保险费用。
+
+申请人认为,被申请人的行为侵害了女性职工的合法生育权益,违反了国家保护女职工权益的法律法规。为维护自身合法权益,申请人特向贵仲裁委员会提出仲裁申请。
+
+证据材料:劳动合同、医院出生证明、生育保险缴费记录、产假审批单等。
+
+此致
+青岛市市南区劳动人事争议仲裁委员会
+
+申请人(签名):徐静
+日期:2023-12-15

+ 29 - 0
案例文件/戴丽案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:戴丽,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:昆明旅游服务有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资12000元。
+2、支付加班费6000元。
+
+事实与理由:
+
+申请人于2020-06-10入职被申请人处,担任导游一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币6000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,工作期间长期加班但被申请人未依法足额支付加班工资。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至被申请人拖欠工资后申请人被迫离职。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十条、《劳动合同法》第三十一条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资银行流水、考勤打卡记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):戴丽
+日期:2023-08-15

+ 29 - 0
案例文件/方芳案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:方芳,女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:呼和浩特餐饮管理有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付一次性伤残补助金28000元。
+2、支付医疗费8000元。
+
+事实与理由:
+
+申请人于2020-05-10入职被申请人处,在服务员领班岗位工作,月工资人民币4000元,双方建立了合法的劳动关系。申请人在职期间,被申请人依法为申请人缴纳了工伤保险费用。
+
+申请人在工作期间因工作原因受伤/患职业病,经XX医院诊断为:XX损伤。事故发生后,申请人及时向被申请人报告了伤情,被申请人也知晓此事。经人力资源和社会保障局依法认定,申请人所受伤害为工伤(工伤认定书编号:XXXXXX)。经劳动能力鉴定委员会鉴定,申请人劳动功能障碍程度为XX级伤残。
+
+然而,申请人受伤后,被申请人未按照《工伤保险条例》的规定向申请人足额支付各项工伤保险待遇,包括一次性伤残补助金、停工留薪期工资、医疗费等。申请人多次与被申请人就工伤待遇问题进行沟通协商,但被申请人以"公司正在走流程""等保险公司赔付"等理由一直推诿拖延,致使申请人的合法权益长期得不到保障。
+
+根据《工伤保险条例》第十七条、第三十三条、第三十七条等相关规定,用人单位应当及时为受伤职工申请工伤认定并依法支付各项工伤保险待遇。《工伤保险条例》第十七条、《工伤保险条例》第三十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已违反上述法律规定。
+
+为维护自身合法权益,申请人根据《中华人民共和国劳动争议调解仲裁法》的规定提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决。
+
+证据材料:劳动合同、工伤认定决定书、劳动能力鉴定结论、医疗费发票等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):方芳
+日期:2023-04-15

+ 29 - 0
案例文件/易芳案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:易芳,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:唐山钢铁企业有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付一次性伤残补助金54600元。
+2、支付医疗费15600元。
+
+事实与理由:
+
+申请人于2020-08-10入职被申请人处,在化验员岗位工作,月工资人民币7800元,双方建立了合法的劳动关系。申请人在职期间,被申请人依法为申请人缴纳了工伤保险费用。
+
+申请人在工作期间因工作原因受伤/患职业病,经XX医院诊断为:XX损伤。事故发生后,申请人及时向被申请人报告了伤情,被申请人也知晓此事。经人力资源和社会保障局依法认定,申请人所受伤害为工伤(工伤认定书编号:XXXXXX)。经劳动能力鉴定委员会鉴定,申请人劳动功能障碍程度为XX级伤残。
+
+然而,申请人受伤后,被申请人未按照《工伤保险条例》的规定向申请人足额支付各项工伤保险待遇,包括一次性伤残补助金、停工留薪期工资、医疗费等。申请人多次与被申请人就工伤待遇问题进行沟通协商,但被申请人以"公司正在走流程""等保险公司赔付"等理由一直推诿拖延,致使申请人的合法权益长期得不到保障。
+
+根据《工伤保险条例》第十七条、第三十三条、第三十七条等相关规定,用人单位应当及时为受伤职工申请工伤认定并依法支付各项工伤保险待遇。《工伤保险条例》第十七条、《工伤保险条例》第三十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已违反上述法律规定。
+
+为维护自身合法权益,申请人根据《中华人民共和国劳动争议调解仲裁法》的规定提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决。
+
+证据材料:劳动合同、工伤认定决定书、劳动能力鉴定结论、医疗费发票等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):易芳
+日期:2023-04-15

+ 30 - 0
案例文件/曹丽案.txt

@@ -0,0 +1,30 @@
+劳动仲裁申请书
+
+申请人:曹丽,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:郑州红叶服装厂,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、确认劳动关系。
+2、支付双倍工资差额49500元。
+3、补缴社会保险。
+
+事实与理由:
+
+申请人于2020-08-10起在被申请人处工作,岗位为缝纫工,月平均工资约人民币4500元。申请人自入职以来,一直在被申请人的经营场所内提供劳动,接受被申请人的劳动管理和工作安排,遵守被申请人的各项规章制度,被申请人按月向申请人支付劳动报酬。
+
+然而,自申请人入职至今,被申请人始终未与申请人签订书面劳动合同。申请人曾多次向被申请人的管理人员提出签订劳动合同的要求,但被申请人每次都以"公司统一办理""等通知"等理由推脱搪塞,至今未与申请人签订书面劳动合同。
+
+直至口头辞退。
+
+根据《中华人民共和国劳动合同法》第十条规定:"建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。"第八十二条规定:"用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。"
+
+申请人认为,被申请人在与申请人存在事实劳动关系的情况下,长期不签订书面劳动合同的行为严重违反了法律规定,侵害了申请人的合法权益。为明确双方之间的权利义务关系,保障申请人的合法劳动权益,申请人依据相关法律规定向贵仲裁委员会提出仲裁申请,恳请贵委依法裁决。
+
+证据材料:工作证、工资微信转账记录、同事证人证言、工作照片等。上述证据能够充分证明申请人与被申请人之间存在长期、稳定的事实劳动关系。
+
+此致
+郑州市二七区劳动人事争议仲裁委员会
+
+申请人(签名):曹丽
+日期:2023-06-15

+ 30 - 0
案例文件/李强案.txt

@@ -0,0 +1,30 @@
+劳动仲裁申请书
+
+申请人:李强,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:深圳创新科技有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付拖欠工资15000元。
+2、支付加班费12000元。
+3、支付经济补偿金22500元。
+
+事实与理由:
+
+申请人于2019-08-15入职被申请人处,担任销售经理一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币15000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,2022年10月至2023年2月期间周末加班共计28天未调休。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至2022年底公司以经营困难为由裁员。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十一条、《劳动合同法》第四十六条、《劳动合同法》第四十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、工资发放明细、加班审批记录、微信工作群聊天记录等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+深圳市南山区劳动人事争议仲裁委员会
+
+申请人(签名):李强
+日期:2023-03-15

+ 28 - 0
案例文件/杨勇案.txt

@@ -0,0 +1,28 @@
+劳动仲裁申请书
+
+申请人:杨勇,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:合肥精工机械制造有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金46500元。
+
+事实与理由:
+
+申请人于2016-08-20入职被申请人处,在钳工岗位工作,月工资为人民币6200元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-10-31。
+
+直至用人单位未依法缴纳社会保险费,劳动者提出解除劳动合同。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第三十八条、《劳动合同法》第四十六条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作7年,月平均工资为人民币6200元,依据法律规定应获得经济补偿金共计人民币46500元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、社保缴费查询记录、解除通知、工资单等。
+
+此致
+合肥市经开区劳动人事争议仲裁委员会
+
+申请人(签名):杨勇
+日期:2023-10-15

+ 29 - 0
案例文件/林丹案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:林丹,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:厦门鑫源外贸进出口有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金24500元。
+2、支付代通知金7000元。
+
+事实与理由:
+
+申请人于2020-05-15入职被申请人处,在外贸业务员岗位工作,月工资为人民币7000元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-07-05。
+
+直至公司经营不善裁员。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第四十条、《劳动合同法》第四十六条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作3年,月平均工资为人民币7000元,依据法律规定应获得经济补偿金共计人民币31500元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、裁员通知、工资发放明细、社保缴纳记录等。
+
+此致
+厦门市思明区劳动人事争议仲裁委员会
+
+申请人(签名):林丹
+日期:2023-07-15

+ 28 - 0
案例文件/段丽案.txt

@@ -0,0 +1,28 @@
+劳动仲裁申请书
+
+申请人:段丽,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:宝鸡商贸物流有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金24800元。
+
+事实与理由:
+
+申请人于2020-05-10入职被申请人处,在会计岗位工作,月工资为人民币6200元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-04-15。
+
+直至用人单位提出协商解除劳动合同。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第四十六条、《劳动合同法》第四十七条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作3年,月平均工资为人民币6200元,依据法律规定应获得经济补偿金共计人民币24800元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、解除劳动关系证明、工资发放记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):段丽
+日期:2023-04-15

+ 29 - 0
案例文件/沈磊案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:沈磊,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:拉萨商贸公司有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、确认劳动关系。
+2、支付未签合同双倍工资差额52800元。
+
+事实与理由:
+
+申请人于2020-08-10起在被申请人处工作,岗位为仓库管理员,月平均工资约人民币4800元。申请人自入职以来,一直在被申请人的经营场所内提供劳动,接受被申请人的劳动管理和工作安排,遵守被申请人的各项规章制度,被申请人按月向申请人支付劳动报酬。
+
+然而,自申请人入职至今,被申请人始终未与申请人签订书面劳动合同。申请人曾多次向被申请人的管理人员提出签订劳动合同的要求,但被申请人每次都以"公司统一办理""等通知"等理由推脱搪塞,至今未与申请人签订书面劳动合同。
+
+
+
+根据《中华人民共和国劳动合同法》第十条规定:"建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。"第八十二条规定:"用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。"
+
+申请人认为,被申请人在与申请人存在事实劳动关系的情况下,长期不签订书面劳动合同的行为严重违反了法律规定,侵害了申请人的合法权益。为明确双方之间的权利义务关系,保障申请人的合法劳动权益,申请人依据相关法律规定向贵仲裁委员会提出仲裁申请,恳请贵委依法裁决。
+
+证据材料:工作证明、工资支付记录、工作沟通记录、同事证言等。上述证据能够充分证明申请人与被申请人之间存在长期、稳定的事实劳动关系。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):沈磊
+日期:2023-07-15

+ 26 - 0
案例文件/沈芳案.txt

@@ -0,0 +1,26 @@
+劳动仲裁申请书
+
+申请人:沈芳,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:南宁教育培训有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付违法解除赔偿金40800元。
+
+事实与理由:
+
+申请人于2020-08-10入职被申请人处,担任课程顾问,月工资标准为人民币6800元。工作期间,申请人表现良好,认真完成各项工作任务,从未出现严重违反公司规章制度或不能胜任工作的情形,被申请人也从未对申请人的工作表现提出过任何书面警告或批评。
+
+直至被用人单位无正当理由违法辞退。被申请人在没有法定事由、也未履行法定程序的情况下,单方面解除了与申请人之间的劳动合同。被申请人的行为属于违法解除劳动合同。
+
+根据《中华人民共和国劳动合同法》第四十八条规定:"用人单位违反本法规定解除或者终止劳动合同,劳动者要求继续履行劳动合同的,用人单位应当继续履行;劳动者不要求继续履行劳动合同或者劳动合同已经不能继续履行的,用人单位应当依照本法第八十七条规定支付赔偿金。"第八十七条规定:"用人单位违反本法规定解除或者终止劳动合同的,应当依照本法第四十七条规定的经济补偿标准的二倍向劳动者支付赔偿金。"
+
+《劳动合同法》第四十八条、《劳动合同法》第八十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的违法解除行为严重侵害了申请人的劳动权益,给申请人造成了重大经济损失和精神损害。为维护申请人的合法权益,根据《中华人民共和国劳动争议调解仲裁法》的规定,申请人特向贵仲裁委员会提起仲裁申请,恳请贵委依法裁决。
+
+申请人提供的证据材料包括:劳动合同、违法解除通知、工资记录、社保缴费记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):沈芳
+日期:2023-04-15

+ 29 - 0
案例文件/潘强案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:潘强,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:福州物流配送有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、确认劳动关系。
+2、支付未签合同双倍工资差额60500元。
+
+事实与理由:
+
+申请人于2020-02-10起在被申请人处工作,岗位为配送员,月平均工资约人民币5500元。申请人自入职以来,一直在被申请人的经营场所内提供劳动,接受被申请人的劳动管理和工作安排,遵守被申请人的各项规章制度,被申请人按月向申请人支付劳动报酬。
+
+然而,自申请人入职至今,被申请人始终未与申请人签订书面劳动合同。申请人曾多次向被申请人的管理人员提出签订劳动合同的要求,但被申请人每次都以"公司统一办理""等通知"等理由推脱搪塞,至今未与申请人签订书面劳动合同。
+
+
+
+根据《中华人民共和国劳动合同法》第十条规定:"建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。"第八十二条规定:"用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。"
+
+申请人认为,被申请人在与申请人存在事实劳动关系的情况下,长期不签订书面劳动合同的行为严重违反了法律规定,侵害了申请人的合法权益。为明确双方之间的权利义务关系,保障申请人的合法劳动权益,申请人依据相关法律规定向贵仲裁委员会提出仲裁申请,恳请贵委依法裁决。
+
+证据材料:工作证明、工资支付记录、工作沟通记录、同事证言等。上述证据能够充分证明申请人与被申请人之间存在长期、稳定的事实劳动关系。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):潘强
+日期:2023-07-15

+ 26 - 0
案例文件/熊伟案.txt

@@ -0,0 +1,26 @@
+劳动仲裁申请书
+
+申请人:熊伟,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:宜昌化工企业有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付违法解除赔偿金57000元。
+
+事实与理由:
+
+申请人于2020-02-10入职被申请人处,担任安全工程师,月工资标准为人民币9500元。工作期间,申请人表现良好,认真完成各项工作任务,从未出现严重违反公司规章制度或不能胜任工作的情形,被申请人也从未对申请人的工作表现提出过任何书面警告或批评。
+
+直至被用人单位无正当理由违法辞退。被申请人在没有法定事由、也未履行法定程序的情况下,单方面解除了与申请人之间的劳动合同。被申请人的行为属于违法解除劳动合同。
+
+根据《中华人民共和国劳动合同法》第四十八条规定:"用人单位违反本法规定解除或者终止劳动合同,劳动者要求继续履行劳动合同的,用人单位应当继续履行;劳动者不要求继续履行劳动合同或者劳动合同已经不能继续履行的,用人单位应当依照本法第八十七条规定支付赔偿金。"第八十七条规定:"用人单位违反本法规定解除或者终止劳动合同的,应当依照本法第四十七条规定的经济补偿标准的二倍向劳动者支付赔偿金。"
+
+《劳动合同法》第四十八条、《劳动合同法》第八十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的违法解除行为严重侵害了申请人的劳动权益,给申请人造成了重大经济损失和精神损害。为维护申请人的合法权益,根据《中华人民共和国劳动争议调解仲裁法》的规定,申请人特向贵仲裁委员会提起仲裁申请,恳请贵委依法裁决。
+
+申请人提供的证据材料包括:劳动合同、违法解除通知、工资记录、社保缴费记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):熊伟
+日期:2023-07-15

+ 29 - 0
案例文件/王芳案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:王芳,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号,联系电话:138XXXXXXXX。
+
+被申请人:上海恒达信息技术有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX,联系电话:010-XXXXXXXX。
+
+申请事项:
+1、支付加班费18600元。
+2、支付经济补偿金54000元。
+
+事实与理由:
+
+申请人于2021-01-04入职被申请人处,担任产品经理一职,双方签订了书面劳动合同。入职后,申请人严格遵守被申请人的各项规章制度,认真履行岗位职责,按时完成各项工作任务。申请人的月工资标准为人民币18000元,每月通过银行转账方式发放至申请人名下工资卡。
+
+在劳动关系存续期间,2023年3月至7月项目冲刺期间工作日延长加班累计120小时。申请人多次与被申请人沟通协商,要求被申请人按照劳动合同的约定和国家法律法规的规定,足额支付劳动报酬和加班工资,但被申请人以各种理由推诿拖延,始终未予足额支付。
+
+直至2023年8月协商解除劳动合同后未支付经济补偿。
+
+申请人认为,根据中华人民共和国劳动法律法规的规定,用人单位应当按时足额支付劳动者的工资报酬。《劳动合同法》第三十一条、《劳动合同法》第四十六条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已严重违反了相关法律规定,损害了申请人的合法权益,给申请人的生活造成了严重影响。
+
+为维护自身合法权益,申请人依据《中华人民共和国劳动争议调解仲裁法》的相关规定,特向贵仲裁委员会提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决,支持申请人的全部仲裁请求。
+
+申请人提交以下证据材料以证明上述事实:劳动合同、钉钉打卡记录、项目排期文档、解除协议等。上述证据相互印证,能够充分证明申请人与被申请人之间存在劳动关系以及被申请人拖欠劳动报酬的事实。
+
+此致
+上海市浦东新区劳动人事争议仲裁委员会
+
+申请人(签名):王芳
+日期:2023-08-15

+ 29 - 0
案例文件/白涛案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:白涛,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:延吉商贸进出口有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、确认劳动关系。
+2、支付未签合同双倍工资差额71500元。
+
+事实与理由:
+
+申请人于2020-07-10起在被申请人处工作,岗位为报关员,月平均工资约人民币6500元。申请人自入职以来,一直在被申请人的经营场所内提供劳动,接受被申请人的劳动管理和工作安排,遵守被申请人的各项规章制度,被申请人按月向申请人支付劳动报酬。
+
+然而,自申请人入职至今,被申请人始终未与申请人签订书面劳动合同。申请人曾多次向被申请人的管理人员提出签订劳动合同的要求,但被申请人每次都以"公司统一办理""等通知"等理由推脱搪塞,至今未与申请人签订书面劳动合同。
+
+
+
+根据《中华人民共和国劳动合同法》第十条规定:"建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。"第八十二条规定:"用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。"
+
+申请人认为,被申请人在与申请人存在事实劳动关系的情况下,长期不签订书面劳动合同的行为严重违反了法律规定,侵害了申请人的合法权益。为明确双方之间的权利义务关系,保障申请人的合法劳动权益,申请人依据相关法律规定向贵仲裁委员会提出仲裁申请,恳请贵委依法裁决。
+
+证据材料:工作证明、工资支付记录、工作沟通记录、同事证言等。上述证据能够充分证明申请人与被申请人之间存在长期、稳定的事实劳动关系。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):白涛
+日期:2023-03-15

+ 29 - 0
案例文件/秦丽案.txt

@@ -0,0 +1,29 @@
+劳动仲裁申请书
+
+申请人:秦丽,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:乌鲁木齐食品加工有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付一次性伤残补助金29400元。
+2、支付医疗费8400元。
+
+事实与理由:
+
+申请人于2020-09-10入职被申请人处,在包装工岗位工作,月工资人民币4200元,双方建立了合法的劳动关系。申请人在职期间,被申请人依法为申请人缴纳了工伤保险费用。
+
+申请人在工作期间因工作原因受伤/患职业病,经XX医院诊断为:XX损伤。事故发生后,申请人及时向被申请人报告了伤情,被申请人也知晓此事。经人力资源和社会保障局依法认定,申请人所受伤害为工伤(工伤认定书编号:XXXXXX)。经劳动能力鉴定委员会鉴定,申请人劳动功能障碍程度为XX级伤残。
+
+然而,申请人受伤后,被申请人未按照《工伤保险条例》的规定向申请人足额支付各项工伤保险待遇,包括一次性伤残补助金、停工留薪期工资、医疗费等。申请人多次与被申请人就工伤待遇问题进行沟通协商,但被申请人以"公司正在走流程""等保险公司赔付"等理由一直推诿拖延,致使申请人的合法权益长期得不到保障。
+
+根据《工伤保险条例》第十七条、第三十三条、第三十七条等相关规定,用人单位应当及时为受伤职工申请工伤认定并依法支付各项工伤保险待遇。《工伤保险条例》第十七条、《工伤保险条例》第三十七条明确规定,劳动者依法享有获得劳动报酬的权利,被申请人的行为已违反上述法律规定。
+
+为维护自身合法权益,申请人根据《中华人民共和国劳动争议调解仲裁法》的规定提出仲裁申请,恳请贵仲裁委员会查明事实,依法裁决。
+
+证据材料:劳动合同、工伤认定决定书、劳动能力鉴定结论、医疗费发票等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):秦丽
+日期:2023-08-15

+ 28 - 0
案例文件/蒋伟案.txt

@@ -0,0 +1,28 @@
+劳动仲裁申请书
+
+申请人:蒋伟,男/女,汉族,身份证号码:XXXXXXXXXXXXXX,住址:XX省XX市XX区XX路XX号。
+
+被申请人:兰州医疗器械有限公司,住所地:XX省XX市XX区XX路XX号,法定代表人:XXX。
+
+申请事项:
+1、支付经济补偿金30000元。
+
+事实与理由:
+
+申请人于2020-07-10入职被申请人处,在销售代表岗位工作,月工资为人民币7500元。申请人在职期间兢兢业业、勤勉尽责,严格遵守被申请人的各项劳动规章制度,从未发生任何违纪违规行为。双方劳动关系一直持续至2023-03-15。
+
+直至用人单位提出协商解除劳动合同。
+
+根据《中华人民共和国劳动合同法》的相关规定,《劳动合同法》第四十六条、《劳动合同法》第四十七条明确规定,劳动者依法享有获得劳动报酬的权利,用人单位应当向劳动者支付经济补偿金。然而,被申请人至今未向申请人支付任何经济补偿,也未就补偿事宜与申请人进行协商。申请人多次联系被申请人要求支付经济补偿金,均被被申请人以"公司经营困难""再等等"等理由推脱。
+
+经济补偿金的计算应以申请人在被申请人处的工作年限和离职前十二个月的平均工资为基数。申请人在被申请人处连续工作3年,月平均工资为人民币7500元,依据法律规定应获得经济补偿金共计人民币30000元。
+
+申请人认为,被申请人拒绝支付经济补偿金的行为违反了劳动法律法规,严重侵害了申请人的合法权益。为维护自身合法权益,根据《中华人民共和国劳动争议调解仲裁法》之规定,申请人特此向贵委提出仲裁申请,恳请贵委依法裁决被申请人向申请人支付经济补偿金。
+
+证据材料:劳动合同、解除劳动关系证明、工资发放记录等。
+
+此致
+当地劳动人事争议仲裁委员会
+
+申请人(签名):蒋伟
+日期:2023-03-15

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini