Преглед на файлове

Merge branch 'dev_sgsc_wxm' of CRBC-MaaS-Platform-Project/LQAgentPlatform into dev

WangXuMing преди 2 дни
родител
ревизия
e27dc30084
променени са 27 файла, в които са добавени 723 реда и са изтрити 3514 реда
  1. 160 154
      config/config.ini
  2. 184 180
      config/config.ini.template
  3. 14 38
      config/model_setting.yaml
  4. 46 35
      config/模型调用指南.md
  5. 3 3
      core/construction_review/component/doc_worker/config/config.yaml
  6. 6 14
      core/construction_review/component/infrastructure/relevance.py
  7. 11 1
      core/construction_review/component/minimal_pipeline/simple_processor.py
  8. 0 427
      core/construction_review/component/outline_catalogue_matcher.py
  9. 2 8
      core/construction_review/component/reviewers/__init__.py
  10. 1 4
      core/construction_review/component/reviewers/base_reviewer.py
  11. 0 342
      core/construction_review/component/reviewers/check_completeness/components/result_analyzer.py
  12. 21 378
      core/construction_review/component/reviewers/completeness_reviewer.py
  13. 41 0
      core/construction_review/component/reviewers/prompt/completeness_reviewers.yaml
  14. 12 2
      core/construction_review/component/reviewers/utils/prompt_loader.py
  15. 1 0
      foundation/ai/agent/generate/model_generate.py
  16. 2 2
      foundation/ai/models/model_config_loader.py
  17. 73 100
      foundation/ai/models/model_handler.py
  18. 43 22
      foundation/ai/rag/retrieval/retrieval.py
  19. 0 122
      utils_test/minimal_pipeline/chunk_assembler.py
  20. 0 472
      utils_test/minimal_pipeline/classifier.py
  21. 0 100
      utils_test/minimal_pipeline/models.py
  22. 0 289
      utils_test/minimal_pipeline/pdf_extractor.py
  23. 0 194
      utils_test/minimal_pipeline/pipeline.py
  24. 0 173
      utils_test/minimal_pipeline/run.py
  25. 0 48
      utils_test/minimal_pipeline/toc_builder.py
  26. 52 206
      views/construction_write/content_completion.py
  27. 51 200
      views/construction_write/outline_views.py

+ 160 - 154
config/config.ini

@@ -1,81 +1,21 @@
+# ============================================================
+#  LQAgentPlatform 配置文件
+#  按业务分类组织:应用 → 基础设施 → 模型凭证 → 业务功能
+# ============================================================
 
 
-[model]
-# 注意:模型配置已迁移到 model_setting.yaml
-# 请通过 config/model_config_loader.py 获取模型配置
-# Embedding模型类型选择: lq_qwen3_8b_emd, siliconflow_embed, shutian_qwen3_embed
-EMBEDDING_MODEL_TYPE=shutian_qwen3_embed
-
-# Rerank模型类型选择: bge_rerank_model, lq_rerank_model, silicoflow_rerank_model
-RERANK_MODEL_TYPE=shutian_rerank_model
-
-
-[deepseek]
-DEEPSEEK_SERVER_URL=https://api.deepseek.com
-DEEPSEEK_MODEL_ID=deepseek-chat
-DEEPSEEK_API_KEY=sk-9fe722389bac47e9ab30cf45b32eb736
-
-[doubao]
-DOUBAO_SERVER_URL=https://ark.cn-beijing.volces.com/api/v3/
-DOUBAO_MODEL_ID=doubao-seed-1-6-flash-250715
-DOUBAO_API_KEY=c98686df-506f-432c-98de-32e571a8e916
-
-
-[qwen]
-QWEN_SERVER_URL=http://192.168.91.253:8003/v1/
-QWEN_MODEL_ID=qwen3-30b
-QWEN_API_KEY=sk-123456
-
-# Qwen3-30B 独立配置(与qwen配置相同,方便后续独立管理)
-[qwen3_30b]
-QWEN3_30B_SERVER_URL=http://192.168.91.253:8003/v1/
-QWEN3_30B_MODEL_ID=qwen3-30b
-QWEN3_30B_API_KEY=sk-123456
-
-
-[ai_review]
-# 调试模式配置
-MAX_REVIEW_UNITS=5
-REVIEW_MODE=all
-# REVIEW_MODE=all/random/first
-
+# ============================================================
+# 一、应用配置
+# ============================================================
 
 [app]
 APP_CODE=lq-agent
 APP_SECRET=sx-73d32556-605e-11f0-9dd8-acde48001122
 
-
 [launch]
 HOST = 0.0.0.0
 LAUNCH_PORT = 8002
 
-[redis]
-REDIS_URL=redis://:123456@127.0.0.1:6379
-REDIS_HOST=127.0.0.1
-REDIS_PORT=6379
-REDIS_DB=0
-REDIS_PASSWORD=123456
-REDIS_MAX_CONNECTIONS=50
-
-[ocr]
-# 是否启用 OCR 表格识别(true/false)
-enable = true
-
-# OCR 引擎选择(以下写法都支持):
-# GLM-OCR: glm_ocr | glm-ocr | glmocr
-# MinerU:  mineru | mineru-ocr | mineru_ocr
-# 默认: glm_ocr
-ENGINE=glm-ocr
-
-# GLM-OCR 配置
-GLM_OCR_API_URL=http://183.220.37.46:25429/v1/chat/completions
-GLM_OCR_TIMEOUT=600
-GLM_OCR_API_KEY=sk_prod_sXgHYxfVvZdw7O-cki6i7Cp2TbguOvbA_f4beb12a
-
-# MinerU 配置  
-MINERU_API_URL=http://183.220.37.46:25428/file_parse
-MINERU_TIMEOUT=300
-
 [log]
 LOG_FILE_PATH=logs
 LOG_FILE_MAX_MB=10
@@ -86,63 +26,17 @@ CONSOLE_OUTPUT=True
 USERS=['user-001']
 
 
-[siliconflow]
-SLCF_MODEL_SERVER_URL=https://api.siliconflow.cn/v1
-SLCF_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
-SLCF_CHAT_MODEL_ID=test-model
-SLCF_EMBED_MODEL_ID=netease-youdao/bce-embedding-base_v1
-SLCF_REANKER_MODEL_ID=BAAI/bge-reranker-v2-m3
-SLCF_VL_CHAT_MODEL_ID=THUDM/GLM-4.1V-9B-Thinking
-
-[siliconflow_embed]
-# 硅基流动 Embedding 模型配置
-SLCF_EMBED_SERVER_URL=https://api.siliconflow.cn/v1
-SLCF_EMBED_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
-SLCF_EMBED_MODEL_ID=Qwen/Qwen3-Embedding-8B
-SLCF_EMBED_DIMENSIONS=4096
-
-[lq_qwen3_8b]
-QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9002/v1
-QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-8B
-QWEN_LOCAL_1_5B_API_KEY=dummy
-
-# 本地部署的Qwen3-Embedding-8B配置
-[lq_qwen3_8b_emd]
-LQ_EMBEDDING_SERVER_URL=http://192.168.91.253:9003/v1
-LQ_EMBEDDING_MODEL_ID=Qwen3-Embedding-8B
-LQ_EMBEDDING_API_KEY=dummy
-
-[lq_qwen3_4b]
-QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9001/v1
-QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-4B
-QWEN_LOCAL_1_5B_API_KEY=dummy
-
-# 本地部署的Qwen3-Reranker-8B配置
-[lq_rerank_model]
-LQ_RERANKER_SERVER_URL=http://192.168.91.253:9004/v1/rerank
-LQ_RERANKER_MODEL=Qwen3-Reranker-8B
-LQ_RERANKER_API_KEY=dummy
-LQ_RERANKER_TOP_N=10
-
-# 硅基流动API的Qwen3-Reranker-8B配置
-[silicoflow_rerank_model]
-SILICOFLOW_RERANKER_API_URL=https://api.siliconflow.cn/v1/rerank
-SILICOFLOW_RERANKER_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
-SILICOFLOW_RERANKER_MODEL=Qwen/Qwen3-Reranker-8B
-
-# BGE Reranker配置
-[bge_rerank_model]
-BGE_RERANKER_SERVER_URL=http://192.168.91.253:9004/rerank
-BGE_RERANKER_MODEL=BAAI/bge-reranker-v2-m3
-BGE_RERANKER_API_KEY=dummy
-BGE_RERANKER_TOP_N=10
-
-[lq_qwen3_8B_lora]
-LQ_QWEN3_8B_LQ_LORA_SERVER_URL=http://192.168.91.253:9006/v1
-LQ_QWEN3_8B_LQ_LORA_MODEL_ID=Qwen3-8B-lq-lora
-LQ_QWEN3_8B_LQ_LORA_API_KEY=dummy
-
+# ============================================================
+# 二、系统基础设施
+# ============================================================
 
+[redis]
+REDIS_URL=redis://:123456@127.0.0.1:6379
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+REDIS_DB=0
+REDIS_PASSWORD=123456
+REDIS_MAX_CONNECTIONS=50
 
 [mysql]
 MYSQL_HOST=192.168.92.61
@@ -154,7 +48,6 @@ MYSQL_MIN_SIZE=1
 MYSQL_MAX_SIZE=5
 MYSQL_AUTO_COMMIT=True
 
-
 [pgvector]
 PGVECTOR_HOST=124.223.140.149
 PGVECTOR_PORT=7432
@@ -162,7 +55,22 @@ PGVECTOR_DB=vector_db
 PGVECTOR_USER=vector_user
 PGVECTOR_PASSWORD=pg16@123
 
-# 蜀天AI模型服务器配置(183.220.37.46)
+[milvus]
+MILVUS_HOST=192.168.92.96
+MILVUS_PORT=30129
+MILVUS_DB=lq_db
+MILVUS_USER=
+MILVUS_PASSWORD=
+
+
+# ============================================================
+# 三、模型密钥配置
+# ============================================================
+
+# --------------------------------------------------
+# 3.1 蜀天算力(主用,183.220.37.46)
+# --------------------------------------------------
+
 [shutian]
 # Qwen3.5-122B-A10B 模型(端口25423)
 SHUTIAN_122B_SERVER_URL=http://183.220.37.46:25423/v1
@@ -195,52 +103,141 @@ SHUTIAN_RERANK_MODEL_ID=/model/Qwen3-Reranker-8B
 SHUTIAN_RERANK_API_KEY=sk_prod_dvgYHKWFoQlYAKmkIvBSyuguNSQGeNh0_23c65608
 
 
-[milvus]
-MILVUS_HOST=192.168.92.96
-MILVUS_PORT=30129
-MILVUS_DB=lq_db
-MILVUS_USER=
-MILVUS_PASSWORD=
-
-
-[hybrid_search]
-# 混合检索权重配置
-DENSE_WEIGHT=0.3
-SPARSE_WEIGHT=0.7
-
-[rag_collections]
-# RAG 检索链路使用的 Milvus 集合名
-ENTITY_COLLECTION=first_bfp_collection_entity
-CHILDREN_COLLECTION=t_rag_kng_standard # 子分段集合
-PARENT_COLLECTION=t_rag_kng_standard_parent # 父分段集合
-
-
-# ============================================================
-# DashScope Qwen3.5 系列模型配置
-# ============================================================
+# --------------------------------------------------
+# 3.2 DashScope 阿里云(备用)
+# --------------------------------------------------
 
-# DashScope Qwen3.5-35B-A3B 模型
 [qwen3_5_35b_a3b]
 DASHSCOPE_SERVER_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
 DASHSCOPE_MODEL_ID=qwen3.5-35b-a3b
 DASHSCOPE_API_KEY=sk-98cca096416a41d5a6cec68b824486c5
 
-# DashScope Qwen3.5-27B 模型
 [qwen3_5_27b]
 DASHSCOPE_SERVER_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
 DASHSCOPE_MODEL_ID=qwen3.5-27b
 DASHSCOPE_API_KEY=sk-98cca096416a41d5a6cec68b824486c5
 
-# DashScope Qwen3.5-122B-A10B 模型
 [qwen3_5_122b_a10b]
 DASHSCOPE_SERVER_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
 DASHSCOPE_MODEL_ID=qwen3.5-122b-a10b
 DASHSCOPE_API_KEY=sk-98cca096416a41d5a6cec68b824486c5
 
+
+# --------------------------------------------------
+# 3.3 第三方云端模型
+# --------------------------------------------------
+
+[deepseek]
+DEEPSEEK_SERVER_URL=https://api.deepseek.com
+DEEPSEEK_MODEL_ID=deepseek-chat
+DEEPSEEK_API_KEY=sk-9fe722389bac47e9ab30cf45b32eb736
+
+[doubao]
+DOUBAO_SERVER_URL=https://ark.cn-beijing.volces.com/api/v3/
+DOUBAO_MODEL_ID=doubao-seed-1-6-flash-250715
+DOUBAO_API_KEY=c98686df-506f-432c-98de-32e571a8e916
+
+[qwen]
+QWEN_SERVER_URL=http://192.168.91.253:8003/v1/
+QWEN_MODEL_ID=qwen3-30b
+QWEN_API_KEY=sk-123456
+
+[siliconflow]
+# 已废弃 — 各功能已拆分到独立 section:[siliconflow_embed] / [silicoflow_rerank_model]
+SLCF_MODEL_SERVER_URL=https://api.siliconflow.cn/v1
+SLCF_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
+
+[siliconflow_embed]
+SLCF_EMBED_SERVER_URL=https://api.siliconflow.cn/v1
+SLCF_EMBED_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
+SLCF_EMBED_MODEL_ID=Qwen/Qwen3-Embedding-8B
+SLCF_EMBED_DIMENSIONS=4096
+
+[silicoflow_rerank_model]
+SILICOFLOW_RERANKER_API_URL=https://api.siliconflow.cn/v1/rerank
+SILICOFLOW_RERANKER_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
+SILICOFLOW_RERANKER_MODEL=Qwen/Qwen3-Reranker-8B
+
+
+# --------------------------------------------------
+# 3.4 本地部署模型
+# --------------------------------------------------
+
+[lq_qwen3_8b]
+QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9002/v1
+QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-8B
+QWEN_LOCAL_1_5B_API_KEY=dummy
+
+[lq_qwen3_4b]
+QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9001/v1
+QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-4B
+QWEN_LOCAL_1_5B_API_KEY=dummy
+
+[lq_qwen3_8B_lora]
+LQ_QWEN3_8B_LQ_LORA_SERVER_URL=http://192.168.91.253:9006/v1
+LQ_QWEN3_8B_LQ_LORA_MODEL_ID=Qwen3-8B-lq-lora
+LQ_QWEN3_8B_LQ_LORA_API_KEY=dummy
+
+[lq_qwen3_8b_emd]
+LQ_EMBEDDING_SERVER_URL=http://192.168.91.253:9003/v1
+LQ_EMBEDDING_MODEL_ID=Qwen3-Embedding-8B
+LQ_EMBEDDING_API_KEY=dummy
+
+[lq_rerank_model]
+LQ_RERANKER_SERVER_URL=http://192.168.91.253:9004/v1/rerank
+LQ_RERANKER_MODEL=Qwen3-Reranker-8B
+LQ_RERANKER_API_KEY=dummy
+LQ_RERANKER_TOP_N=10
+
+[bge_rerank_model]
+BGE_RERANKER_SERVER_URL=http://192.168.91.253:9004/rerank
+BGE_RERANKER_MODEL=BAAI/bge-reranker-v2-m3
+BGE_RERANKER_API_KEY=dummy
+BGE_RERANKER_TOP_N=10
+
+
 # ============================================================
-# LLM 通用配置
+# 四、业务功能配置
 # ============================================================
 
+# --------------------------------------------------
+# 4.1 审查配置
+# --------------------------------------------------
+
+[ai_review]
+MAX_REVIEW_UNITS=5
+REVIEW_MODE=all
+# REVIEW_MODE=all/random/first
+
+[construction_review]
+MAX_CELERY_TASKS=1
+
+[timeliness_review]
+# 时效性审查匹配前需要去除的符号
+REMOVE_SYMBOLS=),-,.,/,,:,[,],【,】,〔,〕,(,),-,—,―,‐,‑,‒,–,−
+
+
+# --------------------------------------------------
+# 4.2 OCR 配置
+# --------------------------------------------------
+
+[ocr]
+enable = true
+
+# GLM-OCR 配置
+GLM_OCR_API_URL=http://183.220.37.46:25429/v1/chat/completions
+GLM_OCR_TIMEOUT=600
+GLM_OCR_API_KEY=sk_prod_sXgHYxfVvZdw7O-cki6i7Cp2TbguOvbA_f4beb12a
+
+# MinerU 配置
+MINERU_API_URL=http://183.220.37.46:25428/file_parse
+MINERU_TIMEOUT=300
+
+
+# --------------------------------------------------
+# 4.3 LLM 调用参数
+# --------------------------------------------------
+
 [llm_keywords]
 TIMEOUT=60
 MAX_RETRIES=2
@@ -249,8 +246,17 @@ STREAM=false
 TEMPERATURE=0.3
 MAX_TOKENS=1024
 
-[construction_review]
-MAX_CELERY_TASKS=1
 
+# --------------------------------------------------
+# 4.4 RAG 检索配置
+# --------------------------------------------------
 
+[hybrid_search]
+DENSE_WEIGHT=0.3
+SPARSE_WEIGHT=0.7
+
+[rag_collections]
+ENTITY_COLLECTION=first_bfp_collection_entity
+CHILDREN_COLLECTION=t_rag_kng_standard
+PARENT_COLLECTION=t_rag_kng_standard_parent
 

+ 184 - 180
config/config.ini.template

@@ -1,81 +1,22 @@
+# ============================================================
+#  LQAgentPlatform 配置文件模板
+#  按业务分类组织:应用 → 基础设施 → 模型凭证 → 业务功能
+#  复制为 config.ini 后填入实际密钥
+# ============================================================
 
 
-[model]
-# 注意:模型配置已迁移到 model_setting.yaml
-# 请通过 config/model_config_loader.py 获取模型配置
-# Embedding模型类型选择: lq_qwen3_8b_emd, siliconflow_embed, shutian_qwen3_embed
-EMBEDDING_MODEL_TYPE=shutian_qwen3_embed
-
-# Rerank模型类型选择: bge_rerank_model, lq_rerank_model, silicoflow_rerank_model
-RERANK_MODEL_TYPE=shutian_rerank_model
-
-
-[deepseek]
-DEEPSEEK_SERVER_URL=https://api.deepseek.com
-DEEPSEEK_MODEL_ID=deepseek-chat
-DEEPSEEK_API_KEY=sk-9fe722389bac47e9ab30cf45b32eb736
-
-[doubao]
-DOUBAO_SERVER_URL=https://ark.cn-beijing.volces.com/api/v3/
-DOUBAO_MODEL_ID=doubao-seed-1-6-flash-250715
-DOUBAO_API_KEY=c98686df-506f-432c-98de-32e571a8e916
-
-
-[qwen]
-QWEN_SERVER_URL=http://192.168.91.253:8003/v1/
-QWEN_MODEL_ID=qwen3-30b
-QWEN_API_KEY=sk-123456
-
-# Qwen3-30B 独立配置(与qwen配置相同,方便后续独立管理)
-[qwen3_30b]
-QWEN3_30B_SERVER_URL=http://192.168.91.253:8003/v1/
-QWEN3_30B_MODEL_ID=qwen3-30b
-QWEN3_30B_API_KEY=sk-123456
-
-
-[ai_review]
-# 调试模式配置
-MAX_REVIEW_UNITS=5
-REVIEW_MODE=all
-# REVIEW_MODE=all/random/first
-
+# ============================================================
+# 一、应用配置
+# ============================================================
 
 [app]
 APP_CODE=lq-agent
-APP_SECRET=sx-73d32556-605e-11f0-9dd8-acde48001122
-
+APP_SECRET=<your-app-secret>
 
 [launch]
 HOST = 0.0.0.0
 LAUNCH_PORT = 8002
 
-[redis]
-REDIS_URL=redis://:Wxcz666@@lqRedis_dev:6379
-REDIS_HOST=lqRedis_dev
-REDIS_PORT=6379
-REDIS_DB=0
-REDIS_PASSWORD=Wxcz666@
-REDIS_MAX_CONNECTIONS=50
-
-[ocr]
-# 是否启用 OCR 表格识别(true/false)
-enable = true
-
-# OCR 引擎选择(以下写法都支持):
-# GLM-OCR: glm_ocr | glm-ocr | glmocr
-# MinerU:  mineru | mineru-ocr | mineru_ocr
-# 默认: glm_ocr
-ENGINE=glm-ocr
-
-# GLM-OCR 配置
-GLM_OCR_API_URL=http://183.220.37.46:25429/v1/chat/completions
-GLM_OCR_TIMEOUT=600
-GLM_OCR_API_KEY=2026_Unified_Secure_Key
-
-# MinerU 配置  
-MINERU_API_URL=http://183.220.37.46:25428/file_parse
-MINERU_TIMEOUT=300
-
 [log]
 LOG_FILE_PATH=logs
 LOG_FILE_MAX_MB=10
@@ -86,161 +27,216 @@ CONSOLE_OUTPUT=True
 USERS=['user-001']
 
 
-[siliconflow]
-SLCF_MODEL_SERVER_URL=https://api.siliconflow.cn/v1
-SLCF_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
-SLCF_CHAT_MODEL_ID=test-model
-SLCF_EMBED_MODEL_ID=netease-youdao/bce-embedding-base_v1
-SLCF_REANKER_MODEL_ID=BAAI/bge-reranker-v2-m3
-SLCF_VL_CHAT_MODEL_ID=THUDM/GLM-4.1V-9B-Thinking
-
-[siliconflow_embed]
-# 硅基流动 Embedding 模型配置
-SLCF_EMBED_SERVER_URL=https://api.siliconflow.cn/v1
-SLCF_EMBED_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
-SLCF_EMBED_MODEL_ID=Qwen/Qwen3-Embedding-8B
-SLCF_EMBED_DIMENSIONS=4096
-
-[lq_qwen3_8b]
-QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9002/v1
-QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-8B
-QWEN_LOCAL_1_5B_API_KEY=dummy
-
-# 本地部署的Qwen3-Embedding-8B配置
-[lq_qwen3_8b_emd]
-LQ_EMBEDDING_SERVER_URL=http://192.168.91.253:9003/v1
-LQ_EMBEDDING_MODEL_ID=Qwen3-Embedding-8B
-LQ_EMBEDDING_API_KEY=dummy
-
-[lq_qwen3_4b]
-QWEN_LOCAL_1_5B_SERVER_URL=http://192.168.91.253:9001/v1
-QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-4B
-QWEN_LOCAL_1_5B_API_KEY=dummy
-
-# 本地部署的Qwen3-Reranker-8B配置
-[lq_rerank_model]
-LQ_RERANKER_SERVER_URL=http://192.168.91.253:9004/v1/rerank
-LQ_RERANKER_MODEL=Qwen3-Reranker-8B
-LQ_RERANKER_API_KEY=dummy
-LQ_RERANKER_TOP_N=10
-
-# 硅基流动API的Qwen3-Reranker-8B配置
-[silicoflow_rerank_model]
-SILICOFLOW_RERANKER_API_URL=https://api.siliconflow.cn/v1/rerank
-SILICOFLOW_RERANKER_API_KEY=sk-rdabeukkgfwyelstbqlcupsrwfkmduqvadztvxeyumvllstt
-SILICOFLOW_RERANKER_MODEL=Qwen/Qwen3-Reranker-8B
-
-# BGE Reranker配置
-[bge_rerank_model]
-BGE_RERANKER_SERVER_URL=http://192.168.91.253:9004/rerank
-BGE_RERANKER_MODEL=BAAI/bge-reranker-v2-m3
-BGE_RERANKER_API_KEY=dummy
-BGE_RERANKER_TOP_N=10
-
-[lq_qwen3_8B_lora]
-LQ_QWEN3_8B_LQ_LORA_SERVER_URL=http://192.168.91.253:9006/v1
-LQ_QWEN3_8B_LQ_LORA_MODEL_ID=Qwen3-8B-lq-lora
-LQ_QWEN3_8B_LQ_LORA_API_KEY=dummy
-
+# ============================================================
+# 二、系统基础设施
+# ============================================================
 
+[redis]
+REDIS_URL=redis://:<password>@<host>:6379
+REDIS_HOST=<redis-host>
+REDIS_PORT=6379
+REDIS_DB=0
+REDIS_PASSWORD=<redis-password>
+REDIS_MAX_CONNECTIONS=50
 
 [mysql]
-MYSQL_HOST=192.168.92.61
+MYSQL_HOST=<mysql-host>
 MYSQL_PORT=13306
 MYSQL_USER=root
-MYSQL_PASSWORD=Lq123456!
+MYSQL_PASSWORD=<mysql-password>
 MYSQL_DB=lq_db_dev
 MYSQL_MIN_SIZE=1
 MYSQL_MAX_SIZE=5
 MYSQL_AUTO_COMMIT=True
 
-
 [pgvector]
-PGVECTOR_HOST=124.223.140.149
+PGVECTOR_HOST=<pgvector-host>
 PGVECTOR_PORT=7432
 PGVECTOR_DB=vector_db
 PGVECTOR_USER=vector_user
-PGVECTOR_PASSWORD=pg16@123
+PGVECTOR_PASSWORD=<pgvector-password>
+
+[milvus]
+MILVUS_HOST=<milvus-host>
+MILVUS_PORT=30129
+MILVUS_DB=lq_db
+MILVUS_USER=
+MILVUS_PASSWORD=
+
+
+# ============================================================
+# 三、模型密钥配置
+# ============================================================
+
+# --------------------------------------------------
+# 3.1 蜀天算力(主用)
+# --------------------------------------------------
 
-# 蜀天AI模型服务器配置(183.220.37.46)
 [shutian]
-# Qwen3.5-122B-A10B 模型(端口25423)
-SHUTIAN_122B_SERVER_URL=http://183.220.37.46:25423/v1
+# Qwen3.5-122B-A10B 模型
+SHUTIAN_122B_SERVER_URL=http://<shutian-host>:25423/v1
 SHUTIAN_122B_MODEL_ID=/model/Qwen3.5-122B-A10B
-SHUTIAN_122B_API_KEY=sk-prod_ojkjwcO4TTd9TL3vK6uo8a2Dvcdoz64u_9a89845f
+SHUTIAN_122B_API_KEY=<shutian-122b-api-key>
 
-# Qwen3-8B 模型(端口25424)
-SHUTIAN_8B_SERVER_URL=http://183.220.37.46:25424/v1
+# Qwen3-8B 模型
+SHUTIAN_8B_SERVER_URL=http://<shutian-host>:25424/v1
 SHUTIAN_8B_MODEL_ID=/model/Qwen3-8B
-SHUTIAN_8B_API_KEY=sk_prod_SELVoIV1d3gku28koH_ONg8L_B2cQis__71f55615
+SHUTIAN_8B_API_KEY=<shutian-8b-api-key>
 
-# Qwen3.6-27B 模型(端口25424)
-SHUTIAN_27B_SERVER_URL=http://183.220.37.46:25424/v1
+# Qwen3.6-27B 模型
+SHUTIAN_27B_SERVER_URL=http://<shutian-host>:25424/v1
 SHUTIAN_27B_MODEL_ID=/model/Qwen3.6-27B
-SHUTIAN_27B_API_KEY=sk_prod_HH21x5WB9Pm7IM9Bf808BoJPEn_4bPX5_f2c5f3f6
+SHUTIAN_27B_API_KEY=<shutian-27b-api-key>
 
-# Qwen3.5-35B 模型(端口25427)
-SHUTIAN_35B_SERVER_URL=http://183.220.37.46:25427/v1
+# Qwen3.5-35B 模型
+SHUTIAN_35B_SERVER_URL=http://<shutian-host>:25427/v1
 SHUTIAN_35B_MODEL_ID=/model/Qwen3.5-35B
-SHUTIAN_35B_API_KEY=sk_prod_0NuLZt1a2UrD80F9iB-GTxOIuAkJSZxH_5522d7ae
+SHUTIAN_35B_API_KEY=<shutian-35b-api-key>
 
-# Qwen3-Embedding-8B 嵌入模型(端口25425)
-SHUTIAN_EMBED_SERVER_URL=http://183.220.37.46:25425/v1
+# Qwen3-Embedding-8B 嵌入模型
+SHUTIAN_EMBED_SERVER_URL=http://<shutian-host>:25425/v1
 SHUTIAN_EMBED_MODEL_ID=/model/Qwen3-Embedding-8B
-SHUTIAN_EMBED_API_KEY=sk_prod_3HDoVka8mU8Jqj9Xnmfkn8bxk5kmzKrz_700c186f
+SHUTIAN_EMBED_API_KEY=<shutian-embed-api-key>
 
-# Qwen3-Reranker-8B 重排序模型(端口25426)
-SHUTIAN_RERANK_SERVER_URL=http://183.220.37.46:25426/v1/rerank
+# Qwen3-Reranker-8B 重排序模型
+SHUTIAN_RERANK_SERVER_URL=http://<shutian-host>:25426/v1/rerank
 SHUTIAN_RERANK_MODEL_ID=/model/Qwen3-Reranker-8B
-SHUTIAN_RERANK_API_KEY=sk_prod_dvgYHKWFoQlYAKmkIvBSyuguNSQGeNh0_23c65608
+SHUTIAN_RERANK_API_KEY=<shutian-rerank-api-key>
 
 
-[milvus]
-MILVUS_HOST=192.168.92.96
-MILVUS_PORT=30129
-MILVUS_DB=lq_db
-MILVUS_USER=
-MILVUS_PASSWORD=
-
-
-[hybrid_search]
-# 混合检索权重配置
-DENSE_WEIGHT=0.3
-SPARSE_WEIGHT=0.7
-
-[rag_collections]
-# RAG 检索链路使用的 Milvus 集合名
-ENTITY_COLLECTION=first_bfp_collection_entity
-CHILDREN_COLLECTION=t_rag_kng_standard # 子分段集合
-PARENT_COLLECTION=t_rag_kng_standard_parent # 父分段集合
-
+# --------------------------------------------------
+# 3.2 DashScope 阿里云(备用)
+# --------------------------------------------------
 
-# ============================================================
-# DashScope Qwen3.5 系列模型配置
-# ============================================================
-
-# DashScope Qwen3.5-35B-A3B 模型
 [qwen3_5_35b_a3b]
 DASHSCOPE_SERVER_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
 DASHSCOPE_MODEL_ID=qwen3.5-35b-a3b
-DASHSCOPE_API_KEY=sk-98cca096416a41d5a6cec68b824486c5
+DASHSCOPE_API_KEY=<dashscope-api-key>
 
-# DashScope Qwen3.5-27B 模型
 [qwen3_5_27b]
 DASHSCOPE_SERVER_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
 DASHSCOPE_MODEL_ID=qwen3.5-27b
-DASHSCOPE_API_KEY=sk-98cca096416a41d5a6cec68b824486c5
+DASHSCOPE_API_KEY=<dashscope-api-key>
 
-# DashScope Qwen3.5-122B-A10B 模型
 [qwen3_5_122b_a10b]
 DASHSCOPE_SERVER_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
 DASHSCOPE_MODEL_ID=qwen3.5-122b-a10b
-DASHSCOPE_API_KEY=sk-98cca096416a41d5a6cec68b824486c5
+DASHSCOPE_API_KEY=<dashscope-api-key>
+
+
+# --------------------------------------------------
+# 3.3 第三方云端模型
+# --------------------------------------------------
+
+[deepseek]
+DEEPSEEK_SERVER_URL=https://api.deepseek.com
+DEEPSEEK_MODEL_ID=deepseek-chat
+DEEPSEEK_API_KEY=<deepseek-api-key>
+
+[doubao]
+DOUBAO_SERVER_URL=https://ark.cn-beijing.volces.com/api/v3/
+DOUBAO_MODEL_ID=doubao-seed-1-6-flash-250715
+DOUBAO_API_KEY=<doubao-api-key>
+
+[qwen]
+QWEN_SERVER_URL=http://<qwen-host>:8003/v1/
+QWEN_MODEL_ID=qwen3-30b
+QWEN_API_KEY=<qwen-api-key>
+
+[siliconflow]
+# 已废弃 — 各功能已拆分到独立 section
+SLCF_MODEL_SERVER_URL=https://api.siliconflow.cn/v1
+SLCF_API_KEY=<siliconflow-api-key>
+
+[siliconflow_embed]
+SLCF_EMBED_SERVER_URL=https://api.siliconflow.cn/v1
+SLCF_EMBED_API_KEY=<siliconflow-api-key>
+SLCF_EMBED_MODEL_ID=Qwen/Qwen3-Embedding-8B
+SLCF_EMBED_DIMENSIONS=4096
+
+[silicoflow_rerank_model]
+SILICOFLOW_RERANKER_API_URL=https://api.siliconflow.cn/v1/rerank
+SILICOFLOW_RERANKER_API_KEY=<siliconflow-api-key>
+SILICOFLOW_RERANKER_MODEL=Qwen/Qwen3-Reranker-8B
+
+
+# --------------------------------------------------
+# 3.4 本地部署模型
+# --------------------------------------------------
+
+[lq_qwen3_8b]
+QWEN_LOCAL_1_5B_SERVER_URL=http://<local-host>:9002/v1
+QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-8B
+QWEN_LOCAL_1_5B_API_KEY=dummy
+
+[lq_qwen3_4b]
+QWEN_LOCAL_1_5B_SERVER_URL=http://<local-host>:9001/v1
+QWEN_LOCAL_1_5B_MODEL_ID=Qwen3-4B
+QWEN_LOCAL_1_5B_API_KEY=dummy
+
+[lq_qwen3_8B_lora]
+LQ_QWEN3_8B_LQ_LORA_SERVER_URL=http://<local-host>:9006/v1
+LQ_QWEN3_8B_LQ_LORA_MODEL_ID=Qwen3-8B-lq-lora
+LQ_QWEN3_8B_LQ_LORA_API_KEY=dummy
+
+[lq_qwen3_8b_emd]
+LQ_EMBEDDING_SERVER_URL=http://<local-host>:9003/v1
+LQ_EMBEDDING_MODEL_ID=Qwen3-Embedding-8B
+LQ_EMBEDDING_API_KEY=dummy
+
+[lq_rerank_model]
+LQ_RERANKER_SERVER_URL=http://<local-host>:9004/v1/rerank
+LQ_RERANKER_MODEL=Qwen3-Reranker-8B
+LQ_RERANKER_API_KEY=dummy
+LQ_RERANKER_TOP_N=10
+
+[bge_rerank_model]
+BGE_RERANKER_SERVER_URL=http://<local-host>:9004/rerank
+BGE_RERANKER_MODEL=BAAI/bge-reranker-v2-m3
+BGE_RERANKER_API_KEY=dummy
+BGE_RERANKER_TOP_N=10
+
 
 # ============================================================
-# LLM 通用配置
+# 四、业务功能配置
 # ============================================================
 
+# --------------------------------------------------
+# 4.1 审查配置
+# --------------------------------------------------
+
+[ai_review]
+MAX_REVIEW_UNITS=5
+REVIEW_MODE=all
+
+[construction_review]
+MAX_CELERY_TASKS=1
+
+[timeliness_review]
+REMOVE_SYMBOLS=),-,.,/,,:,[,],【,】,〔,〕,(,),-,—,―,‐,‑,‒,–,−
+
+
+# --------------------------------------------------
+# 4.2 OCR 配置
+# --------------------------------------------------
+
+[ocr]
+enable = true
+
+# GLM-OCR
+GLM_OCR_API_URL=http://<ocr-host>:25429/v1/chat/completions
+GLM_OCR_TIMEOUT=600
+GLM_OCR_API_KEY=<glm-ocr-api-key>
+
+# MinerU
+MINERU_API_URL=http://<ocr-host>:25428/file_parse
+MINERU_TIMEOUT=300
+
+
+# --------------------------------------------------
+# 4.3 LLM 调用参数
+# --------------------------------------------------
+
 [llm_keywords]
 TIMEOUT=60
 MAX_RETRIES=2
@@ -249,9 +245,17 @@ STREAM=false
 TEMPERATURE=0.3
 MAX_TOKENS=1024
 
-[construction_review]
-MAX_CELERY_TASKS=1
 
+# --------------------------------------------------
+# 4.4 RAG 检索配置
+# --------------------------------------------------
 
+[hybrid_search]
+DENSE_WEIGHT=0.3
+SPARSE_WEIGHT=0.7
 
+[rag_collections]
+ENTITY_COLLECTION=first_bfp_collection_entity
+CHILDREN_COLLECTION=t_rag_kng_standard
+PARENT_COLLECTION=t_rag_kng_standard_parent
 

+ 14 - 38
config/model_setting.yaml

@@ -35,8 +35,10 @@ available_models:
   - shutian_qwen3_embed    # 蜀天Embedding
 
   # Reranker 模型
-  - lq_bge_reranker_v2_m3  # BGE-reranker-v2-m3
-  - shutian_qwen3_reranker # 蜀天Reranker
+  - lq_bge_reranker_v2_m3        # 本地BGE-reranker-v2-m3
+  - lq_qwen3_reranker            # 本地Qwen3-Reranker-8B
+  - shutian_qwen3_reranker       # 蜀天Reranker
+  - silicoflow_qwen3_reranker    # 硅基流动Reranker
 
 # 功能模块模型配置
 model_settings:
@@ -52,42 +54,12 @@ model_settings:
     enable_thinking: false
     description: "文档二级分类,蜀天122B"
 
-  # 文档分类 - 三级分类(需要高精度行级分类)
-  doc_classification_tertiary:
-    model: shutian_qwen3_5_122b
-    enable_thinking: false
-    description: "文档三级分类,蜀天122B"
-
-  # 文档分类 - 三级分类复杂段落(可选更强的模型)
-  doc_classification_tertiary_complex:
-    model: shutian_qwen3_5_122b
-    enable_thinking: false
-    description: "文档三级分类-复杂段落,蜀天122B"
-
   # 完整性审查 - 内容生成
   completeness_review_generate:
     model: shutian_qwen3_5_122b
     enable_thinking: false
     description: "完整性审查内容生成,蜀天122B"
 
-  # 完整性审查 - 分类识别
-  completeness_review_classify:
-    model: shutian_qwen3_5_35b
-    enable_thinking: false
-    description: "完整性审查快速分类,蜀天35B"
-
-  # RAG 检索 - 查询理解
-  rag_query_understand:
-    model: shutian_qwen3_5_35b
-    enable_thinking: false
-    description: "RAG查询理解,蜀天35B"
-
-  # RAG 检索 - 答案生成
-  rag_answer_generate:
-    model: shutian_qwen3_5_122b
-    enable_thinking: false
-    description: "RAG答案生成,蜀天122B"
-
   # 查询提取(从审查条文中提取查询关键词)
   query_extract:
     model: shutian_qwen3_5_35b
@@ -166,12 +138,6 @@ model_settings:
     enable_thinking: false
     description: "施工方案章节模板受限校订,蜀天122B"
 
-  # 施工方案大纲生成(SSE流式)
-  write_outline_generate:
-    model: shutian_qwen3_5_122b
-    enable_thinking: false
-    description: "施工方案大纲流式生成,蜀天122B"
-
   # 施工方案内容补全生成(SSE流式)
   write_content_generate:
     model: shutian_qwen3_5_122b
@@ -183,6 +149,16 @@ model_settings:
     model: shutian_qwen3_embed
     description: "文本Embedding向量生成(蜀天)"
 
+  # Rerank 模型(文档重排序)
+  rerank:
+    model: shutian_qwen3_reranker
+    description: "文档重排序模型"
+
+  # OCR 引擎(文档表格/目录识别,非 LLM 模型)
+  ocr_engine:
+    model: glm_ocr
+    description: "文档OCR识别引擎(glm_ocr/mineru)"
+
 # 默认配置(当功能未指定时使用)
 default:
   model: shutian_qwen3_5_122b

+ 46 - 35
config/模型调用指南.md

@@ -11,43 +11,37 @@
 ```yaml
 # 可用模型列表(必须与 model_handler.py 中的模型类型名称一致)
 available_models:
-  - qwen3_5_35b_a3b        # DashScope Qwen3.5-35B-A3B
-  - shutian_qwen3_5_35b    # 蜀天Qwen3.5-35B
-  - shutian_qwen3_5_122b   # 蜀天Qwen3.5-122B
-  - shutian_qwen3_6_27b    # 蜀天Qwen3.6-27B
-  - shutian_qwen3_embed    # 蜀天Embedding模型
+  # DashScope 系列 — qwen3_5_35b_a3b / qwen3_5_27b / qwen3_5_122b_a10b
+  # 豆包系列 — doubao / doubao-1.5-pro-256k / doubao-1.5-lite-32k
+  # DeepSeek 系列 — deepseek / deepseek-v3
+  # 本地模型系列 — lq_qwen3_8b / lq_qwen3_8b_lq_lora / lq_qwen3_4b / qwen_local_14b
+  # 蜀天算力系列 — shutian_qwen3_5_122b / shutian_qwen3_8b / shutian_qwen3_5_35b / shutian_qwen3_6_27b
+  # Embedding — siliconflow_embed / shutian_qwen3_embed
+  # Reranker — lq_bge_reranker_v2_m3 / lq_qwen3_reranker / shutian_qwen3_reranker / silicoflow_qwen3_reranker
 
 # 功能模块模型配置
 model_settings:
-  # 文档分类 - 二级分类
   doc_classification_secondary:
-    model: shutian_qwen3_5_35b
-    enable_thinking: false
-    description: "文档二级分类,蜀天35B"
-
-  # 文档分类 - 三级分类
-  doc_classification_tertiary:
-    model: shutian_qwen3_5_35b
+    model: shutian_qwen3_5_122b
     enable_thinking: false
-    description: "文档三级分类,蜀天35B"
+    description: "文档二级分类,蜀天122B"
 
-  # 完整性审查 - 内容生成
   completeness_review_generate:
     model: shutian_qwen3_5_122b
-    enable_thinking: true
-    description: "完整性审查内容生成,蜀天122B详细推理"
+    enable_thinking: false
+    description: "完整性审查内容生成,蜀天122B"
 
-  # 敏感信息检查
   sensitive_check:
-    model: shutian_qwen3_5_35b
+    model: shutian_qwen3_5_122b
     enable_thinking: false
-    description: "敏感信息快速检查,蜀天35B"
+    description: "敏感信息快速检查,蜀天122B"
 
-  # ... 其他功能配置
+  # ... 其他功能配置(完整列表见下方功能名称表)
 
 # 默认配置(当功能未指定时使用)
 default:
-  model: shutian_qwen3_5_35b
+  model: shutian_qwen3_5_122b
+  enable_thinking: false
   enable_thinking: false
 ```
 
@@ -212,23 +206,40 @@ default_model = get_model_for_function("default")
 
 ## 功能名称列表
 
+### 审查模块 (construction_review)
+
 | 功能名称 | 说明 | 默认模型 |
 |---------|------|---------|
-| `doc_classification_secondary` | 文档二级分类 | shutian_qwen3_5_35b |
-| `doc_classification_tertiary` | 文档三级分类 | shutian_qwen3_5_35b |
-| `doc_classification_tertiary_complex` | 三级分类-复杂段落 | shutian_qwen3_5_122b |
+| `doc_classification_primary` | 文档一级分类 | shutian_qwen3_5_35b |
+| `doc_classification_secondary` | 文档二级分类 | shutian_qwen3_5_122b |
 | `completeness_review_generate` | 完整性审查-生成 | shutian_qwen3_5_122b |
-| `completeness_review_classify` | 完整性审查-分类 | shutian_qwen3_5_35b |
-| `rag_query_understand` | RAG查询理解 | shutian_qwen3_5_35b |
-| `rag_answer_generate` | RAG答案生成 | shutian_qwen3_5_122b |
-| `sensitive_check` | 敏感信息检查 | shutian_qwen3_5_35b |
-| `grammar_check` | 语法检查 | shutian_qwen3_5_35b |
+| `catalog_integrity_review` | 目录完整性审查 | shutian_qwen3_5_122b |
+| `sensitive_check` | 敏感信息检查 | shutian_qwen3_5_122b |
+| `grammar_check` | 语法检查 | shutian_qwen3_5_122b |
 | `semantic_logic_check` | 语义逻辑审查 | shutian_qwen3_5_122b |
-| `timeliness_review` | 时效性审查 | shutian_qwen3_5_35b |
-| `reference_review` | 规范性审查 | shutian_qwen3_5_35b |
+| `timeliness_review` | 时效性审查 | shutian_qwen3_5_122b |
+| `reference_review` | 规范性审查 | shutian_qwen3_5_122b |
+| `non_parameter_compliance_check` | 非参数合规性检查 | shutian_qwen3_5_122b |
+| `parameter_compliance_check` | 参数合规性检查 | shutian_qwen3_5_122b |
 | `directory_extraction` | 目录提取 | shutian_qwen3_5_35b |
-| `outline_chapter_revise` | 施工方案编写-章节模板校订 | shutian_qwen3_5_122b |
-| `default` | 默认兜底配置 | shutian_qwen3_5_35b |
+| `query_extract` | 查询提取 | shutian_qwen3_5_35b |
+| `relevance_judge` | 相关性判断 | shutian_qwen3_5_122b |
+
+### 编写模块 (construction_write)
+
+| 功能名称 | 说明 | 默认模型 |
+|---------|------|---------|
+| `outline_chapter_revise` | 章节模板校订 | shutian_qwen3_5_122b |
+| `write_content_generate` | 内容补全流式生成 | shutian_qwen3_5_122b |
+
+### 基础设施
+
+| 功能名称 | 说明 | 默认模型 |
+|---------|------|---------|
+| `embedding` | 文本Embedding | shutian_qwen3_embed |
+| `rerank` | 文档重排序 | shutian_qwen3_reranker |
+| `ocr_engine` | OCR引擎 | glm_ocr |
+| `default` | 默认兜底配置 | shutian_qwen3_5_122b |
 
 ## 迁移指南
 
@@ -240,7 +251,7 @@ default_model = get_model_for_function("default")
 response = await model_client.get_model_generate_invoke(
     trace_id="xxx",
     messages=messages,
-    model_name="qwen3_30b"  # 硬编码
+    model_name="shutian_qwen3_5_122b"  # 硬编码
 )
 ```
 

+ 3 - 3
core/construction_review/component/doc_worker/config/config.yaml

@@ -82,9 +82,9 @@ secondary_classification:
   mode: batch
   # 批量模式下最大标题数
   batch_max_titles: 50
-# 请修改项目根目录 config.ini 文件中的 [ocr] 配置:
-#   ENGINE=glm_ocr 或 ENGINE=mineru
-# 本文件保留其他非 OCR 相关配置
+# OCR 引擎请在项目根目录 config/model_setting.yaml 中配置:
+#   model_settings.ocr_engine.model: glm_ocr 或 mineru
+# 连接参数在 config.ini [ocr] 中配置。本文件保留其他非 OCR 相关配置
 
 # 目录识别配置
 toc_detection:

+ 6 - 14
core/construction_review/component/infrastructure/relevance.py

@@ -2,23 +2,15 @@ import asyncio
 import json
 import re
 
-from foundation.ai.models.model_handler import model_handler
-
-
-# ===============================
-# 1) LLM 调用(通过统一模型管理,使用 蜀天122B)
-# ===============================
-def _build_messages(prompt: str):
-    """构建 LangChain 消息格式"""
-    from langchain_core.messages import HumanMessage
-    return [HumanMessage(content=prompt)]
+from foundation.ai.agent.generate.model_generate import generate_model_client
 
 
 async def qwen_chat_async(prompt: str) -> str:
-    llm = model_handler.get_model_by_function("relevance_judge")
-    messages = _build_messages(prompt)
-    response = await llm.ainvoke(messages)
-    return response.content if hasattr(response, 'content') else str(response)
+    return await generate_model_client.get_model_generate_invoke(
+        trace_id="relevance_judge",
+        prompt=prompt,
+        function_name="relevance_judge"
+    )
 
 
 # ===============================

+ 11 - 1
core/construction_review/component/minimal_pipeline/simple_processor.py

@@ -36,7 +36,15 @@ class SimpleDocumentProcessor:
     """最简文档处理器"""
 
     def __init__(self, use_ocr: bool = False):
-        # 从配置读取 OCR 配置
+        # 从 model_setting.yaml 读取 OCR 引擎类型
+        ocr_engine = "glm_ocr"
+        try:
+            from foundation.ai.models.model_config_loader import get_model_for_function
+            ocr_engine = get_model_for_function("ocr_engine") or "glm_ocr"
+        except Exception:
+            pass
+
+        # 从配置读取 OCR 连接参数
         ocr_api_url = "http://183.220.37.46:25429/v1/chat/completions"
         ocr_api_key = ""
         ocr_timeout = 600
@@ -49,6 +57,8 @@ class SimpleDocumentProcessor:
         except Exception:
             pass
 
+        logger.info(f"初始化OCR处理器,引擎: {ocr_engine}")
+
         self.pdf_extractor = PdfStructureExtractor(
             use_ocr=use_ocr,
             ocr_api_url=ocr_api_url,

+ 0 - 427
core/construction_review/component/outline_catalogue_matcher.py

@@ -1,427 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-目录缺失检查 - 模糊匹配模块
-
-独立模块,用于 AIReviewEngine.check_outline_catalogue 方法
-提供基于模糊匹配的目录缺失统计功能
-"""
-
-import difflib
-import re
-from typing import Dict, List, Optional, Set, Tuple, Any
-from collections import defaultdict
-from pathlib import Path
-
-import pandas as pd
-
-from foundation.observability.logger.loggering import review_logger as logger
-
-
-class OutlineCatalogueMatcher:
-    """
-    目录模糊匹配器
-    
-    提供独立于 LightweightCompletenessChecker 的模糊匹配功能
-    支持基于名称相似度的目录匹配
-    """
-    
-    def __init__(self, standard_csv_path: str, raw_content_csv_path: str = None):
-        """
-        初始化匹配器
-        
-        Args:
-            standard_csv_path: StandardCategoryTable.csv 路径
-            raw_content_csv_path: construction_plan_standards.csv 路径(可选)
-        """
-        self.standard_csv_path = standard_csv_path
-        self.raw_content_csv_path = raw_content_csv_path
-        
-        # 加载标准数据
-        self.first_names: Dict[str, str] = {}  # code -> name
-        self.second_names: Dict[Tuple[str, str], str] = {}  # (first_code, second_code) -> name
-        self.first_seq: Dict[str, int] = {}  # code -> seq
-        self.second_seq: Dict[Tuple[str, str], int] = {}  # (first_code, second_code) -> seq
-        
-        # 详细定义内容
-        self.second_raw_content: Dict[Tuple[str, str], str] = {}  # (first_name, second_name) -> content
-        
-        self._load_standard_csv()
-        if raw_content_csv_path:
-            self._load_raw_content_csv()
-    
-    def _load_standard_csv(self) -> None:
-        """加载标准分类表"""
-        encodings = ['utf-8-sig', 'utf-16', 'gbk', 'utf-8']
-        df = None
-        
-        for encoding in encodings:
-            try:
-                df = pd.read_csv(self.standard_csv_path, encoding=encoding, sep=None, engine='python')
-                break
-            except UnicodeDecodeError:
-                continue
-        
-        if df is None:
-            raise ValueError(f"无法读取CSV文件: {self.standard_csv_path}")
-        
-        df.columns = [c.strip().lower().replace(' ', '_') for c in df.columns]
-        
-        # 提取一级和二级信息(去重)
-        for _, row in df.iterrows():
-            first_code = str(row.get('first_code', '')).strip()
-            second_code = str(row.get('second_code', '')).strip()
-            first_name = str(row.get('first_name', '')).strip()
-            second_name = str(row.get('second_name', '')).strip()
-            
-            if not all([first_code, second_code, first_name, second_name]):
-                continue
-            
-            try:
-                first_seq = int(row.get('first_seq', 0) or 0)
-                second_seq = int(row.get('second_seq', 0) or 0)
-            except:
-                first_seq = 0
-                second_seq = 0
-            
-            # 存储一级信息
-            if first_code not in self.first_names:
-                self.first_names[first_code] = first_name
-                self.first_seq[first_code] = first_seq
-            
-            # 存储二级信息
-            sec_key = (first_code, second_code)
-            if sec_key not in self.second_names:
-                self.second_names[sec_key] = second_name
-                self.second_seq[sec_key] = second_seq
-    
-    def _load_raw_content_csv(self) -> None:
-        """加载详细定义表"""
-        try:
-            encodings = ['utf-8-sig', 'utf-16', 'gbk', 'utf-8']
-            df = None
-            
-            for encoding in encodings:
-                try:
-                    df = pd.read_csv(self.raw_content_csv_path, encoding=encoding, sep=None, engine='python')
-                    break
-                except UnicodeDecodeError:
-                    continue
-            
-            if df is None:
-                return
-            
-            df.columns = [c.strip().lower().replace(' ', '_') for c in df.columns]
-            
-            if 'second_raw_content' not in df.columns:
-                return
-            
-            for _, row in df.iterrows():
-                first_name = str(row.get('first_name', '')).strip()
-                second_name = str(row.get('second_name', '')).strip()
-                raw_content = str(row.get('second_raw_content', '')).strip()
-                
-                if first_name and second_name and raw_content and raw_content != 'nan':
-                    self.second_raw_content[(first_name, second_name)] = raw_content
-                    
-        except Exception:
-            pass  # 加载失败不影响主功能
-    
-    def _normalize_text(self, text: str) -> str:
-        """文本标准化"""
-        if not text:
-            return ""
-        text = re.sub(r'[\s\n\r\t.,;:!?,。;:!?、""''()()【】\[\]《》<>]', '', text)
-        return text.lower().strip()
-    
-    def _calculate_similarity(self, text1: str, text2: str) -> float:
-        """计算两个文本的相似度"""
-        if not text1 or not text2:
-            return 0.0
-        
-        norm1 = self._normalize_text(text1)
-        norm2 = self._normalize_text(text2)
-        
-        if not norm1 or not norm2:
-            return 0.0
-        
-        return difflib.SequenceMatcher(None, norm1, norm2).ratio()
-    
-    def _extract_keywords(self, text: str) -> List[str]:
-        """提取关键词"""
-        stopwords = {'的', '及', '与', '或', '和', '等', '之', '第', '章', '节', '条',
-                     '编制', '施工', '措施', '要求', '管理', '保证', '质量', '安全',
-                     '技术', '计划', '人员', '组织', '体系', '条件', '概述', '概况'}
-        
-        words = []
-        for word in text:
-            if word not in stopwords and len(word.strip()) > 0:
-                words.append(word)
-        
-        if not words and text:
-            return list(text)
-        
-        return words
-    
-    def _calculate_enhanced_similarity(
-        self,
-        standard_name: str,
-        actual_title: str,
-        standard_raw_content: str = None
-    ) -> float:
-        """
-        增强的相似度计算 - 基础相似度主导
-        
-        策略:
-        1. 基础相似度(SequenceMatcher)- 核心,必须 >= 0.3 才能进入加分
-        2. 关键词匹配(+0.2)- 辅助
-        3. 包含关系(+0.1)- 辅助
-        4. 详细定义匹配(+0.2)- 辅助
-        
-        规则:基础相似度 < 0.3 时,直接返回基础分(避免完全不相关的匹配)
-        """
-        if not standard_name or not actual_title:
-            return 0.0
-        
-        # 1. 基础相似度(核心)
-        base_similarity = self._calculate_similarity(standard_name, actual_title)
-        
-        # 基础相似度太低,说明完全不相关,不进入加分阶段
-        if base_similarity < 0.3:
-            return base_similarity
-        
-        # 基础相似度达标,开始计算加分
-        scores = [base_similarity]
-        
-        norm_standard = self._normalize_text(standard_name)
-        norm_actual = self._normalize_text(actual_title)
-        
-        # 2. 关键词匹配(权重0.2,比原来降低)
-        keyword_bonus = 0.0
-        standard_keywords = self._extract_keywords(norm_standard)
-        actual_keywords = self._extract_keywords(norm_actual)
-        
-        if standard_keywords and actual_keywords:
-            matched = len(set(standard_keywords) & set(actual_keywords))
-            total = len(set(standard_keywords) | set(actual_keywords))
-            if total > 0:
-                # 权重从0.3降到0.2,避免关键词过度影响
-                keyword_bonus = (matched / total) * 0.2
-        
-        scores.append(keyword_bonus)
-        
-        # 3. 包含关系(权重0.1,比原来降低)
-        contain_bonus = 0.0
-        if norm_standard in norm_actual or norm_actual in norm_standard:
-            contain_bonus = 0.1
-        scores.append(contain_bonus)
-        
-        # 4. 详细定义匹配(权重0.2,比原来降低)
-        if standard_raw_content and standard_raw_content != 'nan':
-            raw_content_score = self._calculate_similarity(
-                self._normalize_text(standard_raw_content),
-                norm_actual
-            )
-            # 阈值提高到0.6(原来0.5),确保详细定义必须足够相关才加分
-            if raw_content_score > 0.6:
-                # 权重从0.4降到0.2,避免详细定义过度影响
-                scores.append(raw_content_score * 0.2)
-        
-        return min(sum(scores), 1.0)
-    
-    def _match_by_title_fuzzy(
-        self,
-        standard_name: str,
-        candidate_titles: List[str],
-        threshold: float
-    ) -> Tuple[bool, float, Optional[str]]:
-        """
-        在候选标题中找到与标准名称最相似的一个
-        
-        Returns:
-            (是否匹配, 最佳分数, 匹配的标题)
-        """
-        best_score = 0.0
-        best_title = None
-        
-        for title in candidate_titles:
-            score = self._calculate_enhanced_similarity(standard_name, title)
-            if score > best_score:
-                best_score = score
-                best_title = title
-        
-        is_match = best_score >= threshold
-        return is_match, best_score, best_title
-    
-    def match_catalogue_by_title(
-        self,
-        outline_by_first: Dict[str, Dict[str, any]],
-        threshold: float = 0.6
-    ) -> Dict[str, Any]:
-        """
-        🆕 基于标题的独立模糊匹配(一二级都独立)
-        
-        Args:
-            outline_by_first: {
-                first_code: {
-                    'title': '一级标题',
-                    'subsections': ['二级标题1', '二级标题2', ...]
-                }
-            }
-            threshold: 匹配阈值,默认0.6
-            
-        Returns:
-            匹配结果
-        """
-        logger.info(f"[独立模糊匹配] 开始,阈值={threshold}")
-        
-        # ========== 一级目录匹配(独立模糊)==========
-        actual_first_titles = {
-            code: info['title'] 
-            for code, info in outline_by_first.items()
-        }
-        
-        matched_first = set()
-        missing_first = []
-        
-        for req_code, req_name in self.first_names.items():
-            # 优先:直接用code精确匹配,因为一级分类通常较准
-            if req_code in actual_first_titles:
-                matched_first.add(req_code)
-                logger.debug(f"[一级匹配] {req_name}: 存在")
-            else:
-                # 尝试用标题模糊匹配
-                is_match, score, matched_title = self._match_by_title_fuzzy(
-                    req_name,
-                    list(actual_first_titles.values()),
-                    threshold
-                )
-                if is_match:
-                    # 找到匹配的标题,反向查找code
-                    for code, title in actual_first_titles.items():
-                        if title == matched_title:
-                            matched_first.add(req_code)
-                            logger.debug(f"[一级模糊匹配] {req_name} -> {matched_title} ({score:.3f})")
-                            break
-                else:
-                    missing_first.append({
-                        'first_code': req_code,
-                        'first_name': req_name,
-                        'first_seq': self.first_seq.get(req_code, 0)
-                    })
-                    logger.debug(f"[一级缺失] {req_name}")
-        
-        # ========== 二级目录匹配(结合一级 + 全局兜底)==========
-        # 🆕 先收集所有二级标题用于全局兜底
-        all_actual_second_titles = []
-        for fc, info in outline_by_first.items():
-            for sub_title in info.get('subsections', []):
-                all_actual_second_titles.append({
-                    'first_code': fc,
-                    'title': sub_title
-                })
-        
-        matched_second = set()
-        missing_second = []
-        match_details = []
-        matched_actual_titles = set()  # 防重复
-        
-        for req_key, req_name in self.second_names.items():
-            first_code, second_code = req_key
-            
-            # 🆕 步骤1:优先在同一一级下匹配
-            same_group_titles = outline_by_first.get(first_code, {}).get('subsections', [])
-            best_score_same = 0.0
-            best_match_same = None
-            
-            for title in same_group_titles:
-                if title in matched_actual_titles:
-                    continue
-                score = self._calculate_enhanced_similarity(req_name, title)
-                if score > best_score_same:
-                    best_score_same = score
-                    best_match_same = title
-            
-            # 同组匹配成功
-            if best_score_same >= threshold and best_match_same:
-                matched_second.add(req_key)
-                matched_actual_titles.add(best_match_same)
-                match_details.append({
-                    'level': 'second',
-                    'required_first_code': first_code,
-                    'required_second_code': second_code,
-                    'required_second_name': req_name,
-                    'matched': True,
-                    'match_type': 'same_group_fuzzy',
-                    'similarity': best_score_same,
-                    'matched_title': best_match_same
-                })
-                logger.debug(f"[二级同组匹配] {req_name} -> {best_match_same} ({best_score_same:.3f})")
-                continue
-            
-            # 🆕 步骤2:同组失败,尝试全局匹配(提高阈值防误匹配)
-            GLOBAL_THRESHOLD = 0.7  # 全局匹配阈值更高
-            best_score_global = 0.0
-            best_match_global = None
-            best_match_fc = None
-            
-            for actual in all_actual_second_titles:
-                if actual['title'] in matched_actual_titles:
-                    continue
-                score = self._calculate_enhanced_similarity(req_name, actual['title'])
-                if score > best_score_global:
-                    best_score_global = score
-                    best_match_global = actual['title']
-                    best_match_fc = actual['first_code']
-            
-            # 全局匹配成功(且跨组)
-            if best_score_global >= GLOBAL_THRESHOLD and best_match_global:
-                matched_second.add(req_key)
-                matched_actual_titles.add(best_match_global)
-                match_details.append({
-                    'level': 'second',
-                    'required_first_code': first_code,
-                    'required_second_code': second_code,
-                    'required_second_name': req_name,
-                    'matched': True,
-                    'match_type': 'cross_group_fuzzy',  # 标记为跨组匹配
-                    'similarity': best_score_global,
-                    'matched_title': best_match_global,
-                    'matched_actual_first': best_match_fc  # 实际匹配到的一级
-                })
-                logger.warning(f"[二级跨组匹配] {req_name}(应在{first_code}) -> {best_match_global}(实际在{best_match_fc}) ({best_score_global:.3f})")
-                continue
-            
-            # 都失败,记为缺失
-            best_score = max(best_score_same, best_score_global)
-            best_attempt = best_match_same or best_match_global
-            missing_second.append({
-                'first_code': first_code,
-                'first_name': self.first_names.get(first_code, ''),
-                'secondary_code': second_code,
-                'secondary_name': req_name,
-                'second_seq': self.second_seq.get(req_key, 0)
-            })
-            match_details.append({
-                'level': 'second',
-                'required_first_code': first_code,
-                'required_second_code': second_code,
-                'required_second_name': req_name,
-                'matched': False,
-                'match_type': 'none',
-                'similarity': best_score,
-                'best_attempt': best_attempt
-            })
-            logger.debug(f"[二级缺失] {req_name} (最佳尝试: {best_attempt}, {best_score:.3f})")
-        
-        logger.info(f"[独立模糊匹配] 完成:一级缺失 {len(missing_first)} 个,二级缺失 {len(missing_second)} 个")
-        
-        return {
-            'matched_first': matched_first,
-            'matched_second': matched_second,
-            'missing_first': missing_first,
-            'missing_second': missing_second,
-            'missing_first_count': len(missing_first),
-            'missing_second_count': len(missing_second),
-            'match_details': match_details
-        }

+ 2 - 8
core/construction_review/component/reviewers/__init__.py

@@ -5,14 +5,11 @@
 
 from .base_reviewer import BaseReviewer
 
-# 轻量级完整性审查(基于分类结果,无LLM
+# 完整性审查(方案B:直接LLM解释法
 from .completeness_reviewer import (
     LightweightCompletenessChecker,
     TertiarySpecLoader,
-    TertiaryItem,
-    SecondaryItem,
     LightweightCompletenessResult,
-    check_completeness_lightweight,
     result_to_dict,
 )
 
@@ -26,13 +23,10 @@ from .timeliness_reviewer import (
 
 __all__ = [
     'BaseReviewer',
-    # 轻量级完整性审查
+    # 完整性审查
     'LightweightCompletenessChecker',
     'TertiarySpecLoader',
-    'TertiaryItem',
-    'SecondaryItem',
     'LightweightCompletenessResult',
-    'check_completeness_lightweight',
     'result_to_dict',
     # 标准时效性审查
     'StandardTimelinessReviewer',

+ 1 - 4
core/construction_review/component/reviewers/base_reviewer.py

@@ -62,10 +62,7 @@ class BaseReviewer(ABC):
                                   lq_qwen3_4b, qwen_local_14b
                       如果为None,则使用配置文件中的默认模型
             function_name: 功能名称 (可选),如提供则从 model_setting.yaml 加载模型配置
-                      支持的功能:doc_classification_secondary, doc_classification_tertiary,
-                                  completeness_review_generate, completeness_review_classify,
-                                  rag_query_understand, rag_answer_generate,
-                                  sensitive_check, grammar_check
+                      各审查器通过 function_name 指定自己的模型配置
 
         Returns:
             ReviewResult: 审查结果

+ 0 - 342
core/construction_review/component/reviewers/check_completeness/components/result_analyzer.py

@@ -1,342 +0,0 @@
-"""
-结果汇总与规范覆盖分析组件
-"""
-import json
-from typing import Dict, List, Any, Set, Optional
-import ast
-import sys
-from pathlib import Path
-
-_root = Path(__file__).parent.parent
-if str(_root) not in sys.path:
-
-from interfaces import IResultAnalyzer, IKeywordChecker
-from utils.file_utils import read_csv, write_csv
-from foundation.observability.logger.loggering import review_logger as logger
-from foundation.observability.cachefiles import cache, CacheBaseDir
-
-
-class ResultAnalyzer(IResultAnalyzer):
-    """审查结果汇总分析器"""
-
-    def __init__(self, spec_csv_path: str, keyword_checker: Optional[IKeywordChecker] = None):
-        """
-        Args:
-            spec_csv_path: 规范 CSV 文件路径(Construction_Plan_Content_Specification.csv)
-            keyword_checker: 关键词检查器(可选)
-        """
-        self.spec_csv_path = spec_csv_path
-        self.keyword_checker = keyword_checker
-
-    def process_results(self, results: List[Dict[str, Any]], specification:  Optional[Dict[str, List[Dict[str, str]]]] = None) -> List[Dict[str, Any]]:
-        """
-        按规则清洗审查结果,生成新的 JSON 列表
-
-        - 新列表仅保留字段:chunk_id、section_label、chapter_classification、review_result
-        - 如果 section_label 中包含 "->"(视为包含两段及以上标题),
-          则遍历 review_result 的键名(即二级目录名称):
-            * 若键名未出现在 section_label 的字符串中,则将该键对应的值列表清空 []
-        - 所有要点编号列表在块内部去重
-        - 如果启用了关键词检查器,则对缺失的要点进行二次验证
-        """
-        processed: List[Dict[str, Any]] = []
-
-        for item in results:
-            chunk_id = item.get("chunk_id", "")
-            section_label = item.get("section_label", "") or ""
-            chapter_classification = item.get("chapter_classification", "") or ""
-            review_result = item.get("review_result", {})
-            content = item.get("content", "")
-
-            if not isinstance(review_result, dict):
-                review_result = {}
-
-            new_review_result: Dict[str, List[int]] = {}
-
-            has_multi_titles = "->" in section_label
-
-            for key, value in review_result.items():
-                # 只接受列表类型的要点编号
-                points: List[int] = []
-                if isinstance(value, list):
-                    for v in value:
-                        if isinstance(v, int):
-                            points.append(v)
-                        elif isinstance(v, str) and v.isdigit():
-                            points.append(int(v))
-                # 去重并排序
-                points = sorted(set(points))
-
-                if has_multi_titles:
-                    # 标题中未出现该二级目录名,则清空
-                    if key and key not in section_label:
-                        new_review_result[key] = []
-                    else:
-                        new_review_result[key] = points
-                else:
-                    new_review_result[key] = points
-
-            # 关键词二次检查(只过滤,不改变数据结构)
-            if self.keyword_checker and specification:
-                new_review_result = self.keyword_checker.filter_missing_points(
-                    new_review_result, content, specification, chapter_classification
-                )
-
-            processed.append(
-                {
-                    "chunk_id": chunk_id,
-                    "section_label": section_label,
-                    "chapter_classification": chapter_classification,
-                    "review_result": new_review_result,
-                    "content": content
-                }
-            )
-
-        return processed
-
-    def build_spec_summary(
-        self, processed_results: List[Dict[str, Any]], output_csv_path: str = None
-    ) -> List[Dict[str, Any]]:
-        """
-        基于规范 CSV 与处理后的审查结果,生成规范要点覆盖汇总表
-
-        - 从规范 CSV 读取原始行,增加三列:
-            * 审查到的要点: 形如 [1, 2, 3]
-            * 缺失的要点: 形如 [1, 3]
-            * 要点来源:    形如 ["第五章 施工安全保证措施->一) 组织保证措施", ...]
-        - 对于每个规范行(标签 + 二级目录):
-            * 从所有块中收集该标签 & 二级目录下出现的要点编号(不重复)
-            * 根据"内容要点数量"推算缺失的要点编号
-            * 将出现过要点的块的 section_label 作为来源去重记录
-        """
-        # 读取规范原始表(制表符分隔)
-        spec_rows = read_csv(self.spec_csv_path, delimiter="\t")
-
-        # 预处理:按 (标签, 二级目录) 聚合审查结果
-        # key: (chapter_classification, level2)
-        points_found_map: Dict[str, Set[int]] = {}
-        sources_map: Dict[str, Set[str]] = {}
-        content_map: Dict[str, str] = {}  # 二级目录的 content 映射
-        section_label_map: Dict[str, str] = {}  # 二级目录的 section_label 映射
-        # 一级章节的映射(作为后备,当找不到二级目录时使用)
-        chapter_content_map: Dict[str, str] = {}  # 按 chapter_classification 索引
-        chapter_section_label_map: Dict[str, str] = {}  # 按 chapter_classification 索引
-
-        for item in processed_results:
-            chapter_classification = (item.get("chapter_classification") or "").strip()
-            section_label = (item.get("section_label") or "").strip()
-            review_result = item.get("review_result", {}) or {}
-            content = item.get("content", "")
-
-            if not chapter_classification or not isinstance(review_result, dict):
-                continue
-
-            # 记录一级章节信息(作为后备)
-            if chapter_classification not in chapter_content_map:
-                chapter_content_map[chapter_classification] = content
-                chapter_section_label_map[chapter_classification] = section_label
-            else:
-                # 如果已存在且当前内容非空,更新为最新的
-                if content:
-                    chapter_content_map[chapter_classification] = content
-                    chapter_section_label_map[chapter_classification] = section_label
-
-            # 截断来源标题:只保留前两段(按 "->" 分隔)
-            if "->" in section_label:
-                parts = [p.strip() for p in section_label.split("->") if p.strip()]
-                if len(parts) >= 2:
-                    source_label = "->".join(parts[:2])
-                else:
-                    source_label = parts[0] if parts else section_label
-            else:
-                source_label = section_label
-
-            for level2_name, points in review_result.items():
-                if not level2_name:
-                    continue
-
-                key = f"{chapter_classification}_{level2_name}"
-                if key not in points_found_map:
-                    points_found_map[key] = set()
-                    sources_map[key] = set()
-                    content_map[key] = ""  # 初始化 content
-                    section_label_map[key] = ""  # 初始化 section_label
-
-                if isinstance(points, list) and points:
-                    for p in points:
-                        if isinstance(p, int):
-                            points_found_map[key].add(p)
-                        elif isinstance(p, str) and p.isdigit():
-                            points_found_map[key].add(int(p))
-                    # 只要该块在该二级目录下有任何要点,就记录来源(截断后的标题)
-                    sources_map[key].add(source_label)
-                    # 记录 content 和 section_label(使用最后一个遇到的内容)
-                    content_map[key] = content
-                    section_label_map[key] = section_label
-
-        # 根据规范逐行生成统计结果
-        summary_rows: List[Dict[str, Any]] = []
-        for row in spec_rows:
-            tag = (row.get("标签") or "").strip()
-            level2 = (row.get("二级目录") or "").strip()
-            point_count_str = (row.get("内容要点数量") or "").strip()
-
-            # 解析要点总数
-            try:
-                total_points = int(point_count_str)
-            except (TypeError, ValueError):
-                total_points = 0
-
-            key = f"{tag}_{level2}"
-            found_points = sorted(points_found_map.get(key, set()))
-
-            if total_points > 0:
-                missing_points = [
-                    i for i in range(1, total_points + 1) if i not in found_points
-                ]
-            else:
-                missing_points = []
-
-            sources = sorted(sources_map.get(key, set())) if key in sources_map else []
-
-            # 获取 content 和 section_label,优先使用二级目录映射,找不到则使用一级章节映射
-            content = content_map.get(key, "")
-            section_label = section_label_map.get(key, "")
-            # 如果二级目录映射为空,使用一级章节映射作为后备
-            if not content:
-                content = chapter_content_map.get(tag, "")
-            if not section_label:
-                section_label = chapter_section_label_map.get(tag, "")
-
-            # 组装输出行(在原规范行基础上增加三列)
-            new_row = dict(row)
-            new_row["审查到的要点"] = str(found_points)
-            new_row["缺失的要点"] = str(missing_points)
-            new_row["要点来源"] = str(sources)
-            new_row['content'] = content
-            new_row['section_label'] = section_label
-            summary_rows.append(new_row)
-
-        # 写出 CSV,使用 UTF-8-SIG 编码(write_csv 内部已固定为 utf-8-sig)
-        # 使用逗号分隔符便于通用 CSV 工具查看
-        if output_csv_path:
-            write_csv(summary_rows, output_csv_path, delimiter=",")
-        return summary_rows
-
-    # 生成缺失要点的 JSON 列表,便于前端或其他系统直接消费
-    def build_missing_issue_list(
-        self, summary_rows: List[Dict[str, Any]]
-    ) -> Dict[str, Any]:
-        """
-        构建缺失要点的问题列表
-
-        Args:
-            summary_rows: 规范汇总行数据
-
-        Returns:
-            Dict: 审查结果字典,包含 response 字段的问题列表和必要的元数据
-        """
-        all_issues = []
-        metadata = {}
-        suorces_eum = {
-            "basis": "编制依据",
-            "overview": "工程概况",
-            "plan": "施工计划",
-            "technology": "施工工艺技术",
-            "safety": "安全保证措施",
-            "quality": "质量保证措施",
-            "environment": "环境保证措施",
-            "management": "施工管理及作业人员配备与分工",
-            "acceptance": "验收要求",
-            "other": "其他资料"
-            }
-        for row in summary_rows:
-            level2 = (row.get("二级目录") or "").strip()
-            requirement = (row.get("内容要求") or "").strip()
-            reference_source = '交通运输部《公路水运危险性较大工程专项施工方案编制审查规程》(JT/T 1495—2024)'
-            reason= f"参照:{reference_source} 中的内容要求,{row.get('section_label', '')}内容属于,专项施工方案内容要求中的 【{suorces_eum[row.get('标签', '')]}】 板块,应包含{requirement}"
-            review_references = (row.get("依据") or "").strip()
-            if level2 in row.get("content", ""):
-                continue
-            missing_points_raw = row.get("缺失的要点", "")
-            missing_points = self._parse_list_field(missing_points_raw)
-            if not missing_points:
-                continue
-
-            sources_raw = row.get("要点来源", "")
-            #sources = self._parse_list_field(sources_raw)
-            #location = "; ".join(map(str, sources)) if sources else ""
-
-            requirement_list = requirement.split(':')[-1].split(';')
-            missing_count = len(missing_points)
-            section_label = row.get('section_label', '')
-            level2_name = suorces_eum.get(row.get('标签', ''), '')
-            
-            # 获取缺失要点的具体内容
-            missing_content_list = []
-            for idx in missing_points:
-                if 0 < idx <= len(requirement_list):
-                    missing_content_list.append(f"{idx}.{requirement_list[idx-1]}")
-            missing_content_text = ';'.join(missing_content_list)
-            
-            issue_point = f"【内容不完整】{section_label}的'{level2_name}'部分缺少{missing_count}个要点"
-            suggestion = f"请补充'{level2_name}'的{','.join(map(str, missing_points))}内容:{missing_content_text}"
-            risk_level = self._map_risk_level(len(missing_points))
-            risk_level_std = "high" if "高" in risk_level else ("medium" if "中" in risk_level else "low")
-
-            # 构建问题项并添加到列表(与参数合规审查格式一致)
-            issue_item = {
-                "check_item": "completeness_check",
-                "chapter_code": row.get("标签", "completeness"),
-                "check_item_code": f"{row.get('标签', 'completeness')}_completeness_check",
-                "check_result": {
-                    "issue_point": issue_point,
-                    "location": row.get("section_label", ""),
-                    "suggestion": suggestion,
-                    "reason": f"根据交通运输部《公路水运危险性较大工程专项施工方案编制审查规程》(JT/T 1495—2024),{section_label}的'{level2_name}'应包含:{requirement}。当前缺失:{missing_content_text}",
-                    "risk_level": risk_level
-                },
-                "exist_issue": True,
-                "risk_info": {"risk_level": risk_level_std}
-            }
-            all_issues.append(issue_item)
-            # with open("temp/construction_review/document_temp/missing_points.json", "w", encoding="utf-8") as f:
-            #     json.dump(all_issues, f, ensure_ascii=False, indent=4)
-            cache.document_temp(all_issues, base_cache_dir=CacheBaseDir.CONSTRUCTION_REVIEW)
-            if not metadata:
-                metadata = {
-                    "review_location_label": row.get("section_label", ""),
-                    "chapter_code": row.get("标签", ""),
-                    "original_content": row.get("content", "")
-                }
-                cache.document_temp(metadata, base_cache_dir=CacheBaseDir.CONSTRUCTION_REVIEW)
-        logger.debug(f"build_missing_issue_list_all_issues:{len(all_issues)}")
-        # 返回包含问题和元数据的字典,由外层统一格式化
-        return {
-            "response": all_issues,
-            **metadata
-        }
-
-    @staticmethod
-    def _parse_list_field(value: Any) -> List[Any]:
-        """把 CSV 中的列表字符串安全转回列表"""
-        if isinstance(value, list):
-            return value
-        if not value:
-            return []
-        try:
-            parsed = ast.literal_eval(value)
-            if isinstance(parsed, list):
-                return parsed
-        except Exception:
-            return []
-        return []
-
-    @staticmethod
-    def _map_risk_level(missing_count: int) -> str:
-        """根据缺失要点数量映射风险等级"""
-        if missing_count >= 3:
-            return "高风险"
-        if missing_count == 2:
-            return "中风险"
-        return "低风险"

+ 21 - 378
core/construction_review/component/reviewers/completeness_reviewer.py

@@ -1,52 +1,19 @@
 """
-轻量级完整性审查模块
+完整性审查模块(方案B:直接LLM解释法)
 
-特点:
-- 目录审查:二级粒度(检查二级章节是否齐全)
-- 完整性审查:三级粒度(基于分类结果,无LLM)
-- 大纲审查:二级粒度(检查二级章节一致性)
-
-完全依赖分类器输出的三级分类结果,无需LLM参与。
+直接将文档原文与标准要求送交LLM,逐条判断是否覆盖,
+并输出证据原文和判断理由。
 """
 
 import pandas as pd
-import asyncio
 import re
 import json
-from typing import Dict, List, Optional, Set, Tuple, Any
+from typing import Dict, List, Optional, Tuple, Any
 from dataclasses import dataclass, field
 from collections import defaultdict
-from pathlib import Path
 
 from foundation.observability.logger.loggering import review_logger as logger
-from ..doc_worker.classification.hierarchy_classifier import is_secondary_in_whitelist
-
-
-# 方案B:直接LLM完整性审查 System Prompt
-DIRECT_CHECK_SYSTEM_PROMPT = """你是专业的施工方案完整性审查专家。
-
-【任务】
-给你一组施工方案文档片段和一组标准要求(来自《公路水运危险性较大工程专项施工方案编制审查规程》JT/T 1495—2024),请逐条判断文档是否覆盖了每条标准要求。
-
-【输出格式】
-对每条标准要求,输出一个JSON对象:
-- standard_code: 标准分类代码(原样传入)
-- standard_name: 标准分类名称(原样传入)
-- is_covered: true/false(文档是否包含该内容)
-- evidence: 如果覆盖,引用文档中的关键原文(50-150字);如果未覆盖,写"无"
-- reason: 判断原因(30-80字,说明为什么认为覆盖或未覆盖)
-- confidence: 置信度 0.0-1.0
-
-【判断原则】
-1. 只要文档中有相关内容(即使不完全匹配),就算覆盖
-2. 如果文档提到了相关概念但不够具体,is_covered=true 但 confidence 较低(0.3-0.6)
-3. 如果文档完全没有相关内容,is_covered=false
-4. 注意区分:文档可能在不同片段提到同一标准的不同方面
-
-【输出要求】
-- 只输出JSON数组,不要任何解释文字
-- 数组中每条标准要求对应一个对象
-- 保持 standard_code 与输入一致"""
+from .utils.prompt_loader import prompt_loader
 
 
 @dataclass
@@ -75,18 +42,6 @@ class SecondaryItem:
     second_seq: int = 0
 
 
-@dataclass
-class DirectCheckItem:
-    """方案B:单条标准要求的直接LLM检查结果"""
-    standard_code: str          # 三级分类代码
-    standard_name: str          # 三级分类名称
-    third_focus: str            # 标准要求描述
-    is_covered: bool            # 是否覆盖
-    evidence: str               # LLM给出的证据(文档原文引用)
-    reason: str                 # LLM给出的判断原因
-    confidence: float           # LLM置信度 0-1
-
-
 @dataclass
 class LightweightCompletenessResult:
     """轻量级完整性审查结果"""
@@ -230,41 +185,28 @@ class TertiarySpecLoader:
 class LightweightCompletenessChecker:
     """轻量级完整性检查器"""
 
-    def __init__(self, standard_csv_path: str, model_client=None, prompt_loader=None):
+    def __init__(self, standard_csv_path: str, model_client=None):
         """
         初始化检查器
 
         Args:
             standard_csv_path: StandardCategoryTable.csv 文件路径
-            model_client: 模型客户端(可选),用于生成智能建议
-            prompt_loader: 提示词加载器(可选)
+            model_client: 模型客户端(可选)
         """
         self.spec_loader = TertiarySpecLoader(standard_csv_path)
         self.tertiary_specs = self.spec_loader.get_tertiary_items()
         self.secondary_specs = self.spec_loader.get_secondary_items()
         self.secondary_names = self.spec_loader.get_secondary_names()
 
-        # 大模型客户端和提示词加载器(用于生成智能建议)
         self.model_client = model_client
-        self.prompt_loader = prompt_loader
 
-        # 如果没有提供model_client,尝试从foundation导入
         if self.model_client is None:
             try:
                 from foundation.ai.agent.generate.model_generate import generate_model_client
                 self.model_client = generate_model_client
             except ImportError:
-                logger.warning("无法导入generate_model_client,建议生成功能将使用简单拼接模式")
+                logger.warning("无法导入generate_model_client")
                 self.model_client = None
-
-        # 如果没有提供prompt_loader,尝试从当前模块导入
-        if self.prompt_loader is None:
-            try:
-                from .utils.prompt_loader import prompt_loader
-                self.prompt_loader = prompt_loader
-            except ImportError:
-                logger.warning("无法导入prompt_loader,建议生成功能将使用简单拼接模式")
-                self.prompt_loader = None
     
     def _normalize_chapter_code(self, code: str) -> str:
         """将章节分类码大小写归一化为与CSV一致(如 'management' -> 'management')"""
@@ -279,13 +221,13 @@ class LightweightCompletenessChecker:
     # 方案B:直接LLM完整性检查方法
     # ═══════════════════════════════════════════════════════════════
 
-    def _build_direct_check_user_prompt(
+    def _build_direct_check_prompt_kwargs(
         self,
         chunks: List[Dict[str, Any]],
         standard_items: List[Dict[str, Any]],
         chapter_name: str = ""
-    ) -> str:
-        """构建方案B的用户Prompt"""
+    ) -> dict:
+        """构建方案B用户提示词变量"""
         content_parts = []
         for i, chunk in enumerate(chunks):
             label = chunk.get("section_label", "")
@@ -302,18 +244,12 @@ class LightweightCompletenessChecker:
                 f" — {item['third_focus']}\n"
             )
 
-        prompt = f"""请审查以下施工方案文档是否覆盖了标准要求。
-
-【文档章节】{chapter_name}
-
-【文档内容】
-{document_text[:8000]}
-
-【标准要求(共{len(standard_items)}条)】
-{standards_text}
-
-请逐条判断文档是否覆盖了上述标准要求,输出JSON数组。"""
-        return prompt
+        return {
+            "chapter_name": chapter_name,
+            "document_text": document_text[:8000],
+            "total_standards": str(len(standard_items)),
+            "standards_text": standards_text,
+        }
 
     def _try_parse_json(self, text: str):
         """尝试直接解析JSON"""
@@ -520,12 +456,14 @@ class LightweightCompletenessChecker:
         if not standard_items:
             return [], 0
 
-        user_prompt = self._build_direct_check_user_prompt(
+        prompt_kwargs = self._build_direct_check_prompt_kwargs(
             chunks, standard_items, chapter_name
         )
 
         task_prompt_info = {
-            "task_prompt": f"{DIRECT_CHECK_SYSTEM_PROMPT}\n\n{user_prompt}",
+            "task_prompt": prompt_loader.get_prompt_template(
+                "completeness", "completeness_direct_check", **prompt_kwargs
+            ),
             "task_name": "completeness_direct_check"
         }
 
@@ -553,7 +491,6 @@ class LightweightCompletenessChecker:
                     return items, attempt + 1
 
                 logger.warning(f"[完整性审查] 第{attempt+1}次尝试解析为空,准备重试")
-                # 重试时加修复提示
                 if attempt < max_retries:
                     codes_str = ", ".join(
                         s["third_code"] for s in standard_items[:10]
@@ -571,7 +508,6 @@ class LightweightCompletenessChecker:
             except Exception as e:
                 logger.error(f"[完整性审查] LLM调用失败 (第{attempt+1}次): {e}")
 
-        # 全部失败,返回空
         logger.error("[完整性审查] 所有LLM尝试均失败")
         return [], max_retries + 1
 
@@ -640,7 +576,6 @@ class LightweightCompletenessChecker:
             })
 
         # 按二级分组统计
-        from collections import defaultdict
         secondary_stats = defaultdict(lambda: {"total": 0, "present": 0, "missing": 0})
         for item in direct_items:
             key = (item.get("first_code", ""), item.get("secondary_code", ""))
@@ -757,14 +692,6 @@ class LightweightCompletenessChecker:
 
         return recommendations
 
-    def _check_secondary_whitelist(self, cat1: str, second_name: str) -> bool:
-        """检查二级章节是否在白名单中(同步版本,用于同步上下文)"""
-        try:
-            from ..doc_worker.classification.hierarchy_classifier import is_secondary_in_whitelist
-            return is_secondary_in_whitelist(cat1, second_name)
-        except ImportError:
-            return False
-
     async def check(
         self,
         chunks: List[Dict],
@@ -844,248 +771,10 @@ class LightweightCompletenessChecker:
         """检查一级分类代码是否有效(在标准分类中)"""
         if not code:
             return False
-        # 排除已知的非章节键
         if code in ("quality_check", "catalog", "metadata"):
             return False
-        # 检查是否在标准一级分类中
         return code in self.spec_loader.first_names
 
-    def _extract_first_from_chunks(self, chunks: List[Dict]) -> Set[str]:
-        """
-        从chunks独立提取实际存在的一级分类(不依赖二级)。
-
-        解决场景:当一级存在但所有二级被过滤(如标记为non_standard)时,
-        避免误报"一级章节缺失"。
-        """
-        actual_first = set()
-        for chunk in chunks:
-            # 支持 metadata 嵌套格式和直接字段格式
-            metadata = chunk.get("metadata", {})
-            cat1 = (metadata.get("chapter_classification") or
-                    chunk.get("chapter_classification") or
-                    chunk.get("first_code"))
-            # 归一化并验证
-            cat1 = self._normalize_chapter_code(cat1)
-            if self._is_valid_first_code(cat1):
-                actual_first.add(cat1)
-        return actual_first
-
-    def _extract_secondary_from_chunks(self, chunks: List[Dict]) -> Set[Tuple[str, str]]:
-        """从chunks提取实际存在的二级分类(支持 metadata 嵌套格式),跳过非标准项"""
-        actual = set()
-        for chunk in chunks:
-            # 支持 metadata 嵌套格式和直接字段格式
-            metadata = chunk.get("metadata", {})
-            cat1 = (metadata.get("chapter_classification") or
-                    chunk.get("chapter_classification") or
-                    chunk.get("first_code"))
-            cat2 = (metadata.get("secondary_category_code") or
-                    chunk.get("secondary_category_code") or
-                    chunk.get("second_code"))
-            # 跳过非标准项
-            if cat2 == "non_standard":
-                continue
-            # 跳过无效的一级分类代码
-            if not self._is_valid_first_code(cat1):
-                continue
-            if cat1 and cat2:
-                actual.add((cat1, cat2))
-        return actual
-
-    def _extract_from_outline(
-        self, outline: List[Dict]
-    ) -> Tuple[Set[str], Dict[Tuple[str, str], str]]:
-        """
-        从目录页提取一级分类集合与二级分类映射(含原始标题)
-
-        Returns:
-            outline_first:     {first_code, ...}
-            outline_secondary: {(first_code, second_code): outline_title, ...}
-        """
-        outline_first: Set[str] = set()
-        outline_secondary: Dict[Tuple[str, str], str] = {}
-
-        if not isinstance(outline, list):
-            return outline_first, outline_secondary
-
-        for item in outline:
-            if not isinstance(item, dict):
-                continue
-            cat1 = item.get("chapter_classification")
-            cat2 = item.get("secondary_category_code")
-            title = item.get("title", "")
-            if cat1:
-                outline_first.add(cat1)
-            if cat1 and cat2 and (cat1, cat2) not in outline_secondary:
-                outline_secondary[(cat1, cat2)] = title
-
-        return outline_first, outline_secondary
-
-    def _check_catalogue(self, outline_first: Set[str],
-                         outline_secondary: Dict[Tuple[str, str], str],
-                         chapter_classification: Optional[str] = None) -> Dict[str, Any]:
-        """
-        目录结构审查(一级 + 二级粒度)
-        检查目录页是否列出了标准要求的所有一级和二级章节
-
-        Args:
-            outline_first:     从目录页提取的一级分类集合
-            outline_secondary: 从目录页提取的二级分类映射 {(cat1,cat2): title}
-            chapter_classification: 若提供则只检查该一级章节范围
-        """
-        outline_second_keys = set(outline_secondary.keys())
-
-        # 确定检查范围
-        if chapter_classification:
-            required_first = (
-                {chapter_classification}
-                if any(k[0] == chapter_classification for k in self.secondary_specs)
-                else set()
-            )
-            required_second = {
-                (c1, c2) for (c1, c2) in self.secondary_specs
-                if c1 == chapter_classification
-            }
-            actual_first = {c for c in outline_first if c == chapter_classification}
-            actual_second_keys = {
-                (c1, c2) for (c1, c2) in outline_second_keys if c1 == chapter_classification
-            }
-        else:
-            required_first = {k[0] for k in self.secondary_specs}
-            required_second = set(self.secondary_specs.keys())
-            actual_first = outline_first
-            actual_second_keys = outline_second_keys
-
-        # 一级差异
-        missing_first = required_first - actual_first
-        extra_first = actual_first - required_first
-
-        # 二级差异
-        missing_second = required_second - actual_second_keys
-        extra_second = actual_second_keys - required_second
-
-        # 一级缺失详情
-        missing_first_details = []
-        for c in sorted(missing_first):
-            # 从任意该一级下的二级获取 first_seq
-            first_seq = 0
-            for (fc, sc), item in self.secondary_specs.items():
-                if fc == c:
-                    first_seq = item.first_seq
-                    break
-            missing_first_details.append({
-                "first_code": c,
-                "first_name": self.spec_loader.first_names.get(c, c),
-                "first_seq": first_seq
-            })
-
-        # 二级缺失详情
-        missing_second_details = []
-        for cat1, cat2 in sorted(missing_second):
-            item = self.secondary_specs.get((cat1, cat2))
-            missing_second_details.append({
-                "first_code": cat1,
-                "first_name": item.first_cn if item else self.spec_loader.first_names.get(cat1, cat1),
-                "first_seq": item.first_seq if item else 0,
-                "secondary_code": cat2,
-                "secondary_name": item.second_cn if item else "未知",
-                "second_seq": item.second_seq if item else 0
-            })
-
-        # 二级多余详情(目录有但标准无)
-        extra_second_details = []
-        for cat1, cat2 in sorted(extra_second):
-            item = self.secondary_specs.get((cat1, cat2))
-            extra_second_details.append({
-                "first_code": cat1,
-                "first_name": self.spec_loader.first_names.get(cat1, cat1),
-                "first_seq": item.first_seq if item else 0,
-                "secondary_code": cat2,
-                "secondary_name": item.second_cn if item else "未知",
-                "second_seq": item.second_seq if item else 0,
-                "outline_title": outline_secondary.get((cat1, cat2), "")
-            })
-
-        present_second = len(required_second & actual_second_keys)
-        second_rate = present_second / len(required_second) * 100 if required_second else 0
-
-        return {
-            "level": "primary_and_secondary",
-            "is_complete": len(missing_first) == 0 and len(missing_second) == 0,
-            "first_level": {
-                "total_required": len(required_first),
-                "actual_present": len(actual_first & required_first),
-                "missing_count": len(missing_first),
-                "extra_count": len(extra_first),
-                "missing": missing_first_details,
-                "extra": [
-                    {"first_code": c, "first_name": self.spec_loader.first_names.get(c, c)}
-                    for c in sorted(extra_first)
-                ]
-            },
-            "second_level": {
-                "total_required": len(required_second),
-                "actual_present": present_second,
-                "missing_count": len(missing_second),
-                "extra_count": len(extra_second),
-                "completeness_rate": f"{second_rate:.1f}%",
-                "missing": missing_second_details,
-                "extra": extra_second_details
-            }
-        }
-    
-    def _check_outline(
-        self,
-        actual_secondary: Set[Tuple[str, str]],
-        outline_secondary: Dict[Tuple[str, str], str]
-    ) -> Dict[str, Any]:
-        """
-        一致性审查(二级粒度)
-        对比目录页标题与正文实际内容的二级分类是否吻合
-
-        Args:
-            actual_secondary:  从正文 chunks 提取的二级分类集合
-            outline_secondary: 从目录页提取的二级分类映射 {(cat1,cat2): outline_title}
-        """
-        outline_keys = set(outline_secondary.keys())
-
-        # 空章节:目录页列了,但正文无对应内容
-        empty_sections = []
-        for (cat1, cat2) in sorted(outline_keys - actual_secondary):
-            item = self.secondary_specs.get((cat1, cat2))
-            empty_sections.append({
-                "first_code": cat1,
-                "first_name": item.first_cn if item else self.spec_loader.first_names.get(cat1, cat1),
-                "secondary_code": cat2,
-                "secondary_name": item.second_cn if item else "未知",
-                "outline_title": outline_secondary.get((cat1, cat2), "")  # 目录页原始标题
-            })
-
-        # 未归类内容:正文有内容,但目录页未列出
-        unclassified_content = []
-        for (cat1, cat2) in sorted(actual_secondary - outline_keys):
-            item = self.secondary_specs.get((cat1, cat2))
-            unclassified_content.append({
-                "first_code": cat1,
-                "first_name": item.first_cn if item else self.spec_loader.first_names.get(cat1, cat1),
-                "secondary_code": cat2,
-                "secondary_name": item.second_cn if item else "未知"
-            })
-
-        matched = outline_keys & actual_secondary
-        match_rate = len(matched) / len(outline_keys) * 100 if outline_keys else 0
-
-        return {
-            "level": "secondary",
-            "is_consistent": len(empty_sections) == 0 and len(unclassified_content) == 0,
-            "outline_secondary_count": len(outline_keys),
-            "content_secondary_count": len(actual_secondary),
-            "matched_count": len(matched),
-            "match_rate": f"{match_rate:.1f}%",
-            "empty_sections": empty_sections,       # 目录有,正文无
-            "unclassified_content": unclassified_content  # 正文有,目录无
-        }
-    
     def _calc_overall_status(self, tertiary_result: Dict) -> str:
         """计算总体状态"""
         rate_str = tertiary_result.get("completeness_rate", "0%").rstrip("%")
@@ -1149,52 +838,6 @@ class LightweightCompletenessChecker:
             return parts[-1].strip()  # 返回二级小节名
         return section_label.strip()
 
-    def _get_actual_first_name(self, label_map: Dict[Tuple[str, str], str],
-                                first_code: str) -> str:
-        """
-        获取实际一级章节名(从任意一个该一级下的 section_label 提取)
-        """
-        for (fc, sc), label in label_map.items():
-            if fc == first_code and "->" in label:
-                return label.split("->")[0].strip()
-        # 回退到标准名称
-        return self.spec_loader.first_names.get(first_code, first_code)
-
-
-# 便捷函数
-async def check_completeness_lightweight(
-    chunks: List[Dict],
-    outline: Optional[List[Dict]] = None,
-    standard_csv_path: Optional[str] = None,
-    model_client=None,
-    prompt_loader=None
-) -> LightweightCompletenessResult:
-    """
-    轻量级完整性审查入口函数
-
-    Args:
-        chunks: 文档分块列表,每个chunk需包含tertiary_category_code
-        outline: 目录结构(可选)
-        standard_csv_path: 三级标准CSV文件路径,默认为doc_worker/config/StandardCategoryTable.csv
-        model_client: 模型客户端(可选),用于生成智能建议
-        prompt_loader: 提示词加载器(可选)
-
-    Returns:
-        LightweightCompletenessResult
-    """
-    if standard_csv_path is None:
-        # 默认路径
-        default_path = Path(__file__).parent.parent.parent.parent.parent / "doc_worker" / "config" / "StandardCategoryTable.csv"
-        standard_csv_path = str(default_path)
-
-    checker = LightweightCompletenessChecker(
-        standard_csv_path,
-        model_client=model_client,
-        prompt_loader=prompt_loader
-    )
-    return await checker.check(chunks=chunks, outline=outline)
-
-
 def result_to_dict(result: LightweightCompletenessResult) -> Dict[str, Any]:
     """将结果对象转换为字典"""
     return {

+ 41 - 0
core/construction_review/component/reviewers/prompt/completeness_reviewers.yaml

@@ -0,0 +1,41 @@
+# 完整性审查提示词配置(方案B:直接LLM解释法)
+
+completeness_direct_check:
+  system_prompt: |
+    你是专业的施工方案完整性审查专家。
+
+    【任务】
+    给你一组施工方案文档片段和一组标准要求(来自《公路水运危险性较大工程专项施工方案编制审查规程》JT/T 1495—2024),请逐条判断文档是否覆盖了每条标准要求。
+
+    【输出格式】
+    对每条标准要求,输出一个JSON对象:
+    - standard_code: 标准分类代码(原样传入)
+    - standard_name: 标准分类名称(原样传入)
+    - is_covered: true/false(文档是否包含该内容)
+    - evidence: 如果覆盖,引用文档中的关键原文(50-150字);如果未覆盖,写"无"
+    - reason: 判断原因(30-80字,说明为什么认为覆盖或未覆盖)
+    - confidence: 置信度 0.0-1.0
+
+    【判断原则】
+    1. 只要文档中有相关内容(即使不完全匹配),就算覆盖
+    2. 如果文档提到了相关概念但不够具体,is_covered=true 但 confidence 较低(0.3-0.6)
+    3. 如果文档完全没有相关内容,is_covered=false
+    4. 注意区分:文档可能在不同片段提到同一标准的不同方面
+
+    【输出要求】
+    - 只输出JSON数组,不要任何解释文字
+    - 数组中每条标准要求对应一个对象
+    - 保持 standard_code 与输入一致
+
+  user_prompt_template: |
+    请审查以下施工方案文档是否覆盖了标准要求。
+
+    【文档章节】{chapter_name}
+
+    【文档内容】
+    {document_text}
+
+    【标准要求(共{total_standards}条)】
+    {standards_text}
+
+    请逐条判断文档是否覆盖了上述标准要求,输出JSON数组。

+ 12 - 2
core/construction_review/component/reviewers/utils/prompt_loader.py

@@ -182,8 +182,18 @@ class PromptLoader:
 
         try:
             prompt_config = self._cache[cache_key]
+        except KeyError:
+            try:
+                prompt_config = self._load_prompt(reviewer_type, prompt_name)
+            except Exception as e:
+                logger.error(f"创建ChatPromptTemplate失败: {reviewer_type}/{prompt_name}, 错误: {str(e)}")
+                display_name = prompt_name if prompt_name else reviewer_type
+                return ChatPromptTemplate.from_messages([
+                    ("system", f"你是专业的施工方案审查专家,负责进行{display_name}审查。"),
+                    ("user", "请审查:{review_content}")
+                ])
 
-            # 创建ChatPromptTemplate
+        try:
             template = ChatPromptTemplate.from_messages([
                 ("system", prompt_config['system_prompt']),
                 ("user", prompt_config['user_prompt_template'])
@@ -261,7 +271,7 @@ class PromptLoader:
             Dict[str, Any]: 加载结果统计
         """
 
-        reviewer_types = ['basic', 'technical', 'rag', 'ai', 'outline', 'query_extract']
+        reviewer_types = ['basic', 'technical', 'rag', 'ai', 'outline', 'query_extract', 'completeness', 'catalog']
 
         stats = {
             'loaded_types': [],

+ 1 - 0
foundation/ai/agent/generate/model_generate.py

@@ -585,6 +585,7 @@ class GenerateModelClient:
         prompt: Optional[str] = None,
         timeout: Optional[int] = None,
         model_name: Optional[str] = None,
+        enable_thinking: Optional[bool] = False,
         function_name: Optional[str] = None
     ):
         """模型流式生成(同步生成器)

+ 2 - 2
foundation/ai/models/model_config_loader.py

@@ -83,10 +83,10 @@ class ModelConfigLoader:
             self._config = self._get_default_config()
 
     def _get_default_config(self) -> Dict[str, Any]:
-        """获取默认配置"""
+        """获取默认配置(与 model_setting.yaml 的 default 保持一致)"""
         return {
             "default": {
-                "model": "qwen3_5_35b_a3b",
+                "model": "shutian_qwen3_5_122b",
                 "enable_thinking": False
             },
             "model_settings": {}

+ 73 - 100
foundation/ai/models/model_handler.py

@@ -65,6 +65,26 @@ class ModelHandler:
     REQUEST_TIMEOUT_THINKING = 360
     MAX_RETRIES = 2
 
+    # 模型类型 → 工厂方法 注册表
+    MODEL_FACTORY_MAP = {
+        "doubao": "_get_doubao_model",
+        "qwen": "_get_qwen_model",
+        "deepseek": "_get_deepseek_model",
+        "lq_qwen3_8b": "_get_lq_qwen3_8b_model",
+        "lq_qwen3_8b_lq_lora": "_get_lq_qwen3_8b_lora_model",
+        "lq_qwen3_4b": "_get_lq_qwen3_4b_model",
+        "qwen_local_14b": "_get_qwen_local_14b_model",
+        "qwen3_5_35b_a3b": "_get_qwen3_5_35b_a3b_model",
+        "qwen3_5_27b": "_get_qwen3_5_27b_model",
+        "qwen3_5_122b_a10b": "_get_qwen3_5_122b_a10b_model",
+        "shutian_qwen3_5_122b": "_get_shutian_qwen3_5_122b_model",
+        "shutian_qwen3_8b": "_get_shutian_qwen3_8b_model",
+        "shutian_qwen3_5_35b": "_get_shutian_qwen3_5_35b_model",
+        "shutian_qwen3_6_27b": "_get_shutian_qwen3_6_27b_model",
+    }
+
+    DEFAULT_FALLBACK_MODEL = "qwen3_5_35b_a3b"
+
     def __init__(self):
         """
         初始化模型处理器
@@ -75,6 +95,14 @@ class ModelHandler:
         self._model_cache = {}  # 模型实例缓存
         self._request_timeout_override = None
 
+    def _create_model_by_type(self, model_type: str):
+        """根据模型类型名称创建模型实例(通过注册表分发)"""
+        method_name = self.MODEL_FACTORY_MAP.get(model_type)
+        if method_name:
+            return getattr(self, method_name)()
+        logger.warning(f"未知的模型类型 '{model_type}',使用默认 {self.DEFAULT_FALLBACK_MODEL} 模型")
+        return self._get_qwen3_5_35b_a3b_model()
+
     @property
     def request_timeout(self):
         """当前请求超时时间,有 override 时优先返回 override"""
@@ -187,10 +215,10 @@ class ModelHandler:
             if model_type:
                 logger.debug(f"从 model_setting.yaml 读取默认模型: {model_type}")
             else:
-                model_type = self.config.get("model", "MODEL_TYPE")
+                model_type = self.DEFAULT_FALLBACK_MODEL
         except Exception as e:
-            logger.debug(f"从 model_setting.yaml 读取默认模型失败: {e},回退到 config.ini")
-            model_type = self.config.get("model", "MODEL_TYPE")
+            logger.debug(f"从 model_setting.yaml 读取默认模型失败: {e},回退到默认模型")
+            model_type = self.DEFAULT_FALLBACK_MODEL
         logger.info(f"正在初始化AI模型,模型类型: {model_type}")
 
         # 检查缓存
@@ -202,37 +230,7 @@ class ModelHandler:
         model = None
 
         try:
-            if model_type == "doubao":
-                model = self._get_doubao_model()
-            elif model_type == "qwen":
-                model = self._get_qwen_model()
-            elif model_type == "deepseek":
-                model = self._get_deepseek_model()
-            elif model_type == "lq_qwen3_8b":
-                model = self._get_lq_qwen3_8b_model()
-            elif model_type == "lq_qwen3_8b_lq_lora":
-                model = self._get_lq_qwen3_8b_lora_model()
-            elif model_type == "lq_qwen3_4b":
-                model = self._get_lq_qwen3_4b_model()
-            elif model_type == "qwen_local_14b":
-                model = self._get_qwen_local_14b_model()
-            elif model_type == "qwen3_5_35b_a3b":
-                model = self._get_qwen3_5_35b_a3b_model()
-            elif model_type == "qwen3_5_27b":
-                model = self._get_qwen3_5_27b_model()
-            elif model_type == "qwen3_5_122b_a10b":
-                model = self._get_qwen3_5_122b_a10b_model()
-            elif model_type == "shutian_qwen3_5_122b":
-                model = self._get_shutian_qwen3_5_122b_model()
-            elif model_type == "shutian_qwen3_8b":
-                model = self._get_shutian_qwen3_8b_model()
-            elif model_type == "shutian_qwen3_5_35b":
-                model = self._get_shutian_qwen3_5_35b_model()
-            elif model_type == "shutian_qwen3_6_27b":
-                model = self._get_shutian_qwen3_6_27b_model()
-            else:
-                logger.warning(f"未知的模型类型 '{model_type}',使用默认 qwen3_5_35b_a3b 模型")
-                model = self._get_qwen3_5_35b_a3b_model()
+            model = self._create_model_by_type(model_type)
 
             if model:
                 self._model_cache[cache_key] = model
@@ -280,7 +278,7 @@ class ModelHandler:
         """
         # 如果未指定模型类型,使用配置文件中的默认模型
         if model_type is None:
-            model_type = self.config.get("model", "MODEL_TYPE")
+            model_type = self.DEFAULT_FALLBACK_MODEL
 
         logger.info(f"动态获取AI模型,模型类型: {model_type}, thinking: {enable_thinking}")
 
@@ -298,37 +296,7 @@ class ModelHandler:
         model = None
 
         try:
-            if model_type == "doubao":
-                model = self._get_doubao_model()
-            elif model_type == "qwen":
-                model = self._get_qwen_model()
-            elif model_type == "deepseek":
-                model = self._get_deepseek_model()
-            elif model_type == "lq_qwen3_8b":
-                model = self._get_lq_qwen3_8b_model()
-            elif model_type == "lq_qwen3_8b_lq_lora":
-                model = self._get_lq_qwen3_8b_lora_model()
-            elif model_type == "lq_qwen3_4b":
-                model = self._get_lq_qwen3_4b_model()
-            elif model_type == "qwen_local_14b":
-                model = self._get_qwen_local_14b_model()
-            elif model_type == "qwen3_5_35b_a3b":
-                model = self._get_qwen3_5_35b_a3b_model()
-            elif model_type == "qwen3_5_27b":
-                model = self._get_qwen3_5_27b_model()
-            elif model_type == "qwen3_5_122b_a10b":
-                model = self._get_qwen3_5_122b_a10b_model()
-            elif model_type == "shutian_qwen3_5_122b":
-                model = self._get_shutian_qwen3_5_122b_model()
-            elif model_type == "shutian_qwen3_8b":
-                model = self._get_shutian_qwen3_8b_model()
-            elif model_type == "shutian_qwen3_5_35b":
-                model = self._get_shutian_qwen3_5_35b_model()
-            elif model_type == "shutian_qwen3_6_27b":
-                model = self._get_shutian_qwen3_6_27b_model()
-            else:
-                logger.warning(f"未知的模型类型 '{model_type}',使用默认 qwen3_5_35b_a3b 模型")
-                model = self._get_qwen3_5_35b_a3b_model()
+            model = self._create_model_by_type(model_type)
 
             if model:
                 self._model_cache[cache_key] = model
@@ -367,13 +335,9 @@ class ModelHandler:
         从 config/model_setting.yaml 加载功能对应的模型配置
 
         Args:
-            function_name: 功能名称,如:
+            function_name: 功能名称(定义在 model_setting.yaml 中),如:
                 - doc_classification_secondary: 文档二级分类
-                - doc_classification_tertiary: 文档三级分类
                 - completeness_review_generate: 完整性审查生成
-                - completeness_review_classify: 完整性审查分类
-                - rag_query_understand: RAG查询理解
-                - rag_answer_generate: RAG答案生成
                 - sensitive_check: 敏感信息检查
                 - grammar_check: 语法检查
 
@@ -381,7 +345,7 @@ class ModelHandler:
             ChatOpenAI: 配置好的AI模型实例
 
         Example:
-            model = model_handler.get_model_by_function("doc_classification_tertiary")
+            model = model_handler.get_model_by_function("doc_classification_secondary")
         """
         try:
             from foundation.ai.models.model_config_loader import get_model_for_function
@@ -394,7 +358,7 @@ class ModelHandler:
                 default_model = get_model_for_function("default")
                 return self.get_model_by_name(default_model)
             except Exception:
-                return self.get_model_by_name("qwen3_5_35b_a3b")
+                return self.get_model_by_name(self.DEFAULT_FALLBACK_MODEL)
 
     def get_embedding_model(self):
         """
@@ -404,26 +368,20 @@ class ModelHandler:
             OpenAIEmbeddings: 配置好的Embedding模型实例
 
         Note:
-            根据配置文件中的EMBEDDING_MODEL_TYPE参数选择对应模型
+            从 model_setting.yaml 读取embedding模型配置
             支持的模型类型:shutian_qwen3_embed, siliconflow_embed
             默认返回蜀天 shutian_qwen3_embed 模型
         """
-        # 优先从 model_setting.yaml 读取embedding配置
-        embedding_model_type = None
+        embedding_model_type = "shutian_qwen3_embed"
         try:
-            from .model_config_loader import model_config_loader
-            settings = model_config_loader._config.get("model_settings", {})
-            embedding_config = settings.get("embedding", {})
-            if embedding_config and "model" in embedding_config:
-                embedding_model_type = embedding_config["model"]
+            from .model_config_loader import get_model_for_function
+            model_name = get_model_for_function("embedding")
+            if model_name:
+                embedding_model_type = model_name
                 logger.debug(f"从 model_setting.yaml 读取embedding模型: {embedding_model_type}")
         except Exception as e:
             logger.debug(f"从 model_setting.yaml 读取embedding配置失败: {e}")
 
-        # 回退到 config.ini
-        if not embedding_model_type:
-            embedding_model_type = self.config.get("model", "EMBEDDING_MODEL_TYPE", "shutian_qwen3_embed")
-
         logger.info(f"正在初始化Embedding模型,模型类型: {embedding_model_type}")
 
         # 检查缓存
@@ -610,18 +568,21 @@ class ModelHandler:
             ChatOpenAI: 配置好的本地Qwen3-8B模型实例
         """
         try:
-            server_url = "http://192.168.91.253:9002/v1"
-            model_id = "Qwen3-8B"
+            server_url = self.config.get("lq_qwen3_8b", "QWEN_LOCAL_1_5B_SERVER_URL", "http://192.168.91.253:9002/v1")
+            model_id = self.config.get("lq_qwen3_8b", "QWEN_LOCAL_1_5B_MODEL_ID", "Qwen3-8B")
+            api_key = self.config.get("lq_qwen3_8b", "QWEN_LOCAL_1_5B_API_KEY", "dummy")
 
-            # 检查本地服务连接
-            if not self._check_connection(server_url, "dummy", timeout=3):
+            if not all([server_url, model_id]):
+                raise ModelConfigError("本地Qwen3-8B模型配置不完整")
+
+            if not self._check_connection(server_url, api_key, timeout=3):
                 logger.warning(f"本地Qwen3-8B模型服务连接失败: {server_url}")
                 raise ModelConnectionError(f"无法连接到本地Qwen3-8B模型服务: {server_url}")
 
             llm = ChatOpenAI(
                 base_url=server_url,
                 model=model_id,
-                api_key="dummy",
+                api_key=api_key,
                 temperature=0.7,
                 timeout=self.request_timeout,
             )
@@ -629,6 +590,8 @@ class ModelHandler:
             logger.info(f"本地Qwen3-8B模型初始化成功: {model_id}")
             return llm
 
+        except ModelConfigError:
+            raise
         except ModelConnectionError:
             raise
         except Exception as e:
@@ -688,18 +651,21 @@ class ModelHandler:
             ChatOpenAI: 配置好的本地Qwen3-4B模型实例
         """
         try:
-            server_url = "http://192.168.91.253:9001/v1"
-            model_id = "Qwen3-4B"
+            server_url = self.config.get("lq_qwen3_4b", "QWEN_LOCAL_1_5B_SERVER_URL", "http://192.168.91.253:9001/v1")
+            model_id = self.config.get("lq_qwen3_4b", "QWEN_LOCAL_1_5B_MODEL_ID", "Qwen3-4B")
+            api_key = self.config.get("lq_qwen3_4b", "QWEN_LOCAL_1_5B_API_KEY", "dummy")
 
-            # 检查本地服务连接
-            if not self._check_connection(server_url, "dummy", timeout=3):
+            if not all([server_url, model_id]):
+                raise ModelConfigError("本地Qwen3-4B模型配置不完整")
+
+            if not self._check_connection(server_url, api_key, timeout=3):
                 logger.warning(f"本地Qwen3-4B模型服务连接失败: {server_url}")
                 raise ModelConnectionError(f"无法连接到本地Qwen3-4B模型服务: {server_url}")
 
             llm = ChatOpenAI(
                 base_url=server_url,
                 model=model_id,
-                api_key="dummy",
+                api_key=api_key,
                 temperature=0.7,
                 timeout=self.request_timeout,
             )
@@ -707,6 +673,8 @@ class ModelHandler:
             logger.info(f"本地Qwen3-4B模型初始化成功: {model_id}")
             return llm
 
+        except ModelConfigError:
+            raise
         except ModelConnectionError:
             raise
         except Exception as e:
@@ -721,18 +689,21 @@ class ModelHandler:
             ChatOpenAI: 配置好的本地Qwen3-14B模型实例
         """
         try:
-            server_url = "http://192.168.91.253:9003/v1"
-            model_id = "Qwen3-14B"
+            server_url = self.config.get("qwen_local_14b", "QWEN_LOCAL_14B_SERVER_URL", "http://192.168.91.253:9003/v1")
+            model_id = self.config.get("qwen_local_14b", "QWEN_LOCAL_14B_MODEL_ID", "Qwen3-14B")
+            api_key = self.config.get("qwen_local_14b", "QWEN_LOCAL_14B_API_KEY", "dummy")
 
-            # 检查本地服务连接
-            if not self._check_connection(server_url, "dummy", timeout=3):
+            if not all([server_url, model_id]):
+                raise ModelConfigError("本地Qwen3-14B模型配置不完整")
+
+            if not self._check_connection(server_url, api_key, timeout=3):
                 logger.warning(f"本地Qwen3-14B模型服务连接失败: {server_url}")
                 raise ModelConnectionError(f"无法连接到本地Qwen3-14B模型服务: {server_url}")
 
             llm = ChatOpenAI(
                 base_url=server_url,
                 model=model_id,
-                api_key="dummy",
+                api_key=api_key,
                 temperature=0.7,
                 timeout=self.request_timeout,
             )
@@ -740,6 +711,8 @@ class ModelHandler:
             logger.info(f"本地Qwen3-14B模型初始化成功: {model_id}")
             return llm
 
+        except ModelConfigError:
+            raise
         except ModelConnectionError:
             raise
         except Exception as e:

+ 43 - 22
foundation/ai/rag/retrieval/retrieval.py

@@ -10,6 +10,24 @@ from foundation.infrastructure.config.config import config_handler
 from foundation.observability.logger.loggering import review_logger
 from foundation.database.base.vector.milvus_vector import MilvusVectorManager
 
+# model_setting.yaml 模型名 → (rerank_model 方法名, 日志描述)
+# 兼容旧 config.ini section 名(如 'bge_rerank_model')以保证平稳过渡
+RERANK_MODEL_ROUTE = {
+    # model_setting.yaml 标准名称
+    'shutian_qwen3_reranker':    ('shutian_rerank', '蜀天云算力 Qwen3-Reranker-8B'),
+    'lq_bge_reranker_v2_m3':     ('bge_rerank', '本地 BGE-reranker-v2-m3'),
+    'lq_qwen3_reranker':         ('lq_rerank', '本地 Qwen3-Reranker-8B'),
+    'silicoflow_qwen3_reranker': ('qwen3_rerank', '硅基流动 Qwen3-Reranker-8B'),
+    # 旧 config.ini section 名(向后兼容)
+    'bge_rerank_model':          ('bge_rerank', '本地 BGE-reranker-v2-m3'),
+    'lq_rerank_model':           ('lq_rerank', '本地 Qwen3-Reranker-8B'),
+    'silicoflow_rerank_model':   ('qwen3_rerank', '硅基流动 Qwen3-Reranker-8B'),
+    'shutian_rerank_model':      ('shutian_rerank', '蜀天云算力 Qwen3-Reranker-8B'),
+}
+
+VALID_RERANK_MODELS = list(RERANK_MODEL_ROUTE.keys())
+
+
 class RetrievalManager:
     """
     召回管理器,实现多路召回功能
@@ -24,20 +42,32 @@ class RetrievalManager:
         self.dense_weight = config_handler.get('hybrid_search', 'DENSE_WEIGHT', 0.7)
         self.sparse_weight = config_handler.get('hybrid_search', 'SPARSE_WEIGHT', 0.3)
 
-        # 重排序模型配置(从 [model] 部分统一管理)
-        self.rerank_model_type = config_handler.get('model', 'RERANK_MODEL_TYPE', 'bge_rerank_model')
+        # 重排序模型配置:优先从 model_setting.yaml 读取
+        rerank_model_name = self._resolve_rerank_model()
+        self.rerank_model_type = rerank_model_name
         self.logger.info(f"初始化重排序模型类型: {self.rerank_model_type}")
 
+    @staticmethod
+    def _resolve_rerank_model() -> str:
+        """从 model_setting.yaml 读取 rerank 模型"""
+        try:
+            from foundation.ai.models.model_config_loader import get_model_for_function
+            model_name = get_model_for_function("rerank")
+            if model_name and model_name in RERANK_MODEL_ROUTE:
+                return model_name
+        except Exception:
+            pass
+        return "shutian_qwen3_reranker"
+
     def set_rerank_model(self, model_type: str):
         """
         设置重排序模型类型
 
         Args:
-            model_type: 配置section名称 ('bge_rerank_model', 'lq_rerank_model', 'silicoflow_rerank_model')
+            model_type: 模型名称(支持 model_setting.yaml 名称或旧 config.ini section 名)
         """
-        valid_models = ['bge_rerank_model', 'lq_rerank_model', 'silicoflow_rerank_model', 'shutian_rerank_model']
-        if model_type not in valid_models:
-            raise ValueError(f"model_type 必须是 {valid_models}")
+        if model_type not in VALID_RERANK_MODELS:
+            raise ValueError(f"model_type 必须是 {VALID_RERANK_MODELS}")
 
         self.rerank_model_type = model_type
         self.logger.info(f"重排序模型类型已设置为: {model_type}")
@@ -104,22 +134,13 @@ class RetrievalManager:
             if not cleaned_documents:
                 return []
 
-            # 根据配置section名称路由到对应的reranker方法
-            if self.rerank_model_type == 'lq_rerank_model':
-                self.logger.info("使用本地 Qwen3-Reranker-8B (lq_rerank_model) 进行重排序")
-                rerank_results = rerank_model.lq_rerank(query_text, cleaned_documents, top_k)
-
-            elif self.rerank_model_type == 'silicoflow_rerank_model':
-                self.logger.info("使用硅基流动 Qwen3-Reranker-8B (silicoflow_rerank_model) 进行重排序")
-                rerank_results = rerank_model.qwen3_rerank(query_text, cleaned_documents, top_k)
-
-            elif self.rerank_model_type == 'shutian_rerank_model':
-                self.logger.info("使用蜀天云算力 Qwen3-Reranker-8B (shutian_rerank_model) 进行重排序")
-                rerank_results = rerank_model.shutian_rerank(query_text, cleaned_documents, top_k)
-
-            else:  # bge_rerank_model (默认)
-                self.logger.info("使用 BGE Reranker (bge_rerank_model) 进行重排序")
-                rerank_results = rerank_model.bge_rerank(query_text, cleaned_documents, top_k)
+            # 根据模型名称路由到对应的reranker方法
+            method_name, log_desc = RERANK_MODEL_ROUTE.get(
+                self.rerank_model_type,
+                ('bge_rerank', '默认 BGE Reranker')
+            )
+            self.logger.info(f"使用 {log_desc} ({self.rerank_model_type}) 进行重排序")
+            rerank_results = getattr(rerank_model, method_name)(query_text, cleaned_documents, top_k)
 
             # 将清理后的文本映射回原始文本(所有reranker都需要)
             for result in rerank_results:

+ 0 - 122
utils_test/minimal_pipeline/chunk_assembler.py

@@ -1,122 +0,0 @@
-"""
-把 PDF 提取结构 + 一/二级分类结果 组装成标准 chunks。
-
-chunk 格式保持与下游 chunk_classifier(三级分类)兼容。
-"""
-
-import re
-from typing import Dict, Any, List
-
-
-def assemble_chunks(
-    structure: Dict[str, Any],
-    primary_result: Dict[str, Any],
-    secondary_result: Dict[str, Any],
-) -> List[Dict[str, Any]]:
-    """
-    组装 chunks。
-
-    Args:
-        structure: PdfStructureExtractor 输出
-        primary_result: 一级分类结果
-        secondary_result: 二级分类结果
-
-    Returns:
-        标准 chunk 列表
-    """
-    # 1. 构建一级分类映射
-    primary_map: Dict[str, Dict[str, Any]] = {}
-    for item in primary_result.get("items", []):
-        title = item.get("title", "").strip()
-        if not title:
-            continue
-        info = {
-            "code": item.get("category_code", ""),
-            "name": item.get("category", ""),
-            "level2_titles": item.get("level2_titles", []),
-        }
-        primary_map[title] = info
-        primary_map[title.replace(" ", "")] = info
-        primary_map[title.replace(" ", "").replace("\t", "")] = info
-
-    # 2. 构建二级分类映射
-    secondary_map: Dict[str, Dict[str, str]] = {}
-    if secondary_result:
-        for sec_item in secondary_result.get("items", []):
-            original_title = sec_item.get("original_title", "")
-            for cls in sec_item.get("classifications", []):
-                section_title = cls.get("title", "")
-                section_label = f"{original_title}->{section_title}"
-                secondary_map[section_label] = {
-                    "code": cls.get("category_code", "non_standard"),
-                    "name": cls.get("category_name", "非标准项"),
-                }
-
-    # 3. 遍历结构生成 chunks
-    chunks: List[Dict[str, Any]] = []
-    chunk_index = 0
-
-    for chapter_title, sections in structure.get("chapters", {}).items():
-        if chapter_title == "quality_check":
-            continue
-        if not isinstance(sections, dict):
-            continue
-        primary_info = _get_primary_info(chapter_title, primary_map)
-        first_code = primary_info["code"] or "non_standard"
-        first_name = primary_info["name"] or "非标准项"
-        title_number = _extract_chapter_number(chapter_title)
-
-        for section_title, section_data in sections.items():
-            content = section_data.get("content", "")
-            if not content.strip():
-                continue
-
-            section_label = (
-                f"{chapter_title}->{section_title}"
-                if section_title != "章节标题"
-                else chapter_title
-            )
-            sec_info = secondary_map.get(section_label, {"code": "non_standard", "name": "非标准项"})
-
-            chunk = {
-                "chunk_id": f"doc_chunk_{title_number}_{chunk_index}",
-                "section_label": section_label,
-                "project_plan_type": first_code,
-                "chapter_classification": first_code,
-                "first_name": first_name,
-                "secondary_category_code": sec_info["code"],
-                "secondary_category_cn": sec_info["name"],
-                "hierarchy_path": [chapter_title, section_title],
-                "element_tag": {
-                    "chunk_id": f"doc_chunk_{title_number}_{chunk_index}",
-                    "page": section_data.get("page_start", 1),
-                    "serial_number": title_number if title_number else str(chunk_index + 1),
-                },
-                "review_chunk_content": content,
-                "page": section_data.get("page_start", 1),
-                "page_start": section_data.get("page_start", 1),
-                "page_end": section_data.get("page_end", 1),
-                "chapter": chapter_title,
-                "title": section_title,
-                "_sort_key": chunk_index,
-            }
-            chunks.append(chunk)
-            chunk_index += 1
-
-    return chunks
-
-
-def _get_primary_info(chapter_title: str, primary_map: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
-    if chapter_title in primary_map:
-        return primary_map[chapter_title]
-    no_space = chapter_title.replace(" ", "").replace("\t", "")
-    if no_space in primary_map:
-        return primary_map[no_space]
-    return {"code": "", "name": "", "level2_titles": []}
-
-
-def _extract_chapter_number(chapter_title: str) -> str:
-    match = re.search(r"第([一二三四五六七八九十百]+)章", chapter_title)
-    if match:
-        return f"第{match.group(1)}章"
-    return ""

+ 0 - 472
utils_test/minimal_pipeline/classifier.py

@@ -1,472 +0,0 @@
-"""
-简化版分类器(一级/二级/三级)
-
-直接调用 OpenAI 兼容 API,不依赖 core/foundation 代码。
-"""
-
-import asyncio
-import csv
-import json
-import re
-from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple
-
-from openai import AsyncOpenAI
-
-
-# ==================== 配置默认值 ====================
-
-DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-DEFAULT_MODEL = "qwen3.5-122b-a10b"
-DEFAULT_CONCURRENCY = 10
-
-# 一级分类标准
-PRIMARY_CATEGORIES = {
-    "编制依据": "basis",
-    "工程概况": "overview",
-    "施工计划": "plan",
-    "施工工艺技术": "technology",
-    "安全保证措施": "safety",
-    "质量保证措施": "quality",
-    "环境保证措施": "environment",
-    "施工管理及作业人员配备与分工": "management",
-    "验收要求": "acceptance",
-    "其他资料": "other",
-}
-
-# 标准二级标题白名单
-STANDARD_SECONDARY_TITLES: Dict[str, List[str]] = {
-    "basis": ["法律法规", "标准规范", "文件制度", "编制原则", "编制范围"],
-    "overview": ["设计概况", "工程地质与水文气象", "周边环境", "施工平面及立面布置", "施工要求和技术保证条件", "风险辨识与分级", "参建各方责任主体单位"],
-    "plan": ["施工进度计划", "施工材料计划", "施工设备计划", "劳动力计划", "安全生产费用使用计划"],
-    "technology": ["主要施工方法概述", "技术参数", "工艺流程", "施工准备", "施工方法及操作要求", "检查要求"],
-    "safety": ["安全保证体系", "组织保证措施", "技术保证措施", "监测监控措施", "应急处置措施"],
-    "quality": ["质量保证体系", "质量目标", "工程创优规划", "质量控制程序与具体措施"],
-    "environment": ["环境保证体系", "环境保护组织机构", "环境保护及文明施工措施"],
-    "management": ["施工管理人员", "专职安全生产管理人员", "其他作业人员"],
-    "acceptance": ["验收标准", "验收程序", "验收内容", "验收时间", "验收人员"],
-    "other": ["计算书", "相关施工图纸", "附图附表", "编制及审核人员情况"],
-}
-
-
-class SimpleClassifier:
-    """简化版文档分类器"""
-
-    def __init__(
-        self,
-        api_key: str,
-        base_url: str = DEFAULT_BASE_URL,
-        model: str = DEFAULT_MODEL,
-        concurrency: int = DEFAULT_CONCURRENCY,
-        csv_path: Optional[str] = None,
-    ):
-        self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
-        self.model = model
-        self.concurrency = concurrency
-        self.classification_tree = self._load_classification_tree(csv_path)
-
-    def _load_classification_tree(self, csv_path: Optional[str]) -> Dict[str, Dict[str, Any]]:
-        """从 CSV 加载分类标准树"""
-        tree: Dict[str, Dict[str, Any]] = {}
-        if csv_path is None:
-            # 默认路径:相对于项目根目录
-            csv_path = Path(__file__).parent.parent.parent / "core" / "construction_review" / "component" / "doc_worker" / "config" / "StandardCategoryTable.csv"
-        else:
-            csv_path = Path(csv_path)
-
-        if not csv_path.exists():
-            # 如果找不到 CSV,使用硬编码的最小标准
-            return self._build_minimal_tree()
-
-        with csv_path.open("r", encoding="utf-8-sig") as f:
-            reader = csv.DictReader(f)
-            for row in reader:
-                first_code = (row.get("first_code") or "").strip()
-                first_name = (row.get("first_name") or "").strip()
-                second_code = (row.get("second_code") or "").strip()
-                second_name = (row.get("second_name") or "").strip()
-                second_focus = (row.get("second_focus") or "").strip()
-                third_code = (row.get("third_code") or "").strip()
-                third_name = (row.get("third_name") or "").strip()
-                third_focus = (row.get("third_focus") or "").strip()
-
-                if not first_code or not second_code:
-                    continue
-
-                if first_code not in tree:
-                    tree[first_code] = {}
-                if second_code not in tree[first_code]:
-                    tree[first_code][second_code] = {
-                        "second_name": second_name,
-                        "second_focus": second_focus,
-                        "third_items": [],
-                    }
-                if third_code and third_name:
-                    tree[first_code][second_code]["third_items"].append({
-                        "third_code": third_code,
-                        "third_name": third_name,
-                        "third_focus": third_focus,
-                    })
-        return tree
-
-    def _build_minimal_tree(self) -> Dict[str, Dict[str, Any]]:
-        """构建最小化的分类标准树(兜底)"""
-        tree: Dict[str, Dict[str, Any]] = {}
-        for first_name, first_code in PRIMARY_CATEGORIES.items():
-            tree[first_code] = {}
-            second_titles = STANDARD_SECONDARY_TITLES.get(first_code, [])
-            for idx, title in enumerate(second_titles, 1):
-                tree[first_code][f"sec_{idx}"] = {
-                    "second_name": title,
-                    "second_focus": "",
-                    "third_items": [],
-                }
-        return tree
-
-    # ==================== 公共接口 ====================
-
-    async def classify_primary(self, toc_items: List[Dict[str, Any]]) -> Dict[str, Any]:
-        """一级目录分类"""
-        level1_items = [item for item in toc_items if item["level"] == 1]
-        if not level1_items:
-            return {"items": [], "total_count": 0, "target_level": 1, "category_stats": {}}
-
-        semaphore = asyncio.Semaphore(self.concurrency)
-
-        async def _classify_one(item: Dict[str, Any]) -> Dict[str, Any]:
-            async with semaphore:
-                return await self._call_llm_primary(item)
-
-        tasks = [_classify_one(item) for item in level1_items]
-        classified_items = await asyncio.gather(*tasks)
-
-        category_stats = {}
-        for item in classified_items:
-            cat = item.get("category", "非标准项")
-            category_stats[cat] = category_stats.get(cat, 0) + 1
-
-        return {
-            "items": classified_items,
-            "total_count": len(classified_items),
-            "target_level": 1,
-            "category_stats": category_stats,
-        }
-
-    async def classify_secondary(self, primary_result: Dict[str, Any]) -> Dict[str, Any]:
-        """二级目录分类"""
-        primary_items = primary_result.get("items", [])
-        if not primary_items:
-            return {"items": [], "total_count": 0, "category_stats": {}}
-
-        semaphore = asyncio.Semaphore(self.concurrency)
-
-        async def _classify_one(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
-            async with semaphore:
-                first_category = item.get("category", "")
-                first_code = item.get("category_code", "")
-                level2_titles = item.get("level2_titles", [])
-                if not level2_titles:
-                    return None
-                return await self._call_llm_secondary(
-                    first_category, first_code, level2_titles, item.get("title", "")
-                )
-
-        tasks = [_classify_one(item) for item in primary_items]
-        results = await asyncio.gather(*tasks)
-        results = [r for r in results if r is not None]
-
-        category_stats = {}
-        for result in results:
-            for cls in result.get("classifications", []):
-                code = cls.get("category_code", "non_standard")
-                category_stats[code] = category_stats.get(code, 0) + 1
-
-        return {
-            "items": results,
-            "total_count": sum(r.get("level2_count", 0) for r in results),
-            "category_stats": category_stats,
-        }
-
-    async def classify_tertiary(self, chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
-        """三级分类(简化版:逐 chunk 分类)"""
-        if not chunks:
-            return chunks
-
-        semaphore = asyncio.Semaphore(self.concurrency)
-
-        async def _classify_chunk(chunk: Dict[str, Any]) -> Dict[str, Any]:
-            first_code = chunk.get("chapter_classification", "")
-            second_code = chunk.get("secondary_category_code", "")
-            if not first_code or not second_code or second_code == "non_standard":
-                chunk["tertiary_category_code"] = "none"
-                chunk["tertiary_category_cn"] = "无"
-                return chunk
-
-            standards = self._build_tertiary_standards(first_code, second_code)
-            if not standards:
-                chunk["tertiary_category_code"] = "none"
-                chunk["tertiary_category_cn"] = "无"
-                return chunk
-
-            async with semaphore:
-                return await self._call_llm_tertiary(chunk, standards)
-
-        tasks = [_classify_chunk(c) for c in chunks]
-        return list(await asyncio.gather(*tasks))
-
-    # ==================== LLM 调用实现 ====================
-
-    async def _call_llm(self, system_prompt: str, user_prompt: str) -> Optional[Dict[str, Any]]:
-        """基础 LLM 调用"""
-        try:
-            response = await self.client.chat.completions.create(
-                model=self.model,
-                messages=[
-                    {"role": "system", "content": system_prompt},
-                    {"role": "user", "content": user_prompt},
-                ],
-                temperature=0.3,
-            )
-            content = response.choices[0].message.content or ""
-            return _extract_json(content)
-        except Exception as e:
-            print(f"[LLM 调用失败] {e}")
-            return None
-
-    async def _call_llm_primary(self, item: Dict[str, Any]) -> Dict[str, Any]:
-        """调用 LLM 进行一级分类"""
-        title = item.get("title", "")
-        system_prompt = """你是一个施工方案文档目录分类专家。
-请将给定的一级章节标题分类到以下类别之一,返回 JSON 格式:
-{"category_cn": "类别中文名", "category_code": "类别代码", "confidence": 0.95}
-
-可选类别:
-- 编制依据 (basis)
-- 工程概况 (overview)
-- 施工计划 (plan)
-- 施工工艺技术 (technology)
-- 安全保证措施 (safety)
-- 质量保证措施 (quality)
-- 环境保证措施 (environment)
-- 施工管理及作业人员配备与分工 (management)
-- 验收要求 (acceptance)
-- 其他资料 (other)
-- 非标准项 (non_standard)
-
-如果标题明显不属于以上任何类别,归为"非标准项"。"""
-
-        user_prompt = f"一级章节标题:{title}"
-
-        result = await self._call_llm(system_prompt, user_prompt)
-        if result and isinstance(result, dict):
-            category_cn = result.get("category_cn", "")
-            category_code = result.get("category_code", "")
-            confidence = result.get("confidence", 0.0)
-            if category_cn not in PRIMARY_CATEGORIES and category_cn != "非标准项":
-                category_cn = "非标准项"
-                category_code = "non_standard"
-                confidence = 0.0
-            if category_cn in PRIMARY_CATEGORIES and not category_code:
-                category_code = PRIMARY_CATEGORIES[category_cn]
-        else:
-            category_cn = "非标准项"
-            category_code = "non_standard"
-            confidence = 0.0
-
-        return {
-            "title": title,
-            "page": item.get("page", 0),
-            "level": item.get("level", 1),
-            "category": category_cn,
-            "category_code": category_code,
-            "original": item.get("original", ""),
-            "level2_titles": item.get("level2_titles", []),
-            "confidence": confidence,
-        }
-
-    async def _call_llm_secondary(
-        self,
-        first_category: str,
-        first_category_code: str,
-        level2_titles: List[str],
-        original_title: str,
-    ) -> Dict[str, Any]:
-        """调用 LLM 进行二级分类(批量模式)"""
-        # 获取该一级分类下的二级标准
-        secondary_items = []
-        if first_category_code in self.classification_tree:
-            for sec_code, sec_data in self.classification_tree[first_category_code].items():
-                secondary_items.append(f"- {sec_data['second_name']} ({sec_code})")
-
-        standards_text = "\n".join(secondary_items) if secondary_items else "(无预定义标准)"
-        titles_list = "\n".join(f"{i+1}. {title}" for i, title in enumerate(level2_titles))
-
-        system_prompt = f"""你是一个施工方案文档目录分类专家。
-请将以下二级小节标题分类到对应类别,返回 JSON 格式:
-{{"classifications": [{{"title": "原标题", "category_index": 索引, "category_name": "分类名"}}]}}
-
-一级分类:{first_category}
-
-可选二级分类:
-{standards_text}
-
-特殊索引:0 = 非标准项
-
-要求:
-1. 返回的 classifications 数组长度必须与输入标题数量完全一致
-2. category_index 必须是数字索引
-3. 只返回 JSON,不要其他解释"""
-
-        user_prompt = f"待分类的二级标题:\n{titles_list}"
-
-        result = await self._call_llm(system_prompt, user_prompt)
-        classifications = []
-
-        if result and isinstance(result, dict) and "classifications" in result:
-            raw_list = result["classifications"]
-            if len(raw_list) == len(level2_titles):
-                for i, raw in enumerate(raw_list):
-                    idx = raw.get("category_index", 0)
-                    name = raw.get("category_name", "")
-                    # 查找代码
-                    code = "non_standard"
-                    if first_category_code in self.classification_tree:
-                        for sec_code, sec_data in self.classification_tree[first_category_code].items():
-                            if sec_data["second_name"] == name or sec_code == name:
-                                code = sec_code
-                                break
-                    if idx == 0 or not name:
-                        name = "非标准项"
-                        code = "non_standard"
-                    classifications.append({
-                        "title": level2_titles[i],
-                        "category_index": idx,
-                        "category_code": code,
-                        "category_name": name,
-                    })
-            else:
-                # 数量不匹配,全部设为非标准项
-                for title in level2_titles:
-                    classifications.append({
-                        "title": title,
-                        "category_index": 0,
-                        "category_code": "non_standard",
-                        "category_name": "非标准项",
-                    })
-        else:
-            # LLM 调用失败,全部设为非标准项
-            for title in level2_titles:
-                classifications.append({
-                    "title": title,
-                    "category_index": 0,
-                    "category_code": "non_standard",
-                    "category_name": "非标准项",
-                })
-
-        return {
-            "first_category": first_category,
-            "first_category_code": first_category_code,
-            "original_title": original_title,
-            "level2_count": len(level2_titles),
-            "classifications": classifications,
-        }
-
-    async def _call_llm_tertiary(
-        self,
-        chunk: Dict[str, Any],
-        standards: List[Dict[str, str]],
-    ) -> Dict[str, Any]:
-        """调用 LLM 进行三级分类(简化版)"""
-        content = chunk.get("review_chunk_content", "")[:500]  # 限制长度
-        section_label = chunk.get("section_label", "")
-
-        standards_text = "\n".join(
-            f"{i+1}. {s['name']} ({s['code']}) - {s.get('focus', '')}"
-            for i, s in enumerate(standards)
-        )
-
-        system_prompt = """你是一个施工方案文档内容分类专家。
-请判断给定的文档内容属于哪个三级分类,返回 JSON 格式:
-{"category_index": 索引, "category_name": "分类名"}
-
-如果内容不属于任何类别,返回 {"category_index": 0, "category_name": "非标准项"}。
-只返回 JSON,不要其他解释。"""
-
-        user_prompt = f"""文档章节:{section_label}
-
-内容预览:
-{content}
-
-可选分类:
-{standards_text}
-"""
-
-        result = await self._call_llm(system_prompt, user_prompt)
-        if result and isinstance(result, dict):
-            idx = result.get("category_index", 0)
-            name = result.get("category_name", "")
-            if idx == 0 or not name:
-                chunk["tertiary_category_code"] = "non_standard"
-                chunk["tertiary_category_cn"] = "非标准项"
-            else:
-                # 查找 code
-                code = "non_standard"
-                if idx <= len(standards):
-                    code = standards[idx - 1]["code"]
-                    name = standards[idx - 1]["name"]
-                chunk["tertiary_category_code"] = code
-                chunk["tertiary_category_cn"] = name
-        else:
-            chunk["tertiary_category_code"] = "non_standard"
-            chunk["tertiary_category_cn"] = "非标准项"
-
-        return chunk
-
-    def _build_tertiary_standards(self, first_code: str, second_code: str) -> List[Dict[str, str]]:
-        """构建三级分类标准列表"""
-        if first_code not in self.classification_tree:
-            return []
-        if second_code not in self.classification_tree[first_code]:
-            return []
-        third_items = self.classification_tree[first_code][second_code].get("third_items", [])
-        if not third_items:
-            return []
-        return [
-            {
-                "code": item["third_code"],
-                "name": item["third_name"],
-                "focus": item.get("third_focus", ""),
-            }
-            for item in third_items
-        ]
-
-
-# ==================== 工具函数 ====================
-
-def _extract_json(text: str) -> Optional[Dict[str, Any]]:
-    """从字符串中提取第一个有效 JSON 对象"""
-    if not text or not text.strip():
-        return None
-    text = text.strip()
-    try:
-        return json.loads(text)
-    except json.JSONDecodeError:
-        pass
-    for pattern in [r"```json\s*(\{.*?})\s*```", r"```\s*(\{.*?})\s*```"]:
-        m = re.search(pattern, text, re.DOTALL)
-        if m:
-            try:
-                return json.loads(m.group(1))
-            except json.JSONDecodeError:
-                pass
-    try:
-        for candidate in re.findall(r"(\{[\s\S]*?})", text):
-            try:
-                result = json.loads(candidate)
-                if isinstance(result, dict):
-                    return result
-            except json.JSONDecodeError:
-                continue
-    except Exception:
-        pass
-    return None

+ 0 - 100
utils_test/minimal_pipeline/models.py

@@ -1,100 +0,0 @@
-"""
-最简化数据模型
-"""
-
-from dataclasses import dataclass, field
-from typing import Dict, Any, List, Optional
-
-
-@dataclass
-class ClassificationItem:
-    """分类项(一级或二级)"""
-    title: str
-    page: int
-    level: int
-    category: str = ""          # 中文分类名
-    category_code: str = ""     # 分类代码
-    confidence: float = 0.0
-    original: str = ""
-    # 二级分类特有
-    level2_titles: List[str] = field(default_factory=list)
-    classifications: List[Dict[str, Any]] = field(default_factory=list)
-
-
-@dataclass
-class ChunkItem:
-    """文档 chunk"""
-    chunk_id: str
-    section_label: str
-    chapter_classification: str     # 一级分类代码
-    first_name: str                 # 一级分类中文
-    secondary_category_code: str    # 二级分类代码
-    secondary_category_cn: str      # 二级分类中文
-    hierarchy_path: List[str]
-    review_chunk_content: str
-    page_start: int
-    page_end: int
-    # 三级分类结果
-    tertiary_category_code: str = ""
-    tertiary_category_cn: str = ""
-    tertiary_classification_details: List[Dict[str, Any]] = field(default_factory=list)
-
-
-@dataclass
-class PipelineResult:
-    """管线处理结果"""
-    document_name: str
-    total_pages: int
-    # 原始提取结构
-    chapters: Dict[str, Any] = field(default_factory=dict)
-    # 分类结果
-    primary_items: List[ClassificationItem] = field(default_factory=list)
-    secondary_items: List[Dict[str, Any]] = field(default_factory=list)
-    # chunks
-    chunks: List[ChunkItem] = field(default_factory=list)
-    # 质量检查
-    quality_check: Dict[str, Any] = field(default_factory=dict)
-    # 统计
-    stats: Dict[str, Any] = field(default_factory=dict)
-
-    def to_dict(self) -> Dict[str, Any]:
-        """转换为可序列化的字典"""
-        return {
-            "document_name": self.document_name,
-            "total_pages": self.total_pages,
-            "chapters": self.chapters,
-            "primary_items": [
-                {
-                    "title": item.title,
-                    "page": item.page,
-                    "level": item.level,
-                    "category": item.category,
-                    "category_code": item.category_code,
-                    "confidence": item.confidence,
-                    "original": item.original,
-                    "level2_titles": item.level2_titles,
-                }
-                for item in self.primary_items
-            ],
-            "secondary_items": self.secondary_items,
-            "chunks": [
-                {
-                    "chunk_id": c.chunk_id,
-                    "section_label": c.section_label,
-                    "chapter_classification": c.chapter_classification,
-                    "first_name": c.first_name,
-                    "secondary_category_code": c.secondary_category_code,
-                    "secondary_category_cn": c.secondary_category_cn,
-                    "hierarchy_path": c.hierarchy_path,
-                    "review_chunk_content": c.review_chunk_content,
-                    "page_start": c.page_start,
-                    "page_end": c.page_end,
-                    "tertiary_category_code": c.tertiary_category_code,
-                    "tertiary_category_cn": c.tertiary_category_cn,
-                    "tertiary_classification_details": c.tertiary_classification_details,
-                }
-                for c in self.chunks
-            ],
-            "quality_check": self.quality_check,
-            "stats": self.stats,
-        }

+ 0 - 289
utils_test/minimal_pipeline/pdf_extractor.py

@@ -1,289 +0,0 @@
-"""
-简化版 PDF 结构提取器
-
-基于 PyMuPDF 的规则引擎,将 PDF 按一级/二级标题切分为章节结构。
-不依赖 OCR,不依赖任何 core/foundation 代码。
-"""
-
-import re
-from dataclasses import dataclass
-from typing import Any, Dict, List, Optional, Tuple
-
-import fitz
-
-
-@dataclass(frozen=True)
-class BodyLine:
-    """一条规范化后的正文行,以及它所在的 PDF 页码。"""
-    page: int
-    text: str
-
-
-class SimplePdfExtractor:
-    """基于规则的 PDF 正文结构提取器。"""
-
-    RULE_LIB = {
-        "Rule_1_纯数字派": {
-            "l1": re.compile(
-                r"^\d{1,2}(?:[\..。])?\s+(?:(?!\d)[\u4e00-\u9fa5A-Za-z].*|[、,,]\s*[\u4e00-\u9fa5A-Za-z0-9].*)"
-            ),
-            "l2": re.compile(r"^(\d+)\.(\d+)(?!\.\d)\.?\s*([\u4e00-\u9fa5]+.*)"),
-        },
-        "Rule_2_混合章派": {
-            "l1": re.compile(r"^第\s*(\d+)\s*[章部部分篇]\s*[,、]?\s*(.*)"),
-            "l2": re.compile(r"^(\d+)\.(\d+)(?!\.\d)\.?\s*([\u4e00-\u9fa5]+.*)"),
-        },
-        "Rule_3_中英混血派": {
-            "l1": re.compile(r"^第\s*[一二三四五六七八九十百零两]+\s*[章部部分篇]\s*[,、]?\s*(.*)"),
-            "l2": re.compile(r"^(\d+)\.(\d+)(?!\.\d)\.?\s*([\u4e00-\u9fa5]+.*)"),
-        },
-        "Rule_4_传统公文派": {
-            "l1": re.compile(r"^第\s*[一二三四五六七八九十百零两]+\s*[章部部分篇]\s*[,、]?\s*(.*)"),
-            "l2": re.compile(r"^([一二三四五六七八九十百零两]+)[,、\s]+([\u4e00-\u9fa5]+.*)"),
-        },
-        "Rule_5_单边括号派": {
-            "l1": re.compile(r"^第\s*(?:\d+|[一二三四五六七八九十百零两]+)\s*[章部部分篇]\s*[,、]?\s*(.*)"),
-            "l2": re.compile(r"^([一二三四五六七八九十百零两]+)[))\]]\s*([\u4e00-\u9fa5]+.*)"),
-        },
-        "Rule_6_小节派": {
-            "l1": re.compile(r"^第\s*(?:\d+|[一二三四五六七八九十百零两]+)\s*[章部部分篇]\s*[,、]?\s*(.*)"),
-            "l2": re.compile(r"^第\s*(\d+|[一二三四五六七八九十百零两]+)\s*节\s*[,、]?\s*([\u4e00-\u9fa5]+.*)"),
-        },
-        "Rule_7_粗体括号派": {
-            "l1": re.compile(r"^第\s*[一二三四五六七八九十百零两]+\s*[章部部分篇]\s*[,、]?\s*(.*)"),
-            "l2": re.compile(r"^[【\[]\s*(\d+)\s*[\]】]\s*([\u4e00-\u9fa5]+.*)"),
-        },
-        "Rule_8_中文序号章数字小节派": {
-            "l1": re.compile(r"^([一二三四五六七八九十百零两]+)[,、))\]]\s*([\u4e00-\u9fa5A-Za-z].*)"),
-            "l2": re.compile(r"^(\d+)\.(\d+)(?!\.\d)\.?\s*([\u4e00-\u9fa5]+.*)"),
-        },
-    }
-
-    CN_NUM_MAP = {
-        "零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
-        "五": 5, "六": 6, "七": 7, "八": 8, "九": 9,
-    }
-
-    TOC_PATTERN = re.compile(r"\.{3,}|…{2,}|-{3,}|·{3,}|•{3,}")
-
-    def __init__(self, clip_top: float = 60, clip_bottom: float = 60):
-        self.clip_top = clip_top
-        self.clip_bottom = clip_bottom
-
-    def extract(self, file_content: bytes) -> Dict[str, Any]:
-        """提取章节结构。"""
-        result: Dict[str, Any] = {
-            "chapters": {},
-            "total_pages": 0,
-        }
-        doc = fitz.open(stream=file_content, filetype="pdf")
-        try:
-            body_lines = self._extract_body_lines(doc)
-            raw_data, winning_rule, coverage_rate, rule_performance = self._extract_body_with_best_rule(body_lines)
-            chapters = self._convert_rule_output_to_chapters(raw_data)
-
-            result["chapters"] = chapters
-            result["total_pages"] = len(doc)
-            result["body_rule"] = winning_rule
-            result["body_coverage"] = coverage_rate
-            result["rule_performance"] = rule_performance
-            return result
-        finally:
-            doc.close()
-
-    def _extract_body_lines(self, doc: fitz.Document) -> List[BodyLine]:
-        """读取裁剪后的页面文本,规范化正文行。"""
-        page_lines_by_page: List[Tuple[int, List[str]]] = []
-        total_pages = len(doc)
-
-        for page_index in range(total_pages):
-            page = doc.load_page(page_index)
-            rect = page.rect
-            clip_box = fitz.Rect(0, self.clip_top, rect.width, rect.height - self.clip_bottom)
-            text = page.get_text("text", clip=clip_box)
-
-            page_lines: List[str] = []
-            for line in text.splitlines():
-                stripped = line.strip()
-                if not stripped or self._is_header_footer(stripped):
-                    continue
-                page_lines.append(stripped)
-
-            page_lines_by_page.append((page_index + 1, page_lines))
-
-        # 移除跨页重复的非标题噪声(页眉页脚)
-        repeated_noise_keys = self._find_repeated_non_heading_lines(page_lines_by_page, total_pages)
-        body_lines: List[BodyLine] = []
-        for page, lines in page_lines_by_page:
-            for line in lines:
-                if self._normalize_repeated_line_key(line) in repeated_noise_keys:
-                    continue
-                body_lines.append(BodyLine(page=page, text=line))
-        return body_lines
-
-    def _is_header_footer(self, text: str) -> bool:
-        """判断是否为页眉页脚。"""
-        # 纯数字页码
-        if re.match(r"^\d+$", text):
-            return True
-        # 常见页眉格式
-        if re.match(r"^(四川路桥|专项施工方案|第\s*\d+\s*页|Page\s*\d+)$", text, re.IGNORECASE):
-            return True
-        return False
-
-    def _normalize_repeated_line_key(self, text: str) -> str:
-        """归一化行文本,用于检测重复。"""
-        return text.replace(" ", "").replace("\t", "").replace("\u3000", "")
-
-    def _find_repeated_non_heading_lines(self, page_lines_by_page: List[Tuple[int, List[str]]], total_pages: int) -> set:
-        """找出跨页重复且不像标题的行。"""
-        line_counts: Dict[str, int] = {}
-        for _, lines in page_lines_by_page:
-            for line in lines:
-                key = self._normalize_repeated_line_key(line)
-                line_counts[key] = line_counts.get(key, 0) + 1
-
-        repeated = set()
-        for key, count in line_counts.items():
-            if count >= 2 and count >= total_pages * 0.3:
-                # 只移除明显不像标题的重复行
-                sample = next((line for _, lines in page_lines_by_page for line in lines
-                               if self._normalize_repeated_line_key(line) == key), "")
-                if not self._looks_like_heading(sample):
-                    repeated.add(key)
-        return repeated
-
-    def _looks_like_heading(self, text: str) -> bool:
-        """判断文本是否像标题。"""
-        for rule_name, rule in self.RULE_LIB.items():
-            if rule["l1"].match(text) or rule["l2"].match(text):
-                return True
-        return False
-
-    def _extract_body_with_best_rule(
-        self, body_lines: List[BodyLine]
-    ) -> Tuple[Dict[str, Any], str, float, Dict[str, Any]]:
-        """用所有规则竞争,选出覆盖率最高的规则。"""
-        best_result = None
-        best_rule = ""
-        best_coverage = 0.0
-        rule_performance = {}
-
-        for rule_name, rule in self.RULE_LIB.items():
-            try:
-                result, coverage = self._apply_rule(body_lines, rule["l1"], rule["l2"])
-                rule_performance[rule_name] = {"coverage": coverage}
-                if coverage > best_coverage:
-                    best_coverage = coverage
-                    best_result = result
-                    best_rule = rule_name
-            except Exception:
-                rule_performance[rule_name] = {"coverage": 0.0, "error": True}
-
-        if best_result is None:
-            best_result = {}
-            best_rule = "none"
-            best_coverage = 0.0
-
-        return best_result, best_rule, best_coverage, rule_performance
-
-    def _apply_rule(
-        self,
-        body_lines: List[BodyLine],
-        l1_pattern: re.Pattern,
-        l2_pattern: re.Pattern,
-    ) -> Tuple[Dict[str, Any], float]:
-        """应用一组规则,提取章节结构。"""
-        result: Dict[str, Any] = {"chapters": []}
-        current_chapter = None
-        current_section = None
-        current_content_lines: List[str] = []
-        current_pages: List[int] = []
-        total_lines = len(body_lines)
-        heading_lines = 0
-
-        def _flush_section():
-            nonlocal current_chapter, current_section, current_content_lines, current_pages
-            if current_chapter is None:
-                return
-            if current_section is None:
-                # 章节标题行
-                chapter_data = result["chapters"][-1] if result["chapters"] else None
-                if chapter_data:
-                    chapter_data["sections"]["章节标题"]["content"] = "\n".join(current_content_lines).strip()
-                    if current_pages:
-                        chapter_data["sections"]["章节标题"]["page_start"] = min(current_pages)
-                        chapter_data["sections"]["章节标题"]["page_end"] = max(current_pages)
-            else:
-                chapter_data = result["chapters"][-1] if result["chapters"] else None
-                if chapter_data and current_section in chapter_data["sections"]:
-                    chapter_data["sections"][current_section]["content"] = "\n".join(current_content_lines).strip()
-                    if current_pages:
-                        chapter_data["sections"][current_section]["page_start"] = min(current_pages)
-                        chapter_data["sections"][current_section]["page_end"] = max(current_pages)
-            current_content_lines = []
-            current_pages = []
-
-        for line in body_lines:
-            text = line.text
-            page = line.page
-            l1_match = l1_pattern.match(text)
-            l2_match = l2_pattern.match(text)
-
-            if l1_match and not l2_match:
-                # 一级标题
-                _flush_section()
-                current_chapter = text
-                current_section = None
-                result["chapters"].append({
-                    "title": text,
-                    "page_start": page,
-                    "sections": {
-                        "章节标题": {
-                            "content": "",
-                            "page_start": page,
-                            "page_end": page,
-                        }
-                    }
-                })
-                current_content_lines = [text]
-                current_pages = [page]
-                heading_lines += 1
-            elif l2_match and current_chapter is not None:
-                # 二级标题
-                _flush_section()
-                current_section = text
-                chapter_data = result["chapters"][-1]
-                chapter_data["sections"][text] = {
-                    "content": "",
-                    "page_start": page,
-                    "page_end": page,
-                }
-                current_content_lines = [text]
-                current_pages = [page]
-                heading_lines += 1
-            else:
-                # 正文
-                current_content_lines.append(text)
-                current_pages.append(page)
-
-        _flush_section()
-
-        # 计算覆盖率
-        coverage = heading_lines / max(total_lines, 1)
-        return result, coverage
-
-    def _convert_rule_output_to_chapters(self, raw_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
-        """将规则输出转换为以章节标题为键的字典。"""
-        chapters: Dict[str, Dict[str, Any]] = {}
-        for chapter in raw_data.get("chapters", []):
-            title = chapter.get("title", "未命名章节")
-            sections = {}
-            for sec_name, sec_data in chapter.get("sections", {}).items():
-                sections[sec_name] = {
-                    "content": sec_data.get("content", ""),
-                    "page_start": sec_data.get("page_start", 1),
-                    "page_end": sec_data.get("page_end", 1),
-                }
-            chapters[title] = sections
-        return chapters

+ 0 - 194
utils_test/minimal_pipeline/pipeline.py

@@ -1,194 +0,0 @@
-"""
-管线编排:调度 PDF 提取 → 目录识别 → 切分 → 分类
-"""
-
-import asyncio
-from pathlib import Path
-from typing import Any, Dict, List, Optional
-
-from .pdf_extractor import SimplePdfExtractor
-from .toc_builder import build_toc_items_from_structure
-from .chunk_assembler import assemble_chunks
-from .classifier import SimpleClassifier
-from .models import PipelineResult, ClassificationItem
-
-
-class MinimalPipeline:
-    """独立最小化文档处理管线"""
-
-    def __init__(
-        self,
-        api_key: str,
-        base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
-        model: str = "qwen3.5-122b-a10b",
-        concurrency: int = 10,
-        csv_path: Optional[str] = None,
-    ):
-        self.extractor = SimplePdfExtractor()
-        self.classifier = SimpleClassifier(
-            api_key=api_key,
-            base_url=base_url,
-            model=model,
-            concurrency=concurrency,
-            csv_path=csv_path,
-        )
-
-    async def process(
-        self,
-        file_content: bytes,
-        file_name: str = "",
-        skip_tertiary: bool = False,
-        progress_callback: Optional[callable] = None,
-    ) -> PipelineResult:
-        """
-        处理 PDF 文档。
-
-        Args:
-            file_content: PDF 文件字节内容
-            file_name: 文件名(用于报告)
-            skip_tertiary: 是否跳过三级分类(节省 LLM 调用)
-            progress_callback: 进度回调函数 (stage, percent, message) -> None
-
-        Returns:
-            PipelineResult
-        """
-        result = PipelineResult(document_name=file_name, total_pages=0)
-
-        # 1. PDF 结构提取
-        if progress_callback:
-            progress_callback("文档提取", 0, "开始 PDF 结构提取...")
-
-        structure = self.extractor.extract(file_content)
-        result.total_pages = structure.get("total_pages", 0)
-        result.chapters = structure.get("chapters", {})
-
-        if progress_callback:
-            chapter_count = len([k for k in result.chapters.keys() if k != "quality_check"])
-            progress_callback("文档提取", 20, f"PDF 提取完成,共 {chapter_count} 个一级章节")
-
-        # 2. 目录构建
-        toc_items = build_toc_items_from_structure(structure)
-        if not toc_items:
-            result.quality_check = {"error": "未提取到有效目录结构"}
-            return result
-
-        if progress_callback:
-            progress_callback("文档分类", 25, f"构建目录完成,共 {len(toc_items)} 个目录项")
-
-        # 3. 一级分类
-        primary_result = await self.classifier.classify_primary(toc_items)
-        result.primary_items = [
-            ClassificationItem(
-                title=item["title"],
-                page=item["page"],
-                level=item["level"],
-                category=item["category"],
-                category_code=item["category_code"],
-                confidence=item["confidence"],
-                original=item["original"],
-                level2_titles=item.get("level2_titles", []),
-            )
-            for item in primary_result.get("items", [])
-        ]
-
-        if progress_callback:
-            progress_callback("文档分类", 40, f"一级分类完成,共 {len(result.primary_items)} 项")
-
-        # 4. 二级分类
-        secondary_result = await self.classifier.classify_secondary(primary_result)
-        result.secondary_items = secondary_result.get("items", [])
-
-        if progress_callback:
-            progress_callback("文档分类", 55, f"二级分类完成,共 {secondary_result.get('total_count', 0)} 项")
-
-        # 5. 组装 chunks
-        chunks = assemble_chunks(structure, primary_result, secondary_result)
-        if not chunks:
-            result.quality_check = {"error": "无可用的 chunks"}
-            return result
-
-        if progress_callback:
-            progress_callback("文档切分", 60, f"组装完成,共 {len(chunks)} 个内容块")
-
-        # 6. 三级分类(可选)
-        if not skip_tertiary:
-            chunks = await self.classifier.classify_tertiary(chunks)
-            if progress_callback:
-                progress_callback("文档分类", 90, "三级分类完成")
-        else:
-            for chunk in chunks:
-                chunk["tertiary_category_code"] = "skipped"
-                chunk["tertiary_category_cn"] = "已跳过"
-            if progress_callback:
-                progress_callback("文档分类", 90, "已跳过三级分类")
-
-        # 7. 转换为 ChunkItem
-        from .models import ChunkItem
-        result.chunks = [
-            ChunkItem(
-                chunk_id=c["chunk_id"],
-                section_label=c["section_label"],
-                chapter_classification=c["chapter_classification"],
-                first_name=c["first_name"],
-                secondary_category_code=c["secondary_category_code"],
-                secondary_category_cn=c["secondary_category_cn"],
-                hierarchy_path=c["hierarchy_path"],
-                review_chunk_content=c["review_chunk_content"],
-                page_start=c["page_start"],
-                page_end=c["page_end"],
-                tertiary_category_code=c.get("tertiary_category_code", ""),
-                tertiary_category_cn=c.get("tertiary_category_cn", ""),
-                tertiary_classification_details=c.get("tertiary_classification_details", []),
-            )
-            for c in chunks
-        ]
-
-        # 8. 质量检查
-        result.quality_check = self._build_quality_check(structure, result)
-
-        # 9. 统计
-        result.stats = {
-            "total_pages": result.total_pages,
-            "chapter_count": len(result.primary_items),
-            "chunk_count": len(result.chunks),
-            "primary_category_distribution": primary_result.get("category_stats", {}),
-            "secondary_category_distribution": secondary_result.get("category_stats", {}),
-        }
-
-        if progress_callback:
-            progress_callback("完成", 100, "处理完成")
-
-        return result
-
-    def _build_quality_check(self, structure: Dict[str, Any], result: PipelineResult) -> Dict[str, Any]:
-        """构建质量检查结果"""
-        chapters = structure.get("chapters", {})
-        l1_count = len([k for k in chapters.keys() if k != "quality_check"])
-        l2_count = 0
-        for chapter_name, sections in chapters.items():
-            if isinstance(sections, dict):
-                for section_name in sections.keys():
-                    if section_name != "章节标题":
-                        l2_count += 1
-
-        default_total_chapters = 10
-        default_total_subsections = 41
-        l1_rate = l1_count / default_total_chapters if default_total_chapters > 0 else 1.0
-        l2_rate = l2_count / default_total_subsections if default_total_subsections > 0 else 1.0
-
-        return {
-            "l1_chapter_quality": {
-                "extracted_count": l1_count,
-                "expected_count": default_total_chapters,
-                "extraction_rate": round(l1_rate * 100, 2),
-                "threshold": 70.0,
-                "exist_issue": l1_rate < 0.70,
-            },
-            "l2_subsection_quality": {
-                "extracted_count": l2_count,
-                "expected_count": default_total_subsections,
-                "extraction_rate": round(l2_rate * 100, 2),
-                "threshold": 73.0,
-                "exist_issue": l2_rate < 0.73,
-            },
-        }

+ 0 - 173
utils_test/minimal_pipeline/run.py

@@ -1,173 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-独立最小化管线运行入口
-
-用法:
-    python run.py -p <pdf路径> [-o <输出目录>] [--skip-tertiary] [--ocr]
-
-示例:
-    python utils_test/minimal_pipeline/run.py \
-        -p "D:/wx_work/sichuan_luqiao/lu_sgsc_testfile/测试模版.pdf" \
-        -o ./output \
-        --skip-tertiary
-"""
-
-import argparse
-import asyncio
-import json
-import os
-import sys
-import time
-from pathlib import Path
-
-PROJECT_ROOT = Path(__file__).parent.parent.parent
-os.chdir(PROJECT_ROOT)
-
-from utils_test.minimal_pipeline import MinimalPipeline
-from utils_test.minimal_pipeline.models import PipelineResult
-
-
-def parse_args():
-    parser = argparse.ArgumentParser(description="独立最小化文档处理管线")
-    parser.add_argument("-p", "--pdf", required=True, help="PDF 文件路径")
-    parser.add_argument("-o", "--output", default="./output", help="输出目录(默认 ./output)")
-    parser.add_argument("--skip-tertiary", action="store_true", help="跳过三级分类(节省 LLM 调用)")
-    parser.add_argument("--api-key", default=os.environ.get("DASHSCOPE_API_KEY", ""), help="API Key(默认从环境变量 DASHSCOPE_API_KEY 读取)")
-    parser.add_argument("--base-url", default="https://dashscope.aliyuncs.com/compatible-mode/v1", help="API Base URL")
-    parser.add_argument("--model", default="qwen3.5-122b-a10b", help="模型名称")
-    parser.add_argument("--csv", default=None, help="StandardCategoryTable.csv 路径(默认自动查找)")
-    return parser.parse_args()
-
-
-def print_progress(stage: str, percent: int, message: str):
-    """进度回调"""
-    bar_len = 30
-    filled = int(bar_len * percent / 100)
-    bar = "█" * filled + "░" * (bar_len - filled)
-    print(f"\r[{bar}] {percent:3d}% | {stage:10s} | {message}", end="", flush=True)
-    if percent >= 100:
-        print()
-
-
-def print_result(result: PipelineResult, elapsed: float):
-    """打印结果摘要"""
-    print("\n" + "=" * 80)
-    print("处理结果摘要")
-    print("=" * 80)
-    print(f"文档名称: {result.document_name}")
-    print(f"总页数: {result.total_pages}")
-    print(f"处理耗时: {elapsed:.2f} 秒")
-    print(f"\n一级章节数: {len(result.primary_items)}")
-    for item in result.primary_items:
-        print(f"  [{item.category_code:15s}] {item.title}")
-
-    print(f"\nChunks 数: {len(result.chunks)}")
-    for chunk in result.chunks[:5]:
-        print(f"  {chunk.chunk_id} | {chunk.section_label} | "
-              f"一级={chunk.first_name} 二级={chunk.secondary_category_cn} "
-              f"三级={chunk.tertiary_category_cn}")
-    if len(result.chunks) > 5:
-        print(f"  ... 共 {len(result.chunks)} 个 chunks")
-
-    print(f"\n质量检查:")
-    qc = result.quality_check
-    l1 = qc.get("l1_chapter_quality", {})
-    l2 = qc.get("l2_subsection_quality", {})
-    print(f"  一级提取率: {l1.get('extraction_rate', 0):.1f}% ({l1.get('extracted_count', 0)}/{l1.get('expected_count', 0)})")
-    print(f"  二级提取率: {l2.get('extraction_rate', 0):.1f}% ({l2.get('extracted_count', 0)}/{l2.get('expected_count', 0)})")
-
-    print(f"\n分类统计:")
-    for level, stats in result.stats.items():
-        if isinstance(stats, dict) and stats:
-            print(f"  {level}:")
-            for cat, count in stats.items():
-                print(f"    {cat}: {count}")
-
-    print("=" * 80)
-
-
-def main():
-    args = parse_args()
-
-    pdf_path = Path(args.pdf)
-    if not pdf_path.exists():
-        print(f"[错误] PDF 文件不存在: {pdf_path}")
-        return 1
-
-    if not args.api_key:
-        print("[错误] 未提供 API Key。请通过 --api-key 参数或 DASHSCOPE_API_KEY 环境变量设置。")
-        return 1
-
-    output_dir = Path(args.output)
-    output_dir.mkdir(parents=True, exist_ok=True)
-
-    print(f"[信息] 处理文档: {pdf_path}")
-    print(f"[信息] 输出目录: {output_dir}")
-    print(f"[信息] 模型: {args.model}")
-    print(f"[信息] 跳过三级分类: {args.skip_tertiary}")
-    print()
-
-    # 读取 PDF
-    with open(pdf_path, "rb") as f:
-        file_content = f.read()
-
-    # 初始化管线
-    pipeline = MinimalPipeline(
-        api_key=args.api_key,
-        base_url=args.base_url,
-        model=args.model,
-        concurrency=10,
-        csv_path=args.csv,
-    )
-
-    # 运行管线
-    start_time = time.time()
-    try:
-        result = asyncio.run(pipeline.process(
-            file_content=file_content,
-            file_name=pdf_path.name,
-            skip_tertiary=args.skip_tertiary,
-            progress_callback=print_progress,
-        ))
-    except Exception as e:
-        print(f"\n[错误] 处理失败: {e}")
-        import traceback
-        traceback.print_exc()
-        return 1
-
-    elapsed = time.time() - start_time
-
-    # 打印结果
-    print_result(result, elapsed)
-
-    # 保存结果
-    output_file = output_dir / f"{pdf_path.stem}_result.json"
-    with open(output_file, "w", encoding="utf-8") as f:
-        json.dump(result.to_dict(), f, ensure_ascii=False, indent=2)
-    print(f"[信息] 结果已保存到: {output_file}")
-
-    # 保存 chunks 明细
-    chunks_file = output_dir / f"{pdf_path.stem}_chunks.jsonl"
-    with open(chunks_file, "w", encoding="utf-8") as f:
-        for chunk in result.chunks:
-            f.write(json.dumps({
-                "chunk_id": chunk.chunk_id,
-                "section_label": chunk.section_label,
-                "chapter_classification": chunk.chapter_classification,
-                "first_name": chunk.first_name,
-                "secondary_category_code": chunk.secondary_category_code,
-                "secondary_category_cn": chunk.secondary_category_cn,
-                "tertiary_category_code": chunk.tertiary_category_code,
-                "tertiary_category_cn": chunk.tertiary_category_cn,
-                "page_start": chunk.page_start,
-                "page_end": chunk.page_end,
-                "content_preview": chunk.review_chunk_content[:200] + "...",
-            }, ensure_ascii=False) + "\n")
-    print(f"[信息] Chunks 明细已保存到: {chunks_file}")
-
-    return 0
-
-
-if __name__ == "__main__":
-    sys.exit(main())

+ 0 - 48
utils_test/minimal_pipeline/toc_builder.py

@@ -1,48 +0,0 @@
-"""
-从 PDF 提取结构构造 toc_items,供分类器使用。
-"""
-
-from typing import Dict, Any, List
-
-
-def build_toc_items_from_structure(structure: Dict[str, Any]) -> List[Dict[str, Any]]:
-    """
-    将 PdfStructureExtractor 的输出转换为分类器所需的 toc_items 格式。
-
-    Returns:
-        [
-            {"title": "第一章 xxx", "page": 1, "level": 1, "original": "第一章 xxx"},
-            {"title": "一、xxx", "page": 2, "level": 2, "original": "一、xxx"},
-            ...
-        ]
-    """
-    toc_items: List[Dict[str, Any]] = []
-    for chapter_title, sections in structure.get("chapters", {}).items():
-        # 跳过 quality_check 等非章节数据
-        if chapter_title == "quality_check":
-            continue
-        # 安全获取 page_start
-        page_starts = [
-            s.get("page_start", 1)
-            for s in sections.values()
-            if isinstance(s, dict)
-        ]
-        page_start = min(page_starts) if page_starts else 1
-
-        toc_items.append({
-            "title": chapter_title,
-            "page": page_start,
-            "level": 1,
-            "original": chapter_title,
-        })
-        for section_title, section_data in sections.items():
-            if section_title == "章节标题":
-                continue
-            sec_page_start = section_data.get("page_start", 1) if isinstance(section_data, dict) else 1
-            toc_items.append({
-                "title": section_title,
-                "page": sec_page_start,
-                "level": 2,
-                "original": section_title,
-            })
-    return toc_items

+ 52 - 206
views/construction_write/content_completion.py

@@ -1,23 +1,19 @@
 # -*- coding: utf-8 -*-
 """
-上下文生成接口 - 极速版 (Shutian Optimized)
-目标平台:蜀天算力 Qwen3.5-122B-A10B
-API: 蜀天算力 (通过统一配置管理)
-模型:Qwen3.5-122B-A10B
+上下文生成接口 - 通过统一模型调用框架 (generate_model_client)
 """
 
 import uuid
 import json
 import time
 import asyncio
-import aiohttp
 from typing import Optional, List, Dict, Any, AsyncGenerator
 from pydantic import BaseModel, Field
 from fastapi import APIRouter, HTTPException
 from fastapi.responses import StreamingResponse
 from foundation.observability.logger.loggering import write_logger as logger
 from foundation.infrastructure.tracing import TraceContext, auto_trace
-from foundation.infrastructure.config.config import config_handler
+from foundation.ai.agent.generate.model_generate import generate_model_client
 from core.base.workflow_manager import workflow_manager
 from redis.asyncio import Redis as AsyncRedis
 
@@ -25,24 +21,15 @@ from redis.asyncio import Redis as AsyncRedis
 
 content_completion_router = APIRouter(prefix="/sgbx", tags=["施工方案编写"])
 
-# ==================== 2. 全局资源池 (速度优化核心) ====================
+CONTENT_COMPLETION_FUNCTION = "write_content_generate"
+
+# ==================== 2. 全局资源池 ====================
 
-GLOBAL_HTTP_SESSION: Optional[aiohttp.ClientSession] = None
 GLOBAL_REDIS_CLIENT: Optional[AsyncRedis] = None
 
 async def init_global_resources():
     """初始化全局连接池"""
-    global GLOBAL_HTTP_SESSION, GLOBAL_REDIS_CLIENT
-    
-    if GLOBAL_HTTP_SESSION is None or GLOBAL_HTTP_SESSION.closed:
-        # 增加 DNS 缓存和连接复用,针对蜀天算力域名优化
-        connector = aiohttp.TCPConnector(limit=100, limit_per_host=20, ttl_dns_cache=300, force_close=False)
-        GLOBAL_HTTP_SESSION = aiohttp.ClientSession(
-            timeout=aiohttp.ClientTimeout(total=120, connect=10, sock_read=10), # 连接超时稍长以防网络波动
-            connector=connector,
-            headers={"User-Agent": "FastAPI-Shutian-Optimized/2.0"}
-        )
-        logger.info("✅ 全局 HTTP 连接池已初始化 (Shutian Ready)")
+    global GLOBAL_REDIS_CLIENT
 
     if GLOBAL_REDIS_CLIENT is None:
         try:
@@ -62,173 +49,40 @@ async def _background_ping():
         try: await GLOBAL_REDIS_CLIENT.ping()
         except: pass
 
-async def get_http_session():
-    if GLOBAL_HTTP_SESSION is None or GLOBAL_HTTP_SESSION.closed:
-        await init_global_resources()
-    return GLOBAL_HTTP_SESSION
-
 async def get_redis_client():
     if GLOBAL_REDIS_CLIENT is None:
         await init_global_resources()
     return GLOBAL_REDIS_CLIENT
 
-# ==================== 3. 文件操作工具 ====================
-
-# ==================== 4. 自定义 API 配置 (蜀天算力 Qwen3.5-122B) ====================
-
-class CustomAPIConfig:
-    # model_setting.yaml 中的功能名称
-    FUNCTION_NAME = "write_content_generate"
-
-    # 兜底默认值(蜀天 Qwen3.5-122B-A10B)
-    SHUTIAN_SERVER_URL_DEFAULT = "http://183.220.37.46:25423/v1"
-    SHUTIAN_API_KEY_DEFAULT = "lq123456"
-    DEFAULT_MODEL_NAME = "/model/Qwen3.5-122B-A10B"
-
-    @staticmethod
-    def _resolve_from_model_handler():
-        """通过 model_handler 统一解析模型配置(url, api_key, model_id)"""
-        try:
-            from foundation.ai.models.model_handler import model_handler
-            llm = model_handler.get_model_by_function(CustomAPIConfig.FUNCTION_NAME)
-            url = getattr(llm, 'base_url', None) or getattr(llm, 'openai_api_base', '')
-            url = str(url) if url else ''
-            model_id = getattr(llm, 'model_name', None) or getattr(llm, 'model', '')
-            model_id = str(model_id) if model_id else ''
-            api_key = getattr(llm, 'openai_api_key', None)
-            if api_key:
-                api_key = api_key.get_secret_value() if hasattr(api_key, 'get_secret_value') else str(api_key)
-            else:
-                api_key = ''
-            if url and api_key:
-                return url, api_key, model_id
-        except Exception:
-            pass
-        return None, None, None
-
-    @staticmethod
-    def get_api_url() -> str:
-        configured_url = config_handler.get("custom_api", "API_URL", "")
-        if configured_url:
-            return configured_url
-        url, _, _ = CustomAPIConfig._resolve_from_model_handler()
-        if url:
-            return url
-        return config_handler.get("shutian", "SHUTIAN_122B_SERVER_URL", CustomAPIConfig.SHUTIAN_SERVER_URL_DEFAULT)
-
-    @staticmethod
-    def get_api_key() -> str:
-        configured_key = config_handler.get("custom_api", "API_KEY", "")
-        if configured_key:
-            return configured_key
-        _, api_key, _ = CustomAPIConfig._resolve_from_model_handler()
-        if api_key:
-            return api_key
-        return config_handler.get("shutian", "SHUTIAN_122B_API_KEY", CustomAPIConfig.SHUTIAN_API_KEY_DEFAULT)
-
-    @staticmethod
-    def get_model_name() -> str:
-        configured_model = config_handler.get("custom_api", "MODEL_NAME", "")
-        if configured_model:
-            return configured_model
-        _, _, model_id = CustomAPIConfig._resolve_from_model_handler()
-        if model_id:
-            return model_id
-        return config_handler.get("shutian", "SHUTIAN_122B_MODEL_ID", CustomAPIConfig.DEFAULT_MODEL_NAME)
-
-    @staticmethod
-    def is_enabled() -> bool:
-        return bool(CustomAPIConfig.get_api_key()) and bool(CustomAPIConfig.get_api_url())
-
-# ==================== 5. 极速流式调用 (核心优化) ====================
+# ==================== 3. 流式调用 (通过统一模型框架) ====================
 
 async def call_custom_api_stream(
-    prompt: str, system_prompt: str = "", max_tokens: int = 2000, 
+    prompt: str, system_prompt: str = "", max_tokens: int = 2000,
     temperature: float = 0.7, trace_id: str = ""
 ) -> AsyncGenerator[tuple[str, Optional[float]], None]:
-    
-    api_url = CustomAPIConfig.get_api_url()
-    model_name = CustomAPIConfig.get_model_name()
-    api_key = CustomAPIConfig.get_api_key()
-    
-    logger.debug(f"[{trace_id}] 正在调用蜀天算力: {model_name} @ {api_url}")
+    """流式调用 LLM,通过 generate_model_client 统一管理"""
 
-    # 截断过长的 Prompt (服务端对输入长度有限制,且为了速度)
+    # 截断过长的 Prompt
     max_prompt_len = 10000
     if len(prompt) > max_prompt_len:
         prompt = prompt[-max_prompt_len:]
         logger.debug(f"[{trace_id}] Prompt 已截断至 {max_prompt_len} 字符")
 
-    payload = {
-        "model": model_name,
-        "messages": [
-            {"role": "system", "content": system_prompt},
-            {"role": "user", "content": prompt}
-        ],
-        "max_tokens": max_tokens,
-        "temperature": temperature,
-        "stream": True,
-        "incremental_output": True # 蜀天算力兼容模式支持此参数,优化流式体验
-    }
-    
-    headers = {
-        "Content-Type": "application/json",
-        "Authorization": f"Bearer {api_key}"
-    }
-    
     start_time = time.time()
     first_token_time: Optional[float] = None
-    buffer = ""
-    
-    session = await get_http_session()
-    
+
     try:
-        # 蜀天算力 HTTP 连接,保持 read_bufsize=1 以获取最快首字
-        async with session.post(api_url, json=payload, headers=headers, read_bufsize=1) as response:
-            if response.status != 200:
-                error_text = await response.text()
-                logger.error(f"[{trace_id}] API 错误 {response.status}: {error_text}")
-                raise Exception(f"API 错误 {response.status}: {error_text}")
-            
-            async for chunk in response.content.iter_any():
-                if not chunk: continue
-                try:
-                    text = chunk.decode('utf-8', errors='ignore')
-                    if not text: continue
-                    buffer += text
-                    
-                    while '\n' in buffer:
-                        line, buffer = buffer.split('\n', 1)
-                        line = line.strip()
-                        
-                        if line.startswith('data: '):
-                            data = line[6:]
-                            if data == '[DONE]':
-                                return
-                            
-                            try:
-                                event_data = json.loads(data)
-                                # 处理服务端可能的错误格式
-                                if "error" in event_data:
-                                    err_msg = event_data["error"].get("message", "Unknown Error")
-                                    logger.error(f"[{trace_id}] 流式数据中包含错误: {err_msg}")
-                                    continue
-
-                                choices = event_data.get("choices", [])
-                                if choices:
-                                    delta = choices[0].get("delta", {})
-                                    content = delta.get("content", "")
-                                    
-                                    if content:
-                                        if first_token_time is None:
-                                            first_token_time = time.time() - start_time
-                                        yield (content, first_token_time)
-                            except json.JSONDecodeError:
-                                continue
-                except UnicodeDecodeError:
-                    continue
+        for chunk in generate_model_client.get_model_generate_stream(
+            trace_id=trace_id,
+            system_prompt=system_prompt,
+            user_prompt=prompt,
+            function_name=CONTENT_COMPLETION_FUNCTION
+        ):
+            if first_token_time is None:
+                first_token_time = time.time() - start_time
+            yield (chunk, first_token_time)
     except Exception as e:
-        logger.error(f"[{trace_id}] API 流式请求异常: {e}")
+        logger.error(f"[{trace_id}] 流式请求异常: {e}")
         raise
 
 # ==================== 6. 数据模型 ====================
@@ -334,42 +188,35 @@ async def generate_content_stream(callback_task_id, source_task_id, user_id, req
         )
 
         yield format_sse_event("generating", json.dumps({
-            "status": "generating", 
-            "message": f"正在调用蜀天 Qwen3.5-122B ({CustomAPIConfig.get_model_name()})...",
+            "status": "generating",
+            "message": "正在调用 LLM 模型 (write_content_generate)...",
             "timestamp": int(time.time())
         }, ensure_ascii=False))
 
-        # 执行生成
-        if CustomAPIConfig.is_enabled():
-            logger.info(f"[{callback_task_id}] 使用蜀天算力 API (模型:{CustomAPIConfig.get_model_name()})")
-            async for content, ftl in call_custom_api_stream(
-                prompt=user_prompt,
-                system_prompt=CONTENT_COMPLETION_SYSTEM_PROMPT,
-                max_tokens=min(request.completion_config.target_length, 4000),
-                temperature=0.7,
-                trace_id=callback_task_id
-            ):
-                if await is_cancelled():
-                    yield format_sse_event("cancelled", json.dumps({"status": "cancelled"}, ensure_ascii=False))
-                    return
-                
-                if content:
-                    full_content_parts.append(content)
-                    chunk_count += 1
-                    
-                    if first_token_latency is None:
-                        first_token_latency = ftl if ftl is not None else (time.time() - stream_start_time)
-                        logger.info(f"[{callback_task_id}] ⚡ 首字延迟: {first_token_latency:.3f}s (Model: {CustomAPIConfig.get_model_name()})")
-                    
-                    yield format_sse_event("chunk", json.dumps({
-                        "chunk": content,
-                        "first_token_latency": round(first_token_latency, 3),
-                        "timestamp": int(time.time())
-                    }, ensure_ascii=False))
-        else:
-            # 备用逻辑 (理论上不会触发,因为 Key 已硬编码)
-            logger.warning(f"[{callback_task_id}] API 配置失效,回退到默认模型 (不应发生)")
-            raise Exception("API 配置未生效,请检查 CustomAPIConfig")
+        async for content, ftl in call_custom_api_stream(
+            prompt=user_prompt,
+            system_prompt=CONTENT_COMPLETION_SYSTEM_PROMPT,
+            max_tokens=min(request.completion_config.target_length, 4000),
+            temperature=0.7,
+            trace_id=callback_task_id
+        ):
+            if await is_cancelled():
+                yield format_sse_event("cancelled", json.dumps({"status": "cancelled"}, ensure_ascii=False))
+                return
+
+            if content:
+                full_content_parts.append(content)
+                chunk_count += 1
+
+                if first_token_latency is None:
+                    first_token_latency = ftl if ftl is not None else (time.time() - stream_start_time)
+                    logger.info(f"[{callback_task_id}] ⚡ 首字延迟: {first_token_latency:.3f}s")
+
+                yield format_sse_event("chunk", json.dumps({
+                    "chunk": content,
+                    "first_token_latency": round(first_token_latency, 3),
+                    "timestamp": int(time.time())
+                }, ensure_ascii=False))
 
         # 完成统计
         total_duration = time.time() - stream_start_time
@@ -385,7 +232,7 @@ async def generate_content_stream(callback_task_id, source_task_id, user_id, req
                 "total_duration": round(total_duration, 3),
                 "char_count": len(full_content),
                 "chunk_count": chunk_count,
-                "model_used": CustomAPIConfig.get_model_name()
+                "model_used": CONTENT_COMPLETION_FUNCTION
             },
             "full_content": full_content,
             "timestamp": int(time.time())
@@ -439,7 +286,7 @@ async def health_check():
     return {
         "status": "healthy",
         "provider": "Shutian",
-        "current_model": CustomAPIConfig.get_model_name(),
+        "current_model": CONTENT_COMPLETION_FUNCTION,
         "api_url_prefix": "https://dashscope.aliyuncs.com/compatible-mode/v1"
     }
 
@@ -453,12 +300,11 @@ async def get_modes():
 
 @content_completion_router.get("/content_completion_api_status", response_model=ContentCompletionResponse)
 async def get_api_status():
-    enabled = CustomAPIConfig.is_enabled()
     return ContentCompletionResponse(
-        code=200, message="success", 
+        code=200, message="success",
         data={
-            "enabled": enabled, 
+            "enabled": True,
             "provider": "Shutian",
-            "model": CustomAPIConfig.get_model_name()
+            "model": CONTENT_COMPLETION_FUNCTION
         }
     )

+ 51 - 200
views/construction_write/outline_views.py

@@ -14,14 +14,13 @@ import uuid
 import json
 import time
 import asyncio
-import aiohttp
 from typing import Optional, Dict, Any, List, AsyncGenerator, Union
 from pydantic import BaseModel, Field
 from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from foundation.observability.logger.loggering import write_logger as logger
 from foundation.infrastructure.tracing import TraceContext, auto_trace
-from foundation.infrastructure.config.config import config_handler
+from foundation.ai.agent.generate.model_generate import generate_model_client
 from core.base.workflow_manager import workflow_manager
 from core.base.sse_manager import unified_sse_manager
 from core.base.progress_manager import ProgressManager
@@ -268,23 +267,15 @@ class ContextGenerateResponse(BaseModel):
     data: Optional[Dict[str, Any]] = None
 
 
-# ==================== 全局资源池 (速度优化核心) ====================
+# ==================== 全局资源池 ====================
 
-GLOBAL_HTTP_SESSION: Optional[aiohttp.ClientSession] = None
+CONTEXT_GENERATE_FUNCTION = "write_content_generate"
+
+GLOBAL_REDIS_CLIENT: Optional[AsyncRedis] = None
 
 async def init_global_resources():
     """初始化全局连接池"""
-    global GLOBAL_HTTP_SESSION, GLOBAL_REDIS_CLIENT
-    
-    if GLOBAL_HTTP_SESSION is None or GLOBAL_HTTP_SESSION.closed:
-        # 增加 DNS 缓存和连接复用,针对蜀天算力域名优化
-        connector = aiohttp.TCPConnector(limit=100, limit_per_host=20, ttl_dns_cache=300, force_close=False)
-        GLOBAL_HTTP_SESSION = aiohttp.ClientSession(
-            timeout=aiohttp.ClientTimeout(total=120, connect=10, sock_read=10), # 连接超时稍长以防网络波动
-            connector=connector,
-            headers={"User-Agent": "FastAPI-Shutian-Optimized/2.0"}
-        )
-        logger.info("✅ 全局 HTTP 连接池已初始化 (Shutian Ready)")
+    global GLOBAL_REDIS_CLIENT
 
     if GLOBAL_REDIS_CLIENT is None:
         try:
@@ -304,171 +295,39 @@ async def _background_ping():
         try: await GLOBAL_REDIS_CLIENT.ping()
         except: pass
 
-async def get_http_session():
-    if GLOBAL_HTTP_SESSION is None or GLOBAL_HTTP_SESSION.closed:
-        await init_global_resources()
-    return GLOBAL_HTTP_SESSION
-
 async def get_redis_client():
     if GLOBAL_REDIS_CLIENT is None:
         await init_global_resources()
     return GLOBAL_REDIS_CLIENT
 
-# ==================== 自定义 API 配置 (蜀天算力 Qwen3.5-122B) ====================
-
-class CustomAPIConfig:
-    # model_setting.yaml 中的功能名称
-    FUNCTION_NAME = "write_outline_generate"
-
-    # 兜底默认值(蜀天 Qwen3.5-122B-A10B)
-    SHUTIAN_SERVER_URL_DEFAULT = "http://183.220.37.46:25423/v1"
-    SHUTIAN_API_KEY_DEFAULT = "lq123456"
-    DEFAULT_MODEL_NAME = "/model/Qwen3.5-122B-A10B"
-
-    @staticmethod
-    def _resolve_from_model_handler():
-        """通过 model_handler 统一解析模型配置(url, api_key, model_id)"""
-        try:
-            from foundation.ai.models.model_handler import model_handler
-            llm = model_handler.get_model_by_function(CustomAPIConfig.FUNCTION_NAME)
-            url = getattr(llm, 'base_url', None) or getattr(llm, 'openai_api_base', '')
-            url = str(url) if url else ''
-            model_id = getattr(llm, 'model_name', None) or getattr(llm, 'model', '')
-            model_id = str(model_id) if model_id else ''
-            api_key = getattr(llm, 'openai_api_key', None)
-            if api_key:
-                api_key = api_key.get_secret_value() if hasattr(api_key, 'get_secret_value') else str(api_key)
-            else:
-                api_key = ''
-            if url and api_key:
-                return url, api_key, model_id
-        except Exception:
-            pass
-        return None, None, None
-
-    @staticmethod
-    def get_api_url() -> str:
-        configured_url = config_handler.get("custom_api", "API_URL", "")
-        if configured_url:
-            return configured_url
-        url, _, _ = CustomAPIConfig._resolve_from_model_handler()
-        if url:
-            return url
-        return config_handler.get("shutian", "SHUTIAN_122B_SERVER_URL", CustomAPIConfig.SHUTIAN_SERVER_URL_DEFAULT)
-
-    @staticmethod
-    def get_api_key() -> str:
-        configured_key = config_handler.get("custom_api", "API_KEY", "")
-        if configured_key:
-            return configured_key
-        _, api_key, _ = CustomAPIConfig._resolve_from_model_handler()
-        if api_key:
-            return api_key
-        return config_handler.get("shutian", "SHUTIAN_122B_API_KEY", CustomAPIConfig.SHUTIAN_API_KEY_DEFAULT)
-
-    @staticmethod
-    def get_model_name() -> str:
-        configured_model = config_handler.get("custom_api", "MODEL_NAME", "")
-        if configured_model:
-            return configured_model
-        _, _, model_id = CustomAPIConfig._resolve_from_model_handler()
-        if model_id:
-            return model_id
-        return config_handler.get("shutian", "SHUTIAN_122B_MODEL_ID", CustomAPIConfig.DEFAULT_MODEL_NAME)
-
-    @staticmethod
-    def is_enabled() -> bool:
-        return bool(CustomAPIConfig.get_api_key()) and bool(CustomAPIConfig.get_api_url())
-
-# ==================== 极速流式调用 (核心优化) ====================
+# ==================== 极速流式调用 (通过统一模型框架) ====================
 
 async def call_custom_api_stream(
-    prompt: str, system_prompt: str = "", max_tokens: int = 2000, 
+    prompt: str, system_prompt: str = "", max_tokens: int = 2000,
     temperature: float = 0.7, trace_id: str = ""
 ) -> AsyncGenerator[tuple[str, Optional[float]], None]:
-    
-    api_url = CustomAPIConfig.get_api_url()
-    model_name = CustomAPIConfig.get_model_name()
-    api_key = CustomAPIConfig.get_api_key()
-    
-    logger.debug(f"[{trace_id}] 正在调用蜀天算力: {model_name} @ {api_url}")
+    """流式调用 LLM,通过 generate_model_client 统一管理"""
 
-    # 截断过长的 Prompt (服务端对输入长度有限制,且为了速度)
     max_prompt_len = 10000
     if len(prompt) > max_prompt_len:
         prompt = prompt[-max_prompt_len:]
         logger.debug(f"[{trace_id}] Prompt 已截断至 {max_prompt_len} 字符")
 
-    payload = {
-        "model": model_name,
-        "messages": [
-            {"role": "system", "content": system_prompt},
-            {"role": "user", "content": prompt}
-        ],
-        "max_tokens": max_tokens,
-        "temperature": temperature,
-        "stream": True,
-        "incremental_output": True # 蜀天算力兼容模式支持此参数,优化流式体验
-    }
-    
-    headers = {
-        "Content-Type": "application/json",
-        "Authorization": f"Bearer {api_key}"
-    }
-    
     start_time = time.time()
     first_token_time: Optional[float] = None
-    buffer = ""
-    
-    session = await get_http_session()
-    
+
     try:
-        # 蜀天算力 HTTP 连接,保持 read_bufsize=1 以获取最快首字
-        async with session.post(api_url, json=payload, headers=headers, read_bufsize=1) as response:
-            if response.status != 200:
-                error_text = await response.text()
-                logger.error(f"[{trace_id}] API 错误 {response.status}: {error_text}")
-                raise Exception(f"API 错误 {response.status}: {error_text}")
-            
-            async for chunk in response.content.iter_any():
-                if not chunk: continue
-                try:
-                    text = chunk.decode('utf-8', errors='ignore')
-                    if not text: continue
-                    buffer += text
-                    
-                    while '\n' in buffer:
-                        line, buffer = buffer.split('\n', 1)
-                        line = line.strip()
-                        
-                        if line.startswith('data: '):
-                            data = line[6:]
-                            if data == '[DONE]':
-                                return
-                            
-                            try:
-                                event_data = json.loads(data)
-                                # 处理服务端可能的错误格式
-                                if "error" in event_data:
-                                    err_msg = event_data["error"].get("message", "Unknown Error")
-                                    logger.error(f"[{trace_id}] 流式数据中包含错误: {err_msg}")
-                                    continue
-
-                                choices = event_data.get("choices", [])
-                                if choices:
-                                    delta = choices[0].get("delta", {})
-                                    content = delta.get("content", "")
-                                    
-                                    if content:
-                                        if first_token_time is None:
-                                            first_token_time = time.time() - start_time
-                                        yield (content, first_token_time)
-                            except json.JSONDecodeError:
-                                continue
-                except UnicodeDecodeError:
-                    continue
+        for chunk in generate_model_client.get_model_generate_stream(
+            trace_id=trace_id,
+            system_prompt=system_prompt,
+            user_prompt=prompt,
+            function_name=CONTEXT_GENERATE_FUNCTION
+        ):
+            if first_token_time is None:
+                first_token_time = time.time() - start_time
+            yield (chunk, first_token_time)
     except Exception as e:
-        logger.error(f"[{trace_id}] API 流式请求异常: {e}")
+        logger.error(f"[{trace_id}] 流式请求异常: {e}")
         raise
 
 # ==================== 上下文生成业务逻辑辅助 ====================
@@ -541,42 +400,35 @@ async def generate_content_stream(callback_task_id, source_task_id, user_id, req
         )
 
         yield format_sse_event("generating", json.dumps({
-            "status": "generating", 
-            "message": f"正在调用蜀天 Qwen3.5-122B ({CustomAPIConfig.get_model_name()})...",
+            "status": "generating",
+            "message": "正在调用 LLM 模型 (write_content_generate)...",
             "timestamp": int(time.time())
         }, ensure_ascii=False))
 
-        # 执行生成
-        if CustomAPIConfig.is_enabled():
-            logger.info(f"[{callback_task_id}] 使用蜀天算力 API (模型:{CustomAPIConfig.get_model_name()})")
-            async for content, ftl in call_custom_api_stream(
-                prompt=user_prompt,
-                system_prompt=CONTEXT_GENERATE_SYSTEM_PROMPT,
-                max_tokens=min(request.completion_config.target_length, 4000),
-                temperature=0.7,
-                trace_id=callback_task_id
-            ):
-                if await is_cancelled():
-                    yield format_sse_event("cancelled", json.dumps({"status": "cancelled"}, ensure_ascii=False))
-                    return
-                
-                if content:
-                    full_content_parts.append(content)
-                    chunk_count += 1
-                    
-                    if first_token_latency is None:
-                        first_token_latency = ftl if ftl is not None else (time.time() - stream_start_time)
-                        logger.info(f"[{callback_task_id}] ⚡ 首字延迟: {first_token_latency:.3f}s (Model: {CustomAPIConfig.get_model_name()})")
-                    
-                    yield format_sse_event("chunk", json.dumps({
-                        "chunk": content,
-                        "first_token_latency": round(first_token_latency, 3),
-                        "timestamp": int(time.time())
-                    }, ensure_ascii=False))
-        else:
-            # 备用逻辑 (理论上不会触发,因为 Key 已硬编码)
-            logger.warning(f"[{callback_task_id}] API 配置失效,回退到默认模型 (不应发生)")
-            raise Exception("API 配置未生效,请检查 CustomAPIConfig")
+        async for content, ftl in call_custom_api_stream(
+            prompt=user_prompt,
+            system_prompt=CONTEXT_GENERATE_SYSTEM_PROMPT,
+            max_tokens=min(request.completion_config.target_length, 4000),
+            temperature=0.7,
+            trace_id=callback_task_id
+        ):
+            if await is_cancelled():
+                yield format_sse_event("cancelled", json.dumps({"status": "cancelled"}, ensure_ascii=False))
+                return
+
+            if content:
+                full_content_parts.append(content)
+                chunk_count += 1
+
+                if first_token_latency is None:
+                    first_token_latency = ftl if ftl is not None else (time.time() - stream_start_time)
+                    logger.info(f"[{callback_task_id}] ⚡ 首字延迟: {first_token_latency:.3f}s")
+
+                yield format_sse_event("chunk", json.dumps({
+                    "chunk": content,
+                    "first_token_latency": round(first_token_latency, 3),
+                    "timestamp": int(time.time())
+                }, ensure_ascii=False))
 
         # 完成统计
         total_duration = time.time() - stream_start_time
@@ -592,7 +444,7 @@ async def generate_content_stream(callback_task_id, source_task_id, user_id, req
                 "total_duration": round(total_duration, 3),
                 "char_count": len(full_content),
                 "chunk_count": chunk_count,
-                "model_used": CustomAPIConfig.get_model_name()
+                "model_used": CONTEXT_GENERATE_FUNCTION
             },
             "full_content": full_content,
             "timestamp": int(time.time())
@@ -648,7 +500,7 @@ async def health_check():
     return {
         "status": "healthy",
         "provider": "Shutian",
-        "current_model": CustomAPIConfig.get_model_name(),
+        "current_model": CONTEXT_GENERATE_FUNCTION,
         "api_url_prefix": "https://dashscope.aliyuncs.com/compatible-mode/v1"
     }
 
@@ -662,13 +514,12 @@ async def get_modes():
 
 @context_generate_router.get("/context_generate_api_status", response_model=ContextGenerateResponse)
 async def get_api_status():
-    enabled = CustomAPIConfig.is_enabled()
     return ContextGenerateResponse(
-        code=200, message="success", 
+        code=200, message="success",
         data={
-            "enabled": enabled, 
+            "enabled": True,
             "provider": "Shutian",
-            "model": CustomAPIConfig.get_model_name()
+            "model": CONTEXT_GENERATE_FUNCTION
         }
     )