Przeglądaj źródła

初始化 TenderAgent 源码与环境脚本

yangxiaolong 3 tygodni temu
commit
7bf663b802
100 zmienionych plików z 19217 dodań i 0 usunięć
  1. 31 0
      .gitignore
  2. 327 0
      TenderAgent完整代码/README.md
  3. 0 0
      TenderAgent完整代码/backend/__init__.py
  4. BIN
      TenderAgent完整代码/backend/agents/bid_review/bidder_response.docx
  5. 57 0
      TenderAgent完整代码/backend/agents/bid_review/graph.py
  6. 1192 0
      TenderAgent完整代码/backend/agents/bid_review/nodes.py
  7. 115 0
      TenderAgent完整代码/backend/agents/bid_review/prompts.py
  8. 94 0
      TenderAgent完整代码/backend/agents/bid_review/review_hitl.py
  9. 89 0
      TenderAgent完整代码/backend/agents/bid_review/state.py
  10. 74 0
      TenderAgent完整代码/backend/agents/defense/graph.py
  11. 78 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_02_state.py
  12. 82 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_03_prompts.py
  13. 63 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_04_load_context.py
  14. 109 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_05_check_stage.py
  15. 48 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_06_evaluate.py
  16. 44 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_07_resp_warmup_tech.py
  17. 45 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_08_resp_project_closing.py
  18. 44 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_09_report.py
  19. 84 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_10_save.py
  20. 75 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_11_graph.py
  21. 65 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_12_http.py
  22. 138 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_13_e2e.py
  23. 503 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/itv_fixtures.py
  24. 102 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/seed_defense_questions.py
  25. 85 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/test_bid_review.py
  26. 81 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/test_bid_review2.py
  27. 58 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/test_defense.py
  28. 53 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/test_defense_stream.py
  29. 29 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/test_qualification.py
  30. 62 0
      TenderAgent完整代码/backend/agents/defense/manual_tests/test_qualification2.py
  31. 964 0
      TenderAgent完整代码/backend/agents/defense/nodes.py
  32. 270 0
      TenderAgent完整代码/backend/agents/defense/prompts.py
  33. 87 0
      TenderAgent完整代码/backend/agents/defense/state.py
  34. 137 0
      TenderAgent完整代码/backend/agents/qa/graph.py
  35. 899 0
      TenderAgent完整代码/backend/agents/qa/nodes.py
  36. 135 0
      TenderAgent完整代码/backend/agents/qa/prompts.py
  37. 46 0
      TenderAgent完整代码/backend/agents/qa/state.py
  38. 51 0
      TenderAgent完整代码/backend/agents/qualification/graph.py
  39. 475 0
      TenderAgent完整代码/backend/agents/qualification/nodes.py
  40. 275 0
      TenderAgent完整代码/backend/agents/qualification/prompts.py
  41. 2 0
      TenderAgent完整代码/backend/agents/qualification/qualification.json
  42. 160 0
      TenderAgent完整代码/backend/agents/qualification/state.py
  43. 15 0
      TenderAgent完整代码/backend/api/router.py
  44. 108 0
      TenderAgent完整代码/backend/api/v1/auth.py
  45. 501 0
      TenderAgent完整代码/backend/api/v1/bid_review.py
  46. 351 0
      TenderAgent完整代码/backend/api/v1/defense.py
  47. 252 0
      TenderAgent完整代码/backend/api/v1/qa.py
  48. 221 0
      TenderAgent完整代码/backend/api/v1/qualification.py
  49. 462 0
      TenderAgent完整代码/backend/api/v1/unified_chat.py
  50. 127 0
      TenderAgent完整代码/backend/api/v1/verify_defense_e2e.py
  51. 162 0
      TenderAgent完整代码/backend/api/v1/verify_qa_e2e.py
  52. 123 0
      TenderAgent完整代码/backend/api/v1/verify_qualification_e2e.py
  53. 194 0
      TenderAgent完整代码/backend/api/v1/veriry_bid_review_e2e.py
  54. 86 0
      TenderAgent完整代码/backend/config.py
  55. 2125 0
      TenderAgent完整代码/backend/core/data/training_data.jsonl
  56. 61 0
      TenderAgent完整代码/backend/core/exceptions.py
  57. 401 0
      TenderAgent完整代码/backend/core/knowledge_base.py
  58. 181 0
      TenderAgent完整代码/backend/core/llm_factory.py
  59. 96 0
      TenderAgent完整代码/backend/core/logger.py
  60. 202 0
      TenderAgent完整代码/backend/core/memory.py
  61. 446 0
      TenderAgent完整代码/backend/core/orchestrator.py
  62. 385 0
      TenderAgent完整代码/backend/core/query_classifier.py
  63. 184 0
      TenderAgent完整代码/backend/core/reranker.py
  64. 225 0
      TenderAgent完整代码/backend/core/retry.py
  65. 64 0
      TenderAgent完整代码/backend/db/migrations.py
  66. 90 0
      TenderAgent完整代码/backend/dependencies.py
  67. 104 0
      TenderAgent完整代码/backend/main.py
  68. 12 0
      TenderAgent完整代码/backend/mcp/check_hello.py
  69. 17 0
      TenderAgent完整代码/backend/mcp/check_kg_client.py
  70. 22 0
      TenderAgent完整代码/backend/mcp/check_web_client.py
  71. 118 0
      TenderAgent完整代码/backend/mcp/client.py
  72. 18 0
      TenderAgent完整代码/backend/mcp/hello_mcp.py
  73. 85 0
      TenderAgent完整代码/backend/mcp/knowledge_base_server.py
  74. 109 0
      TenderAgent完整代码/backend/mcp/web_search_server.py
  75. 344 0
      TenderAgent完整代码/backend/models/classifier/all-MiniLM-L6-v2/train_script.py
  76. 31 0
      TenderAgent完整代码/backend/new_main.py
  77. 23 0
      TenderAgent完整代码/check_env.py
  78. 110 0
      TenderAgent完整代码/docker-compose.yml
  79. 13 0
      TenderAgent完整代码/frontend/index.html
  80. 2163 0
      TenderAgent完整代码/frontend/package-lock.json
  81. 30 0
      TenderAgent完整代码/frontend/package.json
  82. 3 0
      TenderAgent完整代码/frontend/src/App.vue
  83. 22 0
      TenderAgent完整代码/frontend/src/api/auth.ts
  84. 108 0
      TenderAgent完整代码/frontend/src/api/bid_review.ts
  85. 47 0
      TenderAgent完整代码/frontend/src/api/client.ts
  86. 172 0
      TenderAgent完整代码/frontend/src/api/defense.ts
  87. 31 0
      TenderAgent完整代码/frontend/src/api/qa.ts
  88. 67 0
      TenderAgent完整代码/frontend/src/api/qualification.ts
  89. 78 0
      TenderAgent完整代码/frontend/src/components/chat/ChatBubble.vue
  90. 53 0
      TenderAgent完整代码/frontend/src/components/chat/MarkdownRenderer.vue
  91. 223 0
      TenderAgent完整代码/frontend/src/components/chat/PipelinePlanCard.vue
  92. 116 0
      TenderAgent完整代码/frontend/src/components/chat/RoutingDecisionCard.vue
  93. 97 0
      TenderAgent完整代码/frontend/src/components/defense/StageProgressBar.vue
  94. 133 0
      TenderAgent完整代码/frontend/src/components/layout/AppLayout.vue
  95. 125 0
      TenderAgent完整代码/frontend/src/components/layout/Sidebar.vue
  96. 75 0
      TenderAgent完整代码/frontend/src/components/qualification/DimensionScoreCard.vue
  97. 100 0
      TenderAgent完整代码/frontend/src/composables/useSSEChat.ts
  98. 28 0
      TenderAgent完整代码/frontend/src/main.ts
  99. 109 0
      TenderAgent完整代码/frontend/src/router/index.ts
  100. 41 0
      TenderAgent完整代码/frontend/src/stores/auth.ts

+ 31 - 0
.gitignore

@@ -0,0 +1,31 @@
+# Local secrets
+.env
+.env.*
+!.env.example
+!.env.*.example
+*.pem
+*.key
+# Dependencies and build output
+.venv/
+venv/
+node_modules/
+__pycache__/
+*.py[cod]
+.pytest_cache/
+dist/
+# Local runtime data and backups
+Back/
+.runtime/
+logs/
+*.log
+*.db
+*.sqlite*
+TenderAgent完整代码/review/my_output/
+# Downloaded models; retain project training source
+TenderAgent完整代码/backend/models/**
+!TenderAgent完整代码/backend/models/**/
+!TenderAgent完整代码/backend/models/**/*.py
+# Editor and OS metadata
+.idea/
+.DS_Store
+Thumbs.db

+ 327 - 0
TenderAgent完整代码/README.md

@@ -0,0 +1,327 @@
+# TenderAgent — 招投标场景的 AI 多智能体辅助平台
+
+> 面向 IT 招投标培训机构的 AI 原生评审辅助系统。  
+> 基于 **LangChain 1.2.10 + LangGraph 1.0.9 + FastAPI** 构建,核心大模型为 **DeepSeek-V3 / DeepSeek-Coder-V2**。
+
+---
+
+## 项目背景
+
+IT 招采机构每天面对四类重复性高、耗费师资的业务:
+
+| 场景 | 痛点 |
+|------|------|
+| 投标人答疑 | 同类问题反复出现,评审专家精力有限 |
+| 投标文件评分 | 量大、主观评分项评分容易不一致 |
+| 资质审查 | 评审专家无暇逐份精读打分 |
+| 技术答辩 | 缺少随时可陪练并给出专业反馈的「评审专家」 |
+
+TenderAgent 将这四类业务分别封装为独立的 AI Agent,每个 Agent 融合了企业私有知识、内置了完整业务流程、并配备了工程化容错机制。
+
+---
+
+## 四大核心 Agent
+
+| Agent | 业务能力 | 核心技术范式 |
+|-------|---------|------------|
+| **智能问答(QA)** | 基于招标知识库实时答疑 | RAG 混合检索 + BGE-M3 + Reranker 精排 + SSE 流式 |
+| **投标文件评分(BidReview)** | 自动评分客观项 / 方案响应项 / 编程题 | 三轨并行评分 + HitL 专家确认环 |
+| **资质审查(Qualification)** | 六维度评分并给出改进建议 | PDF 解析 + 结构化抽取 + 多维度并行评分 |
+| **供应商答辩(Defense)** | 五阶段全程技术答辩陪练 | 状态机对话 + Think Tool 双轨质量评估 |
+
+四个 Agent 之上由 **Orchestrator 编排层**统一做意图路由与多 Agent 串联 Pipeline。
+
+---
+
+## 技术栈
+
+| 层面 | 选型 |
+|------|------|
+| 开发语言 | Python 3.11(严格锁定) |
+| Web 框架 | FastAPI + SSE-Starlette |
+| Agent 框架 | LangChain 1.2.10 + LangGraph 1.0.9 |
+| 主力大模型 | DeepSeek-V3(问答 / 资质材料 / 答辩)|
+| 代码评分模型 | DeepSeek-Coder-V2 |
+| 向量数据库 | Milvus |
+| 关系数据库 | PostgreSQL + SQLAlchemy(全异步)|
+| 缓存 | Redis |
+| 对象存储 | MinIO |
+| 嵌入模型 | BGE-M3(进程内,dense + sparse 双输出)|
+| 精排模型 | BGE-Reranker-large(进程内)|
+| 意图分类 | MiniLM-L6-v2(进程内)|
+| 前端 | Vue3 + TypeScript + Element Plus |
+
+> BGE-M3、BGE-Reranker、MiniLM **三个本地模型均为进程内调用**,无需单独起服务,启动时随后端进程并行预热。
+
+---
+
+## 系统架构
+
+```
+┌─────────────────────────────────────────────────┐
+│              前端层   Vue3 SPA (:3000)            │
+└────────────────────┬────────────────────────────┘
+                     │ HTTP / SSE
+┌────────────────────▼────────────────────────────┐
+│            API 层   FastAPI (:8000)              │
+│          JWT 鉴权 / SSE 流式 / 文件上传            │
+└────────────────────┬────────────────────────────┘
+                     │
+┌────────────────────▼────────────────────────────┐
+│          编排层   Orchestrator                   │
+│    MiniLM 意图识别 → 单 Agent 路由 / Pipeline     │
+└──────┬───────────┬───────────┬───────────┬──────┘
+       │           │           │           │
+  ┌────▼───┐  ┌───▼────┐  ┌───▼────┐  ┌───▼──────┐
+  │  QA    │  │  BidReview  │  │Qualification  │  │Defense │  ← LangGraph StateGraph × 4
+  │ Agent  │  │ Agent  │  │ Agent  │  │  Agent   │
+  └────────┘  └────────┘  └────────┘  └──────────┘
+                       公共层
+       ├── LLM Factory(DeepSeek API 统一封装)
+       ├── BGE-Reranker / MiniLM / MemorySaver
+       └── MCP Server(知识库检索 + Web 搜索)
+                     │
+┌────────────────────▼────────────────────────────┐
+│   数据层   PostgreSQL · Redis · Milvus · MinIO   │
+└─────────────────────────────────────────────────┘
+```
+
+---
+
+## 环境要求
+
+- Python **3.11**(严格锁定,不兼容其他版本)
+- Conda(推荐用于环境隔离)
+- Docker & Docker Compose
+- DeepSeek API Key
+
+---
+
+## 快速开始
+
+### 1. 克隆项目 & 创建环境
+
+```bash
+git clone <repo-url>
+cd TenderAgent
+
+conda create -n edu_agent python=3.11 -y
+conda activate edu_agent
+pip install -r requirments.txt
+```
+
+### 2. 配置环境变量
+
+```bash
+cp .env.bid_reviewple .env.local
+```
+
+打开 `.env.local`,填写必填项:
+
+```ini
+DB_USER=your_db_user
+DB_PASSWORD=your_db_password
+DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
+JWT_SECRET_KEY=any-random-secret-string
+```
+
+### 3. 启动基础设施
+
+```bash
+docker-compose --env-file .env.local up -d postgres redis minio etcd milvus langfuse
+```
+
+### 4. 初始化数据(首次运行)
+
+```bash
+python scripts/init_minio.py        # 创建 MinIO Bucket
+python scripts/init_milvus.py       # 创建 Milvus Collection
+python scripts/seed_data.py         # 写入测试用户与投标文件数据
+```
+
+### 5. 验证环境
+
+```bash
+python scripts/verify_env.py
+```
+
+### 6. 启动后端
+
+```bash
+# 在项目根目录(TenderAgent/)下执行
+uvicorn backend.main:app --host 0.0.0.0 --port 8000
+```
+
+### 7. 启动前端
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+---
+
+## 服务端口
+
+| 服务 | 端口 | 说明 |
+|------|------|------|
+| FastAPI 后端 | **8000** | REST + SSE 接口,`/docs` 查看 Swagger |
+| Vue3 前端 | **3000** | 投标人 / 评审专家 / 管理员界面 |
+| PostgreSQL | **5433** | 宿主机 5432 已占用,隔离到 5433 |
+| Redis | **6380** | 宿主机 6379 已占用 |
+| MinIO API | **9002** | 宿主机 9000 已占用 |
+| MinIO 控制台 | **9003** | 对象存储管理 |
+| Milvus | **19531** | 宿主机 19530 已占用 |
+| Langfuse | **3001** | LLM 调用追踪与可观测性 |
+
+> 所有连接配置统一从 `.env.local` 读取,**禁止硬编码端口号**。
+
+---
+
+## 访问地址 & 测试账号
+
+| 地址 | 说明 |
+|------|------|
+| http://localhost:3000 | 前端应用 |
+| http://localhost:8000/docs | FastAPI 接口文档(Swagger) |
+| http://localhost:3001 | Langfuse 可观测性平台 |
+| http://localhost:9003 | MinIO 控制台 |
+
+| 角色 | 密码 |
+|------|------|
+| bidder | Bidder@123456 |
+| expert | Expert@123456 |
+| admin | Admin@123456 |
+
+---
+
+## 目录结构
+
+```
+TenderAgent/
+├── backend/
+│   ├── main.py                  # FastAPI 入口 & lifespan(模型预热 + DB 迁移)
+│   ├── config.py                # pydantic-settings 配置(从 .env.local 读取)
+│   ├── dependencies.py          # JWT 鉴权依赖注入
+│   ├── api/v1/                  # 路由:qa / bid_review / qualification / defense / auth
+│   ├── agents/                  # 四大 Agent
+│   │   ├── qa/                  # graph.py / nodes.py / state.py / prompts.py
+│   │   ├── bid_review/
+│   │   ├── qualification/
+│   │   └── defense/
+│   ├── core/
+│   │   ├── llm_factory.py       # LLM 统一工厂(DeepSeek via OpenAI 兼容接口)
+│   │   ├── orchestrator.py      # 编排器(意图路由 + Pipeline)
+│   │   ├── knowledge_base.py    # BGE-M3 向量检索
+│   │   ├── reranker.py          # BGE-Reranker 精排
+│   │   ├── memory.py            # MemorySaver 对话记忆管理
+│   │   └── retry.py             # 三层兜底重试
+│   ├── db/migrations.py         # 启动时自动执行的幂等 DDL 补丁
+│   └── mcp/                     # MCP Server(知识库检索 + Web 搜索)
+├── frontend/src/
+│   ├── views/                   # 页面(qa / bid_review / qualification / defense / login)
+│   ├── api/                     # HTTP 客户端封装
+│   └── stores/                  # Pinia 状态管理
+├── models/                      # 本地模型权重(.gitignore,启动时自动下载)
+│   ├── reranker/bge-reranker-large/
+│   ├── classifier/all-MiniLM-L6-v2/
+│   └── embedding/bge-m3/
+├── scripts/
+│   ├── init_milvus.py           # 初始化向量集合
+│   ├── seed_data.py             # 填充测试数据
+│   ├── build_knowledge_base.py  # 导入知识库文档
+│   └── verify_env.py            # 环境自检
+├── tests/                       # 单元测试 + 集成测试(76 passed / 1 skipped)
+├── requirments.txt
+├── docker-compose.yml
+└── .env.bid_reviewple
+```
+
+---
+
+## 知识库导入
+
+首次使用或知识库重建后,需手动导入招标资料文档:
+
+```bash
+# 重建 Milvus Collection(Phase D 后需执行)
+python scripts/init_milvus.py
+
+# 导入文档(支持 PDF / Word / Markdown)
+python scripts/build_knowledge_base.py --file <文档路径> --tender_document_id <招标资料UUID>
+```
+
+---
+
+## 容错机制
+
+系统内置三层兜底,任何情况下用户都能拿到响应:
+
+| 层级 | 触发条件 | 处理方式 |
+|------|---------|---------|
+| 第一层:自动重试 | 网络抖动 / LLM 超时 | 间隔 1s / 3s 重试,最多 2 次 |
+| 第二层:Agent 降级 | 重试后仍失败 | 问答直答 / 评分跳过 Judge0 / 资质材料给格式提示 / 答辩给基础反馈 |
+| 第三层:系统兜底 | 所有降级均失败 | 友好提示 + 已完成结果持久化 + 错误记录 |
+
+---
+
+## 常见问题
+
+**Q: 后端启动报 `extra inputs are not permitted`**
+
+`.env.local` 中保留旧字段没有问题,`config.py` 已设置 `extra = "ignore"` 自动忽略。
+
+**Q: Milvus 连接超时**
+
+确认 etcd 和 milvus-standalone 容器均已 healthy:
+
+```bash
+docker-compose ps | grep -E "milvus|etcd"
+```
+
+**Q: 前端 SSE 无流式效果**
+
+QA 和答辩的 SSE 接口直连 `http://localhost:8000`,不经过 Vite proxy,属于正常设计。
+
+**Q: 供应商答辩如何快速结束?**
+
+点击输入框右侧的**"结束答辩"**按钮,AI 将跳过剩余阶段直接生成报告(约 10-30 秒)。
+
+**Q: 投标文件评分一直显示"AI 评分中"**
+
+评分为后台异步任务,刷新页面可获取最新状态。若超过 15 分钟未完成,记录将自动标记为 `failed`,可重新提交。
+
+---
+
+## 配套课件
+
+完整学习课件位于 `TenderAgent课件/` 目录,共 10 章:
+
+| 条款 | 内容 |
+|------|------|
+| 第一章 | 项目简介与 Agent 核心概念 |
+| 第二章 | 工具介绍(async / Pydantic / LangChain / LangGraph / FastAPI)|
+| 第三章 | 环境搭建与工程地基 |
+| 第四章 | 供应商资质审查 Agent |
+| 第五章 | RAG 智能问答系统 |
+| 第六章 | 投标文件评分 Agent(含 HitL)|
+| 第七章 | 供应商答辩 Agent |
+| 第八章 | 系统集成与编排 |
+| 第九章 | 前端集成扩展 |
+| 第十章 | 收尾、能力迁移与答辩问题集 |
+
+生成 HTML 讲义:
+
+```bash
+# 在 docs/ 目录下执行
+mkdocs serve   # 本地预览(:8080)
+mkdocs build   # 输出静态 HTML 到 site/
+```
+
+---
+
+## License
+
+本项目仅供评审使用,未经授权不得用于商业目的。  
+© 黑马程序员

+ 0 - 0
TenderAgent完整代码/backend/__init__.py


BIN
TenderAgent完整代码/backend/agents/bid_review/bidder_response.docx


+ 57 - 0
TenderAgent完整代码/backend/agents/bid_review/graph.py

@@ -0,0 +1,57 @@
+# backend/agents/bid_review/graph.py
+
+from langgraph.graph import StateGraph, START, END
+
+from backend.agents.bid_review.state import BidReviewState
+from backend.agents.bid_review.nodes import (
+    parse_word_node,
+    load_questions_meta_node,
+    run_three_tracks_node,
+    aggregate_results_node,
+    analyze_weak_points_node,
+    notify_expert_node,
+    expert_review_node,
+    apply_expert_decision_node,
+    publish_results_node,
+)
+from backend.core.memory import get_memory_saver
+
+
+def build_bid_review_graph():
+    """
+    构建并编译投标文件评分 Agent 的 LangGraph 状态图。
+
+    执行链路(线性):
+        parse_word → load_questions_meta → run_three_tracks
+        → aggregate_results → analyze_weak_points
+        → notify_expert → expert_review [interrupt]
+        → apply_expert_decision → publish_results → END
+    """
+    builder = StateGraph(BidReviewState)
+
+    # ── 注册节点 ──────────────────────────────────────────────
+    builder.add_node("parse_word",             parse_word_node)
+    builder.add_node("load_questions_meta",    load_questions_meta_node)
+    builder.add_node("run_three_tracks",       run_three_tracks_node)
+    builder.add_node("aggregate_results",      aggregate_results_node)
+    builder.add_node("analyze_weak_points",    analyze_weak_points_node)
+    builder.add_node("notify_expert",         notify_expert_node)
+    builder.add_node("expert_review",         expert_review_node)
+    builder.add_node("apply_expert_decision", apply_expert_decision_node)
+    builder.add_node("publish_results",        publish_results_node)
+
+    # ── 固定边(线性链)──────────────────────────────────────
+    builder.add_edge(START,                    "parse_word")
+    builder.add_edge("parse_word",             "load_questions_meta")
+    builder.add_edge("load_questions_meta",    "run_three_tracks")
+    builder.add_edge("run_three_tracks",       "aggregate_results")
+    builder.add_edge("aggregate_results",      "analyze_weak_points")
+    builder.add_edge("analyze_weak_points",    "notify_expert")
+    builder.add_edge("notify_expert",         "expert_review")
+    builder.add_edge("expert_review",         "apply_expert_decision")
+    builder.add_edge("apply_expert_decision", "publish_results")
+    builder.add_edge("publish_results",        END)
+
+    # ── 编译,绑定 MemorySaver ────────────────────────────────
+    checkpointer = get_memory_saver("bid_review")
+    return builder.compile(checkpointer=checkpointer)

+ 1192 - 0
TenderAgent完整代码/backend/agents/bid_review/nodes.py

@@ -0,0 +1,1192 @@
+# backend/agents/bid_review/nodes.py
+
+import asyncio
+import json
+import uuid
+from typing import Any
+
+import httpx
+from sqlalchemy import text
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from langgraph.types import interrupt
+
+from backend.agents.bid_review.state import (
+    BidReviewState,
+    SubjectiveReviewResult,
+    WeakPointsReport,
+)
+from backend.agents.bid_review.prompts import (
+    SYSTEM_PROMPT,
+    SUBJECTIVE_REVIEW_PROMPT,
+    SUBJECTIVE_THINK_PROMPT,
+    CODE_REVIEW_PROMPT,
+    WEAK_POINTS_ANALYSIS_PROMPT,
+)
+from backend.core.llm_factory import get_llm, get_structured_llm
+from backend.core.logger import get_logger
+from backend.dependencies import AsyncSessionLocal
+
+logger = get_logger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────
+# 工具函数
+# ──────────────────────────────────────────────────────────────
+
+def _get_message_content(msg) -> str:
+    """统一获取消息文本内容(兼容 text 属性和 content 属性)"""
+    if hasattr(msg, "text") and not callable(getattr(msg, "text", None)):
+        return msg.text
+    if isinstance(msg.content, str):
+        return msg.content
+    return str(msg.content)
+
+
+def _chinese_to_int(s: str) -> int:
+    """中文数字转整数,转换失败时直接 int(),仍失败时返回1"""
+    cn_map = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5,
+              "六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
+    try:
+        return int(s)
+    except ValueError:
+        # print("强转失败")
+        return cn_map.get(s, 1)
+
+
+# ──────────────────────────────────────────────────────────────
+# 节点1:parse_word — 解析投标人作答 Word 文件
+# ──────────────────────────────────────────────────────────────
+
+def _sync_parse_word(word_path: str) -> list:
+    """
+    同步解析 Word 文件(在线程池中运行,避免阻塞事件循环)。
+
+    返回 list[dict],每个 dict 包含:
+        question_no:    题号(int)
+        header_text:    原始评分项行文本
+        bidder_response: 投标人作答文本(技术方案项为代码字符串)
+        is_code:        True 表示技术方案项(仅技术方案项有此字段)
+    """
+    from docx import Document
+    import re
+
+    doc = Document(word_path)
+    parsed_questions = [] # 最终结果:所有评分项组成的列表
+    current_question  = None # 当前正在解析的评分项
+    current_answer_lines = [] # 当前题【答案】
+    in_code_block = False # 是否正处于代码内部
+    code_buffer   = [] # 当前代码块累积的内容
+
+    for para in doc.paragraphs:
+        para_text = para.text.strip()
+        # print(f'para_text: {para_text}')
+        # 空行:代码块内保留,其他忽略
+        if not para_text:
+            if in_code_block:
+                code_buffer.append("")
+            continue
+        # 判断是否是评分项开头行(支持 "第X题" / "Q.X" / "评分项X")
+        is_question_header = re.match(
+            r"^(第?\s*[一二三四五六七八九十\d]+\s*[题、。.]|Q\.?\s*\d+|评分项\s*\d+)",
+            para_text,
+            re.IGNORECASE,
+        )
+        # print(f'is_question_header-->{is_question_header} ')
+
+        if is_question_header:
+            # print(f'1current_question:{current_question}')
+            # 保存上一题(如果有)
+            if current_question is not None:
+                if not current_question.get("is_code"):
+                    answer_text = "\n".join(code_buffer) if in_code_block \
+                        else "\n".join(current_answer_lines)
+                    current_question["bidder_response"] = answer_text.strip()
+                parsed_questions.append(current_question)
+            # print(f'2current_question:{current_question}')
+            # print(f'parsed_questions-->{parsed_questions}')
+            # 提取题号(优先数字,其次中文数字)
+            match = re.search(r"[一二三四五六七八九十\d]+", para_text)
+            # print(f'match-->{match}')
+            # print(f'match.group()-->{match.group()}')
+            q_no = _chinese_to_int(match.group()) if match else len(parsed_questions) + 1
+            # print(f'q_no-->{q_no}')
+
+            current_question     = {"question_no": q_no, "header_text": para_text, "bidder_response": ""}
+            current_answer_lines = []
+            code_buffer          = []
+            in_code_block        = False
+            # print(f'current_question-->{current_question}')
+
+        elif para_text.startswith("```"):
+            # 代码块开关:遇到第二个 ``` 时闭合,保存代码内容
+            # print(f'1in_code_block-->{in_code_block}')
+            in_code_block = not in_code_block
+            # print(f'3current_question-->{current_question}')
+            if not in_code_block and current_question:
+                current_question["bidder_response"] = "\n".join(code_buffer).strip()
+                current_question["is_code"] = True
+
+        elif in_code_block:
+            # 代码块内部:保留原始缩进(不 strip)
+            # print(f'2in_code_block-->{in_code_block}')
+            code_buffer.append(para.text)
+            # print(f"code_buffer-->{code_buffer}")
+
+        elif current_question is not None:
+            # 跳过纯模板提示行(不含实际答案)
+            # print(f'4current_question-->{current_question}')
+            skip_prefixes = ["作答区", "请在此处"]
+            if any(para_text.startswith(p) for p in skip_prefixes):
+                pass
+            else:
+                # 提取 "答:X" 格式的答案内容(只取冒号后部分)
+                answer_prefixes = ["答:", "答:", "Answer:"]
+                extracted = None
+                for prefix in answer_prefixes:
+                    if para_text.startswith(prefix):
+                        rest = para_text[len(prefix):].strip()
+                        if rest:
+                            extracted = rest
+                        break   # 无论是否有内容都不再把整行加入
+                # print(f'extracted-->{extracted}')
+                if extracted is not None:
+                    current_answer_lines.append(extracted)
+                elif not any(para_text.startswith(p) for p in answer_prefixes):
+                    current_answer_lines.append(para_text)
+        #         print(f'current_answer_lines-->{current_answer_lines}')
+        # print("*"*60)
+    # print(f'parsed_questions: {parsed_questions}')
+    # print("*"*80)
+    # print(f'last_current_question: {current_question}')
+    # 保存最后一题
+    if current_question is not None:
+        if not current_question.get("is_code"):
+            answer_text = "\n".join(code_buffer) if in_code_block \
+                else "\n".join(current_answer_lines)
+            current_question["bidder_response"] = answer_text.strip()
+        parsed_questions.append(current_question)
+
+    return parsed_questions
+
+async def parse_word_node(state: BidReviewState) -> dict:
+    """
+    解析投标人提交的 Word 投标文件文件,提取各题作答内容。
+
+    python-docx 内部有文件 I/O(打开 .docx zip)和 XML 解析(ElementTree),
+    两者都是同步阻塞操作,不能直接在 async 函数里调用。
+    用 run_in_executor(None, ...) 放入默认线程池,asyncio 事件循环继续处理
+    其他协程,线程完成后 await 恢复。
+    """
+    word_path = state["word_file_path"]
+
+    try:
+        loop = asyncio.get_running_loop()
+        parsed_questions = await loop.run_in_executor(None, _sync_parse_word, word_path)
+
+        logger.info(
+            "parse_word.done",
+            file=word_path,
+            questions_found=len(parsed_questions),
+        )
+
+        return {"parsed_questions": parsed_questions}
+
+    except Exception as e:
+        logger.error("parse_word.failed", error=str(e), file=word_path)
+        # 优雅降级:文件损坏或格式不符时,返回空列表。
+        # 后续 load_questions_meta_node 从 DB 补全评分项信息,
+        # bidder_response 全部为空字符串,评审专家人工补批。
+        return {"parsed_questions": []}
+
+
+async def load_questions_meta_node(state: BidReviewState) -> dict:
+    """
+    从数据库加载投标文件的完整评分项元数据(含评分依据、评分点、评审要点标签),
+    与解析出的投标响应合并,覆盖写入 parsed_questions。
+    """
+    bid_review_id = state["bid_review_id"]
+    parsed  = state["parsed_questions"]   # parse_word_node 的输出
+
+    async with AsyncSessionLocal() as session:
+        # ── ① 加载评分项列表 ──────────────────────────────────────
+        result = await session.execute(
+            text("""
+                SELECT id, question_no, question_type, content,
+                       correct_answer, score, knowledge_tag
+                FROM questions
+                WHERE bid_review_id = :bid_review_id
+                ORDER BY question_no
+            """),
+            {"bid_review_id": bid_review_id},
+        )
+        questions = result.mappings().all()
+        # print(f'questions: {len(questions)}')
+        # print(f'*'*60)
+        # print(f'questions: {questions}')
+        # print(f'*' * 60)
+        # ── ② 加载评分点(仅方案响应项有)──────────────────────────
+        question_ids = [str(q["id"]) for q in questions]
+        # print(f'question_ids: {question_ids}')
+        # print(f'*' * 60)
+        scoring_points_rows = []
+        if question_ids:
+            # 动态构造 IN 子句(避免 asyncpg 的 ANY(:qids::uuid[]) 类型不兼容问题)
+            param_names = [f":qid_{i}" for i in range(len(question_ids))]
+            # print(f'param_names: {param_names}')
+            # print(f'*' * 60)
+            qid_params = {f"qid_{i}": qid for i, qid in enumerate(question_ids)}
+            # print(f'qid_params: {qid_params}')
+            # print(f'*' * 60)
+            sp_result = await session.execute(
+                text(f"""
+                            SELECT id, question_id, point_desc, point_score
+                            FROM scoring_points
+                            WHERE question_id IN ({", ".join(param_names)})
+                              AND is_active = TRUE
+                            ORDER BY question_id, id
+                        """),
+                qid_params,
+            )
+            scoring_points_rows = sp_result.mappings().all()
+            # print(f'scoring_points_rows: {scoring_points_rows}')
+
+        # ── ③ 按 question_id 聚合评分点 ─────────────────────────────
+    sp_by_question: dict[str, list] = {}
+    for sp in scoring_points_rows:
+        qid = str(sp["question_id"])
+        sp_by_question.setdefault(qid, []).append({
+            "id": str(sp["id"]),
+            "desc": sp["point_desc"],
+            "score": sp["point_score"],
+        })
+
+    # print(f'sp_by_question: {sp_by_question}')
+    # print("*"*80)
+    # parsed_by_no = {p["question_no"]: p for p in parsed}
+    # print(f'parsed_by_no: {parsed_by_no}')
+
+    # ── ④ 以 DB 评分项为主,合并解析结果 ─────────────────────────
+    parsed_by_no = {p["question_no"]: p for p in parsed}
+    merged_questions = []
+
+    for q in questions:
+        q_no = q["question_no"]
+        merged_questions.append({
+            "question_id": str(q["id"]),
+            "question_no": q_no,
+            "question_type": q["question_type"],
+            "content": q["content"],
+            "bidder_response": parsed_by_no.get(q_no, {}).get("bidder_response", ""),
+            "correct_answer": q["correct_answer"] or "",
+            "scoring_points": sp_by_question.get(str(q["id"]), []),
+            "full_score": q["score"],
+            "knowledge_tag": q["knowledge_tag"] or "",
+        })
+
+    logger.info(
+        "load_questions_meta.done",
+        bid_review_id=bid_review_id,
+        total_questions=len(merged_questions),
+    )
+
+    return {"parsed_questions": merged_questions}
+
+# ── 客观评分项答案标准化 ──────────────────────────────────────────
+
+def _normalize_answer(answer: str) -> str:
+    """
+    标准化答案字符串,消除大小写/空格/标点差异,使多选题选项顺序无关。
+
+    处理步骤:
+        1. 统一大写(A/a → A)
+        2. 去除所有空格、中文逗号、英文逗号
+           "A, B, C" → "ABC","A,B,C" → "ABC"
+        3. 字符排序(多选题 "BA" 和 "AB" 视为等价)
+           sorted("ABC") → ['A','B','C'] → "ABC"
+    """
+    cleaned = answer.upper().replace(" ", "").replace(",", "").replace(",", "")
+    return "".join(sorted(cleaned))
+
+# ── 第一轨:规则引擎(客观评分项)────────────────────────────────
+
+async def _run_objective_track(questions: list[dict]) -> list[dict]:
+    """
+    客观评分项规则评分。虽然声明为 async,内部没有 await,
+    但保持 async 统一接口方便在 asyncio.gather 中与其他两轨并行。
+    """
+    results = []
+    for q in questions:
+        bidder_ans = _normalize_answer(q["bidder_response"])
+        correct_ans = _normalize_answer(q["correct_answer"])
+        is_correct  = (bidder_ans == correct_ans)
+
+        results.append({
+            "question_id":    q["question_id"],
+            "question_no":    q["question_no"],
+            "question_type":  q["question_type"],
+            "knowledge_tag":  q.get("knowledge_tag", ""),
+            "content":        q.get("content", ""),
+            "bidder_response": q["bidder_response"],
+            "correct_answer": q["correct_answer"],
+            "is_correct":     is_correct,
+            "score":          q["full_score"] if is_correct else 0,
+            "full_score":     q["full_score"],
+            "needs_review":   False,          # 客观评分项不需要专家复核
+            "ai_feedback":    "正确" if is_correct else f"正确答案:{q['correct_answer']}",
+        })
+
+    return results
+
+# ── 第二轨:LLM 语义评分(方案响应项)────────────────────────────
+
+async def _review_one_subjective(q: dict) -> dict:
+    """评分单道方案响应项,两步流程:先 Think Tool 推理,再结构化评分。"""
+    # 构造评分点描述文本
+    scoring_points_text = "\n".join([
+        f"  {i + 1}. [{sp['score']}分] {sp['desc']}"
+        for i, sp in enumerate(q["scoring_points"])
+    ]) or "  (无预设评分点,请综合评分)"
+    # print("*"*60)
+    # print(f'scoring_points_text: {scoring_points_text}')
+    bidder_response_text = q["bidder_response"] or "(投标人未作答)"
+
+    # ── 第一步:Think Tool 推理分析 ───────────────────────────
+    reasoning_trace = ""
+    try:
+        think_prompt = SUBJECTIVE_THINK_PROMPT.format(
+            question_content=q["content"],
+            scoring_points=scoring_points_text,
+            bidder_response=bidder_response_text,
+        )
+        think_llm = get_llm("bid_review_subjective", temperature=0)
+        think_resp = await think_llm.ainvoke([HumanMessage(content=think_prompt)])
+        reasoning_trace = _get_message_content(think_resp).strip()
+        logger.debug("subjective_think.done", question_no=q.get("question_no"))
+    except Exception as e:
+        # 推理失败不影响主评分,降级为直接评分
+        logger.warning("subjective_think.failed", error=str(e))
+
+    # ── 第二步:结构化评分(附带推理结论)────────────────────
+    think_context = (
+        f"\n\n【评分前分析】\n{reasoning_trace}" if reasoning_trace else ""
+    )
+    review_prompt = SUBJECTIVE_REVIEW_PROMPT.format(
+        question_content=q["content"],
+        scoring_points=scoring_points_text,
+        full_score=q["full_score"],
+        bidder_response=bidder_response_text,
+    ) + think_context
+
+    structured_llm = get_structured_llm("bid_review_subjective", SubjectiveReviewResult)
+    result: SubjectiveReviewResult = await structured_llm.ainvoke([
+        SystemMessage(content=SYSTEM_PROMPT),
+        HumanMessage(content=review_prompt),
+    ])
+    # print(f'result: {result}')
+
+    return {
+        "question_id": q["question_id"],
+        "question_no": q["question_no"],
+        "question_type": "short_answer",
+        "knowledge_tag": q.get("knowledge_tag", ""),
+        "content": q.get("content", ""),
+        "bidder_response": q["bidder_response"],
+        "score": result.total_score,
+        "full_score": result.full_score,
+        "needs_review": result.confidence < 0.7,  # 低把握度标记专家复核
+        "confidence": result.confidence,
+        "ai_feedback": result.overall_comment,
+        "point_results": [p.model_dump() for p in result.point_results],
+    }
+async def _run_subjective_track(questions: list[dict]) -> list[dict]:
+    """
+    方案响应项评分,每 3 题一组并行处理。
+
+    并行策略:
+        把 N 道方案响应项切成 ⌈N/3⌉ 个组,组内 asyncio.gather 并行,
+        组间顺序执行。目的是避免同时发起几十个 LLM 请求导致 API 限速。
+    """
+    if not questions:
+        return []
+
+    GROUP_SIZE = 3
+    groups = [questions[i:i + GROUP_SIZE] for i in range(0, len(questions), GROUP_SIZE)]
+
+    all_results = []
+    for group in groups:
+        group_results = await asyncio.gather(
+            *[_review_one_subjective(q) for q in group],
+            return_exceptions=True,
+        )
+        for q, result in zip(group, group_results):
+            if isinstance(result, Exception):
+                # 单题失败降级:标记 needs_review=True,不阻断整批
+                logger.warning(
+                    "subjective_track.question_failed",
+                    question_id=q["question_id"],
+                    error=str(result),
+                )
+                all_results.append({
+                    "question_id":    q["question_id"],
+                    "question_no":    q["question_no"],
+                    "question_type":  "short_answer",
+                    "knowledge_tag":  q.get("knowledge_tag", ""),
+                    "content":        q.get("content", ""),
+                    "bidder_response": q["bidder_response"],
+                    "score":          0,
+                    "full_score":     q["full_score"],
+                    "needs_review":   True,
+                    "confidence":     0.0,
+                    "ai_feedback":    "AI 评分失败,已标记需评审专家人工评分",
+                    "point_results":  [],
+                })
+            else:
+                all_results.append(result)
+
+    return all_results
+
+
+# ── 第三轨:LLM(技术方案项)────────────────────────────
+# backend/agents/bid_review/nodes.py(接 6.6)
+
+async def _run_code_track(questions: list[dict]) -> list[dict]:
+    """
+    技术方案项评分入口:顺序逐题评分(技术方案项一般数量少,不必并行)。
+
+    参数 questions:本卷里所有「技术方案项」的合并评分项字典列表(来自 6.4 的合并结果,
+                    已按题型筛选,这里只会收到 question_type == "code" 的题)。
+    返回:每道技术方案项的评分结果字典组成的列表。
+    """
+    if not questions:                       # 没有技术方案项(空列表)
+        return []                           # 直接返回空,省去后续循环
+    results = []                            # 收集每道题的评分结果
+    for q in questions:                     # 逐道技术方案项处理
+        results.append(await _review_one_code(q))   # 调用单题评分,await 等它出结果
+    return results                          # 返回全部技术方案项的评分结果
+
+
+async def _review_one_code(q: dict) -> dict:
+    """
+    评分「单道技术方案项」:交给大模型综合评估功能正确性 + 代码质量。
+
+    参数 q:一道技术方案项的「合并评分项字典」(就是 6.4 load_questions_meta_node 产出的那种),
+            本函数会读取它的下列键:
+        q["question_id"]     评分项 ID(str),原样写进评分结果,方便回填数据库
+        q["question_no"]     题号(int)
+        q["content"]         评分项内容(str),喂给 LLM 当评分依据
+        q["bidder_response"]  投标人提交的代码(str)
+        q["correct_answer"]  评分依据(str):这道题的「满分参考实现」,给 LLM 当对照标杆
+        q["full_score"]      该题满分(int)
+        q["knowledge_tag"]   评审要点标签(str,可能缺省,用 .get 兜底)
+    返回:一道技术方案项的评分结果字典(含 score / confidence / needs_review / quality_feedback 等)。
+    """
+    bidder_code       = q["bidder_response"]                # 取出投标人代码
+    # print(f'bidder_code: {bidder_code}')
+    reference_solution = q.get("correct_answer", "") or ""  # 取出参考实现(缺省给空串)
+    # print(f'reference_solution: {reference_solution}')
+
+    # 交给大模型评分:返回(逐条评语, 得分, 把握度)三元组
+    feedback, score, confidence = await _llm_code_review(
+        question_content=q["content"],          # 评分项内容
+        bidder_code=bidder_code,              # 投标人代码
+        full_score=q["full_score"],             # 该题满分(评分上限)
+        reference_solution=reference_solution,  # 参考实现(对照标杆)
+    )
+    # ── 组装评分结果字典返回 ───────────────────────────────────
+    return {
+        "question_id":      q["question_id"],           # 评分项 ID
+        "question_no":      q["question_no"],           # 题号
+        "question_type":    "code",                     # 题型固定 code
+        "knowledge_tag":    q.get("knowledge_tag", ""), # 评审要点(缺省空串)
+        "content":          q.get("content", ""),       # 评分项内容(缺省空串)
+        "bidder_response":   bidder_code,               # 投标人代码
+        "score":            score,                      # 本题最终得分(0~full_score)
+        "full_score":       q["full_score"],            # 本题满分
+        "confidence":       confidence,                 # LLM 评分把握度(0~1)
+        "needs_review":     confidence < 0.7,           # 把握度低于 0.7 → 标记专家复核
+        "quality_feedback": feedback,                   # LLM 逐条评语(list[str],下游会用到)
+        "ai_feedback":      "\n".join(feedback),        # 评语拼成单个字符串,方便展示
+    }
+
+
+async def _llm_code_review(
+    question_content:   str,        # 评分项内容
+    bidder_code:       str,        # 投标人代码
+    full_score:         int,        # 该题满分(评分上限)
+    reference_solution: str = "",   # 参考实现(默认空串)
+) -> tuple[list[str], int, float]:
+    """
+    大模型综合评估代码:功能正确性 + 代码质量。
+
+    返回一个三元组 (feedback, score, confidence):
+        feedback:  list[str],逐条评语(功能正确性 + 四个质量维度)
+        score:     int,得分,范围 0 ~ full_score
+        confidence:float,LLM 自报的评分把握度,范围 0 ~ 1(越不确定越低)
+    """
+    # 用模板拼出完整 Prompt(把评分项 / 参考实现 / 投标人代码 / 满分填进去)
+    prompt = CODE_REVIEW_PROMPT.format(
+        question=question_content,                                  # {question}
+        reference_solution=reference_solution or "(无参考实现,仅按代码本身评估)",  # {reference_solution},没有就填兜底语
+        code=bidder_code or "(未提交代码)",                       # {code},没提交就填兜底语
+        full_score=full_score,                                      # {full_score}:满分制
+    )
+
+    llm      = get_llm("bid_review_code")             # 从 llm_factory 取「代码评估」用的模型实例
+    response = await llm.ainvoke([              # 异步调用大模型
+        SystemMessage(content=SYSTEM_PROMPT),   # 系统提示(统一人设/规则)
+        HumanMessage(content=prompt),           # 把上面拼好的 Prompt 作为用户消息
+    ])
+    # print(f'response: {response}')
+    # 取出回复纯文本,并去掉模型可能多带的 ```json 代码围栏,方便 json.loads
+    raw = _get_message_content(response).strip().replace("```json", "").replace("```", "").strip()
+    # print(f'raw: {raw}')
+    # print(json.loads(raw))
+    try:                                        # 解析可能失败,包 try
+        data       = json.loads(raw)            # 把回复解析成字典
+        feedback   = data.get("feedback", [])   # 取逐条评语列表(缺省空列表)
+        score      = min(int(data.get("score", 0)), full_score)  # 取分数,封顶到满分
+        confidence = float(data.get("confidence", 0))            # 取把握度(缺省 0)
+    except Exception:                           # 模型没返回合法 JSON
+        # JSON 解析失败时的降级:不崩,给 0 分 + 把握度 0(→ 必复核)+ 提示人工
+        feedback   = ["代码评估结果解析失败,请评审专家人工复核"]
+        score      = 0
+        confidence = 0.0
+
+    return feedback, score, confidence          # 返回(评语列表, 得分, 把握度)
+
+
+# backend/agents/bid_review/nodes.py(接 6.7)
+
+# ──────────────────────────────────────────────────────────────
+# 节点3:run_three_tracks — 三轨并行评分
+# ──────────────────────────────────────────────────────────────
+
+async def run_three_tracks_node(state: BidReviewState) -> dict:
+    """
+    三轨并行评分:
+        第一轨:规则引擎(客观评分项:单选/多选/判断)
+        第二轨:LLM 语义评分(方案响应项,按3题一组并行)
+        第三轨:LLM 代码质量评估(技术方案项,顺序执行)
+
+    asyncio.gather(return_exceptions=True):
+        某一轨抛异常不会中断其他轨,异常作为返回值处理。
+        失败的轨结果置为空列表,其余轨正常写入 State。
+    """
+    questions = state["parsed_questions"]
+
+    objective_qs  = [q for q in questions if q["question_type"] in
+                     ("single_choice", "multi_choice", "judge")]
+    subjective_qs = [q for q in questions if q["question_type"] == "short_answer"]
+    code_qs       = [q for q in questions if q["question_type"] == "code"]
+
+    logger.info(
+        "three_tracks.start",
+        objective=len(objective_qs),
+        subjective=len(subjective_qs),
+        code=len(code_qs),
+    )
+
+    # 三轨并行启动,任何一轨失败不影响其他轨
+    raw = await asyncio.gather(
+        _run_objective_track(objective_qs),
+        _run_subjective_track(subjective_qs),
+        _run_code_track(code_qs),
+        return_exceptions=True,
+    )
+
+    objective_results  = raw[0] if not isinstance(raw[0], Exception) else []
+    subjective_results = raw[1] if not isinstance(raw[1], Exception) else []
+    code_results       = raw[2] if not isinstance(raw[2], Exception) else []
+
+    # 记录失败信息(不影响正常流程)
+    for name, exc in zip(["objective", "subjective", "code"], raw):
+        if isinstance(exc, Exception):
+            logger.error(f"three_tracks.{name}_failed", error=str(exc))
+
+    logger.info(
+        "three_tracks.done",
+        objective_done=len(objective_results),
+        subjective_done=len(subjective_results),
+        code_done=len(code_results),
+    )
+
+    return {
+        "objective_results":  objective_results,
+        "subjective_results": subjective_results,
+        "code_results":       code_results,
+    }
+# ──────────────────────────────────────────────────────────────
+# 节点4:aggregate_results — 汇总预评分结果
+# ──────────────────────────────────────────────────────────────
+
+# ──────────────────────────────────────────────────────────────
+# 节点4:aggregate_results — 汇总预评分结果
+# ──────────────────────────────────────────────────────────────
+
+async def aggregate_results_node(state: BidReviewState) -> dict:
+    """
+    合并三轨结果,按题号排序,计算总分和需复核题数。
+
+    pre_review_summary 是后续 HitL 展示给评审专家的核心数据结构。
+    """
+    all_results = (
+        state.get("objective_results", [])
+        + state.get("subjective_results", [])
+        + state.get("code_results", [])
+    )
+    all_results.sort(key=lambda x: x.get("question_no", 0))
+
+    total_score        = sum(r.get("score", 0) for r in all_results)
+    full_score         = sum(r.get("full_score", 0) for r in all_results)
+    score_rate         = round(total_score / full_score, 4) if full_score > 0 else 0.0
+    needs_review_count = sum(1 for r in all_results if r.get("needs_review", False))
+
+    summary = {
+        "total_score":        total_score,
+        "full_score":         full_score,
+        "score_rate":         score_rate,
+        "needs_review_count": needs_review_count,
+        "by_question":        all_results,
+    }
+
+    logger.info(
+        "aggregate_results.done",
+        total_score=total_score, full_score=full_score,
+        score_rate=score_rate, needs_review=needs_review_count,
+    )
+
+    return {"pre_review_summary": summary}
+
+# ──────────────────────────────────────────────────────────────
+# 节点5:analyze_weak_points — 知识薄弱点分析
+# ──────────────────────────────────────────────────────────────
+
+async def analyze_weak_points_node(state: BidReviewState) -> dict:
+    """
+    分析投标人知识薄弱点。
+
+    路径1:有 knowledge_tag 的失分题 → 按标签直接聚合
+    路径2:无 knowledge_tag 的失分题 → 全量失分题交 LLM 推断评审要点 + 生成 suggestion
+    两路合并,去重,按 wrong_count 降序排列。
+    """
+    all_results = state.get("pre_review_summary", {}).get("by_question", [])
+    # print(f'all_results={all_results}')
+    # print("*"*80)
+    # 收集失分题(得分 < 满分)
+    wrong_questions = [
+        r for r in all_results
+        if r.get("score", 0) < r.get("full_score", 1)
+    ]
+    # print(f"len(wrong_questions):{len(wrong_questions)}")
+    # print(f"wrong_questions[0]:{wrong_questions[0]}")
+    if not wrong_questions:
+        logger.info("analyze_weak_points.no_wrong_questions", submission_id=state["submission_id"])
+        return {
+            "weak_points": [],
+            "weak_points_summary": "本次投标文件全部答对,表现优秀!",
+        }
+    # ── 路径1:有标签 → 直接聚合 ─────────────────────────────
+    tagged   = [r for r in wrong_questions if r.get("knowledge_tag")]
+    # print(f"len(tagged):{len(tagged)}")
+    untagged = [r for r in wrong_questions if not r.get("knowledge_tag")]
+    # print(f'len(untagged):{len(untagged)}')
+
+    tagged_weak: dict[str, dict] = {}
+    for r in tagged:
+        tag = r["knowledge_tag"]
+        if tag not in tagged_weak:
+            tagged_weak[tag] = {
+                "tag":          tag,
+                "wrong_count":  0,
+                "total_count":  0,
+                "question_nos": [],
+                "suggestion":   "",
+            }
+        tagged_weak[tag]["wrong_count"]  += 1
+        tagged_weak[tag]["total_count"]  += 1
+        tagged_weak[tag]["question_nos"].append(r["question_no"])
+
+    # print(f'tagged_weak:{tagged_weak}')
+    # print("*"*60)
+    # ── 路径2:全量失分题交 LLM(同时生成 suggestion 和 summary)
+    llm_weak_points: list[dict] = []
+    llm_summary = ""
+
+    questions_for_llm = tagged + untagged
+    if questions_for_llm:
+        wrong_desc = "\n".join([
+            f"第{r['question_no']}题({r['question_type']},{r['score']}/{r['full_score']}分):"
+            f"\n  评分项:{r.get('content', r.get('ai_feedback', ''))[:200]}"
+            f"\n  AI反馈:{r.get('ai_feedback', '')[:150]}"
+            for r in questions_for_llm
+        ])
+
+        prompt = WEAK_POINTS_ANALYSIS_PROMPT.format(wrong_questions=wrong_desc)
+        structured_llm = get_structured_llm("bid_review_subjective", WeakPointsReport)
+
+        try:
+            report: WeakPointsReport = await structured_llm.ainvoke([
+                SystemMessage(content=SYSTEM_PROMPT),
+                HumanMessage(content=prompt),
+            ])
+            # print(f'report:{report}')
+            llm_weak_points = [wp.model_dump() for wp in report.weak_points]
+            llm_summary = report.overall_summary
+        except Exception as e:
+            logger.warning("analyze_weak_points.llm_failed", error=str(e))
+            llm_summary = "薄弱点分析失败,请评审专家根据扣分项情况人工判断。"
+
+    # print(f"*"*80)
+    # print(f'llm_weak_points:{llm_weak_points}')
+    # print(f'llm_summary:{llm_summary}')
+
+    # ── 合并两路结果,去重 ────────────────────────────────────
+    # LLM 输出中可能包含已打标签的评审要点,优先用规则聚合的结果,
+    # LLM 的 suggestion 补充到规则聚合结果里。
+    merged_tags = set(tagged_weak.keys())
+    final_weak_points = []
+
+    for llm_wp in llm_weak_points:
+        tag = llm_wp.get("tag", "")
+        if tag in tagged_weak:
+            # 规则聚合的评审要点,用 LLM 补充 suggestion
+            tagged_weak[tag]["suggestion"] = llm_wp.get("suggestion", "")
+        elif tag not in merged_tags:
+            # 纯 LLM 推断的评审要点(无标签评分项)
+            final_weak_points.append(llm_wp)
+            merged_tags.add(tag)
+    # print(f'*'*80)
+    # print(f'final_weak_points-->{final_weak_points}')
+    # print(f'merged_tags-->{merged_tags}')
+
+    # 规则聚合结果(补充了 suggestion)加入最终列表
+    for wp in tagged_weak.values():
+        if not wp["suggestion"]:
+            wp["suggestion"] = f"建议重点复习 {wp['tag']} 相关评审要点。"
+        final_weak_points.append(wp)
+    # print(f'final_weak_points-->{final_weak_points}')
+
+    # 按 wrong_count 降序排列(最薄弱的评审要点排在前面)
+    final_weak_points.sort(key=lambda x: x.get("wrong_count", 0), reverse=True)
+
+    logger.info(
+        "analyze_weak_points.done",
+        submission_id=state["submission_id"],
+        weak_count=len(final_weak_points),
+    )
+
+    return {
+        "weak_points":         final_weak_points,
+        "weak_points_summary": llm_summary or "已完成薄弱点分析,请查看详情。",
+    }
+# backend/agents/bid_review/nodes.py(接 6.8)
+
+# ──────────────────────────────────────────────────────────────
+# 节点6:notify_expert — 通知评审专家,更新提交状态
+# ──────────────────────────────────────────────────────────────
+
+async def notify_expert_node(state: BidReviewState) -> dict:
+    """
+    将提交状态推进到 pending_review,等待专家确认。
+
+    职责:
+        1. 更新 bid_review_submissions.status = 'pending_review'
+        2. 记录日志
+    """
+    async with AsyncSessionLocal() as session:
+        async with session.begin():
+            await session.execute(
+                text("""
+                    UPDATE bid_review_submissions
+                    SET status = 'pending_review', updated_at = NOW()
+                    WHERE id = :submission_id
+                """),
+                {"submission_id": state["submission_id"]},
+            )
+
+    logger.info("notify_expert.done", submission_id=state["submission_id"])
+    return {"expert_notified": True}
+
+# ──────────────────────────────────────────────────────────────
+# 节点7:expert_review — Human-in-the-Loop 暂停点
+# ──────────────────────────────────────────────────────────────
+
+async def expert_review_node(state: BidReviewState) -> dict:
+    """
+    Human-in-the-Loop 核心节点。
+
+    interrupt(display_data) 做两件事:
+        1. 把 display_data 暴露给外部(评审专家可通过 GET 接口读取)
+        2. 冻结图执行,State 自动保存到 MemorySaver
+
+    图在此暂停,直到评审专家通过 POST /confirm 传入 Command(resume=decision) 恢复。
+    恢复后,interrupt() 的返回值就是 decision(expert_decision 字段)。
+    """
+    display_data = {
+        "submission_id":       state["submission_id"],
+        "bidder_id":          state["bidder_id"],
+        "pre_review_summary":  state.get("pre_review_summary", {}),
+        "weak_points":         state.get("weak_points", []),
+        "weak_points_summary": state.get("weak_points_summary", ""),
+        "message":             "请检查 AI 预评分结果和知识薄弱点分析,确认无误后点击发布。",
+    }
+
+    # interrupt() 在此暂停图执行
+    expert_decision = interrupt(display_data)
+
+    logger.info(
+        "expert_review.qualificationd",
+        submission_id=state["submission_id"],
+        action=expert_decision.get("action", "unknown"),
+    )
+
+    return {"expert_decision": expert_decision}
+
+
+# backend/agents/bid_review/nodes.py(接 6.9)
+
+# ──────────────────────────────────────────────────────────────
+# 节点8:apply_expert_decision — 合并专家修改
+# ──────────────────────────────────────────────────────────────
+
+# backend/agents/bid_review/nodes.py(接 6.9)
+
+# ──────────────────────────────────────────────────────────────
+# 节点8:apply_expert_decision — 合并专家修改
+# ──────────────────────────────────────────────────────────────
+
+async def apply_expert_decision_node(state: BidReviewState) -> dict:
+    """
+    将评审专家决策合并到评分结果中。
+
+    approve:直接把 AI 分数作为最终分数
+    modify: 按 modifications 列表覆盖指定评分项的得分和评语
+    """
+    decision      = state.get("expert_decision", {})
+    action        = decision.get("action", "approve")
+    modifications = decision.get("modifications", [])
+
+    # 取 pre_review_summary 中的完整评分项列表作为基础
+    all_results = list(state.get("pre_review_summary", {}).get("by_question", []))
+
+    # 每道题先用 AI 分数初始化 final_score
+    for r in all_results:
+        r["final_score"] = r.get("score", 0)
+
+    # modify 时,按 question_id 找到对应评分项,覆盖分数和评语
+    if action == "modify" and modifications:
+        # print(f'modify: {modifications}')
+        # print("*"*80)
+        id_to_idx = {r["question_id"]: i for i, r in enumerate(all_results)}
+        # print(f'id_to_idx: {id_to_idx}')
+        # print("*"*80)
+        for mod in modifications:
+            qid = mod.get("question_id")
+            if qid in id_to_idx:
+                idx = id_to_idx[qid]
+                if "new_score" in mod:
+                    all_results[idx]["expert_score"] = mod["new_score"]
+                    all_results[idx]["final_score"]   = mod["new_score"]   # 评审专家分覆盖 AI 分
+                if "comment" in mod:
+                    all_results[idx]["expert_comment"] = mod["comment"]
+                all_results[idx]["reviewed_by"] = decision.get("expert_id", "")
+
+    logger.info(
+        "apply_expert_decision.done",
+        action=action,
+        modifications_count=len(modifications),
+    )
+
+    return {"final_results": all_results}
+
+# ──────────────────────────────────────────────────────────────
+# 节点9:publish_results — 发布评分结果
+# ──────────────────────────────────────────────────────────────
+
+async def publish_results_node(state: BidReviewState) -> dict:
+    """
+    将最终评分结果写入数据库,发布给投标人。
+
+    写入逻辑:
+        bid_review_reviews  ── 先删后插(幂等),每道题一行
+        bid_review_submissions ── 更新 status='published' + weak_points JSON
+    """
+    final_results = state.get("final_results", [])
+    submission_id = state["submission_id"]
+    expert_id    = state.get("expert_decision", {}).get("expert_id", "") or None
+    weak_points   = state.get("weak_points", [])
+
+    async with AsyncSessionLocal() as session:
+        async with session.begin():
+
+            # ── 逐题写入 bid_review_reviews ─────────────────────────
+            for r in final_results:
+                # 先删后插:避免重复发布时产生重复记录
+                await session.execute(
+                    text("""
+                        DELETE FROM bid_review_reviews
+                        WHERE submission_id = :submission_id
+                          AND question_id   = :question_id
+                    """),
+                    {"submission_id": submission_id, "question_id": r["question_id"]},
+                )
+                await session.execute(
+                    text("""
+                        INSERT INTO bid_review_reviews (
+                            id, submission_id, question_id, question_type,
+                            knowledge_tag, bidder_response,
+                            ai_score, ai_feedback, ai_raw_result,
+                            expert_score, expert_comment, final_score,
+                            needs_review, reviewed_by, reviewed_at
+                        ) VALUES (
+                            :id, :submission_id, :question_id, :question_type,
+                            :knowledge_tag, :bidder_response,
+                            :ai_score, :ai_feedback, :ai_raw_result,
+                            :expert_score, :expert_comment, :final_score,
+                            :needs_review, :reviewed_by, NOW()
+                        )
+                    """),
+                    {
+                        "id":              str(uuid.uuid4()),
+                        "submission_id":   submission_id,
+                        "question_id":     r["question_id"],
+                        "question_type":   r["question_type"],
+                        "knowledge_tag":   r.get("knowledge_tag", ""),
+                        "bidder_response":  r.get("bidder_response", ""),
+                        "ai_score":        r.get("score", 0),
+                        "ai_feedback":     r.get("ai_feedback", ""),
+                        "ai_raw_result":   json.dumps(r),           # 完整原始结果存 JSON
+                        "expert_score":   r.get("expert_score"),  # None 表示未修改
+                        "expert_comment": r.get("expert_comment"),
+                        "final_score":     r.get("final_score", r.get("score", 0)),
+                        "needs_review":    r.get("needs_review", False),
+                        "reviewed_by":     expert_id,
+                    },
+                )
+
+            # ── 更新提交状态为已发布 ─────────────────────────
+            await session.execute(
+                text("""
+                    UPDATE bid_review_submissions
+                    SET status               = 'published',
+                        published_at         = NOW(),
+                        updated_at           = NOW(),
+                        weak_points          = :weak_points,
+                        weak_points_summary  = :weak_points_summary
+                    WHERE id = :submission_id
+                """),
+                {
+                    "submission_id":       submission_id,
+                    "weak_points":         json.dumps(weak_points),
+                    "weak_points_summary": state.get("weak_points_summary", ""),
+                },
+            )
+
+    total_final = sum(r.get("final_score", 0) for r in final_results)
+    full_score  = sum(r.get("full_score", 0) for r in final_results)
+
+    logger.info(
+        "publish_results.done",
+        submission_id=submission_id,
+        final_score=total_final,
+        weak_points_count=len(weak_points),
+    )
+
+    # structured_output 供 API 层直接返回给专家确认接口
+    return {
+        "published": True,
+        "structured_output": {
+            "submission_id":       submission_id,
+            "final_score":         total_final,
+            "full_score":          full_score,
+            "score_rate":          round(total_final / full_score, 4) if full_score else 0,
+            "weak_points":         weak_points,
+            "weak_points_summary": state.get("weak_points_summary", ""),
+            "published":           True,
+        },
+    }
+
+
+
+if __name__ == '__main__':
+    #todo: 测试AI的评分结果示例
+    # state = {"word_file_path": "./bidder_response.docx"}
+    # import asyncio
+    # async def main():
+    #     # parsed_questions = await parse_word_node(state)
+    #     # a = {"bid_review_id":"e0000001-0000-0000-0000-000000000001"}
+    #     # state.update(a)
+    #     # state.update(parsed_questions)
+    #     # print(f'state={state}')
+    #     # merge_results = await load_questions_meta_node(state)
+    #     # print(f"{merge_results}")
+    #     # print("*"*80)
+    #     # objective_qs = [q for q in merge_results['parsed_questions'] if q["question_type"] in  # 客观评分项:单选/多选/判断
+    #     #                 ("single_choice", "multi_choice", "judge")]
+    #     # # print(f'objective_qs={objective_qs}')
+    #     # result3 =  await  _run_objective_track(objective_qs)
+    #     # print(result3)
+    #     # 获取简单题内容:取索引
+    #     # print(f'merge_results["parsed_questions"][3]-->{merge_results["parsed_questions"][3]}')
+    #     # result4 = await _review_one_subjective(merge_results["parsed_questions"][3])
+    #     # result5 = await _run_subjective_track([merge_results["parsed_questions"][3],merge_results["parsed_questions"][3],merge_results["parsed_questions"][3]])
+    #     # print(f"result5: {result5}")
+    #     # 评估一个技术方案项:
+    #     # result6 = await _run_code_track([merge_results["parsed_questions"][4]])
+    #     # print(result6)
+    #     # 三轨并行:
+    #     # result7 = await run_three_tracks_node(merge_results)
+    #     # # print(f'result7 = {result7}')
+    #     # result8 = await aggregate_results_node(result7)
+    #     # print(f"result8: {result8}")
+    #     # result9 = await analyze_weak_points_node(result8)
+    #     # state = {"submission_id": "b1e441d8-83ec-49d6-838c-ba5bbc61be96"}
+    #     result = await notify_expert_node(state)
+    #
+    #
+    #     print(result)
+    # asyncio.run(main())
+    # todo:测试合并评审专家审核结果;这里可以直接用前面AI执行的结果
+    # ══════════════════════════════════════════════════════════
+    # apply_expert_decision_node 测试
+    # ══════════════════════════════════════════════════════════
+    # import asyncio
+    # async def a_test():
+    #     # 公共评分项数据(simulate pre_review_summary.by_question)
+    #     BY_QUESTION = [
+    #         {
+    #             "question_id": "q-001", "question_type": "short_answer",
+    #             "score": 7, "full_score": 10,
+    #             "ai_feedback": "基本正确", "bidder_response": "答案A",
+    #         },
+    #         {
+    #             "question_id": "q-002", "question_type": "short_answer",
+    #             "score": 5, "full_score": 10,
+    #             "ai_feedback": "答案不完整", "bidder_response": "答案B",
+    #         },
+    #     ]
+    #
+    #     async def atest_apply_approve():
+    #         """approve:final_score 直接用 AI score,expert_score 不存在"""
+    #         print("\n" + "=" * 55)
+    #         print("【apply_expert_decision_node】用例① approve")
+    #         print("=" * 55)
+    #
+    #         import copy
+    #         state = {
+    #             "expert_decision": {
+    #                 "action": "approve",
+    #                 "modifications": [],
+    #                 "expert_id": "expert-001",
+    #             },
+    #             "pre_review_summary": {"by_question": copy.deepcopy(BY_QUESTION)},
+    #         }
+    #         r = await apply_expert_decision_node(state)
+    #         results = r["final_results"]
+    #         print(results)
+    #         print("✅ approve 用例通过")
+    #     async def atest_apply_modify_single():
+    #         """modify 单题:只改 q-002,q-001 保持 AI 分"""
+    #         print("\n" + "=" * 55)
+    #         print("【apply_expert_decision_node】用例② modify 单题")
+    #         print("=" * 55)
+    #
+    #         import copy
+    #         state = {
+    #             "expert_decision": {
+    #                 "action": "modify",
+    #                 "modifications": [
+    #                     {
+    #                         "question_id": "q-002",
+    #                         "new_score": 8,
+    #                         "comment": "逻辑正确,扣分过严,调高至8分",
+    #                     }
+    #                 ],
+    #                 "expert_id": "expert-001",
+    #             },
+    #             "pre_review_summary": {"by_question": copy.deepcopy(BY_QUESTION)},
+    #         }
+    #         r = await apply_expert_decision_node(state)
+    #         print(r["final_results"])
+    #         results = {x["question_id"]: x for x in r["final_results"]}
+    #         print("✅ modify 单题用例通过")
+    #
+    #     await atest_apply_approve()
+    #     print("*"*50)
+    #     await atest_apply_modify_single()
+    #
+    # asyncio.run(a_test())
+    async def atest_publish_results():
+      print("\n" + "=" * 55)
+      print("【publish_results_node】真实 PostgreSQL 写入验证")
+      print("=" * 55)
+
+      import uuid
+      from sqlalchemy import text
+      from backend.dependencies import AsyncSessionLocal
+
+      test_sub_id = str(uuid.uuid4())
+
+      # ── 1. 准备:查真实 question_id + 插测试 submission ──────
+      async with AsyncSessionLocal() as session:
+          async with session.begin():
+              # 从 questions 表取一条真实 question_id(避免 FK 报错)
+              row = await session.execute(
+                  text("SELECT id FROM questions LIMIT 1")
+              )
+              real_qid = str(row.scalar())
+
+              # 插入测试 submission(bid_review_id/bidder_id 不传,允许 NULL)
+              await session.execute(
+                  text("""
+                      INSERT INTO bid_review_submissions
+                          (id, tenant_id, source, status)
+                      VALUES
+                          (:id, 'tenant_default', 'word', 'pending_review')
+                  """),
+                  {"id": test_sub_id},
+              )
+      print(f"测试 submission_id : {test_sub_id}")
+      print(f"真实 question_id   : {real_qid}")
+
+      # ── 2. 构造 state(模拟 apply_expert_decision_node 的输出)
+      state = {
+          "submission_id": test_sub_id,
+          "final_results": [
+              {
+                  "question_id":     real_qid,
+                  "question_type":   "short_answer",
+                  "score":           7,           # AI 分
+                  "final_score":     8,           # 专家修改后
+                  "full_score":      10,
+                  "ai_feedback":     "基本正确,表述不完整",
+                  "bidder_response":  "递归是函数调用自身的过程",
+                  "knowledge_tag":   "递归算法",
+                  "expert_score":   8,
+                  "expert_comment": "核心概念正确,调高至8分",
+                  "needs_review":    False,
+              },
+          ],
+          # expert_id 为空字符串 → or None → reviewed_by 写 NULL,跳过 FK 检查
+          "expert_decision":    {"action": "modify", "expert_id": ""},
+          "weak_points":         ["递归算法", "时间复杂度分析"],
+          "weak_points_summary": "投标人对递归理解薄弱,建议补充练习",
+      }
+
+      # ── 3. 执行节点 ────────────────────────────────────────
+      result = await publish_results_node(state)
+      print(f'result-->{result}')
+
+      #
+      # # ── 7. 清理测试数据(bid_review_reviews 通过 ON DELETE CASCADE 自动删除)
+      # async with AsyncSessionLocal() as session:
+      #     async with session.begin():
+      #         await session.execute(
+      #             text("DELETE FROM bid_review_submissions WHERE id = :id"),
+      #             {"id": test_sub_id},
+      #         )
+      # print("测试数据已清理")
+      # print("✅ publish_results_node 测试通过")
+
+    asyncio.run(atest_publish_results())

+ 115 - 0
TenderAgent完整代码/backend/agents/bid_review/prompts.py

@@ -0,0 +1,115 @@
+# backend/agents/bid_review/prompts.py
+
+SYSTEM_PROMPT = """你是一位严谨、公正的 IT 招标资料助教,负责协助专家评分投标人投标文件。
+
+【评分原则】
+- 严格按照评分点评分,不随意加减分
+- 给出明确的得分依据,指出投标响应中的具体内容
+- 评语简洁专业,指出核心问题,避免空泛表扬或批评
+- 对有争议的内容保持保守评分,宁可偏低并标记复核,不随意给高分"""
+
+SUBJECTIVE_REVIEW_PROMPT = """请按照以下评分点评分投标人的方案响应项作答。
+
+【评分项】
+{question_content}
+
+【评分点(共{full_score}分)】
+{scoring_points}
+
+【投标响应】
+{bidder_response}
+
+请严格按照评分点逐条评分,输出以下 JSON 结构(直接输出,不要加 Markdown 代码块):
+{{
+  "question_id": "",
+  "bidder_response": "{bidder_response}",
+  "total_score": <整数,各评分点累加>,
+  "full_score": {full_score},
+  "confidence": <0.0-1.0,评分把握度,不确定时给低分并降低 confidence>,
+  "point_results": [
+    {{
+      "point_id": "<评分点ID,若无则填空字符串>",
+      "point_desc": "<评分点描述>",
+      "point_score": <该评分点满分>,
+      "earned": <true/false>,
+      "evidence": "<投标响应中对应的原文,earned=true 时必填>",
+      "missing": "<未得分的原因,earned=false 时必填>"
+    }}
+  ],
+  "overall_comment": "<1-2句整体评语,指出最核心的问题或亮点>"
+}}"""
+
+SUBJECTIVE_THINK_PROMPT = """在评分这道方案响应项之前,请先进行深入分析。
+
+【评分项】
+{question_content}
+
+【标准评分点】
+{scoring_points}
+
+【投标人作答】
+{bidder_response}
+
+请分析以下几点(中文,5-8句话):
+1. 投标人是否理解了评分项的核心概念?
+2. 逐一检查每个评分点:投标人的回答是否覆盖了该要点?是否用不同表述但实质正确?
+3. 有没有表述模糊但实质正确、不应扣分的内容?
+4. 有没有明显的概念错误或理解偏差?
+
+直接输出分析内容,不加任何前缀标签。"""
+
+CODE_REVIEW_PROMPT = """请评估以下技术方案项的投标人提交代码:先判断是否正确实现了评分项要求的功能,再评估代码质量,综合给分。
+
+【评分项要求】
+{question}
+
+【参考实现(评分依据,用于对照投标人代码是否正确)】
+{reference_solution}
+
+【投标人代码】
+
+{code}
+
+请综合「功能正确性 + 代码质量」打分,总分 {full_score} 分,输出 JSON(直接输出,不要加代码块标记):
+{{
+  "score": <整数,0到{full_score}>,
+  "confidence": <0到1之间的小数,表示你对本次评分的把握;越不确定填得越低>,
+  "feedback": [
+    "<功能正确性:是否正确实现评分项要求(最重要)>",
+    "<代码规范性:缩进/括号/命名等>",
+    "<算法效率:时间/空间复杂度>",
+    "<边界与异常处理>",
+    "<可读性与注释>"
+  ]
+}}
+
+评分参考:
+- 功能完全正确且规范:{full_score} × 0.9 ~ {full_score}
+- 功能正确但写法可优化:{full_score} × 0.7
+- 部分正确或有明显缺陷:{full_score} × 0.4
+- 未提交或完全错误:0"""
+
+
+
+WEAK_POINTS_ANALYSIS_PROMPT = """你是一位经验丰富的 IT 招标资料评审专家,请根据投标人本次投标文件的投标响应情况,分析其知识薄弱点并给出复习建议。
+
+【扣分项/扣分题清单】
+{wrong_questions}
+
+【说明】
+- 上方列出了本次投标文件中投标人答错或扣分的评分项,包含评分项内容和错误原因
+- 请将这些评分项归纳到对应的评审要点,分析薄弱原因,并给出具体的复习建议
+
+请输出以下 JSON 结构(直接输出,不要加 Markdown 代码块):
+{{
+  "weak_points": [
+    {{
+      "tag": "<评审要点名称,例如:Spring IOC、Redis缓存穿透、JVM垃圾回收>",
+      "wrong_count": <该评审要点下的扣分项数>,
+      "total_count": <该评审要点下的总题数,若不确定填与wrong_count相同>,
+      "question_nos": [<评分项序号列表>],
+      "suggestion": "<针对该评审要点的具体复习建议,1-2句>"
+    }}
+  ],
+  "overall_summary": "<对投标人本次考试整体表现的简短评价,指出最核心的1-2个薄弱方向,不超过50字>"
+}}"""

+ 94 - 0
TenderAgent完整代码/backend/agents/bid_review/review_hitl.py

@@ -0,0 +1,94 @@
+import asyncio
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint.memory import MemorySaver
+from langgraph.types import Command # 恢复运行
+from backend.agents.bid_review.state import BidReviewState
+from backend.agents.bid_review.nodes import expert_review_node
+
+# 公共基础 state(expert_review_node 用到的所有字段)
+BASE_STATE = {
+    "submission_id": "sub-test-001",
+    "bidder_id": "stu-test-001",
+    "pre_review_summary": {
+        "by_question": [
+            {"question_id": "q-001", "score": 7, "comment": "基本正确"},
+            {"question_id": "q-002", "score": 5, "comment": "答案不完整"},
+        ],
+        "total_score": 12,
+    },
+    "weak_points": ["递归算法", "时间复杂度分析"],
+    "weak_points_summary": "投标人对递归理解薄弱,建议补充练习",
+}
+
+
+def _build_review_graph():
+    """构造只含 expert_review_node 的最小测试图"""
+    builder = StateGraph(BidReviewState)
+    builder.add_node("expert_review", expert_review_node)
+    builder.add_edge(START, "expert_review")
+    builder.add_edge("expert_review", END)
+    return builder.compile(checkpointer=MemorySaver())
+
+
+# ── 用例①:评审专家直接批准 AI 结果 ─────────────────────────────
+async def atest_review_approve():
+    print("\n" + "=" * 55)
+    print("【expert_review_node】用例① approve")
+    print("=" * 55)
+
+    graph = _build_review_graph()
+    config = {"configurable": {"thread_id": "test-approve-001"}}
+
+    # 第一次 invoke:图在 interrupt() 处暂停,返回 display_data
+    snapshot = await graph.ainvoke(BASE_STATE, config=config)
+    print(f"图已暂停,展示给评审专家的数据: {snapshot}")
+
+    # 评审专家选择"批准",不修改任何评分项
+    decision = {
+        "action": "approve",
+        "modifications": [],
+        "expert_id": "expert-001",
+    }
+    final = await graph.ainvoke(Command(resume=decision), config=config)
+    print("*"*80)
+    print(f'final: {final}')
+    print("*" * 80)
+    print(f"expert_decision: {final['expert_decision']}")
+    print("✅ approve 用例通过")
+
+
+# ── 用例②:专家修改部分评分项分数 ─────────────────────────────
+async def atest_review_modify():
+    print("\n" + "=" * 55)
+    print("【expert_review_node】用例② modify(修改 q-002 分数)")
+    print("=" * 55)
+
+    graph = _build_review_graph()
+    config = {"configurable": {"thread_id": "test-modify-001"}}
+
+    await graph.ainvoke(BASE_STATE, config=config)
+
+    # 专家修改 q-002 分数,同时追加评语;q-001 不动
+    decision = {
+        "action": "modify",
+        "modifications": [
+            {
+                "question_id": "q-002",
+                "new_score": 8,
+                "comment": "重新审阅,核心逻辑正确,调高分数",
+            }
+        ],
+        "expert_id": "expert-001",
+    }
+    final = await graph.ainvoke(Command(resume=decision), config=config)
+    print(f"final: {final}")
+    print("*" * 80)
+    td = final["expert_decision"]
+    print(f"expert_decision: {td}")
+    print("✅ modify 用例通过")
+
+
+if __name__ == '__main__':
+    import asyncio
+    # asyncio.run(atest_review_approve())
+    asyncio.run(atest_review_modify())

+ 89 - 0
TenderAgent完整代码/backend/agents/bid_review/state.py

@@ -0,0 +1,89 @@
+# backend/agents/bid_review/state.py
+
+from typing import Annotated, Optional
+from typing_extensions import TypedDict
+from langgraph.graph.message import add_messages
+from langchain_core.messages import BaseMessage
+from pydantic import BaseModel
+
+
+class ScoringPointResult(BaseModel):
+    """单个评分点的评分结果"""
+    point_id:    str
+    point_desc:  str
+    point_score: int
+    earned:      bool
+    evidence:    str   # earned=True 时填投标响应中对应原文
+    missing:     str   # earned=False 时填未得分原因
+
+
+class SubjectiveReviewResult(BaseModel):
+    """方案响应项评分结果(LLM 结构化输出)"""
+    question_id:     str
+    bidder_response:  str
+    total_score:     int
+    full_score:      int
+    confidence:      float       # [0, 1],低于 0.7 时标记需复核
+    point_results:   list[ScoringPointResult]
+    overall_comment: str
+
+
+class WeakPoint(BaseModel):
+    """单个知识薄弱点"""
+    tag:          str            # 评审要点标签,如 "Spring IOC"、"Redis缓存穿透"
+    wrong_count:  int
+    total_count:  int
+    question_nos: list[int]      # 涉及的评分项序号列表
+    suggestion:   str            # 针对该评审要点的复习建议
+
+
+class WeakPointsReport(BaseModel):
+    """知识薄弱点分析报告(LLM 结构化输出)"""
+    weak_points:     list[WeakPoint]
+    overall_summary: str         # 整体评价,不超过50字
+
+
+class ExpertDecision(BaseModel):
+    """专家确认决策(interrupt 恢复时传入)"""
+    action:        str           # "approve" / "modify"
+    modifications: list[dict]    # [{question_id, new_score, comment}, ...]
+    expert_id:    str
+    
+class BidReviewState(TypedDict):
+    """投标文件评分 Agent 完整 State"""
+
+    # ── 请求上下文 ──────────────────────────────────────────────
+    messages:        Annotated[list[BaseMessage], add_messages]
+    bidder_id:      str
+    tenant_id:       str
+    session_id:      str
+    bid_review_id:         str         # 投标文件 ID(bid_reviews 表)
+    submission_id:   str         # 提交记录 ID(bid_review_submissions 表)
+    word_file_path:  str         # 投标人作答 Word 文件本地临时路径
+
+    # ── 解析结果 ───────────────────────────────────────────────
+    parsed_questions: list[dict] # 解析+DB合并后的完整评分项列表
+
+    # ── 三轨评分结果 ────────────────────────────────────────────
+    objective_results:  list[dict]
+    subjective_results: list[dict]
+    code_results:       list[dict]
+
+    # ── 汇总预评分结果 ─────────────────────────────────────────
+    pre_review_summary: dict
+
+    # ── 知识薄弱点分析 ─────────────────────────────────────────
+    weak_points:         list[dict]
+    weak_points_summary: str
+
+    # ── Human-in-the-Loop ──────────────────────────────────────
+    expert_notified:  bool
+    expert_decision:  Optional[dict]
+
+    # ── 最终结果 ───────────────────────────────────────────────
+    final_results:     list[dict]
+    published:         bool
+
+    # ── 降级标记 ───────────────────────────────────────────────
+    fallback_used:     bool
+    structured_output: Optional[dict]

+ 74 - 0
TenderAgent完整代码/backend/agents/defense/graph.py

@@ -0,0 +1,74 @@
+# backend/agents/defense/graph.py
+
+from langgraph.graph import StateGraph, START, END
+
+from backend.agents.defense.state import DefenseState, DefenseStage
+from backend.agents.defense.nodes import (
+    load_context_node,
+    check_stage_node,
+    evaluate_answer_node,
+    generate_response_node,
+    generate_report_node,
+    save_report_node,
+    save_memory_node,
+)
+from backend.core.memory import get_memory_saver
+
+
+def _route_after_check_stage(state: DefenseState) -> str:
+    """
+    check_stage 后的条件路由:
+        - 已进入 FINISHED → 生成评估报告路径
+        - 其他阶段 → 正常对话路径
+    """
+    if state.get("current_stage") == DefenseStage.FINISHED.value:
+        return "generate_report"
+    return "evaluate_answer"
+
+
+def build_defense_graph():
+    """
+    构建并编译供应商答辩 Agent 的 LangGraph 状态图。
+
+    图拓扑:
+        START → load_context → check_stage
+                                   ├─(finished)→ generate_report → save_report → save_memory → END
+                                   └─(else)    → evaluate_answer → generate_response → save_memory → END
+    """
+    builder = StateGraph(DefenseState)
+
+    # 注册节点
+    builder.add_node("load_context",      load_context_node)
+    builder.add_node("check_stage",       check_stage_node)
+    builder.add_node("evaluate_answer",   evaluate_answer_node)
+    builder.add_node("generate_response", generate_response_node)
+    builder.add_node("generate_report",   generate_report_node)
+    builder.add_node("save_report",       save_report_node)
+    builder.add_node("save_memory",       save_memory_node)
+
+    # 固定边
+    builder.add_edge(START,                "load_context")
+    builder.add_edge("load_context",       "check_stage")
+
+    # 条件分支
+    builder.add_conditional_edges(
+        "check_stage",
+        _route_after_check_stage,
+        {
+            "generate_report": "generate_report",
+            "evaluate_answer": "evaluate_answer",
+        },
+    )
+
+    # 正常对话路径
+    builder.add_edge("evaluate_answer",   "generate_response")
+    builder.add_edge("generate_response", "save_memory")
+    builder.add_edge("save_memory",       END)
+
+    # 报告生成路径
+    builder.add_edge("generate_report",   "save_report")
+    builder.add_edge("save_report",       "save_memory")
+
+    # 有 checkpointer:多轮对话需要状态跨请求持久化
+    checkpointer = get_memory_saver("defense")
+    return builder.compile(checkpointer=checkpointer)

+ 78 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_02_state.py

@@ -0,0 +1,78 @@
+# scripts/manual_tests/itv_07_02_state.py
+# 07-02 实测:DefenseState / 枚举 / 报告 Pydantic 模型
+# 纯离线,无需启动任何服务。运行:python scripts/manual_tests/itv_07_02_state.py
+
+from itv_fixtures import section, base_state, QUALIFICATION_PROJECTS
+
+from backend.agents.defense.state import (
+    DefenseState, DefenseStage, AnswerQuality,
+    DimensionEval, DefenseReport,
+)
+
+
+def dtest_enums():
+    section("① 两个枚举:状态机的取值空间")
+    print("DefenseStage(答辩阶段):", [s.value for s in DefenseStage])
+    print("AnswerQuality(回答质量):", [q.value for q in AnswerQuality])
+    # 继承 str,可直接当字符串比较
+    print("\nDefenseStage.WARMUP == 'warmup' ?", DefenseStage.WARMUP == "warmup")
+    print("终态判断:FINISHED.value =", DefenseStage.FINISHED.value)
+
+
+def dtest_state():
+    section("② DefenseState:构造一个完整的答辩状态快照")
+    state = base_state(
+        current_stage=DefenseStage.TECH_BASE.value,
+        stage_turn_count=3,
+        total_turn_count=3,
+        qualification_projects=QUALIFICATION_PROJECTS,
+        last_answer_quality=AnswerQuality.EXCELLENT.value,
+    )
+    print(f'state: {state}')
+    print("State 字段总数(TypedDict 注解数):", len(DefenseState.__annotations__))
+    print("实际构造的字段数:", len(state))
+    print("\n关键字段当前值:")
+    for k in ("current_stage", "stage_turn_count", "total_turn_count",
+              "last_answer_quality", "max_turns"):
+        print(f"  {k:20s} = {state[k]}")
+    print(f"  qualification_projects      = {len(state['qualification_projects'])} 个项目"
+          f"({state['qualification_projects'][0]['name']} ...)")
+
+
+def dtest_report_models():
+    section("③ 两层报告模型:DimensionEval → DefenseReport")
+
+    # 第一层:单维度
+    dim = DimensionEval(
+        dimension="技术深度",
+        score=82,
+        comment="Transformer 与 RAG 原理掌握扎实,能讲清注意力计算与混合检索。",
+        highlights=["自注意力公式准确", "RAG 流程完整"],
+        weaknesses=["自回归生成原理未答出"],
+    )
+    print("DimensionEval 实例:")
+    print(" ", dim.model_dump())
+
+    # 第二层:五维度汇总(直接作为 with_structured_output 的目标类型)
+    report = DefenseReport(
+        dimensions=[dim],
+        overall_score=78,
+        strengths=["RAG 流程理解清晰", "项目有量化数据支撑"],
+        improvements=["补强自回归语言模型原理", "加强抗压临场应变"],
+        overall_comment="整体达到初级 AI 开发岗要求,项目经验是亮点。",
+        recommended_topics=["自回归生成", "LoRA 原理", "向量检索"],
+        next_step_advice="针对薄弱评审要点系统复习后,1-2 周内再次供应商答辩。",
+    )
+    print("\nDefenseReport 校验通过,overall_score =", report.overall_score)
+
+    # 模拟 generate_report_node 的取值方式
+    report_dict = report.model_dump()
+    print("\nreport.model_dump() 的顶层键:")
+    print(" ", list(report_dict.keys()))
+
+
+if __name__ == "__main__":
+    dtest_enums()
+    dtest_state()
+    dtest_report_models()
+    section("✅ 07-02 全部对象构造与校验通过")

+ 82 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_03_prompts.py

@@ -0,0 +1,82 @@
+# scripts/manual_tests/itv_07_03_prompts.py
+# 07-03 实测:把每个 Prompt 用真实资质材料 / 评分项 / 答案 .format() 渲染出来,
+# 直观看到「最终送进 LLM 的文本长什么样」。纯离线,无需启动服务。
+# 运行:python scripts/manual_tests/itv_07_03_prompts.py
+
+from itv_fixtures import (section, TARGET_POSITION, QUALIFICATION_PROJECTS, ANSWER_SCRIPT)
+
+
+from backend.agents.defense.prompts import (
+    SYSTEM_PROMPT, TECH_BASE_GENERATE_PROMPT, WARMUP_PROMPT,
+    INTRO_EVAL_TECH_FIRST_PROMPT, TECH_BASE_PROMPT, PROJECT_PROMPT,
+    CLOSING_PROMPT, STAGE_TRANSITION_PROMPTS,
+    EVALUATE_THINK_PROMPT, EVALUATE_ANSWER_PROMPT, GENERATE_REPORT_PROMPT,
+)
+
+Q_TRANSFORMER = "请解释 Transformer 中自注意力机制的计算过程。"
+
+
+def show(name: str, rendered: str):
+    print(f"\n----- {name} -----")
+    print(rendered.strip())
+
+
+def main():
+    section("① SYSTEM_PROMPT(注入每次 LLM 调用,固定评审专家角色)")
+    show("SYSTEM_PROMPT", SYSTEM_PROMPT.format(position=TARGET_POSITION))
+
+    section("② TECH_BASE_GENERATE_PROMPT(首轮让 LLM 动态出 8 道岗位题)")
+    show("动态出题", TECH_BASE_GENERATE_PROMPT.format(position=TARGET_POSITION, count=8))
+
+    section("③ WARMUP opening(开场白邀请自我介绍)")
+    show("WARMUP[opening]", WARMUP_PROMPT["opening"].format(position=TARGET_POSITION))
+
+    section("④ INTRO_EVAL_TECH_FIRST(评价自我介绍 + 出第一题,仅过渡轮用一次)")
+    show("过渡专用", INTRO_EVAL_TECH_FIRST_PROMPT.format(
+        intro=ANSWER_SCRIPT[0],
+        position=TARGET_POSITION,
+        first_question=Q_TRANSFORMER,
+    ))
+
+    section("⑤ TECH_BASE ask_with_feedback(上题反馈 + 出下一题,技术环节主流程)")
+    show("ask_with_feedback", TECH_BASE_PROMPT["ask_with_feedback"].format(
+        question=Q_TRANSFORMER,
+        answer=ANSWER_SCRIPT[1],
+        quality="excellent",
+        next_question="过拟合和欠拟合分别是什么?如何缓解过拟合?",
+    ))
+
+    section("⑥ PROJECT new_project(深挖资质材料第一个项目,项目数据注入为内部参考)")
+    proj = QUALIFICATION_PROJECTS[0]
+    show("new_project", PROJECT_PROMPT["new_project"].format(
+        project_name=proj["name"],
+        project_role=proj["role"],
+        tech_stack=", ".join(proj["tech_stack"]),
+        highlights="\n".join(f"- {h}" for h in proj["highlights"]),
+        description=proj["description"][:200],
+    ))
+
+    section("⑦ STAGE_TRANSITION_PROMPTS(静态过渡语,不调用 LLM)")
+    print("可用的过渡键:", list(STAGE_TRANSITION_PROMPTS.keys()))
+    for k, v in STAGE_TRANSITION_PROMPTS.items():
+        print(f"  [{k}] {v}")
+
+    section("⑧ Think Tool 两步:EVALUATE_THINK(自由推理)→ EVALUATE_ANSWER(打标签)")
+    show("第一步 EVALUATE_THINK", EVALUATE_THINK_PROMPT.format(
+        question=Q_TRANSFORMER, answer=ANSWER_SCRIPT[1]))
+    show("第二步 EVALUATE_ANSWER", EVALUATE_ANSWER_PROMPT.format(
+        question=Q_TRANSFORMER, answer=ANSWER_SCRIPT[1]))
+
+    section("⑨ GENERATE_REPORT(答辩结束生成五维度报告)")
+    show("GENERATE_REPORT(节选前 400 字)", GENERATE_REPORT_PROMPT.format(
+        position=TARGET_POSITION,
+        total_turns=13,
+        history_summary="(无历史摘要)",
+        conversation="评审专家:... \n投标人:...",
+    )[:400])
+
+    section("✅ 07-03 全部 Prompt 渲染成功")
+
+
+if __name__ == "__main__":
+    main()

+ 63 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_04_load_context.py

@@ -0,0 +1,63 @@
+# scripts/manual_tests/itv_07_04_load_context.py
+# 07-04 实测:load_context_node 首轮初始化——并行加载资质材料、评分标准库、摘要 + LLM 动态出题。
+#
+# 前置条件:
+#   1. PostgreSQL 已启动(docker-compose up -d postgres)
+#   2. 已 seed:python scripts/seed_data.py(需存在 bidder01 账号)
+#   3. .env.local 配好 DEEPSEEK_API_KEY
+# 运行:python scripts/manual_tests/itv_07_04_load_context.py
+
+import asyncio
+from langchain_core.messages import HumanMessage
+
+from itv_fixtures import load_env, section, base_state, setup_db_fixture
+
+load_env()
+
+from backend.agents.defense.nodes import load_context_node, _generate_questions_by_llm
+from backend.agents.defense.state import DefenseStage
+
+
+async def main():
+    section("① 准备 DB fixture:写入李明资质材料 + 新建答辩会话")
+    ctx = await setup_db_fixture()
+    print("bidder_id      :", ctx["bidder_id"])
+    print("session_id      :", ctx["session_id"])
+    print("qualification_review_id:", ctx["qualification_review_id"])
+    #
+    section("② 首轮 load_context_node(total_turn_count=0 → 走初始化路径)")
+    state = base_state(
+        bidder_id=ctx["bidder_id"],
+        session_id=ctx["session_id"],
+        qualification_review_id=ctx["qualification_review_id"],
+        total_turn_count=0,
+        messages=[HumanMessage(content="[开始答辩]")],
+    )
+    updates = await load_context_node(state)
+
+    print("初始化后的阶段:", updates.get("current_stage"),
+          "(应为", DefenseStage.WARMUP.value, ")")
+    print("\n资质材料联动数据(从 qualification_reviews 读取):")
+    print("  qualification_projects:", [p["name"] for p in updates.get("qualification_projects", [])])
+    print("  qualification_skills  :", updates.get("qualification_skills", [])[:5], "...")
+
+    bank = updates.get("question_bank", [])
+    print(f'updates: {updates}')
+    print(f"\n评分标准库 question_bank:共 {len(bank)} 题")
+    # print("  前 5 题(LLM 动态生成优先):")
+    # for q in bank[:5]:
+    #     print(f"    [{q['id']:>6}] ({q['difficulty']}) {q['content'][:34]}")
+    #
+    # print("\n历史摘要 existing_summary:", updates.get("existing_summary"))
+    #
+    # section("③ 单独验证 LLM 动态出题(_generate_questions_by_llm)")
+    # qs = await _generate_questions_by_llm("AI大模型开发工程师", count=4)
+    # print(f"LLM 生成 {len(qs)} 题:")
+    # for q in qs:
+    #     print(f"  - {q['content'][:40]}  tags={q['tags']}")
+
+    section("✅ 07-04 load_context 首轮初始化验证完成")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 109 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_05_check_stage.py

@@ -0,0 +1,109 @@
+# scripts/manual_tests/itv_07_05_check_stage.py
+# 07-05 实测:check_stage_node 是纯逻辑节点(不调用 LLM、不读 DB),
+# 喂入不同的 State 快照,观察它如何决定「推进 / 停留 / 强制结束」。
+# 纯离线,无需启动服务。运行:python scripts/manual_tests/itv_07_05_check_stage.py
+
+import asyncio
+from langchain_core.messages import HumanMessage, AIMessage
+
+from itv_fixtures import section, base_state, sample_question_bank
+
+from backend.agents.defense.nodes import (
+    check_stage_node, _check_advance_condition, _next_stage,
+)
+from backend.agents.defense.state import DefenseStage
+
+
+def dtest_next_stage():
+    section("① _next_stage:阶段顺序推进(纯函数)")
+    chain = ["warmup", "tech_base", "project", "closing", "finished"]
+    for cur in chain:
+        print(f"  {cur:10s} → {_next_stage(cur)}")
+    assert _next_stage("warmup") == "tech_base"
+    assert _next_stage("closing") == "finished"
+    assert _next_stage("finished") == "finished"  # 终态保持
+
+
+async def run_case(title: str, state: dict):
+    result = await check_stage_node(state)
+    decision = result.get("current_stage", "(停留,返回 {})")
+    print(f"\n[{title}]")
+    print(f"  输入: stage={state['current_stage']} stage_turns={state['stage_turn_count']} "
+          f"total_turns={state['total_turn_count']}")
+    print(f"  决策: {decision}")
+    return result
+
+
+async def dtest_check_stage():
+    section("② check_stage_node:各场景的推进决策")
+
+    # 场景1:WARMUP 第 1 轮,投标人介绍完 → 推进到 tech_base
+    await run_case("WARMUP→? 投标人已自我介绍", base_state(
+        current_stage="warmup", stage_turn_count=1, total_turn_count=1,
+        messages=[HumanMessage(content="我是李明,做过 RAG 项目...")],
+    ))
+
+    # 场景2:TECH_BASE 才 3 轮(< min 6)→ 停留
+    await run_case("TECH_BASE 轮数不够(3<6)", base_state(
+        current_stage="tech_base", stage_turn_count=3, total_turn_count=4,
+        question_bank=sample_question_bank(),
+        messages=[HumanMessage(content="过拟合是...")],
+    ))
+    #
+    # 场景3:TECH_BASE 满 6 轮且已问 8 题 → 推进到 project
+    bank_all_asked = [{**q, "asked": True} for q in sample_question_bank()]
+    await run_case("TECH_BASE→? 已问满 8 题", base_state(
+        current_stage="tech_base", stage_turn_count=6, total_turn_count=7,
+        question_bank=bank_all_asked,
+        messages=[HumanMessage(content="RAG 是检索增强生成...")],
+    ))
+    #
+    # 场景4:PROJECT 所有项目都深挖过 → 推进到 closing
+    await run_case("PROJECT→? 项目全覆盖", base_state(
+        current_stage="project", stage_turn_count=2, total_turn_count=11,
+        qualification_projects=[{"name": "RAG系统"}, {"name": "LoRA微调"}],
+        projects_asked=["RAG系统", "LoRA微调"],
+        messages=[HumanMessage(content="这个项目我负责...")],
+    ))
+    #
+    # 场景5:CLOSING 满 2 轮 → 进入 finished
+    await run_case("CLOSING→? 反问满 2 轮", base_state(
+        current_stage="closing", stage_turn_count=2, total_turn_count=14,
+        messages=[HumanMessage(content="团队技术栈是什么?")],
+    ))
+
+    # 场景6:强制结束——投标人发"结束答辩吧"关键词
+    await run_case("任意阶段 强制结束(关键词)", base_state(
+        current_stage="tech_base", stage_turn_count=3, total_turn_count=5,
+        messages=[HumanMessage(content="没有其他问题了,结束答辩吧。")],
+    ))
+
+    # 场景7:强制结束——逼近轮数上限(total >= max-2)
+    await run_case("任意阶段 强制结束(轮数上限)", base_state(
+        current_stage="project", stage_turn_count=5, total_turn_count=39, max_turns=40,
+        messages=[HumanMessage(content="继续聊...")],
+    ))
+
+
+def dtest_advance_condition():
+    section("③ _check_advance_condition:各阶段的推进判据")
+    # WARMUP 永远 True
+    print("WARMUP →", _check_advance_condition("warmup", base_state(), ""))
+    # TECH_BASE:问满 8 题才 True
+    bank = [{**q, "asked": i < 8} for i, q in enumerate(sample_question_bank())]
+    print("TECH_BASE(已问8题)→",
+          _check_advance_condition("tech_base", base_state(question_bank=bank), ""))
+    # PROJECT:项目全覆盖才 True
+    st = base_state(qualification_projects=[{"name": "A"}], projects_asked=["A"])
+    print("PROJECT(项目全覆盖)→", _check_advance_condition("project", st, ""))
+
+
+async def main():
+    dtest_next_stage()
+    await dtest_check_stage()
+    dtest_advance_condition()
+    section("✅ 07-05 check_stage 全部场景验证通过")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 48 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_06_evaluate.py

@@ -0,0 +1,48 @@
+# scripts/manual_tests/itv_07_06_evaluate.py
+# 07-06 实测:evaluate_answer_node 给投标人回答打质量标签。
+# 演示三条路径:① 优秀回答(走 Think Tool + LLM 两步)② 明确不会(走快速路径,不调用 LLM)
+#               ③ WARMUP 阶段(跳过评估,直接 ADEQUATE)
+# 需 DeepSeek API(.env.local 里 DEEPSEEK_API_KEY)。运行:
+#   python scripts/manual_tests/itv_07_06_evaluate.py
+
+import asyncio
+from langchain_core.messages import HumanMessage
+
+from itv_fixtures import ( section, make_state_eval_excellent, make_state_eval_no_answer,
+    base_state, ANSWER_SCRIPT,
+)
+
+from backend.agents.defense.nodes import evaluate_answer_node
+
+
+async def run(title: str, state: dict):
+    cur_q = (state.get("current_question") or {}).get("content", "(无)")
+    answer = state["messages"][-1].content
+    print(f"\n[{title}]")
+    print(f"  问题: {cur_q}")
+    print(f"  投标人回答: {answer[:50]}{'...' if len(answer) > 50 else ''}")
+    updates = await evaluate_answer_node(state)
+    print(f"  → 质量标签: {updates['last_answer_quality']}")
+    print(f"  → 计数更新: total_turns={updates['total_turn_count']} "
+          f"stage_turns={updates['stage_turn_count']}")
+
+
+async def main():
+    section("① 优秀回答(Transformer)→ Think Tool 推理 + LLM 评估")
+    await run("excellent 预期", make_state_eval_excellent())
+
+    section("② 明确不会('不知道')→ 快速路径,不调用 LLM")
+    await run("no_answer 预期", make_state_eval_no_answer())
+
+    section("③ WARMUP 阶段 → 跳过评估,直接 ADEQUATE")
+    warmup_state = base_state(
+        current_stage="warmup", stage_turn_count=1, total_turn_count=1,
+        messages=[HumanMessage(content=ANSWER_SCRIPT[0])],
+    )
+    await run("warmup 跳过", warmup_state)
+
+    section("✅ 07-06 evaluate_answer 三条路径验证完成")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 44 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_07_resp_warmup_tech.py

@@ -0,0 +1,44 @@
+# scripts/manual_tests/itv_07_07_resp_warmup_tech.py
+# 07-07 实测:generate_response_node 在 WARMUP 和 TECH_BASE 阶段如何生成评审专家回应。
+# 演示:① WARMUP 开场白  ② TECH_BASE 首轮(评价自我介绍 + 出第一题)
+#       ③ TECH_BASE 换题(上题反馈 + 下一题)
+# 需 DeepSeek API。运行:python scripts/manual_tests/itv_07_07_resp_warmup_tech.py
+
+import asyncio
+from langchain_core.messages import HumanMessage
+
+from itv_fixtures import ( section, base_state, make_state_tech_first, make_state_tech_next,
+)
+
+from backend.agents.defense.nodes import generate_response_node
+
+
+async def run(title: str, state: dict):
+    print(f"\n[{title}]")
+    updates = await generate_response_node(state)
+    reply = updates["messages"][0].content
+    print("评审专家输出:")
+    print("  " + reply.replace("\n", "\n  "))
+    if updates.get("current_question"):
+        print(f"\n  (当前题已更新为: {updates['current_question']['content'][:40]}...)")
+    return updates
+
+
+async def main():
+    section("① WARMUP:生成开场白,邀请自我介绍")
+    await run("WARMUP opening", base_state(
+        current_stage="warmup", stage_turn_count=0, total_turn_count=0,
+        messages=[HumanMessage(content="[开始答辩]")],
+    ))
+
+    section("② TECH_BASE 首轮:评价自我介绍 + 抛出第一道技术题")
+    await run("INTRO_EVAL_TECH_FIRST", make_state_tech_first())
+
+    section("③ TECH_BASE 换题:上题(adequate)简短反馈 + 出下一题")
+    await run("ask_with_feedback", make_state_tech_next())
+
+    section("✅ 07-07 WARMUP & TECH_BASE 回应生成完成")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 45 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_08_resp_project_closing.py

@@ -0,0 +1,45 @@
+# scripts/manual_tests/itv_07_08_resp_project_closing.py
+# 07-08 实测:generate_response_node 在 PROJECT 和 CLOSING 阶段的回应生成。
+# 演示:① PROJECT 新项目第1问(注入资质审查项目,带过渡语)
+#       ② PROJECT 追问(基于投标人回答深挖混合检索细节)
+#       ③ CLOSING 反问邀请  ④ CLOSING 回应投标人提问
+# 需 DeepSeek API。运行:python scripts/manual_tests/itv_07_08_resp_project_closing.py
+
+import asyncio
+
+from itv_fixtures import ( section,
+    make_state_project_new, make_state_project_followup,
+    make_state_closing_opening, make_state_closing_respond,
+)
+
+from backend.agents.defense.nodes import generate_response_node
+
+
+async def run(title: str, state: dict):
+    print(f"\n[{title}]")
+    updates = await generate_response_node(state)
+    reply = updates["messages"][0].content
+    print("评审专家输出:")
+    print("  " + reply.replace("\n", "\n  "))
+    if "projects_asked" in updates:
+        print(f"\n  (已深挖项目: {updates['projects_asked']})")
+
+
+async def main():
+    section("① PROJECT 新项目第1问(注入资质材料「RAG 问答系统」+ 过渡语)")
+    await run("new_project", make_state_project_new())
+
+    section("② PROJECT 追问(基于投标人对项目背景的回答继续深挖)")
+    await run("followup_with_feedback", make_state_project_followup())
+
+    section("③ CLOSING 反问邀请")
+    await run("closing opening", make_state_closing_opening())
+
+    section("④ CLOSING 回应投标人提问")
+    await run("closing respond", make_state_closing_respond())
+
+    section("✅ 07-08 PROJECT & CLOSING 回应生成完成")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 44 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_09_report.py

@@ -0,0 +1,44 @@
+# scripts/manual_tests/itv_07_09_report.py
+# 07-09 实测:generate_report_node 基于完整对话生成五维度结构化报告。
+# 喂入一份"已完成答辩"的 State(李明的全程对话),看 LLM 输出的报告 JSON。
+# 需 DeepSeek API。运行:python scripts/manual_tests/itv_07_09_report.py
+
+import asyncio
+import json
+
+from itv_fixtures import load_env, section, make_state_for_report
+
+load_env()
+
+from backend.agents.defense.nodes import generate_report_node
+
+
+async def main():
+    section("① 输入:一份已完成答辩的 State(李明全程对话)")
+    state = make_state_for_report()
+    print(f"对话消息数: {len(state['messages'])}  总轮数: {state['total_turn_count']}")
+    print(f"岗位: {state['target_position']}")
+
+    section("② 调用 generate_report_node,LLM 生成五维度结构化报告")
+    updates = await generate_report_node(state)
+    report  = updates["report"]
+
+    print("综合评分:", report.get("overall_score"), "/ 100")
+    print("\n五个维度:")
+    for d in report.get("dimensions", []):
+        print(f"  - {d['dimension']:6s} {d['score']:>3} 分  {d['comment'][:36]}")
+    print("\n核心优势:", report.get("strengths"))
+    print("提升方向:", report.get("improvements"))
+    print("建议复习:", report.get("recommended_topics"))
+    print("下一步  :", report.get("next_step_advice"))
+
+    section("③ 节点同时写入的三件事")
+    print("report           : dict,供 save_report 写入 DB")
+    print("messages         :", updates["messages"][0].content[:40], "...")
+    print("structured_output:", list(updates["structured_output"].keys()))
+
+    section("✅ 07-09 generate_report 生成完成")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 84 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_10_save.py

@@ -0,0 +1,84 @@
+# scripts/manual_tests/itv_07_10_save.py
+# 07-10 实测:save_report_node(UPDATE 报告)+ save_memory_node(UPSERT 摘要)。
+# 写库后立即回查,确认数据真的落库。
+#
+# 前置条件:PostgreSQL 已启动 + 已 seed(存在 bidder01)。
+# 运行:python scripts/manual_tests/itv_07_10_save.py
+
+import asyncio
+import json
+from sqlalchemy import text
+
+from itv_fixtures import load_env, section, base_state, setup_db_fixture
+
+load_env()
+
+from backend.agents.defense.nodes import save_report_node, save_memory_node
+from backend.core.memory import build_thread_id
+from backend.dependencies import AsyncSessionLocal
+
+
+SAMPLE_REPORT = {
+    "dimensions": [
+        {"dimension": "技术深度", "score": 72, "comment": "Transformer 与 RAG 掌握较好",
+         "highlights": ["自注意力准确"], "weaknesses": ["自回归未答出"]},
+    ],
+    "overall_score": 76,
+    "strengths": ["RAG 项目经验扎实", "表达逻辑清晰"],
+    "improvements": ["补全大模型基础原理"],
+    "overall_comment": "整体达到初级 AI 开发岗要求。",
+    "recommended_topics": ["自回归生成", "LoRA 原理"],
+    "next_step_advice": "系统梳理基础原理后再次供应商答辩。",
+}
+
+
+async def query_session(thread_id: str) -> dict:
+    async with AsyncSessionLocal() as s:
+        row = (await s.execute(
+            text("""SELECT status, overall_score, summary, report
+                    FROM defense_sessions WHERE thread_id = :tid"""),
+            {"tid": thread_id},
+        )).mappings().fetchone()
+    return dict(row) if row else {}
+
+
+async def main():
+    section("① 准备 DB fixture(已有一条 in_progress 会话)")
+    ctx       = await setup_db_fixture()
+    thread_id = ctx["thread_id"]
+    print("thread_id:", thread_id)
+    before = await query_session(thread_id)
+    print("写入前:", {"status": before["status"], "overall_score": before["overall_score"]})
+
+    section("② save_report_node:UPDATE 报告,status → finished")
+    state = base_state(
+        bidder_id=ctx["bidder_id"], session_id=ctx["session_id"],
+        report=SAMPLE_REPORT,
+    )
+    await save_report_node(state)
+    after = await query_session(thread_id)
+    print("写入后 status       :", after["status"])
+    print("写入后 overall_score:", after["overall_score"])
+    report_db = after["report"] if isinstance(after["report"], dict) else json.loads(after["report"])
+    print("写入后 report 维度数 :", len(report_db.get("dimensions", [])))
+
+    section("③ save_memory_node:触发摘要压缩 + UPSERT summary")
+    from langchain_core.messages import HumanMessage, AIMessage
+    long_msgs = []
+    for i in range(11):
+        long_msgs.append(AIMessage(content=f"评审专家第{i}个问题"))
+        long_msgs.append(HumanMessage(content=f"投标人第{i}个回答,涉及 RAG 与微调细节"))
+    mem_state = base_state(
+        bidder_id=ctx["bidder_id"], session_id=ctx["session_id"],
+        messages=long_msgs, should_summarize=True,   # 强制触发压缩
+    )
+    await save_memory_node(mem_state)
+    final = await query_session(thread_id)
+    print("UPSERT 后 summary(前 60 字):")
+    print("  ", (final["summary"] or "(空)")[:60])
+
+    section("✅ 07-10 save_report / save_memory 落库验证完成")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 75 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_11_graph.py

@@ -0,0 +1,75 @@
+# scripts/manual_tests/itv_07_11_graph.py
+# 07-11 实测:build_defense_graph 编译整张图,并用 MemorySaver 跑多轮,
+# 观察 current_stage 如何随对话自动推进(warmup → tech_base → ...)。
+#
+# 前置条件:PostgreSQL 已启动 + 已 seed + .env.local 配好 DEEPSEEK_API_KEY。
+# 运行:python scripts/manual_tests/itv_07_11_graph.py
+
+import asyncio
+from langchain_core.messages import HumanMessage
+
+from itv_fixtures import (
+    load_env, section, setup_db_fixture, ANSWER_SCRIPT, TARGET_POSITION,
+)
+
+load_env()
+
+from backend.agents.defense.graph import build_defense_graph
+from backend.agents.defense.state import DefenseStage
+from backend.core.memory import build_config
+
+
+def last_ai(messages):
+    for m in reversed(messages):
+        if m.__class__.__name__ == "AIMessage":
+            return m.content
+    return ""
+
+
+async def main():
+    section("① 编译图(不需要 DB,仅验证拓扑)")
+    graph = build_defense_graph()
+    print("图编译成功,节点数:", len(graph.nodes))
+    print("节点列表:", [n for n in graph.nodes if n not in ("__start__", "__end__")])
+
+    section("② 准备 DB fixture + 首轮启动(完整 initial_state)")
+    ctx    = await setup_db_fixture()
+    config = build_config(ctx["bidder_id"], ctx["session_id"])
+
+    initial_state = {
+        "messages":          [HumanMessage(content="[开始答辩]")],
+        "bidder_id":        ctx["bidder_id"],
+        "tenant_id":         ctx["tenant_id"] if "tenant_id" in ctx else "tenant_default",
+        "session_id":        ctx["session_id"],
+        "target_position":   TARGET_POSITION,
+        "qualification_review_id":  ctx["qualification_review_id"],
+        "qualification_projects":   [], "qualification_skills": [],
+        "current_stage":     DefenseStage.WARMUP.value,
+        "stage_turn_count":  0, "total_turn_count": 0, "max_turns": 40,
+        "question_bank":     [], "current_question": None, "projects_asked": [],
+        "last_answer_quality": "adequate", "followup_count": 0,
+        "existing_summary":  None, "should_summarize": False,
+        "report":            None, "fallback_used": False, "structured_output": None,
+    }
+    result = await graph.ainvoke(initial_state, config=config)
+    print(f"  [首轮] stage={result['current_stage']} "
+          f"turns={result['total_turn_count']}")
+    print("  开场白:", last_ai(result["messages"])[:50], "...")
+
+    section("③ 连续发 4 轮(只传增量,MemorySaver 自动恢复 State)")
+    for i, answer in enumerate(ANSWER_SCRIPT[:4], start=1):
+        result = await graph.ainvoke(
+            {"messages": [HumanMessage(content=answer)],
+             "bidder_id": ctx["bidder_id"], "session_id": ctx["session_id"],
+             "tenant_id": "tenant_default"},
+            config=config,
+        )
+        print(f"  [第{i}轮] stage={result['current_stage']:9s} "
+              f"turns={result['total_turn_count']}  "
+              f"评审专家: {last_ai(result['messages'])[:36]}...")
+
+    section("✅ 07-11 图装配 + 多轮推进验证完成")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 65 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_12_http.py

@@ -0,0 +1,65 @@
+# scripts/manual_tests/itv_07_12_http.py
+# 07-12 实测:依次打通 5 个 HTTP 接口(建会话 / 对话 / 列表 / 报告 / SSE 提示)。
+# 这是「从外部以真实 HTTP 请求」验证答辩 Agent,最接近前端的调用方式。
+#
+# 前置条件:
+#   1. PostgreSQL 已启动 + 已 seed(bidder01 账号)
+#   2. 后端已启动:uvicorn backend.main:app --reload --port 8000
+#   3. .env.local 配好 DEEPSEEK_API_KEY
+# 运行:python scripts/manual_tests/itv_07_12_http.py
+
+import sys
+import httpx
+
+sys.path.insert(0, "scripts/manual_tests")
+from itv_fixtures import section, TARGET_POSITION, ANSWER_SCRIPT
+
+BASE = "http://localhost:8000/api/v1"
+
+
+def login() -> str:
+    r = httpx.post(f"{BASE}/auth/login",
+                   json={"username": "bidder01", "password": "Bidder@123456"},
+                   trust_env=False, timeout=15.0)
+    r.raise_for_status()
+    return r.json()["access_token"]
+
+
+def main():
+    token   = login()
+    headers = {"Authorization": f"Bearer {token}"}
+
+    section("① POST /sessions —— 创建会话(首轮开场白)")
+    r = httpx.post(f"{BASE}/defense/sessions", headers=headers,
+                   json={"target_position": TARGET_POSITION},
+                   trust_env=False, timeout=60.0)
+    r.raise_for_status()
+    sid = r.json()["session_id"]
+    print("session_id:", sid)
+    print("开场白:", r.json()["message"][:60], "...")
+
+    section("② POST /sessions/{id}/chat —— 发送 1 条消息")
+    r = httpx.post(f"{BASE}/defense/sessions/{sid}/chat", headers=headers,
+                   json={"message": ANSWER_SCRIPT[0]},
+                   trust_env=False, timeout=60.0)
+    r.raise_for_status()
+    d = r.json()
+    print(f"阶段: {d['current_stage']}  总轮数: {d['total_turns']}  结束: {d['is_finished']}")
+    print("评审专家:", d["reply"][:60], "...")
+
+    section("③ GET /sessions —— 历史答辩列表")
+    r = httpx.get(f"{BASE}/defense/sessions", headers=headers, trust_env=False, timeout=15.0)
+    r.raise_for_status()
+    print("历史答辩数:", r.json()["total"])
+
+    section("④ GET /sessions/{id}/report —— 答辩未结束应返回 400")
+    r = httpx.get(f"{BASE}/defense/sessions/{sid}/report", headers=headers,
+                  trust_env=False, timeout=15.0)
+    print("HTTP 状态码:", r.status_code, "(进行中 → 400 符合预期)")
+
+    section("⑤ SSE 流式接口 /chat/stream 见 07-13,本脚本只验证 4 个同步接口")
+    section("✅ 07-12 HTTP 接口连通验证完成")
+
+
+if __name__ == "__main__":
+    main()

+ 138 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_07_13_e2e.py

@@ -0,0 +1,138 @@
+# scripts/manual_tests/itv_07_13_e2e.py
+# 07-13 端到端:一份资质材料(李明)跑完整一场供应商答辩——
+#   登录 → 写入资质材料 → 建会话 → 多轮对话 → 自动结束 → 查看五维度报告。
+# 用的是贯穿全章 07-02~07-12 的同一份 fixture,到这里终于串成完整流程。
+#
+# 前置条件:
+#   1. PostgreSQL 已启动 + 已 seed(bidder01 账号)
+#   2. 后端已启动:uvicorn backend.main:app --reload --port 8000
+#   3. .env.local 配好 DEEPSEEK_API_KEY
+# 运行:python scripts/manual_tests/itv_07_13_e2e.py
+
+import sys
+import json
+import time
+import asyncio
+import httpx
+
+sys.path.insert(0, "scripts/manual_tests")
+from itv_fixtures import load_env, seed_qualification, ANSWER_SCRIPT, TARGET_POSITION
+
+load_env()
+
+BASE_URL   = "http://localhost:8000/api/v1"
+BIDDER_UN = "bidder01"
+BIDDER_PW = "Bidder@123456"
+
+# 投标人回答序列:直接复用贯穿全章 07-02~07-12 的统一投标响应脚本(AI 大模型岗,李明)。
+# 覆盖 WARMUP 自我介绍 → 4 道技术题(含一题"不知道")→ 2 轮项目深挖 → 反问 → 结束答辩。
+ANSWER_SEQUENCE = ANSWER_SCRIPT
+
+
+def login(username: str, password: str) -> str:
+    resp = httpx.post(
+        f"{BASE_URL}/auth/login",
+        json={"username": username, "password": password},
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    return resp.json()["access_token"]
+
+
+def print_section(title: str):
+    print(f"\n{'='*60}")
+    print(f"  {title}")
+    print("="*60)
+
+
+if __name__ == "__main__":
+    # ── ① 投标人登录 ─────────────────────────────────────────────
+    print_section("① 投标人登录")
+    token = login(BIDDER_UN, BIDDER_PW)
+    print(f"token 获取成功:{token[:20]}...")
+
+    headers = {"Authorization": f"Bearer {token}"}
+
+    # ── ②a 写入李明资质材料(供 PROJECT 阶段联动深挖)────────────────
+    seeded = asyncio.run(seed_qualification())
+    print(f"资质材料已写入,qualification_review_id: {seeded['qualification_review_id']}")
+
+    # ── ②b 创建答辩会话(带 qualification_review_id 联动)──────────────
+    print_section(f"② 创建答辩会话(目标岗位:{TARGET_POSITION})")
+    resp = httpx.post(
+        f"{BASE_URL}/defense/sessions",
+        headers=headers,
+        json={"target_position": TARGET_POSITION,
+              "qualification_review_id": seeded["qualification_review_id"]},
+        timeout=30.0,
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    session_data = resp.json()
+    session_id   = session_data["session_id"]
+    print(f"session_id: {session_id}")
+    print(f"开场白:\n{session_data['message']}\n")
+
+    # ── ③ 多轮对话 ─────────────────────────────────────────────
+    print_section("③ 开始多轮对话")
+    for i, answer in enumerate(ANSWER_SEQUENCE):
+        print(f"\n--- 第{i+1}轮 ---")
+        print(f"投标人:{answer[:60]}...")
+
+        resp = httpx.post(
+            f"{BASE_URL}/defense/sessions/{session_id}/chat",
+            headers=headers,
+            json={"message": answer},
+            timeout=30.0,
+            trust_env=False,
+        )
+        resp.raise_for_status()
+        result = resp.json()
+
+        print(f"评审专家:{result['reply'][:80]}...")
+        print(f"阶段:{result['current_stage']}  总轮数:{result['total_turns']}")
+
+        if result["is_finished"]:
+            print("\n答辩已结束!")
+            summary = result.get("report_summary", {})
+            print(f"综合评分:{summary.get('overall_score', 0)} / 100")
+            print(f"优势:{summary.get('strengths', [])[:2]}")
+            print(f"改进方向:{summary.get('improvements', [])[:2]}")
+            break
+
+        time.sleep(1)  # 避免过快请求
+
+    # ── ④ 查看完整报告 ─────────────────────────────────────────
+    print_section("④ 查看完整评估报告")
+    resp = httpx.get(
+        f"{BASE_URL}/defense/sessions/{session_id}/report",
+        headers=headers,
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    report = resp.json()
+
+    print(f"综合评分:{report['overall_score']} / 100")
+    print(f"维度数量:{len(report['dimensions'])}")
+    for dim in report["dimensions"]:
+        print(f"  - {dim['dimension']}:{dim['score']}分  {dim['comment'][:40]}")
+    print(f"推荐复习:{report['recommended_topics']}")
+    print(f"下一步建议:{report['next_step_advice']}")
+
+    # ── ⑤ 查看历史答辩列表 ─────────────────────────────────────
+    print_section("⑤ 查看历史答辩列表")
+    resp = httpx.get(
+        f"{BASE_URL}/defense/sessions",
+        headers=headers,
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    sessions_list = resp.json()
+    print(f"历史答辩数量:{sessions_list['total']}")
+    for s in sessions_list["items"][:3]:
+        print(f"  - {s['session_id'][:8]}... [{s['target_position']}] "
+              f"评分:{s['overall_score'] or '进行中'} 状态:{s['status']}")
+
+    print("\n" + "="*60)
+    print("  端到端测试全部通过 ✅")
+    print("="*60)

+ 503 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/itv_fixtures.py

@@ -0,0 +1,503 @@
+# scripts/manual_tests/itv_fixtures.py
+# ──────────────────────────────────────────────────────────────────────────────
+# 供应商答辩 Agent —— 全条款共享测试 fixture(支架)
+#
+# 第 7 章 07-02 ~ 07-13 的每个测试脚本都从这里导入,使用同一份「AI 大模型开发岗」
+# 资质材料和同一套投标人投标响应脚本。这样从 07-02 读到 07-13,就像在看同一场答辩逐步展开:
+#   07-02 看 State 长什么样 → 07-05 看阶段如何推进 → 07-06 看回答如何被评分
+#   → 07-07/08 看评审专家如何提问 → 07-09 看报告如何生成 → 07-11/13 看完整跑通
+#
+# 设计要点:
+#   - QUALIFICATION_STRUCTURED:模拟「供应商资质审查 Agent」产出的结构化结果。答辩 Agent 不读 PDF,
+#     只读 qualification_reviews.structured_data 里的 projects 和 skills_list 两个键。
+#   - ANSWER_SCRIPT:一名投标人从自我介绍到结束答辩的固定回答序列。
+#   - base_state / make_state_*:构造各阶段的 DefenseState 快照,喂给单个节点测试。
+#   - setup_db_fixture / seed_qualification:把资质材料和会话写进 DB,供需要数据库的节点测试。
+#
+# 运行测试脚本时,本文件位于 scripts/manual_tests/ 下,与各测试脚本同目录,
+# 直接 `from itv_fixtures import ...` 即可(Python 会把脚本所在目录加入 sys.path)。
+# ──────────────────────────────────────────────────────────────────────────────
+
+import sys
+import uuid
+
+# 项目根目录加入 sys.path,供 `import backend.*` 使用
+sys.path.insert(0, ".")
+
+from langchain_core.messages import HumanMessage, AIMessage
+
+
+# ──────────────────────────────────────────────────────────────
+# 一、统一测试身份
+# ──────────────────────────────────────────────────────────────
+TENANT_ID       = "tenant_default"
+# 离线测试(不写 DB)直接用这个合成 ID;写 DB 的测试会改用 bidder01 的真实 UUID
+BIDDER_ID      = "stu-itv-test-0001"
+TARGET_POSITION = "AI大模型开发工程师"
+
+
+def new_session_id() -> str:
+    """每次测试用新的 session_id,避免 MemorySaver / DB 旧数据干扰。"""
+    return f"itvtest-{uuid.uuid4().hex[:8]}"
+
+
+def load_env():
+    """加载 .env.local(DeepSeek key / DB 连接等)。"""
+    from dotenv import load_dotenv
+    load_dotenv(".env.local")
+
+
+def section(title: str):
+    """打印分节标题,让测试输出更易读。"""
+    print("\n" + "=" * 66)
+    print(f"  {title}")
+    print("=" * 66)
+
+
+# ──────────────────────────────────────────────────────────────
+# 二、模拟资质材料(AI 大模型开发岗)
+#     结构 == 供应商资质审查 Agent 的 QualificationStructured.model_dump()
+# ──────────────────────────────────────────────────────────────
+
+QUALIFICATION_STRUCTURED = {
+    "name":            "李明",
+    "phone":           "138****6677",
+    "email":           "liming@bid_reviewple.com",
+    "target_position": "AI大模型开发工程师",
+    "education": [
+        {
+            "school":   "华东师范大学",
+            "major":    "计算机科学与技术",
+            "degree":   "本科",
+            "duration": "2021.09 - 2025.06",
+            "gpa":      "3.7/4.0",
+        }
+    ],
+    "skills_raw": "Python、PyTorch、LangChain、Transformer、RAG、LoRA 微调、Milvus、FastAPI、Prompt Engineering",
+    "skills_list": [
+        "Python", "PyTorch", "LangChain", "Transformer",
+        "RAG", "LoRA微调", "Milvus", "FastAPI", "Prompt Engineering",
+    ],
+    "projects": [
+        {
+            "name":       "企业知识库 RAG 问答系统",
+            "role":       "后端开发(核心)",
+            "duration":   "2024.03 - 2024.09",
+            "tech_stack": ["Python", "LangChain", "Milvus", "BGE-M3", "FastAPI"],
+            "description": "面向企业内部文档的检索增强问答系统,支持 PDF/Word 解析、"
+                           "稠密+稀疏混合检索与 BGE-Reranker 精排,最终接入大模型生成答案。",
+            "highlights": [
+                "知识库覆盖 5 万+ 文档分块(chunk)",
+                "引入混合检索 + Reranker 后,检索准确率从 78% 提升到 92%",
+                "P95 响应时间控制在 1.2s 以内",
+            ],
+        },
+        {
+            "name":       "客服领域大模型 LoRA 微调",
+            "role":       "算法开发",
+            "duration":   "2024.10 - 2025.01",
+            "tech_stack": ["PyTorch", "PEFT", "LoRA", "DeepSpeed"],
+            "description": "在 7B 开源基座模型上做 LoRA 微调以适配客服问答领域,"
+                           "构建了 1.2 万条领域指令数据集。",
+            "highlights": [
+                "可训练参数仅占全参数的 0.3%",
+                "领域问答准确率相比基座模型提升约 15%",
+            ],
+        },
+    ],
+    "work_experience": [],
+    "certificates":    ["CET-6", "PyTorch 开发者认证"],
+    "self_intro":      "对大模型应用开发有浓厚兴趣,熟悉 RAG 与微调全流程。",
+}
+
+
+# 答辩 State 直接使用的两个派生字段
+QUALIFICATION_PROJECTS = QUALIFICATION_STRUCTURED["projects"]
+QUALIFICATION_SKILLS   = QUALIFICATION_STRUCTURED["skills_list"]
+
+
+# ──────────────────────────────────────────────────────────────
+# 三、投标人投标响应脚本(一场完整答辩的全部回答)
+# ──────────────────────────────────────────────────────────────
+ANSWER_SCRIPT = [
+    # [0] WARMUP:自我介绍
+    "你好,我是李明,华东师范大学计算机专业应届生。主要技术栈是 Python、PyTorch 和 "
+    "LangChain,做过一个企业知识库 RAG 问答系统,还有一个客服领域的大模型 LoRA 微调项目,"
+    "对大模型应用开发很感兴趣。",
+
+    # [1] TECH:优秀回答(Transformer,应判 excellent)
+    "Transformer 的核心是自注意力机制。Encoder 由多头自注意力和前馈网络堆叠而成,"
+    "每个子层都有残差连接和 LayerNorm;Decoder 多了一个带 mask 的自注意力,防止看到未来 token,"
+    "还有一个对 Encoder 输出做交叉注意力的子层。注意力计算是 softmax(QK^T/√d_k)·V。",
+
+    # [2] TECH:基本回答(过拟合,缺深度,应判 adequate)
+    "过拟合就是模型在训练集上表现很好,但在测试集上效果差。可以用正则化、dropout、"
+    "增加数据来缓解。",
+
+    # [3] TECH:明确不会(应判 no_answer,走快速路径)
+    "不知道",
+
+    # [4] TECH:优秀回答(RAG,应判 excellent)
+    "RAG 是检索增强生成。先把文档切块、向量化存进向量库,用户提问时把问题也向量化,"
+    "做相似度检索召回 top-k 文档,再把文档和问题拼进 prompt 交给大模型生成答案。"
+    "好处是能基于最新的、私有的知识回答,显著减少幻觉。",
+
+    # [5] PROJECT:项目背景与职责
+    "这个 RAG 系统是我主导后端开发的。背景是公司内部文档太多、检索困难。我负责文档解析、"
+    "切块策略、混合检索和 Reranker 精排这部分,知识库大概有 5 万多个 chunk。",
+
+    # [6] PROJECT:深挖(混合检索细节)
+    "我用 BGE-M3 同时生成稠密向量和稀疏向量,稠密向量走 Milvus 的 HNSW 索引,"
+    "稀疏向量做关键词匹配,两路召回后再用 BGE-Reranker 精排取 top-5。"
+    "加了精排之后准确率从 78% 提升到了 92%。",
+
+    # [7] CLOSING:投标人反问
+    "想了解一下团队目前在大模型应用上主要的技术方向,以及新人会接触到哪些项目?",
+
+    # [8] 强制结束
+    "好的,没有其他问题了,结束答辩吧。",
+]
+
+
+# ──────────────────────────────────────────────────────────────
+# 四、固定评分标准库(模拟 load_context 出题结果,供离线测试使用)
+# ──────────────────────────────────────────────────────────────
+def sample_question_bank() -> list:
+    """一份固定的技术评分标准库,每项结构与 load_context 产出的 question_bank 一致。"""
+    qs = [
+        ("请解释 Transformer 中自注意力机制的计算过程。",      ["Transformer", "注意力机制"]),
+        ("过拟合和欠拟合分别是什么?如何缓解过拟合?",          ["机器学习基础"]),
+        ("请描述自回归语言模型的文本生成原理。",                ["语言模型"]),
+        ("RAG(检索增强生成)的基本流程是怎样的?",             ["RAG"]),
+        ("向量相似度检索中,余弦相似度的原理是什么?",          ["向量检索"]),
+        ("LoRA 微调的核心思想是什么?",                         ["LoRA", "微调"]),
+        ("Encoder 和 Decoder 在结构上有什么区别?",            ["Transformer"]),
+        ("什么是 Prompt Engineering?常见技巧有哪些?",         ["Prompt"]),
+    ]
+    return [
+        {"id": f"q_{i}", "content": c, "difficulty": "medium", "tags": t, "asked": False}
+        for i, (c, t) in enumerate(qs)
+    ]
+
+
+# ──────────────────────────────────────────────────────────────
+# 五、DefenseState 快照构造器
+# ──────────────────────────────────────────────────────────────
+def base_state(**overrides) -> dict:
+    """构造一个字段完整的 DefenseState(dict),可用关键字参数覆盖任意字段。"""
+    state = {
+        "messages":            [],
+        "bidder_id":          BIDDER_ID,
+        "tenant_id":           TENANT_ID,
+        "session_id":          new_session_id(),
+        "target_position":     TARGET_POSITION,
+        "qualification_review_id":    None,
+        "qualification_projects":     [],
+        "qualification_skills":       [],
+        "current_stage":       "warmup",
+        "stage_turn_count":    0,
+        "total_turn_count":    0,
+        "max_turns":           40,
+        "question_bank":       [],
+        "current_question":    None,
+        "projects_asked":      [],
+        "last_answer_quality": "adequate",
+        "followup_count":      0,
+        "existing_summary":    None,
+        "should_summarize":    False,
+        "report":              None,
+        "fallback_used":       False,
+        "structured_output":   None,
+    }
+    state.update(overrides)
+    return state
+
+
+def make_state_tech_first() -> dict:
+    """刚从 WARMUP 推进到 TECH_BASE 的第 1 轮(current_question 还是 None)。
+    用于触发 _respond_tech_base 的「评价自我介绍 + 出第一题」路径。"""
+    msgs = [
+        HumanMessage(content="[开始答辩]"),
+        AIMessage(content=f"欢迎来到 {TARGET_POSITION} 岗位的供应商答辩!请先做个简单的自我介绍。"),
+        HumanMessage(content=ANSWER_SCRIPT[0]),
+    ]
+    return base_state(
+        current_stage="tech_base",
+        stage_turn_count=1,
+        total_turn_count=1,
+        current_question=None,
+        question_bank=sample_question_bank(),
+        qualification_projects=QUALIFICATION_PROJECTS,
+        qualification_skills=QUALIFICATION_SKILLS,
+        messages=msgs,
+    )
+
+
+def make_state_tech_followup() -> dict:
+    """技术环节:当前题已问、投标人给出 excellent 回答,触发追问路径。"""
+    bank = sample_question_bank()
+    bank[0]["asked"] = True
+    cur = bank[0]
+    msgs = [
+        AIMessage(content=cur["content"]),
+        HumanMessage(content=ANSWER_SCRIPT[1]),
+    ]
+    return base_state(
+        current_stage="tech_base",
+        stage_turn_count=3,
+        total_turn_count=3,
+        current_question=cur,
+        question_bank=bank,
+        last_answer_quality="excellent",
+        followup_count=0,
+        messages=msgs,
+    )
+
+
+def make_state_tech_next() -> dict:
+    """技术环节:上一题回答 adequate,触发「上题反馈 + 出下一题」换题路径。"""
+    bank = sample_question_bank()
+    bank[1]["asked"] = True
+    cur = bank[1]
+    msgs = [
+        AIMessage(content=cur["content"]),
+        HumanMessage(content=ANSWER_SCRIPT[2]),
+    ]
+    return base_state(
+        current_stage="tech_base",
+        stage_turn_count=5,
+        total_turn_count=5,
+        current_question=cur,
+        question_bank=bank,
+        last_answer_quality="adequate",
+        followup_count=0,
+        messages=msgs,
+    )
+
+
+def make_state_project_new() -> dict:
+    """刚进入 PROJECT 阶段第 1 轮,current_question 为 None,触发「新项目第1问」路径。"""
+    msgs = [
+        AIMessage(content="好的,基础技术问题我们就聊到这里。"),
+        HumanMessage(content=ANSWER_SCRIPT[4]),
+    ]
+    return base_state(
+        current_stage="project",
+        stage_turn_count=1,
+        total_turn_count=9,
+        current_question=None,
+        qualification_projects=QUALIFICATION_PROJECTS,
+        qualification_skills=QUALIFICATION_SKILLS,
+        projects_asked=[],
+        last_answer_quality="adequate",
+        messages=msgs,
+    )
+
+
+def make_state_project_followup() -> dict:
+    """PROJECT 阶段:正在深挖第一个项目,投标人回答后触发追问路径。"""
+    proj = QUALIFICATION_PROJECTS[0]
+    cur = {
+        "content":      f"项目深挖:{proj['name']}",
+        "project_name": proj["name"],
+        "tech_stack":   proj["tech_stack"],
+    }
+    msgs = [
+        AIMessage(content=f"我们聊聊你的「{proj['name']}」项目,背景和你的核心职责是什么?"),
+        HumanMessage(content=ANSWER_SCRIPT[5]),
+    ]
+    return base_state(
+        current_stage="project",
+        stage_turn_count=2,
+        total_turn_count=10,
+        current_question=cur,
+        qualification_projects=QUALIFICATION_PROJECTS,
+        qualification_skills=QUALIFICATION_SKILLS,
+        projects_asked=[proj["name"]],
+        last_answer_quality="adequate",
+        followup_count=0,
+        messages=msgs,
+    )
+
+
+def make_state_closing_opening() -> dict:
+    """刚进入 CLOSING 阶段第 1 轮:评审专家发出反问邀请。"""
+    msgs = [
+        AIMessage(content="项目我们了解得差不多了。"),
+        HumanMessage(content=ANSWER_SCRIPT[6]),
+    ]
+    return base_state(
+        current_stage="closing",
+        stage_turn_count=1,
+        total_turn_count=12,
+        qualification_projects=QUALIFICATION_PROJECTS,
+        messages=msgs,
+    )
+
+
+def make_state_closing_respond() -> dict:
+    """CLOSING 阶段:投标人提了问题,评审专家回应。"""
+    msgs = [
+        AIMessage(content="最后这个环节,你有什么想了解的吗?"),
+        HumanMessage(content=ANSWER_SCRIPT[7]),
+    ]
+    return base_state(
+        current_stage="closing",
+        stage_turn_count=2,
+        total_turn_count=13,
+        messages=msgs,
+    )
+
+
+def make_state_eval_excellent() -> dict:
+    """evaluate_answer 测试:优秀回答(走 Think Tool + LLM 评估)。"""
+    cur = sample_question_bank()[0]
+    msgs = [
+        AIMessage(content=cur["content"]),
+        HumanMessage(content=ANSWER_SCRIPT[1]),
+    ]
+    return base_state(
+        current_stage="tech_base",
+        stage_turn_count=2,
+        total_turn_count=2,
+        current_question=cur,
+        messages=msgs,
+    )
+
+
+def make_state_eval_no_answer() -> dict:
+    """evaluate_answer 测试:明确不会(走快速路径,不调用 LLM)。"""
+    cur = sample_question_bank()[2]
+    msgs = [
+        AIMessage(content=cur["content"]),
+        HumanMessage(content="不知道"),
+    ]
+    return base_state(
+        current_stage="tech_base",
+        stage_turn_count=4,
+        total_turn_count=4,
+        current_question=cur,
+        messages=msgs,
+    )
+
+
+def make_state_for_report() -> dict:
+    """构造一份「已完成答辩」的 State,messages 为完整对话,供 generate_report 测试。"""
+    pairs = [
+        (f"欢迎来到 {TARGET_POSITION} 岗位的供应商答辩!请先自我介绍一下。", ANSWER_SCRIPT[0]),
+        ("好的,你的介绍逻辑清晰。第一题:请解释 Transformer 自注意力机制的计算过程。", ANSWER_SCRIPT[1]),
+        ("回答得很到位。下一题:过拟合和欠拟合分别是什么?如何缓解过拟合?", ANSWER_SCRIPT[2]),
+        ("基本说清了。再来一题:请描述自回归语言模型的生成原理。", ANSWER_SCRIPT[3]),
+        ("没关系,我们换一个。RAG 的基本流程是怎样的?", ANSWER_SCRIPT[4]),
+        ("很好。我们进入项目环节,聊聊你的 RAG 系统,背景和你的职责是什么?", ANSWER_SCRIPT[5]),
+        ("不错。能再具体说说你的混合检索是怎么做的吗?", ANSWER_SCRIPT[6]),
+        ("最后,你有什么想了解的吗?", ANSWER_SCRIPT[7]),
+    ]
+    msgs = [HumanMessage(content="[开始答辩]")]
+    for ai_text, human_text in pairs:
+        msgs.append(AIMessage(content=ai_text))
+        msgs.append(HumanMessage(content=human_text))
+    return base_state(
+        current_stage="finished",
+        stage_turn_count=2,
+        total_turn_count=len(pairs),
+        qualification_projects=QUALIFICATION_PROJECTS,
+        qualification_skills=QUALIFICATION_SKILLS,
+        messages=msgs,
+    )
+
+
+# ──────────────────────────────────────────────────────────────
+# 六、数据库 fixture(供 load_context / save_report / save_memory 测试)
+# ──────────────────────────────────────────────────────────────
+async def seed_qualification(bidder_email: str = "bidder01@tenderagent.local") -> dict:
+    """
+    向 qualification_reviews 写入一条测试资质材料(structured_data = QUALIFICATION_STRUCTURED),
+    返回 {"bidder_id": <真实UUID>, "qualification_review_id": <UUID>}。
+    需 PostgreSQL 已启动且已 seed(存在 bidder01 账号)。
+    """
+    import json
+    from sqlalchemy import text
+    from backend.dependencies import AsyncSessionLocal
+
+    qualification_review_id = str(uuid.uuid4())
+    async with AsyncSessionLocal() as s:
+        row = (await s.execute(
+            text("SELECT id FROM users WHERE email = :e"),
+            {"e": bidder_email},
+        )).mappings().fetchone()
+        if not row:
+            raise RuntimeError(
+                f"未找到用户 {bidder_email},请先运行:python scripts/seed_data.py"
+            )
+        bidder_id = str(row["id"])
+
+        await s.execute(
+            text("""
+                INSERT INTO qualification_reviews
+                    (id, tenant_id, bidder_id, pdf_minio_path, structured_data, status)
+                VALUES
+                    (:id, :tenant_id, :bidder_id, :path, CAST(:structured AS JSONB), 'done')
+                ON CONFLICT (id) DO UPDATE
+                    SET structured_data = EXCLUDED.structured_data
+            """),
+            {
+                "id":         qualification_review_id,
+                "tenant_id":  TENANT_ID,
+                "bidder_id": bidder_id,
+                "path":       "test/fixtures/qualification_ai_dev.pdf",
+                "structured": json.dumps(QUALIFICATION_STRUCTURED, ensure_ascii=False),
+            },
+        )
+        await s.commit()
+
+    return {"bidder_id": bidder_id, "qualification_review_id": qualification_review_id}
+
+
+async def setup_db_fixture(bidder_email: str = "bidder01@tenderagent.local") -> dict:
+    """
+    完整 DB fixture:写入资质材料 + 新建一个 in_progress 答辩会话。
+    返回 {"bidder_id", "session_id", "qualification_review_id", "thread_id"}。
+
+    用于 load_context(读资质材料/会话摘要)、save_report(UPDATE 已有会话)、
+    save_memory(UPSERT 会话)等需要数据库的节点测试。
+    """
+    from sqlalchemy import text
+    from backend.dependencies import AsyncSessionLocal
+    from backend.core.memory import build_thread_id
+
+    seeded     = await seed_qualification(bidder_email)
+    bidder_id = seeded["bidder_id"]
+    session_id = new_session_id()
+    thread_id  = build_thread_id(bidder_id, session_id)
+
+    async with AsyncSessionLocal() as s:
+        await s.execute(
+            text("""
+                INSERT INTO defense_sessions
+                    (id, tenant_id, bidder_id, session_id, thread_id,
+                     target_position, qualification_review_id, status)
+                VALUES
+                    (:id, :tenant_id, :bidder_id, :session_id, :thread_id,
+                     :target, :rrid, 'in_progress')
+                ON CONFLICT (thread_id) DO NOTHING
+            """),
+            {
+                "id":         str(uuid.uuid4()),
+                "tenant_id":  TENANT_ID,
+                "bidder_id": bidder_id,
+                "session_id": session_id,
+                "thread_id":  thread_id,
+                "target":     TARGET_POSITION,
+                "rrid":       seeded["qualification_review_id"],
+            },
+        )
+        await s.commit()
+
+    return {
+        "bidder_id":       bidder_id,
+        "session_id":       session_id,
+        "qualification_review_id": seeded["qualification_review_id"],
+        "thread_id":        thread_id,
+    }
+

+ 102 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/seed_defense_questions.py

@@ -0,0 +1,102 @@
+# seed_defense_questions.py
+# 向 defense_questions 表插入 10 道 AI大模型开发工程师 答辩问题
+# 运行:conda activate edu_agent && python backend/agents/defense/manual_tests/seed_defense_questions.py
+
+import sys, asyncio, json
+sys.path.insert(0, ".")
+
+from dotenv import load_dotenv
+load_dotenv(".env.local")
+
+from sqlalchemy import text
+from backend.dependencies import AsyncSessionLocal
+
+TARGET_POSITION = "AI大模型开发工程师"
+
+QUESTIONS = [
+    {
+        "content": "请解释 Transformer 的自注意力机制(Self-Attention)原理,并说明 Q、K、V 矩阵的含义和计算过程。",
+        "difficulty": "medium",
+        "tags": ["Transformer", "Self-Attention", "深度学习基础"],
+    },
+    {
+        "content": "大模型微调有哪些主流方法?请对比 Full Fine-tuning、LoRA、QLoRA 的适用场景和资源消耗差异。",
+        "difficulty": "medium",
+        "tags": ["微调", "LoRA", "QLoRA", "大模型训练"],
+    },
+    {
+        "content": "什么是 RAG(检索增强生成)?请描述其完整流程,并说明为什么 RAG 比直接 Prompt 更适合处理私域知识问答。",
+        "difficulty": "easy",
+        "tags": ["RAG", "向量检索", "知识库"],
+    },
+    {
+        "content": "向量数据库的作用是什么?Faiss、Milvus、Chroma 各有什么特点,你会如何在生产环境中选型?",
+        "difficulty": "medium",
+        "tags": ["向量数据库", "Faiss", "Milvus", "Chroma"],
+    },
+    {
+        "content": "LLM 推理时的 KV Cache 是什么机制?它如何减少重复计算,对推理速度有什么影响?",
+        "difficulty": "hard",
+        "tags": ["KV Cache", "推理优化", "大模型部署"],
+    },
+    {
+        "content": "请解释 LangChain 中 Chain 和 Agent 的区别,并举例说明你会在什么场景下选择 Agent 而非固定 Chain。",
+        "difficulty": "medium",
+        "tags": ["LangChain", "Agent", "工程实践"],
+    },
+    {
+        "content": "如何评估一个 RAG 系统的效果?你了解哪些 RAG 评测指标(如 Faithfulness、Answer Relevancy),如何用 RAGAS 进行评测?",
+        "difficulty": "hard",
+        "tags": ["RAG评测", "RAGAS", "Faithfulness"],
+    },
+    {
+        "content": "Prompt Engineering 有哪些常用技巧?请对比 Zero-shot、Few-shot、Chain-of-Thought 各自的适用场景。",
+        "difficulty": "easy",
+        "tags": ["Prompt Engineering", "CoT", "Few-shot"],
+    },
+    {
+        "content": "大模型部署时如何做量化(Quantization)?INT8 和 INT4 量化各有什么优缺点,对精度和速度的影响如何?",
+        "difficulty": "hard",
+        "tags": ["量化", "INT8", "INT4", "大模型部署"],
+    },
+    {
+        "content": "LangGraph 与 LangChain 的 AgentExecutor 有什么区别?LangGraph 适合解决什么类型的问题,请举一个实际应用场景。",
+        "difficulty": "medium",
+        "tags": ["LangGraph", "状态机", "多步推理"],
+    },
+]
+
+
+async def main():
+    async with AsyncSessionLocal() as session:
+        async with session.begin():
+            for q in QUESTIONS:
+                await session.execute(
+                    text("""
+                        INSERT INTO defense_questions
+                            (content, difficulty, tags, target_position, tenant_id, is_active)
+                        VALUES
+                            (:content, :difficulty, :tags, :target_position, 'tenant_default', TRUE)
+                    """),
+                    {
+                        "content":         q["content"],
+                        "difficulty":      q["difficulty"],
+                        "tags":            json.dumps(q["tags"], ensure_ascii=False),
+                        "target_position": TARGET_POSITION,
+                    },
+                )
+
+    # 验证写入
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("SELECT id, difficulty, LEFT(content, 30) AS preview FROM defense_questions WHERE target_position = :pos ORDER BY created_at"),
+            {"pos": TARGET_POSITION},
+        )
+        rows = result.mappings().all()
+
+    print(f"\n插入成功,共 {len(rows)} 条评分项:")
+    for i, r in enumerate(rows, 1):
+        print(f"  {i:2d}. [{r['difficulty']:6s}] {r['preview']}...")
+
+
+asyncio.run(main())

+ 85 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/test_bid_review.py

@@ -0,0 +1,85 @@
+import requests, json, time
+
+BASE = 'http://localhost:8000/api/v1'
+
+# 1. 投标人登录
+r = requests.post(f'{BASE}/auth/login',
+    json={'username': 'bidder01', 'password': 'Bidder@123456'})
+bidder_token = r.json()['access_token']
+bidder_headers = {'Authorization': f'Bearer {bidder_token}'}
+print('✅ 投标人登录成功')
+
+# 2. 评审专家登录
+r = requests.post(f'{BASE}/auth/login',
+    json={'username': 'expert01', 'password': 'Expert@123456'})
+expert_token = r.json()['access_token']
+expert_headers = {'Authorization': f'Bearer {expert_token}', 'Content-Type': 'application/json'}
+print('✅ 评审专家登录成功')
+
+# 3. 投标人提交投标文件
+print('\n=== 提交投标文件 ===')
+bid_review_id = 'aaaaaaaa-0000-0000-0000-000000000001'
+with open('/Users/ligang/Desktop/TenderAgent/tests/fixtures/test_bid_review_answer.docx', 'rb') as f:
+    r = requests.post(f'{BASE}/bid_review/submit',
+        headers=bidder_headers,
+        data={'bid_review_id': bid_review_id},
+        files={'file': ('test_bid_review_answer.docx', f, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')})
+print(f'状态码: {r.status_code}')
+if r.status_code != 202:
+    print(f'错误: {r.text}')
+    exit(1)
+data = r.json()
+submission_id = data['submission_id']
+print(f'submission_id: {submission_id}')
+print(f'消息: {data["message"]}')
+
+# 4. 轮询等待 AI 评分完成(状态变为 pending_review)
+print('\n等待 AI 评分完成...')
+for i in range(36):  # 最多等3分钟
+    time.sleep(5)
+    r = requests.get(f'{BASE}/bid_review/pending-reviews', headers=expert_headers)
+    if r.status_code == 200:
+        items = r.json().get('items', [])
+        submission = next((x for x in items if x['submission_id'] == submission_id), None)
+        if submission:
+            print(f'\n  [{i*5+5}s] 待确认列表中找到提交!')
+            print(f'  投标人: {submission["bidder_name"]}')
+            print(f'  AI预评分: {submission["pre_review"]["total_score"]}/{submission["pre_review"]["full_score"]}分')
+            print(f'  需复核题数: {submission["pre_review"]["needs_review_count"]}')
+            if submission["weak_points"]:
+                print(f'  薄弱点: {[wp.get("tag", wp.get("knowledge_tag","?")) for wp in submission["weak_points"][:3]]}')
+            break
+    else:
+        print(f'  [{i*5+5}s] 状态: {r.status_code}')
+else:
+    print('❌ 等待超时')
+    exit(1)
+
+# 5. 评审专家获取详情
+print('\n=== 获取预评分详情 ===')
+r = requests.get(f'{BASE}/bid_review/submissions/{submission_id}/review', headers=expert_headers)
+print(f'状态码: {r.status_code}')
+if r.status_code == 200:
+    detail = r.json()
+    summary = detail['pre_review_summary']
+    print(f'总分: {summary.get("total_score")}/{summary.get("full_score")} ({round(summary.get("score_rate",0)*100)}%)')
+    print(f'需复核: {summary.get("needs_review_count")} 题')
+    print(f'薄弱点数: {len(detail.get("weak_points", []))}')
+    if detail.get("weak_points_summary"):
+        print(f'薄弱点摘要: {detail["weak_points_summary"][:100]}')
+
+# 6. 评审专家 Approve
+print('\n=== 评审专家批准发布 ===')
+r = requests.post(f'{BASE}/bid_review/submissions/{submission_id}/confirm',
+    headers=expert_headers,
+    json={'action': 'approve', 'modifications': []})
+print(f'状态码: {r.status_code}')
+if r.status_code == 200:
+    result = r.json()
+    print(f'✅ 发布成功!')
+    print(f'最终得分: {result["final_score"]}/{result["full_score"]} ({result["score_rate"]*100:.1f}%)')
+    print(f'薄弱点数: {len(result["weak_points"])}')
+    if result.get("weak_points_summary"):
+        print(f'薄弱点摘要: {result["weak_points_summary"][:150]}')
+else:
+    print(f'错误: {r.text}')

+ 81 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/test_bid_review2.py

@@ -0,0 +1,81 @@
+import requests, json, time
+
+BASE = 'http://localhost:8000/api/v1'
+
+# 1. 登录
+r = requests.post(f'{BASE}/auth/login', json={'username': 'bidder01', 'password': 'Bidder@123456'})
+bidder_token = r.json()['access_token']
+bidder_headers = {'Authorization': f'Bearer {bidder_token}'}
+
+r = requests.post(f'{BASE}/auth/login', json={'username': 'expert01', 'password': 'Expert@123456'})
+expert_token = r.json()['access_token']
+expert_headers = {'Authorization': f'Bearer {expert_token}', 'Content-Type': 'application/json'}
+print('✅ 登录成功(投标人+评审专家)')
+
+# 2. 提交投标文件
+print('\n=== 提交投标文件 ===')
+with open('/Users/ligang/Desktop/TenderAgent/tests/fixtures/test_bid_review_answer.docx', 'rb') as f:
+    r = requests.post(f'{BASE}/bid_review/submit',
+        headers=bidder_headers,
+        data={'bid_review_id': 'aaaaaaaa-0000-0000-0000-000000000001'},
+        files={'file': ('test_bid_review_answer.docx', f,
+                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document')})
+print(f'状态码: {r.status_code}')
+if r.status_code != 202:
+    print(f'错误: {r.text}')
+    exit(1)
+submission_id = r.json()['submission_id']
+print(f'submission_id: {submission_id}')
+
+# 3. 轮询,最多等4分钟
+print('\n等待 AI 评分完成(最多4分钟)...')
+for i in range(48):
+    time.sleep(5)
+    r = requests.get(f'{BASE}/bid_review/pending-reviews', headers=expert_headers)
+    if r.status_code == 200:
+        items = r.json().get('items', [])
+        sub = next((x for x in items if x['submission_id'] == submission_id), None)
+        if sub:
+            print(f'\n  [{(i+1)*5}s] ✅ 进入待确认列表!')
+            pr = sub['pre_review']
+            print(f'  AI预评分得分: {pr["total_score"]}/{pr["full_score"]}')
+            print(f'  需复核题数: {pr["needs_review_count"]}')
+            if sub.get('weak_points'):
+                wp_tags = [wp.get('tag', '?') for wp in sub['weak_points'][:3]]
+                print(f'  前3个薄弱点: {wp_tags}')
+            break
+    if (i+1) % 6 == 0:
+        print(f'  [{(i+1)*5}s] 仍在处理中...')
+else:
+    print('❌ 超时,处理未完成')
+    exit(1)
+
+# 4. 评审专家获取详情
+print('\n=== 获取预评分详情 ===')
+r = requests.get(f'{BASE}/bid_review/submissions/{submission_id}/review', headers=expert_headers)
+print(f'状态码: {r.status_code}')
+if r.status_code == 200:
+    d = r.json()
+    s = d['pre_review_summary']
+    print(f'总分: {s["total_score"]}/{s["full_score"]} ({s["score_rate"]*100:.1f}%)')
+    for q in s.get('by_question', [])[:4]:
+        print(f'  Q{q["question_no"]} ({q["question_type"]}): {q.get("score",0)}/{q.get("full_score",0)}分, 需复核={q.get("needs_review",False)}')
+    print(f'薄弱点数: {len(d.get("weak_points", []))}')
+    if d.get('weak_points_summary'):
+        print(f'薄弱点摘要: {d["weak_points_summary"][:150]}')
+
+# 5. 专家确认
+print('\n=== 专家确认发布 ===')
+r = requests.post(f'{BASE}/bid_review/submissions/{submission_id}/confirm',
+    headers=expert_headers,
+    json={'action': 'approve', 'modifications': []})
+print(f'状态码: {r.status_code}')
+if r.status_code == 200:
+    result = r.json()
+    print(f'✅ 发布成功!')
+    print(f'最终得分: {result["final_score"]}/{result["full_score"]} ({result["score_rate"]*100:.1f}%)')
+    print(f'薄弱点数: {len(result["weak_points"])}')
+    if result.get("weak_points_summary"):
+        print(f'摘要: {result["weak_points_summary"][:200]}')
+else:
+    print(f'错误: {r.text[:200]}')

+ 58 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/test_defense.py

@@ -0,0 +1,58 @@
+import requests, json
+
+BASE = 'http://localhost:8000/api/v1'
+
+r = requests.post(f'{BASE}/auth/login',
+    json={'username': 'bidder01', 'password': 'Bidder@123456'})
+token = r.json()['access_token']
+headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
+print('✅ 登录成功')
+
+# 1. 创建答辩会话
+print('\n=== 开始答辩会话 ===')
+r = requests.post(f'{BASE}/defense/sessions', headers=headers,
+    json={'target_position': 'Java后端开发工程师'})
+print(f'状态码: {r.status_code}')
+if r.status_code != 201:
+    print(f'错误: {r.text}')
+    exit(1)
+data = r.json()
+session_id = data['session_id']
+print(f'session_id: {session_id}')
+print(f'评审专家开场白:\n{data["message"]}\n')
+
+# 2. 第一轮回答(暖场阶段)
+print('=== 第1轮对话 ===')
+r = requests.post(f'{BASE}/defense/sessions/{session_id}/chat',
+    headers=headers,
+    json={'message': '你好,我叫王雪纯,是一名应届硕士毕业生,主要做Java后端开发,有项目经验。'})
+print(f'状态码: {r.status_code}')
+if r.status_code != 200:
+    print(f'错误: {r.text}')
+    exit(1)
+data = r.json()
+print(f'阶段: {data["current_stage"]}, 轮次: {data["total_turns"]}')
+print(f'评审专家:\n{data["reply"][:300]}\n')
+
+# 3. 第二轮
+print('=== 第2轮对话 ===')
+r = requests.post(f'{BASE}/defense/sessions/{session_id}/chat',
+    headers=headers,
+    json={'message': '我主要使用Spring Boot、MySQL、Redis,也接触过LangChain做了一些AI应用开发。毕业于北京理工大学,计算机科学专业。'})
+print(f'状态码: {r.status_code}')
+data = r.json()
+print(f'阶段: {data["current_stage"]}, 轮次: {data["total_turns"]}')
+print(f'评审专家:\n{data["reply"][:300]}\n')
+
+# 4. 第三轮
+print('=== 第3轮对话 ===')
+r = requests.post(f'{BASE}/defense/sessions/{session_id}/chat',
+    headers=headers,
+    json={'message': 'Java中HashMap的工作原理?它和Hashtable有什么区别?'})
+print(f'状态码: {r.status_code}')
+data = r.json()
+print(f'阶段: {data["current_stage"]}, 轮次: {data["total_turns"]}, 已结束: {data["is_finished"]}')
+print(f'评审专家:\n{data["reply"][:400]}\n')
+
+print('✅ 供应商答辩 Agent 测试通过!')
+print(f'(session_id={session_id} 可继续追加对话)')

+ 53 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/test_defense_stream.py

@@ -0,0 +1,53 @@
+# scripts/manual_tests/test_defense_stream.py
+# 单独验证 SSE 流式接口(07-13)
+# 前置:postgres + 后端服务运行中。运行:python scripts/manual_tests/test_defense_stream.py
+
+import sys
+import json
+import httpx
+
+sys.path.insert(0, ".")
+
+BASE_URL = "http://localhost:8000/api/v1"
+
+
+def login(username, password):
+    resp = httpx.post(f"{BASE_URL}/auth/login",
+                      json={"username": username, "password": password}, trust_env=False)
+    resp.raise_for_status()
+    return resp.json()["access_token"]
+
+
+if __name__ == "__main__":
+    token   = login("bidder01", "Bidder@123456")
+    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
+
+    # 先创建一个会话(与正常流程一致,AI 大模型岗)
+    resp       = httpx.post(f"{BASE_URL}/defense/sessions", headers=headers,
+                            json={"target_position": "AI大模型开发工程师"}, timeout=30.0, trust_env=False)
+    session_id = resp.json()["session_id"]
+    print(f"session_id: {session_id}")
+    print(f"开场白:{resp.json()['message'][:80]}")
+
+    # 流式发送第一条消息(李明的自我介绍)
+    print("\n--- 流式对话 ---")
+    with httpx.stream(
+        "POST",
+        f"{BASE_URL}/defense/sessions/{session_id}/chat/stream",
+        headers=headers,
+        json={"message": "你好,我是李明,Python/PyTorch/LangChain 方向,做过 RAG 问答和 LoRA 微调项目。"},
+        timeout=30.0,
+    ) as resp:
+        for line in resp.iter_lines():
+            if not line.startswith("data:"):
+                continue
+            data_str = line[len("data:"):].strip()
+            if not data_str:
+                continue
+            event = json.loads(data_str)
+            if event["type"] == "token":
+                print(event["content"], end="", flush=True)
+            elif event["type"] == "done":
+                print(f"\n\n[done] 阶段={event['current_stage']} 总轮数={event['total_turns']}")
+            elif event["type"] == "error":
+                print(f"\n[error] {event['message']}")

+ 29 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/test_qualification.py

@@ -0,0 +1,29 @@
+import requests, json, sys
+
+BASE = 'http://localhost:8000/api/v1'
+
+r = requests.post(f'{BASE}/auth/login',
+    json={'username': 'bidder01', 'password': 'Bidder@123456'})
+token = r.json()['access_token']
+headers = {'Authorization': f'Bearer {token}'}
+print('✅ 登录成功')
+
+review_id = 'e09883d8-572b-40da-82d9-d7f4036936b7'
+r = requests.get(f'{BASE}/qualification/reviews/{review_id}', headers=headers)
+data = r.json()
+
+print(f'\n审查状态: {data["status"]}')
+print(f'综合得分: {data["weighted_score"]} / 100')
+
+print('\n=== 六维度评分(原始结构)===')
+for d in data.get('dimension_scores', []):
+    print(json.dumps(d, ensure_ascii=False, indent=2))
+    print('---')
+
+print('\n=== 问题诊断(第1条原始结构)===')
+issues = data.get('issues', [])
+if issues:
+    print(json.dumps(issues[0], ensure_ascii=False, indent=2))
+
+print('\n=== 整体评价(原始结构)===')
+print(json.dumps(data.get('summary', {}), ensure_ascii=False, indent=2))

+ 62 - 0
TenderAgent完整代码/backend/agents/defense/manual_tests/test_qualification2.py

@@ -0,0 +1,62 @@
+import requests, json, time
+
+BASE = 'http://localhost:8000/api/v1'
+
+r = requests.post(f'{BASE}/auth/login',
+    json={'username': 'bidder01', 'password': 'Bidder@123456'})
+token = r.json()['access_token']
+headers = {'Authorization': f'Bearer {token}'}
+print('✅ 登录成功')
+
+# 上传资质材料
+with open('/Users/ligang/Desktop/TenderAgent/tests/fixtures/资质材料-王雪纯.pdf', 'rb') as f:
+    r = requests.post(f'{BASE}/qualification/upload',
+        headers=headers,
+        files={'file': ('资质材料-王雪纯.pdf', f, 'application/pdf')})
+print(f'上传状态码: {r.status_code}')
+data = r.json()
+review_id = data['review_id']
+print(f'review_id: {review_id}')
+print(f'消息: {data["message"]}')
+
+# 轮询等待完成(最多120秒)
+print('\n等待审查完成...')
+for i in range(24):
+    time.sleep(5)
+    r = requests.get(f'{BASE}/qualification/reviews/{review_id}', headers=headers)
+    data = r.json()
+    status = data.get('status')
+    print(f'  [{i*5+5}s] status={status}')
+    if status == 'done':
+        break
+
+if data.get('status') != 'done':
+    print('❌ 超时,审查未完成')
+    exit(1)
+
+print('\n✅ 资质审查完成!')
+print(f'综合得分: {data["weighted_score"]} / 100')
+
+print('\n=== 六维度评分 ===')
+for d in data.get('dimension_scores', []):
+    print(f'  {d["dimension"]}(权重{int(d["weight"]*100)}%): {d["score"]}分')
+    for issue in d.get('issues', [])[:2]:
+        print(f'    ⚠ {issue}')
+    for s in d.get('suggestions', [])[:1]:
+        print(f'    → {s}')
+
+print('\n=== 问题诊断(前5条)===')
+for i, issue in enumerate(data.get('issues', [])[:5]):
+    print(f'  [{issue["priority"].upper()}] {issue["dimension"]} - {issue["description"][:60]}')
+    print(f'    位置: {issue["location"]}')
+
+print('\n=== 整体评价 ===')
+summary = data.get('summary', {})
+print(f'  综合评语: {summary.get("overall_comment", "")}')
+print(f'  匹配度: {summary.get("fit_assessment", "")}')
+print('  核心亮点:')
+for h in summary.get('highlights', []):
+    print(f'    ✓ {h}')
+print('  主要改进:')
+for c in summary.get('core_improvements', []):
+    print(f'    → {c}')

+ 964 - 0
TenderAgent完整代码/backend/agents/defense/nodes.py

@@ -0,0 +1,964 @@
+# backend/agents/defense/nodes.py
+
+import asyncio
+import json
+import uuid
+
+from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
+from sqlalchemy import text
+
+from backend.agents.defense.state import (
+    DefenseState, DefenseStage, AnswerQuality, DefenseReport,
+)
+from backend.agents.defense.prompts import (
+    SYSTEM_PROMPT, WARMUP_PROMPT, INTRO_EVAL_TECH_FIRST_PROMPT,
+    TECH_BASE_PROMPT, PROJECT_PROMPT, CLOSING_PROMPT,
+    STAGE_TRANSITION_PROMPTS, EVALUATE_ANSWER_PROMPT,
+    EVALUATE_THINK_PROMPT, GENERATE_REPORT_PROMPT,
+    TECH_BASE_GENERATE_PROMPT,
+)
+from langchain_core.messages import BaseMessage
+
+from backend.core.llm_factory import get_llm, get_structured_llm
+from backend.core.memory import (
+    build_thread_id, should_trigger_summary,
+    compress_to_summary, trim_messages_to_window,
+)
+from backend.core.logger import get_logger
+from backend.dependencies import AsyncSessionLocal
+
+logger = get_logger(__name__)            # 本模块日志器
+
+DEFAULT_MAX_TURNS = 40                    # 一场答辩的总轮数上限(兜底防止无限进行)
+MAX_FOLLOWUP_PER_QUESTION = 2            # 同一道题最多追问几次
+
+# 各阶段「最少 / 最多」轮数(check_stage 用,详见 7.5)
+STAGE_MIN_TURNS = {
+    DefenseStage.WARMUP.value:    1,   # 热身至少 1 轮(自我介绍完即可推进)
+    DefenseStage.TECH_BASE.value: 6,   # 技术环节至少 6 轮
+    DefenseStage.PROJECT.value:   2,   # 项目环节至少 2 轮
+    DefenseStage.CLOSING.value:   2,   # 反问环节至少 2 轮
+}
+STAGE_MAX_TURNS = {
+    DefenseStage.WARMUP.value:    4,   # 热身最多 4 轮(避免卡住)
+    DefenseStage.TECH_BASE.value: 12,  # 技术环节最多 12 轮
+    DefenseStage.PROJECT.value:   14,  # 项目环节最多 14 轮
+    DefenseStage.CLOSING.value:   4,   # 反问环节最多 4 轮
+}
+
+
+def _msg_text(msg: BaseMessage) -> str:
+    """安全提取消息文本,兼容 str 和 list(多模态)content。"""
+    content = msg.content                 # 取出消息内容(可能是 str 或 list)
+    if isinstance(content, list):         # 多模态消息:content 是片段列表
+        return "".join(                   # 把各片段的文本拼起来
+            p.get("text", "") if isinstance(p, dict) else str(p)  # dict 取 text 字段,否则 str()
+            for p in content
+        )
+    return str(content)                   # 普通字符串直接返回
+
+
+async def _generate_questions_by_llm(target_position: str, count: int = 8) -> list:
+    """
+    根据目标岗位用 LLM 动态生成技术答辩问题,作为 DB 评分标准库的替代/补充。
+
+    参数:target_position(岗位,决定出题方向)、count(题数,默认 8)
+    返回:评分项字典列表,每项 = {id, content, difficulty, tags, asked};失败返回 []。
+    """
+    try:                                            # 出题失败不应让整个加载崩,包 try
+        llm = get_llm("defense", temperature=0.3) # 取模型(温度 0.3,出题略有多样性)
+        prompt = TECH_BASE_GENERATE_PROMPT.format(  # 拼出题 Prompt
+            position=target_position,               # 填岗位
+            count=count,                            # 填题数
+        )
+        response = await llm.ainvoke([HumanMessage(content=prompt)])  # 调大模型
+        text = _msg_text(response).strip()          # 安全取出回复文本
+
+        # 兼容 LLM 有时用 ```json ... ``` 包裹输出:把里面以 [ 开头的那段抠出来
+        if "```" in text:
+            parts = text.split("```")               # 按围栏切开
+            for part in parts:
+                stripped = part.strip()
+                if stripped.startswith("json"):     # 去掉可能的 "json" 语言标记
+                    stripped = stripped[4:].strip()
+                if stripped.startswith("["):        # 找到以 [ 开头的评分项数组
+                    text = stripped
+                    break
+
+        questions_raw = json.loads(text)            # 解析成 Python 列表
+        result = []                                 # 收集规整后的评分项
+        for i, q in enumerate(questions_raw):       # 逐题处理
+            if not isinstance(q, dict) or not q.get("content"):  # 跳过格式不对/无题干的
+                continue
+            result.append({                         # 规整成统一结构
+                "id":         f"llm_{i}",           # LLM 题用 llm_0/llm_1... 当 id
+                "content":    q["content"],         # 题干
+                "difficulty": q.get("difficulty", "medium"),     # 难度(缺省 medium)
+                "tags":       q.get("tags", [target_position]),  # 标签(缺省用岗位名)
+                "asked":      False,                # 初始都没问过
+            })
+        logger.info("generate_questions_llm.done", count=len(result), position=target_position)
+        return result                               # 返回评分标准库
+    except Exception as e:                          # 出题失败(LLM 报错/JSON 解析失败等)
+        logger.warning("generate_questions_llm.failed", error=str(e))
+        return []                                   # 返回空,让 load_context 降级到纯 DB 评分标准库
+
+
+async def load_context_node(state: DefenseState) -> dict:
+    """
+    上下文加载节点(每轮第一个执行)。
+
+    读取 state:
+        bidder_id / session_id        定位会话(拼 thread_id)
+        total_turn_count               判断是否首轮(==0 即首轮)
+        qualification_review_id               首轮用它读资质材料联动数据(可为空)
+        target_position                首轮用它生成/查询评分标准库
+        qualification_projects / qualification_skills 首轮的兜底初始值
+    返回(合并进 state):
+        首轮:current_stage / 各计数 / question_bank / qualification_projects / qualification_skills
+              / existing_summary 等一整套初始化
+        非首轮:只刷新 existing_summary
+
+    每轮都执行:加载历史摘要(from DB)。
+    首轮额外执行:初始化 State、加载资质材料联动数据、从评分标准库拉题。
+    """
+    bidder_id = state.get("bidder_id") or ""       # 取投标人 ID
+    session_id = state.get("session_id") or ""       # 取会话 ID
+    if not bidder_id or not session_id:             # 缺 ID 无法定位会话 → 返回空
+        logger.error("load_context.missing_ids", state_keys=list(state.keys()))
+        return {}
+    thread_id = build_thread_id(bidder_id, session_id)  # 拼 thread_id(摘要表/检查点的 key)
+
+    is_first_turn = state.get("total_turn_count", 0) == 0  # 总轮数为 0 = 首轮
+    updates: dict = {}                               # 收集要写回 state 的更新
+
+    # ── 内部查询函数(各自独立 session,可并行执行)──────────
+
+    async def _fetch_qualification(qualification_review_id: str) -> dict:
+        # 按 qualification_review_id 去 qualification_reviews 表读资质材料结构化数据(第4章资质材料Agent写的)
+        try:
+            async with AsyncSessionLocal() as s:     # 独立 DB 会话
+                result = await s.execute(
+                    text("SELECT structured_data FROM qualification_reviews WHERE id = :id"),
+                    {"id": qualification_review_id},
+                )
+                row = result.mappings().fetchone()
+            if row and row["structured_data"]:       # 读到数据
+                structured = (                       # structured_data 可能已是 dict 或 JSON 字符串
+                    row["structured_data"]
+                    if isinstance(row["structured_data"], dict)
+                    else json.loads(row["structured_data"])
+                )
+                return {                             # 只取项目和技能两项
+                    "qualification_projects": structured.get("projects", []),
+                    "qualification_skills":   structured.get("skills_list", []),
+                }
+        except Exception as e:                       # 读失败不影响答辩,记日志返回空
+            logger.warning("load_context.qualification_load_failed", error=str(e))
+        return {}
+
+    async def _fetch_questions(target: str) -> list:
+        # 从 defense_questions 表查岗位评分标准库(匹配该岗位或 general 通用题)
+        try:
+            async with AsyncSessionLocal() as s:
+                result = await s.execute(
+                    text("""
+                        SELECT id, content, difficulty, tags
+                        FROM defense_questions
+                        WHERE (target_position = :target OR target_position = 'general')
+                          AND is_active = TRUE
+                        ORDER BY
+                            CASE difficulty
+                                WHEN 'medium' THEN 1
+                                WHEN 'easy'   THEN 2
+                                ELSE 3
+                            END,
+                            RANDOM()
+                        LIMIT 20
+                    """),
+                    {"target": target},
+                )
+                rows = result.mappings().all()
+            return [                                 # 规整成统一评分项结构
+                {
+                    "id":         str(r["id"]),
+                    "content":    r["content"],
+                    "difficulty": r["difficulty"],
+                    "tags":       r["tags"] if isinstance(r["tags"], list) else (r["tags"] or []),
+                    "asked":      False,             # 初始都没问过
+                }
+                for r in rows
+            ]
+        except Exception as e:
+            logger.warning("load_context.question_bank_failed", error=str(e))
+            return []
+
+    async def _fetch_summary() -> str | None:
+        # 读历史对话摘要(长对话压缩后存在 defense_sessions.summary)
+        try:
+            async with AsyncSessionLocal() as s:
+                result = await s.execute(
+                    text("SELECT summary FROM defense_sessions WHERE thread_id = :tid"),
+                    {"tid": thread_id},
+                )
+                row = result.mappings().fetchone()
+            return row["summary"] if row else None
+        except Exception as e:
+            logger.warning("load_context.summary_load_failed", error=str(e))
+            return None
+
+    # ── 并行执行所有查询 ─────────────────────────────────────
+    if is_first_turn:                                # 首轮:完整初始化
+        updates.update({                             # 一次性写入所有起始字段
+            "current_stage":       DefenseStage.WARMUP.value,   # 起始阶段 = 热身
+            "stage_turn_count":    0,                # 当前阶段轮数清零
+            "total_turn_count":    0,                # 总轮数清零
+            "followup_count":      0,                # 追问次数清零
+            "projects_asked":      [],               # 已深挖项目清空
+            "last_answer_quality": AnswerQuality.ADEQUATE.value,  # 上轮质量给中性初值
+            "max_turns":           DEFAULT_MAX_TURNS,# 轮数上限 40
+            "report":              None,             # 报告未生成
+            "should_summarize":    False,            # 暂不压缩
+            "qualification_projects":     state.get("qualification_projects") or [],  # 资质审查项目兜底空
+            "qualification_skills":       state.get("qualification_skills") or [],    # 资质材料技能兜底空
+        })
+
+        qualification_review_id = state.get("qualification_review_id")  # 资质材料联动 ID(可能为空)
+        target = state.get("target_position", "")         # 目标岗位
+
+        async def _noop_qualification():                    # 没有资质材料 ID 时的占位协程(返回空)
+            return {}
+
+        # 四路 I/O 各起一个协程:资质材料 / DB评分标准库 / LLM出题 / 历史摘要
+        qualification_coro    = _fetch_qualification(qualification_review_id) if qualification_review_id else _noop_qualification()
+        questions_coro = _fetch_questions(target)
+        llm_q_coro     = _generate_questions_by_llm(target)
+        summary_coro   = _fetch_summary()
+
+        import asyncio
+        # gather 同时发起四路、等全部完成(总耗时≈最慢的 LLM 出题,详见 7.4.4)
+        qualification_data, db_questions, llm_questions, existing_summary = await asyncio.gather(
+            qualification_coro, questions_coro, llm_q_coro, summary_coro
+        )
+
+        # LLM 生成题优先(岗位精准),DB 题兜底/补充
+        if llm_questions:
+            question_bank = llm_questions + (db_questions if db_questions else [])  # LLM 题在前,DB 题补后
+        else:
+            question_bank = db_questions             # LLM 出题失败 → 纯用 DB 题
+
+        if qualification_data:                              # 读到了资质材料联动数据
+            updates["qualification_projects"] = qualification_data.get("qualification_projects", [])
+            updates["qualification_skills"]   = qualification_data.get("qualification_skills", [])
+            logger.info("load_context.qualification_loaded", projects=len(updates["qualification_projects"]))
+
+        updates["question_bank"]    = question_bank  # 写入评分标准库
+        updates["existing_summary"] = existing_summary  # 写入历史摘要
+        logger.info("load_context.question_bank_loaded", count=len(question_bank))
+    else:                                            # 非首轮:只刷新摘要
+        updates["existing_summary"] = await _fetch_summary()
+
+    return updates                                   # 返回更新,写回 state
+
+
+async def check_stage_node(state: DefenseState) -> dict:
+    """
+    阶段推进节点(纯逻辑,不调 LLM):判断当前阶段是否要推进 / 强制结束。
+
+    读取 state:
+        current_stage / stage_turn_count / total_turn_count / max_turns / messages
+        (TECH_BASE 还看 question_bank,PROJECT 还看 qualification_projects/projects_asked)
+    返回(写回 state):
+        推进时:{current_stage: 新阶段, stage_turn_count:0, followup_count:0, current_question:None}
+        不推进:{}(空 dict,什么都不改)
+
+    阶段推进条件:
+        WARMUP → TECH_BASE: turns >= 1(投标人完成自我介绍)
+        TECH_BASE → PROJECT: stage_turns >= 6 或评分标准库问完
+        PROJECT → CLOSING:   stage_turns >= 2 或项目全覆盖
+        任意 → FINISHED(强制): total_turns >= max-2 或发送结束关键词
+        CLOSING → FINISHED:  stage_turns >= 2
+    """
+    current_stage = state.get("current_stage", DefenseStage.WARMUP.value)  # 当前阶段
+    stage_turns   = state.get("stage_turn_count", 0)    # 当前阶段已进行轮数
+    total_turns   = state.get("total_turn_count", 0)    # 总轮数
+    max_turns     = state.get("max_turns", DEFAULT_MAX_TURNS)  # 轮数上限
+    messages      = state.get("messages", [])           # 对话消息列表
+
+    # 已结束则不再处理(直接交给后面的条件路由去生成报告)
+    if current_stage == DefenseStage.FINISHED.value:
+        return {}
+
+    # 从后往前找投标人最后一条消息(用于检测"结束答辩"关键词)
+    latest_bidder_msg = ""
+    for msg in reversed(messages):
+        if isinstance(msg, HumanMessage):               # 找到第一条 Human 消息
+            latest_bidder_msg = _msg_text(msg)
+            break
+
+    # 强制结束判断:总轮数快到上限,或投标人主动说要结束 → 直接跳 FINISHED(跳过 CLOSING)
+    force_end_keywords = ["结束答辩", "不想继续", "答辩结束", "结束吧", "到此为止"]
+    is_forced_end = (
+        total_turns >= max_turns - 2                    # 轮数 ≥ 上限-2
+        or any(kw in latest_bidder_msg for kw in force_end_keywords)  # 或命中关键词
+    )
+
+    if is_forced_end:                                   # 触发强制结束
+        logger.info("check_stage.forced_to_finished", reason="keyword_or_max_turns")
+        return {"current_stage": DefenseStage.FINISHED.value}
+
+    # CLOSING → FINISHED(反问环节满 2 轮就结束)
+    if current_stage == DefenseStage.CLOSING.value:
+        if stage_turns >= STAGE_MIN_TURNS[DefenseStage.CLOSING.value]:
+            logger.info("check_stage.advance", from_stage="closing", to_stage="finished")
+            return {"current_stage": DefenseStage.FINISHED.value}
+        return {}                                       # 还没满 2 轮,继续 closing
+
+    # 常规阶段推进:取当前阶段的最小/最大轮数
+    stage_min = STAGE_MIN_TURNS.get(current_stage, 2)   # 至少问几轮才允许推进
+    stage_max = STAGE_MAX_TURNS.get(current_stage, 10)  # 问满几轮就强制推进
+
+    should_advance = False
+    if stage_turns >= stage_max:                        # 到上限 → 强制推进
+        should_advance = True
+    elif stage_turns >= stage_min:                      # 到下限 → 看是否满足内容条件
+        should_advance = _check_advance_condition(current_stage, state, latest_bidder_msg)
+
+    if should_advance:                                  # 决定推进
+        next_stage = _next_stage(current_stage)         # 算出下一阶段
+        logger.info("check_stage.advance", from_stage=current_stage, to_stage=next_stage)
+        return {
+            "current_stage":    next_stage,             # 切到新阶段
+            "stage_turn_count": 0,                      # 新阶段轮数从 0 开始
+            "followup_count":   0,                      # 追问计数清零
+            "current_question": None,                   # 清掉当前题,让新阶段重新选题
+        }
+
+    return {}                                           # 不推进,保持当前阶段
+
+def _check_advance_condition(stage: str, state: DefenseState, latest_msg: str) -> bool:
+    """判断当前阶段「内容上」是否满足推进条件(轮数已达下限后才调用)。"""
+    if stage == DefenseStage.WARMUP.value:
+        # WARMUP min=1,投标人发任意消息即视为完成自我介绍,立即推进
+        return True
+
+    if stage == DefenseStage.TECH_BASE.value:
+        bank = state.get("question_bank", [])                       # 取评分标准库
+        asked_count = sum(1 for q in bank if q.get("asked", False)) # 数已问过的题
+        return asked_count >= min(8, len(bank))   # 至少问过 8 道题(或评分标准库全问完)
+
+    if stage == DefenseStage.PROJECT.value:
+        projects       = state.get("qualification_projects", [])           # 资质审查项目
+        projects_asked = state.get("projects_asked", [])            # 已深挖的项目名
+        if not projects:
+            return True   # 无资质审查项目,直接推进
+        return len(projects_asked) >= len(projects)  # 所有项目都深挖过
+
+    return False                                                    # 其它阶段不在这里判断
+
+
+def _next_stage(current: str) -> str:
+    """返回下一个阶段的枚举值字符串(按固定顺序往后走一格)。"""
+    order = [                                                       # 阶段固定顺序
+        DefenseStage.WARMUP.value,
+        DefenseStage.TECH_BASE.value,
+        DefenseStage.PROJECT.value,
+        DefenseStage.CLOSING.value,
+        DefenseStage.FINISHED.value,
+    ]
+    try:
+        idx = order.index(current)                                 # 找当前阶段位置
+        return order[idx + 1] if idx + 1 < len(order) else DefenseStage.FINISHED.value  # 下一个;到尾就 FINISHED
+    except ValueError:                                             # current 不在表里(异常)
+        return DefenseStage.CLOSING.value                        # 兜底切到 closing
+
+
+def _get_last_bidder_response(messages: list) -> str:
+    """提取消息列表中最后一条投标人(Human)消息的文本。"""
+    for msg in reversed(messages):                                 # 从后往前找
+        if isinstance(msg, HumanMessage):
+            return _msg_text(msg)
+    return ""                                                      # 没有投标人消息就返回空串
+
+async def evaluate_answer_node(state: DefenseState) -> dict:
+    """
+    评估投标人上一轮回答质量(EXCELLENT / ADEQUATE / WEAK / NO_ANSWER)。
+
+    读取 state:
+        total_turn_count / current_stage  判断是否跳过评估
+        messages                          取投标人最后一条回答
+        current_question                  取当前题干(评估时作上下文)
+        stage_turn_count                  用于 +1
+    返回(写回 state):
+        last_answer_quality  本轮质量标签(给 generate_response 决定追问/换题)
+        total_turn_count / stage_turn_count  各 +1(轮次真实发生)
+
+    首轮和热身阶段不评估,直接返回 ADEQUATE。
+    """
+    total_turns   = state.get("total_turn_count", 0)    # 当前总轮数
+    current_stage = state.get("current_stage", DefenseStage.WARMUP.value)  # 当前阶段
+    messages      = state.get("messages", [])           # 对话消息
+
+    # 首轮或热身阶段跳过评估:直接给中性标签 ADEQUATE,但计数照常 +1
+    if total_turns == 0 or current_stage == DefenseStage.WARMUP.value:
+        return {
+            "last_answer_quality": AnswerQuality.ADEQUATE.value,    # 默认质量
+            "total_turn_count":    total_turns + 1,                # 总轮数 +1
+            "stage_turn_count":    state.get("stage_turn_count", 0) + 1,  # 阶段轮数 +1
+        }
+    bidder_response = _get_last_bidder_response(messages)   # 取投标人最后一条回答文本
+
+    # 明确未作答:空回答,或直说"不知道/不清楚/没学过" → 直接 NO_ANSWER,省一次 LLM
+    if not bidder_response.strip() or bidder_response.strip() in ["不知道", "不清楚", "没学过"]:
+        return {
+            "last_answer_quality": AnswerQuality.NO_ANSWER.value,  # 标记未作答
+            "total_turn_count":    total_turns + 1,               # 计数照常 +1
+            "stage_turn_count":    state.get("stage_turn_count", 0) + 1,
+        }
+
+    current_q     = state.get("current_question") or {}   # 当前题(可能为空 dict)
+    question_text = current_q.get("content", "上一个问题") # 题干(缺省占位)
+    answer_text   = bidder_response[:800]                  # 回答截断到 800 字,控制 token
+
+    # ── 第一步:Think Tool 推理分析(不约束格式,让 LLM 写判断依据)──
+    reasoning_trace = ""
+    try:
+        think_prompt = EVALUATE_THINK_PROMPT.format(      # 拼推理 Prompt
+            question=question_text,
+            answer=answer_text,
+        )
+        think_llm  = get_llm("qa", temperature=0)         # 取模型(温度0,评估要稳定)
+        think_resp = await think_llm.ainvoke([HumanMessage(content=think_prompt)])  # 调 LLM 推理
+        reasoning_trace = _msg_text(think_resp).strip()   # 取出推理文本("内心独白")
+        logger.debug("evaluate_think.done", stage=current_stage)
+    except Exception as e:                                # 推理失败不致命,继续走主评估
+        logger.warning("evaluate_think.failed", error=str(e))
+
+    # ── 第二步:主评估,把第一步的推理结论注入 prompt 末尾 ───────
+    think_context = (
+        f"\n\n【评估前分析】\n{reasoning_trace}" if reasoning_trace else ""  # 有推理才追加
+    )
+    prompt = EVALUATE_ANSWER_PROMPT.format(               # 拼主评估 Prompt
+        question=question_text,
+        answer=answer_text,
+    ) + think_context                                     # 末尾接上推理依据
+
+    try:
+        llm = get_llm("qa", temperature=0)                # 同样温度0
+        response = await llm.ainvoke([HumanMessage(content=prompt)])  # 调 LLM 打标签
+        quality_str = _msg_text(response).strip().lower() # 取文本转小写,便于匹配
+
+        # 用 in 匹配(LLM 可能带空格/换行),命中哪个就用哪个标签
+        if "excellent" in quality_str or "优秀" in quality_str:
+            quality = AnswerQuality.EXCELLENT.value
+        elif "weak" in quality_str or "较弱" in quality_str:
+            quality = AnswerQuality.WEAK.value
+        elif "no_answer" in quality_str or "未作答" in quality_str:
+            quality = AnswerQuality.NO_ANSWER.value
+        else:
+            quality = AnswerQuality.ADEQUATE.value        # 兜底:模糊情况按基本及格
+    except Exception as e:                                # 评估失败 → 兜底 ADEQUATE
+        logger.warning("evaluate_answer.failed", error=str(e))
+        quality = AnswerQuality.ADEQUATE.value
+
+    logger.info(
+        "evaluate_answer.done",
+        stage=current_stage,
+        quality=quality,
+        total_turns=total_turns + 1,
+    )
+
+    return {
+        "last_answer_quality": quality,                   # 本轮质量标签
+        "total_turn_count":    total_turns + 1,           # 总轮数 +1
+        "stage_turn_count":    state.get("stage_turn_count", 0) + 1,  # 阶段轮数 +1
+    }
+async def generate_response_node(state: DefenseState) -> dict:
+    """
+    分派主函数:根据当前阶段调用对应子函数生成评审专家回应。
+
+    读取 state:
+        current_stage    决定分派到哪个 _respond_*
+        stage_turn_count 判断是否刚切换阶段(==1 → 插过渡语)
+        messages         传给子函数 + 判断是否触发摘要
+    返回(写回 state):
+        messages          评审专家这一轮的回复(AIMessage)
+        should_summarize  本轮是否要触发摘要压缩
+        以及各 _respond_* 子函数返回的字段(如 current_question / projects_asked / followup_count)
+
+    PROJECT/CLOSING 阶段刚切换时(stage_turns==1)插入静态过渡语;
+    TECH_BASE 的过渡(介绍评价+第一题)由 _respond_tech_base 内部处理。
+    """
+    current_stage = state.get("current_stage", DefenseStage.WARMUP.value)  # 当前阶段
+    stage_turns   = state.get("stage_turn_count", 0)    # 当前阶段轮数
+    messages      = state.get("messages", [])           # 对话消息
+
+    # 阶段刚切换时(stage_turns==1)插入静态过渡语;只 PROJECT 和 CLOSING 用
+    transition_prefix = ""
+    if stage_turns == 1 and current_stage not in (
+        DefenseStage.WARMUP.value, DefenseStage.TECH_BASE.value
+    ):
+        transition_prefix = STAGE_TRANSITION_PROMPTS.get(current_stage, "")  # 取该阶段过渡语
+
+    # 按阶段分派到对应子函数,各自返回 (回复文本, 要写回的 updates)
+    if current_stage == DefenseStage.WARMUP.value:
+        response_text, updates = await _respond_warmup(state)
+    elif current_stage == DefenseStage.TECH_BASE.value:
+        response_text, updates = await _respond_tech_base(state)
+    elif current_stage == DefenseStage.PROJECT.value:
+        response_text, updates = await _respond_project(state)
+    elif current_stage == DefenseStage.CLOSING.value:
+        response_text, updates = await _respond_closing(state)
+    else:                                               # FINISHED 等:兜底文案
+        response_text = "感谢您参加本次供应商答辩,请稍等,正在生成评估报告..."
+        updates = {}
+
+    if transition_prefix:                               # 有过渡语就拼到回复开头
+        response_text = transition_prefix + "\n\n" + response_text
+
+    updates["messages"]         = [AIMessage(content=response_text)]  # 评审专家回复(追加进 messages)
+    updates["should_summarize"] = should_trigger_summary(messages)   # 判断是否该压缩摘要
+
+    logger.info(
+        "generate_response.done",
+        stage=current_stage,
+        response_length=len(response_text),
+    )
+
+    return updates                                      # 返回更新,写回 state
+
+
+def _build_system_with_summary(state: DefenseState) -> str:
+    """构建包含历史摘要的 System Prompt(有摘要就追加,让评审专家"记得"早前对话)。"""
+    position = state.get("target_position", "软件工程师")  # 岗位
+    system   = SYSTEM_PROMPT.format(position=position)    # 渲染系统提示词
+    summary  = state.get("existing_summary")              # 历史摘要(可能为空)
+    if summary:                                           # 有摘要才追加
+        system += f"\n\n【投标人答辩历史摘要】\n{summary}"
+    return system
+
+
+def _build_messages(
+    system: str,            # System Prompt 文本
+    history: list,          # 完整对话历史
+    current_prompt: str,    # 本轮要让 LLM 做的"任务描述"
+    window: int = 10,       # 滑动窗口大小(保留最近几条历史)
+) -> list:
+    """
+    构建发送给 LLM 的消息列表:
+        [SystemMessage] + 滑动窗口历史(排除最后一条投标人消息)+ current_prompt
+    """
+    result = [SystemMessage(content=system)]             # 第一条永远是 System
+    # history[:-1] 排除最后一条投标人消息(它已在 evaluate_answer 处理过,且 current_prompt 已涵盖)
+    windowed = trim_messages_to_window(history[:-1], window_size=window)  # 截取最近 window 条
+    for msg in windowed:
+        if not isinstance(msg, SystemMessage):           # 跳过历史里的 System(避免重复)
+            result.append(msg)
+    result.append(HumanMessage(content=current_prompt))  # 末尾追加本轮任务描述
+    return result
+
+async def _respond_warmup(state: DefenseState) -> tuple[str, dict]:
+    """破冰热身阶段:只生成欢迎开场白,邀请自我介绍(WARMUP 仅 1 轮)。"""
+    messages = state.get("messages", [])                 # 对话历史
+    position = state.get("target_position", "软件工程师") # 岗位
+    system   = _build_system_with_summary(state)         # 带摘要的 System Prompt
+
+    prompt = WARMUP_PROMPT["opening"].format(position=position)  # 开场白 Prompt
+    llm = get_llm("defense", temperature=0.7)          # 温度0.7,开场白更自然
+    response = await llm.ainvoke(_build_messages(system, messages, prompt, window=4))  # 调 LLM
+    return _msg_text(response).strip(), {}               # 返回(回复文本, 空updates)
+
+async def _respond_tech_base(state: DefenseState) -> tuple[str, dict]:
+    """
+    技术基础阶段:出题或追问。
+
+    读取 state:
+        last_answer_quality  上轮质量(EXCELLENT 才追问)
+        followup_count       当前题已追问次数(上限 2)
+        current_question     当前题(None 表示要出新题)
+        question_bank        评分标准库(按 asked 标记选下一题)
+        stage_turn_count     ==1 表示刚从 WARMUP 切来
+        target_position      岗位
+    返回 (回复文本, updates),updates 可能含:
+        question_bank(标记 asked)/ current_question / followup_count
+    """
+    messages       = state.get("messages", [])           # 对话历史
+    quality        = state.get("last_answer_quality", AnswerQuality.ADEQUATE.value)  # 上轮质量
+    followup_count = state.get("followup_count", 0)      # 当前题已追问次数
+    current_q      = state.get("current_question")       # 当前题
+    bank           = state.get("question_bank", [])      # 评分标准库
+    stage_turns    = state.get("stage_turn_count", 0)    # 本阶段轮数
+    position       = state.get("target_position", "软件工程师")  # 岗位
+    system         = _build_system_with_summary(state)   # 带摘要的 System Prompt
+    updates: dict  = {}                                  # 收集要写回的字段
+
+    # ── 路径1·特殊:刚从 WARMUP 推进而来(stage_turns==1,尚无 current_question)──
+    if stage_turns == 1 and current_q is None:
+        unasked = [q for q in bank if not q.get("asked", False)]  # 未问过的题
+        if not unasked:                                  # 评分标准库空:给一句兜底
+            return ("感谢你的自我介绍!接下来我们开始技术基础环节。", {})
+
+        first_q  = unasked[0]                            # 取第一道题
+        new_bank = [                                     # 把这道题标记成 asked=True(生成新list)
+            {**q, "asked": True} if q["id"] == first_q["id"] else q
+            for q in bank
+        ]
+        updates["question_bank"]    = new_bank           # 写回更新后的评分标准库
+        updates["current_question"] = first_q            # 设为当前题
+        updates["followup_count"]   = 0                  # 追问计数清零
+
+        intro_text = _get_last_bidder_response(messages)  # 投标人的自我介绍文本
+        prompt = INTRO_EVAL_TECH_FIRST_PROMPT.format(    # 过渡 Prompt:评价介绍 + 问第一题
+            intro=intro_text[:500],
+            position=position,
+            first_question=first_q["content"],
+        )
+        llm = get_llm("defense", temperature=0.5)      # 温度0.5
+        response = await llm.ainvoke(_build_messages(system, messages, prompt, window=4))
+        return _msg_text(response).strip(), updates
+
+    # ── 判断是否追问当前题:只有回答 EXCELLENT 且没追问满 2 次才追问 ──
+    should_followup = (
+        current_q is not None
+        and followup_count < MAX_FOLLOWUP_PER_QUESTION
+        and quality == AnswerQuality.EXCELLENT.value     # 技术环节门槛高:仅 EXCELLENT
+    )
+
+    if should_followup:                                  # 路径2·追问当前题
+        tech_stack_str = ", ".join(current_q.get("tags", []))  # 把标签拼成技术栈串
+        prompt = TECH_BASE_PROMPT["followup"].format(
+            question=current_q["content"],
+            answer=_get_last_bidder_response(messages)[:600],
+            quality=quality,
+            tech_stack=tech_stack_str,
+        )
+        updates["followup_count"] = followup_count + 1   # 追问次数 +1
+    else:                                                # 路径3·出下一道题
+        unasked = [q for q in bank if not q.get("asked", False)]  # 未问过的题
+        if not unasked:                                  # 题问完了 → 提示进入项目环节
+            return (
+                "好的,基础技术问题我们就聊到这里,接下来我想了解一下你的项目案例。",
+                {"current_question": None},              # 清空当前题
+            )
+
+        next_q = unasked[0]                              # 取下一道题
+        new_bank = [                                     # 标记成 asked=True
+            {**q, "asked": True} if q["id"] == next_q["id"] else q
+            for q in bank
+        ]
+        updates["question_bank"]    = new_bank           # 写回评分标准库
+        updates["current_question"] = next_q             # 设为当前题
+        updates["followup_count"]   = 0                  # 追问计数清零
+
+        prev_question = current_q["content"] if current_q else ""  # 上一题题干
+        prev_answer   = _get_last_bidder_response(messages)[:600]    # 上一题回答
+        prompt = TECH_BASE_PROMPT["ask_with_feedback"].format(     # 反馈上题 + 问下题
+            question=prev_question,
+            answer=prev_answer,
+            quality=quality,
+            next_question=next_q["content"],
+        )
+
+    llm = get_llm("defense", temperature=0.4)          # 温度0.4
+    response = await llm.ainvoke(_build_messages(system, messages, prompt, window=8))  # window=8
+    return _msg_text(response).strip(), updates          # 返回(回复文本, updates)
+
+async def _respond_project(state: DefenseState) -> tuple[str, dict]:
+    """
+    项目深挖阶段:基于资质审查项目针对性追问。
+
+    读取 state:
+        last_answer_quality  上轮质量(EXCELLENT 或 ADEQUATE 都可追问,门槛比技术环节低)
+        followup_count       当前项目已追问次数(上限 2)
+        qualification_projects      资质审查项目列表
+        projects_asked       已深挖过的项目名
+        current_question     当前正在深挖的项目(动态构造的 dict)
+        target_position      岗位
+    返回 (回复文本, updates),updates 可能含:
+        followup_count / current_question / projects_asked
+    """
+    messages       = state.get("messages", [])           # 对话历史
+    quality        = state.get("last_answer_quality", AnswerQuality.ADEQUATE.value)  # 上轮质量
+    followup_count = state.get("followup_count", 0)      # 当前项目已追问次数
+    projects       = state.get("qualification_projects", [])    # 资质审查项目
+    projects_asked = state.get("projects_asked", [])     # 已深挖的项目名
+    current_q      = state.get("current_question")       # 当前项目(dict)
+    position       = state.get("target_position", "工程师")  # 岗位
+    system         = _build_system_with_summary(state)   # 带摘要的 System Prompt
+    updates: dict  = {}                                  # 收集要写回的字段
+
+    if projects:                                         # 有资质材料联动
+        # 找下一个还没深挖过的项目
+        next_project = None
+        for p in projects:
+            if p.get("name") not in projects_asked:
+                next_project = p
+                break
+
+        # 项目环节追问门槛更低:EXCELLENT 或 ADEQUATE 均可追问
+        should_followup = (
+            current_q is not None
+            and followup_count < MAX_FOLLOWUP_PER_QUESTION
+            and quality in (AnswerQuality.EXCELLENT.value, AnswerQuality.ADEQUATE.value)
+        )
+
+        if should_followup:                              # 路径1·追问当前项目
+            tech_stack_str = ", ".join(current_q.get("tech_stack", []))  # 技术栈串
+            prompt = PROJECT_PROMPT["followup_with_feedback"].format(
+                project_name=current_q.get("project_name", ""),
+                question=current_q["content"],
+                answer=_get_last_bidder_response(messages)[:600],
+                quality=quality,
+                tech_stack=tech_stack_str,
+                question_num=followup_count + 1,
+            )
+            updates["followup_count"] = followup_count + 1   # 追问次数 +1
+
+        elif next_project:                               # 路径2·切到新项目,问第1问
+            tech_stack_str = ", ".join(next_project.get("tech_stack", []))
+            highlights_str = "\n".join(                  # 把亮点拼成多行
+                f"- {h}" for h in next_project.get("highlights", [])
+            ) or "(无量化数据)"
+
+            prompt = PROJECT_PROMPT["new_project"].format(
+                project_name=next_project["name"],
+                project_role=next_project.get("role", "开发者"),
+                tech_stack=tech_stack_str,
+                highlights=highlights_str,
+                description=next_project.get("description", "")[:200],
+            )
+            updates["current_question"] = {              # 动态构造当前项目 dict(非评分标准库题)
+                "content":      f"项目深挖:{next_project['name']}",
+                "project_name": next_project["name"],
+                "tech_stack":   next_project.get("tech_stack", []),
+            }
+            updates["projects_asked"] = projects_asked + [next_project["name"]]  # 记入已深挖
+            updates["followup_count"] = 0                # 新项目追问计数清零
+
+        else:                                            # 路径3·所有项目都深挖完 → 综合题
+            prompt = PROJECT_PROMPT["synthesis"].format(position=position)
+
+    else:                                                # 路径4·无资质材料联动
+        # 基于投标人自述出项目题(或架构设计题)
+        prompt = PROJECT_PROMPT["no_qualification"].format(
+            position=position,
+            answer=_get_last_bidder_response(messages)[:400],
+        )
+
+    llm = get_llm("defense", temperature=0.5)          # 温度0.5
+    response = await llm.ainvoke(_build_messages(system, messages, prompt, window=10))  # window=10
+    return _msg_text(response).strip(), updates          # 返回(回复文本, updates)
+
+async def _respond_closing(state: DefenseState) -> tuple[str, dict]:
+    """
+    反问收尾阶段:开放投标人提问。
+
+    读取 state:
+        stage_turn_count  ≤1 发提问邀请,>1 回应投标人的具体问题
+        messages          取投标人的提问
+        target_position   回应时带上岗位上下文
+    返回 (回复文本, {})——本阶段不更新状态字段。
+    """
+    messages    = state.get("messages", [])              # 对话历史
+    stage_turns = state.get("stage_turn_count", 0)       # 本阶段轮数
+    position    = state.get("target_position", "工程师")  # 岗位
+    system      = _build_system_with_summary(state)      # 带摘要的 System Prompt
+
+    if stage_turns <= 1:                                 # 刚进 CLOSING:邀请投标人提问
+        prompt = CLOSING_PROMPT["opening"]
+    else:                                                # 投标人已提问:回应其问题
+        last_answer = _get_last_bidder_response(messages) # 投标人的提问
+        prompt = CLOSING_PROMPT["respond_question"].format(
+            question=last_answer[:300],
+            position=position,
+        )
+
+    llm = get_llm("defense", temperature=0.6)          # 温度0.6,回应更灵活
+    response = await llm.ainvoke(_build_messages(system, messages, prompt, window=4))  # window=4
+    return _msg_text(response).strip(), {}               # 返回(回复文本, 空updates)
+async def generate_report_node(state: DefenseState) -> dict:
+    """
+    基于完整对话记录和历史摘要,生成五维度结构化评估报告。
+
+    读取 state:
+        messages           完整对话(取最近 40 条格式化)
+        existing_summary   历史摘要(早期对话压缩,作全局视角)
+        target_position    岗位
+        total_turn_count   总轮数(写进 Prompt 和 structured_output)
+        session_id         写进 structured_output
+    返回(写回 state):
+        report             五维度报告 dict(给 save_report 存库)
+        messages           答辩结束通知(AIMessage)
+        structured_output  给 API 层直接读的摘要数据
+    """
+    messages  = state.get("messages", [])                # 完整对话
+    summary   = state.get("existing_summary", "")        # 历史摘要
+    position  = state.get("target_position", "软件工程师") # 岗位
+
+    # 取最近 40 条对话,只保留投标人/评审专家消息,格式化成文本
+    recent_messages = [
+        msg for msg in messages[-40:]                    # 最近 40 条
+        if isinstance(msg, (HumanMessage, AIMessage))    # 只要人/AI 消息
+    ]
+    conversation_text = "\n".join([                      # 拼成"角色:内容"多行文本
+        f"{'评审专家' if isinstance(m, AIMessage) else '投标人'}:"
+        f"{_msg_text(m)}"
+        for m in recent_messages
+    ])
+
+    prompt = GENERATE_REPORT_PROMPT.format(              # 拼报告 Prompt(双层输入)
+        position=position,
+        history_summary=summary or "(无历史摘要)",      # 早期对话用摘要
+        conversation=conversation_text[:4000],          # 近期对话截断到 4000 字
+        total_turns=state.get("total_turn_count", 0),
+    )
+
+    structured_llm = get_structured_llm("defense", DefenseReport)
+
+    report_dict = None
+    for attempt in range(2):                             # function_calling 偶发返回 None → 判空+重试
+        try:
+            result: DefenseReport = await structured_llm.ainvoke([
+                SystemMessage(content=SYSTEM_PROMPT.format(position=position)),
+                HumanMessage(content=prompt),
+            ])
+            if result is None:
+                raise ValueError("structured output returned None")
+            report_dict = result.model_dump()
+            break
+        except Exception as e:
+            if attempt == 0:
+                logger.warning("generate_report.retry", error=str(e))
+                await asyncio.sleep(1)
+            else:
+                logger.warning("generate_report.failed", error=str(e))
+
+    if report_dict is None:                              # 两次均失败 → 硬编码保守报告
+        report_dict = {
+            "dimensions":         [],
+            "overall_score":      60,                    # 保守默认分,提示报告生成有误
+            "strengths":          ["参与了完整的供应商答辩流程"],
+            "improvements":       ["建议深化技术基础知识", "加强项目案例的量化描述"],
+            "overall_comment":    "感谢参加本次供应商答辩,请参考各维度建议持续提升。",
+            "recommended_topics": [],
+            "next_step_advice":   "建议针对薄弱点系统复习,1-2周后再次供应商答辩。",
+        }
+
+    overall_score = report_dict.get("overall_score", 0)  # 取综合分
+    closing_message = (                                   # 拼答辩结束通知(展示给前端)
+        f"好的,本次供应商答辩到这里就结束了,感谢你的参与!\n\n"
+        f"下面是你的整体表现总结:\n\n"
+        f"**综合评分:{overall_score} / 100**\n\n"
+        f"详细的各维度分析和改进建议可以在答辩记录中查看。"
+    )
+
+    logger.info(
+        "generate_report.done",
+        overall_score=overall_score,
+        dimensions=len(report_dict.get("dimensions", [])),
+    )
+
+    return {
+        "report":   report_dict,                          # 给 save_report 存库
+        "messages": [AIMessage(content=closing_message)], # 结束通知(追加进 messages)
+        "structured_output": {                            # 给 API 层直接读的摘要
+            "session_id":  state["session_id"],
+            "report":      report_dict,
+            "total_turns": state.get("total_turn_count", 0),
+        },
+    }
+async def save_report_node(state: DefenseState) -> dict:
+    """
+    将 DefenseReport 写入 defense_sessions,更新 status=finished。
+
+    读取 state:
+        bidder_id / session_id  拼 thread_id 定位会话行
+        report                   7.9 生成的报告 dict
+    返回:{}(不改 state,只落库)
+    """
+    thread_id = build_thread_id(state["bidder_id"], state["session_id"])  # 定位会话的 key
+    report    = state.get("report", {})                  # 取报告 dict
+
+    async with AsyncSessionLocal() as session:           # 开异步 DB 会话
+        try:
+            await session.execute(                       # UPDATE(行已在 POST /sessions 插过)
+                text("""
+                    UPDATE defense_sessions
+                    SET report        = :report,
+                        overall_score = :overall_score,
+                        status        = 'finished',
+                        finished_at   = NOW(),
+                        updated_at    = NOW()
+                    WHERE thread_id = :thread_id
+                """),
+                {
+                    "report":        json.dumps(report, ensure_ascii=False),  # dict→JSON 字符串(中文不转义)
+                    "overall_score": report.get("overall_score", 0),          # 综合分单独存一列
+                    "thread_id":     thread_id,
+                },
+            )
+            await session.commit()                       # 提交
+            logger.info("save_report.done", thread_id=thread_id)
+        except Exception as e:                           # 失败回滚 + 记日志(不抛,不阻断流程)
+            await session.rollback()
+            logger.error("save_report.db_failed", error=str(e))
+
+    return {}
+
+async def save_memory_node(state: DefenseState) -> dict:
+    """
+    按需触发摘要压缩,将最新摘要 UPSERT 到 defense_sessions。
+
+    读取 state:
+        messages / bidder_id / session_id / tenant_id / target_position / qualification_review_id
+        existing_summary   现有摘要(压缩时与之合并)
+        should_summarize   是否触发压缩
+    返回:{}(不改 state,只落库)
+
+    ON CONFLICT (thread_id) 需要表上有 UNIQUE(thread_id) 约束。
+    """
+    messages   = state.get("messages", [])               # 对话历史
+    bidder_id = state["bidder_id"]                     # 投标人 ID
+    session_id = state["session_id"]                     # 会话 ID
+    tenant_id  = state["tenant_id"]                      # 租户 ID
+    thread_id  = build_thread_id(bidder_id, session_id) # 定位 key
+    summary    = state.get("existing_summary")           # 现有摘要
+
+    # 按需压缩:should_summarize 为 True 时调 LLM 把对话压成新摘要
+    if state.get("should_summarize", False):
+        try:
+            summary = await compress_to_summary(messages, summary)  # 与旧摘要合并压缩
+        except Exception as e:
+            logger.warning("save_memory.compress_failed", error=str(e))
+
+    async with AsyncSessionLocal() as session:           # 开异步 DB 会话
+        try:
+            await session.execute(                       # UPSERT:存在则更新摘要,不存在则插入
+                text("""
+                    INSERT INTO defense_sessions
+                        (id, tenant_id, bidder_id, session_id, thread_id,
+                         target_position, qualification_review_id, summary, status)
+                    VALUES
+                        (:id, :tenant_id, :bidder_id, :session_id, :thread_id,
+                         :target_position, :qualification_review_id, :summary, 'in_progress')
+                    ON CONFLICT (thread_id) DO UPDATE
+                        SET summary    = EXCLUDED.summary,
+                            updated_at = NOW()
+                """),
+                {
+                    "id":               str(uuid.uuid4()),              # 插入时的新主键
+                    "tenant_id":        tenant_id,
+                    "bidder_id":       bidder_id,
+                    "session_id":       session_id,
+                    "thread_id":        thread_id,
+                    "target_position":  state.get("target_position", ""),
+                    "qualification_review_id": state.get("qualification_review_id"),
+                    "summary":          summary,                        # 最新摘要
+                },
+            )
+            await session.commit()                       # 提交
+        except Exception as e:                           # 失败回滚 + 记日志
+            await session.rollback()
+            logger.warning("save_memory.db_failed", error=str(e))
+
+    return {}

+ 270 - 0
TenderAgent完整代码/backend/agents/defense/prompts.py

@@ -0,0 +1,270 @@
+# backend/agents/defense/prompts.py
+
+SYSTEM_PROMPT = """你是一位经验丰富的 IT 公司技术评审专家,正在对应届生或初级工程师供应商进行供应商答辩,岗位为{position}。
+
+【评审专家角色要求】
+- 语气专业但不严苛,模拟真实答辩氛围,让供应商感到适度紧张但不焦虑
+- 每次只问一个问题,不连续抛出多个问题
+- 对回答给予简短反应("嗯,好的" / "有意思" / "可以"),不直接评判对错
+- 不主动透露答案,可以用引导性提示("可以从XXX角度思考")
+- 对于确实超出供应商能力范围的问题,轻描淡写地跳过("没关系,我们换一个话题")
+- 答辩结束前不暴露评分或评价结论"""
+
+TECH_BASE_GENERATE_PROMPT = """你是一位{position}岗位的资深技术评审专家。
+请为该岗位生成{count}道技术答辩问题,要真实考察供应商对核心概念的理解深度。
+
+岗位:{position}
+
+出题要求:
+- 难度分布:中等(60%)、简单(20%)、困难(20%)
+- 评分项要考察原理理解,不是死记硬背,例如"请解释XXX的工作原理/核心思想"
+- 每道题不超过60字,表述清晰
+- 标签填该题涉及的技术方向(如 ["深度学习", "Transformer"])
+
+如果岗位是 AI/大模型/算法/NLP 方向,必须包含以下类型评分项:
+- Transformer 架构(Encoder/Decoder 各包含哪些模块,注意力机制如何计算)
+- 过拟合与欠拟合的概念及解决过拟合的方法
+- 自回归语言模型的生成原理
+- RAG(检索增强生成)的基本流程
+- 向量相似度检索的原理(如余弦相似度)
+- 大模型微调方法(如 LoRA 的核心思想)
+
+按 JSON 数组格式输出,每项格式:
+{{"content": "评分项内容", "difficulty": "medium/easy/hard", "tags": ["标签1"]}}
+
+只输出 JSON 数组,不要任何其他文字。"""
+
+WARMUP_PROMPT = {
+    "opening": """请生成供应商答辩的欢迎开场白,邀请供应商自我介绍。
+
+要求:
+- 第一句以"欢迎来到{position}岗位的供应商答辩"开头
+- 自然友好,总共2-3句话
+- 明确说"请先做个简单的自我介绍",可提示包含学习背景、技术栈、感兴趣的方向""",
+}
+
+INTRO_EVAL_TECH_FIRST_PROMPT = """供应商刚才的自我介绍如下:
+{intro}
+
+岗位:{position}
+第一道技术题:{first_question}
+
+请以评审专家身份,在同一条回复中完成以下三件事:
+1. 用2-3句话中肯评价自我介绍(重点:逻辑是否清晰、各部分是否自洽、有无明显矛盾或遗漏,给出具体的正向反馈或改进提示)
+2. 一句自然过渡语,引导进入技术基础环节(如"好的,接下来我们进入技术基础环节")
+3. 直接问出第一道技术题
+
+语气自然,总共不超过5句话。"""
+
+TECH_BASE_PROMPT = {
+    # 上题反馈 + 出下一题(主流程)
+    "ask_with_feedback": """上一道题:{question}
+供应商回答:{answer}
+回答质量:{quality}(excellent=优秀/adequate=基本达到/weak=较弱/no_answer=未作答)
+
+下一道题:{next_question}
+
+请以评审专家身份,在同一条回复中:
+1. 先用1-2句话对上一道题的回答给出具体反馈:
+   - excellent:肯定核心亮点,点出1个可以进一步深化的方向
+   - adequate:肯定基本思路,明确指出1个遗漏的关键评审要点
+   - weak:轻描淡写跳过("好的,我们换个话题"),不展开评价
+   - no_answer:给一句简短提示思路,然后说"我们换下一题"
+2. 自然过渡后,直接问出下一道题
+
+总共不超过4句话,语气自然像真实评审专家。""",
+
+    # 追问(回答 EXCELLENT 时触发)
+    "followup": """供应商对"{question}"的回答如下:
+{answer}
+
+供应商回答质量:{quality}(excellent=优秀)
+
+请生成一个深入追问,要求:
+- 基于供应商回答中提到的具体技术点追问(如"你提到了X,能说说它的内部实现吗?")
+- 不重复相同问题
+- 技术栈背景:{tech_stack}""",
+
+    "weak_transition":  "好的,我们换一个话题。",
+    "no_answer_hint":   """对于"{question}",可以先从这个角度想想:它主要解决什么问题?我们换下一个话题。""",
+}
+
+PROJECT_PROMPT = {
+    # 开始一个新项目的第1问
+    "new_project": """接下来我要深挖资质材料上的"{project_name}"项目。
+
+项目信息(内部参考,不要直接读出):
+- 角色:{project_role}
+- 技术栈:{tech_stack}
+- 亮点:{highlights}
+- 描述摘要:{description}
+
+请生成项目第1问(共3问),评估项目背景合理性和供应商的实际参与度。
+要求:
+- 1-2句自然过渡语(像翻资质材料的评审专家)
+- 问题重点:这个项目的背景是什么?你在其中承担的核心职责是什么?
+- 不超过3句话""",
+
+    # 项目环节追问 + 反馈(带质量反馈版)
+    "followup_with_feedback": """供应商在"{project_name}"项目中的回答:
+{answer}
+
+上一个问题:{question}
+回答质量:{quality}(excellent=优秀/adequate=基本达到/weak=较弱/no_answer=未作答)
+项目技术栈:{tech_stack}
+当前是第 {question_num} 个追问(共最多2次)
+
+请以评审专家身份,在同一条回复中:
+1. 先用1-2句话对上一个回答给出具体反馈:
+   - excellent/adequate:肯定回答中的亮点,同时指出1个可以深化的点
+   - weak:轻描淡写("好的,我们继续往下聊")
+   - no_answer:简短引导("没关系,我们看下一个方向")
+2. 然后问出深度追问(根据技术栈选择最合适的方向):
+
+【RAG / 知识库检索相关项目】
+- 数据量级:知识库有多少条数据?如何做分块(chunk)的?
+- PDF 解析:遇到 PDF 中有图片或表格时如何处理?
+- 混合检索:有没有结合稠密检索和稀疏检索?
+- 精排:用了什么方法做 Reranker?
+
+【LLM 微调相关项目】
+- LoRA 的核心原理是什么?可训练参数比例大概是多少?
+- 微调数据集怎么构建的?
+- 微调前后效果如何对比?
+
+【通用深挖方向】
+- 项目中最大的技术难点是什么?你是怎么解决的?
+- 为什么选择 {tech_stack} 而不是其他方案?
+
+总共不超过4句话,语气自然。""",
+
+    # 项目环节纯追问(首问无反馈版)
+    "followup": """供应商在"{project_name}"项目中的回答:
+{answer}
+
+项目技术栈:{tech_stack}
+当前是第 {question_num} 个追问(共最多2次)
+
+请生成1个深度技术追问,优先考虑以下方向(根据技术栈选择最合适的):
+
+【RAG / 知识库检索相关项目】
+- 数据量级:知识库有多少条数据?如何做分块(chunk)的?
+- PDF 解析:遇到 PDF 中有图片或表格时如何处理?
+- 混合检索:有没有结合稠密检索和稀疏检索?召回策略是什么?
+- 精排:用了什么方法做 Reranker?BGE-Reranker 的原理是什么?
+
+【LLM 微调相关项目】
+- LoRA 微调方法的核心原理是什么?可训练参数占模型总参数的比例大概是多少?
+- 微调数据集怎么构建的?数量级是多少条?
+- 微调前后效果如何对比的?用了什么评估指标?
+
+【通用深挖方向】
+- 项目中遇到的最大技术难点是什么?你是怎么解决的?
+- 为什么选择 {tech_stack} 而不是其他方案?
+
+直接输出追问内容,不超过2句话,语气自然像真实评审专家。""",
+
+    # 所有项目深挖完毕后的综合题
+    "synthesis": """项目案例我们了解得差不多了。最后问你一个综合性问题:
+
+在你的所有项目案例中,遇到过最复杂的技术问题是什么?你是如何排查和解决的?
+
+这是针对{position}岗位的综合考查。""",
+
+    # 无资质材料联动时的应对
+    "no_qualification": """我们来聊聊你的项目经验。
+{answer}
+
+请根据供应商的回答,针对{position}岗位设计一个项目经验问题:
+- 如果供应商提到了具体项目,顺势深挖
+- 如果供应商没有明显项目经验,问一个"如果让你设计一个XXX系统,你会怎么考虑架构?"的设计题
+- 1个问题,不超过3句话""",
+}
+
+CLOSING_PROMPT = {
+    "opening": """答辩问题我们差不多问完了。
+
+最后这个环节,请问你有什么想了解的吗?比如关于岗位要求、技术栈方向、团队情况,
+或者你有什么想补充的都可以说。""",
+
+    "respond_question": """供应商提问如下:
+{question}
+
+请以评审专家身份给出回应,要求:
+- 对供应商的提问表示欢迎("很好的问题")
+- 给出合理的、积极的回答(可以适当模糊,如"团队目前主要使用xxx技术栈")
+- 结合{position}岗位背景回答
+- 结束时自然引导供应商是否还有其他问题""",
+}
+
+
+STAGE_TRANSITION_PROMPTS = {
+    "project": "好的,技术基础题我们就到这里了。那接下来,我们进入项目深挖环节,我想结合你的项目案例再聊聊。",
+    "closing": "好的,我这边的问题差不多都问完了,进入最后一个环节。",
+}
+
+# 前置推理(不约束输出格式)
+EVALUATE_THINK_PROMPT = """在评估这道答辩问题的回答质量之前,请先进行深入分析。
+
+【答辩问题】
+{question}
+
+【供应商回答】
+{answer}
+
+请分析以下几点(中文,4-6句话):
+1. 供应商具体说明了哪些技术要点?(列举实质性内容)
+2. 有哪些关键技术点完全未提及或一带而过?
+3. 供应商是在真实理解基础上作答,还是在背诵模板答案?有何判断依据?
+4. 回答的技术深度和覆盖广度总体如何?
+
+直接输出分析内容,不加任何前缀标签。"""
+
+
+# 主评估(输出质量标签)
+EVALUATE_ANSWER_PROMPT = """请评估供应商对以下答辩问题的回答质量。
+
+【问题】
+{question}
+
+【供应商回答】
+{answer}
+
+请只输出以下4个标签之一,不要任何其他内容:
+- excellent:回答有具体技术细节/量化数据/原理解释,深度足够
+- adequate:回答方向正确但缺乏深度,表述较笼统
+- weak:回答方向偏差、过于表面或明显不理解
+- no_answer:明确表示不知道、回答为空或内容无关"""
+
+GENERATE_REPORT_PROMPT = """请基于以下供应商答辩记录,生成五维度评估报告。
+
+【岗位】{position}
+【总轮数】{total_turns}
+
+【历史摘要(前期对话压缩)】
+{history_summary}
+
+【近期完整对话】
+{conversation}
+
+五个评估维度及权重:
+1. 技术深度(35%):技术知识掌握程度、概念理解深度、原理解释能力
+2. 表达逻辑(20%):回答条理性、是否使用 STAR 结构、语言表达清晰度
+3. 项目经验(25%):项目贡献清晰度、技术选型理由、难点解决过程
+4. 抗压反应(10%):遇到不会的问题如何应对、是否能快速调整思路
+5. 整体印象(10%):积极性、学习态度、对岗位的准备程度
+
+评分标准:
+- 90-100:远超初级岗位期望
+- 75-89: 达到初级岗位要求
+- 60-74: 基本达标,有明显提升空间
+- 45-59: 差距较大,需系统补强
+- 0-44:  尚未达到申请门槛
+
+输出要求:
+- overall_score:五维度加权平均(技术深度35% + 项目经验25% + 表达逻辑20% + 抗压反应10% + 整体印象10%)
+- strengths:找出2-3条供应商在答辩中真实表现出的优势
+- improvements:基于实际表现,提出2-3条最重要的改进方向
+- overall_comment:2-3句综合评语,概括供应商整体答辩表现
+- recommended_topics:列出3-5个供应商需要重点复习的评审要点
+- next_step_advice:给出一句具体的下一步备考建议"""

+ 87 - 0
TenderAgent完整代码/backend/agents/defense/state.py

@@ -0,0 +1,87 @@
+# backend/agents/defense/state.py
+
+from typing import Annotated, Optional
+from typing_extensions import TypedDict
+from langgraph.graph.message import add_messages
+from langchain_core.messages import BaseMessage
+from pydantic import BaseModel, Field
+from enum import Enum
+
+
+class DefenseStage(str, Enum):
+    """答辩的五个阶段(状态机的状态取值)。"""
+    WARMUP    = "warmup"      # 热身:邀请自我介绍
+    TECH_BASE = "tech_base"   # 技术基础:评分标准库问答
+    PROJECT   = "project"     # 项目深挖:针对资质审查项目追问
+    CLOSING   = "closing"     # 反问收尾:让投标人提问
+    FINISHED  = "finished"    # 终态:触发报告生成
+
+
+class AnswerQuality(str, Enum):
+    """投标人单次回答的质量标签(驱动追问/换题决策)。"""
+    EXCELLENT = "excellent"   # 优秀:有技术细节/量化/原理
+    ADEQUATE  = "adequate"    # 基本及格:方向对但缺深度
+    WEAK      = "weak"        # 较弱:方向偏或太表面
+    NO_ANSWER = "no_answer"   # 未作答:明说不知道或为空
+
+
+class DimensionEval(BaseModel):
+    """单个维度的评估结果"""
+    dimension:  str       = Field(description="评估维度名称")
+    score:      int       = Field(description="得分 0-100")
+    comment:    str       = Field(description="1-2句评语")
+    highlights: list[str] = Field(default_factory=list, description="亮点列举")
+    weaknesses: list[str] = Field(default_factory=list, description="薄弱点列举")
+
+
+class DefenseReport(BaseModel):
+    """答辩五维度评估报告"""
+    dimensions:         list[DimensionEval]
+    overall_score:      int       = Field(description="综合得分 0-100(五维度加权平均)")
+    strengths:          list[str] = Field(description="2-3条核心优势(真实答辩表现)")
+    improvements:       list[str] = Field(description="2-3条重点提升方向")
+    overall_comment:    str       = Field(description="2-3句综合评语")
+    recommended_topics: list[str] = Field(description="建议重点复习的3-5个评审要点")
+    next_step_advice:   str       = Field(description="下一步备考建议(1-2句)")
+
+
+class DefenseState(TypedDict):
+    """供应商答辩 Agent 完整 State(22 个字段,跨轮由 MemorySaver 持久化)"""
+
+    # ── 请求上下文 ─────────────────────────────────────────────
+    messages:         Annotated[list[BaseMessage], add_messages]  # 对话消息;add_messages 让新消息自动追加而非覆盖
+    bidder_id:       str           # 投标人 ID
+    tenant_id:        str           # 租户 ID(多租户隔离)
+    session_id:       str           # 会话 ID(API 层生成)
+    target_position:  str           # 目标岗位,决定出题方向
+
+    # ── 资质材料联动数据(从资质审查结果读取,可为空)──────────────
+    qualification_review_id: Optional[str]  # 关联的资质审查记录 ID;非空才做项目深挖
+    qualification_projects:  list[dict]    # 资质审查项目列表(每项 = 名称/角色/技术栈/亮点)
+    qualification_skills:    list[str]     # 资质材料技能列表
+
+    # ── 答辩阶段控制 ───────────────────────────────────────────
+    current_stage:    str           # 当前阶段,DefenseStage 枚举值
+    stage_turn_count: int           # 当前阶段已进行的轮数
+    total_turn_count: int           # 总轮数(跨阶段累计)
+    max_turns:        int           # 轮数上限,默认 40
+
+    # ── 评分项管理 ───────────────────────────────────────────────
+    question_bank:    list[dict]    # 评分标准库;每项 {id, content, difficulty, tags, asked}
+    current_question: Optional[dict] # 当前正在问/追问的题
+    projects_asked:   list[str]     # 已深挖过的项目名称列表(防重复深挖)
+
+    # ── 回答质量追踪 ───────────────────────────────────────────
+    last_answer_quality: str        # 上一条回答的质量标签,AnswerQuality 枚举值
+    followup_count:      int        # 当前问题已追问次数(上限 2)
+
+    # ── 记忆管理 ───────────────────────────────────────────────
+    existing_summary:    Optional[str]   # 历史对话摘要(DB 持久化,长对话压缩用)
+    should_summarize:    bool            # 本轮是否需要触发摘要压缩
+
+    # ── 评估结果(答辩结束后填充)────────────────────────────
+    report:              Optional[dict]  # 五维度报告,DefenseReport.model_dump() 的结果
+
+    # ── 降级标记 ────────────────────────────────────────────────
+    fallback_used:       bool            # 是否用过兜底逻辑(如报告生成失败走兜底)
+    structured_output:   Optional[dict]  # 供 API 层直接读取的结构化结果(如报告摘要)

+ 137 - 0
TenderAgent完整代码/backend/agents/qa/graph.py

@@ -0,0 +1,137 @@
+# backend/agents/qa/graph.py
+
+from langgraph.graph import StateGraph, START, END
+
+from backend.agents.qa.state import QAState
+from backend.agents.qa.nodes import (
+    classify_query_node,
+    hyde_generate_node,
+    multi_query_rewrite_node,
+    retrieve_node,
+    generate_rag_node,
+    web_search_node,
+    generate_direct_node,
+    generate_general_node,
+    enqueue_pending_node,
+    save_memory_node,
+)
+from backend.core.memory import get_memory_saver
+
+
+def _route_by_query_type(state: QAState) -> str:
+    """
+    classify_query 之后的路由:根据 query_type 和 enable_web_search 分流。
+
+    GENERAL + enable_web_search=True  → "GENERAL_WEB":先联网再回答
+    GENERAL + enable_web_search=False → "GENERAL":直接 LLM
+    PRECISE / VAGUE / BROAD           → 直接进对应检索分支
+    """
+    qt = state.get("query_type", "PRECISE").upper()
+    if qt == "GENERAL" and state.get("enable_web_search", False):
+        return "GENERAL_WEB"
+    return qt
+
+
+def _route_by_confidence(state: QAState) -> str:
+    """
+    retrieve 之后的路由:根据置信度和联网开关分流。
+
+    is_high_confidence=True              → "high":RAG 高质量回答
+    is_high_confidence=False
+      + enable_web_search=True           → "low_web":先联网补充再直答
+      + enable_web_search=False          → "low_direct":直接 LLM 兜底
+    """
+    if state.get("is_high_confidence", False):
+        return "high"
+    if state.get("enable_web_search", False):
+        return "low_web"
+    return "low_direct"
+
+
+def _route_after_web_search(state: QAState) -> str:
+    """
+    web_search 节点被两条路径共用,走完搜索后需要区分去向:
+      - 来自 GENERAL_WEB 路径(query_type=GENERAL)→ generate_general
+      - 来自低置信度路径                            → generate_direct
+    """
+    if state.get("query_type", "").upper() == "GENERAL":
+        return "generate_general"
+    return "generate_direct"
+def build_qa_graph():
+    """
+    构建并编译 QA Agent 的 LangGraph 状态图。
+
+    Returns:
+        编译后的 CompiledGraph,供 API 层和 Orchestrator 调用。
+    """
+    builder = StateGraph(QAState)
+
+    # ── 注册节点 ──────────────────────────────────────────────
+    builder.add_node("classify_query",       classify_query_node)
+    builder.add_node("hyde_generate",        hyde_generate_node)
+    builder.add_node("multi_query_rewrite",  multi_query_rewrite_node)
+    builder.add_node("retrieve",             retrieve_node)
+    builder.add_node("generate_rag",         generate_rag_node)
+    builder.add_node("web_search",           web_search_node)
+    builder.add_node("generate_direct",      generate_direct_node)
+    builder.add_node("generate_general",     generate_general_node)
+    builder.add_node("enqueue_pending",      enqueue_pending_node)
+    builder.add_node("save_memory",          save_memory_node)
+
+    # ── 入口固定边 ────────────────────────────────────────────
+    builder.add_edge(START, "classify_query")
+
+    # ── 条件边①:Query 类型路由 ──────────────────────────────
+    builder.add_conditional_edges(
+        "classify_query",
+        _route_by_query_type,
+        {
+            "GENERAL":     "generate_general",
+            "GENERAL_WEB": "web_search",
+            "PRECISE":     "retrieve",
+            "VAGUE":       "hyde_generate",
+            "BROAD":       "multi_query_rewrite",
+        },
+    )
+
+    # VAGUE / BROAD 预处理完成后汇入 retrieve
+    builder.add_edge("hyde_generate",       "retrieve")
+    builder.add_edge("multi_query_rewrite", "retrieve")
+
+    # ── 条件边②:置信度路由 ──────────────────────────────────
+    builder.add_conditional_edges(
+        "retrieve",
+        _route_by_confidence,
+        {
+            "high":       "generate_rag",
+            "low_web":    "web_search",
+            "low_direct": "generate_direct",
+        },
+    )
+
+    # ── 条件边③:web_search 出口路由 ─────────────────────────
+    builder.add_conditional_edges(
+        "web_search",
+        _route_after_web_search,
+        {
+            "generate_general": "generate_general",
+            "generate_direct":  "generate_direct",
+        },
+    )
+
+    # ── 固定边:各生成节点 → 收尾节点 ────────────────────────
+    builder.add_edge("generate_rag",     "save_memory")
+    builder.add_edge("generate_general", "save_memory")
+    builder.add_edge("generate_direct",  "enqueue_pending")
+    builder.add_edge("enqueue_pending",  "save_memory")
+    builder.add_edge("save_memory",      END)
+
+    # ── 编译(绑定 MemorySaver 实现多轮记忆)────────────────
+    checkpointer = get_memory_saver("qa")
+    return builder.compile(checkpointer=checkpointer)
+
+if __name__ == '__main__':
+
+    graph = build_qa_graph()
+    print('图编译成功')
+    print('节点列表:', list(graph.nodes.keys()))

+ 899 - 0
TenderAgent完整代码/backend/agents/qa/nodes.py

@@ -0,0 +1,899 @@
+# backend/agents/qa/nodes.py
+
+import asyncio
+import uuid
+
+from sqlalchemy import text
+from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
+
+from backend.agents.qa.state import QAState
+from backend.agents.qa.prompts import (
+    HYDE_PROMPT,
+    MULTI_QUERY_REWRITE_PROMPT,
+    RAG_ANSWER_PROMPT,
+    DIRECT_ANSWER_PROMPT,
+    GENERAL_ANSWER_PROMPT,
+    RAG_STRATEGY_PROMPT,
+    SYSTEM_PROMPT,
+)
+from backend.core.llm_factory import get_llm
+from backend.core.memory import (
+    trim_messages_to_window,
+    should_trigger_summary,
+    compress_to_summary,
+    build_thread_id,
+)
+from backend.config import get_settings
+from backend.core.logger import get_logger
+from backend.core.query_classifier import get_query_classifier
+
+logger = get_logger(__name__)
+
+# ── 检索相关常量 ───────────────────────────────────────────────
+MAX_BROAD_QUERIES        = 3   # BROAD 分支最多并行的子 Query 数
+RECALL_TOP_K_PRECISE     = 8   # PRECISE:直接检索召回数
+RECALL_TOP_K_VAGUE       = 10  # VAGUE:HyDE 语义扩充后多召回些
+RECALL_TOP_K_BROAD_PER   = 4   # BROAD:每个子 Query 的召回数
+RERANK_TOP_K             = 3   # 精排后保留的最终 chunk 数
+
+def _get_message_content(msg) -> str:
+    """统一获取消息文本(兼容 .text 属性和 .content 属性)"""
+    if hasattr(msg, "text") and not callable(getattr(msg, "text", None)):
+        return msg.text
+    if isinstance(msg.content, str):
+        return msg.content
+    return str(msg.content)
+
+
+def _format_history_for_prompt(messages: list) -> str:
+    """把最近几轮对话格式化为 Prompt 可用的文本"""
+    lines = []
+    for msg in messages:
+        if isinstance(msg, HumanMessage):
+            lines.append(f"投标人:{_get_message_content(msg)}")
+        elif isinstance(msg, AIMessage):
+            # AI 回答截断,避免 Prompt 过长
+            lines.append(f"AI:{_get_message_content(msg)[:200]}...")
+    # print(f'lines: {lines}')
+    return "\n".join(lines) if lines else "(无历史对话)"
+
+
+_WEEKDAYS_CN = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
+
+def _current_datetime_str() -> str:
+    """返回格式化的当前时间字符串,供注入 Prompt 使用"""
+    from datetime import datetime
+    now = datetime.now()
+    # print(f'now: {now}')
+    # print(f'now.weekday()-->{now.weekday()}')
+    weekday = _WEEKDAYS_CN[now.weekday()]
+    return now.strftime(f"%Y年%m月%d日 {weekday} %H:%M")
+
+def _build_system_content(summary: str | None = None) -> str:
+    """
+    构建注入了当前时间的 SystemMessage 内容,所有生成节点共用。
+    summary 非空时追加历史学习摘要,帮助 LLM 保持多轮上下文连贯。
+    """
+    content = SYSTEM_PROMPT + f"\n\n【当前时间】{_current_datetime_str()}"
+    # print(f'content: {content}')
+    if summary:
+        content += f"\n\n【投标人历史学习摘要】\n{summary}"
+    return content
+
+# ──────────────────────────────────────────────────────────────
+# 联网请求自动识别
+# ──────────────────────────────────────────────────────────────
+
+_WEB_SEARCH_HINTS = (
+    "联网", "联网查询", "联网搜索", "上网查", "上网搜", "网上搜",
+    "搜索一下", "可以搜索", "帮我搜", "百度一下", "谷歌一下",
+)
+
+def _extract_query_and_web_flag(raw: str) -> tuple[str, bool]:
+    """
+    检测用户消息是否含联网请求指令。
+    返回 (清洗后的问题, 是否自动启用联网)。
+
+    联网指令部分被去除,防止被 LLM 当作问题内容处理。
+    例:"AI是什么,如果不知道可以联网搜索"
+      → ("AI 是什么", True)
+    """
+    import re
+    needs_web = any(h in raw for h in _WEB_SEARCH_HINTS)
+    # print(f'needs_web: {needs_web}')
+    if not needs_web:
+        return raw, False
+    # clean = re.sub(
+    #     r'[,,。.??!!\s]*(?:如果不知道|不知道的话|不清楚)?(?:你可以|可以)?'
+    #     r'(?:联网|上网|网上)?(?:查询|搜索|搜一下|查一查|百度|谷歌).*$',
+    #     '',
+    #     raw
+    # ).strip()
+    clean = re.sub(
+        r'[,,。.??!!\s]*(?:如果不知道|不知道的话|不清楚|你可以)'
+        r'?\s*(?:可以)?(?:联网|上网|网上)'
+        r'?(?:查询|搜索|搜一下|查一查|百度|谷歌).*$',
+        '',
+        raw,
+    ).strip()
+    return (clean or raw), True
+
+# ──────────────────────────────────────────────────────────────
+# 规则集
+# ──────────────────────────────────────────────────────────────
+
+_GENERAL_EXACT = {
+    "你好", "hi", "hello", "嗨", "hey",
+    "谢谢", "谢谢你", "感谢", "thanks", "thank you",
+    "你是谁", "你叫什么", "你叫什么名字", "你是什么",
+    "你能做什么", "你有什么功能", "你能帮我做什么",
+    "再见", "拜拜", "bye",
+}
+
+_GENERAL_KEYWORDS = (
+    "你是谁", "你叫什么", "你能做什么", "你有什么功能",
+    "介绍一下你自己", "自我介绍",
+    "今天天气", "天气怎么样",
+    "讲个笑话", "说个故事",
+    "今天是", "今天几号", "今天是几号", "今天是星期",
+    "现在是", "现在几点", "现在时间", "当前时间", "当前日期",
+    "几月几号", "星期几", "是几月", "几号了", "日期是", "今天日期",
+)
+
+# 命中即确认为专业问题,跳过 MiniLM,直接进 Layer 2
+_SPECIALIZED_KEYWORDS = (
+    "招标资料", "实战", "项目", "案例", "评审专家", "条款",
+    "作业", "课堂", "培训", "我们学的", "招标资料项目",
+    "第几章", "第几节", "训练营",
+)
+
+_VAGUE_QUERY_HINTS = (
+    "没懂", "不懂", "不太懂", "讲讲", "解释一下",
+    "啥意思", "什么意思", "看不懂",
+)
+_BROAD_QUERY_HINTS = (
+    "全面", "系统", "总结", "梳理", "路线",
+    "对比", "区别", "全景", "有哪些",
+)
+
+
+def _rule_classify_general(query: str) -> bool:
+    """规则层:是否为闲聊/时间/打招呼类(→ GENERAL)"""
+    q = query.strip().lower()
+    if q in _GENERAL_EXACT:
+        return True
+    return any(kw in q for kw in _GENERAL_KEYWORDS)
+
+
+
+def _rule_classify_specialized(query: str) -> bool:
+    """规则层:是否含招标资料/项目信号词(→ 专业,跳过 MiniLM)"""
+    q = query.lower()
+    return any(kw in q for kw in _SPECIALIZED_KEYWORDS)
+
+def _fast_rag_strategy(query: str) -> str:
+    """规则快判 RAG 策略(< 1ms)"""
+    q = query.strip().lower()
+    if len(q) <= 6 and any(kw in q for kw in _VAGUE_QUERY_HINTS):
+        return "VAGUE"
+    if any(kw in q for kw in _BROAD_QUERY_HINTS):
+        return "BROAD"
+    return "PRECISE"
+
+
+async def _determine_rag_strategy(query: str) -> str:
+    """LLM 精判 RAG 策略(仅在规则判为 VAGUE/BROAD 且问题较长时调用)"""
+    try:
+        llm = get_llm("qa", temperature=0)
+        resp = await llm.ainvoke([
+            HumanMessage(content=RAG_STRATEGY_PROMPT.format(query=query))
+        ])
+        label = _get_message_content(resp).strip().upper()
+        if label in ("PRECISE", "VAGUE", "BROAD"):
+            return label
+    except Exception as e:
+        logger.warning("classify_query.rag_strategy_failed", error=str(e))
+    return "PRECISE"   # 兜底:最保守策略
+
+
+async def _determine_rag_strategy_fast(query: str) -> str:
+    """
+    两阶段策略判定:
+    ① 规则快判 → 若为 PRECISE,直接返回(不调 LLM)
+    ② 规则判为 VAGUE/BROAD 且问题较长(≥18字)→ LLM 校正(避免误判)
+    ③ 规则判为 VAGUE/BROAD 且问题极短 → 直接相信规则
+    """
+    strategy = _fast_rag_strategy(query)
+    if strategy == "PRECISE":
+        return strategy
+    if len(query.strip()) >= 18:
+        return await _determine_rag_strategy(query)
+    return strategy
+
+
+# ──────────────────────────────────────────────────────────────
+# 节点:classify_query — Query 类型判断(含历史摘要加载)
+# ──────────────────────────────────────────────────────────────
+
+# ──────────────────────────────────────────────────────────────
+# 节点:classify_query — Query 类型判断(含历史摘要加载)
+# ──────────────────────────────────────────────────────────────
+
+async def classify_query_node(state: QAState) -> dict:
+    """
+    Query 分类节点,决定走哪条处理路径。
+
+    同时负责从 DB 加载当前会话的历史摘要(合并了源码中的 load_memory 节点)。
+
+    返回的 query_type:
+      GENERAL  → 跳过 RAG,直接 LLM 回答
+      PRECISE  → 直接向量检索
+      VAGUE    → 先 HyDE 再检索
+      BROAD    → 先 Multi-Query 改写再并行检索
+    """
+    # ── 取最后一条 HumanMessage 作为原始输入 ─────────────────
+    messages = state.get("messages", [])
+    raw_query = ""
+    for msg in reversed(messages):
+        if isinstance(msg, HumanMessage):
+            raw_query = _get_message_content(msg)
+            break
+
+    # ── 联网指令识别 ──────────────────────────────────────────
+    original_query, auto_web = _extract_query_and_web_flag(raw_query)
+    # print(f'original_query: {original_query}')
+    # print(f'auto_web: {auto_web}')
+    # ── 从 DB 加载历史摘要(取代 load_memory_and_embed_node)──
+    existing_summary: str | None = None
+    try:
+        from backend.dependencies import AsyncSessionLocal
+        thread_id = build_thread_id(state["bidder_id"], state["session_id"])
+        async with AsyncSessionLocal() as db:
+            row = (await db.execute(
+                text("SELECT summary FROM qa_sessions WHERE thread_id = :tid"),
+                {"tid": thread_id},
+            )).fetchone()
+            existing_summary = row[0] if row else None
+    except Exception as e:
+        logger.warning("classify_query.load_memory_failed", error=str(e))
+
+    _base: dict = {
+        "original_query":    original_query,
+        "existing_summary":  existing_summary,
+        "rewritten_queries": [],
+        "hyde_document":     None,
+    }
+    if auto_web and not state.get("enable_web_search", False):
+        _base["enable_web_search"] = True
+        logger.info("classify_query.auto_web_enabled", query=original_query[:50])
+    # print(f'_base: {_base}')
+    # ── Layer 0a:规则 → GENERAL(闲聊/时间/打招呼)───────────
+    if _rule_classify_general(original_query):
+        logger.info("classify_query.general_by_rule", query=original_query[:50])
+        return {**_base, "query_type": "GENERAL"}
+
+        # ── Layer 0b:关键词快速通道 → 专业(招标资料/项目词)──────────
+    if _rule_classify_specialized(original_query):
+        logger.info("classify_query.specialized_by_keyword", query=original_query[:50])
+        strategy = await _determine_rag_strategy_fast(original_query)
+        logger.info("classify_query.rag_strategy", strategy=strategy)
+        return {**_base, "query_type": strategy}
+    # ── Layer 1:MiniLM 二分类(CPU 推理,线程池避免阻塞)──────
+    loop = asyncio.get_running_loop()
+    label, confidence = await loop.run_in_executor(
+        None, get_query_classifier().classify, original_query
+    )
+
+    if label == "general":
+        logger.info(
+            "classify_query.general_by_minilm",
+            query=original_query[:50],
+            confidence=round(confidence, 4),
+        )
+        return {**_base, "query_type": "GENERAL"}
+    # ── Layer 2:MiniLM → 专业,LLM 判检索策略 ──────────────
+    logger.info(
+        "classify_query.specialized_by_minilm",
+        query=original_query[:50],
+        confidence=round(confidence, 4),
+    )
+    strategy = await _determine_rag_strategy_fast(original_query)
+    logger.info("classify_query.rag_strategy", strategy=strategy)
+    return {**_base, "query_type": strategy}
+
+
+# ──────────────────────────────────────────────────────────────
+# 节点:hyde_generate — HyDE 假设文档生成(VAGUE 分支)
+# ──────────────────────────────────────────────────────────────
+
+async def hyde_generate_node(state: QAState) -> dict:
+    """
+    针对模糊 Query,让 LLM 先生成一段假设性回答文档,
+    用该文档的向量代替原始 Query 向量去检索。
+
+    temperature=0.3:生成结果要有一定多样性(覆盖更多语义),
+    但不能太随机(避免偏离主题)。
+    """
+    query    = state["original_query"]
+    messages = state.get("messages", [])
+
+    history_text = _format_history_for_prompt(messages[-6:])  # 最近 3 轮
+
+    prompt = HYDE_PROMPT.format(history=history_text, query=query)
+
+    llm = get_llm("qa", temperature=0.3)
+    response = await llm.ainvoke([HumanMessage(content=prompt)])
+    hyde_doc = _get_message_content(response).strip()
+
+    logger.info(
+        "hyde_generate.done",
+        query=query[:50],
+        hyde_doc_length=len(hyde_doc),
+    )
+
+    return {"hyde_document": hyde_doc}
+
+# ──────────────────────────────────────────────────────────────
+# 节点:multi_query_rewrite — Multi-Query 改写(BROAD 分支)
+# ──────────────────────────────────────────────────────────────
+
+async def multi_query_rewrite_node(state: QAState) -> dict:
+    """
+    针对宽泛/极短 Query,改写为 3-5 个具体子 Query,
+    下一节 retrieve_node 会对这些子 Query 并行检索后合并去重。
+    """
+    query    = state["original_query"]
+    messages = state.get("messages", [])
+
+    # 取上一轮 AI 回答(推断"没懂"指的是什么)
+    last_ai_text = ""
+    for msg in reversed(messages):
+        if isinstance(msg, AIMessage):
+            last_ai_text = _get_message_content(msg)
+            break
+
+    prompt = MULTI_QUERY_REWRITE_PROMPT.format(
+        last_answer=last_ai_text[:500] if last_ai_text else "(无上一轮回答)",
+        query=query,
+    )
+
+    llm = get_llm("qa", temperature=0.3)
+    response = await llm.ainvoke([HumanMessage(content=prompt)])
+
+    # 解析:每行一个子 Query,去掉序号前缀("1. " "①" "- " 等)
+    raw = _get_message_content(response).strip()
+    print(f'raw: {raw}')
+    rewritten = [line.lstrip("0123456789.-))、 ").strip()
+                 for line in raw.split("\n") if line.strip() and len(line.strip()) > 3][:MAX_BROAD_QUERIES]
+
+    if not rewritten:
+        rewritten = [query]   # 改写失败时回退到原始 Query
+
+    logger.info(
+        "multi_query_rewrite.done",
+        original=query,
+        count=len(rewritten),
+        queries=rewritten,
+    )
+
+    return {"rewritten_queries": rewritten}
+
+
+# ──────────────────────────────────────────────────────────────
+# 节点:retrieve — 混合召回 + 精排
+# ──────────────────────────────────────────────────────────────
+
+async def retrieve_node(state: QAState) -> dict:
+    """
+    调用 retrieve() Pipeline 完成检索与精排,直接输出 ranked_chunks。
+
+    三条路径:
+      PRECISE → 用 original_query 直接检索
+      VAGUE   → 用 hyde_document 替代 original_query(语义扩充)
+      BROAD   → 对所有 rewritten_queries 并行检索,结果合并去重
+
+    retrieve() 是同步函数(BGE-M3 CPU 推理 + Milvus 阻塞 IO),
+    必须用 run_in_executor 包装,避免阻塞 asyncio 事件循环。
+    """
+    from backend.core.reranker import retrieve, RankedDocument
+
+    query_type     = state.get("query_type", "PRECISE").upper()
+    tenant_id      = state["tenant_id"]
+    tender_document_id      = state.get("tender_document_id")
+    original_query = state["original_query"]
+
+    loop = asyncio.get_running_loop()
+
+    # ── BROAD:并行多 Query 检索,合并去重 ───────────────────────
+    if query_type == "BROAD" and state.get("rewritten_queries"):
+        broad_queries = state["rewritten_queries"][:MAX_BROAD_QUERIES]
+
+        async def retrieve_one(sub_query: str) -> tuple[list, float]:
+            return await loop.run_in_executor(
+                None,
+                lambda: retrieve(
+                    sub_query,
+                    tenant_id,
+                    tender_document_id,
+                    recall_top_k=RECALL_TOP_K_BROAD_PER,
+                    rerank_top_k=RERANK_TOP_K,
+                ),
+            )
+
+        results = await asyncio.gather(*[retrieve_one(q) for q in broad_queries])
+        # print(f'retrieve_one.results', results)
+        # 合并去重:content 前 100 字符为 key,同一内容保留最高分
+        seen: dict[str, RankedDocument] = {}
+        for ranked_docs, _ in results:
+            for doc in ranked_docs:
+                key = doc.content[:100]
+                if key not in seen or doc.score > seen[key].score:
+                    seen[key] = doc
+
+        merged = sorted(seen.values(), key=lambda x: x.score, reverse=True)[:RERANK_TOP_K]
+        # ── PRECISE / VAGUE:单路检索 ─────────────────────────────────
+    else:
+        if query_type == "VAGUE" and state.get("hyde_document"):
+            query_text = state["hyde_document"]
+            recall_top_k = RECALL_TOP_K_VAGUE
+        else:
+            query_text = original_query
+            recall_top_k = RECALL_TOP_K_PRECISE
+
+        merged, _ = await loop.run_in_executor(
+            None,
+            lambda: retrieve(
+                query_text,
+                tenant_id,
+                tender_document_id,
+                recall_top_k=recall_top_k,
+                rerank_top_k=RERANK_TOP_K,
+            ),
+        )
+
+        # ── 转换 RankedDocument → dict,写入 State ─────────────────────
+
+    # print(f'merged: {merged}')
+    # print("*"*80)
+    ranked_chunks = [
+        {
+            "content": doc.content,
+            "score": doc.score,
+            "metadata": doc.metadata,
+        }
+        for doc in merged
+    ]
+
+    confidence = ranked_chunks[0]["score"] if ranked_chunks else 0.0
+    is_high_confidence = confidence >= 0.75
+
+    logger.info(
+        "retrieve.done",
+        query_type=query_type,
+        ranked=len(ranked_chunks),
+        confidence=round(confidence, 4),
+        is_high_confidence=is_high_confidence,
+    )
+
+    return {
+        "ranked_chunks": ranked_chunks,
+        "confidence": confidence,
+        "is_high_confidence": is_high_confidence,
+    }
+
+# ──────────────────────────────────────────────────────────────
+# 节点:generate_rag — 高置信度 RAG 生成
+# ──────────────────────────────────────────────────────────────
+
+async def generate_rag_node(state: QAState) -> dict:
+    """
+    高置信度 RAG 生成节点。
+
+    将精排后的 Top-3 文档拼成 context,让 LLM 严格基于知识库内容回答。
+    回答末尾附加 📚 参考来源,支持历史摘要注入保持多轮连贯性。
+    """
+    ranked_chunks = state.get("ranked_chunks", [])
+    query         = state["original_query"]
+    messages      = state.get("messages", [])
+    summary       = state.get("existing_summary")
+
+    # 构建知识库上下文与来源列表
+    context_parts = []
+    sources = []
+    for i, chunk in enumerate(ranked_chunks, 1):
+        context_parts.append(f"【参考{i}】\n{chunk['content']}")
+        source_name = chunk.get("metadata", {}).get("source_name", "招标资料文档")
+        if source_name not in sources:
+            sources.append(source_name)
+
+    context_text = "\n\n".join(context_parts)
+
+    # 消息列表:SystemMessage(含当前时间 + 历史摘要)
+    llm_messages = [SystemMessage(content=_build_system_content(summary))]
+
+    # 注入历史对话窗口(排除最后一条 HumanMessage)
+    # 最后一条 HumanMessage 已拼入 RAG_ANSWER_PROMPT 的 {query},
+    # 再传一次会让问题在上下文里出现两次,影响生成质量。
+    windowed = trim_messages_to_window(messages[:-1], window_size=10)
+    for msg in windowed:
+        if not isinstance(msg, SystemMessage):
+            llm_messages.append(msg)
+
+    rag_prompt = RAG_ANSWER_PROMPT.format(context=context_text, query=query)
+    llm_messages.append(HumanMessage(content=rag_prompt))
+
+    llm = get_llm("qa", streaming=True)
+    response = await llm.ainvoke(llm_messages)
+    answer_text = _get_message_content(response).strip()
+
+    sources_text = "\n".join([f"  • {s}" for s in sources])
+    final_answer = f"{answer_text}\n\n📚 **参考来源**\n{sources_text}"
+
+    logger.info(
+        "generate_rag.done",
+        answer_length=len(final_answer),
+        sources=sources,
+        confidence=round(state.get("confidence", 0), 4),
+    )
+
+    return {
+        "answer":      final_answer,
+        "sources":     sources,
+        "answer_mode": "rag",
+        "messages":    [AIMessage(content=final_answer)],
+        "should_summarize": should_trigger_summary(messages),
+        "structured_output": {
+            "answer":      final_answer,
+            "sources":     sources,
+            "confidence":  state.get("confidence", 0),
+            "answer_mode": "rag",
+        },
+    }
+
+
+# ──────────────────────────────────────────────────────────────
+# 节点:web_search — Web 搜索补充(低置信度分支)
+# ──────────────────────────────────────────────────────────────
+
+async def web_search_node(state: QAState) -> dict:
+    """
+    低置信度分支的 Web 搜索节点。
+
+    知识库置信度不足时,调用 Web Search MCP Server 补充互联网信息。
+    MCP Server 不可用时静默降级,返回空列表,不阻断后续生成节点。
+    """
+    from backend.mcp.client import call_mcp_tool
+
+    settings = get_settings()
+
+    if not settings.web_search_mcp_url:
+        return {"web_search_results": []}
+
+    try:
+        results = await call_mcp_tool(
+            server_url=settings.web_search_mcp_url,
+            tool_name="web_search",
+            arguments={
+                "query":       state["original_query"],
+                "max_results": 3,
+            },
+            timeout=10.0,
+        )
+        count = len(results or [])
+        logger.info("web_search.done", count=count)
+        return {"web_search_results": results or []}
+    except Exception as e:
+        logger.warning("web_search.failed", error=str(e))
+        return {"web_search_results": []}
+
+
+# ──────────────────────────────────────────────────────────────
+# 节点:generate_direct — 低置信度 LLM 直答(含 Web 搜索补充)
+# ──────────────────────────────────────────────────────────────
+
+async def generate_direct_node(state: QAState) -> dict:
+    """
+    低置信度 LLM 直答节点。
+
+    知识库无足够相关内容时,直接用 LLM 参数知识回答。
+    有 Web 搜索结果时注入为上下文(web_augmented 模式);
+    无搜索结果时在回答末尾追加 ⚠️ 提示(llm_direct 模式)。
+    """
+    query    = state["original_query"]
+    messages = state.get("messages", [])
+    summary  = state.get("existing_summary")
+
+    llm_messages = [SystemMessage(content=_build_system_content(summary))]
+
+    windowed = trim_messages_to_window(messages[:-1], window_size=10)
+    for msg in windowed:
+        if not isinstance(msg, SystemMessage):
+            llm_messages.append(msg)
+
+    # ── Web 搜索结果注入 ──────────────────────────────────────
+    web_results = state.get("web_search_results") or []
+    # print(f'web_results-->{web_results}')
+    web_context = ""
+    web_sources: list[str] = []
+    if web_results:
+        snippets = "\n".join(
+            f"  [{i + 1}] {r.get('title', '')}({r.get('url', '')})\n {r.get('snippet', '')[:300]}"
+            for i, r in enumerate(web_results)
+        )
+        web_context = f"\n\n【Web 搜索补充参考】\n{snippets}"
+        web_sources = [r.get("url", "") for r in web_results if r.get("url")]
+    # print(f'web_context-->{web_context}')
+    # print(f'web_sources-->{web_sources}')
+    #
+    direct_prompt = DIRECT_ANSWER_PROMPT.format(query=query) + web_context
+    llm_messages.append(HumanMessage(content=direct_prompt))
+
+    llm = get_llm("qa", streaming=True)
+    response = await llm.ainvoke(llm_messages)
+    answer_text = _get_message_content(response).strip()
+    # print(f'answer_text-->{answer_text}')
+
+    if web_sources:
+        # URL 通过 sources 字段传给前端,由 UI 折叠面板展示,不拼进正文
+        final_answer = answer_text
+        answer_mode  = "web_augmented"
+    else:
+        final_answer = (
+            f"{answer_text}\n\n"
+            f"⚠️ **说明**:以上为 AI 基于通用知识的回答,招标知识库中暂无相关内容。"
+            f"建议以评审专家讲解为准,或联系评审专家补充相关资料。"
+        )
+        answer_mode = "llm_direct"
+
+    logger.info(
+        "generate_direct.done",
+        answer_length=len(final_answer),
+        confidence=round(state.get("confidence", 0), 4),
+        web_sources=len(web_sources),
+    )
+
+    return {
+        "answer":      final_answer,
+        "sources":     web_sources,
+        "answer_mode": answer_mode,
+        "messages":    [AIMessage(content=final_answer)],
+        "should_summarize": should_trigger_summary(messages),
+        "structured_output": {
+            "answer":      final_answer,
+            "sources":     web_sources,
+            "confidence":  state.get("confidence", 0),
+            "answer_mode": answer_mode,
+        },
+    }
+
+
+# ──────────────────────────────────────────────────────────────
+# 节点:generate_general — 通用问题直答(跳过 RAG)
+# ──────────────────────────────────────────────────────────────
+
+async def generate_general_node(state: QAState) -> dict:
+    """
+    通用问题直答节点(query_type=GENERAL)。
+
+    适用于:打招呼、问时间、闲聊等与招标资料无关的问题。
+    联网模式下若 web_search_results 非空,注入搜索结果提供时效性信息。
+    """
+    query       = state["original_query"]
+    messages    = state.get("messages", [])
+    web_results = state.get("web_search_results") or []
+
+    web_context = ""
+    web_sources: list[str] = []
+    if web_results:
+        snippets = "\n".join(
+            f"  [{i + 1}] {r.get('title', '')}({r.get('url', '')})\n"
+            f"      {r.get('snippet', '')[:300]}"
+            for i, r in enumerate(web_results)
+        )
+        web_context = f"【Web 搜索结果】\n{snippets}\n\n"
+        web_sources = [r.get("url", "") for r in web_results if r.get("url")]
+
+    history_text = _format_history_for_prompt(messages[-6:])
+    prompt = GENERAL_ANSWER_PROMPT.format(
+        query=query,
+        history=history_text,
+        current_time=_current_datetime_str(),
+        web_context=web_context,
+    )
+    # print(f'prompt: {prompt}')
+    # print("*"*80)
+    llm = get_llm("qa", streaming=True)
+    response = await llm.ainvoke([HumanMessage(content=prompt)])
+    answer_text = _get_message_content(response).strip()
+
+    answer_mode = "web_augmented" if web_sources else "general"
+    # print(f'answer_text: {answer_text}')
+    # print("*" * 80)
+    logger.info(
+        "generate_general.done",
+        answer_length=len(answer_text),
+        web_sources=len(web_sources),
+    )
+
+    return {
+        "answer":      answer_text,
+        "sources":     web_sources,
+        "answer_mode": answer_mode,
+        "messages":    [AIMessage(content=answer_text)],
+        "should_summarize": should_trigger_summary(messages),
+        "structured_output": {
+            "answer":      answer_text,
+            "sources":     web_sources,
+            "confidence":  1.0,
+            "answer_mode": answer_mode,
+        },
+    }
+# ──────────────────────────────────────────────────────────────
+# 节点:enqueue_pending — 低置信度问题入队(纯副作用)
+# ──────────────────────────────────────────────────────────────
+
+async def enqueue_pending_node(state: QAState) -> dict:
+    """
+    将低置信度问题写入 knowledge_pending_queue,供评审专家审查补充知识库。
+
+    ON CONFLICT DO NOTHING:幂等写入,同一问题重复触发不会产生重复记录。
+    失败静默,不影响已生成的回答。返回 {} 不修改 State。
+    """
+    from backend.dependencies import AsyncSessionLocal
+
+    try:
+        async with AsyncSessionLocal() as session:
+            async with session.begin():
+                await session.execute(
+                    text("""
+                        INSERT INTO knowledge_pending_queue
+                            (id, tenant_id, question, bidder_id, confidence, status)
+                        VALUES (:id, :tenant_id, :question, :bidder_id, :confidence, 'pending')
+                        ON CONFLICT DO NOTHING
+                    """),
+                    {
+                        "id":         str(uuid.uuid4()),
+                        "tenant_id":  state["tenant_id"],
+                        "question":   state["original_query"],
+                        "bidder_id": state["bidder_id"],
+                        "confidence": state.get("confidence", 0.0),
+                    },
+                )
+        logger.info(
+            "enqueue_pending.done",
+            question=state["original_query"][:50],
+            confidence=state.get("confidence", 0),
+        )
+    except Exception as e:
+        logger.warning("enqueue_pending.failed", error=str(e))
+
+    return {}
+
+# ──────────────────────────────────────────────────────────────
+# 节点:save_memory — 记忆保存(纯副作用)
+# ──────────────────────────────────────────────────────────────
+
+async def save_memory_node(state: QAState) -> dict:
+    """
+    记忆保存节点:条件触发摘要压缩 + 写回 qa_sessions 表。
+
+    should_summarize=True(对话超过 10 轮)时先压缩历史再写库。
+    两步均失败静默,不中断流程。返回 {} 不修改 State。
+    """
+    from backend.dependencies import AsyncSessionLocal
+
+    messages   = state.get("messages", [])
+    bidder_id = state["bidder_id"]
+    session_id = state["session_id"]
+    tenant_id  = state["tenant_id"]
+    thread_id  = build_thread_id(bidder_id, session_id)
+    summary    = state.get("existing_summary")
+
+    # ── 条件触发摘要压缩 ─────────────────────────────────────────
+    if state.get("should_summarize", False):
+        msgs_to_compress = trim_messages_to_window(messages, window_size=10)
+
+        try:
+            summary = await compress_to_summary(
+                messages=msgs_to_compress,
+                existing_summary=summary,
+            )
+            logger.info("save_memory.summary_compressed", thread_id=thread_id)
+        except Exception as e:
+            logger.warning("save_memory.compress_failed", error=str(e))
+
+    # ── UPSERT 到 qa_sessions 表 ──────────────────────────────────
+        try:
+            async with AsyncSessionLocal() as session:
+                async with session.begin():
+                    await session.execute(
+                        text("""
+                            INSERT INTO qa_sessions
+                                (id, tenant_id, bidder_id, thread_id, summary, summary_version)
+                            VALUES (:id, :tenant_id, :bidder_id, :thread_id, :summary, 1)
+                            ON CONFLICT (thread_id) DO UPDATE
+                                SET summary         = EXCLUDED.summary,
+                                    summary_version = qa_sessions.summary_version + 1,
+                                    updated_at      = NOW()
+                        """),
+                        {
+                            "id":         str(uuid.uuid4()),
+                            "tenant_id":  tenant_id,
+                            "bidder_id": bidder_id,
+                            "thread_id":  thread_id,
+                            "summary":    summary,
+                        },
+                    )
+        except Exception as e:
+            logger.warning("save_memory.db_write_failed", error=str(e))
+
+    return {}
+
+
+if __name__ == '__main__':
+    # messages = [SystemMessage(content="你是一个专家"),
+    #             HumanMessage(content="你是谁"),
+    #             AIMessage(content="我叫张三")]
+    #
+    # print(_format_history_for_prompt(messages))
+    # print(_current_datetime_str())
+    # _build_system_content()
+    # print(_extract_query_and_web_flag(raw="什么是AI, 如果不知道可以联网搜索"))
+    # print(_rule_classify_general(query="你好"))
+    # print(_rule_classify_specialized(query="AI招标资料怎么样"))
+    # print(_fast_rag_strategy(query="全面的帮我概括下"))
+    import asyncio
+    # print(asyncio.run(_determine_rag_strategy(query="Transformer模型的架构组成部分是什么")))
+    # 测试下规则下:判断问题为GENERAL
+    # state = {"messages": [HumanMessage(content="模型训练的时候是怎么计算梯度的"),
+    #                       AIMessage(content="反向传播的算法")],
+    #          "original_query":"给我概括下",
+    #          "bidder_id":"stu_001",
+    #          "session_id":"sess_001"}
+    # result = asyncio.run(classify_query_node(state))
+    # result = asyncio.run(hyde_generate_node(state))
+    # result = asyncio.run(multi_query_rewrite_node(state))
+    # print(f'result: {result}')
+    # results = asyncio.run(retrieve_node(state))
+    # state.update(results)
+    # results2 = asyncio.run(generate_rag_node(state))
+    # print(results2)
+    # 一定开启mcp server服务(new_main.py运行)
+    # results3 = asyncio.run(web_search_node(state))
+    # # print(f'results3={results3}')
+    # state = {"query_type":"PRECISE",
+    #          "original_query":"什么是AI",
+    #          "tenant_id":"tenant_default",}
+    # #
+    # web_search_results = {'web_search_results': [{'title': '人工智能',
+    #                                              'url': 'https:www.123.com',
+    #                                              'snippet': '人工智能',
+    #                                              'content': '人工智能(AI)让机器像人一样智能,代替人类工作'}]}
+    # state.update(web_search_results)
+    # # results4 = asyncio.run(generate_direct_node(state))
+    # # print(results4)
+    # results5 = asyncio.run(generate_general_node(state))
+    # print(results5)
+    # state = {"query_type": "PRECISE",
+    #          "original_query": "什么是AI",
+    #          "tenant_id": "tenant_default", }
+    # # state.update(web_search_results)
+    # state = {"query_type": "PRECISE",
+    #          "original_query": "AI和JAVA有什么区别和联系",
+    #          "tenant_id": "tenant_default",
+    #          "bidder_id":"1efe1246-e355-4714-8b1b-bd4e7c8bce51", #必须在users库中出现
+    #          "confidence": 0.74}
+    #
+    # asyncio.run(enqueue_pending_node(state))
+    state = {"messages": [HumanMessage(content="模型训练的时候是怎么计算梯度的"),
+                          AIMessage(content="反向传播的算法")],
+             "original_query": "给我概括下",
+             "bidder_id":"1efe1246-e355-4714-8b1b-bd4e7c8bce51",
+             "tenant_id": "tenant_default",
+             "session_id": "sess_001",
+             "existing_summary":"这是我们给大家演示的最最新的摘要"}
+    result5 = asyncio.run(save_memory_node(state))
+    print("*"*80)
+    print(result5)
+
+

+ 135 - 0
TenderAgent完整代码/backend/agents/qa/prompts.py

@@ -0,0 +1,135 @@
+# backend/agents/qa/prompts.py
+
+# ──────────────────────────────────────────────────────────────
+# ① 系统提示(所有生成节点共用,注入为 SystemMessage)
+# ──────────────────────────────────────────────────────────────
+
+SYSTEM_PROMPT = """你是 TenderAgent 智能助教,专门辅助 IT 培训招标资料的投标人学习。
+
+【你的角色】
+- 解答投标人关于招标资料内容的技术问题
+- 语言风格:友善、专业、简洁;适合有一定基础的成年学习者
+- 回答长度:视问题复杂度适当调整,不过度展开,不简单敷衍
+
+【回答规范】
+- 代码示例使用 Markdown 代码块(```语言名 ... ```)
+- 涉及原理时先给结论,再给解释
+- 如果问题包含明显错误理解,先纠正再解答
+- 不编造不确定的信息,不确定时明确说明"""
+
+
+# ──────────────────────────────────────────────────────────────
+# ② RAG 策略判断(分类节点:LLM 二次确认后选择检索策略)
+# ──────────────────────────────────────────────────────────────
+
+RAG_STRATEGY_PROMPT = """判断以下评审人员问题应采用哪种检索策略。
+
+【策略定义】
+PRECISE:问题表达明确,含具体技术点,可直接向量检索。
+         例:"Redis ZSet 的底层数据结构是什么" / "项目里 LSTM 那层怎么定义的"
+
+VAGUE  :问题模糊,只给出宽泛意图,直接检索效果差,需先生成假设文档扩充语义再检索(HyDE)。
+         例:"解释一下" / "我没太懂这块" / "能说说吗"
+
+BROAD  :问题范围过宽或极度简短(≤5字),需拆成多个子问题并行检索扩大召回(Multi-Query)。
+         例:"没懂" / "讲讲微服务" / "项目怎么做"
+
+【评审人员问题】
+{query}
+
+只输出一个词:PRECISE 或 VAGUE 或 BROAD"""
+
+
+# ──────────────────────────────────────────────────────────────
+# ③ HyDE:假设文档生成(VAGUE 分支)
+# ──────────────────────────────────────────────────────────────
+
+HYDE_PROMPT = """你是一位 IT 培训助教。请根据以下对话上下文,推断投标人想问的具体技术问题,
+并生成一段高质量的技术文档片段作为假设性回答。
+
+【对话上下文(最近几轮)】
+{history}
+
+【投标人当前输入】
+{query}
+
+请直接输出一段专业的技术文档内容(150-300字),不要包含"假设"或"可能"等不确定语气。
+输出格式:纯文本,可包含代码块。"""
+
+
+# ──────────────────────────────────────────────────────────────
+# ④ Multi-Query 改写(BROAD 分支)
+# ──────────────────────────────────────────────────────────────
+
+MULTI_QUERY_REWRITE_PROMPT = """你是一位评审助手,负责将投标人模糊的问题改写为多个具体的技术问题。
+
+【上一轮 AI 回答内容(供参考,推断"没懂"指的是什么)】
+{last_answer}
+
+【投标人当前输入】
+{query}
+
+请将投标人的问题改写为 3-5 个具体、独立的技术问题,每行一个。
+要求:
+- 每个问题独立完整,可以单独搜索
+- 覆盖不同角度(是什么 / 为什么 / 怎么用 / 和什么区别)
+- 不要编号,直接输出问题文本
+
+输出示例:
+Spring IOC 容器的核心作用是什么?
+IOC 和 DI 的区别是什么?
+Spring 如何通过注解实现依赖注入?"""
+
+
+# ──────────────────────────────────────────────────────────────
+# ⑤ RAG 回答生成(高置信度,严格基于知识库内容)
+# ──────────────────────────────────────────────────────────────
+
+RAG_ANSWER_PROMPT = """请基于以下招标知识库内容,回答投标人的问题。
+
+【知识库参考内容】
+{context}
+
+【评审人员问题】
+{query}
+
+【回答要求】
+1. 严格基于参考内容回答,不要引入参考内容之外的信息
+2. 如果参考内容不足以完整回答问题,明确说明哪些部分来自参考内容,哪些是补充说明
+3. 代码示例保持原样,不要修改
+4. 回答简洁清晰,直接切入要点"""
+
+
+# ──────────────────────────────────────────────────────────────
+# ⑥ LLM 直答生成(低置信度兜底,知识库命中率不足时使用)
+# ──────────────────────────────────────────────────────────────
+
+DIRECT_ANSWER_PROMPT = """请根据你的知识回答以下技术问题。
+
+【评审人员问题】
+{query}
+
+【回答要求】
+1. 基于通用技术知识回答,不要声称来自招标资料
+2. 如果不确定,明确说明不确定的部分
+3. 回答简洁准确,代码示例使用代码块格式"""
+
+
+# ──────────────────────────────────────────────────────────────
+# ⑦ 通用问题直答(query_type=GENERAL,跳过 RAG,无需检索)
+# ──────────────────────────────────────────────────────────────
+
+GENERAL_ANSWER_PROMPT = """你是 TenderAgent 智能助教,专门辅助 IT 培训招标资料的投标人学习。
+
+【当前时间】{current_time}
+
+【评审人员问题】
+{query}
+
+{web_context}【历史对话(最近几轮)】
+{history}
+
+请直接回答投标人的问题。
+- 语言友善、简洁
+- 如果问题涉及时间/日期,直接根据【当前时间】作答
+- 如果提供了【Web 搜索结果】,优先基于搜索结果回答,并在回答末尾注明信息来源"""

+ 46 - 0
TenderAgent完整代码/backend/agents/qa/state.py

@@ -0,0 +1,46 @@
+# backend/agents/qa/state.py
+
+from typing import Annotated, Optional
+from typing_extensions import TypedDict
+from langgraph.graph.message import add_messages
+from langchain_core.messages import BaseMessage
+
+
+class QAState(TypedDict):
+    """
+    智能问答 Agent 的完整状态定义。
+    所有节点通过读写此 State 进行数据传递。
+    """
+
+    # ── ① 消息历史(LangGraph 核心,add_messages reducer)────────
+    # 节点每次返回 messages 时自动追加,不覆盖历史
+    messages: Annotated[list[BaseMessage], add_messages]
+
+    # ── ② 请求上下文(Orchestrator 注入,节点只读)───────────────
+    bidder_id:  str            # 投标人 ID
+    tenant_id:   str            # 租户 ID(Milvus / DB 多租户隔离)
+    session_id:  str            # 会话 ID(用于构造 thread_id)
+    tender_document_id:   Optional[str]  # 招标资料 ID,限制检索范围;None = 全库检索
+
+    # ── ③ Query 处理中间结果──────────────────────────────────────
+    original_query:    str         # 用户原始输入,全程不变
+    query_type:        str         # GENERAL / PRECISE / VAGUE / BROAD
+    rewritten_queries: list[str]   # BROAD 分支:Multi-Query 改写后的子 Query 列表
+    hyde_document:     Optional[str]  # VAGUE 分支:HyDE 生成的假设文档文本
+
+    # ── ④ 检索与精排结果─────────────────────────────────────────
+    # retrieve() 内部已处理 BGE-M3 编码,State 无需存储中间向量
+    ranked_chunks:      list[dict]   # BGEReranker 精排后的 Top-K chunk
+    confidence:         float        # 精排置信度 [0, 1]
+    is_high_confidence: bool         # confidence >= 0.75
+    web_search_results: list[dict]   # Web Search MCP 返回结果(低置信度时填充)
+
+    # ── ⑤ 生成结果 & 控制标记──────────────────────────────────────
+    answer:            str           # 最终回答文本
+    sources:           list[str]     # 来源标注列表(高置信度 RAG 时填充)
+    answer_mode:       str           # "rag" / "llm_direct"
+    existing_summary:  Optional[str] # 当前会话的历史摘要(从 DB 读取)
+    should_summarize:  bool          # 是否触发摘要压缩
+    enable_web_search: bool          # True = 低置信度时先走 Web Search 再兜底
+    fallback_used:     bool          # 是否触发了降级处理
+    structured_output: Optional[dict]  # 传给 Orchestrator 的结构化数据

+ 51 - 0
TenderAgent完整代码/backend/agents/qualification/graph.py

@@ -0,0 +1,51 @@
+# backend/agents/qualification/graph.py
+
+from langgraph.graph import StateGraph, START, END
+
+from backend.agents.qualification.state import QualificationState
+from backend.agents.qualification.nodes import (          # 导入 8 个节点函数
+    upload_to_minio_node,
+    download_pdf_node,
+    extract_text_node,
+    extract_structured_node,
+    run_six_dimensions_node,
+    diagnose_issues_node,
+    generate_summary_node,
+    save_results_node,
+)
+
+
+def build_qualification_graph():
+    """构建并编译供应商资质审查 Agent 的状态图。
+    特点:无分支、无 interrupt、无 checkpointer(一次性任务),六维度并行是性能关键路径。"""
+    builder = StateGraph(QualificationState)              # 用 QualificationState 作为图的状态类型
+
+    # ① 注册 8 个节点(节点名 → 节点函数)
+    builder.add_node("upload_to_minio",    upload_to_minio_node)
+    builder.add_node("download_pdf",       download_pdf_node)
+    builder.add_node("extract_text",       extract_text_node)
+    builder.add_node("extract_structured", extract_structured_node)
+    builder.add_node("run_six_dimensions", run_six_dimensions_node)
+    builder.add_node("diagnose_issues",    diagnose_issues_node)
+    builder.add_node("generate_summary",   generate_summary_node)
+    builder.add_node("save_results",       save_results_node)
+
+    # ② 顺次连边:START → … → END(一条直线,无分支)
+    builder.add_edge(START,                 "upload_to_minio")
+    builder.add_edge("upload_to_minio",     "download_pdf")
+    builder.add_edge("download_pdf",        "extract_text")
+    builder.add_edge("extract_text",        "extract_structured")
+    builder.add_edge("extract_structured",  "run_six_dimensions")
+    builder.add_edge("run_six_dimensions",  "diagnose_issues")
+    builder.add_edge("diagnose_issues",     "generate_summary")
+    builder.add_edge("generate_summary",    "save_results")
+    builder.add_edge("save_results",        END)
+
+    # ③ 编译。不传 checkpointer:一次性任务,不需要断点恢复
+    return builder.compile()
+if __name__ == '__main__':
+    g = build_qualification_graph()
+    nodes = [n for n in g.get_graph().nodes if n not in ("__start__", "__end__")]
+    print("图编译成功:", g is not None)
+    print("节点数:", len(nodes))
+    print("节点:", nodes)

+ 475 - 0
TenderAgent完整代码/backend/agents/qualification/nodes.py

@@ -0,0 +1,475 @@
+# backend/agents/qualification/nodes.py
+
+import asyncio
+import json
+import os
+
+from langchain_core.messages import HumanMessage, SystemMessage
+from sqlalchemy import text
+
+from backend.agents.qualification.state import (
+    QualificationState, QualificationStructured, DimensionScore, IssueList, QualificationSummary,
+)
+from backend.agents.qualification.prompts import (
+    SYSTEM_PROMPT, EXTRACT_STRUCTURED_PROMPT, DIMENSION_REVIEW_PROMPTS,
+    DIAGNOSE_ISSUES_PROMPT, GENERATE_SUMMARY_PROMPT, DIAGNOSE_THINK_PROMPT,
+)
+from backend.core.llm_factory import get_structured_llm, get_llm
+from backend.core.logger import get_logger
+from backend.dependencies import AsyncSessionLocal
+
+logger = get_logger(__name__)
+
+
+# ── 六维度定义(名称 / 权重 / 评分侧重)。权重之和 = 1.0 ──
+SIX_DIMENSIONS = [
+    {"key": "project_depth",  "name": "项目深度",   "weight": 0.30,
+     "focus": "项目描述是否有量化数据、技术选型理由、个人贡献、难点解决"},
+    {"key": "tech_match",     "name": "技术匹配度", "weight": 0.25,
+     "focus": "技术栈是否与目标岗位匹配,技能描述是否有层次(熟练/了解/掌握)"},
+    {"key": "expression",     "name": "表达规范性", "weight": 0.15,
+     "focus": "动词开头、STAR 结构、无错别字、无主语省略歧义"},
+    {"key": "structure",      "name": "资质材料结构",   "weight": 0.15,
+     "focus": "模块完整性、排版逻辑、信息密度、重要内容是否放前面"},
+    {"key": "quantification", "name": "量化程度",   "weight": 0.10,
+     "focus": "性能指标、用户量、优化幅度等量化数据的使用情况"},
+    {"key": "authenticity",   "name": "真实可信度", "weight": 0.05,
+     "focus": "表述是否夸大、技术深度描述是否与经验年限匹配、时间线是否合理"},
+]
+
+
+# ── 节点①②:本地模式空跑(保留以对齐结构 / 预留对象存储扩展点)──
+async def upload_to_minio_node(state: QualificationState) -> dict:
+    """文件已在本地,无需上传对象存储,直接跳过。"""
+    logger.info("upload_to_minio.skip", review_id=state["review_id"], reason="local_mode")
+    return {}
+
+
+async def download_pdf_node(state: QualificationState) -> dict:
+    """文件已在 pdf_local_path,无需下载,直接跳过。"""
+    logger.info("download_pdf.skip", review_id=state["review_id"], reason="local_file_exists")
+    return {}
+
+
+# ── 节点③:extract_text —— PDF 文本提取(双栏处理 + 线程池)──
+def _sync_extract_text(pdf_path: str) -> dict:
+    """同步 PDF 文本提取(线程池中运行),处理双栏布局。"""
+    import fitz                                       # PyMuPDF
+    doc = fitz.open(pdf_path)
+    page_count = len(doc)
+    all_text_parts = []
+
+    for page in doc:
+        blocks = page.get_text("blocks")              # 每块 (x0,y0,x1,y1,text,no,type)
+        text_blocks = [b for b in blocks if b[6] == 0]   # type==0 文字块
+        if not text_blocks:
+            continue
+        page_width = page.rect.width
+        midpoint   = page_width / 2
+        left_blocks  = [b for b in text_blocks if b[0] < midpoint - 20]
+        right_blocks = [b for b in text_blocks if b[0] >= midpoint - 20]
+        is_two_column = (
+            len(left_blocks) >= 2 and len(right_blocks) >= 2
+            and len(right_blocks) / max(len(text_blocks), 1) > 0.3
+        )
+        if is_two_column:                             # 双栏:左栏读完读右栏
+            left_sorted  = sorted(left_blocks,  key=lambda b: b[1])
+            right_sorted = sorted(right_blocks, key=lambda b: b[1])
+            page_text = (
+                "\n".join(b[4].strip() for b in left_sorted  if b[4].strip())
+                + "\n"
+                + "\n".join(b[4].strip() for b in right_sorted if b[4].strip())
+            )
+        else:                                         # 单栏:按 y 从上到下
+            sorted_blocks = sorted(text_blocks, key=lambda b: b[1])
+            page_text = "\n".join(b[4].strip() for b in sorted_blocks if b[4].strip())
+        all_text_parts.append(page_text)
+
+    doc.close()
+    raw_text = "\n\n---PAGE BREAK---\n\n".join(all_text_parts)
+    return {"raw_text": raw_text, "page_count": page_count}
+
+
+async def extract_text_node(state: QualificationState) -> dict:
+    """异步节点:线程池跑同步解析,避免阻塞事件循环。"""
+    pdf_path = state["pdf_local_path"]
+    try:
+        loop = asyncio.get_running_loop()
+        result = await loop.run_in_executor(None, _sync_extract_text, pdf_path)
+        raw_text, page_count = result["raw_text"], result["page_count"]
+        if len(raw_text.strip()) < 200:               # 疑似扫描件/图片 PDF,仅告警
+            logger.warning("extract_text.text_too_short", text_length=len(raw_text.strip()))
+        logger.info("extract_text.done", page_count=page_count, text_length=len(raw_text))
+        return {"raw_text": raw_text, "page_count": page_count}
+    except Exception as e:
+        logger.error("extract_text.failed", error=str(e))
+        raise
+
+
+# # ── 节点④:extract_structured —— LLM 结构化提取 ──
+# async def extract_structured_node(state: QualificationState) -> dict:
+#     """用 LLM Function Calling 把文本提取成结构化资质材料。"""
+#     raw_text = state["raw_text"]
+#     text_for_llm = raw_text[:4000] if len(raw_text) > 4000 else raw_text   # 超长截断
+#     prompt = EXTRACT_STRUCTURED_PROMPT.format(qualification_text=text_for_llm)
+#     structured_llm = get_structured_llm("qualification", QualificationStructured)
+#     structured_dict = None
+#     for attempt in range(2):                           # 结构化输出偶发返回 None → 判空+重试
+#         try:
+#             result = await structured_llm.ainvoke([
+#                 SystemMessage(content=SYSTEM_PROMPT),
+#                 HumanMessage(content=prompt),
+#             ])
+#             if result is None:
+#                 raise ValueError("structured output returned None")
+#             structured_dict = result.model_dump()
+#             break
+#         except Exception as e:
+#             if attempt == 0:
+#                 logger.warning("extract_structured.retry", error=str(e))
+#                 await asyncio.sleep(1)
+#             else:
+#                 logger.warning("extract_structured.failed", error=str(e))
+#     if structured_dict is None:
+#         structured_dict = QualificationStructured(name="未能提取").model_dump()   # 降级空结构
+#     logger.info("extract_structured.done",
+#                 name=structured_dict.get("name", ""),
+#                 projects_count=len(structured_dict.get("projects", [])))
+#     return {"structured": structured_dict}
+
+
+# ── 节点④:extract_structured —— LLM 通过提示词结构化提取 ──
+async def extract_structured_node(state: QualificationState) -> dict:
+    """用 JSON mode 把文本提取成结构化资质材料,比 Function Calling 更稳定。"""
+    raw_text = state["raw_text"]
+    text_for_llm = raw_text[:4000] if len(raw_text) > 4000 else raw_text
+
+    prompt = EXTRACT_STRUCTURED_PROMPT.format(qualification_text=text_for_llm)
+    llm = get_llm("qualification", temperature=0)
+
+    structured_dict = None
+    last_error = None
+
+    for attempt in range(2):
+        try:
+            response = await llm.ainvoke([
+                SystemMessage(content=SYSTEM_PROMPT),
+                HumanMessage(content=prompt),
+            ])
+
+            # 取出文本内容
+            content = (
+                response.content
+                if hasattr(response, "content")
+                else str(response)
+            ).strip()
+
+            # 记录原始返回,方便排查
+            logger.debug("extract_structured.raw_response",
+                         attempt=attempt, content_preview=content[:200])
+
+            # 清理 markdown 代码块(```json ... ```)
+            if "```" in content:
+                blocks = content.split("```")
+                # 取第一个代码块内容
+                for block in blocks[1::2]:
+                    block = block.strip()
+                    if block.startswith("json"):
+                        block = block[4:].strip()
+                    content = block
+                    break
+
+            raw_dict = json.loads(content)
+            # Pydantic 验证 + 补全缺失字段
+            structured_dict = QualificationStructured(**raw_dict).model_dump()
+            break
+
+        except json.JSONDecodeError as e:
+            last_error = f"JSON parse failed: {e} | raw={content[:300]}"
+            logger.warning("extract_structured.json_error", attempt=attempt, error=last_error)
+            if attempt == 0:
+                await asyncio.sleep(1)
+
+        except Exception as e:
+            last_error = f"{type(e).__name__}: {e}"
+            logger.warning("extract_structured.error", attempt=attempt, error=last_error)
+            if attempt == 0:
+                await asyncio.sleep(1)
+
+    if structured_dict is None:
+        logger.warning("extract_structured.fallback", last_error=last_error)
+        structured_dict = QualificationStructured(name="未能提取").model_dump()
+
+    logger.info("extract_structured.done",
+                name=structured_dict.get("name", ""),
+                projects_count=len(structured_dict.get("projects", [])))
+    return {"structured": structured_dict}
+
+
+
+# ── 节点⑤:run_six_dimensions —— 六维度并行评审 ──
+async def run_six_dimensions_node(state: QualificationState) -> dict:
+    """六维度并行评审:asyncio.gather 同时评,算加权综合分。"""
+    raw_text   = state["raw_text"]
+    structured = state.get("structured") or {}
+    structured_summary = _build_structured_summary(structured)
+
+    async def review_one_dimension(dim: dict) -> dict:
+        prompt_template = DIMENSION_REVIEW_PROMPTS.get(dim["key"], "")
+        if not prompt_template:
+            return _empty_dimension_score(dim)
+        prompt = prompt_template.format(
+            qualification_text=raw_text[:3000], structured_summary=structured_summary, focus=dim["focus"],
+        )
+        for attempt in range(2):                      # 最多 2 次尝试
+            try:
+                structured_llm = get_structured_llm("qualification", DimensionScore)
+                result: DimensionScore = await structured_llm.ainvoke([
+                    SystemMessage(content=SYSTEM_PROMPT),
+                    HumanMessage(content=prompt),
+                ])
+                d = result.model_dump()
+                d["dimension"], d["weight"], d["key"] = dim["name"], dim["weight"], dim["key"]
+                return d
+            except Exception as e:
+                if attempt == 0:
+                    logger.warning("six_dimensions.dimension_retry", dimension=dim["name"], error=str(e))
+                    await asyncio.sleep(1)
+                else:
+                    logger.warning("six_dimensions.dimension_failed", dimension=dim["name"], error=str(e))
+                    return _empty_dimension_score(dim)
+
+    tasks = [review_one_dimension(dim) for dim in SIX_DIMENSIONS]
+    dimension_scores = await asyncio.gather(*tasks)              # 并行
+    weighted_score = sum(d["score"] * d["weight"] for d in dimension_scores)
+    logger.info("six_dimensions.done", weighted_score=round(weighted_score, 2),
+                scores={d["key"]: d["score"] for d in dimension_scores})
+    return {"dimension_scores": list(dimension_scores), "weighted_score": round(weighted_score, 2)}
+
+
+def _build_structured_summary(structured: dict) -> str:
+    """把结构化数据浓缩成几行摘要,供评审使用(省 token)。"""
+    lines = []
+    if structured.get("name"):
+        lines.append(f"姓名:{structured['name']}")
+    if structured.get("target_position"):
+        lines.append(f"投标意向:{structured['target_position']}")
+    if structured.get("education"):
+        edu = structured["education"][0]
+        lines.append(f"最高资质等级:{edu.get('school','')} {edu.get('major','')} {edu.get('degree','')}")
+    if structured.get("skills_list"):
+        lines.append(f"技术栈:{', '.join(structured['skills_list'][:10])}")
+    if structured.get("projects"):
+        proj_names = [p.get("name", "") for p in structured["projects"]]
+        lines.append(f"项目数量:{len(structured['projects'])} 个({', '.join(proj_names[:3])})")
+    if structured.get("work_experience"):
+        lines.append(f"履约经验:{len(structured['work_experience'])} 段")
+    return "\n".join(lines) if lines else "(结构化提取失败,请基于原文评审)"
+
+
+def _empty_dimension_score(dim: dict) -> dict:
+    """维度评审失败时的降级结果。"""
+    return {"key": dim["key"], "dimension": dim["name"], "score": 50, "weight": dim["weight"],
+            "issues": ["该维度评审失败,建议人工复核"], "suggestions": []}
+
+
+# ── 节点⑥:diagnose_issues —— 逐条问题诊断 ──
+async def diagnose_issues_node(state: QualificationState) -> dict:
+    """汇总维度问题 → Think 前置推理 → 结构化生成问题清单 → 按优先级排序。"""
+    dimension_scores = state.get("dimension_scores", [])
+    raw_text         = state["raw_text"]
+    structured       = state.get("structured") or {}
+
+    all_raw_issues = []
+    for dim in dimension_scores:
+        for issue_text in dim.get("issues", []):
+            all_raw_issues.append(f"[{dim['dimension']}] {issue_text}")
+    raw_issues_text = "\n".join(f"- {i}" for i in all_raw_issues) or "(暂无)"
+
+    reasoning_trace = ""                              # Think 前置推理(可失败)
+    try:
+        dimension_scores_summary = "\n".join(
+            f"- {d['dimension']}:{d['score']}分 — 问题:{', '.join(d.get('issues', [])[:2])}"
+            for d in dimension_scores
+        )
+        think_prompt = DIAGNOSE_THINK_PROMPT.format(
+            dimension_scores_summary=dimension_scores_summary, raw_issues=raw_issues_text)
+        think_llm = get_llm("qualification", temperature=0)
+        think_resp = await think_llm.ainvoke([HumanMessage(content=think_prompt)])
+        reasoning_trace = (
+            think_resp.text if hasattr(think_resp, "text") and not callable(think_resp.text)
+            else str(think_resp.content)
+        ).strip()
+    except Exception as e:
+        logger.warning("diagnose_think.failed", error=str(e))
+
+    think_context = f"\n\n【诊断前宏观分析】\n{reasoning_trace}" if reasoning_trace else ""
+
+    prompt = DIAGNOSE_ISSUES_PROMPT.format(
+        qualification_text=raw_text[:3000],
+        structured_summary=_build_structured_summary(structured),
+        raw_issues=raw_issues_text,
+    ) + think_context
+
+    try:
+        structured_llm = get_structured_llm("qualification", IssueList)
+        result: IssueList = await structured_llm.ainvoke([
+            SystemMessage(content=SYSTEM_PROMPT), HumanMessage(content=prompt)])
+        issues = [item.model_dump() for item in result.items]
+    except Exception as e:
+        logger.warning("diagnose_issues.failed", error=str(e))
+        issues = [                                    # 降级:用维度问题,统一 medium
+            {"priority": "medium", "dimension": dim["dimension"], "description": issue,
+             "location": "资质材料全文", "suggestion": "请参考评审建议修改"}
+            for dim in dimension_scores for issue in dim.get("issues", [])
+        ]
+
+    priority_order = {"high": 0, "medium": 1, "low": 2}          # 按优先级排序
+    issues.sort(key=lambda x: priority_order.get(x.get("priority", "low"), 2))
+    logger.info("diagnose_issues.done", total=len(issues),
+                high=sum(1 for i in issues if i.get("priority") == "high"))
+    return {"issues": issues}
+
+
+# ── 节点⑦:generate_summary —— 整体评价 ──
+async def generate_summary_node(state: QualificationState) -> dict:
+    """综合结构化信息、评分、问题,生成面向投标人的整体评价。"""
+    structured       = state.get("structured") or {}
+    dimension_scores = state.get("dimension_scores", [])
+    issues           = state.get("issues", [])
+    weighted_score   = state.get("weighted_score", 0.0)
+
+    high_issues = [i["description"] for i in issues if i.get("priority") == "high"][:5]
+    high_issues_text = "\n".join(f"- {i}" for i in high_issues) or "(无高优先级问题)"
+    scores_text = "\n".join(
+        f"- {d['dimension']}:{d['score']}分(权重{int(d['weight'] * 100)}%)" for d in dimension_scores)
+
+    prompt = GENERATE_SUMMARY_PROMPT.format(
+        structured_summary=_build_structured_summary(structured),
+        scores_summary=scores_text, weighted_score=round(weighted_score, 1),
+        high_issues=high_issues_text, target_position=structured.get("target_position", "后端开发"),
+    )
+    structured_llm = get_structured_llm("qualification", QualificationSummary)
+    summary_dict = None
+    for attempt in range(2):                           # 结构化输出偶发返回 None → 判空+重试
+        try:
+            result = await structured_llm.ainvoke([
+                SystemMessage(content=SYSTEM_PROMPT), HumanMessage(content=prompt)])
+            if result is None:
+                raise ValueError("structured output returned None")
+            summary_dict = result.model_dump()
+            break
+        except Exception as e:
+            if attempt == 0:
+                logger.warning("generate_summary.retry", error=str(e))
+                await asyncio.sleep(1)
+            else:
+                logger.warning("generate_summary.failed", error=str(e))
+    if summary_dict is None:
+        summary_dict = {                              # 降级默认评价
+            "highlights": ["资质材料内容已完整提交"],
+            "core_improvements": high_issues[:2] if high_issues else ["请参考各维度建议修改"],
+            "overall_comment": f"综合评分 {round(weighted_score, 1)} 分,请参考各维度详细反馈。",
+            "fit_assessment": "与目标岗位匹配度评估暂不可用",
+        }
+    logger.info("generate_summary.done", highlights_count=len(summary_dict.get("highlights", [])))
+    return {"summary": summary_dict}
+
+
+# ── 节点⑧:save_results —— 持久化结果 ──
+async def save_results_node(state: QualificationState) -> dict:
+    """把完整结果写入 qualification_reviews(JSONB 字段),清理临时文件。"""
+    review_id = state["review_id"]
+
+    # 留一份完整结果给上层(API 可直接用)
+    structured_output = {
+        "review_id": review_id, "bidder_id": state["bidder_id"],
+        "structured": state.get("structured"),
+        "weighted_score": state.get("weighted_score", 0),
+        "dimension_scores": state.get("dimension_scores", []),
+        "issues": state.get("issues", []),
+        "summary": state.get("summary"),
+    }
+
+    async with AsyncSessionLocal() as session:        # 用统一的 SQLAlchemy 异步会话
+        try:
+            await session.execute(
+                text("""
+                    UPDATE qualification_reviews
+                    SET structured_data = :structured_data,
+                        scores          = :scores,
+                        issues          = :issues,
+                        summary         = :summary,
+                        status          = 'done',
+                        updated_at      = NOW()
+                    WHERE id = :review_id
+                """),
+                {
+                    # JSONB 列:先 json.dumps 转 JSON 字符串;ensure_ascii=False 保留中文原文
+                    "structured_data": json.dumps(state.get("structured"), ensure_ascii=False),
+                    "scores": json.dumps(
+                        {"dimension_scores": state.get("dimension_scores", []),
+                         "weighted_score": state.get("weighted_score", 0)},
+                        ensure_ascii=False),
+                    "issues":  json.dumps(state.get("issues", []), ensure_ascii=False),
+                    "summary": json.dumps(state.get("summary"), ensure_ascii=False),
+                    "review_id": review_id,
+                },
+            )
+            await session.commit()
+            logger.info("save_results.db_written", review_id=review_id)
+        except Exception as e:
+            await session.rollback()
+            logger.error("save_results.db_failed", error=str(e))
+            raise
+
+    # 清理本地临时 PDF
+    local_path = state.get("pdf_local_path", "")
+    if local_path and os.path.exists(local_path):
+        os.remove(local_path)
+        logger.info("save_results.tmp_cleaned", path=local_path)
+
+    return {"fallback_used": False, "structured_output": structured_output}
+
+
+# ── 模块自测:实测 PDF 文本提取(离线;其余节点测试见各节)──
+if __name__ == "__main__":
+    import json
+    async def main():
+        with open("./qualification.json") as f:
+            state = json.load(f)
+        result5 = await generate_summary_node(state)
+        state.update(result5)
+        with open("./qualification.json", "w") as f:
+            f.write(json.dumps(state, indent=4, ensure_ascii=False))
+    asyncio.run(main())
+    import asyncio, uuid
+    from sqlalchemy import text
+    from backend.dependencies import AsyncSessionLocal
+
+    rid = str(uuid.uuid4())
+
+    async def main():
+        async with AsyncSessionLocal() as s:        # 先插一条 processing 记录
+            sid = (await s.execute(text("SELECT id FROM users WHERE username='bidder01'"))).scalar()
+            await s.execute(text("""INSERT INTO qualification_reviews (id,tenant_id,bidder_id,pdf_minio_path,status)
+                VALUES (:id,'tenant_default',:sid,'qualifications/x.pdf','processing')"""), {"id": rid, "sid": sid})
+            await s.commit()
+        with open("./qualification.json") as f:
+            state = json.load(f)
+
+        dict1 = {"review_id": rid, "bidder_id": str(sid)}
+        state.update(dict1)
+        await save_results_node(state)
+
+        async with AsyncSessionLocal() as s:  # 查回验证(用 JSONB 操作符)
+            row = (await s.execute(text("""
+                                        SELECT status,
+                                               (scores ->>'weighted_score') AS ws,
+                                               (structured_data ->>'name')  AS nm,
+                                               jsonb_array_length(issues)   AS cnt
+                                        FROM qualification_reviews
+                                        WHERE id = :id"""), {"id": rid})).mappings().fetchone()
+            print("status:", row["status"], "| 加权分:", row["ws"], "| 姓名:", row["nm"], "| 问题数:", row["cnt"])
+
+    asyncio.run(main())

+ 275 - 0
TenderAgent完整代码/backend/agents/qualification/prompts.py

@@ -0,0 +1,275 @@
+# backend/agents/qualification/prompts.py
+
+# 系统提示:定义 LLM 的「人设」,作为 SystemMessage 放在每次对话最前
+SYSTEM_PROMPT = """你是一位经验丰富的 IT 行业职业顾问,专门为应届毕业生和初/中级工程师审查资质材料。
+你的评审严格、客观、可操作,不给出模糊的夸奖,只给出具体的问题定位和修改建议。"""
+
+
+# ──────────────────────────────────────────────────────────────
+# 结构化提取   占位符:{qualification_text}   用于:extract_structured 节点
+# ──────────────────────────────────────────────────────────────
+# EXTRACT_STRUCTURED_PROMPT = """请从以下资质材料文本中提取结构化信息。
+#
+# 【资质材料原文】
+# {qualification_text}
+#
+# 提取要求:
+# - 完整保留项目描述的原始文字,不要改写或压缩
+# - 技术栈列表每项单独一个(如 Spring Boot、MySQL,不合并)
+# - 时间格式统一为 YYYY.MM - YYYY.MM(如写"至今"则保留"至今")
+# - 无法提取的字段填空字符串,不要填"未知"或"无"
+# - 量化亮点:只提取含数字的句子(如"提升30%"、"10万DAU")"""
+
+EXTRACT_STRUCTURED_PROMPT = """请从以下资质材料文本中提取结构化信息。
+
+【资质材料原文】
+{qualification_text}
+
+【提取要求】
+- 完整保留项目描述的原始文字,不要改写或压缩
+- 技术栈列表每项单独一个(如 Spring Boot、MySQL,不合并)
+- duration 格式统一为 "YYYY.MM - YYYY.MM",如写"至今"则为 "YYYY.MM - 至今"
+- 无法提取的字段填空字符串 "",不要填"未知"或"无"
+- highlights 只提取含数字的句子(如"提升30%"、"10万DAU")
+
+【输出格式】
+只返回 JSON,不要有任何解释、前缀、markdown 代码块。
+
+{{
+  "name": "姓名",
+  "phone": "手机号",
+  "email": "邮箱",
+  "target_position": "投标意向岗位",
+  "education": [
+    {{
+      "school": "供应商名称",
+      "major": "专业名称",
+      "degree": "资质等级,如本科/硕士",
+      "duration": "YYYY.MM - YYYY.MM",
+      "gpa": ""
+    }}
+  ],
+  "skills_raw": "技能栏原始文本",
+  "skills_list": ["技术1", "技术2"],
+  "projects": [
+    {{
+      "name": "项目名称",
+      "role": "担任角色,如后端开发/负责人",
+      "duration": "YYYY.MM - YYYY.MM",
+      "tech_stack": ["技术1", "技术2"],
+      "description": "项目描述原文",
+      "highlights": ["含数字的亮点句子"]
+    }}
+  ],
+  "work_experience": [
+    {{
+      "company": "公司名称",
+      "position": "职位名称",
+      "duration": "YYYY.MM - YYYY.MM",
+      "tech_stack": [],
+      "description": "工作内容原文"
+    }}
+  ],
+  "certificates": [],
+  "self_intro": "个人简介原文"
+}}"""
+# ──────────────────────────────────────────────────────────────
+# 六维度评审   占位符:{focus} {structured_summary} {qualification_text}
+# 用于:run_six_dimensions 节点(按维度键取对应提示)
+# ──────────────────────────────────────────────────────────────
+DIMENSION_REVIEW_PROMPTS = {
+
+    "project_depth": """请评审以下资质材料在【项目深度】维度的表现。
+
+【评审重点】{focus}
+
+【结构化摘要】
+{structured_summary}
+
+【资质材料原文(前3000字)】
+{qualification_text}
+
+评分标准(0-100分):
+- 90-100:每个项目都有量化指标、明确的技术选型理由、清晰的个人贡献和难点解决
+- 70-89: 大部分项目有量化数据,个人贡献基本清晰
+- 50-69: 项目描述偏泛,缺少量化数据,个人贡献不明确
+- 30-49: 项目描述流水账,看不出技术深度
+- 0-29:  项目描述极度简陋或与岗位完全不相关""",
+
+    "tech_match": """请评审以下资质材料在【技术匹配度】维度的表现。
+
+【评审重点】{focus}
+
+【结构化摘要】
+{structured_summary}
+
+【资质材料原文(前3000字)】
+{qualification_text}
+
+评分标准(0-100分):
+- 90-100:技术栈与目标岗位高度匹配,有明确掌握程度分级
+- 70-89: 主要技术匹配,技能描述基本清晰
+- 50-69: 技术栈部分匹配,技能描述缺乏层次
+- 30-49: 技术栈与目标岗位匹配度低,或罗列过多不相关技术
+- 0-29:  几乎无相关技术经验""",
+
+    "expression": """请评审以下资质材料在【表达规范性】维度的表现。
+
+【评审重点】{focus}
+
+【结构化摘要】
+{structured_summary}
+
+【资质材料原文(前3000字)】
+{qualification_text}
+
+评分标准(0-100分):
+- 90-100:所有条目动词开头,逻辑清晰,无错别字,无歧义
+- 70-89: 大部分条目规范,偶有主语省略但不影响理解
+- 50-69: 部分条目表述不清,有明显的主语歧义或格式问题
+- 30-49: 大量表达问题,影响阅读体验
+- 0-29:  表达混乱,难以理解""",
+
+    "structure": """请评审以下资质材料在【资质材料结构】维度的表现。
+
+【评审重点】{focus}
+
+【结构化摘要】
+{structured_summary}
+
+【资质材料原文(前3000字)】
+{qualification_text}
+
+评分标准(0-100分):
+- 90-100:模块齐全,重要内容置顶,信息密度合理,一眼抓住亮点
+- 70-89: 结构基本合理,略有排版瑕疵
+- 50-69: 模块顺序不合理或有重要模块缺失
+- 30-49: 结构混乱,重要信息被埋没
+- 0-29:  无清晰结构""",
+
+    "quantification": """请评审以下资质材料在【量化程度】维度的表现。
+
+【评审重点】{focus}
+
+【结构化摘要】
+{structured_summary}
+
+【资质材料原文(前3000字)】
+{qualification_text}
+
+评分标准(0-100分):
+- 90-100:绝大多数项目/工作描述有具体数字(QPS/DAU/优化比例/服务规模)
+- 70-89: 部分有量化数据,覆盖主要项目
+- 50-69: 少量量化数据,大部分描述为定性表述
+- 30-49: 几乎无量化数据
+- 0-29:  完全无量化数据""",
+
+    "authenticity": """请评审以下资质材料在【描述一致性】维度的表现。
+
+【评审重点】{focus}
+
+【结构化摘要】
+{structured_summary}
+
+【资质材料原文(前3000字)】
+{qualification_text}
+
+评分标准(0-100分),依据技术描述与经验年限的内在一致性打分:
+- 90-100:技术深度与工作年限高度吻合,项目复杂度与经验阶段匹配,时间线清晰连贯
+- 70-89: 整体一致,个别描述略显笼统,但不影响对供应商水平的判断
+- 50-69: 项目描述与经验阶段存在明显落差,或时间线衔接不清晰
+- 30-49: 多处技术描述与经验年限不符,项目职责描述模糊或缺乏细节
+- 0-29:  描述极度空洞或前后矛盾,无法评估供应商实际水平""",
+}
+
+
+# ──────────────────────────────────────────────────────────────
+# 问题诊断   占位符:{structured_summary} {qualification_text} {raw_issues}
+# 用于:diagnose_issues 节点
+# ──────────────────────────────────────────────────────────────
+DIAGNOSE_ISSUES_PROMPT = """请基于以下信息,生成资质材料的逐条问题诊断清单。
+
+【结构化摘要】
+{structured_summary}
+
+【资质材料原文(前3000字)】
+{qualification_text}
+
+【各维度已发现问题(参考,可扩充)】
+{raw_issues}
+
+输出要求:
+1. 每条问题必须定位到资质材料的具体位置(如"项目案例-电商系统-第2句")
+2. 优先级判断标准:
+   - high:严重影响 HR 通过率,如:无量化数据/项目描述空洞/关键技术缺失
+   - medium:影响竞争力,如:表达不规范/技能描述层次不清
+   - low:锦上添花,如:证书未列出/格式细节
+3. 建议必须具体可操作(给出修改示例或明确指导)
+4. 去除重复问题,合并相似问题
+5. 总数控制在 5-15 条"""
+
+
+# ──────────────────────────────────────────────────────────────
+# 整体评价   占位符:{structured_summary} {scores_summary}
+#            {weighted_score} {high_issues} {target_position}
+# 用于:generate_summary 节点
+# ──────────────────────────────────────────────────────────────
+GENERATE_SUMMARY_PROMPT = """请为投标人生成资质审查的整体评价报告。
+
+【投标人信息摘要】
+{structured_summary}
+
+【各维度评分】
+{scores_summary}
+
+【综合得分】{weighted_score} / 100
+
+【高优先级问题(最需解决的)】
+{high_issues}
+
+【目标岗位】{target_position}
+
+生成要求:
+1. 亮点:找出资质材料中真实存在的2-3个优势(有数据支撑更好),语气积极正面
+2. 核心改进:提炼最重要的2-3个改进方向(优先级 high 的问题),具体指出是哪方面
+3. 综合评语:1-2句话,客观评价整体水平,不要过度夸奖也不要打击
+4. 匹配度:基于技术栈和经历,评估与{target_position}岗位的匹配程度(1句话)"""
+
+
+# ──────────────────────────────────────────────────────────────
+# Think 提示   占位符:{dimension_scores_summary} {raw_issues}
+# 用于:diagnose_issues 节点(结构化诊断前的「先想后答」)
+# ──────────────────────────────────────────────────────────────
+DIAGNOSE_THINK_PROMPT = """在生成资质材料问题诊断清单之前,请先进行宏观分析。
+
+【六维度评分汇总】
+{dimension_scores_summary}
+
+【已识别的原始问题列表】
+{raw_issues}
+
+请分析以下几点(中文,5-8句话):
+1. 这份资质材料最核心的短板是什么?(最多2个,直接影响竞争力)
+2. 各维度问题之间是否存在共同模式或根本原因?
+3. 声称的技能与实际项目案例描述之间是否存在明显落差?
+4. 哪些问题最影响评审专家的第一印象,应列为高优先级?
+
+直接输出分析内容,不加任何前缀标签。"""
+
+
+# ──────────────────────────────────────────────────────────────
+# 模块自测:验证占位符填充(不依赖大模型)
+# ──────────────────────────────────────────────────────────────
+if __name__ == "__main__":
+    print("六维度数量:", len(DIMENSION_REVIEW_PROMPTS))
+    print("维度键:", list(DIMENSION_REVIEW_PROMPTS.keys()))
+
+    # # 填充提取提示
+    p1 = EXTRACT_STRUCTURED_PROMPT.format(qualification_text="张三,后端开发,熟悉Spring Boot")
+    print("提取提示填充后开头:", repr(p1[:24]))
+    #
+    # # 填充一个维度提示(3 个占位符都给值)
+    p2 = DIMENSION_REVIEW_PROMPTS["project_depth"].format(
+        focus="项目是否有量化数据", structured_summary="姓名:张三", qualification_text="项目:电商系统")
+    print("project_depth 填充后含评审重点:", "项目是否有量化数据" in p2)
+    print("project_depth 含评分标准 90-100:", "90-100" in p2)

Plik diff jest za duży
+ 2 - 0
TenderAgent完整代码/backend/agents/qualification/qualification.json


+ 160 - 0
TenderAgent完整代码/backend/agents/qualification/state.py

@@ -0,0 +1,160 @@
+from typing import Annotated, Optional
+from typing_extensions import TypedDict
+from langgraph.graph.message import add_messages      # 消息列表的「追加」合并器(回顾 2.4 拓展)
+from langchain_core.messages import BaseMessage
+from pydantic import BaseModel, Field
+
+
+# ──────────────────────────────────────────────────────────────
+# 一、结构化提取结果(LLM Function Calling 的输出目标)
+# ──────────────────────────────────────────────────────────────
+
+class EducationItem(BaseModel):
+    """单条资质信息。"""
+    school:   str = Field(description="供应商名称")          # description 会发给 LLM,指导它提取
+    major:    str = Field(description="专业名称")
+    degree:   str = Field(description="资质等级:本科/专科/硕士等")
+    duration: str = Field(description="在校时间,如 2020.09 - 2024.06")
+    gpa:      str = Field(default="", description="GPA 或成绩(可选)")  # 可选字段,提取不到给空串
+
+
+class ProjectItem(BaseModel):
+    """单条项目案例。"""
+    name:        str       = Field(description="项目名称")
+    role:        str       = Field(description="担任角色,如:后端开发/全栈/负责人")
+    duration:    str       = Field(description="项目时间,如 2023.06 - 2023.12")
+    tech_stack:  list[str] = Field(description="使用的技术栈列表,如 [Spring Boot, MySQL, Redis]")
+    description: str       = Field(description="项目描述原文(保留原始表述)")
+    # default_factory=list:列表默认值必须用工厂函数,不能写 default=[](会被所有实例共享,经典坑)
+    highlights:  list[str] = Field(default_factory=list, description="量化亮点句子列表(含数字的句子)")
+
+
+class WorkItem(BaseModel):
+    """单条工作/实习经历。"""
+    company:     str       = Field(description="公司名称")
+    position:    str       = Field(description="职位名称")
+    duration:    str       = Field(description="工作时间")
+    tech_stack:  list[str] = Field(default_factory=list, description="涉及技术栈")
+    description: str       = Field(description="工作内容原文")
+
+
+class QualificationStructured(BaseModel):
+    """资质材料结构化提取结果(完整 Schema)——extract_structured 节点的输出目标。"""
+    # 基本信息
+    name:            str  = Field(description="姓名")
+    phone:           str  = Field(default="", description="手机号")
+    email:           str  = Field(default="", description="邮箱")
+    target_position: str  = Field(default="", description="投标意向岗位")
+    # 资质信息(按时间倒序)
+    education:       list[EducationItem] = Field(default_factory=list)
+    # 技能
+    skills_raw:      str       = Field(default="", description="技能栏原始文本")
+    skills_list:     list[str] = Field(default_factory=list, description="解析后的技术标签列表")
+    # 项目案例
+    projects:        list[ProjectItem] = Field(default_factory=list)
+    # 工作/实习经历
+    work_experience: list[WorkItem] = Field(default_factory=list)
+    # 其他
+    certificates:    list[str] = Field(default_factory=list, description="证书列表")
+    self_intro:      str       = Field(default="", description="个人简介/自我评价原文")
+
+# ──────────────────────────────────────────────────────────────
+# 二、六维度评审结果
+# ──────────────────────────────────────────────────────────────
+
+class DimensionScore(BaseModel):
+    """单个评审维度结果。"""
+    # dimension/weight 由代码层填(来自 4.5 的 SIX_DIMENSIONS),LLM 不用管,给默认值即可
+    dimension:   str       = Field(default="", description="维度名称(代码层覆盖,LLM 可留空)")
+    score:       int       = Field(description="得分 0-100")               # LLM 真正要给的
+    weight:      float     = Field(default=0.0, description="权重(代码层覆盖,LLM 可填 0)")
+    issues:      list[str] = Field(default_factory=list, description="该维度问题列表")
+    suggestions: list[str] = Field(default_factory=list, description="改进建议列表")
+
+
+class IssueItem(BaseModel):
+    """单条诊断问题。"""
+    priority:    str = Field(description="优先级:high / medium / low")
+    dimension:   str = Field(description="所属维度")
+    description: str = Field(description="问题描述(1句话)")
+    location:    str = Field(description="问题在资质材料中的定位,如:项目案例-电商系统-第2句")
+    suggestion:  str = Field(description="具体修改建议(可操作)")
+
+
+class IssueList(BaseModel):
+    """IssueItem 列表的包装类。
+    为什么要包一层:with_structured_output 要求顶层是「对象」而非「裸列表」,
+    所以不能直接让 LLM 返回 list[IssueItem],要包成 {items: [...]}。"""
+    items: list[IssueItem]
+
+
+class QualificationSummary(BaseModel):
+    """资质材料整体评价——generate_summary 节点的输出目标。"""
+    highlights:        list[str] = Field(description="2-3 条核心亮点")
+    core_improvements: list[str] = Field(description="2-3 条最重要的改进方向")
+    overall_comment:   str       = Field(description="1-2 句综合评语")
+    fit_assessment:    str       = Field(description="对目标岗位的匹配度评估(1句话)")
+
+
+class QualificationState(TypedDict):
+    """供应商资质审查 Agent 完整 State。字段按数据流阶段分组,对应各节点的产出。"""
+
+    # ── 请求上下文 ──
+    messages:       Annotated[list[BaseMessage], add_messages]  # 对话消息(追加合并)
+    bidder_id:     str
+    tenant_id:      str
+    review_id:      str        # qualification_reviews 表的 UUID
+    pdf_minio_path: str        # 对象存储路径(本地模式留空,遗留字段)
+    pdf_local_path: str        # 本地临时文件路径(真正用的)
+
+    # ── 解析中间结果 ──
+    raw_text:       str        # extract_text 产出:PDF 全文
+    page_count:     int        # PDF 页数
+
+    # ── 结构化提取结果 ──
+    structured:     Optional[dict]   # extract_structured 产出:QualificationStructured.model_dump()
+
+    # ── 六维度评审结果 ──
+    dimension_scores: list[dict]     # run_six_dimensions 产出:六维度各一条
+    weighted_score:   float          # 加权综合得分 0-100
+
+    # ── 逐条问题诊断 ──
+    issues:          list[dict]      # diagnose_issues 产出:IssueItem 列表,按优先级排序
+
+    # ── 整体评价 ──
+    summary:         Optional[dict]  # generate_summary 产出:QualificationSummary.model_dump()
+
+    # ── 降级标记 ──
+    fallback_used:    bool
+    structured_output: Optional[dict]
+
+if __name__ == '__main__':
+    from pydantic import ValidationError
+
+    # ① 嵌套构建一份结构化资质材料,并 dump 成字典
+    r = QualificationStructured(
+        name="张三",
+        target_position="后端开发",
+        education=[EducationItem(school="某大学", major="计算机", degree="本科", duration="2020.09-2024.06")],
+        skills_list=["Java", "Spring Boot", "MySQL"],
+        projects=[ProjectItem(name="电商系统", role="后端", duration="2023.06-2023.12",
+                              tech_stack=["Spring Boot", "Redis"], description="做了下单与库存")],
+    )
+    # print(f'r-->{r}')
+    # print("=="*50)
+    # d = r.model_dump()
+    # print(f'd-->{d}')
+    # print("=="*50)
+    # print("① 结构化资质材料 name:", d["name"], "| 项目数:", len(d["projects"]),
+    #       "| 第一个项目技术栈:", d["projects"][0]["tech_stack"])
+    # # ③ IssueList 包装
+    # il = IssueList(items=[IssueItem(priority="high", dimension="项目深度",
+    #                                 description="项目描述空洞", location="项目-电商-第2句", suggestion="补充技术难点")])
+    # print("③ IssueList 条数:", len(il.items), "| 第一条优先级:", il.items[0].priority)
+
+    # ④ 校验:缺必填字段会报错
+    try:
+        EducationItem(school="只有供应商")   # 缺 major/degree/duration
+    except ValidationError as e:
+        print("④ 缺必填字段校验:", e.error_count(), "个错误(major/degree/duration 必填)")
+        raise

+ 15 - 0
TenderAgent完整代码/backend/api/router.py

@@ -0,0 +1,15 @@
+# backend/api/router.py
+# API 路由总入口
+
+from fastapi import APIRouter
+from backend.api.v1 import auth, qa, bid_review, qualification, defense, unified_chat  # 六个子模块
+
+api_router = APIRouter()                              # 总路由
+
+# 把每个子 router 带前缀 + 标签聚合进来
+api_router.include_router(auth.router,          prefix="/auth",      tags=["认证"])
+api_router.include_router(unified_chat.router,  prefix="/chat",      tags=["AI助手"])   # 本章统一入口
+api_router.include_router(qa.router,            prefix="/qa",        tags=["智能问答"])  # 第 5 章
+api_router.include_router(bid_review.router,          prefix="/bid_review",      tags=["投标文件评分"])  # 第 6 章
+api_router.include_router(qualification.router,        prefix="/qualification",    tags=["资质审查"])  # 第 4 章
+api_router.include_router(defense.router,     prefix="/defense", tags=["供应商答辩"])  # 第 7 章

+ 108 - 0
TenderAgent完整代码/backend/api/v1/auth.py

@@ -0,0 +1,108 @@
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import uvicorn
+from fastapi import APIRouter, HTTPException, status, Depends
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import text
+from jose import jwt
+from passlib.context import CryptContext
+
+from backend.config import get_settings
+from backend.dependencies import get_db,get_current_user
+from backend.core.logger import get_logger
+
+# ── 兼容性补丁:passlib 1.7.4 要读 bcrypt.__about__.__version__,而 bcrypt>=4 删了它 ──
+import bcrypt as _bcrypt_mod, types as _types
+if not hasattr(_bcrypt_mod, "__about__"):
+    _about = _types.SimpleNamespace(__version__=getattr(_bcrypt_mod, "__version__", "4.x"))
+    _bcrypt_mod.__about__ = _about   # 注入假的 __about__,让 passlib 能探测到版本
+
+router = APIRouter()                                          # 本模块的路由集合
+logger = get_logger(__name__)
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")  # 密码哈希上下文
+
+
+class LoginRequest(BaseModel):
+    """登录请求体。"""
+    username: str = Field(..., description="用户名或邮箱")
+    password: str = Field(..., description="密码")
+
+
+class TokenResponse(BaseModel):
+    """登录成功的响应体。"""
+    access_token: str                 # JWT 令牌
+    token_type:   str = "bearer"      # 令牌类型,固定 bearer
+    expires_in:   int                 # 有效期(秒)
+    role:         str                 # 用户角色
+    user_id:      str                 # 用户ID
+
+
+
+def _create_access_token(data: dict, expires_minutes: int) -> str:
+    """把身份信息 + 过期时间打包,用密钥签名成 JWT 字符串。"""
+    settings = get_settings()
+    payload = data.copy()                                            # 拷一份,避免改到原字典
+    expire = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
+    payload["exp"] = expire                                          # exp 是 JWT 标准的过期字段
+    return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
+
+@router.post("/login", response_model=TokenResponse)
+async def login(
+    req: LoginRequest,                            # 请求体自动解析为 LoginRequest
+    db: AsyncSession = Depends(get_db),           # 注入数据库会话
+):
+    # print(f'req-->{req}')
+    # print(f'req-->{req.model_dump()}')
+    """用户登录,返回 JWT Access Token(支持用户名或邮箱登录)。"""
+    settings = get_settings()
+
+    # 查用户:用户名或邮箱都行(:val 同时匹配两列),参数化查询防注入(回顾 2.6)
+    result = await db.execute(
+        text(
+            "SELECT id, password_hash, role, tenant_id, is_active "
+            "FROM users WHERE username = :val OR email = :val LIMIT 1"
+        ),
+        {"val": req.username},
+    )
+    row = result.fetchone()
+    # print(f'row-->{row}')
+    if not row:                                   # 用户不存在
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
+
+    if not row.is_active:                         # 账号被禁用
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用,请联系管理员")
+    # 密码校验是 CPU 密集型(~100ms),用线程池避免阻塞事件循环(回顾 2.1.4)
+    loop = asyncio.get_running_loop()
+    password_ok = await loop.run_in_executor(
+        None, pwd_context.verify, req.password, row.password_hash
+    )
+    if not password_ok:                           # 密码不对
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
+
+    # 校验通过,签发 Token(把用户身份装进去)
+    token = _create_access_token(
+        data={"sub": str(row.id), "role": row.role, "tenant_id": row.tenant_id},
+        expires_minutes=settings.jwt_access_token_expire_minutes,
+    )
+    logger.info("auth.login_success", user_id=str(row.id), role=row.role)
+
+    return TokenResponse(
+        access_token=token,
+        expires_in=settings.jwt_access_token_expire_minutes * 60,   # 分钟转秒
+        role=row.role,
+        user_id=str(row.id),
+    )
+
+@router.get("/me")
+async def get_me(
+    current_user: dict = Depends(get_current_user),   # 注入当前用户(顺带完成鉴权)
+):
+    """获取当前登录用户信息(用于验证 Token 是否有效)。"""
+    return current_user
+if __name__ == '__main__':
+    from fastapi import FastAPI
+    app = FastAPI()
+    app.include_router(router)
+    uvicorn.run(app, host="0.0.0.0", port=8000)

+ 501 - 0
TenderAgent完整代码/backend/api/v1/bid_review.py

@@ -0,0 +1,501 @@
+# backend/api/v1/bid_review.py
+
+import asyncio
+import os
+import tempfile
+import uuid
+
+from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, status
+from pydantic import BaseModel
+from langgraph.types import Command
+from sqlalchemy import text
+
+from backend.agents.bid_review.graph import build_bid_review_graph
+from backend.dependencies import get_current_user, AsyncSessionLocal
+from backend.core.memory import build_config
+from backend.core.logger import get_logger
+
+router = APIRouter()
+logger = get_logger(__name__)
+
+# 模块级编译图(只执行一次,避免每次请求重新编译)
+_graph = build_bid_review_graph()
+
+# 持有 background task 引用,防止 asyncio GC 回收未完成的任务
+_background_tasks: set[asyncio.Task] = set()
+
+
+@router.post("/submit", status_code=202)
+async def submit_bid_review(
+    bid_review_id: str      = Form(...),
+    file:    UploadFile = File(...),
+    current_user: dict = Depends(get_current_user),
+):
+    """
+    投标人提交作答 Word 投标文件,触发 AI 三轨评分(异步后台)。
+    立即返回 202 和 submission_id,评分在后台异步完成。
+    """
+    if not (file.filename or "").endswith(".docx"):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="仅支持 .docx 格式",
+        )
+
+    submission_id = str(uuid.uuid4())
+    bidder_id    = current_user["user_id"]
+    tmp_path      = os.path.join(tempfile.gettempdir(), f"{submission_id}.docx")
+
+    # 把上传文件保存到临时目录
+    content = await file.read()
+    with open(tmp_path, "wb") as f:
+        f.write(content)
+
+    # ── 验证投标文件 ID 是否存在 ─────────────────────────────────────
+    async with AsyncSessionLocal() as session:
+        bid_review_row = (await session.execute(
+            text("SELECT id FROM bid_reviews WHERE id = :bid_review_id AND tenant_id = :tenant_id"),
+            {"bid_review_id": bid_review_id, "tenant_id": current_user["tenant_id"]},
+        )).fetchone()
+
+    if not bid_review_row:
+        if os.path.exists(tmp_path):
+            os.remove(tmp_path)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=(
+                f"投标文件 ID 不存在({bid_review_id})。"
+                "本地开发环境请先运行 python scripts/seed_data.py。"
+            ),
+        )
+
+    # ── 检查是否已有提交记录(同一供应商同一投标文件只能有一份)────────
+    existing_id: str | None = None
+    async with AsyncSessionLocal() as session:
+        row = (await session.execute(
+            text("""
+                SELECT id, status FROM bid_review_submissions
+                WHERE bid_review_id = :bid_review_id AND bidder_id = :bidder_id
+            """),
+            {"bid_review_id": bid_review_id, "bidder_id": bidder_id},
+        )).fetchone()
+
+    if row:
+        _existing_id, _existing_status = str(row[0]), row[1]
+        if _existing_status in ("pending_review", "reviewed", "published"):
+            # 已有待确认或已发布的结果,拒绝重新提交
+            if os.path.exists(tmp_path):
+                os.remove(tmp_path)
+            raise HTTPException(
+                status_code=status.HTTP_409_CONFLICT,
+                detail="该投标文件已有待确认或已发布的评分结果,无法重新提交。",
+            )
+        # ai_processing 或 submitted 状态:允许重提(删旧记录)
+        existing_id = _existing_id
+
+    # ── 写入提交记录 ─────────────────────────────────────────────
+    async with AsyncSessionLocal() as session:
+        async with session.begin():
+            if existing_id:
+                await session.execute(
+                    text("DELETE FROM bid_review_submissions WHERE id = :id"),
+                    {"id": existing_id},
+                )
+            await session.execute(
+                text("""
+                    INSERT INTO bid_review_submissions
+                        (id, tenant_id, bid_review_id, bidder_id, status, submitted_at)
+                    VALUES
+                        (:id, :tenant_id, :bid_review_id, :bidder_id, 'ai_processing', NOW())
+                """),
+                {
+                    "id":         submission_id,
+                    "tenant_id":  current_user["tenant_id"],
+                    "bid_review_id":    bid_review_id,
+                    "bidder_id": bidder_id,
+                },
+            )
+
+    config = build_config(current_user["user_id"], submission_id)
+    print(f'config: {config}')
+
+    initial_state = {
+        "messages":           [],
+        "bidder_id":         current_user["user_id"],
+        "tenant_id":          current_user["tenant_id"],
+        "session_id":         submission_id,
+        "bid_review_id":            bid_review_id,
+        "submission_id":      submission_id,
+        "word_file_path":     tmp_path,
+        "parsed_questions":   [],
+        "objective_results":  [],
+        "subjective_results": [],
+        "code_results":       [],
+        "pre_review_summary": {},
+        "weak_points":        [],
+        "weak_points_summary": "",
+        "expert_decision":   None,
+        "final_results":      [],
+        "structured_output":  None,
+        "fallback_used":      False,
+        "expert_notified":   False,
+        "published":          False,
+    }
+
+    # ── 后台任务的 done callback ──────────────────────────────
+    def _on_task_done(t: asyncio.Task):
+        _background_tasks.discard(t)   # 从 set 移除,允许 GC
+        task_failed = not t.cancelled() and t.exception() is not None
+
+        if task_failed:
+            logger.error(
+                "bid_review.background_task_failed",
+                submission_id=submission_id,
+                error=str(t.exception()),
+                exc_info=t.exception(),
+            )
+
+        async def _cleanup():
+            if task_failed:
+                # 评分失败时,把状态回滚为 submitted,允许投标人重新提交
+                try:
+                    async with AsyncSessionLocal() as db_sess:
+                        async with db_sess.begin():
+                            await db_sess.execute(
+                                text("""
+                                    UPDATE bid_review_submissions
+                                    SET status = 'submitted', updated_at = NOW()
+                                    WHERE id = :sid AND status = 'ai_processing'
+                                """),
+                                {"sid": submission_id},
+                            )
+                except Exception as db_err:
+                    logger.warning(
+                        "bid_review.status_reset_failed",
+                        submission_id=submission_id,
+                        error=str(db_err),
+                    )
+            # 无论成败都清理临时文件
+            if os.path.exists(tmp_path):
+                try:
+                    os.remove(tmp_path)
+                except OSError:
+                    pass
+
+        cleanup_task = asyncio.ensure_future(_cleanup())
+        _background_tasks.add(cleanup_task)
+        cleanup_task.add_done_callback(_background_tasks.discard)
+
+    # ── 启动后台评分任务 ──────────────────────────────────────
+    # graph.ainvoke 会在 expert_review_node 处 interrupt,自动返回
+    # 此时 task 完成,_on_task_done 被调用;图状态存在 MemorySaver 里等待恢复
+    task = asyncio.create_task(_graph.ainvoke(initial_state, config=config))
+    _background_tasks.add(task)
+    task.add_done_callback(_on_task_done)
+
+    logger.info(
+        "bid_review.submitted",
+        submission_id=submission_id,
+        bidder_id=current_user["user_id"],
+        bid_review_id=bid_review_id,
+    )
+
+    return {
+        "submission_id": submission_id,
+        "status":        "ai_processing",
+        "message":       "投标文件已提交,AI 正在评分中,完成后等待专家确认。",
+    }
+
+
+@router.get("/my-submissions")
+async def list_my_submissions(
+    current_user: dict = Depends(get_current_user),
+):
+    """投标人查询自己所有的投标文件提交记录"""
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT es.id AS submission_id, e.title AS bid_review_title,
+                       es.bid_review_id, es.status, es.submitted_at
+                FROM bid_review_submissions es
+                LEFT JOIN bid_reviews e ON e.id = es.bid_review_id
+                WHERE es.bidder_id = :bidder_id
+                ORDER BY es.submitted_at DESC
+                LIMIT 20
+            """),
+            {"bidder_id": current_user["user_id"]},
+        )
+        rows = result.mappings().all()
+
+    return {
+        "items": [
+            {
+                "submission_id": str(r["submission_id"]),
+                "bid_review_id":       str(r["bid_review_id"]),
+                "bid_review_title":    r["bid_review_title"] or "",
+                "status":        r["status"],
+                "submitted_at":  r["submitted_at"].isoformat() if r["submitted_at"] else None,
+            }
+            for r in rows
+        ]
+    }
+
+
+@router.get("/my-submissions/{submission_id}")
+async def get_my_submission(
+    submission_id: str,
+    current_user:  dict = Depends(get_current_user),
+):
+    """
+    投标人查询评分状态与结果。
+    - 未发布(ai_processing / pending_review):只返回状态,等待
+    - 已发布(published):返回完整评分结果(逐题 + 薄弱点)
+    """
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT id, status, weak_points, weak_points_summary
+                FROM bid_review_submissions
+                WHERE id = :sid AND bidder_id = :bidder_id
+            """),
+            {"sid": submission_id, "bidder_id": current_user["user_id"]},
+        )
+        row = result.mappings().fetchone()
+
+    if not row:
+        raise HTTPException(status_code=404, detail="提交记录不存在")
+
+    # 未发布:只返回状态,让投标人继续轮询
+    if row["status"] != "published":
+        return {"submission_id": submission_id, "status": row["status"]}
+
+    # 已发布:从 bid_review_reviews 读取完整结果
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT er.question_id, er.question_type, er.knowledge_tag,
+                       er.bidder_response, er.ai_score, er.ai_feedback,
+                       er.expert_score, er.expert_comment, er.final_score,
+                       er.needs_review, er.ai_raw_result,
+                       q.question_no, q.content, q.correct_answer, q.score AS full_score
+                FROM bid_review_reviews er
+                JOIN questions q ON q.id = er.question_id
+                WHERE er.submission_id = :sid
+                ORDER BY q.question_no
+            """),
+            {"sid": submission_id},
+        )
+        reviews = result.mappings().all()
+
+    by_question      = []
+    total_score      = 0
+    full_score_total = 0
+
+    for r in reviews:
+        import json as _json
+        raw = r["ai_raw_result"] or {}
+        if isinstance(raw, str):
+            try:
+                raw = _json.loads(raw)
+            except Exception:
+                raw = {}
+        final = r["final_score"] if r["final_score"] is not None else r["ai_score"]
+        by_question.append({
+            "question_id":       str(r["question_id"]),
+            "question_no":       r["question_no"],
+            "question_type":     r["question_type"],
+            "full_score":        r["full_score"],
+            "score":             r["ai_score"],
+            "final_score":       final,
+            "bidder_response":    r["bidder_response"] or "",
+            "correct_answer":    r["correct_answer"] or "",
+            "ai_feedback":       r["ai_feedback"] or "",
+            "expert_comment":   r["expert_comment"] or "",
+            "needs_review":      r["needs_review"],
+            "point_results":     raw.get("point_results", []),
+            "quality_feedback":  raw.get("quality_feedback", []),
+        })
+        total_score      += final or 0
+        full_score_total += r["full_score"] or 0
+
+    weak_points         = row["weak_points"] if isinstance(row["weak_points"], list) else []
+    weak_points_summary = row["weak_points_summary"] or ""
+
+    return {
+        "submission_id": submission_id,
+        "status":        "published",
+        "pre_review_summary": {
+            "total_score": total_score,
+            "full_score":  full_score_total,
+            "score_rate":  round(total_score / full_score_total, 4) if full_score_total else 0,
+            "by_question": by_question,
+        },
+        "weak_points":         weak_points,
+        "weak_points_summary": weak_points_summary,
+    }
+
+
+
+@router.get("/submissions/{submission_id}/review")
+async def get_submission_review(
+    submission_id: str,
+    current_user:  dict = Depends(get_current_user),
+):
+    """评审专家获取 AI 预评分详情(从 MemorySaver 读取图暂停时的 State)"""
+    config = {"configurable": {"thread_id": await _get_thread_id(submission_id)}}
+
+    try:
+        state_snapshot = await _graph.aget_state(config)
+        print(f'state: {state_snapshot}')
+        print(f'state: {state_snapshot.values}')
+    except Exception as e:
+        raise HTTPException(status_code=404, detail=f"找不到该提交记录的评分状态:{e}")
+
+    if not state_snapshot or not state_snapshot.values:
+        raise HTTPException(status_code=404, detail="评分尚未完成或记录不存在")
+
+    sv = state_snapshot.values
+    return {
+        "submission_id":       submission_id,
+        "bidder_id":          sv.get("bidder_id", ""),
+        "pre_review_summary":  sv.get("pre_review_summary", {}),
+        "weak_points":         sv.get("weak_points", []),
+        "weak_points_summary": sv.get("weak_points_summary", ""),
+    }
+
+# ── 辅助函数:从 DB 获取 thread_id ───────────────────────────
+
+async def _get_thread_id(submission_id: str) -> str:
+    """从 bid_review_submissions 读 bidder_id,拼出 MemorySaver 的 thread_id"""
+    from backend.core.memory import build_thread_id
+    try:
+        async with AsyncSessionLocal() as session:
+            result = await session.execute(
+                text("SELECT bidder_id FROM bid_review_submissions WHERE id = :sid"),
+                {"sid": submission_id},
+            )
+            row = result.fetchone()
+            if row:
+                return build_thread_id(str(row[0]), submission_id)
+    except Exception as e:
+        logger.warning("bid_review.get_thread_id_failed", submission_id=submission_id, error=str(e))
+    return f"bidder_unknown_session_{submission_id}"
+
+
+@router.get("/pending-reviews")
+async def get_pending_reviews(
+    current_user: dict = Depends(get_current_user),
+):
+    """评审专家获取所有待确认的提交(status=pending_review)"""
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT
+                    es.id           AS submission_id,
+                    es.bidder_id,
+                    u.username      AS bidder_name,
+                    e.title         AS bid_review_title,
+                    es.submitted_at
+                FROM bid_review_submissions es
+                JOIN users u ON u.id = es.bidder_id
+                JOIN bid_reviews e ON e.id = es.bid_review_id
+                WHERE es.tenant_id = :tenant_id
+                  AND es.status    = 'pending_review'
+                ORDER BY es.submitted_at DESC
+            """),
+            {"tenant_id": current_user["tenant_id"]},
+        )
+        rows = result.mappings().all()
+
+    items = []
+    for row in rows:
+        submission_id = str(row["submission_id"])
+        pre_review    = {"total_score": 0, "full_score": 0, "needs_review_count": 0}
+        weak_points   = []
+
+        # 从 MemorySaver 读取 AI 预评分结果(图暂停时保存的 State)
+        try:
+            thread_id = await _get_thread_id(submission_id)
+            config    = {"configurable": {"thread_id": thread_id}}
+            snapshot  = await _graph.aget_state(config)
+            if snapshot and snapshot.values:
+                sv         = snapshot.values
+                summary    = sv.get("pre_review_summary", {})
+                pre_review = {
+                    "total_score":        summary.get("total_score", 0),
+                    "full_score":         summary.get("full_score", 0),
+                    "needs_review_count": summary.get("needs_review_count", 0),
+                }
+                weak_points = sv.get("weak_points", [])
+        except Exception as _e:
+            logger.warning("bid_review.pending_review_state_read_failed",
+                           submission_id=submission_id, error=str(_e))
+
+        items.append({
+            "submission_id": submission_id,
+            "bidder_name":  row["bidder_name"],
+            "bid_review_title":    row["bid_review_title"],
+            "submitted_at":  row["submitted_at"].isoformat() if row["submitted_at"] else None,
+            "pre_review":    pre_review,
+            "weak_points":   weak_points[:3],  # 列表只显示前3条
+        })
+
+    return {"items": items, "total": len(items)}
+
+
+class ConfirmRequest(BaseModel):
+    action:        str
+    modifications: list[dict] = []
+
+
+@router.post("/submissions/{submission_id}/confirm")
+async def confirm_review(
+    submission_id: str,
+    req:           ConfirmRequest,
+    current_user:  dict = Depends(get_current_user),
+):
+    """
+    专家确认评分结果,恢复 interrupt,触发 apply_expert_decision → publish_results。
+    """
+    if req.action not in ("approve", "modify"):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="action 只能为 approve 或 modify",
+        )
+
+    thread_id = await _get_thread_id(submission_id)
+    config    = {"configurable": {"thread_id": thread_id}}
+
+    decision = {
+        "action":        req.action,
+        "modifications": req.modifications,
+        "expert_id":    current_user["user_id"],
+    }
+
+    try:
+        # Command(resume=decision) 让图从 expert_review_node 的 interrupt() 处继续
+        result = await _graph.ainvoke(Command(resume=decision), config=config)
+    except Exception as e:
+        logger.error("bid_review.confirm_failed", submission_id=submission_id, error=str(e))
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"发布失败:{e}",
+        )
+
+    structured = result.get("structured_output", {}) or {}
+
+    logger.info(
+        "bid_review.published",
+        submission_id=submission_id,
+        expert_id=current_user["user_id"],
+        final_score=structured.get("final_score", 0),
+    )
+
+    return {
+        "submission_id":       submission_id,
+        "status":              "published",
+        "final_score":         structured.get("final_score", 0),
+        "full_score":          structured.get("full_score", 0),
+        "score_rate":          structured.get("score_rate", 0),
+        "weak_points":         structured.get("weak_points", []),
+        "weak_points_summary": structured.get("weak_points_summary", ""),
+    }

+ 351 - 0
TenderAgent完整代码/backend/api/v1/defense.py

@@ -0,0 +1,351 @@
+# backend/api/v1/defense.py
+
+import json
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException
+from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
+from pydantic import BaseModel
+from sse_starlette.sse import EventSourceResponse
+from sqlalchemy import text
+
+from backend.agents.defense.graph import build_defense_graph
+from backend.agents.defense.state import DefenseStage
+from backend.core.logger import get_logger
+from backend.core.memory import build_thread_id, build_config
+from backend.dependencies import AsyncSessionLocal, get_current_user
+
+router = APIRouter()
+logger = get_logger(__name__)
+_graph = build_defense_graph()   # 模块级单例:导入时编译一次图,所有请求共用
+
+
+class StartSessionRequest(BaseModel):
+    target_position:  str                  # 目标岗位(必填)
+    qualification_review_id: str | None = None    # 资质审查记录 ID(可选,做项目联动)
+
+
+class ChatRequest(BaseModel):
+    message: str                           # 投标人这一轮发的消息
+
+@router.post("/sessions", status_code=201)
+async def start_session(
+    req: StartSessionRequest,                            # 请求体:岗位 + 可选资质材料 ID
+    current_user: dict = Depends(get_current_user),      # 依赖注入:当前登录用户
+):
+    """
+    创建新答辩会话,触发首轮开场白(同步等待 LLM 返回)。
+
+    请求:{target_position, qualification_review_id?}
+    响应:201 + {session_id, target_position, status, message(开场白)}
+    """
+    bidder_id = current_user["user_id"]                 # 投标人 ID
+    tenant_id  = current_user["tenant_id"]               # 租户 ID
+    session_id = str(uuid.uuid4())                       # 生成会话 ID
+    thread_id  = build_thread_id(bidder_id, session_id) # 拼 MemorySaver 的 key
+
+    # 1. 写入初始会话记录(status=in_progress)
+    async with AsyncSessionLocal() as session:
+        await session.execute(
+            text("""
+                INSERT INTO defense_sessions
+                    (id, tenant_id, bidder_id, session_id, thread_id,
+                     target_position, qualification_review_id, status)
+                VALUES
+                    (:id, :tenant_id, :bidder_id, :session_id, :thread_id,
+                     :target_position, :qualification_review_id, 'in_progress')
+            """),
+            {
+                "id":               str(uuid.uuid4()),
+                "tenant_id":        tenant_id,
+                "bidder_id":       bidder_id,
+                "session_id":       session_id,
+                "thread_id":        thread_id,
+                "target_position":  req.target_position,
+                "qualification_review_id": req.qualification_review_id,
+            },
+        )
+        await session.commit()
+
+    # 2. 构造完整 initial_state(首轮 MemorySaver 里还没存档,必须传全量 22 字段)
+    initial_state = {
+        "messages":            [HumanMessage(content="[开始答辩]")],  # 触发图执行的特殊消息
+        "bidder_id":          bidder_id,
+        "tenant_id":           tenant_id,
+        "session_id":          session_id,
+        "target_position":     req.target_position,
+        "qualification_review_id":    req.qualification_review_id,
+        "qualification_projects":     [],
+        "qualification_skills":       [],
+        "current_stage":       DefenseStage.WARMUP.value,  # 从热身开始
+        "stage_turn_count":    0,
+        "total_turn_count":    0,                         # 0 → load_context 走首轮初始化
+        "max_turns":           40,
+        "question_bank":       [],
+        "current_question":    None,
+        "projects_asked":      [],
+        "last_answer_quality": "adequate",
+        "followup_count":      0,
+        "existing_summary":    None,
+        "should_summarize":    False,
+        "report":              None,
+        "fallback_used":       False,
+        "structured_output":   None,
+    }
+
+    # 3. 首轮图执行(同步等待,拿到开场白)
+    config  = build_config(bidder_id, session_id)        # 带 thread_id 的运行配置
+    result  = await _graph.ainvoke(initial_state, config=config)  # 跑一遍图
+    messages     = result.get("messages", [])             # 取结果消息
+    opening_msg  = _get_last_ai_message(messages)          # 提取评审专家开场白
+
+    logger.info("defense.session_started", session_id=session_id,
+                target_position=req.target_position)
+
+    return {                                              # 返回会话信息 + 开场白
+        "session_id":      session_id,
+        "target_position": req.target_position,
+        "status":          "in_progress",
+        "message":         opening_msg,
+    }
+
+@router.post("/sessions/{session_id}/chat")
+async def chat(
+    session_id: str,
+    req: ChatRequest,
+    current_user: dict = Depends(get_current_user),
+):
+    """
+    投标人发送消息,评审专家生成回应。
+    MemorySaver 通过 thread_id 自动恢复历史 State,只需传入新消息。
+
+    请求:{message}
+    响应:{session_id, reply, current_stage, total_turns, is_finished, report_summary?}
+    """
+    bidder_id = current_user["user_id"]                 # 投标人 ID
+    tenant_id  = current_user["tenant_id"]               # 租户 ID
+
+    # 验证 session 归属(防止越权访问别人的会话)
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT session_id FROM defense_sessions
+                WHERE session_id = :session_id
+                  AND tenant_id  = :tenant_id
+                  AND bidder_id = :bidder_id
+            """),
+            {"session_id": session_id, "tenant_id": tenant_id, "bidder_id": bidder_id},
+        )
+        if not result.fetchone():                        # 查不到 → 不属于该用户
+            raise HTTPException(status_code=404, detail="答辩会话不存在")
+
+    config = build_config(bidder_id, session_id)        # 带 thread_id 的运行配置
+
+    # 只传增量:完整 State 由 MemorySaver 按 thread_id 自动恢复
+    state_update = {
+        "messages":   [HumanMessage(content=req.message)],  # 投标人新消息(add_messages 会追加)
+        "bidder_id": bidder_id,                        # 显式带上,防 MemorySaver 丢失时崩
+        "session_id": session_id,
+        "tenant_id":  tenant_id,
+    }
+
+    result = await _graph.ainvoke(state_update, config=config)  # 跑一遍图
+
+    current_stage = result.get("current_stage", DefenseStage.WARMUP.value)  # 当前阶段
+    total_turns   = result.get("total_turn_count", 0)    # 总轮数
+    is_finished   = current_stage == DefenseStage.FINISHED.value  # 是否已结束
+    reply         = _get_last_ai_message(result.get("messages", []))  # 评审专家这轮回复
+
+    response = {
+        "session_id":    session_id,
+        "reply":         reply,
+        "current_stage": current_stage,
+        "total_turns":   total_turns,
+        "is_finished":   is_finished,
+    }
+
+    # 答辩结束时附带报告摘要(前端可直接展示)
+    if is_finished:
+        report = result.get("report") or {}
+        response["report_summary"] = {
+            "overall_score": report.get("overall_score", 0),
+            "strengths":     report.get("strengths", []),
+            "improvements":  report.get("improvements", []),
+        }
+
+    return response
+
+
+@router.get("/sessions/{session_id}/report")
+async def get_report(
+    session_id: str,
+    current_user: dict = Depends(get_current_user),
+):
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT session_id, target_position, status,
+                       overall_score, report, finished_at
+                FROM defense_sessions
+                WHERE session_id = :session_id
+                  AND bidder_id = :bidder_id
+            """),
+            {"session_id": session_id, "bidder_id": current_user["user_id"]},
+        )
+        row = result.mappings().fetchone()           # 取会话行
+
+    if not row:                                       # 不存在或不属于该投标人
+        raise HTTPException(status_code=404, detail="答辩记录不存在")
+
+    if row["status"] != "finished":                   # 还没结束 → 报告未生成
+        raise HTTPException(status_code=400, detail="答辩尚未结束,报告生成中")
+
+    # report 存在 JSONB 列:ORM 可能返回 dict(已解析)或字符串(需 json.loads),两种都兼容
+    report = (
+        row["report"]
+        if isinstance(row["report"], dict)
+        else (json.loads(row["report"]) if row["report"] else {})
+    )
+
+    return {                                           # 摊平成给前端的完整报告
+        "session_id":         session_id,
+        "target_position":    row["target_position"],
+        "overall_score":      row["overall_score"],
+        "dimensions":         report.get("dimensions", []),
+        "strengths":          report.get("strengths", []),
+        "improvements":       report.get("improvements", []),
+        "overall_comment":    report.get("overall_comment", ""),
+        "recommended_topics": report.get("recommended_topics", []),
+        "next_step_advice":   report.get("next_step_advice", ""),
+    }
+
+@router.get("/sessions")
+async def list_sessions(current_user: dict = Depends(get_current_user)):
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT session_id, target_position, overall_score,
+                       status, finished_at, created_at
+                FROM defense_sessions
+                WHERE bidder_id = :bidder_id
+                ORDER BY created_at DESC
+                LIMIT 50
+            """),
+            {"bidder_id": current_user["user_id"]},
+        )
+        rows = result.mappings().all()
+
+    items = [
+        {
+            "session_id":      row["session_id"],
+            "target_position": row["target_position"],
+            "overall_score":   row["overall_score"],
+            "status":          row["status"],
+            "finished_at":     row["finished_at"].isoformat() if row["finished_at"] else None,
+            "created_at":      row["created_at"].isoformat() if row["created_at"] else None,
+        }
+        for row in rows
+    ]
+
+    return {"items": items, "total": len(items)}
+
+
+@router.post("/sessions/{session_id}/chat/stream")
+async def chat_stream(
+    session_id: str,
+    req: ChatRequest,
+    current_user: dict = Depends(get_current_user),
+):
+    """
+    SSE 流式接口,适配语音交互模式(边生成边推 token)。
+
+    请求:{message}
+    响应:text/event-stream,逐条 data: 事件——
+          type=token(评审专家回复的每个片段)/ type=done(结束+最终状态)/ type=error
+    """
+    bidder_id = current_user["user_id"]                 # 投标人 ID
+    tenant_id  = current_user["tenant_id"]               # 租户 ID
+
+    async with AsyncSessionLocal() as db:                # 验证会话归属
+        result = await db.execute(
+            text("SELECT session_id FROM defense_sessions WHERE session_id = :sid AND tenant_id = :tid"),
+            {"sid": session_id, "tid": tenant_id},
+        )
+        if not result.fetchone():
+            raise HTTPException(status_code=404, detail="答辩会话不存在")
+
+    config = build_config(bidder_id, session_id)        # 带 thread_id 的运行配置
+    state_update = {                                     # 只传增量(同 /chat)
+        "messages":   [HumanMessage(content=req.message)],
+        "bidder_id": bidder_id,
+        "session_id": session_id,
+        "tenant_id":  tenant_id,
+    }
+
+    async def event_generator():                         # 异步生成器:逐个 yield SSE 事件
+        try:
+            # astream_events 流式跑图,逐个事件回调(version="v2" 是事件格式版本)
+            async for event in _graph.astream_events(state_update, config=config, version="v2"):
+                evt  = event["event"]                    # 事件类型
+                node = event.get("metadata", {}).get("langgraph_node", "")  # 事件来自哪个节点
+
+                # 只对 generate_response 节点的 token 流做转发(评审专家回复)
+                # 其他节点(check_stage/evaluate_answer 等)的 LLM 输出不暴露给前端
+                if evt == "on_chat_model_stream" and node == "generate_response":
+                    chunk = event["data"].get("chunk")   # 取本次 token 片段
+                    if chunk and chunk.content:
+                        yield {                          # 推一个 token 事件
+                            "data": json.dumps(
+                                {"type": "token", "content": chunk.content},
+                                ensure_ascii=False,      # 中文不转义
+                            )
+                        }
+
+            # 流结束后用 aget_state 读最终 State,组装 done 事件
+            final         = await _graph.aget_state(config)  # 读检查点最新状态
+            state         = final.values if final else {}
+            current_stage = state.get("current_stage", DefenseStage.WARMUP.value)
+            total_turns   = state.get("total_turn_count", 0)
+            is_finished   = current_stage == DefenseStage.FINISHED.value
+            reply         = _get_last_ai_message(state.get("messages", []))  # 完整回复(给前端兜底)
+
+            done_payload: dict = {                       # done 事件:带最终状态
+                "type":          "done",
+                "reply":         reply,
+                "current_stage": current_stage,
+                "total_turns":   total_turns,
+                "is_finished":   is_finished,
+            }
+            if is_finished:                              # 结束则附报告摘要
+                report = state.get("report") or {}
+                done_payload["report_summary"] = {
+                    "overall_score": report.get("overall_score", 0),
+                    "strengths":     report.get("strengths", []),
+                    "improvements":  report.get("improvements", []),
+                }
+
+            yield {"data": json.dumps(done_payload, ensure_ascii=False)}  # 推 done 事件
+
+        except Exception as e:                           # 出错推一个 error 事件
+            logger.error("defense.chat_stream_error", error=str(e), exc_info=True)
+            yield {
+                "data": json.dumps(
+                    {"type": "error", "message": "流式输出异常,请重试"},
+                    ensure_ascii=False,
+                )
+            }
+
+    return EventSourceResponse(event_generator())        # 用 SSE 响应包装生成器
+
+def _get_last_ai_message(messages: list) -> str:
+    """提取消息列表中最后一条评审专家(AI)消息的文本。"""
+    for msg in reversed(messages):                    # 从后往前找
+        if isinstance(msg, AIMessage):                # 找到最后一条 AI 消息
+            content = msg.content
+            if isinstance(content, list):             # 多模态:拼接各片段文本
+                return "".join(
+                    p.get("text", "") if isinstance(p, dict) else str(p)
+                    for p in content
+                )
+            return str(content)                       # 普通字符串直接返回
+    return "答辩已开始,请等待评审专家回应..."           # 没有 AI 消息时的兜底文案

+ 252 - 0
TenderAgent完整代码/backend/api/v1/qa.py

@@ -0,0 +1,252 @@
+# backend/api/v1/qa.py
+
+import json
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, Field
+from sse_starlette.sse import EventSourceResponse
+from langchain_core.messages import HumanMessage
+
+from backend.agents.qa.graph import build_qa_graph
+from backend.core.memory import build_thread_id
+from backend.dependencies import get_current_user
+from backend.core.logger import get_logger
+
+router = APIRouter()
+logger = get_logger(__name__)
+
+
+# ── 请求 / 响应模型 ───────────────────────────────────────────
+
+class ChatRequest(BaseModel):
+    session_id:        str        = Field(..., description="会话 ID")
+    tender_document_id:         str | None = Field(None, description="招标资料 ID(可选,限定检索范围)")
+    message:           str        = Field(..., min_length=1, max_length=2000)
+    enable_web_search: bool       = Field(False, description="低置信度时是否先走 Web Search 再给 LLM")
+
+
+class ChatResponse(BaseModel):
+    session_id:    str
+    answer:        str
+    answer_mode:   str        # "rag" / "web_augmented" / "llm_direct" / "general"
+    confidence:    float
+    sources:       list[str]
+    fallback_used: bool
+
+
+class SessionMessage(BaseModel):
+    role:       str   # "user" / "assistant"
+    content:    str
+    created_at: str
+
+
+class HistoryResponse(BaseModel):
+    session_id:  str
+    messages:    list[SessionMessage]
+    summary:     str | None
+    total_turns: int
+
+@router.post("/chat", response_model=ChatResponse)
+async def chat(
+    req: ChatRequest,
+    current_user: dict = Depends(get_current_user),
+):
+    """智能问答:发送消息,获取 RAG 或 LLM 直答(非流式)"""
+    graph     = build_qa_graph()
+    thread_id = build_thread_id(current_user["user_id"], req.session_id)
+
+    initial_state = {
+        "messages":            [HumanMessage(content=req.message)],
+        "bidder_id":          current_user["user_id"],
+        "tenant_id":           current_user["tenant_id"],
+        "session_id":          req.session_id,
+        "tender_document_id":           req.tender_document_id,
+        "query_type":          "PRECISE",     # 占位初始值,classify_query_node 内部会动态覆盖
+        "enable_web_search":   req.enable_web_search,
+        "web_search_results":  [],            # 每轮重置,防止上轮搜索结果污染本轮 sources
+    }
+    config: dict = {"configurable": {"thread_id": thread_id}}
+
+    try:
+        result = await graph.ainvoke(initial_state, config=config)
+    except Exception as e:
+        logger.error("chat.invoke_error", error=str(e), exc_info=True)
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail={"code": "AGENT_ERROR", "message": str(e)},
+        )
+    print(f'response: {result}')
+    return ChatResponse(
+        session_id=req.session_id,
+        answer=result.get("answer", ""),
+        answer_mode=result.get("answer_mode", "llm_direct"),
+        confidence=result.get("confidence", 0.0),
+        sources=result.get("sources", []),
+        fallback_used=result.get("fallback_used", False),
+    )
+
+@router.post("/chat/stream")
+async def chat_stream(
+    req: ChatRequest,
+    current_user: dict = Depends(get_current_user),
+):
+    """智能问答流式接口(SSE),与 /chat 入参相同,响应改为流式推送"""
+    graph     = build_qa_graph()
+    thread_id = build_thread_id(current_user["user_id"], req.session_id)
+
+    initial_state = {
+        "messages":            [HumanMessage(content=req.message)],
+        "bidder_id":          current_user["user_id"],
+        "tenant_id":           current_user["tenant_id"],
+        "session_id":          req.session_id,
+        "tender_document_id":           req.tender_document_id,
+        "query_type":          "PRECISE",
+        "enable_web_search":   req.enable_web_search,
+        "web_search_results":  [],            # 每轮重置,防止上轮搜索结果污染本轮 sources
+    }
+    config: dict = {"configurable": {"thread_id": thread_id}}
+
+    # 只对这三个节点做 token 级流式推送
+    _GENERATE_NODES = {"generate_rag", "generate_direct", "generate_general"}
+
+    # 节点开始时推送的进度文案
+    _PROGRESS_LABELS = {
+        "classify_query":      "理解问题中...",
+        "hyde_generate":       "理解问题中...",
+        "multi_query_rewrite": "改写查询中...",
+        "retrieve":            "召回相关文档...",
+        "web_search":          "搜索互联网...",
+        "generate_general":    "思考中...",
+    }
+
+    async def event_generator():
+        answer_mode = "llm_direct"
+        confidence  = 0.0
+        sources: list[str] = []
+
+        try:
+            async for event in graph.astream_events(
+                initial_state, config=config, version="v2"
+            ):
+                evt  = event["event"]
+                node = event.get("metadata", {}).get("langgraph_node", "")
+
+                # ── 节点开始:推送进度提示 ──────────────────────
+                if evt == "on_chain_start" and node in _PROGRESS_LABELS:
+                    yield {
+                        "data": json.dumps(
+                            {"type": "progress", "stage": _PROGRESS_LABELS[node]},
+                            ensure_ascii=False,
+                        )
+                    }
+
+                # ── LLM 实时 token ──────────────────────────────
+                elif evt == "on_chat_model_stream" and node in _GENERATE_NODES:
+                    chunk = event["data"].get("chunk")
+                    if chunk and chunk.content:
+                        yield {
+                            "data": json.dumps(
+                                {"type": "token", "content": chunk.content},
+                                ensure_ascii=False,
+                            )
+                        }
+
+                # ── 节点结束:捕获元数据 ────────────────────────
+                elif evt == "on_chain_end" and node in _GENERATE_NODES:
+                    output = event["data"].get("output", {})
+                    if isinstance(output, dict):
+                        _mode = output.get("answer_mode")
+                        if _mode:
+                            answer_mode = _mode
+                        _srcs = output.get("sources")
+                        if _srcs is not None:
+                            sources = _srcs
+                        _conf = (output.get("structured_output") or {}).get("confidence")
+                        if _conf is not None:
+                            confidence = _conf
+
+        except Exception as e:
+            logger.error("chat_stream.error", error=str(e), exc_info=True)
+            yield {
+                "data": json.dumps(
+                    {"type": "error", "message": "流式输出异常,请使用普通接口重试"},
+                    ensure_ascii=False,
+                )
+            }
+            return
+
+        # ── 元数据帧(流结束后一次性推送)──────────────────────
+        yield {
+            "data": json.dumps(
+                {
+                    "type":        "meta",
+                    "session_id":  req.session_id,
+                    "answer_mode": answer_mode,
+                    "confidence":  confidence,
+                    "sources":     sources,
+                },
+                ensure_ascii=False,
+            )
+        }
+        yield {"data": json.dumps({"type": "done"})}
+
+    return EventSourceResponse(event_generator())
+
+@router.get("/sessions/{session_id}/history", response_model=HistoryResponse)
+async def get_session_history(
+    session_id: str,
+    current_user: dict = Depends(get_current_user),
+):
+    """获取会话历史消息与摘要"""
+    from sqlalchemy import text as sa_text
+    from langchain_core.messages import HumanMessage as LCHuman, AIMessage as LCAi
+    from backend.dependencies import AsyncSessionLocal
+
+    bidder_id = current_user["user_id"]
+    thread_id  = build_thread_id(bidder_id, session_id)
+
+    # ── ① 从 DB 读摘要 ────────────────────────────────────────
+    summary = None
+    try:
+        async with AsyncSessionLocal() as db_session:
+            result = await db_session.execute(
+                sa_text(
+                    "SELECT summary FROM qa_sessions "
+                    "WHERE thread_id = :tid AND bidder_id = :sid"
+                ),
+                {"tid": thread_id, "sid": bidder_id},
+            )
+            row = result.fetchone()
+            if row:
+                summary = row[0]
+    except Exception as e:
+        logger.warning("get_history.db_error", error=str(e))
+
+    # ── ② 从 MemorySaver 读消息历史 ──────────────────────────
+    messages: list[SessionMessage] = []
+    total_turns = 0
+    try:
+        graph  = build_qa_graph()
+        config = {"configurable": {"thread_id": thread_id}}
+        state  = await graph.aget_state(config)
+        if state and state.values:
+            for msg in state.values.get("messages", []):
+                content = (
+                    msg.text
+                    if hasattr(msg, "text") and not callable(msg.text)
+                    else str(msg.content)
+                )
+                if isinstance(msg, LCHuman):
+                    messages.append(SessionMessage(role="user", content=content, created_at=""))
+                elif isinstance(msg, LCAi):
+                    messages.append(SessionMessage(role="assistant", content=content, created_at=""))
+            total_turns = sum(1 for m in messages if m.role == "user")
+    except Exception as e:
+        logger.warning("get_history.checkpoint_error", error=str(e))
+
+    return HistoryResponse(
+        session_id=session_id,
+        messages=messages,
+        summary=summary,
+        total_turns=total_turns,
+    )
+

+ 221 - 0
TenderAgent完整代码/backend/api/v1/qualification.py

@@ -0,0 +1,221 @@
+import asyncio
+import os
+import uuid
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from sqlalchemy import text
+
+from backend.agents.qualification.graph import build_qualification_graph
+from backend.core.logger import get_logger
+from backend.dependencies import AsyncSessionLocal, get_current_user
+
+router = APIRouter()
+logger = get_logger(__name__)
+
+# ── 线程本地图:每个线程一份独立的图实例,避免并发竞争 ──
+import threading
+_graph_local = threading.local()
+
+def _get_graph():
+    """获取线程本地的图实例(当前线程没有就编译一个)。"""
+    if not hasattr(_graph_local, "graph"):
+        _graph_local.graph = build_qualification_graph()
+    return _graph_local.graph
+
+# ── GC 保护:模块级集合持有后台任务的强引用,防止被垃圾回收 ──
+_background_tasks: set[asyncio.Task] = set()
+QUALIFICATION_REVIEW_TIMEOUT_SECONDS = 15 * 60          # 审查超时阈值:15 分钟
+
+
+async def _mark_review_failed(review_id: str, error_msg: str) -> None:
+    """把仍处于 processing 的记录标记为 failed(幂等:仅当当前是 processing 才改)。"""
+    async with AsyncSessionLocal() as session:
+        await session.execute(
+            text("""
+                UPDATE qualification_reviews
+                SET status = 'failed', error_msg = :error_msg, updated_at = NOW()
+                WHERE id = :review_id AND status = 'processing'
+            """),
+            {"review_id": review_id, "error_msg": error_msg[:1000]},
+        )
+        await session.commit()
+
+
+@router.post("/upload", status_code=202)         # 202 Accepted:已接受、正在处理
+async def upload_qualification(
+    file: UploadFile = File(...),                # 上传的文件
+    current_user: dict = Depends(get_current_user),   # 鉴权:拿到当前用户
+):
+    """上传 PDF 资质材料,触发异步审查,立即返回 review_id。"""
+    if not file.filename.lower().endswith(".pdf"):       # 只收 PDF
+        raise HTTPException(status_code=400, detail="仅支持 PDF 格式")
+
+    review_id  = str(uuid.uuid4())
+    bidder_id = current_user["user_id"]
+    tenant_id  = current_user["tenant_id"]
+
+    # 1. 读取并校验文件(用 await 读,避免阻塞事件循环)
+    MAX_PDF_SIZE = 20 * 1024 * 1024              # 20MB 上限
+    file_bytes = await file.read()
+    if len(file_bytes) > MAX_PDF_SIZE:
+        raise HTTPException(status_code=413, detail="文件过大,最大支持 20MB")
+    if not file_bytes:
+        raise HTTPException(status_code=400, detail="上传文件为空")
+
+    # 暂存到临时目录
+    import tempfile
+    tmp_path = os.path.join(tempfile.gettempdir(), f"{review_id}_upload.pdf")
+    with open(tmp_path, "wb") as f:
+        f.write(file_bytes)
+    logger.info("upload_qualification.file_saved", review_id=review_id, tmp_path=tmp_path)
+
+    # 2. 写入 qualification_reviews 初始记录(status=processing)
+    async with AsyncSessionLocal() as session:
+        await session.execute(
+            text("""
+                INSERT INTO qualification_reviews (id, tenant_id, bidder_id, pdf_minio_path, status)
+                VALUES (:id, :tenant_id, :bidder_id, :pdf_minio_path, 'processing')
+            """),
+            {"id": review_id, "tenant_id": tenant_id, "bidder_id": bidder_id,
+             "pdf_minio_path": f"qualifications/{bidder_id}/{review_id}.pdf"},
+        )
+        await session.commit()
+
+    # 3. 准备初始 State,后台启动图执行
+    initial_state = {
+        "messages": [], "bidder_id": bidder_id, "tenant_id": tenant_id,
+        "review_id": review_id, "pdf_minio_path": "", "pdf_local_path": tmp_path,
+        "raw_text": "", "page_count": 0, "structured": None,
+        "dimension_scores": [], "weighted_score": 0.0, "issues": [],
+        "summary": None, "fallback_used": False, "structured_output": None,
+    }
+
+    def _on_task_done(t: asyncio.Task):
+        """任务结束回调:移除引用、清理临时文件、失败则标记 failed。"""
+        _background_tasks.discard(t)                  # 从集合移除(释放强引用)
+        if os.path.exists(tmp_path):                  # 清理临时 PDF
+            try:
+                os.remove(tmp_path)
+            except OSError:
+                pass
+        mark_failed_msg = None
+        if t.cancelled():                             # 任务被取消(如服务重启)
+            mark_failed_msg = "审查任务被服务重启中断,请重试。"
+            logger.warning("qualification.background_task_cancelled", review_id=review_id)
+        else:
+            exc = t.exception()                       # 任务抛了异常
+            if exc:
+                mark_failed_msg = f"审查任务执行失败:{exc}"
+                logger.error("qualification.background_task_failed", review_id=review_id,
+                             error=str(exc), exc_info=exc)
+        if mark_failed_msg:                           # 异步把记录标记为 failed
+            try:
+                loop = asyncio.get_running_loop()
+                loop.create_task(_mark_review_failed(review_id, mark_failed_msg))
+            except RuntimeError:
+                pass                                  # 循环已关闭:下次查询走超时兜底
+
+    graph = _get_graph()                              # 拿线程本地图
+    task = asyncio.create_task(graph.ainvoke(initial_state))   # 后台执行
+    _background_tasks.add(task)                       # GC 保护:持有强引用
+    task.add_done_callback(_on_task_done)             # 注册完成回调
+    logger.info("upload_qualification.task_started", review_id=review_id)
+
+    return {                                          # 立即返回(不等审查完成)
+        "review_id": review_id, "status": "processing",
+        "message": "资质材料已上传,正在审查中,预计 30-60 秒完成。",
+    }
+
+
+@router.get("/reviews/{review_id}")
+async def get_review(review_id: str, current_user: dict = Depends(get_current_user)):
+    """查询审查状态/结果。processing / done / failed / 404。"""
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT id, status, scores, issues, summary, error_msg, created_at, updated_at
+                FROM qualification_reviews
+                WHERE id = :review_id AND bidder_id = :bidder_id
+            """),
+            {"review_id": review_id, "bidder_id": current_user["user_id"]},
+        )
+        row = result.mappings().fetchone()
+
+    if not row:                                       # 不存在或不属于自己
+        raise HTTPException(status_code=404, detail="审查记录不存在")
+
+    if row["status"] == "processing":
+        # 超时兜底:防止后台任务中断后长期卡 processing
+        last_ts = row["updated_at"] or row["created_at"]
+        if isinstance(last_ts, datetime):
+            elapsed = (datetime.now(timezone.utc) - last_ts).total_seconds()
+            if elapsed >= QUALIFICATION_REVIEW_TIMEOUT_SECONDS:
+                timeout_msg = "审查任务超时或被中断,请重新上传后重试。"
+                await _mark_review_failed(review_id, timeout_msg)
+                return {"review_id": review_id, "status": "failed", "error_msg": timeout_msg}
+        return {"review_id": review_id, "status": "processing"}
+
+    if row["status"] == "failed":
+        return {"review_id": review_id, "status": "failed",
+                "error_msg": row.get("error_msg") or "审查任务失败,请重新上传。"}
+
+    # status == done:JSONB 列已被 asyncpg 自动反序列化为 dict/list,无需 json.loads
+    def _to_dict(v):
+        if v is None:
+            return {}
+        if isinstance(v, (dict, list)):
+            return v
+        import json as _json
+        return _json.loads(v)
+
+    scores_data = _to_dict(row["scores"])
+    return {
+        "review_id": review_id, "status": "done",
+        "weighted_score": scores_data.get("weighted_score", 0),
+        "dimension_scores": scores_data.get("dimension_scores", []),
+        "issues": _to_dict(row["issues"]) if not isinstance(row["issues"], list) else row["issues"],
+        "summary": _to_dict(row["summary"]),
+    }
+
+
+@router.delete("/reviews/{review_id}", status_code=204)
+async def delete_review(review_id: str, current_user: dict = Depends(get_current_user)):
+    """删除审查记录(WHERE 带 bidder_id,只能删自己的)。"""
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                DELETE FROM qualification_reviews
+                WHERE id = :review_id AND bidder_id = :bidder_id
+            """),
+            {"review_id": review_id, "bidder_id": current_user["user_id"]},
+        )
+        await session.commit()
+    if result.rowcount == 0:                          # 没删到任何行 = 不存在/无权限
+        raise HTTPException(status_code=404, detail="记录不存在")
+
+
+@router.get("/reviews")
+async def list_reviews(current_user: dict = Depends(get_current_user)):
+    """列出本人历史审查记录(摘要,按时间倒序)。"""
+    async with AsyncSessionLocal() as session:
+        result = await session.execute(
+            text("""
+                SELECT id, status, created_at,
+                       (scores::jsonb ->> 'weighted_score')::float AS weighted_score
+                FROM qualification_reviews
+                WHERE bidder_id = :bidder_id
+                ORDER BY created_at DESC
+                LIMIT 50
+            """),
+            {"bidder_id": current_user["user_id"]},
+        )
+        rows = result.mappings().all()
+
+    items = [
+        {"review_id": row["id"], "status": row["status"],
+         "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+         "weighted_score": row["weighted_score"]}
+        for row in rows
+    ]
+    return {"items": items, "total": len(items)}

+ 462 - 0
TenderAgent完整代码/backend/api/v1/unified_chat.py

@@ -0,0 +1,462 @@
+# backend/api/v1/unified_chat.py
+# 统一 AI 助手入口:意图识别 → 路由决策可视化 → Agent 执行
+#
+# SSE 事件类型:
+#   routing_decision  路由决策结果(agent_type / confidence / reason / execution_mode)
+#   progress          QA 流水线进度提示(检索知识库...等)
+#   token             QA Agent 流式 token
+#   guidance          非 QA 意图的引导消息(含跳转链接)
+#   pipeline_plan     多 Agent 协同计划(steps 数组,含各步 label/desc/action_url)
+#   meta              QA 回答完毕后的元数据(answer_mode / sources 等)
+#   done              流结束信号
+#   error             异常
+
+import json
+import re
+from dataclasses import dataclass
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+from sse_starlette.sse import EventSourceResponse
+from langchain_core.messages import HumanMessage
+
+from backend.core.orchestrator import AgentType, ExecutionMode   # 8.2 定义的枚举
+from backend.core.memory import build_thread_id                  # 5.10 的 thread_id 工具
+from backend.core.llm_factory import get_llm                     # 3.4 的 LLM 工厂
+from backend.dependencies import get_current_user                # 3.6 的认证依赖
+from backend.core.logger import get_logger
+
+router = APIRouter()
+logger = get_logger(__name__)
+
+
+# ── Agent 中文名映射(路由卡片展示用)──────────────────────────
+_AGENT_DISPLAY = {
+    AgentType.QA:        "智能问答",
+    AgentType.BID_REVIEW:      "投标文件评分",
+    AgentType.QUALIFICATION:    "资质审查",
+    AgentType.DEFENSE: "供应商答辩",
+}
+
+# ── 非 QA 意图的引导消息和跳转路径 ─────────────────────────────
+# bid_review/qualification/defense 需要专门的页面(上传文件/选岗位),统一入口不直接执行,
+# 而是返回引导卡片,让前端把用户导到对应功能页。
+_GUIDANCE = {
+    AgentType.BID_REVIEW: {
+        "message": "检测到您需要进行投标文件评分。请前往「投标文件评分」页面上传 Word 格式答卷,AI 将自动完成三轨评分并分析知识薄弱点。",
+        "action_label": "前往投标文件评分",
+        "action_url": "/bid_review",
+    },
+    AgentType.QUALIFICATION: {
+        "message": "检测到您需要进行资质审查。请前往「资质审查」页面上传 PDF 格式资质材料,AI 将从六个维度进行评分并给出改进建议。",
+        "action_label": "前往资质审查",
+        "action_url": "/qualification",
+    },
+    AgentType.DEFENSE: {
+        "message": "检测到您需要进行供应商答辩。请前往「供应商答辩」页面选择目标岗位和技术方向,AI 将进行技术原理与项目深挖双轨考察。",
+        "action_label": "前往供应商答辩",
+        "action_url": "/defense",
+    },
+}
+
+# ── QA Agent 节点进度提示(与 qa.py 保持一致,8.5 用到)────────
+_PROGRESS_LABELS = {
+    "classify_query":        "理解问题中...",
+    "load_memory_and_embed": "检索知识库...",
+    "retrieve":              "召回相关文档...",
+    "rerank":                "精排中...",
+    "hyde_generate":         "理解问题中...",
+    "multi_query_rewrite":   "改写查询中...",
+    "generate_general":      "思考中...",
+}
+_GENERATE_NODES = {"generate_rag", "generate_direct", "generate_general"}  # QA 的三个生成节点
+# ── 去除末尾标点空白,用于关键词精确比对 ──────────────────────────
+_STRIP_TAIL_RE = re.compile(r"[\s!!??。~~,.,。]+$")
+
+# ── 类别一:问候(你好 / 在吗)────────────────────────────────────
+_HELLO_KEYWORDS = frozenset([
+    "你好", "您好", "hi", "hello", "hey", "哈喽", "嗨",
+    "在吗", "在不在", "在线吗", "有人吗",
+])
+
+# ── 类别二:感谢(谢谢 / 辛苦了 / 太棒了)────────────────────────
+_THANKS_KEYWORDS = frozenset([
+    "谢谢", "感谢", "多谢", "谢了", "非常感谢", "万分感谢",
+    "辛苦了", "辛苦", "麻烦了", "不好意思",
+    "太棒了", "太好了", "厉害", "厉害了", "牛", "牛啊", "牛逼",
+    "好的好的", "明白了", "懂了", "知道了", "收到",
+])
+
+# ── 类别三:道别(再见 / bye)──────────────────────────────────────
+_BYE_KEYWORDS = frozenset([
+    "再见", "拜拜", "拜", "88", "886", "bye", "goodbye", "byebye",
+    "下次见", "下次再聊", "先走了", "先撤了", "溜了", "闪了",
+])
+
+# ── 类别四:身份询问(你是谁 / 介绍你自己)── 正则匹配 ──────────────
+_IDENTITY_RE = re.compile(
+    r"(你|您)(是谁|叫什么|的名字|是什么|是.*AI|是.*机器人|是.*助手)"
+    r"|介绍.{0,4}(你自己|自己|一下)"
+    r"|你是谁"
+    r"|你叫(啥|什么名)",
+    re.IGNORECASE,
+)
+
+# ── 类别五:功能询问(你能做什么 / 怎么用)── 正则匹配 ──────────────
+_CAPABILITY_RE = re.compile(
+    r"(你|您)(能|可以|会).{0,6}(做|帮|干)"
+    r"|(你|您).{0,4}(功能|用途|能力|特点)"
+    r"|怎么(用|使用)(你|您|这个)?"
+    r"|(使用说明|帮助菜单|help|usage)"
+    r"|你能帮(我|忙)吗",
+    re.IGNORECASE,
+)
+
+
+# ── 多 Agent 能力说明(HELLO / IDENTITY / CAPABILITY 共享结尾)────
+_MULTI_AGENT_TIP = (
+    "- **多 Agent 协同**:描述综合需求(如「帮我准备投标」),"
+    "AI 将自动串联资质审查 + 供应商答辩两个 Agent 协同为您服务\n"
+)
+
+# ── 五类回复模板(节选问候,其余结构相同)──────────────────────────
+_REPLY_HELLO = (
+    "您好!我是 TenderAgent AI 助手。\n\n"
+    "我可以帮您:\n"
+    "- **技术问答**:直接输入问题,我会从知识库中检索并解答\n"
+    "- **投标文件评分**:告诉我「我要提交投标文件」,AI 完成三轨评分和知识薄弱点分析\n"
+    "- **资质审查**:告诉我「帮我看看资质材料」,AI 给出六维度评分与改进建议\n"
+    "- **供应商答辩**:告诉我「我要供应商答辩」,开启技术双轨考察\n"
+    + _MULTI_AGENT_TIP
+    + "\n请问有什么可以帮到您?"
+)
+
+_REPLY_THANKS = (
+    "不客气,很高兴能帮到您!\n\n"
+    "如果还有其他问题,随时告诉我,我随时在线。"
+)
+
+_REPLY_BYE = (
+    "再见!希望今天的学习对您有所帮助,期待下次与您交流。\n\n"
+    "祝您学习顺利!"
+)
+
+_REPLY_IDENTITY = (
+    "我是 **TenderAgent AI 助手**,一个面向 IT 招投标培训的智能学习辅助系统。\n\n"
+    "我由多个专业 Agent 协同构成:\n"
+    "- **智能问答 Agent**:基于 RAG 知识库,7x24 即时解答技术问题\n"
+    "- **投标文件评分 Agent**:AI 三轨并行评分 + 知识薄弱点分析\n"
+    "- **供应商资质审查 Agent**:六维度质量评审,提供原文定位的修改建议\n"
+    "- **供应商答辩 Agent**:技术原理 + 项目深挖双轨考察,生成结构化报告\n"
+    + _MULTI_AGENT_TIP
+    + "\n有什么可以帮到您吗?"
+)
+
+_REPLY_CAPABILITY = (
+    "我能为您提供以下功能:\n\n"
+    "**单 Agent 直达**\n"
+    "- 直接输入技术问题 → 智能问答(RAG 知识库检索)\n"
+    "- 「提交投标文件评分」 → 上传 Word 答卷,AI 完成评分并分析薄弱点\n"
+    "- 「审查我的资质材料」 → 上传 PDF 资质材料,六维度评分 + 改进建议\n"
+    "- 「开始供应商答辩」 → 选择岗位方向,进入双轨答辩考察\n\n"
+    "**多 Agent 协同**\n"
+    + _MULTI_AGENT_TIP
+    + "\n直接告诉我您的需求,我会自动路由到最合适的 Agent。"
+)
+
+def _pre_filter(text: str) -> str | None:
+    """
+    规则前置拦截,返回模板回复字符串;无命中返回 None(继续正常路由)。
+    五类社交/元场景均为零 Token 消耗(不调 LLM)。
+    """
+    t = text.strip()
+    # print(f"t-->{t}")# 去首尾空白
+    t_lower = _STRIP_TAIL_RE.sub("", t.lower())      # 转小写 + 去末尾标点,得到比对用文本
+    # print(f't_lower-->{t_lower}')
+    # 类别一:问候(精确匹配整句)
+    if t_lower in _HELLO_KEYWORDS:
+        return _REPLY_HELLO
+
+    # 类别二:感谢(精确匹配整句)
+    if t_lower in _THANKS_KEYWORDS:
+        return _REPLY_THANKS
+
+    # 类别三:道别(精确匹配整句)
+    if t_lower in _BYE_KEYWORDS:
+        return _REPLY_BYE
+
+    # 类别四:身份询问(正则,用原文 t 而非去标点的 t_lower)
+    if _IDENTITY_RE.search(t):
+        return _REPLY_IDENTITY
+
+    # 类别五:功能询问(正则)
+    if _CAPABILITY_RE.search(t):
+        return _REPLY_CAPABILITY
+
+    return None                                      # 五类都没命中 → 交给 LLM 路由
+
+# ── LLM 路由 Prompt:让模型把用户输入归到 6 类之一,返回 JSON ──
+_ROUTE_PROMPT = """判断用户需求应路由到哪个功能。
+
+可选功能:
+- qa          : 技术知识问答(用户直接提问,不涉及文件上传)
+- bid_review        : 投标文件/作业评分(需上传 Word 答卷,用户提到"评分""作业""提交投标文件"等)
+- qualification      : 资质审查(需上传 PDF 资质材料,用户提到"资质材料""帮我看看资质材料"等)
+- defense   : 供应商答辩(用户提到"答辩""练习答辩""供应商答辩"等)
+- multi_agent : 综合投标准备(用户同时提到资质材料 + 答辩,或"全套""一条龙""投标准备"等)
+- clarify     : 意图不明确,无法判断,需要追问
+
+严格按以下 JSON 格式返回,不要有其他内容:
+{{"label": "功能名", "reason": "一句话说明判断依据"}}
+
+用户输入:{message}"""
+
+# label → AgentType 映射(multi_agent / clarify 用 QA 占位)
+_LABEL_TO_AGENT: dict[str, AgentType] = {
+    "qa":          AgentType.QA,
+    "bid_review":        AgentType.BID_REVIEW,
+    "qualification":      AgentType.QUALIFICATION,
+    "defense":   AgentType.DEFENSE,
+    "multi_agent": AgentType.QA,   # pipeline 入口,以 QA 作占位
+    "clarify":     AgentType.QA,
+}
+
+# label → ExecutionMode 映射
+_LABEL_TO_MODE: dict[str, ExecutionMode] = {
+    "qa":          ExecutionMode.SINGLE,
+    "bid_review":        ExecutionMode.SINGLE,
+    "qualification":      ExecutionMode.SINGLE,
+    "defense":   ExecutionMode.SINGLE,
+    "multi_agent": ExecutionMode.PIPELINE,
+    "clarify":     ExecutionMode.CLARIFY,
+}
+
+_VALID_LABELS = frozenset(_LABEL_TO_AGENT.keys())    # 合法 label 集合(校验 LLM 输出用)
+
+
+@dataclass
+class _RouteResult:
+    """LLM 路由结果,对齐前端展示需要的字段。"""
+    label:          str            # "qa"|"bid_review"|"qualification"|"defense"|"multi_agent"|"clarify"
+    agent_type:     AgentType      # 由 label 映射
+    execution_mode: ExecutionMode  # 由 label 映射
+    confidence:     float          # LLM 路由固定返回 0.85(无置信度概念,仅供前端展示)
+    reason:         str            # 一句话判断依据
+
+async def _llm_route(message: str) -> _RouteResult:
+    """
+    调用 LLM 对用户输入做跨 Agent 路由判断。
+
+    替代原 IntentRouter(MiniLM 7 分类 + LLM 兜底)——跨 Agent 路由直接用 LLM 判断,
+    无需本地分类模型(这个路径调用频率低,LLM 开销可接受)。
+    异常时降级返回 qa 路由,不阻断 SSE 流。
+
+    参数:message(用户原始输入)
+    返回:_RouteResult(label + agent_type + execution_mode + confidence + reason)
+    """
+    try:
+        llm = get_llm("intent", temperature=0)       # 取"intent"专用模型(3.4 配置),温度0求稳定
+        resp = await llm.ainvoke([                    # 调 LLM
+            HumanMessage(content=_ROUTE_PROMPT.format(message=message))
+        ])
+        raw = resp.text.strip()
+        parsed = json.loads(raw)                      # 解析 LLM 返回的 JSON
+        label = parsed.get("label", "qa").strip().lower()    # 取 label(缺省 qa)
+        reason = parsed.get("reason", "LLM 路由判断")        # 取判断依据
+
+        if label not in _VALID_LABELS:               # LLM 返回了非法 label → 降级 qa
+            logger.warning("unified_chat.llm_route_unknown_label", label=label, fallback="qa")
+            label = "qa"
+
+        logger.info("unified_chat.llm_route_result", label=label, reason=reason)
+
+    except Exception as e:                            # 调用/解析失败 → 降级 qa,不阻断流
+        logger.warning("unified_chat.llm_route_failed", error=str(e), fallback="qa")
+        label  = "qa"
+        reason = "路由判断异常,默认转入智能问答"
+
+    return _RouteResult(                              # 组装路由结果
+        label=label,
+        agent_type=_LABEL_TO_AGENT[label],           # label → AgentType
+        execution_mode=_LABEL_TO_MODE[label],        # label → ExecutionMode
+        confidence=0.85,                             # 固定值,仅供前端展示
+        reason=reason,
+    )
+
+class UnifiedChatRequest(BaseModel):
+    session_id: str = Field(..., description="会话 ID")
+    message:    str = Field(..., min_length=1, max_length=2000, description="用户输入")  # 非空、限长
+
+
+def _sse(data: dict) -> dict:
+    """把 dict 包成 sse_starlette 要的格式:{"data": "<JSON字符串>"}。ensure_ascii=False 保留中文。"""
+    return {"data": json.dumps(data, ensure_ascii=False)}
+
+@router.post("/stream")
+async def unified_chat_stream(
+    req: UnifiedChatRequest,                          # 请求体:session_id + message
+    current_user: dict = Depends(get_current_user),   # 认证依赖(3.6)
+):
+    """
+    统一 AI 助手流式接口(SSE)。
+
+    请求:{session_id, message}
+    响应:text/event-stream,事件序列随路由结果不同而不同
+
+    流程:
+        1. 规则前置拦截(五类社交/元场景,零 Token 直接返回)
+        2. LLM 路由判断(_llm_route,DeepSeek 直接分类,无本地模型)
+        3. 推送 routing_decision 事件(前端显示路由卡片)
+        4a. qa 路由      → 流式执行 QA Agent
+        4b. bid_review/qualification/defense → 推送 guidance 引导跳转
+        4c. multi_agent  → 推送投标全链路 pipeline 计划
+        4d. clarify      → 推送追问提示
+    """
+
+    async def event_generator():                      # 异步生成器:逐个 yield SSE 事件
+        # ── Step 0:规则前置拦截(零 Token,五类模板回复)──────────
+        pre_reply = _pre_filter(req.message)          # 命中则返回模板字符串,否则 None
+        if pre_reply is not None:
+            yield _sse({"type": "token", "content": pre_reply})  # 模板内容当作一个 token 事件推出
+            yield _sse({"type": "done"})              # 直接结束
+            return                                    # 不再走路由(省 LLM)
+
+        # ── Step 1:LLM 路由判断 ────────────────────────────
+        decision = await _llm_route(req.message)      # 得到 _RouteResult(label/agent_type/mode/...)
+
+        # ── Step 2:推送路由决策卡片(前端显示"已转接到 XX")──────
+        yield _sse({
+            "type":           "routing_decision",
+            "agent_type":     decision.agent_type.value,
+            "agent_display":  _AGENT_DISPLAY.get(decision.agent_type, ""),  # 中文名
+            "confidence":     round(decision.confidence, 4),
+            "reason":         decision.reason,
+            "execution_mode": decision.execution_mode.value,
+        })
+
+        # ── Step 3:按路由结果分发处理 ──────────────────────
+        label = decision.label                        # 用 label 决定走哪条分支
+
+        # ── 3a:qa → 流式执行 QA Agent ──────────────────────
+        if label == "qa":
+            async for event in _stream_qa_agent(req, current_user):  # 把 QA 的流式事件透传出去
+                yield event
+
+        # ── 3b:bid_review / qualification / defense → 引导跳转 ────────
+        elif label in ("bid_review", "qualification", "defense"):
+            guidance = _GUIDANCE.get(decision.agent_type, {})    # 取对应引导文案
+            yield _sse({
+                "type":         "guidance",
+                "message":      guidance.get("message", "请前往对应功能页面操作"),
+                "action_label": guidance.get("action_label", ""),
+                "action_url":   guidance.get("action_url", "/dashboard"),
+            })
+
+        # ── 3c:multi_agent → 投标全链路 Pipeline 计划 ──────
+        elif label == "multi_agent":
+            yield _sse({
+                "type":     "pipeline_plan",
+                "title":    "投标全链路",
+                "intro":    "已为您规划「投标全链路」,建议按顺序完成以下两个步骤,"
+                            "资质审查结果将作为供应商答辩的参考依据。",
+                "steps": [
+                    {
+                        "step":         1,
+                        "agent_type":   "qualification",
+                        "label":        "资质审查",
+                        "desc":         "上传 PDF 资质材料,AI 从六个维度评分并给出带原文定位的改进建议",
+                        "action_label": "开始资质审查",
+                        "action_url":   "/qualification",
+                        "tip":          "建议先完成此步骤,审查结果将作为答辩的参考依据",
+                    },
+                    {
+                        "step":         2,
+                        "agent_type":   "defense",
+                        "label":        "供应商答辩",
+                        "desc":         "基于资质材料背景,进行技术原理 + 项目深挖双轨考察,生成结构化雷达图报告",
+                        "action_label": "开始供应商答辩",
+                        "action_url":   "/defense",
+                        "tip":          "完成资质审查后进行效果更佳",
+                    },
+                ],
+            })
+
+        # ── 3d:clarify → 追问提示 ───────────────────────────
+        else:
+            yield _sse({
+                "type":    "guidance",
+                "message": "您的问题我还不太确定应该用哪个功能来帮您,能否描述得更具体一些?"
+                           "例如:您是想提问技术知识、提交投标文件评分、审查资质材料,还是进行供应商答辩?",
+                "action_label": "",
+                "action_url":   "",
+            })
+
+        yield _sse({"type": "done"})                  # 所有分支最终都推一个 done 收尾
+
+    return EventSourceResponse(event_generator())     # 用 SSE 响应包装生成器
+
+
+async def _stream_qa_agent(req: UnifiedChatRequest, current_user: dict):
+    """
+    在统一入口中流式执行 QA Agent,复用 qa.py 的 astream_events 逻辑。
+
+    读取:req.message / req.session_id、current_user
+    yield:progress / token / meta / error 四类 SSE 事件
+    """
+    from backend.core.orchestrator import get_orchestrator   # 懒加载取编排器单例
+
+    orchestrator = get_orchestrator()
+    graph = orchestrator._get_agent_graph(AgentType.QA)       # 复用编排器缓存的 QA 图(不重复 build)
+
+    thread_id = build_thread_id(current_user["user_id"], req.session_id)  # 拼检查点 key
+    initial_state = {                                         # 构造 QA 图输入 State
+        "messages":   [HumanMessage(content=req.message)],
+        "bidder_id": current_user["user_id"],
+        "tenant_id":  current_user["tenant_id"],
+        "session_id": req.session_id,
+        "tender_document_id":  None,
+        "query_type": "PRECISE",                             # QA 的查询类型(见第 5 章)
+    }
+    config = {"configurable": {"thread_id": thread_id}}
+
+    answer_mode = None
+    confidence  = 0.0
+    sources: list[str] = []                                 # 引用来源
+
+    try:
+        # astream_events 流式跑 QA 图,逐事件处理
+        async for event in graph.astream_events(initial_state, config=config, version="v2"):
+            evt  = event["event"]                            # 事件类型
+            node = event.get("metadata", {}).get("langgraph_node", "")  # 来自哪个节点
+
+            # ① 节点开始且在进度表里 → 推 progress("检索知识库..."等)
+            if evt == "on_chain_start" and node in _PROGRESS_LABELS:
+                yield _sse({"type": "progress", "stage": _PROGRESS_LABELS[node]})
+
+            # ② 生成节点的 token 流 → 推 token(答案逐字)
+            elif evt == "on_chat_model_stream" and node in _GENERATE_NODES:
+                chunk = event["data"].get("chunk")
+                if chunk and chunk.content:
+                    yield _sse({"type": "token", "content": chunk.content})
+
+            # ③ 生成节点结束 → 回填 answer_mode / sources / confidence(供最后 meta 用)
+            elif evt == "on_chain_end" and node in _GENERATE_NODES:
+                output = event["data"].get("output", {})
+                # 仅在值非空时更新,防止后续 on_chain_end 覆盖已捕获的 "rag" 值
+                if output.get("answer_mode"):
+                    answer_mode = output["answer_mode"]
+                if output.get("sources"):
+                    sources = output["sources"]
+                confidence = (output.get("structured_output") or {}).get("confidence", confidence)
+
+    except Exception as e:                                   # QA 执行异常 → 推 error 并结束
+        logger.error("unified_chat.qa_stream_error", error=str(e), exc_info=True)
+        yield _sse({"type": "error", "message": "问答服务异常,请稍后重试"})
+        return
+
+    # ④ QA 答完 → 推 meta(答案模式 / 来源 / 置信度),前端据此显示"引用了 N 篇"
+    if answer_mode:
+        yield _sse({"type": "meta", "answer_mode": answer_mode, "confidence": confidence, "sources": sources})
+
+
+if __name__ == '__main__':
+    print(_pre_filter(text="你好."))

+ 127 - 0
TenderAgent完整代码/backend/api/v1/verify_defense_e2e.py

@@ -0,0 +1,127 @@
+# scripts/manual_tests/itv_07_13_e2e.py
+
+import sys
+import json
+import time
+import asyncio
+import httpx
+
+sys.path.insert(0, "scripts/manual_tests")
+from backend.agents.defense.manual_tests.itv_fixtures import seed_qualification, ANSWER_SCRIPT, TARGET_POSITION
+
+BASE_URL   = "http://localhost:8006/api/v1"
+BIDDER_UN = "bidder01"
+BIDDER_PW = "Bidder@123456"
+
+# 投标人回答序列:直接复用贯穿全章 07-02~07-12 的统一投标响应脚本(AI 大模型岗,李明)。
+# 覆盖 WARMUP 自我介绍 → 4 道技术题(含一题"不知道")→ 2 轮项目深挖 → 反问 → 结束答辩。
+ANSWER_SEQUENCE = ANSWER_SCRIPT
+
+
+def login(username: str, password: str) -> str:
+    resp = httpx.post(
+        f"{BASE_URL}/auth/login",
+        json={"username": username, "password": password},
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    return resp.json()["access_token"]
+
+
+def print_section(title: str):
+    print(f"\n{'='*60}")
+    print(f"  {title}")
+    print("="*60)
+
+
+if __name__ == "__main__":
+    # ── ① 投标人登录 ─────────────────────────────────────────────
+    print_section("① 投标人登录")
+    token = login(BIDDER_UN, BIDDER_PW)
+    print(f"token 获取成功:{token[:20]}...")
+
+    headers = {"Authorization": f"Bearer {token}"}
+
+    # ── ②a 写入李明资质材料(供 PROJECT 阶段联动深挖)────────────────
+    seeded = asyncio.run(seed_qualification())
+    print(f"资质材料已写入,qualification_review_id: {seeded['qualification_review_id']}")
+
+    # ── ②b 创建答辩会话(带 qualification_review_id 联动)──────────────
+    print_section(f"② 创建答辩会话(目标岗位:{TARGET_POSITION})")
+    resp = httpx.post(
+        f"{BASE_URL}/defense/sessions",
+        headers=headers,
+        json={"target_position": TARGET_POSITION,
+              "qualification_review_id": seeded["qualification_review_id"]},
+        timeout=30.0,
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    session_data = resp.json()
+    session_id   = session_data["session_id"]
+    print(f"session_id: {session_id}")
+    print(f"开场白:\n{session_data['message']}\n")
+
+    # ── ③ 多轮对话 ─────────────────────────────────────────────
+    print_section("③ 开始多轮对话")
+    for i, answer in enumerate(ANSWER_SEQUENCE):
+        print(f"\n--- 第{i+1}轮 ---")
+        print(f"投标人:{answer[:60]}...")
+
+        resp = httpx.post(
+            f"{BASE_URL}/defense/sessions/{session_id}/chat",
+            headers=headers,
+            json={"message": answer},
+            timeout=30.0,
+            trust_env=False,
+        )
+        resp.raise_for_status()
+        result = resp.json()
+
+        print(f"评审专家:{result['reply'][:80]}...")
+        print(f"阶段:{result['current_stage']}  总轮数:{result['total_turns']}")
+
+        if result["is_finished"]:
+            print("\n答辩已结束!")
+            summary = result.get("report_summary", {})
+            print(f"综合评分:{summary.get('overall_score', 0)} / 100")
+            print(f"优势:{summary.get('strengths', [])[:2]}")
+            print(f"改进方向:{summary.get('improvements', [])[:2]}")
+            break
+
+        time.sleep(1)  # 避免过快请求
+
+    # ── ④ 查看完整报告 ─────────────────────────────────────────
+    print_section("④ 查看完整评估报告")
+    resp = httpx.get(
+        f"{BASE_URL}/defense/sessions/{session_id}/report",
+        headers=headers,
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    report = resp.json()
+
+    print(f"综合评分:{report['overall_score']} / 100")
+    print(f"维度数量:{len(report['dimensions'])}")
+    for dim in report["dimensions"]:
+        print(f"  - {dim['dimension']}:{dim['score']}分  {dim['comment'][:40]}")
+    print(f"推荐复习:{report['recommended_topics']}")
+    print(f"下一步建议:{report['next_step_advice']}")
+
+    # ── ⑤ 查看历史答辩列表 ─────────────────────────────────────
+    print_section("⑤ 查看历史答辩列表")
+    resp = httpx.get(
+        f"{BASE_URL}/defense/sessions",
+        headers=headers,
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    sessions_list = resp.json()
+    print(f"历史答辩数量:{sessions_list['total']}")
+    for s in sessions_list["items"][:3]:
+        print(f"  - {s['session_id'][:8]}... [{s['target_position']}] "
+              f"评分:{s['overall_score'] or '进行中'} 状态:{s['status']}")
+
+    print("\n" + "="*60)
+    print("  端到端测试全部通过 ✅")
+    print("="*60)

+ 162 - 0
TenderAgent完整代码/backend/api/v1/verify_qa_e2e.py

@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+# scripts/manual_tests/test_qa_simple.py
+"""
+简易 QA 接口集成测试(真实服务调用)
+前提:服务已启动(localhost:8000),且 .env 配置了必要的 LLM/数据库/Milvus。
+
+运行:
+    conda activate edu_agent
+    python scripts/manual_tests/test_qa_simple.py
+"""
+
+import sys
+import json
+import httpx
+
+sys.path.insert(0, ".")
+
+BASE_URL  = "http://localhost:8000/api/v1"
+USERNAME  = "bidder01"
+PASSWORD  = "Bidder@123456"
+SESSION   = "qa-simple-session"   # 固定会话 ID
+
+
+def login() -> str:
+    """登录获取 JWT token"""
+    resp = httpx.post(
+        f"{BASE_URL}/auth/login",
+        json={"username": USERNAME, "password": PASSWORD},
+        trust_env=False
+    )
+    resp.raise_for_status()
+    token = resp.json()["access_token"]
+    print(f"[登录] Token 获取成功(前20字符): {token[:20]}...")
+    return token
+
+
+def chat(token: str, message: str, session_id: str = SESSION, enable_web_search: bool = False) -> dict:
+    """非流式聊天"""
+    resp = httpx.post(
+        f"{BASE_URL}/qa/chat",
+        headers={"Authorization": f"Bearer {token}"},
+        json={
+            "session_id":        session_id,
+            "message":           message,
+            "enable_web_search": enable_web_search,
+        },
+        timeout=60.0,
+    )
+    resp.raise_for_status()
+    return resp.json()
+
+
+def stream_chat(token: str, message: str, session_id: str = SESSION) -> None:
+    """流式聊天,打印所有 SSE 事件"""
+    print(f"\n[流式] 问题: {message}")
+    with httpx.stream(
+        "POST",
+        f"{BASE_URL}/qa/chat/stream",
+        headers={
+            "Authorization": f"Bearer {token}",
+            "Accept":        "text/event-stream",
+        },
+        json={"session_id": session_id, "message": message},
+        timeout=60.0,
+    ) as resp:
+        print("[流式] 开始接收事件:")
+        for line in resp.iter_lines():
+            if line.startswith("data:"):
+                data_str = line[5:].strip()
+                try:
+                    event = json.loads(data_str)
+                    # 根据事件类型美化打印
+                    if event.get("type") == "token":
+                        print(event["content"], end="", flush=True)  # 打字机效果
+                    elif event.get("type") == "progress":
+                        print(f"\n[进度] {event['stage']}")
+                    elif event.get("type") == "meta":
+                        print(f"\n[元数据] 模式={event['answer_mode']}, 置信度={event['confidence']}, 来源={event.get('sources', [])}")
+                    elif event.get("type") == "done":
+                        print("\n[流式] 完成")
+                    else:
+                        print(f"\n[其他事件] {event}")
+                except json.JSONDecodeError:
+                    print(f"[原始数据] {data_str}")
+        print("\n")
+
+
+def print_result(label: str, result: dict) -> None:
+    """打印非流式结果摘要"""
+    print(f"\n{'='*60}")
+    print(f"[{label}]")
+    print(f"  模式 : {result['answer_mode']}")
+    print(f"  置信度: {result['confidence']:.4f}")
+    print(f"  来源 : {result['sources']}")
+    print(f"  回答前100字: {result['answer'][:100]}...")
+    print(f"{'='*60}")
+
+
+if __name__ == "__main__":
+    try:
+        token = login()
+    except Exception as e:
+        print(f"❌ 登录失败,请检查服务是否启动及账号密码: {e}")
+        sys.exit(1)
+
+    # # ---------- 1. 测试 GENERAL 路径(闲聊) ----------
+    # print("\n>>> 测试 GENERAL 路径")
+    # r = chat(token, "你好,介绍一下你自己")
+    # print_result("GENERAL", r)
+    # if r["answer_mode"] == "general":
+    #     print("✅ GENERAL 路径正常")
+    # else:
+    #     print(f"⚠️ 模式为 {r['answer_mode']},可能未触发 GENERAL(需检查分类器)")
+    #
+    # # ---------- 2. 测试 PRECISE 路径(具体问题,期望 RAG) ----------
+    # print("\n>>> 测试 PRECISE 路径(RAG)")
+    # r = chat(token, "介绍下商品聚合的项目?")
+    # print_result("PRECISE", r)
+    # if r["answer_mode"] == "rag" and len(r["sources"]) > 0:
+    #     print("✅ PRECISE RAG 正常")
+    # else:
+    #     print(f"⚠️ 模式={r['answer_mode']}, 来源数={len(r['sources'])},可能知识库无相关内容或置信度不足")
+    #
+    # # ---------- 3. 测试低置信度(知识库无关内容) ----------
+    # print("\n>>> 测试低置信度(知识库无关)")
+    # r = chat(token, "如何制作披萨?")
+    # print_result("低置信度", r)
+    # if r["answer_mode"] == "llm_direct" and "⚠️" in r["answer"]:
+    #     print("✅ 低置信度路径正常(llm_direct + 提示)")
+    # else:
+    #     print(f"⚠️ 模式={r['answer_mode']},可能知识库意外包含相关内容,或置信度判读有变")
+
+    # ---------- 4. 测试流式接口 ----------
+    print("\n>>> 测试流式接口")
+    stream_chat(token, "介绍下商品聚合的项目")
+
+    # ---------- 5. 测试历史记录接口 ----------
+    # print("\n>>> 测试历史记录接口")
+    # try:
+    #     hist_resp = httpx.get(
+    #         f"{BASE_URL}/qa/sessions/{SESSION}/history",
+    #         headers={"Authorization": f"Bearer {token}"},
+    #         timeout=10.0,
+    #     )
+    #     hist_resp.raise_for_status()
+    #     hist_data = hist_resp.json()
+    #     print(f'hist_data-->{hist_data}')
+    #     print(f"  会话ID: {hist_data['session_id']}")
+    #     print(f"  总轮数: {hist_data['total_turns']}")
+    #     summary_val = hist_data.get('summary')
+    #     if summary_val is None:
+    #         summary_display = "无摘要(对话轮数不足10轮)"
+    #     else:
+    #         summary_display = summary_val[:100]
+    #     if hist_data["total_turns"] > 0:
+    #         print("✅ 历史记录接口正常")
+    #     else:
+    #         print("⚠️ 历史记录为空,可能服务未保存")
+    # except Exception as e:
+    #     print(f"❌ 历史记录接口异常: {e}")
+
+    print("\n✅ 所有测试完成。")

+ 123 - 0
TenderAgent完整代码/backend/api/v1/verify_qualification_e2e.py

@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""
+供应商资质审查 Agent —— 端到端本地验证脚本(免命令行版)
+直接运行即可,无需输入任何参数。
+"""
+
+import sys
+import time
+import httpx
+
+# ============ 在这里直接配置所有参数 ============
+FILE_PATH = "/Users/ligang/Desktop/LiveTenderAgent/samples/qualification_sample.pdf"          # 改成你的 PDF 绝对路径或相对路径
+BASE_URL = "http://localhost:8000"        # 后端地址
+USERNAME = "bidder01"                    # 登录用户名
+PASSWORD = "Bidder@123456"               # 登录密码
+POLL_INTERVAL = 3.0                       # 轮询间隔(秒)
+POLL_TIMEOUT = 180.0                      # 最长等待(秒)
+# =============================================
+
+
+def login(client: httpx.Client, username: str, password: str) -> str:
+    """登录,返回 access_token。"""
+    resp = client.post("/api/v1/auth/login", json={"username": username, "password": password})
+    if resp.status_code != 200:
+        sys.exit(f"❌ 登录失败({resp.status_code}):{resp.text}")
+    print(f'response: {resp.json()}')
+    token = resp.json().get("access_token")
+    if not token:
+        sys.exit(f"❌ 登录响应里没有 access_token:{resp.text}")
+    print(f"✅ 登录成功(用户:{username})")
+    return token
+
+
+def upload(client: httpx.Client, headers: dict, file_path: str) -> str:
+    """上传 PDF,返回 review_id。"""
+    try:
+        f = open(file_path, "rb")
+    except OSError as e:
+        sys.exit(f"❌ 打不开文件 {file_path}:{e}")
+    with f:
+        resp = client.post("/api/v1/qualification/upload", headers=headers,
+                           files={"file": (file_path.split("/")[-1], f, "application/pdf")})
+    if resp.status_code != 202:
+        sys.exit(f"❌ 上传失败({resp.status_code}):{resp.text}")
+    data = resp.json()
+    review_id = data["review_id"]
+    print(f"✅ 上传成功 → review_id = {review_id}")
+    print(f"   状态:{data['status']}(审查在后台进行,开始轮询…)\n")
+    return review_id
+
+
+def poll(client: httpx.Client, headers: dict, review_id: str,
+         interval: float, timeout: float) -> dict:
+    """轮询查询,直到 done / failed / 超时。"""
+    deadline = time.time() + timeout
+    attempt = 0
+    while time.time() < deadline:
+        attempt += 1
+        resp = client.get(f"/api/v1/qualification/reviews/{review_id}", headers=headers)
+        if resp.status_code != 200:
+            sys.exit(f"❌ 查询失败({resp.status_code}):{resp.text}")
+        data = resp.json()
+        status = data["status"]
+        print(f"  第 {attempt} 次轮询 → status: {status}")
+        if status in ("done", "failed"):
+            return data
+        time.sleep(interval)
+    sys.exit("⏰ 超时:仍未完成。请检查后端日志(搜 qualification.background_task_failed / 是否配了可用的 DEEPSEEK_API_KEY)。")
+
+
+def print_report(data: dict) -> None:
+    """漂亮地打印审查报告。"""
+    if data["status"] == "failed":
+        print("\n❌ 审查失败:", data.get("error_msg", "(无错误信息)"))
+        return
+
+    print("\n" + "=" * 48)
+    print("            资质审查报告")
+    print("=" * 48)
+    print(f"综合得分:{data.get('weighted_score')} / 100\n")
+
+    print("【六维度评分】")
+    for d in data.get("dimension_scores", []):
+        weight = d.get("weight", 0)
+        print(f"  · {d.get('dimension')}:{d.get('score')} 分(权重 {int(weight * 100)}%)")
+        for issue in d.get("issues", []):
+            print(f"      - 问题:{issue}")
+        for sug in d.get("suggestions", []):
+            print(f"      - 建议:{sug}")
+
+    print("\n【问题诊断】")
+    issues = data.get("issues", [])
+    if not issues:
+        print("  (无)")
+    for it in issues:
+        loc = it.get("location", "")
+        print(f"  [{it.get('priority', '').upper()}] {it.get('description', '')}"
+              + (f"({loc})" if loc else ""))
+        if it.get("suggestion"):
+            print(f"        → {it['suggestion']}")
+
+    summary = data.get("summary") or {}
+    print("\n【整体评价】")
+    print("  亮点:", ";".join(summary.get("highlights", [])) or "(无)")
+    print("  核心改进:", ";".join(summary.get("core_improvements", [])) or "(无)")
+    print("  综合评语:", summary.get("overall_comment", "(无)"))
+    print("  岗位匹配:", summary.get("fit_assessment", "(无)"))
+    print("=" * 48)
+
+
+def main() -> None:
+    # 直接使用上面定义的全局常量,不再需要 argparse
+    with httpx.Client(base_url=BASE_URL, timeout=30.0) as client:
+        token = login(client, USERNAME, PASSWORD)
+        print(f'token = {token}')
+        headers = {"Authorization": f"Bearer {token}"}
+        review_id = upload(client, headers, FILE_PATH)
+        data = poll(client, headers, review_id, POLL_INTERVAL, POLL_TIMEOUT)
+        print_report(data)
+
+
+if __name__ == "__main__":
+    main()

+ 194 - 0
TenderAgent完整代码/backend/api/v1/veriry_bid_review_e2e.py

@@ -0,0 +1,194 @@
+# scripts/manual_tests/test_bid_review.py
+
+import sys
+import json
+import time
+import httpx
+
+sys.path.insert(0, ".")
+
+BASE_URL    = "http://localhost:8000/api/v1"
+BID_REVIEW_ID     = "e0000001-0000-0000-0000-000000000001"   # seed_data.py 写入的 Java 基础测评文件
+DOCX_PATH   = "/Users/ligang/PycharmProjects/LLM/TenderAgent/backend/agents/bid_review/bidder_response.docx"
+BIDDER_UN  = "bidder01"
+BIDDER_PW  = "Bidder@123456"
+EXPERT_UN  = "expert01"
+EXPERT_PW  = "Expert@123456"
+
+
+def login(username: str, password: str) -> str:
+    resp = httpx.post(
+        f"{BASE_URL}/auth/login",
+        json={"username": username, "password": password},
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    return resp.json()["access_token"]
+
+
+def print_section(title: str):
+    print(f"\n{'='*60}")
+    print(f"  {title}")
+    print("="*60)
+
+
+if __name__ == "__main__":
+    # ── ① 投标人登录 ────────────────────────────────────────────
+    print_section("① 投标人登录")
+    bidder_token = login(BIDDER_UN, BIDDER_PW)
+    print(f"投标人 token 获取成功:{bidder_token[:20]}...")
+
+    # ── ② 提交投标文件 ───────────────────────────────────────────
+    print_section("② 投标人提交投标文件")
+    with open(DOCX_PATH, "rb") as f:
+        resp = httpx.post(
+            f"{BASE_URL}/bid_review/submit",
+            headers={"Authorization": f"Bearer {bidder_token}"},
+            data={"bid_review_id": BID_REVIEW_ID},
+            files={"file": ("test_bid_review_answer.docx", f, "application/octet-stream")},
+            timeout=30.0,
+            trust_env=False,
+        )
+    resp.raise_for_status()
+    submit_result = resp.json()
+    # print(f"submit result: {submit_result}")
+
+    submission_id = submit_result["submission_id"]
+    print(f"提交成功,submission_id: {submission_id}")
+    print(f"状态: {submit_result['status']}")
+    # ── ③ 轮询评分状态 ─────────────────────────────────────
+    print_section("③ 轮询评分状态(等待 AI 评分完成)")
+    for attempt in range(20):
+        time.sleep(5)
+        resp = httpx.get(
+            f"{BASE_URL}/bid_review/my-submissions/{submission_id}",
+            headers={"Authorization": f"Bearer {bidder_token}"},
+            trust_env=False,
+        )
+        resp.raise_for_status()
+        poll = resp.json()
+        print(f"  第{attempt+1}次轮询,状态:{poll['status']}")
+        if poll["status"] == "pending_review":
+            print("  AI 评分完成,等待专家确认")
+            break
+        if poll["status"] == "published":
+            print("  已发布(可能已被其他测试发布)")
+            break
+    else:
+        print("  ⚠️ 超时,请检查后端日志")
+        sys.exit(1)
+    # ── ④ 评审专家登录 ────────────────────────────────────────────
+    print_section("④ 评审专家登录")
+    expert_token = login(EXPERT_UN, EXPERT_PW)
+    print(f"评审专家 token 获取成功:{expert_token[:20]}...")
+    #
+    # ── ⑤ 查看待确认列表 ─────────────────────────────────────
+    print_section("⑤ 评审专家查看待确认列表")
+    resp = httpx.get(
+        f"{BASE_URL}/bid_review/pending-reviews",
+        headers={"Authorization": f"Bearer {expert_token}"},
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    pending = resp.json()
+    print(f'pending: {pending}')
+    print(f"待确认数量:{pending['total']}")
+    for item in pending["items"][:3]:
+        print(f"  - {item['submission_id'][:8]}... 投标人:{item['bidder_name']} "
+              f"AI分:{item['pre_review']['total_score']}/{item['pre_review']['full_score']}")
+
+    # # ── ⑥ 查看预评分详情 ─────────────────────────────────────
+    print_section("⑥ 评审专家查看预评分详情")
+    resp = httpx.get(
+        f"{BASE_URL}/bid_review/submissions/{submission_id}/review",
+        headers={"Authorization": f"Bearer {expert_token}"},
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    review = resp.json()
+    print(f"review result: {review}")
+    summary = review["pre_review_summary"]
+    print(f"AI 总分:{summary['total_score']}/{summary['full_score']} "
+          f"({summary['score_rate']*100:.1f}%)")
+    print(f"需复核题数:{summary.get('needs_review_count', 0)}")
+    print(f"薄弱点数量:{len(review['weak_points'])}")
+    for wp in review["weak_points"][:3]:
+        print(f"  - {wp['tag']}:{wp['wrong_count']} 道扣分项")
+
+    # 在 ⑥ 获取预评分详情之后,插入这段代码
+    print_section("⑥.⑤ 准备修改一道题的分数")
+
+    # 从 review 结果中找一道方案响应项(short_answer)或任意非客观评分项
+    target_question = None
+    for q in review["pre_review_summary"]["by_question"]:
+        if q["question_type"] == "short_answer":
+            target_question = q
+            break
+
+    if not target_question:
+        # 如果没有方案响应项,就取第一道题(但需要确保不是客观评分项,否则改了没意义)
+        target_question = review["pre_review_summary"]["by_question"][0]
+
+    original_score = target_question["score"]
+    new_score = min(original_score + 1, target_question["full_score"])  # 加1分,不超过满分
+
+    print(f"准备修改题号 {target_question['question_no']},原分 {original_score} -> 新分 {new_score}")
+
+    # ── ⑦ 评审专家 modify 发布 ──────────────────────────────────
+    print_section("⑦ 评审专家执行 modify 发布")
+    resp = httpx.post(
+        f"{BASE_URL}/bid_review/submissions/{submission_id}/confirm",
+        headers={"Authorization": f"Bearer {expert_token}"},
+        json={
+            "action": "modify",
+            "modifications": [
+                {
+                    "question_id": target_question["question_id"],  # 使用真实 ID
+                    "new_score": new_score,
+                    "comment": f"测试修改:从 {original_score} 分调整为 {new_score} 分"
+                }
+            ]
+        },
+        timeout=30.0,
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    confirm = resp.json()
+    print(f"发布成功,最终总分:{confirm['final_score']}/{confirm['full_score']}")
+    # ── ⑦ 评审专家 approve 发布 ──────────────────────────────────
+    # print_section("⑦ 评审专家 approve 发布")
+    # resp = httpx.post(
+    #     f"{BASE_URL}/bid_review/submissions/{submission_id}/confirm",
+    #     headers={"Authorization": f"Bearer {expert_token}"},
+    #     json={"action": "approve", "modifications": []},
+    #     timeout=30.0,
+    #     trust_env=False,
+    # )
+    # resp.raise_for_status()
+    # confirm = resp.json()
+    # print(f"发布成功,最终得分:{confirm['final_score']}/{confirm['full_score']}")
+    # print(f"得分率:{confirm['score_rate']*100:.1f}%")
+    # assert confirm["status"] == "published"
+    # print("✅ approve 流程通过")
+
+    # ── ⑧ 投标人查询最终结果 ──────────────────────────────────
+    print_section("⑧ 投标人查询最终结果")
+    resp = httpx.get(
+        f"{BASE_URL}/bid_review/my-submissions/{submission_id}",
+        headers={"Authorization": f"Bearer {bidder_token}"},
+        trust_env=False,
+    )
+    resp.raise_for_status()
+    final = resp.json()
+    assert final["status"] == "published"
+    by_q = final["pre_review_summary"]["by_question"]
+    print(f"评分项数量:{len(by_q)}")
+    for q in by_q:
+        print(f"  题{q['question_no']} [{q['question_type']}] "
+              f"{q['final_score']}/{q['full_score']} - {q['ai_feedback'][:40]}")
+    print("✅ 投标人查询结果通过")
+
+    print("\n" + "="*60)
+    print("  端到端测试全部通过 ✅")
+    print("="*60)
+

+ 86 - 0
TenderAgent完整代码/backend/config.py

@@ -0,0 +1,86 @@
+# backend/config.py
+# 全项目唯一的「配置中心」:从 .env.local 读取所有配置项,供任何模块取用。
+
+from pydantic_settings import BaseSettings   # Pydantic 的「配置基类」,能自动从环境变量/.env 读取并做类型校验
+from functools import lru_cache              # 标准库装饰器:缓存函数结果,让函数实际只执行一次
+import os, sys
+live_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+env_local_path = os.path.join(live_path, ".env.local")
+# print(f"env_local_path: {env_local_path}")
+
+class Settings(BaseSettings):
+    """配置模型:每个类属性对应 .env.local 里的一项配置。
+    继承 BaseSettings 后,Pydantic 会自动把同名(大小写不敏感)的配置读进来并转成对应类型。"""
+
+    # ── 数据库(PostgreSQL)──
+    db_host: str = "localhost"   # 主机;写了默认值 = 可选项
+    db_port: int = 5433          # 端口;本机 5432 已占用,隔离到 5433
+    db_name: str = "tenderagent"    # 库名
+    db_user: str                 # 用户名;没有默认值 = 必填,.env.local 缺了会启动报错
+    db_password: str             # 密码;同样必填
+
+    @property
+    def database_url(self) -> str:
+        """把上面几个散件拼成 SQLAlchemy 需要的连接串。
+        用 @property 装饰后,可像访问属性一样 settings.database_url 取值,不用加括号调用。"""
+        return (
+            f"postgresql+asyncpg://{self.db_user}:{self.db_password}"
+            f"@{self.db_host}:{self.db_port}/{self.db_name}"
+        )
+
+    # ── Milvus 向量库 ──
+    milvus_host: str = "localhost"
+    milvus_port: int = 19531     # 本机 19530 已占用,隔离到 19531
+
+    # ── 大模型(DeepSeek)──
+    deepseek_api_key: str                                   # 必填:DeepSeek 的 API Key
+    deepseek_base_url: str = "https://api.deepseek.com/v1"  # DeepSeek 接口地址
+    deepseek_model_chat: str = "deepseek-chat"              # 对话模型名
+    deepseek_model_coder: str = "deepseek-coder"            # 代码模型名
+
+    # ── 本地模型权重路径 ──
+    reranker_model_path: str = "./models/reranker/bge-reranker-large"    # 精排模型
+    classifier_model_path: str = "./models/classifier/all-MiniLM-L6-v2"  # 意图分类模型
+    bge_m3_model_path: str = "./models/embedding/bge-m3"                 # 嵌入模型
+    finetuned_classifier_path: str="./models/classifier/finetune"
+
+    # ── JWT 认证 ──
+    jwt_secret_key: str                           # 必填:签发登录令牌用的密钥
+    jwt_algorithm: str = "HS256"                  # 签名算法
+    jwt_access_token_expire_minutes: int = 10080  # 令牌有效期(分钟)
+
+    # ── MCP Server 地址(第五章用)──
+    kb_mcp_server_url:  str = "http://localhost:8000/mcp/kb"
+    web_search_mcp_url: str = "http://localhost:8000/mcp/web-search"
+
+    # ── Web 搜索(Tavily 可选;留空则自动用免费的 DuckDuckGo)──
+    tavily_api_key: str = ""
+
+    # ── 应用基础配置 ──
+    app_env: str = "local"                     # 运行环境标识
+    app_debug: bool = False                    # 是否调试模式
+    app_host: str = "0.0.0.0"                  # 监听地址
+    app_port: int = 8000                       # 监听端口
+    log_level: str = "INFO"                    # 日志级别
+    default_tenant_id: str = "tenant_default"  # 多租户默认值
+
+    class Config:
+        """Pydantic 的元配置:告诉 BaseSettings 该怎么读取配置。"""
+        env_file = env_local_path          # 从这个文件读取配置
+        env_file_encoding = "utf-8"      # 文件编码
+        case_sensitive = False           # 大小写不敏感:环境变量 DB_HOST 能对应字段 db_host
+        extra = "ignore"                 # .env.local 里多出来的、模型没定义的字段一律忽略(不报错)
+
+
+@lru_cache()                             # 缓存:保证 get_settings() 只创建一次 Settings、只读一次文件
+def get_settings() -> Settings:
+    """获取全局唯一的配置对象。任何模块要用配置,都调用这个函数。"""
+    return Settings()                    # 首次调用时创建实例;之后每次都返回同一个缓存对象
+
+
+if __name__ == '__main__':
+    settings = get_settings()
+    print(settings.database_url)
+    print(settings.db_user)
+    ...

+ 2125 - 0
TenderAgent完整代码/backend/core/data/training_data.jsonl

@@ -0,0 +1,2125 @@
+{"text": "什么是过拟合", "label": "general"}
+{"text": "过拟合和欠拟合有什么区别", "label": "general"}
+{"text": "正则化是什么意思", "label": "general"}
+{"text": "L1 和 L2 正则化有什么区别", "label": "general"}
+{"text": "什么是交叉验证", "label": "general"}
+{"text": "K折交叉验证怎么实现", "label": "general"}
+{"text": "偏差和方差的 trade-off 怎么理解", "label": "general"}
+{"text": "损失函数是什么", "label": "general"}
+{"text": "梯度下降原理是什么", "label": "general"}
+{"text": "随机梯度下降和批量梯度下降有什么区别", "label": "general"}
+{"text": "什么是学习率", "label": "general"}
+{"text": "学习率太大或太小会怎样", "label": "general"}
+{"text": "特征工程包括哪些步骤", "label": "general"}
+{"text": "特征归一化为什么重要", "label": "general"}
+{"text": "MinMaxScaler 和 StandardScaler 有什么区别", "label": "general"}
+{"text": "什么是 PCA", "label": "general"}
+{"text": "PCA 降维的原理是什么", "label": "general"}
+{"text": "逻辑回归和线性回归有什么区别", "label": "general"}
+{"text": "SVM 的核函数是什么意思", "label": "general"}
+{"text": "决策树怎么剪枝", "label": "general"}
+{"text": "随机森林和 GBDT 有什么区别", "label": "general"}
+{"text": "XGBoost 的原理是什么", "label": "general"}
+{"text": "集成学习中 Bagging 和 Boosting 有什么区别", "label": "general"}
+{"text": "kmeans 聚类算法怎么实现", "label": "general"}
+{"text": "kmeans 中 k 怎么选取", "label": "general"}
+{"text": "什么是 ROC 曲线", "label": "general"}
+{"text": "AUC 值怎么理解", "label": "general"}
+{"text": "精确率和召回率有什么区别", "label": "general"}
+{"text": "F1-score 是什么", "label": "general"}
+{"text": "什么是混淆矩阵", "label": "general"}
+{"text": "类别不平衡问题怎么处理", "label": "general"}
+{"text": "SMOTE 过采样原理是什么", "label": "general"}
+{"text": "贝叶斯分类器原理是什么", "label": "general"}
+{"text": "朴素贝叶斯中独立性假设是什么意思", "label": "general"}
+{"text": "KNN 算法原理是什么", "label": "general"}
+{"text": "机器学习模型调参有哪些方法", "label": "general"}
+{"text": "网格搜索和随机搜索哪个更好", "label": "general"}
+{"text": "特征选择有哪些方法", "label": "general"}
+{"text": "什么是特征重要性", "label": "general"}
+{"text": "数据集划分一般怎么划分", "label": "general"}
+{"text": "什么是神经网络", "label": "general"}
+{"text": "全连接层的作用是什么", "label": "general"}
+{"text": "激活函数为什么重要", "label": "general"}
+{"text": "ReLU 和 sigmoid 有什么区别", "label": "general"}
+{"text": "梯度消失问题是怎么产生的", "label": "general"}
+{"text": "梯度爆炸怎么解决", "label": "general"}
+{"text": "batch normalization 原理是什么", "label": "general"}
+{"text": "dropout 怎么防止过拟合", "label": "general"}
+{"text": "反向传播算法是怎么工作的", "label": "general"}
+{"text": "权重初始化为什么重要", "label": "general"}
+{"text": "Xavier 初始化和 He 初始化的区别", "label": "general"}
+{"text": "Adam 优化器原理是什么", "label": "general"}
+{"text": "momentum 优化器是什么", "label": "general"}
+{"text": "什么是学习率衰减", "label": "general"}
+{"text": "early stopping 是什么", "label": "general"}
+{"text": "深度学习中 epoch 和 iteration 有什么区别", "label": "general"}
+{"text": "mini-batch 大小如何选择", "label": "general"}
+{"text": "什么是残差连接", "label": "general"}
+{"text": "skip connection 解决了什么问题", "label": "general"}
+{"text": "什么是迁移学习", "label": "general"}
+{"text": "pytorch 里的 tensor 怎么创建", "label": "general"}
+{"text": "torch.Tensor 和 torch.tensor 有什么区别", "label": "general"}
+{"text": "pytorch 怎么定义一个自定义 Dataset", "label": "general"}
+{"text": "DataLoader 的 num_workers 参数是什么意思", "label": "general"}
+{"text": "pytorch 里的 backward 怎么触发的", "label": "general"}
+{"text": "autograd 的原理是什么", "label": "general"}
+{"text": "requires_grad 是什么", "label": "general"}
+{"text": "pytorch 里 model.train() 和 model.eval() 有什么区别", "label": "general"}
+{"text": "torch.no_grad() 有什么用", "label": "general"}
+{"text": "怎么在 pytorch 里保存和加载模型", "label": "general"}
+{"text": "state_dict 是什么", "label": "general"}
+{"text": "pytorch 里怎么迁移到 GPU", "label": "general"}
+{"text": "cuda 和 cpu 张量不能直接运算怎么解决", "label": "general"}
+{"text": "pytorch 里 view 和 reshape 有什么区别", "label": "general"}
+{"text": "squeeze 和 unsqueeze 怎么用", "label": "general"}
+{"text": "pytorch 里 nn.Module 怎么继承", "label": "general"}
+{"text": "forward 方法怎么写", "label": "general"}
+{"text": "loss.backward() 之后必须做什么", "label": "general"}
+{"text": "optimizer.zero_grad() 为什么必须调用", "label": "general"}
+{"text": "pytorch 的 collate_fn 怎么自定义", "label": "general"}
+{"text": "pytorch 里怎么做 embedding", "label": "general"}
+{"text": "nn.Embedding 的参数怎么设置", "label": "general"}
+{"text": "pytorch 里的 pack_padded_sequence 干什么用", "label": "general"}
+{"text": "torch.cat 和 torch.stack 有什么区别", "label": "general"}
+{"text": "pytorch hook 是什么", "label": "general"}
+{"text": "Transformer 的注意力机制是什么", "label": "general"}
+{"text": "self-attention 的计算过程是什么", "label": "general"}
+{"text": "多头注意力的作用是什么", "label": "general"}
+{"text": "Transformer 中位置编码是什么", "label": "general"}
+{"text": "为什么 Transformer 需要位置编码", "label": "general"}
+{"text": "encoder 和 decoder 在 Transformer 里分别做什么", "label": "general"}
+{"text": "BERT 的预训练任务是什么", "label": "general"}
+{"text": "BERT 中 MLM 是什么意思", "label": "general"}
+{"text": "NSP 任务是什么", "label": "general"}
+{"text": "BERT 的输入格式是什么", "label": "general"}
+{"text": "[CLS] token 有什么用", "label": "general"}
+{"text": "BERT 和 GPT 有什么区别", "label": "general"}
+{"text": "Transformer 比 RNN 好在哪里", "label": "general"}
+{"text": "attention mask 是什么", "label": "general"}
+{"text": "什么是 fine-tuning", "label": "general"}
+{"text": "BERT 做文本分类怎么做", "label": "general"}
+{"text": "tokenizer 的作用是什么", "label": "general"}
+{"text": "WordPiece 和 BPE 有什么区别", "label": "general"}
+{"text": "什么是大模型微调", "label": "general"}
+{"text": "LoRA 原理是什么", "label": "general"}
+{"text": "RNN 的基本结构是什么", "label": "general"}
+{"text": "LSTM 为什么能解决梯度消失", "label": "general"}
+{"text": "LSTM 的三个门分别是什么", "label": "general"}
+{"text": "遗忘门的作用是什么", "label": "general"}
+{"text": "GRU 和 LSTM 有什么区别", "label": "general"}
+{"text": "双向 LSTM 怎么理解", "label": "general"}
+{"text": "seq2seq 模型是什么", "label": "general"}
+{"text": "attention mechanism 在 seq2seq 里怎么用", "label": "general"}
+{"text": "RNN 的 hidden state 是什么", "label": "general"}
+{"text": "时间序列预测用什么模型好", "label": "general"}
+{"text": "卷积操作的原理是什么", "label": "general"}
+{"text": "池化层有什么作用", "label": "general"}
+{"text": "最大池化和平均池化区别是什么", "label": "general"}
+{"text": "CNN 中 padding 是什么", "label": "general"}
+{"text": "stride 参数是什么意思", "label": "general"}
+{"text": "感受野是什么", "label": "general"}
+{"text": "ResNet 的残差连接怎么工作", "label": "general"}
+{"text": "什么是 VGG 网络", "label": "general"}
+{"text": "目标检测和图像分类有什么区别", "label": "general"}
+{"text": "anchor box 是什么", "label": "general"}
+{"text": "YOLO 的原理是什么", "label": "general"}
+{"text": "图像增强有哪些方法", "label": "general"}
+{"text": "什么是 batch normalization 在 CNN 里的作用", "label": "general"}
+{"text": "深度可分离卷积是什么", "label": "general"}
+{"text": "1x1 卷积的作用是什么", "label": "general"}
+{"text": "词向量是什么", "label": "general"}
+{"text": "word2vec 原理是什么", "label": "general"}
+{"text": "CBOW 和 skip-gram 有什么区别", "label": "general"}
+{"text": "TF-IDF 是什么", "label": "general"}
+{"text": "文本分类一般怎么做", "label": "general"}
+{"text": "情感分析是什么任务", "label": "general"}
+{"text": "命名实体识别是什么", "label": "general"}
+{"text": "GAN 的原理是什么", "label": "general"}
+{"text": "生成器和判别器分别做什么", "label": "general"}
+{"text": "GAN 训练不稳定怎么解决", "label": "general"}
+{"text": "VAE 和 GAN 有什么区别", "label": "general"}
+{"text": "什么是强化学习", "label": "general"}
+{"text": "Q-learning 是什么", "label": "general"}
+{"text": "reward function 怎么设计", "label": "general"}
+{"text": "强化学习怎么入门", "label": "general"}
+{"text": "Java 里接口和抽象类有什么区别", "label": "general"}
+{"text": "Java 中 final 关键字有什么用", "label": "general"}
+{"text": "static 关键字的作用是什么", "label": "general"}
+{"text": "Java 的基本数据类型有哪些", "label": "general"}
+{"text": "装箱和拆箱是什么", "label": "general"}
+{"text": "Java 中 == 和 equals 有什么区别", "label": "general"}
+{"text": "hashCode 和 equals 的关系是什么", "label": "general"}
+{"text": "Java 里的泛型是什么", "label": "general"}
+{"text": "通配符 ? extends 和 ? super 有什么区别", "label": "general"}
+{"text": "Java 中的异常体系是什么", "label": "general"}
+{"text": "checked exception 和 unchecked exception 有什么区别", "label": "general"}
+{"text": "try-with-resources 怎么用", "label": "general"}
+{"text": "Java 8 的 lambda 表达式是什么", "label": "general"}
+{"text": "Stream API 怎么用", "label": "general"}
+{"text": "Optional 有什么用", "label": "general"}
+{"text": "Java 里的函数式接口是什么", "label": "general"}
+{"text": "方法引用怎么写", "label": "general"}
+{"text": "Java 里的内部类有哪几种", "label": "general"}
+{"text": "匿名类怎么用", "label": "general"}
+{"text": "Java 中字符串是不可变的吗", "label": "general"}
+{"text": "String StringBuilder StringBuffer 有什么区别", "label": "general"}
+{"text": "Java 里的反射机制是什么", "label": "general"}
+{"text": "反射的应用场景有哪些", "label": "general"}
+{"text": "什么是注解", "label": "general"}
+{"text": "自定义注解怎么写", "label": "general"}
+{"text": "JVM 内存结构是什么", "label": "general"}
+{"text": "堆和栈的区别是什么", "label": "general"}
+{"text": "方法区存什么", "label": "general"}
+{"text": "什么是 GC", "label": "general"}
+{"text": "垃圾回收算法有哪些", "label": "general"}
+{"text": "标记清除算法有什么缺点", "label": "general"}
+{"text": "G1 GC 的原理是什么", "label": "general"}
+{"text": "什么是 STW", "label": "general"}
+{"text": "类加载机制是什么", "label": "general"}
+{"text": "双亲委派模型是什么", "label": "general"}
+{"text": "什么是 OOM", "label": "general"}
+{"text": "如何排查内存泄漏", "label": "general"}
+{"text": "JVM 调优参数有哪些", "label": "general"}
+{"text": "-Xmx 和 -Xms 分别是什么", "label": "general"}
+{"text": "什么是 JIT 编译", "label": "general"}
+{"text": "synchronized 关键字怎么用", "label": "general"}
+{"text": "volatile 的作用是什么", "label": "general"}
+{"text": "什么是线程安全", "label": "general"}
+{"text": "Java 里有哪些锁", "label": "general"}
+{"text": "ReentrantLock 和 synchronized 有什么区别", "label": "general"}
+{"text": "什么是死锁", "label": "general"}
+{"text": "死锁怎么避免", "label": "general"}
+{"text": "ThreadLocal 是什么", "label": "general"}
+{"text": "线程池的参数有哪些", "label": "general"}
+{"text": "核心线程数和最大线程数有什么区别", "label": "general"}
+{"text": "阻塞队列有哪些实现", "label": "general"}
+{"text": "CountDownLatch 怎么用", "label": "general"}
+{"text": "CyclicBarrier 和 CountDownLatch 有什么区别", "label": "general"}
+{"text": "什么是 CAS", "label": "general"}
+{"text": "AQS 的原理是什么", "label": "general"}
+{"text": "Java 里的 happens-before 规则是什么", "label": "general"}
+{"text": "什么是内存可见性问题", "label": "general"}
+{"text": "线程的生命周期有哪些状态", "label": "general"}
+{"text": "Spring IOC 是什么", "label": "general"}
+{"text": "依赖注入有哪几种方式", "label": "general"}
+{"text": "Spring AOP 是什么", "label": "general"}
+{"text": "切面、切点、通知分别是什么", "label": "general"}
+{"text": "@Autowired 和 @Resource 有什么区别", "label": "general"}
+{"text": "Spring Bean 的作用域有哪些", "label": "general"}
+{"text": "Spring 的事务管理怎么用", "label": "general"}
+{"text": "@Transactional 注解的属性有哪些", "label": "general"}
+{"text": "事务的传播行为是什么", "label": "general"}
+{"text": "Spring MVC 的请求处理流程是什么", "label": "general"}
+{"text": "DispatcherServlet 的作用是什么", "label": "general"}
+{"text": "SpringBoot 自动配置原理是什么", "label": "general"}
+{"text": "@SpringBootApplication 包含哪些注解", "label": "general"}
+{"text": "SpringBoot 怎么读取配置文件", "label": "general"}
+{"text": "@Value 和 @ConfigurationProperties 有什么区别", "label": "general"}
+{"text": "SpringBoot 怎么集成 Redis", "label": "general"}
+{"text": "Spring Security 是什么", "label": "general"}
+{"text": "JWT 怎么实现认证", "label": "general"}
+{"text": "MyBatis 里 # 和 $ 有什么区别", "label": "general"}
+{"text": "MyBatis 的动态 SQL 怎么写", "label": "general"}
+{"text": "resultMap 有什么用", "label": "general"}
+{"text": "MySQL 的索引原理是什么", "label": "general"}
+{"text": "B+树和B树有什么区别", "label": "general"}
+{"text": "什么是聚簇索引", "label": "general"}
+{"text": "联合索引的最左匹配原则是什么", "label": "general"}
+{"text": "SQL 优化有哪些方法", "label": "general"}
+{"text": "explain 怎么用", "label": "general"}
+{"text": "MySQL 的事务隔离级别有哪些", "label": "general"}
+{"text": "幻读是什么", "label": "general"}
+{"text": "MVCC 原理是什么", "label": "general"}
+{"text": "MySQL 里行锁和表锁有什么区别", "label": "general"}
+{"text": "Redis 的数据结构有哪些", "label": "general"}
+{"text": "Redis 持久化方式有哪些", "label": "general"}
+{"text": "RDB 和 AOF 有什么区别", "label": "general"}
+{"text": "Redis 缓存穿透怎么解决", "label": "general"}
+{"text": "缓存雪崩和缓存击穿有什么区别", "label": "general"}
+{"text": "Redis 分布式锁怎么实现", "label": "general"}
+{"text": "什么是 Redis 集群", "label": "general"}
+{"text": "什么是微服务架构", "label": "general"}
+{"text": "微服务和单体架构有什么区别", "label": "general"}
+{"text": "服务注册与发现是什么", "label": "general"}
+{"text": "Nacos 和 Eureka 有什么区别", "label": "general"}
+{"text": "什么是 API 网关", "label": "general"}
+{"text": "服务熔断是什么", "label": "general"}
+{"text": "Hystrix 和 Sentinel 有什么区别", "label": "general"}
+{"text": "Docker 的原理是什么", "label": "general"}
+{"text": "镜像和容器有什么区别", "label": "general"}
+{"text": "Dockerfile 怎么写", "label": "general"}
+{"text": "Docker Compose 是什么", "label": "general"}
+{"text": "K8s 的架构是什么", "label": "general"}
+{"text": "Pod 是什么", "label": "general"}
+{"text": "Deployment 和 Service 有什么区别", "label": "general"}
+{"text": "什么是 Ingress", "label": "general"}
+{"text": "K8s 里的 ConfigMap 怎么用", "label": "general"}
+{"text": "Python 里的列表和元组有什么区别", "label": "general"}
+{"text": "字典的底层实现是什么", "label": "general"}
+{"text": "Python 的 GIL 是什么", "label": "general"}
+{"text": "Python 里的装饰器怎么写", "label": "general"}
+{"text": "生成器和迭代器有什么区别", "label": "general"}
+{"text": "yield 关键字的作用是什么", "label": "general"}
+{"text": "Python 里的上下文管理器是什么", "label": "general"}
+{"text": "__enter__ 和 __exit__ 怎么实现", "label": "general"}
+{"text": "Python 的多进程和多线程区别是什么", "label": "general"}
+{"text": "asyncio 是什么", "label": "general"}
+{"text": "async await 怎么用", "label": "general"}
+{"text": "Python 里的深拷贝和浅拷贝区别是什么", "label": "general"}
+{"text": "列表推导式和 map 有什么区别", "label": "general"}
+{"text": "Python 里的 *args 和 **kwargs 是什么", "label": "general"}
+{"text": "什么是闭包", "label": "general"}
+{"text": "Python 的垃圾回收机制是什么", "label": "general"}
+{"text": "引用计数的缺点是什么", "label": "general"}
+{"text": "numpy 的广播机制是什么", "label": "general"}
+{"text": "pandas 里 merge 和 join 有什么区别", "label": "general"}
+{"text": "groupby 怎么用", "label": "general"}
+{"text": "时间复杂度和空间复杂度怎么分析", "label": "general"}
+{"text": "二分查找的时间复杂度是多少", "label": "general"}
+{"text": "快速排序的原理是什么", "label": "general"}
+{"text": "归并排序和快速排序有什么区别", "label": "general"}
+{"text": "链表和数组的区别是什么", "label": "general"}
+{"text": "什么是哈希冲突", "label": "general"}
+{"text": "哈希冲突怎么解决", "label": "general"}
+{"text": "什么是 DFS 和 BFS", "label": "general"}
+{"text": "二叉树的前中后序遍历怎么实现", "label": "general"}
+{"text": "动态规划和贪心算法有什么区别", "label": "general"}
+{"text": "什么是回溯算法", "label": "general"}
+{"text": "最短路径算法有哪些", "label": "general"}
+{"text": "Dijkstra 算法原理是什么", "label": "general"}
+{"text": "堆排序怎么实现", "label": "general"}
+{"text": "什么是 trie 树", "label": "general"}
+{"text": "Linux 里 chmod 怎么用", "label": "general"}
+{"text": "什么是软链接和硬链接", "label": "general"}
+{"text": "Linux 进程和线程有什么区别", "label": "general"}
+{"text": "top 命令怎么看 CPU 使用率", "label": "general"}
+{"text": "grep 命令怎么用", "label": "general"}
+{"text": "awk 和 sed 有什么区别", "label": "general"}
+{"text": "Git 里 merge 和 rebase 有什么区别", "label": "general"}
+{"text": "git reset 和 git revert 有什么区别", "label": "general"}
+{"text": "什么是 REST API", "label": "general"}
+{"text": "HTTP 和 HTTPS 有什么区别", "label": "general"}
+{"text": "GET 和 POST 的区别是什么", "label": "general"}
+{"text": "什么是设计模式", "label": "general"}
+{"text": "单例模式怎么实现", "label": "general"}
+{"text": "工厂模式是什么", "label": "general"}
+{"text": "观察者模式怎么用", "label": "general"}
+{"text": "什么是大数据", "label": "general"}
+{"text": "Hadoop 和 Spark 有什么区别", "label": "general"}
+{"text": "MapReduce 的原理是什么", "label": "general"}
+{"text": "Kafka 是什么", "label": "general"}
+{"text": "什么是消息队列", "label": "general"}
+{"text": "transformer attention 我没太听懂能说说吗", "label": "general"}
+{"text": "pytorch 的 backward 咋触发的", "label": "general"}
+{"text": "GAN 原理把我整懵了能解释下吗", "label": "general"}
+{"text": "spring ioc 是啥意思", "label": "general"}
+{"text": "mysql 索引咋回事啊", "label": "general"}
+{"text": "git rebase 和 merge 区别是啥", "label": "general"}
+{"text": "python 装饰器咋写", "label": "general"}
+{"text": "redis 缓存穿透咋解决", "label": "general"}
+{"text": "k8s pod 是啥", "label": "general"}
+{"text": "docker 镜像和容器啥区别", "label": "general"}
+{"text": "lstm 三个门分别干啥", "label": "general"}
+{"text": "rnn 梯度消失咋产生的", "label": "general"}
+{"text": "bert cls token 有啥用", "label": "general"}
+{"text": "lora 原理是啥", "label": "general"}
+{"text": "bpe 分词是啥意思", "label": "general"}
+{"text": "死锁咋避免", "label": "general"}
+{"text": "线城安全是什么意思", "label": "general"}
+{"text": "cas 是啥", "label": "general"}
+{"text": "aqs 原理是啥", "label": "general"}
+{"text": "jvm oom 咋排查", "label": "general"}
+{"text": "什么是线城安全", "label": "general"}
+{"text": "synchronized 咋用", "label": "general"}
+{"text": "volatile 干啥用", "label": "general"}
+{"text": "threadlocal 是啥", "label": "general"}
+{"text": "lambda 表达式咋写", "label": "general"}
+{"text": "请解释 Transformer 中 Self-Attention 的计算过程", "label": "general"}
+{"text": "请说明 BERT 预训练任务的设计思路", "label": "general"}
+{"text": "请描述 ResNet 残差网络的基本原理及其解决的问题", "label": "general"}
+{"text": "请解释 Spring AOP 的实现原理", "label": "general"}
+{"text": "请描述 MySQL InnoDB 存储引擎的索引结构", "label": "general"}
+{"text": "请说明 JVM 垃圾回收算法的分类及各自特点", "label": "general"}
+{"text": "请解释 Python 异步编程的基本原理", "label": "general"}
+{"text": "请描述 Docker 容器与虚拟机的区别", "label": "general"}
+{"text": "请解释微服务架构中服务治理的核心组件", "label": "general"}
+{"text": "请说明 Kafka 消息队列的基本工作原理", "label": "general"}
+{"text": "attention 机制原理", "label": "general"}
+{"text": "LSTM 结构", "label": "general"}
+{"text": "pytorch dataloader 用法", "label": "general"}
+{"text": "spring boot 自动配置", "label": "general"}
+{"text": "mysql 慢查询优化", "label": "general"}
+{"text": "redis 数据结构", "label": "general"}
+{"text": "docker compose 用法", "label": "general"}
+{"text": "k8s deployment", "label": "general"}
+{"text": "python 生成器", "label": "general"}
+{"text": "git 分支管理", "label": "general"}
+{"text": "卷积核大小选择", "label": "general"}
+{"text": "批归一化原理", "label": "general"}
+{"text": "迁移学习概念", "label": "general"}
+{"text": "正则表达式基础", "label": "general"}
+{"text": "HTTP 状态码含义", "label": "general"}
+{"text": "单例模式线程安全", "label": "general"}
+{"text": "快排时间复杂度", "label": "general"}
+{"text": "二叉树遍历", "label": "general"}
+{"text": "哈希表冲突处理", "label": "general"}
+{"text": "TCP 三次握手", "label": "general"}
+{"text": "pytorch 里的 nn.Module 咋继承", "label": "general"}
+{"text": "transformer 里的 positional encoding 是干啥的", "label": "general"}
+{"text": "bert 的 fine-tuning 怎么做", "label": "general"}
+{"text": "spring 的 @Autowired 和 @Resource 啥区别", "label": "general"}
+{"text": "mysql 里 B+tree 索引咋工作的", "label": "general"}
+{"text": "redis 里 AOF 持久化是啥", "label": "general"}
+{"text": "k8s 里 ingress 是干啥的", "label": "general"}
+{"text": "python 里的 async/await 怎么用", "label": "general"}
+{"text": "git rebase 是什么操作", "label": "general"}
+{"text": "linux 里 awk 命令怎么用", "label": "general"}
+{"text": "GAN 里 generator 和 discriminator 怎么训练", "label": "general"}
+{"text": "YOLO 里的 anchor box 是什么", "label": "general"}
+{"text": "ResNet 里的 skip connection 为什么有效", "label": "general"}
+{"text": "LoRA 微调和全量微调有什么区别", "label": "general"}
+{"text": "word2vec 里 CBOW 和 skip-gram 哪个更好", "label": "general"}
+{"text": "SparkSQL 和普通 SQL 有什么区别", "label": "general"}
+{"text": "Kafka 里的 partition 和 consumer group 怎么对应", "label": "general"}
+{"text": "Nacos 和 Eureka 注册中心有什么区别", "label": "general"}
+{"text": "Spring Security 里 JWT 认证怎么实现", "label": "general"}
+{"text": "MyBatis 里 # 和 $ 占位符啥区别", "label": "general"}
+{"text": "深度学习从哪里开始学", "label": "general"}
+{"text": "机器学习需要什么数学基础", "label": "general"}
+{"text": "Python 基础学完了该学什么", "label": "general"}
+{"text": "Java 学到什么程度可以找工作", "label": "general"}
+{"text": "大数据方向怎么规划学习路径", "label": "general"}
+{"text": "NLP 方向怎么入门", "label": "general"}
+{"text": "CV 方向需要掌握哪些技术", "label": "general"}
+{"text": "微服务架构怎么系统学习", "label": "general"}
+{"text": "Docker 和 K8s 有必要都学吗", "label": "general"}
+{"text": "混淆矩阵怎么看", "label": "general"}
+{"text": "ROC 曲线面积 AUC 怎么计算", "label": "general"}
+{"text": "决策树信息增益是什么", "label": "general"}
+{"text": "随机森林为什么比单颗决策树好", "label": "general"}
+{"text": "XGBoost 和 LightGBM 有什么区别", "label": "general"}
+{"text": "DBSCAN 聚类和 kmeans 有什么区别", "label": "general"}
+{"text": "PCA 和 t-SNE 有什么区别", "label": "general"}
+{"text": "t-SNE 是什么算法", "label": "general"}
+{"text": "异常检测有哪些方法", "label": "general"}
+{"text": "时间序列平稳性是什么", "label": "general"}
+{"text": "什么是自回归模型", "label": "general"}
+{"text": "注意力机制和 self-attention 有什么区别", "label": "general"}
+{"text": "cross-attention 是什么", "label": "general"}
+{"text": "位置编码有哪几种", "label": "general"}
+{"text": "Sinusoidal 位置编码怎么计算", "label": "general"}
+{"text": "BERT 和 RoBERTa 有什么区别", "label": "general"}
+{"text": "GPT 的训练方式是什么", "label": "general"}
+{"text": "instructGPT 是什么", "label": "general"}
+{"text": "RLHF 是什么意思", "label": "general"}
+{"text": "什么是 prompt engineering", "label": "general"}
+{"text": "few-shot learning 是什么", "label": "general"}
+{"text": "zero-shot 和 few-shot 有什么区别", "label": "general"}
+{"text": "知识蒸馏是什么", "label": "general"}
+{"text": "模型量化是什么", "label": "general"}
+{"text": "int8 量化会损失多少精度", "label": "general"}
+{"text": "什么是联邦学习", "label": "general"}
+{"text": "对比学习是什么", "label": "general"}
+{"text": "自监督学习和无监督学习有什么区别", "label": "general"}
+{"text": "数据增强有哪些方法", "label": "general"}
+{"text": "mixup 数据增强是什么", "label": "general"}
+{"text": "目标检测中 mAP 怎么计算", "label": "general"}
+{"text": "IOU 是什么", "label": "general"}
+{"text": "NMS 是什么", "label": "general"}
+{"text": "图神经网络是什么", "label": "general"}
+{"text": "GNN 和 CNN 有什么区别", "label": "general"}
+{"text": "图卷积网络 GCN 原理是什么", "label": "general"}
+{"text": "什么是迁移学习中的 domain adaptation", "label": "general"}
+{"text": "多任务学习是什么", "label": "general"}
+{"text": "元学习是什么", "label": "general"}
+{"text": "transformer 能处理多长的序列", "label": "general"}
+{"text": "长序列 transformer 有哪些改进", "label": "general"}
+{"text": "Longformer 的稀疏注意力是什么", "label": "general"}
+{"text": "FlashAttention 原理是什么", "label": "general"}
+{"text": "什么是 KV cache", "label": "general"}
+{"text": "推理加速有哪些方法", "label": "general"}
+{"text": "Java 里 HashMap 的底层实现", "label": "general"}
+{"text": "HashMap 的扩容机制是什么", "label": "general"}
+{"text": "ConcurrentHashMap 怎么保证线程安全", "label": "general"}
+{"text": "LinkedHashMap 和 HashMap 区别", "label": "general"}
+{"text": "TreeMap 底层是什么数据结构", "label": "general"}
+{"text": "Java 里 comparator 和 comparable 区别", "label": "general"}
+{"text": "PriorityQueue 怎么自定义比较", "label": "general"}
+{"text": "Java NIO 是什么", "label": "general"}
+{"text": "BIO NIO AIO 有什么区别", "label": "general"}
+{"text": "Netty 是什么", "label": "general"}
+{"text": "Java 里的 SPI 机制是什么", "label": "general"}
+{"text": "动态代理有几种方式", "label": "general"}
+{"text": "JDK 动态代理和 CGLib 有什么区别", "label": "general"}
+{"text": "Spring 里 Bean 的生命周期是什么", "label": "general"}
+{"text": "BeanFactory 和 ApplicationContext 有什么区别", "label": "general"}
+{"text": "Spring 里循环依赖怎么解决", "label": "general"}
+{"text": "三级缓存是什么", "label": "general"}
+{"text": "MyBatis 里的一级缓存和二级缓存有什么区别", "label": "general"}
+{"text": "MySQL 里的 undo log 和 redo log 有什么区别", "label": "general"}
+{"text": "binlog 是什么", "label": "general"}
+{"text": "MySQL 主从复制原理是什么", "label": "general"}
+{"text": "分库分表怎么做", "label": "general"}
+{"text": "ShardingSphere 是什么", "label": "general"}
+{"text": "Redis 里的 LRU 淘汰策略是什么", "label": "general"}
+{"text": "Redis Cluster 的分片原理是什么", "label": "general"}
+{"text": "哨兵模式是什么", "label": "general"}
+{"text": "Zookeeper 是什么", "label": "general"}
+{"text": "Zookeeper 和 Eureka 有什么区别", "label": "general"}
+{"text": "gRPC 和 HTTP REST 有什么区别", "label": "general"}
+{"text": "Protobuf 是什么", "label": "general"}
+{"text": "什么是 CAP 定理", "label": "general"}
+{"text": "BASE 理论是什么", "label": "general"}
+{"text": "分布式事务有哪些解决方案", "label": "general"}
+{"text": "2PC 和 3PC 有什么区别", "label": "general"}
+{"text": "Saga 模式是什么", "label": "general"}
+{"text": "什么是幂等性", "label": "general"}
+{"text": "接口幂等怎么保证", "label": "general"}
+{"text": "大文件上传怎么实现", "label": "general"}
+{"text": "断点续传原理是什么", "label": "general"}
+{"text": "什么是 CDN", "label": "general"}
+{"text": "负载均衡有哪些策略", "label": "general"}
+{"text": "一致性哈希是什么", "label": "general"}
+{"text": "布隆过滤器原理是什么", "label": "general"}
+{"text": "Spark RDD 是什么", "label": "general"}
+{"text": "DataFrame 和 RDD 有什么区别", "label": "general"}
+{"text": "Spark 的 shuffle 机制是什么", "label": "general"}
+{"text": "Hive 是什么", "label": "general"}
+{"text": "Hive 和 MySQL 有什么区别", "label": "general"}
+{"text": "Flink 和 Spark Streaming 有什么区别", "label": "general"}
+{"text": "实时计算和批处理有什么区别", "label": "general"}
+{"text": "数仓分层架构是什么", "label": "general"}
+{"text": "ODS DWD DWS 分别是什么", "label": "general"}
+{"text": "Linux 里的进程间通信方式有哪些", "label": "general"}
+{"text": "管道和消息队列有什么区别", "label": "general"}
+{"text": "shell 脚本怎么写", "label": "general"}
+{"text": "crontab 怎么用", "label": "general"}
+{"text": "什么是 inode", "label": "general"}
+{"text": "什么是文件描述符", "label": "general"}
+{"text": "epoll 和 select 有什么区别", "label": "general"}
+{"text": "IO 多路复用是什么", "label": "general"}
+{"text": "Python 里多进程怎么用", "label": "general"}
+{"text": "multiprocessing 和 threading 区别", "label": "general"}
+{"text": "Python 里的信号量怎么用", "label": "general"}
+{"text": "什么是幂等请求", "label": "general"}
+{"text": "HTTP 长连接和短连接有什么区别", "label": "general"}
+{"text": "WebSocket 和 HTTP 有什么区别", "label": "general"}
+{"text": "什么是 OAuth2", "label": "general"}
+{"text": "JWT 的结构是什么", "label": "general"}
+{"text": "对称加密和非对称加密有什么区别", "label": "general"}
+{"text": "RSA 算法原理是什么", "label": "general"}
+{"text": "什么是 SQL 注入", "label": "general"}
+{"text": "XSS 和 CSRF 有什么区别", "label": "general"}
+{"text": "怎么防止 SQL 注入", "label": "general"}
+{"text": "设计模式六大原则是什么", "label": "general"}
+{"text": "开闭原则是什么", "label": "general"}
+{"text": "里氏替换原则是什么", "label": "general"}
+{"text": "责任链模式是什么", "label": "general"}
+{"text": "策略模式和工厂模式有什么区别", "label": "general"}
+{"text": "代理模式有哪几种", "label": "general"}
+{"text": "装饰者模式是什么", "label": "general"}
+{"text": "梯度下降有几种变体", "label": "general"}
+{"text": "随机梯度下降的缺点是什么", "label": "general"}
+{"text": "Adam 比 SGD 好在哪里", "label": "general"}
+{"text": "学习率warmup是什么", "label": "general"}
+{"text": "cosine annealing 学习率调度是什么", "label": "general"}
+{"text": "什么是 one-cycle policy", "label": "general"}
+{"text": "标签平滑是什么", "label": "general"}
+{"text": "focal loss 解决了什么问题", "label": "general"}
+{"text": "class imbalance 有哪些处理方法", "label": "general"}
+{"text": "什么是 hard negative mining", "label": "general"}
+{"text": "对抗训练是什么", "label": "general"}
+{"text": "什么是模型蒸馏", "label": "general"}
+{"text": "expert bidder 网络怎么训练", "label": "general"}
+{"text": "模型剪枝有哪些方法", "label": "general"}
+{"text": "结构化剪枝和非结构化剪枝有什么区别", "label": "general"}
+{"text": "什么是 AutoML", "label": "general"}
+{"text": "神经网络结构搜索 NAS 是什么", "label": "general"}
+{"text": "超参数优化有哪些方法", "label": "general"}
+{"text": "贝叶斯优化怎么用于调参", "label": "general"}
+{"text": "什么是多目标优化", "label": "general"}
+{"text": "帕累托前沿是什么", "label": "general"}
+{"text": "什么是招标资料学习 Curriculum Learning", "label": "general"}
+{"text": "自训练半监督学习是什么", "label": "general"}
+{"text": "主动学习是什么", "label": "general"}
+{"text": "标注成本高怎么解决", "label": "general"}
+{"text": "弱监督学习是什么", "label": "general"}
+{"text": "噪声标签怎么处理", "label": "general"}
+{"text": "数据清洗有哪些方法", "label": "general"}
+{"text": "离群点检测有哪些算法", "label": "general"}
+{"text": "Isolation Forest 原理是什么", "label": "general"}
+{"text": "Layer Normalization 和 Batch Normalization 有什么区别", "label": "general"}
+{"text": "Group Normalization 是什么", "label": "general"}
+{"text": "Instance Normalization 什么时候用", "label": "general"}
+{"text": "什么是 weight decay", "label": "general"}
+{"text": "正则化 weight decay 和 L2 有什么关系", "label": "general"}
+{"text": "gradient clipping 怎么用", "label": "general"}
+{"text": "什么是 mixed precision training", "label": "general"}
+{"text": "fp16 训练有什么好处", "label": "general"}
+{"text": "什么是梯度累积", "label": "general"}
+{"text": "模型并行和数据并行有什么区别", "label": "general"}
+{"text": "DDP 是什么", "label": "general"}
+{"text": "分布式训练 all-reduce 是什么", "label": "general"}
+{"text": "什么是 ZeRO 优化", "label": "general"}
+{"text": "pipeline parallelism 是什么", "label": "general"}
+{"text": "Tensor parallelism 是什么", "label": "general"}
+{"text": "什么是 checkpoint activation", "label": "general"}
+{"text": "gradient checkpointing 有什么用", "label": "general"}
+{"text": "什么是 profile 分析", "label": "general"}
+{"text": "怎么分析模型的计算瓶颈", "label": "general"}
+{"text": "什么是 FLOPs", "label": "general"}
+{"text": "模型大小和推理速度的关系", "label": "general"}
+{"text": "INT8 推理是什么", "label": "general"}
+{"text": "TensorRT 是什么", "label": "general"}
+{"text": "ONNX 是什么", "label": "general"}
+{"text": "模型转换有哪些工具", "label": "general"}
+{"text": "什么是 TFLite", "label": "general"}
+{"text": "边缘部署有哪些挑战", "label": "general"}
+{"text": "什么是模型压缩", "label": "general"}
+{"text": "参数共享是什么", "label": "general"}
+{"text": "低秩分解是什么", "label": "general"}
+{"text": "pytorch 里的 pin_memory 有什么用", "label": "general"}
+{"text": "prefetch_factor 参数是什么意思", "label": "general"}
+{"text": "pytorch profiler 怎么用", "label": "general"}
+{"text": "torchscript 是什么", "label": "general"}
+{"text": "torch.jit.trace 和 torch.jit.script 有什么区别", "label": "general"}
+{"text": "pytorch 里怎么做梯度裁剪", "label": "general"}
+{"text": "clip_grad_norm 和 clip_grad_value 有什么区别", "label": "general"}
+{"text": "pytorch 里的 amp 自动混合精度怎么用", "label": "general"}
+{"text": "GradScaler 是干什么的", "label": "general"}
+{"text": "pytorch 里的 DDP 怎么初始化", "label": "general"}
+{"text": "torchvision transforms 常用有哪些", "label": "general"}
+{"text": "pytorch 里的 lr_scheduler 有哪些", "label": "general"}
+{"text": "ReduceLROnPlateau 怎么用", "label": "general"}
+{"text": "CosineAnnealingLR 参数怎么设", "label": "general"}
+{"text": "pytorch 里 model.parameters() 和 model.named_parameters() 区别", "label": "general"}
+{"text": "冻结部分层怎么做", "label": "general"}
+{"text": "pytorch 里的 hook 怎么注册", "label": "general"}
+{"text": "register_forward_hook 和 register_backward_hook 区别", "label": "general"}
+{"text": "pytorch 里怎么可视化特征图", "label": "general"}
+{"text": "grad-cam 是什么", "label": "general"}
+{"text": "cross-entropy loss 怎么计算", "label": "general"}
+{"text": "label smoothing cross entropy 是什么", "label": "general"}
+{"text": "温度系数 temperature 在 softmax 里有什么作用", "label": "general"}
+{"text": "beam search 是什么", "label": "general"}
+{"text": "greedy decoding 和 beam search 有什么区别", "label": "general"}
+{"text": "nucleus sampling 是什么", "label": "general"}
+{"text": "top-k sampling 是什么", "label": "general"}
+{"text": "什么是 perplexity", "label": "general"}
+{"text": "困惑度怎么计算", "label": "general"}
+{"text": "BLEU 分数是什么", "label": "general"}
+{"text": "ROUGE 指标是什么", "label": "general"}
+{"text": "BERTScore 是什么", "label": "general"}
+{"text": "文本生成评估指标有哪些", "label": "general"}
+{"text": "什么是 hallucination", "label": "general"}
+{"text": "RAG 检索增强生成是什么", "label": "general"}
+{"text": "向量数据库是什么", "label": "general"}
+{"text": "FAISS 怎么用", "label": "general"}
+{"text": "embedding 相似度计算用什么", "label": "general"}
+{"text": "余弦相似度和欧氏距离有什么区别", "label": "general"}
+{"text": "sentence transformers 是什么", "label": "general"}
+{"text": "Java 里 String.intern() 有什么用", "label": "general"}
+{"text": "方法区溢出怎么排查", "label": "general"}
+{"text": "Java 里的弱引用是什么", "label": "general"}
+{"text": "WeakHashMap 有什么用", "label": "general"}
+{"text": "Java 里的引用类型有哪几种", "label": "general"}
+{"text": "软引用和弱引用有什么区别", "label": "general"}
+{"text": "Java 9 之后模块系统是什么", "label": "general"}
+{"text": "record 类是什么", "label": "general"}
+{"text": "sealed class 是什么", "label": "general"}
+{"text": "Java 里的 switch 表达式怎么用", "label": "general"}
+{"text": "pattern matching instanceof 是什么", "label": "general"}
+{"text": "Java 里的 text block 怎么用", "label": "general"}
+{"text": "CompletableFuture 怎么用", "label": "general"}
+{"text": "异步编程 CompletableFuture 和 Future 有什么区别", "label": "general"}
+{"text": "thenApply 和 thenCompose 有什么区别", "label": "general"}
+{"text": "Java 里的 Executor 框架是什么", "label": "general"}
+{"text": "ScheduledExecutorService 怎么用", "label": "general"}
+{"text": "什么是 fork/join 框架", "label": "general"}
+{"text": "work-stealing 算法是什么", "label": "general"}
+{"text": "Java 里的 Semaphore 怎么用", "label": "general"}
+{"text": "Exchanger 是什么", "label": "general"}
+{"text": "Phaser 和 CyclicBarrier 有什么区别", "label": "general"}
+{"text": "StampedLock 是什么", "label": "general"}
+{"text": "ReadWriteLock 怎么用", "label": "general"}
+{"text": "Java 里的原子类有哪些", "label": "general"}
+{"text": "AtomicInteger 和 volatile 有什么区别", "label": "general"}
+{"text": "LongAdder 比 AtomicLong 好在哪", "label": "general"}
+{"text": "什么是 false sharing", "label": "general"}
+{"text": "CPU 缓存行是什么", "label": "general"}
+{"text": "MESI 协议是什么", "label": "general"}
+{"text": "Spring 里的 FactoryBean 是什么", "label": "general"}
+{"text": "BeanPostProcessor 有什么用", "label": "general"}
+{"text": "BeanDefinitionRegistryPostProcessor 是什么", "label": "general"}
+{"text": "Spring 事件机制是什么", "label": "general"}
+{"text": "ApplicationEvent 怎么发布和监听", "label": "general"}
+{"text": "Spring 里的 @Conditional 怎么用", "label": "general"}
+{"text": "@Profile 注解是什么", "label": "general"}
+{"text": "Spring Boot 的 starter 机制是什么", "label": "general"}
+{"text": "自定义 starter 怎么写", "label": "general"}
+{"text": "Spring Boot actuator 是什么", "label": "general"}
+{"text": "metrics 监控怎么做", "label": "general"}
+{"text": "Micrometer 是什么", "label": "general"}
+{"text": "Spring 里的 RestTemplate 和 WebClient 有什么区别", "label": "general"}
+{"text": "WebFlux 是什么", "label": "general"}
+{"text": "响应式编程是什么", "label": "general"}
+{"text": "Reactor 里的 Mono 和 Flux 是什么", "label": "general"}
+{"text": "背压是什么", "label": "general"}
+{"text": "Spring Data JPA 和 MyBatis 怎么选", "label": "general"}
+{"text": "JPQL 是什么", "label": "general"}
+{"text": "Hibernate 的 N+1 问题是什么", "label": "general"}
+{"text": "Python 里的 dataclass 是什么", "label": "general"}
+{"text": "namedtuple 和 dataclass 有什么区别", "label": "general"}
+{"text": "Python 里的 Protocol 是什么", "label": "general"}
+{"text": "typing 模块有哪些常用类型", "label": "general"}
+{"text": "Python 里的 slots 有什么用", "label": "general"}
+{"text": "property 装饰器怎么用", "label": "general"}
+{"text": "Python 里的 abc 模块是什么", "label": "general"}
+{"text": "抽象基类怎么定义", "label": "general"}
+{"text": "Python 里的 mro 是什么", "label": "general"}
+{"text": "多继承时方法解析顺序是什么", "label": "general"}
+{"text": "Python 里的 super() 怎么用", "label": "general"}
+{"text": "__init__ 和 __new__ 有什么区别", "label": "general"}
+{"text": "__repr__ 和 __str__ 有什么区别", "label": "general"}
+{"text": "Python 里的魔术方法有哪些", "label": "general"}
+{"text": "__getattr__ 和 __getattribute__ 有什么区别", "label": "general"}
+{"text": "Python 里的描述符是什么", "label": "general"}
+{"text": "functools.lru_cache 怎么用", "label": "general"}
+{"text": "Python 里的 partial 是什么", "label": "general"}
+{"text": "itertools 模块有哪些常用函数", "label": "general"}
+{"text": "Python 里的 chain 和 product 怎么用", "label": "general"}
+{"text": "collections 模块里有哪些常用类", "label": "general"}
+{"text": "deque 和 list 有什么区别", "label": "general"}
+{"text": "Counter 怎么用", "label": "general"}
+{"text": "defaultdict 有什么用", "label": "general"}
+{"text": "heapq 模块怎么用", "label": "general"}
+{"text": "Python 里的堆排序怎么实现", "label": "general"}
+{"text": "快速选择算法是什么", "label": "general"}
+{"text": "拓扑排序怎么实现", "label": "general"}
+{"text": "并查集是什么", "label": "general"}
+{"text": "线段树是什么", "label": "general"}
+{"text": "字典树 Trie 怎么实现", "label": "general"}
+{"text": "最长公共子序列算法是什么", "label": "general"}
+{"text": "编辑距离算法是什么", "label": "general"}
+{"text": "KMP 算法原理是什么", "label": "general"}
+{"text": "字符串哈希是什么", "label": "general"}
+{"text": "矩阵快速幂是什么", "label": "general"}
+{"text": "Linux 里的 inotify 是什么", "label": "general"}
+{"text": "文件系统监控怎么做", "label": "general"}
+{"text": "Linux 里的 namespace 是什么", "label": "general"}
+{"text": "cgroup 是什么", "label": "general"}
+{"text": "容器技术的底层原理是什么", "label": "general"}
+{"text": "什么是 overlay 文件系统", "label": "general"}
+{"text": "Linux 里的 OOM killer 是什么", "label": "general"}
+{"text": "swap 分区是什么", "label": "general"}
+{"text": "什么是 NUMA 架构", "label": "general"}
+{"text": "CPU 亲和性是什么", "label": "general"}
+{"text": "Linux 里的 perf 工具怎么用", "label": "general"}
+{"text": "火焰图是什么", "label": "general"}
+{"text": "什么是 eBPF", "label": "general"}
+{"text": "tcpdump 怎么用", "label": "general"}
+{"text": "wireshark 分析网络包", "label": "general"}
+{"text": "HTTP/2 和 HTTP/1.1 有什么区别", "label": "general"}
+{"text": "HTTP/3 基于什么协议", "label": "general"}
+{"text": "QUIC 协议是什么", "label": "general"}
+{"text": "TCP 四次挥手是什么", "label": "general"}
+{"text": "TCP 的拥塞控制算法有哪些", "label": "general"}
+{"text": "什么是 TLS 握手", "label": "general"}
+{"text": "证书链是什么", "label": "general"}
+{"text": "什么是 SNI", "label": "general"}
+{"text": "DNS 解析过程是什么", "label": "general"}
+{"text": "什么是 anycast", "label": "general"}
+{"text": "什么是 BGP 协议", "label": "general"}
+{"text": "什么是 SDN", "label": "general"}
+{"text": "微内核和宏内核有什么区别", "label": "general"}
+{"text": "什么是 POSIX", "label": "general"}
+{"text": "系统调用怎么工作的", "label": "general"}
+{"text": "什么是 attention sink", "label": "general"}
+{"text": "KV cache 怎么压缩", "label": "general"}
+{"text": "speculative decoding 是什么", "label": "general"}
+{"text": "什么是 MoE 架构", "label": "general"}
+{"text": "mixture of experts 怎么工作", "label": "general"}
+{"text": "稀疏激活是什么", "label": "general"}
+{"text": "什么是 token merging", "label": "general"}
+{"text": "clip 模型是什么", "label": "general"}
+{"text": "DALL-E 的原理是什么", "label": "general"}
+{"text": "stable diffusion 怎么工作", "label": "general"}
+{"text": "diffusion model 和 GAN 有什么区别", "label": "general"}
+{"text": "denoising score matching 是什么", "label": "general"}
+{"text": "U-Net 在扩散模型里做什么", "label": "general"}
+{"text": "CFG guidance scale 是什么意思", "label": "general"}
+{"text": "什么是 inpainting", "label": "general"}
+{"text": "ControlNet 是什么", "label": "general"}
+{"text": "Dreambooth 是什么", "label": "general"}
+{"text": "lora 和 dreambooth 的区别是什么", "label": "general"}
+{"text": "textual inversion 是什么", "label": "general"}
+{"text": "什么是 IP-Adapter", "label": "general"}
+{"text": "SAM 分割一切模型是什么", "label": "general"}
+{"text": "CLIP 的对比学习怎么做", "label": "general"}
+{"text": "DINO 自监督视觉是什么", "label": "general"}
+{"text": "MAE 掩码自编码器是什么", "label": "general"}
+{"text": "什么是 visual prompt tuning", "label": "general"}
+{"text": "prefix tuning 和 prompt tuning 有什么区别", "label": "general"}
+{"text": "P-tuning 是什么", "label": "general"}
+{"text": "adapter 方法是什么", "label": "general"}
+{"text": "delta tuning 有哪些方法", "label": "general"}
+{"text": "什么是 PEFT", "label": "general"}
+{"text": "QLoRA 和 LoRA 有什么区别", "label": "general"}
+{"text": "4bit 量化训练是什么", "label": "general"}
+{"text": "什么是 GPTQ 量化", "label": "general"}
+{"text": "AWQ 量化是什么", "label": "general"}
+{"text": "什么是 bitsandbytes", "label": "general"}
+{"text": "deepspeed 是什么", "label": "general"}
+{"text": "ZeRO stage 有几个", "label": "general"}
+{"text": "什么是 tensor offloading", "label": "general"}
+{"text": "CPU offloading 在训练里有什么用", "label": "general"}
+{"text": "gradient accumulation 和 batch size 的关系", "label": "general"}
+{"text": "什么是 FSDP", "label": "general"}
+{"text": "模型并行的通信开销怎么减少", "label": "general"}
+{"text": "all-gather 和 reduce-scatter 是什么", "label": "general"}
+{"text": "什么是 collective communication", "label": "general"}
+{"text": "nccl 是什么", "label": "general"}
+{"text": "什么是 MPI", "label": "general"}
+{"text": "分布式训练的同步和异步模式有什么区别", "label": "general"}
+{"text": "参数服务器架构是什么", "label": "general"}
+{"text": "Horovod 是什么", "label": "general"}
+{"text": "megatron-LM 是什么", "label": "general"}
+{"text": "什么是 pipeline bubble", "label": "general"}
+{"text": "micro-batch 是什么", "label": "general"}
+{"text": "虚拟流水线并行是什么", "label": "general"}
+{"text": "什么是 activation recomputation", "label": "general"}
+{"text": "什么是 selective recomputation", "label": "general"}
+{"text": "continuous batching 是什么", "label": "general"}
+{"text": "dynamic batching 和 static batching 有什么区别", "label": "general"}
+{"text": "什么是 paged attention", "label": "general"}
+{"text": "vLLM 的原理是什么", "label": "general"}
+{"text": "什么是 prefix caching", "label": "general"}
+{"text": "什么是 speculative sampling", "label": "general"}
+{"text": "draft model 是什么", "label": "general"}
+{"text": "什么是 lookahead decoding", "label": "general"}
+{"text": "什么是 medusa decoding", "label": "general"}
+{"text": "什么是 tree attention", "label": "general"}
+{"text": "什么是 multi-query attention", "label": "general"}
+{"text": "grouped query attention 是什么", "label": "general"}
+{"text": "什么是 sliding window attention", "label": "general"}
+{"text": "ALiBi 位置编码是什么", "label": "general"}
+{"text": "RoPE 旋转位置编码是什么", "label": "general"}
+{"text": "什么是 xPos", "label": "general"}
+{"text": "什么是 extended rope", "label": "general"}
+{"text": "YaRN 是什么", "label": "general"}
+{"text": "long context fine-tuning 怎么做", "label": "general"}
+{"text": "什么是 LongLoRA", "label": "general"}
+{"text": "什么是 LIMA 对齐", "label": "general"}
+{"text": "instruction tuning 是什么", "label": "general"}
+{"text": "什么是 SFT supervised fine-tuning", "label": "general"}
+{"text": "什么是 DPO direct preference optimization", "label": "general"}
+{"text": "PPO 在 RLHF 里怎么用", "label": "general"}
+{"text": "什么是 reward model", "label": "general"}
+{"text": "什么是 constitutional AI", "label": "general"}
+{"text": "RLAIF 是什么", "label": "general"}
+{"text": "什么是 rejection sampling fine-tuning", "label": "general"}
+{"text": "什么是 online DPO", "label": "general"}
+{"text": "IPO 和 DPO 有什么区别", "label": "general"}
+{"text": "KTO 是什么", "label": "general"}
+{"text": "什么是 ORPO", "label": "general"}
+{"text": "self-play 微调是什么", "label": "general"}
+{"text": "什么是 iterative preference optimization", "label": "general"}
+{"text": "chain of thought 是什么", "label": "general"}
+{"text": "tree of thoughts 是什么", "label": "general"}
+{"text": "什么是 graph of thoughts", "label": "general"}
+{"text": "ReAct 框架是什么", "label": "general"}
+{"text": "什么是 tool use in LLM", "label": "general"}
+{"text": "function calling 怎么实现", "label": "general"}
+{"text": "什么是 agent", "label": "general"}
+{"text": "LLM agent 的规划能力是什么", "label": "general"}
+{"text": "什么是 reflection", "label": "general"}
+{"text": "什么是 self-consistency", "label": "general"}
+{"text": "majority voting 在推理里有什么用", "label": "general"}
+{"text": "什么是 process reward model", "label": "general"}
+{"text": "ORM 和 PRM 有什么区别", "label": "general"}
+{"text": "什么是 MCTS 在 LLM 里的应用", "label": "general"}
+{"text": "AlphaCode 是什么", "label": "general"}
+{"text": "code LLM 和通用 LLM 有什么区别", "label": "general"}
+{"text": "什么是 pass@k 指标", "label": "general"}
+{"text": "HumanEval 是什么基准", "label": "general"}
+{"text": "MMLU 评测是什么", "label": "general"}
+{"text": "什么是 BIG-bench", "label": "general"}
+{"text": "what is hellaswag benchmark", "label": "general"}
+{"text": "什么是 ARC challenge", "label": "general"}
+{"text": "TruthfulQA 是什么", "label": "general"}
+{"text": "什么是 alignment tax", "label": "general"}
+{"text": "capability vs safety 的 tradeoff", "label": "general"}
+{"text": "什么是 emergent abilities", "label": "general"}
+{"text": "scaling law 是什么", "label": "general"}
+{"text": "Chinchilla scaling 是什么", "label": "general"}
+{"text": "什么是 compute-optimal training", "label": "general"}
+{"text": "什么是 inference compute scaling", "label": "general"}
+{"text": "test time compute 是什么", "label": "general"}
+{"text": "o1 模型的推理方式是什么", "label": "general"}
+{"text": "什么是 slow thinking", "label": "general"}
+{"text": "什么是 reasoning LLM", "label": "general"}
+{"text": "什么是 long-form reasoning", "label": "general"}
+{"text": "什么是 search augmented generation", "label": "general"}
+{"text": "什么是 multi-hop reasoning", "label": "general"}
+{"text": "什么是 compositional generalization", "label": "general"}
+{"text": "什么是 systematic generalization", "label": "general"}
+{"text": "什么是 OOD generalization", "label": "general"}
+{"text": "什么是 distribution shift", "label": "general"}
+{"text": "covariate shift 和 label shift 有什么区别", "label": "general"}
+{"text": "什么是 domain adaptation", "label": "general"}
+{"text": "UDA unsupervised domain adaptation 是什么", "label": "general"}
+{"text": "什么是 adversarial bid_reviewples", "label": "general"}
+{"text": "FGSM 攻击是什么", "label": "general"}
+{"text": "PGD 攻击是什么", "label": "general"}
+{"text": "什么是 adversarial training", "label": "general"}
+{"text": "什么是 certified robustness", "label": "general"}
+{"text": "什么是 randomized smoothing", "label": "general"}
+{"text": "什么是 interpretability", "label": "general"}
+{"text": "saliency map 是什么", "label": "general"}
+{"text": "integrated gradients 是什么", "label": "general"}
+{"text": "SHAP 是什么", "label": "general"}
+{"text": "LIME 是什么", "label": "general"}
+{"text": "什么是 concept activation vector", "label": "general"}
+{"text": "probing classifier 是什么", "label": "general"}
+{"text": "mechanistic interpretability 是什么", "label": "general"}
+{"text": "什么是 circuit 在神经网络里", "label": "general"}
+{"text": "什么是 superposition hypothesis", "label": "general"}
+{"text": "什么是 polysemanticity", "label": "general"}
+{"text": "sparse autoencoder 在可解释性里有什么用", "label": "general"}
+{"text": "什么是 feature steering", "label": "general"}
+{"text": "什么是 activation patching", "label": "general"}
+{"text": "什么是 causal mediation analysis", "label": "general"}
+{"text": "Java 里什么是虚方法表", "label": "general"}
+{"text": "动态分派是什么", "label": "general"}
+{"text": "Java 里的字节码是什么", "label": "general"}
+{"text": "javap 怎么用", "label": "general"}
+{"text": "字节码增强有哪些框架", "label": "general"}
+{"text": "ASM 和 javassist 有什么区别", "label": "general"}
+{"text": "bytebuddy 是什么", "label": "general"}
+{"text": "Java agent 是什么", "label": "general"}
+{"text": "instrumentation API 怎么用", "label": "general"}
+{"text": "什么是 premain 方法", "label": "general"}
+{"text": "Java 里的软件度量有哪些", "label": "general"}
+{"text": "圈复杂度是什么", "label": "general"}
+{"text": "什么是技术债务", "label": "general"}
+{"text": "重构的时机怎么判断", "label": "general"}
+{"text": "Martin Fowler 的重构手法有哪些", "label": "general"}
+{"text": "代码坏味道有哪些", "label": "general"}
+{"text": "什么是 SOLID 原则", "label": "general"}
+{"text": "依赖倒置原则是什么", "label": "general"}
+{"text": "接口隔离原则是什么", "label": "general"}
+{"text": "单一职责原则是什么", "label": "general"}
+{"text": "开闭原则怎么实践", "label": "general"}
+{"text": "什么是领域模型", "label": "general"}
+{"text": "贫血模型和充血模型有什么区别", "label": "general"}
+{"text": "什么是六边形架构", "label": "general"}
+{"text": "洋葱架构是什么", "label": "general"}
+{"text": "clean architecture 的层次是什么", "label": "general"}
+{"text": "什么是 ports and adapters", "label": "general"}
+{"text": "什么是 event sourcing", "label": "general"}
+{"text": "什么是 aggregate root", "label": "general"}
+{"text": "value object 和 entity 有什么区别", "label": "general"}
+{"text": "什么是 bounded context", "label": "general"}
+{"text": "context map 是什么", "label": "general"}
+{"text": "什么是 anti-corruption layer", "label": "general"}
+{"text": "什么是 saga 模式", "label": "general"}
+{"text": "什么是 outbox pattern", "label": "general"}
+{"text": "transactional outbox 怎么实现", "label": "general"}
+{"text": "什么是 inbox pattern", "label": "general"}
+{"text": "什么是 process manager", "label": "general"}
+{"text": "什么是 choreography vs orchestration", "label": "general"}
+{"text": "什么是 service mesh", "label": "general"}
+{"text": "istio 是什么", "label": "general"}
+{"text": "envoy proxy 是什么", "label": "general"}
+{"text": "sidecar 模式是什么", "label": "general"}
+{"text": "什么是 circuit breaker pattern", "label": "general"}
+{"text": "bulkhead pattern 是什么", "label": "general"}
+{"text": "retry 和 timeout 怎么配合使用", "label": "general"}
+{"text": "什么是 backpressure", "label": "general"}
+{"text": "reactive streams 规范是什么", "label": "general"}
+{"text": "什么是 eventual consistency", "label": "general"}
+{"text": "CRDT 是什么", "label": "general"}
+{"text": "向量时钟是什么", "label": "general"}
+{"text": "lamport timestamp 是什么", "label": "general"}
+{"text": "什么是 raft 协议", "label": "general"}
+{"text": "paxos 和 raft 有什么区别", "label": "general"}
+{"text": "什么是 quorum", "label": "general"}
+{"text": "什么是 gossip protocol", "label": "general"}
+{"text": "一致性哈希在分布式系统里怎么用", "label": "general"}
+{"text": "虚拟节点是什么", "label": "general"}
+{"text": "什么是数据分片", "label": "general"}
+{"text": "hash 分片和 range 分片有什么区别", "label": "general"}
+{"text": "什么是 rebalancing", "label": "general"}
+{"text": "什么是 hot spot", "label": "general"}
+{"text": "什么是 write amplification", "label": "general"}
+{"text": "什么是 read amplification", "label": "general"}
+{"text": "LSM tree 是什么", "label": "general"}
+{"text": "SSTable 是什么", "label": "general"}
+{"text": "compaction 是什么", "label": "general"}
+{"text": "leveldb 的原理是什么", "label": "general"}
+{"text": "rocksdb 和 leveldb 有什么区别", "label": "general"}
+{"text": "什么是 WAL write ahead log", "label": "general"}
+{"text": "什么是 MVCC 多版本并发控制", "label": "general"}
+{"text": "快照隔离是什么", "label": "general"}
+{"text": "serializable snapshot isolation 是什么", "label": "general"}
+{"text": "什么是 Spanner 数据库", "label": "general"}
+{"text": "TiDB 和 MySQL 有什么区别", "label": "general"}
+{"text": "什么是 NewSQL", "label": "general"}
+{"text": "什么是 HTAP", "label": "general"}
+{"text": "什么是列存储", "label": "general"}
+{"text": "parquet 文件格式是什么", "label": "general"}
+{"text": "arrow 内存格式是什么", "label": "general"}
+{"text": "什么是向量化执行引擎", "label": "general"}
+{"text": "SIMD 在数据库里有什么用", "label": "general"}
+{"text": "什么是 sparse attention", "label": "general"}
+{"text": "flash attention 2 和 flash attention 有什么区别", "label": "general"}
+{"text": "什么是 ring attention", "label": "general"}
+{"text": "sequence parallelism 是什么", "label": "general"}
+{"text": "什么是 context parallelism", "label": "general"}
+{"text": "什么是 expert parallelism", "label": "general"}
+{"text": "moe 的负载均衡怎么做", "label": "general"}
+{"text": "什么是 auxiliary loss in MoE", "label": "general"}
+{"text": "什么是 token dropping", "label": "general"}
+{"text": "expert routing 怎么工作", "label": "general"}
+{"text": "什么是 soft moe", "label": "general"}
+{"text": "什么是 shared expert", "label": "general"}
+{"text": "mixtral 和 GPT 架构有什么区别", "label": "general"}
+{"text": "什么是 SSM state space model", "label": "general"}
+{"text": "Mamba 和 Transformer 有什么区别", "label": "general"}
+{"text": "selective SSM 是什么", "label": "general"}
+{"text": "什么是 linear attention", "label": "general"}
+{"text": "RetNet 是什么", "label": "general"}
+{"text": "RWKV 是什么", "label": "general"}
+{"text": "Hyena 是什么", "label": "general"}
+{"text": "什么是 in-context learning", "label": "general"}
+{"text": "ICL 的机制是什么", "label": "general"}
+{"text": "few-shot prompting 和 ICL 是一回事吗", "label": "general"}
+{"text": "什么是 meta-learning", "label": "general"}
+{"text": "MAML 算法是什么", "label": "general"}
+{"text": "什么是 prototypical network", "label": "general"}
+{"text": "我们招标资料里 Transformer 项目怎么部署的", "label": "specialized"}
+{"text": "招标资料项目的 Dataset 类为什么报错", "label": "specialized"}
+{"text": "我们 AI 实战项目里的模型保存路径在哪里", "label": "specialized"}
+{"text": "评审专家说的那个注意力可视化在哪个文件里", "label": "specialized"}
+{"text": "第四章的 LSTM 项目我跑不起来", "label": "specialized"}
+{"text": "招标资料里的文本分类项目准确率只有 60% 怎么提升", "label": "specialized"}
+{"text": "我们实战项目的训练集是怎么划分的", "label": "specialized"}
+{"text": "第二个实战项目的数据预处理部分看不懂", "label": "specialized"}
+{"text": "上次评审专家演示的那个模型怎么保存", "label": "specialized"}
+{"text": "招标资料案例里的 embedding 层为什么维度是 128", "label": "specialized"}
+{"text": "我们招标资料的情感分析项目用的是什么数据集", "label": "specialized"}
+{"text": "实战项目里的 collate_fn 函数怎么改", "label": "specialized"}
+{"text": "招标资料里 BERT 微调那个例子代码报错了", "label": "specialized"}
+{"text": "评审专家讲的那个 GAN 生成图片的项目在哪里下载", "label": "specialized"}
+{"text": "我们 NLP 招标资料的第三章项目代码在哪", "label": "specialized"}
+{"text": "招标资料里的图像分类项目怎么换成自己的数据集", "label": "specialized"}
+{"text": "实战项目的 learning rate scheduler 是怎么设置的", "label": "specialized"}
+{"text": "评审专家课上用的那个 tokenizer 是什么版本", "label": "specialized"}
+{"text": "招标资料里做过的目标检测项目 anchor 怎么调", "label": "specialized"}
+{"text": "我们学的那个推荐系统项目数据怎么预处理", "label": "specialized"}
+{"text": "招标资料作业里的 CNN 模型跑完 loss 不下降怎么回事", "label": "specialized"}
+{"text": "第五章的 ResNet 迁移学习项目训练太慢了", "label": "specialized"}
+{"text": "招标资料材料里的 requirements.txt 装不上去", "label": "specialized"}
+{"text": "我们实战里那个多分类项目 softmax 输出维度搞错了", "label": "specialized"}
+{"text": "评审专家讲的强化学习项目 reward 怎么定义的", "label": "specialized"}
+{"text": "招标资料里 seq2seq 翻译项目 BLEU 分数怎么计算", "label": "specialized"}
+{"text": "我们 AI 训练营里的大模型微调项目用的是哪个基座", "label": "specialized"}
+{"text": "招标资料 LoRA 微调那个实战 rank 设置多少合适", "label": "specialized"}
+{"text": "实战项目里 GPU 显存不够怎么解决,评审专家有说吗", "label": "specialized"}
+{"text": "我们招标资料的知识蒸馏项目 expert model 是什么", "label": "specialized"}
+{"text": "招标资料里的语音识别项目代码在哪个目录", "label": "specialized"}
+{"text": "评审专家演示的那个对比学习实战用的什么框架", "label": "specialized"}
+{"text": "我们项目里 dataloader 的 batch_size 设成多少了", "label": "specialized"}
+{"text": "招标资料案例里多头注意力的头数为什么选 8", "label": "specialized"}
+{"text": "作业里要求实现的 self-attention 模板代码在哪", "label": "specialized"}
+{"text": "我们实战项目部署到服务器报环境错误怎么解决", "label": "specialized"}
+{"text": "招标资料里的 OCR 项目用的是什么模型", "label": "specialized"}
+{"text": "评审专家说第六章会有大项目是什么项目", "label": "specialized"}
+{"text": "我们招标资料里的时序预测项目用的是什么数据", "label": "specialized"}
+{"text": "招标资料作业第二题的 LSTM 层数为什么是两层", "label": "specialized"}
+{"text": "我们 Java 实战项目的微服务怎么拆分的", "label": "specialized"}
+{"text": "招标资料里的商城项目订单模块代码在哪里", "label": "specialized"}
+{"text": "我们的 springboot 实战项目启动报错找不到 bean", "label": "specialized"}
+{"text": "评审专家讲的那个 redis 缓存方案在项目里怎么用的", "label": "specialized"}
+{"text": "招标资料里的秒杀系统项目怎么防止超卖", "label": "specialized"}
+{"text": "我们 Java 训练营的最后一个大项目是什么", "label": "specialized"}
+{"text": "实战项目里的 mybatis xml 配置文件怎么找到", "label": "specialized"}
+{"text": "招标资料里的 JWT 鉴权模块我看不懂", "label": "specialized"}
+{"text": "我们项目里 RocketMQ 消息队列是怎么集成的", "label": "specialized"}
+{"text": "评审专家讲的分布式锁那个招标资料代码在哪", "label": "specialized"}
+{"text": "招标资料里的用户中心模块怎么做单点登录", "label": "specialized"}
+{"text": "我们实战项目数据库表结构设计在哪里看", "label": "specialized"}
+{"text": "招标资料里的 Elasticsearch 搜索功能怎么做的", "label": "specialized"}
+{"text": "实战项目里 Docker 怎么部署的,有 compose 文件吗", "label": "specialized"}
+{"text": "评审专家课上演示的 k8s 部署脚本在哪", "label": "specialized"}
+{"text": "我们 Java 实战里的网关鉴权逻辑看不懂", "label": "specialized"}
+{"text": "招标资料里的 sentinel 熔断那个配置怎么写的", "label": "specialized"}
+{"text": "我们项目里的分库分表是怎么设计的", "label": "specialized"}
+{"text": "招标资料材料里的 ER 图在哪里", "label": "specialized"}
+{"text": "实战项目里为什么要用 feign 而不是 resttemplate", "label": "specialized"}
+{"text": "评审专家说的那个 spring cloud gateway 过滤器怎么写的", "label": "specialized"}
+{"text": "我们招标资料里的内容审核功能是怎么实现的", "label": "specialized"}
+{"text": "招标资料里的支付模块用的是哪个第三方支付", "label": "specialized"}
+{"text": "实战项目里的消息推送功能怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的并发抢购场景用的是什么方案", "label": "specialized"}
+{"text": "招标资料作业要求实现的 ThreadPool 参数怎么设置的", "label": "specialized"}
+{"text": "评审专家说的 AQS 那个手写 lock 例子在哪个条款", "label": "specialized"}
+{"text": "我们 Java 项目里为什么要用 Nacos 不用 Eureka", "label": "specialized"}
+{"text": "招标资料里的 ELK 日志收集是怎么接入项目的", "label": "specialized"}
+{"text": "实战项目里的接口幂等性是怎么实现的", "label": "specialized"}
+{"text": "我们招标资料里的微信登录功能代码看不懂", "label": "specialized"}
+{"text": "招标资料案例里 Caffeine 本地缓存是怎么配置的", "label": "specialized"}
+{"text": "评审专家演示的 Seata 分布式事务那个例子怎么跑", "label": "specialized"}
+{"text": "我们实战项目里的 MQ 消息丢失问题怎么解决的", "label": "specialized"}
+{"text": "招标资料里商城项目的库存扣减逻辑在哪个 service 里", "label": "specialized"}
+{"text": "实战里 Zookeeper 协调服务怎么用的,有招标资料材料吗", "label": "specialized"}
+{"text": "我们招标资料的接口文档用的是 Swagger 还是 Knife4j", "label": "specialized"}
+{"text": "评审专家讲的那个慢 SQL 优化案例代码在哪", "label": "specialized"}
+{"text": "招标资料里的权限系统 RBAC 怎么设计的", "label": "specialized"}
+{"text": "我们 Java 训练营的代码都在哪个 git 仓库", "label": "specialized"}
+{"text": "我们 Python 爬虫招标资料的项目代码在哪里", "label": "specialized"}
+{"text": "招标资料里的数据分析实战用的是什么数据集", "label": "specialized"}
+{"text": "评审专家讲的 pandas 数据清洗那个案例文件在哪", "label": "specialized"}
+{"text": "我们的 Spark 实战项目跑不起来报 Java 版本错误", "label": "specialized"}
+{"text": "招标资料里 Hive 那个实战 SQL 在哪个文件里", "label": "specialized"}
+{"text": "实战项目里 Kafka 消费者代码看不懂", "label": "specialized"}
+{"text": "我们大数据招标资料的最终项目是用户画像还是推荐系统", "label": "specialized"}
+{"text": "招标资料里 Flink 实时计算项目环境怎么搭", "label": "specialized"}
+{"text": "评审专家演示的数仓 ETL 流程用的什么工具", "label": "specialized"}
+{"text": "我们招标资料里 scrapy 爬虫框架的项目怎么运行", "label": "specialized"}
+{"text": "招标资料案例里的可视化用的是 echarts 还是 matplotlib", "label": "specialized"}
+{"text": "实战项目里 redis 作为 session 存储怎么配的", "label": "specialized"}
+{"text": "我们招标资料里的 A/B 测试项目是怎么做的", "label": "specialized"}
+{"text": "评审专家说的那个用户行为分析招标资料项目在哪里下载", "label": "specialized"}
+{"text": "招标资料里 airflow 工作流调度是怎么接入项目的", "label": "specialized"}
+{"text": "我们 Python 训练营的机器学习实战用的是哪个算法", "label": "specialized"}
+{"text": "招标资料里的文本挖掘项目用的是什么分词库", "label": "specialized"}
+{"text": "实战项目里 Flask 接口为什么返回 500", "label": "specialized"}
+{"text": "我们的爬虫项目被反爬了,招标资料里有说解决方法吗", "label": "specialized"}
+{"text": "招标资料里数据可视化大屏项目怎么部署", "label": "specialized"}
+{"text": "评审专家讲的数仓分层那个项目 DWD 层在哪里", "label": "specialized"}
+{"text": "我们招标资料的用户标签体系是怎么设计的", "label": "specialized"}
+{"text": "招标资料里 MySQL 转 HBase 那个迁移脚本在哪", "label": "specialized"}
+{"text": "实战项目里 Spark SQL 跑出来的结果和预期不对", "label": "specialized"}
+{"text": "我们招标资料里的 GNN 推荐系统项目用了什么图数据库", "label": "specialized"}
+{"text": "上次评审专家演示的那个分类模型准确率是多少", "label": "specialized"}
+{"text": "我们的项目里为什么用 128 维的 embedding", "label": "specialized"}
+{"text": "上课那个 spring boot 项目启动一直报错", "label": "specialized"}
+{"text": "我们的 xiangmu 跑不起来了", "label": "specialized"}
+{"text": "第一个项目的环境配置视频在哪里", "label": "specialized"}
+{"text": "训练营里那个算法题答案在哪里找", "label": "specialized"}
+{"text": "评审专家昨天演示的 sql 优化案例能发一下吗", "label": "specialized"}
+{"text": "我们的代码仓库地址是什么", "label": "specialized"}
+{"text": "招标资料里那个 vue 前端项目怎么跑起来", "label": "specialized"}
+{"text": "我们学的那个 ci/cd 流程在项目里怎么配置的", "label": "specialized"}
+{"text": "我们的 xiangmu 数据集在哪里下载", "label": "specialized"}
+{"text": "招标资料里的 shendu 学习项目怎么跑", "label": "specialized"}
+{"text": "实战里那个 weifuwu 拆分方案在哪", "label": "specialized"}
+{"text": "评审专家说的 huancun 方案在招标资料哪里", "label": "specialized"}
+{"text": "招标资料作业关于线城安全那题怎么做", "label": "specialized"}
+{"text": "我们的 peixun 项目代码提交到哪里", "label": "specialized"}
+{"text": "招标资料里的 zifudong 词向量项目跑不起来", "label": "specialized"}
+{"text": "实战项目 shujuku 设计文档在哪看", "label": "specialized"}
+{"text": "招标资料里的 fenbushi 事务那个案例怎么跑", "label": "specialized"}
+{"text": "我们的 renwu 调度项目部署到 k8s 怎么操作", "label": "specialized"}
+{"text": "招标资料项目代码", "label": "specialized"}
+{"text": "实战作业报错", "label": "specialized"}
+{"text": "第三章项目", "label": "specialized"}
+{"text": "招标资料数据集下载", "label": "specialized"}
+{"text": "评审专家演示代码", "label": "specialized"}
+{"text": "训练营项目部署", "label": "specialized"}
+{"text": "招标资料材料在哪", "label": "specialized"}
+{"text": "实战项目启动失败", "label": "specialized"}
+{"text": "招标资料案例 GPU", "label": "specialized"}
+{"text": "作业提交方式", "label": "specialized"}
+{"text": "招标资料项目 git 仓库", "label": "specialized"}
+{"text": "评审专家讲的案例代码", "label": "specialized"}
+{"text": "实战项目环境搭建", "label": "specialized"}
+{"text": "招标资料作业截止时间", "label": "specialized"}
+{"text": "第五章招标资料项目", "label": "specialized"}
+{"text": "训练营最终作业", "label": "specialized"}
+{"text": "招标资料里的数据集", "label": "specialized"}
+{"text": "实战项目跑不起来", "label": "specialized"}
+{"text": "招标资料 ppt 在哪下", "label": "specialized"}
+{"text": "项目代码找不到", "label": "specialized"}
+{"text": "我们招标资料那个 BERT 项目咋弄啊", "label": "specialized"}
+{"text": "评审专家说的 redis 方案在项目里咋用啊", "label": "specialized"}
+{"text": "实战项目里那个微服务咋拆分的", "label": "specialized"}
+{"text": "招标资料里的爬虫项目咋被封 ip 了", "label": "specialized"}
+{"text": "我们的 spark 项目咋跑不起来了", "label": "specialized"}
+{"text": "训练营的最后大项目是啥", "label": "specialized"}
+{"text": "招标资料作业啥时候截止啊", "label": "specialized"}
+{"text": "评审专家演示的那个 gan 生成图片咋下载", "label": "specialized"}
+{"text": "实战里的 k8s 部署脚本在哪啊", "label": "specialized"}
+{"text": "我们招标资料的数据集咋下载", "label": "specialized"}
+{"text": "招标资料项目 loss 一直不降咋回事", "label": "specialized"}
+{"text": "评审专家讲的那个 jwt 认证在哪个模块啊", "label": "specialized"}
+{"text": "实战那个 mq 消费者代码看不懂啊", "label": "specialized"}
+{"text": "我们的训练营代码在哪个仓库啊", "label": "specialized"}
+{"text": "招标资料里的用户画像项目咋部署啊", "label": "specialized"}
+{"text": "我们招标资料里的 Transformer 部署项目我看不懂,能解释一下吗", "label": "specialized"}
+{"text": "招标资料那个 LSTM 股价预测项目一直报维度不匹配,不知道怎么改", "label": "specialized"}
+{"text": "评审专家说的 redis 缓存方案我在项目里找不到对应的代码", "label": "specialized"}
+{"text": "我们 Java 实战里的 Seata 分布式事务配置太复杂了,有没有文档", "label": "specialized"}
+{"text": "招标资料里的 ResNet 迁移学习项目我改了数据集但准确率很低,有没有建议", "label": "specialized"}
+{"text": "我们的 Spark 实战项目环境一直搭不起来,评审专家有说过什么解决方案吗", "label": "specialized"}
+{"text": "招标资料里的 Elasticsearch 搜索功能那块我没太看懂,能帮我看看吗", "label": "specialized"}
+{"text": "实战项目里的 Docker 部署脚本我不太会改,有没有说明文档", "label": "specialized"}
+{"text": "我们招标资料的数据预处理那块代码看不懂,能说说思路吗", "label": "specialized"}
+{"text": "招标资料里的 GAN 生成图片项目我跑完没有输出,应该怎么排查", "label": "specialized"}
+{"text": "评审专家演示的接口鉴权模块我找不到对应文件,能告诉我在哪里吗", "label": "specialized"}
+{"text": "我们实战项目里的 Flink 实时处理我没接触过,招标资料有没有入门说明", "label": "specialized"}
+{"text": "招标资料里的推荐系统项目召回率很低,不知道从哪里优化", "label": "specialized"}
+{"text": "我们训练营的最终项目评分标准在哪里,我找不到", "label": "specialized"}
+{"text": "实战作业要求实现的 ThreadPool 我不确定参数设置是否正确", "label": "specialized"}
+{"text": "我们招标资料里第三章的 Transformer 机器翻译项目跑起来之后 loss 一直不下降,已经调整了 learning rate 但还是有问题,请问评审专家有没有说过这种情况怎么处理", "label": "specialized"}
+{"text": "招标资料案例里的情感分析项目使用了 BERT 微调,但是我发现训练集的 label 分布很不均衡,招标资料里有没有讲过如何处理类别不平衡的情况", "label": "specialized"}
+{"text": "我们 Java 实战项目里的商城秒杀模块,评审专家讲过使用 Redis 分布式锁来防止超卖,但是我在代码里找不到对应的实现,请问是在哪个 service 类里面", "label": "specialized"}
+{"text": "实战项目部署到 Docker 容器里之后一直报找不到配置文件的错误,我们招标资料里有没有讲过在容器里如何挂载配置文件的方案", "label": "specialized"}
+{"text": "我们训练营的大数据实战项目里用了 Spark 做用户行为分析,但是我发现 join 操作特别慢,招标资料里评审专家有没有讲过 Spark SQL 的性能调优方法", "label": "specialized"}
+{"text": "招标资料里的目标检测实战项目我换了自己的数据集之后 mAP 特别低,原来招标资料数据集能达到 0.8 以上,不知道是数据标注问题还是模型配置问题,招标资料有没有说过怎么排查", "label": "specialized"}
+{"text": "我们 Python 爬虫实战招标资料里的招标资料项目用了 scrapy 框架,但是现在目标网站加了反爬措施,招标资料里有没有讲过针对这种情况的处理方案", "label": "specialized"}
+{"text": "实战项目里的微服务间的调用我们用的是 feign,但是现在偶尔会出现调用超时的情况,评审专家在招标资料里有没有说过 feign 超时参数怎么配置以及重试策略怎么设计", "label": "specialized"}
+{"text": "招标资料案例里的 LSTM 时序预测项目我按照代码跑了但是预测结果和真实值偏差很大,我检查了数据格式没有问题,请问招标资料里评审专家有没有讲过这种情况应该从哪几个方向排查", "label": "specialized"}
+{"text": "我们 Java 实战招标资料里的用户权限系统评审专家讲的是基于 RBAC 模型设计的,我想看一下完整的数据库表设计,请问招标资料材料里有没有 ER 图或者设计文档", "label": "specialized"}
+{"text": "我们招标资料 PyTorch 实战项目里自定义 Dataset 怎么改", "label": "specialized"}
+{"text": "招标资料里的 word2vec 训练脚本跑不起来", "label": "specialized"}
+{"text": "评审专家讲的那个 attention 可视化代码在第几章", "label": "specialized"}
+{"text": "实战里的 batch_size 招标资料里推荐设置多少", "label": "specialized"}
+{"text": "我们的 NLP 招标资料第二章作业怎么提交", "label": "specialized"}
+{"text": "招标资料里那个 Bi-LSTM 项目输出维度对不上", "label": "specialized"}
+{"text": "训练营 PyTorch 项目的 GPU 环境怎么配置的", "label": "specialized"}
+{"text": "招标资料作业里要求的 transformer 是从零实现还是用库", "label": "specialized"}
+{"text": "我们实战项目的 val loss 一直比 train loss 高很多正常吗", "label": "specialized"}
+{"text": "招标资料里大模型微调实战用的是什么数据集", "label": "specialized"}
+{"text": "评审专家演示 BERT 那个 checkpoint 文件在哪下载", "label": "specialized"}
+{"text": "实战招标资料里的模型评估指标用的是 F1 还是 accuracy", "label": "specialized"}
+{"text": "我们招标资料里的数据增强方案是在 Dataset 里做的还是在 transform 里", "label": "specialized"}
+{"text": "招标资料项目里预训练权重文件太大了怎么下载", "label": "specialized"}
+{"text": "评审专家说的那个混淆矩阵可视化代码在招标资料哪里", "label": "specialized"}
+{"text": "我们第四个实战项目的环境依赖文件在哪里", "label": "specialized"}
+{"text": "招标资料里的图像分割项目用的是什么损失函数", "label": "specialized"}
+{"text": "实战项目里模型推理速度太慢评审专家有没有说优化方案", "label": "specialized"}
+{"text": "我们招标资料的最终项目展示要做成什么形式", "label": "specialized"}
+{"text": "招标资料里的多标签分类项目 threshold 怎么设置的", "label": "specialized"}
+{"text": "我们 Java 招标资料的第一个实战项目是什么", "label": "specialized"}
+{"text": "招标资料里 springboot 项目的 application.yml 怎么配置", "label": "specialized"}
+{"text": "评审专家讲的那个 redis 缓存穿透解决方案在招标资料项目里怎么用", "label": "specialized"}
+{"text": "我们实战的 mysql 表设计评审专家有没有说过规范", "label": "specialized"}
+{"text": "招标资料里的 feign 调用超时怎么配置的", "label": "specialized"}
+{"text": "实战项目里 nacos 配置中心怎么接入的", "label": "specialized"}
+{"text": "我们招标资料的 API 网关是用 gateway 还是 zuul", "label": "specialized"}
+{"text": "招标资料里的 logback 日志配置文件在哪里", "label": "specialized"}
+{"text": "评审专家说的那个接口限流方案在项目里哪个模块", "label": "specialized"}
+{"text": "我们实战里的数据库连接池用的是什么", "label": "specialized"}
+{"text": "招标资料里 docker 镜像打包脚本在哪里", "label": "specialized"}
+{"text": "实战项目的代码提交规范是什么", "label": "specialized"}
+{"text": "我们招标资料里的定时任务用的是 quartz 还是 xxl-job", "label": "specialized"}
+{"text": "评审专家讲的 jvm 调优那个实战案例在第几章", "label": "specialized"}
+{"text": "招标资料里的并发编程作业要求实现什么", "label": "specialized"}
+{"text": "我们大数据招标资料里的 hive 建表语句在哪", "label": "specialized"}
+{"text": "招标资料里 kafka topic 的 partition 数量怎么定的", "label": "specialized"}
+{"text": "评审专家演示的 spark 调优案例代码在哪个目录", "label": "specialized"}
+{"text": "我们实战里的 hdfs 存储路径是怎么规划的", "label": "specialized"}
+{"text": "招标资料里的 flume 采集配置文件在哪里", "label": "specialized"}
+{"text": "实战项目里 hbase rowkey 设计评审专家是怎么讲的", "label": "specialized"}
+{"text": "我们招标资料的数仓项目用的是 hive 还是 doris", "label": "specialized"}
+{"text": "招标资料里的 datax 数据同步脚本在哪里", "label": "specialized"}
+{"text": "评审专家说的 yarn 资源配置在招标资料哪里", "label": "specialized"}
+{"text": "我们的 Flink 招标资料实战项目用的是哪个版本", "label": "specialized"}
+{"text": "招标资料里的 git 工作流是怎么规范的", "label": "specialized"}
+{"text": "实战项目里的 ci/cd 是怎么配置的", "label": "specialized"}
+{"text": "我们招标资料的设计模式作业要求实现几个", "label": "specialized"}
+{"text": "评审专家讲的那个 linux 运维脚本在哪里", "label": "specialized"}
+{"text": "招标资料里的 RESTful 接口规范是什么", "label": "specialized"}
+{"text": "实战项目里的单元测试用的是什么框架", "label": "specialized"}
+{"text": "我们招标资料里 swagger 文档怎么生成", "label": "specialized"}
+{"text": "评审专家说的那个代码 review 标准在招标资料材料里吗", "label": "specialized"}
+{"text": "招标资料里的 mock 测试怎么做", "label": "specialized"}
+{"text": "我们实战项目的监控方案是什么", "label": "specialized"}
+{"text": "招标资料里的性能测试用的是什么工具", "label": "specialized"}
+{"text": "评审专家讲的压测报告在招标资料哪里", "label": "specialized"}
+{"text": "AI项目的招标资料大纲包括哪些主要模块", "label": "specialized"}
+{"text": "AI大模型学科主要学习哪些技术栈", "label": "specialized"}
+{"text": "Java学科的实战项目有哪些", "label": "specialized"}
+{"text": "大数据学科的核心招标资料内容是什么", "label": "specialized"}
+{"text": "投满分项目的主要功能模块有哪些", "label": "specialized"}
+{"text": "医疗知识图谱项目用到了哪些关键技术", "label": "specialized"}
+{"text": "AI项目的项目实战环节是怎么安排的", "label": "specialized"}
+{"text": "Java学科的招标资料设计包含哪些阶段", "label": "specialized"}
+{"text": "大数据学科的实训项目有哪几个", "label": "specialized"}
+{"text": "架构设计招标资料主要讲哪些内容", "label": "specialized"}
+{"text": "AI项目的作业形式有哪些", "label": "specialized"}
+{"text": "Java招标资料的考核方式是什么", "label": "specialized"}
+{"text": "大数据招标资料的结业项目要求是什么", "label": "specialized"}
+{"text": "投满分项目的前后端分别用了什么框架", "label": "specialized"}
+{"text": "医疗知识图谱的数据来源有哪些", "label": "specialized"}
+{"text": "AI大模型招标资料的实践案例有哪些", "label": "specialized"}
+{"text": "Java学科的招标资料资源在哪里获取", "label": "specialized"}
+{"text": "大数据招标资料的答疑时间安排是怎样的", "label": "specialized"}
+{"text": "架构设计招标资料有配套的源码吗", "label": "specialized"}
+{"text": "AI项目的课件和录播视频在哪里下载", "label": "specialized"}
+{"text": "Java实战项目的小组分工建议是什么", "label": "specialized"}
+{"text": "大数据项目的数据集从哪里获取", "label": "specialized"}
+{"text": "医疗知识图谱的评测指标有哪些", "label": "specialized"}
+{"text": "投满分项目的部署文档在哪里", "label": "specialized"}
+{"text": "AI大模型招标资料的实验环境怎么配置", "label": "specialized"}
+{"text": "Java招标资料的代码规范文档有没有", "label": "specialized"}
+{"text": "大数据招标资料的作业提交截止时间是什么时候", "label": "specialized"}
+{"text": "架构招标资料的复习资料什么时候发布", "label": "specialized"}
+{"text": "AI项目的招标范围包含哪些条款", "label": "specialized"}
+{"text": "Java实战项目的评分标准是什么", "label": "specialized"}
+{"text": "大数据实训的答辩流程是怎样的", "label": "specialized"}
+{"text": "医疗知识图谱项目的难点在哪里", "label": "specialized"}
+{"text": "投满分项目的技术选型理由是什么", "label": "specialized"}
+{"text": "AI大模型招标资料的预习材料有哪些", "label": "specialized"}
+{"text": "Java招标资料的答疑群里常见问题有哪些", "label": "specialized"}
+{"text": "大数据招标资料的实验指导书在哪里", "label": "specialized"}
+{"text": "架构招标资料的招标资料设计评分项有哪些", "label": "specialized"}
+{"text": "AI项目的拓展阅读材料推荐哪些", "label": "specialized"}
+{"text": "Java项目的开发文档模板在哪里", "label": "specialized"}
+{"text": "大数据项目的成果展示形式是什么", "label": "specialized"}
+{"text": "我们招标资料里的 speculative decoding 实战怎么跑", "label": "specialized"}
+{"text": "招标资料里的 vLLM 部署案例在哪个条款", "label": "specialized"}
+{"text": "评审专家演示的 continuous batching 实现在哪里", "label": "specialized"}
+{"text": "我们实战里的 RAG pipeline 代码在哪", "label": "specialized"}
+{"text": "招标资料里的向量数据库用的是 qdrant 还是 milvus", "label": "specialized"}
+{"text": "实战项目里的 embedding 模型用的是哪个", "label": "specialized"}
+{"text": "我们招标资料里的 langchain 实战在哪里", "label": "specialized"}
+{"text": "招标资料里的 agent 实战项目是做什么的", "label": "specialized"}
+{"text": "评审专家演示的 tool calling 案例代码在哪", "label": "specialized"}
+{"text": "我们实战里的 workflow 是用 langraph 实现的吗", "label": "specialized"}
+{"text": "招标资料里的多轮对话项目怎么管理历史", "label": "specialized"}
+{"text": "实战项目里的 prompt 模板在哪个文件", "label": "specialized"}
+{"text": "我们招标资料的 function calling 实战用的是什么模型", "label": "specialized"}
+{"text": "招标资料里的知识库构建流程是什么", "label": "specialized"}
+{"text": "评审专家讲的 chunk 切分策略在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的重排序模型用的是什么", "label": "specialized"}
+{"text": "招标资料里的混合检索怎么实现的", "label": "specialized"}
+{"text": "实战项目里的问答评测脚本在哪里", "label": "specialized"}
+{"text": "我们招标资料里的幻觉检测实战怎么做", "label": "specialized"}
+{"text": "招标资料案例里的 guardrails 是怎么实现的", "label": "specialized"}
+{"text": "我们实战项目的推理服务接口文档在哪", "label": "specialized"}
+{"text": "招标资料里的大模型评测基准是用什么跑的", "label": "specialized"}
+{"text": "评审专家说的那个 MMLU 测试脚本在哪里", "label": "specialized"}
+{"text": "我们招标资料里的红队测试实战是什么", "label": "specialized"}
+{"text": "招标资料里的 alignment 微调数据从哪来的", "label": "specialized"}
+{"text": "实战里的 DPO 训练数据格式是什么", "label": "specialized"}
+{"text": "我们招标资料里的 reward model 训练在哪个条款", "label": "specialized"}
+{"text": "招标资料里的 RLHF 实战用的是什么库", "label": "specialized"}
+{"text": "评审专家演示的 PPO 训练稳定性技巧是什么", "label": "specialized"}
+{"text": "我们实战项目里的 SFT 数据怎么清洗的", "label": "specialized"}
+{"text": "招标资料里的指令跟随评测怎么做", "label": "specialized"}
+{"text": "实战项目里的多模态模型用的是什么", "label": "specialized"}
+{"text": "我们招标资料的视觉语言模型实战在哪里", "label": "specialized"}
+{"text": "招标资料里的 CLIP 微调案例怎么跑", "label": "specialized"}
+{"text": "评审专家说的图文匹配项目数据集在哪里", "label": "specialized"}
+{"text": "我们实战里的 OCR 后处理代码在哪", "label": "specialized"}
+{"text": "招标资料里的语音识别项目用的是 whisper 吗", "label": "specialized"}
+{"text": "实战项目里的 TTS 合成怎么接入的", "label": "specialized"}
+{"text": "我们招标资料里的多模态对话项目在哪个阶段", "label": "specialized"}
+{"text": "招标资料里的视频理解实战用的什么框架", "label": "specialized"}
+{"text": "实战里的关键帧提取代码在哪里", "label": "specialized"}
+{"text": "我们招标资料的三维点云实战有吗", "label": "specialized"}
+{"text": "招标资料里的 NeRF 项目在哪个条款", "label": "specialized"}
+{"text": "评审专家演示的 3DGS 实战是什么", "label": "specialized"}
+{"text": "我们实战里的机器人抓取项目用什么仿真器", "label": "specialized"}
+{"text": "招标资料里的强化学习环境是自己搭的还是用 gym", "label": "specialized"}
+{"text": "实战项目里的奖励函数怎么设计的", "label": "specialized"}
+{"text": "我们招标资料的 PPO agent 训练代码在哪", "label": "specialized"}
+{"text": "招标资料里的 DDPG 实战在哪里", "label": "specialized"}
+{"text": "评审专家讲的多智能体项目是什么场景", "label": "specialized"}
+{"text": "我们实战里的游戏 AI 项目用的什么环境", "label": "specialized"}
+{"text": "招标资料里的 AlphaZero 实战怎么运行", "label": "specialized"}
+{"text": "实战项目里的 Monte Carlo Tree Search 代码在哪", "label": "specialized"}
+{"text": "我们招标资料里的推荐系统实战用的什么算法", "label": "specialized"}
+{"text": "招标资料里的协同过滤项目数据集是什么", "label": "specialized"}
+{"text": "评审专家演示的 deepfm 实战在哪个条款", "label": "specialized"}
+{"text": "我们实战里的召回排序链路怎么设计的", "label": "specialized"}
+{"text": "招标资料里的 embedding 召回用的是什么", "label": "specialized"}
+{"text": "实战项目里的 item2vec 代码在哪里", "label": "specialized"}
+{"text": "我们招标资料里的序列推荐模型是哪个", "label": "specialized"}
+{"text": "招标资料里的在线学习实战有吗", "label": "specialized"}
+{"text": "实战里的特征工程平台用的是什么", "label": "specialized"}
+{"text": "我们招标资料里的 ab 实验平台是怎么搭的", "label": "specialized"}
+{"text": "招标资料案例里的指标体系怎么设计的", "label": "specialized"}
+{"text": "评审专家说的那个业务指标到模型指标的转化在哪里", "label": "specialized"}
+{"text": "我们实战的点击率预估项目用的是什么", "label": "specialized"}
+{"text": "招标资料里的转化率优化实战是什么", "label": "specialized"}
+{"text": "实战项目里的留存分析怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的 LTV 预测实战在哪", "label": "specialized"}
+{"text": "招标资料里的用户生命周期分析是什么项目", "label": "specialized"}
+{"text": "评审专家讲的 RFM 模型实战在哪个条款", "label": "specialized"}
+{"text": "我们实战里的流失预警模型怎么做的", "label": "specialized"}
+{"text": "招标资料里的 uplift modeling 实战有吗", "label": "specialized"}
+{"text": "实战项目里的因果推断怎么做", "label": "specialized"}
+{"text": "我们招标资料里的双重差分法实战在哪", "label": "specialized"}
+{"text": "招标资料里的倾向得分匹配项目是什么", "label": "specialized"}
+{"text": "实战里的反事实分析怎么实现", "label": "specialized"}
+{"text": "我们招标资料的广告出价实战是什么", "label": "specialized"}
+{"text": "招标资料里的预算分配优化项目在哪", "label": "specialized"}
+{"text": "评审专家演示的竞价算法是什么", "label": "specialized"}
+{"text": "我们实战里的实时竞价系统怎么设计的", "label": "specialized"}
+{"text": "招标资料里的 RTB 系统架构是什么", "label": "specialized"}
+{"text": "实战项目里的特征服务怎么建的", "label": "specialized"}
+{"text": "我们招标资料里的模型服务化方案是什么", "label": "specialized"}
+{"text": "招标资料里的 feature store 用的是什么", "label": "specialized"}
+{"text": "评审专家说的模型监控实战在哪里", "label": "specialized"}
+{"text": "我们实战里的数据漂移检测怎么做", "label": "specialized"}
+{"text": "招标资料里的概念漂移告警方案是什么", "label": "specialized"}
+{"text": "实战项目里的 shadow mode 测试怎么做", "label": "specialized"}
+{"text": "我们招标资料里的灰度发布方案是什么", "label": "specialized"}
+{"text": "招标资料里的 canary deployment 怎么实现", "label": "specialized"}
+{"text": "评审专家演示的蓝绿部署案例在哪里", "label": "specialized"}
+{"text": "我们 Java 招标资料里的秒杀项目并发多少", "label": "specialized"}
+{"text": "招标资料里的压测结果在哪里看", "label": "specialized"}
+{"text": "实战项目里的 JMeter 测试脚本在哪", "label": "specialized"}
+{"text": "我们招标资料的性能优化案例报告在哪", "label": "specialized"}
+{"text": "招标资料里的 GC 调优实战在哪个条款", "label": "specialized"}
+{"text": "评审专家说的那个 JVM 参数配置最佳实践在哪", "label": "specialized"}
+{"text": "我们实战里的 thread dump 分析怎么做", "label": "specialized"}
+{"text": "招标资料里的 heap dump 分析实战是什么", "label": "specialized"}
+{"text": "实战项目里的 arthas 诊断怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的在线诊断案例有没有", "label": "specialized"}
+{"text": "招标资料里的链路追踪告警怎么配置的", "label": "specialized"}
+{"text": "评审专家讲的 SLA 保障方案在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的容量规划怎么做的", "label": "specialized"}
+{"text": "招标资料里的混沌工程实践有吗", "label": "specialized"}
+{"text": "实战项目里的故障注入是怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的高可用方案是什么", "label": "specialized"}
+{"text": "招标资料里的同城双活架构是什么", "label": "specialized"}
+{"text": "评审专家演示的异地多活方案在哪里", "label": "specialized"}
+{"text": "我们实战里的 RPO 和 RTO 目标是什么", "label": "specialized"}
+{"text": "招标资料里的灾备演练怎么做的", "label": "specialized"}
+{"text": "实战项目里的数据备份方案是什么", "label": "specialized"}
+{"text": "我们招标资料里的安全加固实战有吗", "label": "specialized"}
+{"text": "招标资料里的 OWASP Top 10 实战案例在哪", "label": "specialized"}
+{"text": "评审专家说的 SQL 注入防御案例在哪个项目", "label": "specialized"}
+{"text": "我们实战里的 XSS 防护怎么实现的", "label": "specialized"}
+{"text": "招标资料里的 CSRF token 实战怎么做", "label": "specialized"}
+{"text": "实战项目里的敏感数据加密在哪里", "label": "specialized"}
+{"text": "我们招标资料里的权限绑定最小原则怎么实现", "label": "specialized"}
+{"text": "招标资料里的 OAuth2 授权服务实战在哪", "label": "specialized"}
+{"text": "评审专家讲的零信任架构在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的 API 安全网关怎么配置", "label": "specialized"}
+{"text": "招标资料里的速率限制实战是怎么做的", "label": "specialized"}
+{"text": "实战项目里的 bot 防护方案是什么", "label": "specialized"}
+{"text": "我们招标资料里的风控规则引擎实战在哪", "label": "specialized"}
+{"text": "招标资料里的实时风控系统架构是什么", "label": "specialized"}
+{"text": "评审专家演示的反欺诈模型是什么", "label": "specialized"}
+{"text": "我们大数据招标资料里的实时数仓架构是什么", "label": "specialized"}
+{"text": "招标资料里的 lambda 架构和 kappa 架构怎么选的", "label": "specialized"}
+{"text": "实战里的数据湖项目用的是什么格式", "label": "specialized"}
+{"text": "我们招标资料里的 iceberg 表格式实战在哪", "label": "specialized"}
+{"text": "招标资料里的 hudi 和 delta lake 怎么对比的", "label": "specialized"}
+{"text": "评审专家说的数仓建模方法论在哪里", "label": "specialized"}
+{"text": "我们实战里的维度建模怎么做的", "label": "specialized"}
+{"text": "招标资料里的星型模型和雪花模型案例在哪", "label": "specialized"}
+{"text": "实战项目里的缓慢变化维度怎么处理", "label": "specialized"}
+{"text": "我们招标资料里的 CDC 方案用的是什么", "label": "specialized"}
+{"text": "招标资料里的 debezium 实战在哪个条款", "label": "specialized"}
+{"text": "评审专家讲的 flink cdc 案例怎么运行", "label": "specialized"}
+{"text": "我们实战里的数据质量管控是怎么做的", "label": "specialized"}
+{"text": "招标资料里的数据校验规则怎么配置", "label": "specialized"}
+{"text": "实战项目里的数据一致性检查脚本在哪", "label": "specialized"}
+{"text": "我们招标资料里的元数据管理工具是什么", "label": "specialized"}
+{"text": "招标资料里的数据字典怎么维护", "label": "specialized"}
+{"text": "评审专家演示的数据资产盘点是什么", "label": "specialized"}
+{"text": "我们实战里的数据权限管控是怎么设计的", "label": "specialized"}
+{"text": "招标资料里的行级权限和列级权限怎么实现", "label": "specialized"}
+{"text": "实战项目里的数据脱敏策略是什么", "label": "specialized"}
+{"text": "我们招标资料里的隐私计算实战有吗", "label": "specialized"}
+{"text": "招标资料里的联邦学习项目怎么跑", "label": "specialized"}
+{"text": "评审专家说的差分隐私案例在哪个条款", "label": "specialized"}
+{"text": "我们实战里的数据安全合规怎么做", "label": "specialized"}
+{"text": "招标资料里的 GDPR 合规实践是什么", "label": "specialized"}
+{"text": "实战项目里的数据删除权怎么实现", "label": "specialized"}
+{"text": "我们招标资料里的数据沿袭追踪是什么", "label": "specialized"}
+{"text": "招标资料里的 atlas 元数据平台实战在哪", "label": "specialized"}
+{"text": "评审专家讲的数据治理框架在招标资料哪里", "label": "specialized"}
+{"text": "我们大数据实战的 kubeflow 怎么用", "label": "specialized"}
+{"text": "招标资料里的 mlflow 实验管理实战在哪", "label": "specialized"}
+{"text": "实战项目里的模型版本管理怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 feature 版本管理是什么", "label": "specialized"}
+{"text": "招标资料里的 model registry 用的是什么", "label": "specialized"}
+{"text": "评审专家说的 bentoml 部署案例在哪里", "label": "specialized"}
+{"text": "我们实战里的 triton 推理服务怎么配置", "label": "specialized"}
+{"text": "招标资料里的 model serving 压测怎么做", "label": "specialized"}
+{"text": "实战项目里的推理延迟优化在哪里", "label": "specialized"}
+{"text": "我们招标资料里的 batch inference 怎么实现", "label": "specialized"}
+{"text": "招标资料里的异步推理方案是什么", "label": "specialized"}
+{"text": "评审专家演示的离线批量预测脚本在哪", "label": "specialized"}
+{"text": "我们实战里的在线特征计算怎么做", "label": "specialized"}
+{"text": "招标资料里的 redis 特征缓存怎么设计的", "label": "specialized"}
+{"text": "实战项目里的特征回填是什么", "label": "specialized"}
+{"text": "我们招标资料里的训练推理特征一致性怎么保证", "label": "specialized"}
+{"text": "招标资料里的 training-serving skew 怎么解决", "label": "specialized"}
+{"text": "评审专家讲的特征工程最佳实践在哪里", "label": "specialized"}
+{"text": "我们 Python 招标资料里的异步爬虫项目怎么跑", "label": "specialized"}
+{"text": "招标资料里的 playwright 爬虫实战在哪", "label": "specialized"}
+{"text": "实战项目里的动态渲染页面怎么处理", "label": "specialized"}
+{"text": "我们招标资料里的 selenium grid 怎么配置", "label": "specialized"}
+{"text": "招标资料里的 ip 代理池怎么搭建的", "label": "specialized"}
+{"text": "评审专家说的验证码识别方案在哪里", "label": "specialized"}
+{"text": "我们实战里的 cookie 池怎么维护", "label": "specialized"}
+{"text": "招标资料里的爬虫调度框架是什么", "label": "specialized"}
+{"text": "实战项目里的 scrapy-redis 怎么用", "label": "specialized"}
+{"text": "我们招标资料里的分布式爬虫架构是什么", "label": "specialized"}
+{"text": "招标资料里的反爬对抗案例在哪个条款", "label": "specialized"}
+{"text": "评审专家演示的 ua 伪装技巧在哪里", "label": "specialized"}
+{"text": "我们实战里的爬虫数据清洗流程是什么", "label": "specialized"}
+{"text": "招标资料里的正则表达式解析案例在哪", "label": "specialized"}
+{"text": "实战项目里的 xpath 和 css selector 怎么选", "label": "specialized"}
+{"text": "我们招标资料里的数据解析性能优化是什么", "label": "specialized"}
+{"text": "招标资料里的 lxml 和 beautifulsoup 对比案例在哪", "label": "specialized"}
+{"text": "评审专家讲的结构化数据提取实战在哪里", "label": "specialized"}
+{"text": "我们实战里的非结构化数据处理是什么", "label": "specialized"}
+{"text": "招标资料里的 pdf 解析项目用的是什么库", "label": "specialized"}
+{"text": "实战项目里的 word 文档解析怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 excel 数据处理实战在哪", "label": "specialized"}
+{"text": "招标资料里的 pandas 性能优化实战是什么", "label": "specialized"}
+{"text": "评审专家演示的 dask 并行计算在哪个条款", "label": "specialized"}
+{"text": "我们实战里的 polars 用法招标资料有讲吗", "label": "specialized"}
+{"text": "招标资料里的数据框架性能对比在哪里", "label": "specialized"}
+{"text": "实战项目里的内存优化是怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的 python profiling 实战是什么", "label": "specialized"}
+{"text": "招标资料里的 cython 加速案例在哪", "label": "specialized"}
+{"text": "评审专家说的 numba jit 实战在哪里", "label": "specialized"}
+{"text": "我们实战里的 c extension 怎么写", "label": "specialized"}
+{"text": "招标资料里的 pybind11 案例在哪个条款", "label": "specialized"}
+{"text": "实战项目里的并行计算用的是什么", "label": "specialized"}
+{"text": "我们招标资料里的 multiprocessing pool 实战是什么", "label": "specialized"}
+{"text": "招标资料里的进程池和线程池怎么选", "label": "specialized"}
+{"text": "评审专家讲的 GIL 绕过方案在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的 IO 密集和 CPU 密集任务怎么处理的", "label": "specialized"}
+{"text": "招标资料里的 asyncio 实战项目是什么", "label": "specialized"}
+{"text": "实战项目里的 aiohttp 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的异步数据库访问用什么库", "label": "specialized"}
+{"text": "招标资料里的 motor 异步 mongodb 实战在哪", "label": "specialized"}
+{"text": "评审专家演示的 tortoise orm 是什么", "label": "specialized"}
+{"text": "我们实战里的连接池配置是怎么做的", "label": "specialized"}
+{"text": "招标资料里的数据库连接超时处理在哪里", "label": "specialized"}
+{"text": "实战项目里的慢查询日志分析怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 ORM 选型建议是什么", "label": "specialized"}
+{"text": "招标资料里的 sqlalchemy 实战在哪个条款", "label": "specialized"}
+{"text": "评审专家说的数据库迁移工具是什么", "label": "specialized"}
+{"text": "我们实战里的 alembic 怎么用", "label": "specialized"}
+{"text": "招标资料里的 flyway 和 liquibase 对比是什么", "label": "specialized"}
+{"text": "实战项目里的数据库回滚怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 zero downtime migration 是什么", "label": "specialized"}
+{"text": "招标资料里的在线 DDL 怎么操作的", "label": "specialized"}
+{"text": "评审专家讲的大表加索引实战在哪里", "label": "specialized"}
+{"text": "我们实战里的 pt-online-schema-change 怎么用", "label": "specialized"}
+{"text": "招标资料里的读写分离实战是怎么配置的", "label": "specialized"}
+{"text": "实战项目里的主从延迟问题怎么处理", "label": "specialized"}
+{"text": "我们招标资料里的 mycat 和 shardingsphere 怎么选", "label": "specialized"}
+{"text": "招标资料里的分布式 id 生成方案是什么", "label": "specialized"}
+{"text": "评审专家演示的雪花算法实战在哪里", "label": "specialized"}
+{"text": "我们实战里的 leaf 发号器怎么部署", "label": "specialized"}
+{"text": "招标资料里的 uuid 和自增 id 怎么选", "label": "specialized"}
+{"text": "实战项目里的全局唯一 id 怎么保证", "label": "specialized"}
+{"text": "我们招标资料里的乐观锁实战案例是什么", "label": "specialized"}
+{"text": "招标资料里的版本号乐观锁怎么实现", "label": "specialized"}
+{"text": "评审专家说的 cas 乐观锁在哪个项目里", "label": "specialized"}
+{"text": "我们实战里的悲观锁和乐观锁如何选择", "label": "specialized"}
+{"text": "招标资料里的库存扣减实战方案是什么", "label": "specialized"}
+{"text": "实战项目里的超卖问题评审专家有几种解法", "label": "specialized"}
+{"text": "我们招标资料里的限流算法有哪些实战", "label": "specialized"}
+{"text": "招标资料里的令牌桶和漏桶算法实现在哪", "label": "specialized"}
+{"text": "评审专家演示的 guava ratelimiter 在哪个项目", "label": "specialized"}
+{"text": "我们实战里的 sentinel 限流规则怎么配", "label": "specialized"}
+{"text": "招标资料里的自适应限流是什么", "label": "specialized"}
+{"text": "实战项目里的降级策略是怎么设计的", "label": "specialized"}
+{"text": "我们招标资料里的熔断恢复机制是什么", "label": "specialized"}
+{"text": "招标资料里的 fallback 方案有哪些", "label": "specialized"}
+{"text": "评审专家讲的服务降级实战案例在哪里", "label": "specialized"}
+{"text": "我们实战里的弹性设计原则是什么", "label": "specialized"}
+{"text": "招标资料里的重试策略实战是怎么写的", "label": "specialized"}
+{"text": "实战项目里的指数退避算法代码在哪", "label": "specialized"}
+{"text": "我们招标资料里的超时传播是怎么做的", "label": "specialized"}
+{"text": "招标资料里的 context 超时取消实战在哪", "label": "specialized"}
+{"text": "评审专家说的截止时间传播在哪个项目", "label": "specialized"}
+{"text": "我们招标资料里的上下文学习实战是什么", "label": "specialized"}
+{"text": "招标资料里的 few-shot prompting 案例在哪", "label": "specialized"}
+{"text": "评审专家演示的提示词工程实战在哪个条款", "label": "specialized"}
+{"text": "我们实战里的系统提示词是怎么设计的", "label": "specialized"}
+{"text": "招标资料里的角色扮演提示词案例在哪", "label": "specialized"}
+{"text": "实战项目里的 CoT 提示词在哪里", "label": "specialized"}
+{"text": "我们招标资料里的思维链实战效果怎么样", "label": "specialized"}
+{"text": "招标资料里的 ReAct agent 实战代码在哪", "label": "specialized"}
+{"text": "评审专家说的那个工具调用实战怎么跑", "label": "specialized"}
+{"text": "我们实战里的 agent 记忆模块怎么做的", "label": "specialized"}
+{"text": "招标资料里的长期记忆和短期记忆怎么设计", "label": "specialized"}
+{"text": "实战项目里的 session 管理是什么", "label": "specialized"}
+{"text": "我们招标资料里的多轮对话管理代码在哪", "label": "specialized"}
+{"text": "招标资料里的对话历史截断策略是什么", "label": "specialized"}
+{"text": "评审专家讲的 sliding window 上下文在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的 summary memory 怎么实现", "label": "specialized"}
+{"text": "招标资料里的 entity memory 是什么", "label": "specialized"}
+{"text": "实战项目里的知识图谱记忆是什么", "label": "specialized"}
+{"text": "我们招标资料里的结构化输出实战怎么做", "label": "specialized"}
+{"text": "招标资料里的 json mode 实战案例在哪", "label": "specialized"}
+{"text": "评审专家演示的 pydantic 输出解析在哪里", "label": "specialized"}
+{"text": "我们实战里的 instructor 库怎么用", "label": "specialized"}
+{"text": "招标资料里的输出格式校验代码在哪", "label": "specialized"}
+{"text": "实战项目里的重试解析机制是什么", "label": "specialized"}
+{"text": "我们招标资料里的幻觉缓解方案是什么", "label": "specialized"}
+{"text": "招标资料里的自我一致性投票实战在哪", "label": "specialized"}
+{"text": "评审专家说的验证链是什么在哪个项目", "label": "specialized"}
+{"text": "我们实战里的 RAG 召回质量怎么评估", "label": "specialized"}
+{"text": "招标资料里的检索评测指标是什么", "label": "specialized"}
+{"text": "实战项目里的 MRR 和 NDCG 怎么算", "label": "specialized"}
+{"text": "我们招标资料里的重排序实战用了什么模型", "label": "specialized"}
+{"text": "招标资料里的 cross-encoder 重排序案例在哪", "label": "specialized"}
+{"text": "评审专家演示的混合搜索实战怎么跑", "label": "specialized"}
+{"text": "我们实战里的 BM25 和向量搜索怎么融合", "label": "specialized"}
+{"text": "招标资料里的 RRF 融合算法代码在哪", "label": "specialized"}
+{"text": "实战项目里的查询扩展是怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的 HyDE 假设文档实战是什么", "label": "specialized"}
+{"text": "招标资料里的多查询检索策略是什么", "label": "specialized"}
+{"text": "评审专家讲的 step back prompting 在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的 decomposition 查询是什么", "label": "specialized"}
+{"text": "招标资料里的知识库分块策略有哪些", "label": "specialized"}
+{"text": "实战项目里的 semantic chunking 怎么实现", "label": "specialized"}
+{"text": "我们招标资料里的递归分块代码在哪", "label": "specialized"}
+{"text": "招标资料里的 parent document retriever 是什么", "label": "specialized"}
+{"text": "评审专家演示的 multi-vector retrieval 在哪", "label": "specialized"}
+{"text": "我们实战里的 summary indexing 怎么做", "label": "specialized"}
+{"text": "招标资料里的 RAPTOR 树索引实战有吗", "label": "specialized"}
+{"text": "实战项目里的文档图谱是怎么构建的", "label": "specialized"}
+{"text": "我们招标资料里的 graph rag 实战在哪里", "label": "specialized"}
+{"text": "招标资料里的社区摘要是什么在哪里", "label": "specialized"}
+{"text": "评审专家说的 graphrag 项目怎么跑", "label": "specialized"}
+{"text": "我们实战里的实体关系抽取怎么做", "label": "specialized"}
+{"text": "招标资料里的 NER 模型用的是什么", "label": "specialized"}
+{"text": "实战项目里的关系三元组提取代码在哪", "label": "specialized"}
+{"text": "我们招标资料里的知识图谱构建流程是什么", "label": "specialized"}
+{"text": "招标资料里的 neo4j 实战在哪个条款", "label": "specialized"}
+{"text": "评审专家演示的图数据库查询实战在哪里", "label": "specialized"}
+{"text": "我们实战里的 cypher 查询怎么写的", "label": "specialized"}
+{"text": "招标资料里的实体消解是怎么做的", "label": "specialized"}
+{"text": "实战项目里的共指消解代码在哪", "label": "specialized"}
+{"text": "我们招标资料里的信息抽取项目用什么框架", "label": "specialized"}
+{"text": "招标资料里的 spacy 实战案例在哪里", "label": "specialized"}
+{"text": "评审专家说的 stanza 用法在哪个项目", "label": "specialized"}
+{"text": "我们实战里的文本解析流水线是什么", "label": "specialized"}
+{"text": "招标资料里的 unstructured 库实战在哪", "label": "specialized"}
+{"text": "实战项目里的多语言支持怎么做", "label": "specialized"}
+{"text": "我们招标资料里的翻译系统实战是什么", "label": "specialized"}
+{"text": "招标资料里的机器翻译评测怎么做", "label": "specialized"}
+{"text": "评审专家演示的多语言 BERT 微调在哪里", "label": "specialized"}
+{"text": "我们实战里的语言检测代码在哪", "label": "specialized"}
+{"text": "招标资料里的跨语言检索实战有吗", "label": "specialized"}
+{"text": "实战项目里的代码搜索是怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的代码语义搜索实战在哪", "label": "specialized"}
+{"text": "招标资料里的 code embedding 模型是什么", "label": "specialized"}
+{"text": "评审专家说的 codebert 实战在哪个条款", "label": "specialized"}
+{"text": "我们实战里的代码补全项目用了什么", "label": "specialized"}
+{"text": "招标资料里的代码生成评测指标是什么", "label": "specialized"}
+{"text": "实战项目里的 docstring 生成代码在哪", "label": "specialized"}
+{"text": "我们招标资料里的代码审查 AI 实战是什么", "label": "specialized"}
+{"text": "招标资料里的自动化测试生成实战在哪", "label": "specialized"}
+{"text": "评审专家演示的单元测试自动生成怎么跑", "label": "specialized"}
+{"text": "我们实战里的 bug 定位模型是什么", "label": "specialized"}
+{"text": "招标资料里的代码漏洞检测实战有吗", "label": "specialized"}
+{"text": "实战项目里的静态分析集成怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 devops 自动化实战在哪", "label": "specialized"}
+{"text": "招标资料里的 github actions 工作流实战在哪", "label": "specialized"}
+{"text": "评审专家讲的 argocd 部署实战在哪里", "label": "specialized"}
+{"text": "我们实战里的 helm chart 怎么写的", "label": "specialized"}
+{"text": "招标资料里的 terraform 基础设施实战在哪", "label": "specialized"}
+{"text": "实战项目里的 pulumi 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的 ansible 自动化运维在哪", "label": "specialized"}
+{"text": "招标资料里的 puppet 和 ansible 对比案例在哪", "label": "specialized"}
+{"text": "评审专家说的配置管理最佳实践在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的环境一致性怎么保证的", "label": "specialized"}
+{"text": "招标资料里的 nix 包管理实战有吗", "label": "specialized"}
+{"text": "实战项目里的容器镜像安全扫描怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 trivy 扫描集成在哪", "label": "specialized"}
+{"text": "招标资料里的 cosign 镜像签名实战在哪", "label": "specialized"}
+{"text": "评审专家演示的 sbom 生成在哪个条款", "label": "specialized"}
+{"text": "我们实战里的供应链安全实践是什么", "label": "specialized"}
+{"text": "招标资料里的 devsecops 实战是什么", "label": "specialized"}
+{"text": "实战项目里的渗透测试模拟怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 sast 和 dast 工具是什么", "label": "specialized"}
+{"text": "招标资料里的 sonarqube 集成实战在哪", "label": "specialized"}
+{"text": "评审专家说的代码质量门禁在哪个项目", "label": "specialized"}
+{"text": "我们实战里的技术债务量化怎么做", "label": "specialized"}
+{"text": "招标资料里的架构适应度函数是什么", "label": "specialized"}
+{"text": "实战项目里的测试覆盖率目标是多少", "label": "specialized"}
+{"text": "我们招标资料里的变异测试实战有吗", "label": "specialized"}
+{"text": "招标资料里的 pitest 工具怎么用的", "label": "specialized"}
+{"text": "评审专家演示的契约测试在哪里", "label": "specialized"}
+{"text": "我们实战里的 pact 测试怎么写", "label": "specialized"}
+{"text": "招标资料里的 consumer driven contract 是什么", "label": "specialized"}
+{"text": "实战项目里的服务虚拟化怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 wiremock 实战在哪", "label": "specialized"}
+{"text": "招标资料里的 mock server 配置在哪里", "label": "specialized"}
+{"text": "评审专家说的测试环境管理在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的测试数据管理是什么", "label": "specialized"}
+{"text": "招标资料里的测试数据工厂代码在哪", "label": "specialized"}
+{"text": "实战项目里的数据库 fixture 怎么管理", "label": "specialized"}
+{"text": "我们招标资料里的 testcontainers 实战在哪", "label": "specialized"}
+{"text": "招标资料里的集成测试环境隔离怎么做", "label": "specialized"}
+{"text": "评审专家演示的 e2e 测试框架是什么", "label": "specialized"}
+{"text": "我们实战里的 cypress 实战在哪个条款", "label": "specialized"}
+{"text": "招标资料里的 playwright 测试代码在哪", "label": "specialized"}
+{"text": "实战项目里的视觉回归测试怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 storybook 实战有吗", "label": "specialized"}
+{"text": "招标资料里的前端组件测试是怎么做的", "label": "specialized"}
+{"text": "评审专家讲的 vitest 和 jest 对比在哪里", "label": "specialized"}
+{"text": "我们实战里的前端单测覆盖率目标是多少", "label": "specialized"}
+{"text": "招标资料里的 react testing library 实战在哪", "label": "specialized"}
+{"text": "实战项目里的 mock service worker 怎么用", "label": "specialized"}
+{"text": "我们招标资料里的 api 测试自动化在哪", "label": "specialized"}
+{"text": "招标资料里的 postman 自动化测试实战在哪", "label": "specialized"}
+{"text": "评审专家说的 karate 测试框架在哪个项目", "label": "specialized"}
+{"text": "我们实战里的性能测试基线是什么", "label": "specialized"}
+{"text": "招标资料里的 k6 性能测试实战在哪", "label": "specialized"}
+{"text": "实战项目里的 gatling 脚本在哪里", "label": "specialized"}
+{"text": "我们招标资料里的压测报告分析怎么做", "label": "specialized"}
+{"text": "招标资料里的 apdex 指标是什么在哪里", "label": "specialized"}
+{"text": "评审专家演示的 p99 延迟分析在哪里", "label": "specialized"}
+{"text": "我们实战里的性能基准测试框架是什么", "label": "specialized"}
+{"text": "招标资料里的 jmh 基准测试实战在哪", "label": "specialized"}
+{"text": "实战项目里的 benchmark 结果在哪里", "label": "specialized"}
+{"text": "我们招标资料里的内存泄漏排查实战是什么", "label": "specialized"}
+{"text": "招标资料里的 eclipse mat 怎么用的", "label": "specialized"}
+{"text": "评审专家说的 jconsole 监控实战在哪", "label": "specialized"}
+{"text": "我们实战里的 jstack 分析案例在哪", "label": "specialized"}
+{"text": "招标资料里的 gc 日志分析实战是什么", "label": "specialized"}
+{"text": "实战项目里的 gc easy 工具怎么用", "label": "specialized"}
+{"text": "我们招标资料里的内存分析报告在哪里", "label": "specialized"}
+{"text": "招标资料里的 allocation profiling 怎么做", "label": "specialized"}
+{"text": "评审专家演示的对象存活分析在哪里", "label": "specialized"}
+{"text": "我们实战里的 heap 大小调优是怎么做的", "label": "specialized"}
+{"text": "招标资料里的 metaspace 溢出排查在哪", "label": "specialized"}
+{"text": "实战项目里的类加载器泄漏怎么排查", "label": "specialized"}
+{"text": "我们招标资料里的线程泄漏检测实战在哪", "label": "specialized"}
+{"text": "招标资料里的连接池泄漏排查案例是什么", "label": "specialized"}
+{"text": "评审专家说的资源泄漏检测工具在哪里", "label": "specialized"}
+{"text": "我们实战里的 FD 泄漏排查是什么", "label": "specialized"}
+{"text": "招标资料里的网络连接泄漏怎么诊断", "label": "specialized"}
+{"text": "实战项目里的 TIME_WAIT 问题怎么处理", "label": "specialized"}
+{"text": "我们招标资料里的 socket 调优实战在哪", "label": "specialized"}
+{"text": "招标资料里的 tcp keepalive 配置在哪里", "label": "specialized"}
+{"text": "评审专家演示的网络性能调优案例在哪", "label": "specialized"}
+{"text": "我们实战里的 zero copy 技术怎么用", "label": "specialized"}
+{"text": "招标资料里的 sendfile 实战案例在哪", "label": "specialized"}
+{"text": "实战项目里的 mmap 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的 io_uring 实战有吗", "label": "specialized"}
+{"text": "招标资料里的异步 io 框架对比在哪里", "label": "specialized"}
+{"text": "评审专家说的 proactor 模式实战在哪", "label": "specialized"}
+{"text": "我们实战里的事件驱动架构是什么", "label": "specialized"}
+{"text": "招标资料里的 event loop 实现在哪个条款", "label": "specialized"}
+{"text": "实战项目里的消息驱动架构代码在哪", "label": "specialized"}
+{"text": "我们招标资料里的 actor model 实战有吗", "label": "specialized"}
+{"text": "招标资料里的 akka 实战案例在哪里", "label": "specialized"}
+{"text": "评审专家演示的 vertx 实战在哪里", "label": "specialized"}
+{"text": "我们实战里的响应式微服务是什么", "label": "specialized"}
+{"text": "招标资料里的 quarkus 框架实战在哪", "label": "specialized"}
+{"text": "实战项目里的 micronaut 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的 graalvm native image 实战在哪", "label": "specialized"}
+{"text": "招标资料里的 AOT 编译实战是什么", "label": "specialized"}
+{"text": "评审专家说的 substrate vm 在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的启动时间优化是什么", "label": "specialized"}
+{"text": "招标资料里的 serverless java 实战在哪", "label": "specialized"}
+{"text": "实战项目里的冷启动优化是怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的 lambda 函数实战在哪", "label": "specialized"}
+{"text": "招标资料里的 aws lambda 部署在哪个条款", "label": "specialized"}
+{"text": "评审专家演示的 azure function 实战在哪", "label": "specialized"}
+{"text": "我们实战里的函数即服务是什么", "label": "specialized"}
+{"text": "招标资料里的 knative 实战案例在哪里", "label": "specialized"}
+{"text": "实战项目里的事件触发函数怎么写", "label": "specialized"}
+{"text": "我们招标资料里的 cloudnative 实践是什么", "label": "specialized"}
+{"text": "招标资料里的 12-factor app 实战案例在哪", "label": "specialized"}
+{"text": "评审专家讲的云原生架构最佳实践在哪里", "label": "specialized"}
+{"text": "我们实战里的 operator 模式怎么实现", "label": "specialized"}
+{"text": "招标资料里的 custom resource 定义在哪", "label": "specialized"}
+{"text": "实战项目里的 controller 怎么写的", "label": "specialized"}
+{"text": "我们招标资料里的 admission webhook 实战有吗", "label": "specialized"}
+{"text": "招标资料里的 k8s 扩展 API 怎么开发", "label": "specialized"}
+{"text": "评审专家演示的 crd 案例代码在哪里", "label": "specialized"}
+{"text": "我们实战里的 operator sdk 怎么用", "label": "specialized"}
+{"text": "招标资料里的 kubebuilder 实战在哪个条款", "label": "specialized"}
+{"text": "实战项目里的 reconcile loop 怎么写", "label": "specialized"}
+{"text": "我们招标资料里的 k8s 网络插件实战有吗", "label": "specialized"}
+{"text": "招标资料里的 cni 插件是什么在哪里", "label": "specialized"}
+{"text": "评审专家说的 calico 网络策略实战在哪", "label": "specialized"}
+{"text": "我们实战里的 service mesh 接入是什么", "label": "specialized"}
+{"text": "招标资料里的 istio 流量管理实战在哪", "label": "specialized"}
+{"text": "实战项目里的 envoy filter 怎么写", "label": "specialized"}
+{"text": "我们招标资料里的 kiali 可视化实战在哪", "label": "specialized"}
+{"text": "招标资料里的 jaeger 链路追踪集成在哪", "label": "specialized"}
+{"text": "评审专家演示的 opentelemetry 实战在哪里", "label": "specialized"}
+{"text": "我们实战里的指标采集方案是什么", "label": "specialized"}
+{"text": "招标资料里的 openmetrics 格式是什么", "label": "specialized"}
+{"text": "实战项目里的 alertmanager 怎么配置", "label": "specialized"}
+{"text": "我们招标资料里的 oncall 流程实战有吗", "label": "specialized"}
+{"text": "招标资料里的 runbook 自动化是什么", "label": "specialized"}
+{"text": "评审专家说的 SRE 实践在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的 SLO 怎么定义的", "label": "specialized"}
+{"text": "招标资料里的 error budget 概念是什么", "label": "specialized"}
+{"text": "实战项目里的可观测性三支柱是什么", "label": "specialized"}
+{"text": "我们招标资料里的日志聚合方案是什么", "label": "specialized"}
+{"text": "招标资料里的 loki 实战在哪个条款", "label": "specialized"}
+{"text": "评审专家演示的 tempo 分布式追踪在哪里", "label": "specialized"}
+{"text": "我们实战里的 grafana stack 怎么搭", "label": "specialized"}
+{"text": "招标资料里的 dashboard 告警规则怎么写", "label": "specialized"}
+{"text": "实战项目里的异常检测模型怎么集成", "label": "specialized"}
+{"text": "我们招标资料里的智能告警实战是什么", "label": "specialized"}
+{"text": "招标资料里的 aiops 实践在哪里", "label": "specialized"}
+{"text": "评审专家讲的根因分析自动化在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的故障预测模型是什么", "label": "specialized"}
+{"text": "招标资料里的容量预测实战案例在哪", "label": "specialized"}
+{"text": "实战项目里的自愈系统怎么实现的", "label": "specialized"}
+{"text": "我们招标资料里的混沌工程工具是什么", "label": "specialized"}
+{"text": "招标资料里的 chaosblade 实战在哪里", "label": "specialized"}
+{"text": "评审专家演示的 chaos mesh 案例在哪", "label": "specialized"}
+{"text": "我们实战里的故障演练方案是什么", "label": "specialized"}
+{"text": "招标资料里的游戏日 gameday 怎么组织的", "label": "specialized"}
+{"text": "实战项目里的压力测试场景是什么", "label": "specialized"}
+{"text": "我们招标资料里的容灾切换实战是什么", "label": "specialized"}
+{"text": "招标资料里的 DNS 故障注入怎么做", "label": "specialized"}
+{"text": "评审专家说的网络分区模拟在哪里", "label": "specialized"}
+{"text": "我们实战里的磁盘故障模拟是什么", "label": "specialized"}
+{"text": "招标资料里的内存压力测试怎么做", "label": "specialized"}
+{"text": "实战项目里的 kill -9 恢复测试是什么", "label": "specialized"}
+{"text": "我们招标资料里的 Chaos engineering 工具是什么", "label": "specialized"}
+{"text": "实战项目的监控大盘如何搭建", "label": "specialized"}
+{"text": "评审专家演示的 DORA 指标分析在哪", "label": "specialized"}
+{"text": "我们实战里的部署频率怎么统计", "label": "specialized"}
+{"text": "招标资料里的变更失败率怎么度量", "label": "specialized"}
+{"text": "实战项目里的 MTTR 如何缩短", "label": "specialized"}
+{"text": "我们招标资料里的工程效能度量是什么", "label": "specialized"}
+{"text": "招标资料里的 cycle time 分析实战在哪", "label": "specialized"}
+{"text": "评审专家说的 lead time 优化案例在哪", "label": "specialized"}
+{"text": "我们实战里的 value stream mapping 是什么", "label": "specialized"}
+{"text": "招标资料里的精益看板实战是什么", "label": "specialized"}
+{"text": "实战项目里的看板限制 WIP 怎么做", "label": "specialized"}
+{"text": "我们招标资料里的 scrum 实践是什么", "label": "specialized"}
+{"text": "招标资料里的 sprint review 怎么做", "label": "specialized"}
+{"text": "评审专家讲的 retrospective 方法在哪里", "label": "specialized"}
+{"text": "我们实战里的 OKR 设定是什么", "label": "specialized"}
+{"text": "招标资料里的技术 roadmap 怎么制定", "label": "specialized"}
+{"text": "实战项目里的架构评审流程是什么", "label": "specialized"}
+{"text": "我们招标资料里的 RFC 模板在哪里", "label": "specialized"}
+{"text": "招标资料里的 ADR 架构决策记录是什么", "label": "specialized"}
+{"text": "评审专家演示的技术方案评审在哪里", "label": "specialized"}
+{"text": "我们实战里的代码分层规范是什么", "label": "specialized"}
+{"text": "招标资料里的 clean code 实践案例在哪", "label": "specialized"}
+{"text": "实战项目里的命名规范是什么", "label": "specialized"}
+{"text": "我们招标资料里的注释规范是什么", "label": "specialized"}
+{"text": "招标资料里的 api 命名规范在哪里", "label": "specialized"}
+{"text": "评审专家说的 restful 设计规范案例在哪", "label": "specialized"}
+{"text": "我们实战里的 graphql 接口设计是什么", "label": "specialized"}
+{"text": "招标资料里的 api versioning 策略是什么", "label": "specialized"}
+{"text": "实战项目里的向后兼容怎么保证", "label": "specialized"}
+{"text": "我们招标资料里的 deprecation 策略是什么", "label": "specialized"}
+{"text": "招标资料里的 semantic versioning 实践在哪", "label": "specialized"}
+{"text": "评审专家讲的 changelog 维护方法在哪里", "label": "specialized"}
+{"text": "我们实战里的 release note 模板在哪", "label": "specialized"}
+{"text": "招标资料里的 feature flag 实战是什么", "label": "specialized"}
+{"text": "实战项目里的 unleash 平台怎么用", "label": "specialized"}
+{"text": "我们招标资料里的功能开关实践案例在哪", "label": "specialized"}
+{"text": "招标资料里的 trunk-based development 实战在哪", "label": "specialized"}
+{"text": "评审专家演示的 feature branch 策略在哪", "label": "specialized"}
+{"text": "我们实战里的 gitflow 是怎么用的", "label": "specialized"}
+{"text": "招标资料里的 monorepo 实践有吗", "label": "specialized"}
+{"text": "实战项目里的 nx 工具怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的 turborepo 实战在哪", "label": "specialized"}
+{"text": "招标资料里的 pnpm workspace 怎么配置", "label": "specialized"}
+{"text": "评审专家说的包版本管理策略在哪里", "label": "specialized"}
+{"text": "我们实战里的私有 npm 仓库是什么", "label": "specialized"}
+{"text": "招标资料里的 nexus 仓库管理实战在哪", "label": "specialized"}
+{"text": "实战项目里的 artifactory 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的制品管理策略是什么", "label": "specialized"}
+{"text": "招标资料里的二进制制品签名是什么", "label": "specialized"}
+{"text": "评审专家演示的 cosign 实战在哪里", "label": "specialized"}
+{"text": "我们实战里的镜像仓库是什么", "label": "specialized"}
+{"text": "招标资料里的 harbor 镜像仓库实战在哪", "label": "specialized"}
+{"text": "实战项目里的镜像清理策略是什么", "label": "specialized"}
+{"text": "我们招标资料里的镜像安全扫描怎么做", "label": "specialized"}
+{"text": "招标资料里的 clair 扫描集成在哪里", "label": "specialized"}
+{"text": "评审专家说的 aqua security 在哪个项目", "label": "specialized"}
+{"text": "我们实战里的合规检查自动化是什么", "label": "specialized"}
+{"text": "招标资料里的 open policy agent 实战在哪", "label": "specialized"}
+{"text": "实战项目里的策略即代码是什么", "label": "specialized"}
+{"text": "我们招标资料里的 conftest 工具怎么用", "label": "specialized"}
+{"text": "招标资料里的 kyverno 策略实战在哪", "label": "specialized"}
+{"text": "评审专家演示的准入控制实战在哪里", "label": "specialized"}
+{"text": "我们实战里的资源配额管理是什么", "label": "specialized"}
+{"text": "招标资料里的 limit range 怎么配置的", "label": "specialized"}
+{"text": "实战项目里的 resource quota 案例在哪", "label": "specialized"}
+{"text": "我们招标资料里的成本优化实战是什么", "label": "specialized"}
+{"text": "招标资料里的 kubecost 使用实战在哪", "label": "specialized"}
+{"text": "评审专家说的云成本分析方法在招标资料哪里", "label": "specialized"}
+{"text": "我们实战里的 spot instance 使用是什么", "label": "specialized"}
+{"text": "招标资料里的抢占式实例最佳实践在哪", "label": "specialized"}
+{"text": "实战项目里的 auto scaling 策略是什么", "label": "specialized"}
+{"text": "我们招标资料里的 HPA 和 VPA 怎么配置", "label": "specialized"}
+{"text": "招标资料里的 KEDA 事件驱动扩缩容实战在哪", "label": "specialized"}
+{"text": "评审专家演示的自定义指标扩缩容在哪里", "label": "specialized"}
+{"text": "我们实战里的节点亲和性怎么设置", "label": "specialized"}
+{"text": "招标资料里的 pod 调度策略是什么", "label": "specialized"}
+{"text": "实战项目里的 taint 和 toleration 怎么用", "label": "specialized"}
+{"text": "我们招标资料里的 gpu 调度实战是什么", "label": "specialized"}
+{"text": "招标资料里的 gpu 资源限制怎么配置", "label": "specialized"}
+{"text": "评审专家说的 time-slicing gpu 在哪个条款", "label": "specialized"}
+{"text": "我们实战里的 mig 分区是什么", "label": "specialized"}
+{"text": "招标资料里的 gpu 利用率监控怎么做", "label": "specialized"}
+{"text": "实战项目里的 dcgm 监控集成在哪", "label": "specialized"}
+{"text": "我们招标资料里的 gpu 集群管理是什么", "label": "specialized"}
+{"text": "招标资料里的 ray 分布式计算实战在哪", "label": "specialized"}
+{"text": "评审专家演示的 ray train 实战在哪里", "label": "specialized"}
+{"text": "我们实战里的 ray serve 部署是什么", "label": "specialized"}
+{"text": "招标资料里的 ray tune 超参搜索实战在哪", "label": "specialized"}
+{"text": "实战项目里的 ray data 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的 spark on kubernetes 实战在哪", "label": "specialized"}
+{"text": "招标资料里的 flink on k8s 部署在哪个条款", "label": "specialized"}
+{"text": "评审专家说的 volcano 任务调度实战在哪", "label": "specialized"}
+{"text": "我们实战里的 Kubeflow Pipelines 怎么用", "label": "specialized"}
+{"text": "招标资料里的 argo workflows 实战在哪里", "label": "specialized"}
+{"text": "实战项目里的 prefect 工作流怎么用", "label": "specialized"}
+{"text": "我们招标资料里的 metaflow 实战有吗", "label": "specialized"}
+{"text": "招标资料里的 zenml 工具怎么用的", "label": "specialized"}
+{"text": "评审专家演示的 vertex ai pipelines 在哪里", "label": "specialized"}
+{"text": "我们实战里的 sagemaker 实战是什么", "label": "specialized"}
+{"text": "招标资料里的 bedrock 实战案例在哪", "label": "specialized"}
+{"text": "实战项目里的 azure ml 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的 hugging face hub 实战在哪", "label": "specialized"}
+{"text": "招标资料里的 spaces 部署案例在哪里", "label": "specialized"}
+{"text": "评审专家说的 gradio 实战在哪个条款", "label": "specialized"}
+{"text": "我们实战里的 streamlit 项目代码在哪", "label": "specialized"}
+{"text": "招标资料里的 chainlit 实战在哪里", "label": "specialized"}
+{"text": "实战项目里的对话界面怎么搭建", "label": "specialized"}
+{"text": "我们招标资料里的 openwebui 部署实战在哪", "label": "specialized"}
+{"text": "招标资料里的 lobe-chat 配置案例在哪", "label": "specialized"}
+{"text": "评审专家演示的 privateGPT 实战在哪里", "label": "specialized"}
+{"text": "我们实战里的本地大模型部署是什么", "label": "specialized"}
+{"text": "招标资料里的 ollama 实战在哪个条款", "label": "specialized"}
+{"text": "实战项目里的 llama.cpp 怎么用的", "label": "specialized"}
+{"text": "我们招标资料里的量化部署实战是什么", "label": "specialized"}
+{"text": "招标资料里的 gguf 格式实战在哪里", "label": "specialized"}
+{"text": "评审专家说的模型格式转换在哪个项目", "label": "specialized"}
+{"text": "我们实战里的 quantization 方案是什么", "label": "specialized"}
+{"text": "招标资料里的 exllamav2 推理实战在哪", "label": "specialized"}
+{"text": "实战项目里的 llm 基准测试怎么跑", "label": "specialized"}
+{"text": "我们招标资料里的 lm evaluation harness 实战在哪", "label": "specialized"}
+{"text": "招标资料里的 openai evals 框架怎么用", "label": "specialized"}
+{"text": "评审专家演示的自定义评测集怎么构建", "label": "specialized"}
+{"text": "我们实战里的 human eval 自动化是什么", "label": "specialized"}
+{"text": "招标资料里的 MT-bench 评测在哪里", "label": "specialized"}
+{"text": "实战项目里的对话质量评估是什么", "label": "specialized"}
+{"text": "我们招标资料里的 G-eval 框架实战在哪", "label": "specialized"}
+{"text": "招标资料里的 prometheus 评测实战有吗", "label": "specialized"}
+{"text": "评审专家说的 ragas 评测框架在哪里", "label": "specialized"}
+{"text": "我们实战里的 RAG 评测指标是什么", "label": "specialized"}
+{"text": "招标资料里的忠实度和相关性怎么评测", "label": "specialized"}
+{"text": "实战项目里的上下文召回评测是什么", "label": "specialized"}
+{"text": "我们招标资料里的上下文压缩策略是什么", "label": "specialized"}
+{"text": "实战项目里的 prompt caching 怎么实现", "label": "specialized"}
+{"text": "招标资料里的 KV cache 复用实战在哪", "label": "specialized"}
+{"text": "评审专家演示的推理成本优化方案在哪里", "label": "specialized"}
+{"text": "我们招标资料里的 RAG 知识库是怎么构建的", "label": "specialized"}
+{"text": "评审专家说的 Contextual RAG 是什么,在哪里实现的", "label": "specialized"}
+{"text": "招标资料里的 BGE-M3 嵌入模型怎么加载", "label": "specialized"}
+{"text": "实战项目里的 Milvus 集合是怎么设计的", "label": "specialized"}
+{"text": "我们项目的 hybrid search 是怎么实现的", "label": "specialized"}
+{"text": "招标资料里的 WeightedRanker 权重是怎么设置的", "label": "specialized"}
+{"text": "评审专家演示的 BGE-Reranker 精排流程在哪个文件", "label": "specialized"}
+{"text": "实战里的 RAG 置信度阈值是多少,在哪里配置", "label": "specialized"}
+{"text": "我们招标资料的 RAG 项目用了几个向量索引", "label": "specialized"}
+{"text": "招标资料里的文档分块策略是什么", "label": "specialized"}
+{"text": "实战项目的 PDF 解析用的是什么库", "label": "specialized"}
+{"text": "我们的 RAG 系统里 dense 和 sparse 向量是怎么融合的", "label": "specialized"}
+{"text": "招标资料里为什么用 MiniLM 做意图分类", "label": "specialized"}
+{"text": "实战里的 RAG 问答走 SSE 流式吗,在哪实现", "label": "specialized"}
+{"text": "评审专家讲的 Milvus 单集合多租户方案是什么", "label": "specialized"}
+{"text": "我们项目里的 BGE-M3 稀疏向量是怎么存进 Milvus 的", "label": "specialized"}
+{"text": "招标资料里的知识库写入脚本叫什么", "label": "specialized"}
+{"text": "实战里的 Reranker 是怎么判断置信度的", "label": "specialized"}
+{"text": "我们 RAG 项目里文档更新时怎么做幂等写入", "label": "specialized"}
+{"text": "招标资料里的 query_classifier 是怎么微调的", "label": "specialized"}
+{"text": "实战项目里 RAG 检索不到时怎么兜底的", "label": "specialized"}
+{"text": "我们的知识库项目里 init_milvus.py 做了什么", "label": "specialized"}
+{"text": "评审专家演示的 build_knowledge_base.py 分几步", "label": "specialized"}
+{"text": "招标资料里的 Contextual RAG 上下文是怎么生成的", "label": "specialized"}
+{"text": "实战项目里的 encode_query 返回什么格式", "label": "specialized"}
+{"text": "我们招标资料里 RAG 的 top-k 参数在哪里配置", "label": "specialized"}
+{"text": "招标资料里的 AnnSearchRequest 是怎么用的", "label": "specialized"}
+{"text": "实战中的 knowledge_base.py 对外暴露了哪些方法", "label": "specialized"}
+{"text": "我们项目里 RAG 召回候选数量是多少", "label": "specialized"}
+{"text": "招标资料里的 MarkdownHeaderTextSplitter 在哪个步骤用", "label": "specialized"}
+{"text": "实战里的 HNSW 索引参数 M 和 efConstruction 是多少", "label": "specialized"}
+{"text": "我们的 RAG 系统里 tenant_id 是怎么做过滤的", "label": "specialized"}
+{"text": "招标资料里的稀疏索引 drop_ratio_build 是多少", "label": "specialized"}
+{"text": "实战项目里意图识别分几类,分别是什么", "label": "specialized"}
+{"text": "评审专家说的 Web 搜索兜底是用哪个 API 的", "label": "specialized"}
+{"text": "我们招标资料里 RAG 的记忆压缩超过几轮触发", "label": "specialized"}
+{"text": "招标资料里的 BGE-M3 为什么禁用 MPS fp16", "label": "specialized"}
+{"text": "实战里的 chunk_id 是怎么生成的", "label": "specialized"}
+{"text": "我们项目里的 source_name 字段是做什么用的", "label": "specialized"}
+{"text": "招标资料里的 MilvusClient 和旧版 Collection 有什么区别", "label": "specialized"}
+{"text": "实战项目里 RAG 系统的 SSE 事件格式是什么", "label": "specialized"}
+{"text": "我们招标资料里的 QA Agent 用了哪个 LangGraph 节点顺序", "label": "specialized"}
+{"text": "招标资料里的 retrieve 方法返回的 RetrievalResult 有哪些字段", "label": "specialized"}
+{"text": "实战里的 Semaphore 并发数是多少,用在哪里", "label": "specialized"}
+{"text": "我们项目的 RAG 系统支持哪些文件格式", "label": "specialized"}
+{"text": "招标资料里的 Multi-Query 改写是怎么做的", "label": "specialized"}
+{"text": "实战中的 HyDE 生成假设文档在哪个节点实现", "label": "specialized"}
+{"text": "我们招标资料里的 MCP Server 是做什么的", "label": "specialized"}
+{"text": "评审专家演示的知识库 MCP 工具有哪些接口", "label": "specialized"}
+{"text": "实战项目里的 web_search_server.py 用了哪个搜索接口", "label": "specialized"}
+{"text": "我们招标资料里 LangGraph 图的节点顺序是怎么设计的", "label": "specialized"}
+{"text": "我们招标资料里的 Agent 系统是用 LangGraph 构建的吗", "label": "specialized"}
+{"text": "评审专家讲的 StateGraph 和 AgentExecutor 有什么区别", "label": "specialized"}
+{"text": "实战里的 MemorySaver 是怎么按 Agent 隔离的", "label": "specialized"}
+{"text": "我们项目里的 HitL 是怎么实现的", "label": "specialized"}
+{"text": "招标资料里的 interrupt() 在哪个节点调用", "label": "specialized"}
+{"text": "实战项目里的 Command(resume=...) 是用来做什么的", "label": "specialized"}
+{"text": "我们招标资料的四大 Agent 分别是什么", "label": "specialized"}
+{"text": "评审专家演示的供应商资质审查 Agent 有几个并行维度", "label": "specialized"}
+{"text": "实战里的资质评分六个维度分别是什么", "label": "specialized"}
+{"text": "我们项目里的投标文件评分 Agent 支持哪些题型", "label": "specialized"}
+{"text": "招标资料里的供应商答辩 Agent 是怎么设计对话状态的", "label": "specialized"}
+{"text": "实战中的 Orchestrator 是怎么路由不同 Agent 的", "label": "specialized"}
+{"text": "我们招标资料里 Agent 的三层兜底机制是什么", "label": "specialized"}
+{"text": "评审专家说的 with_structured_output 为什么要加 method='function_calling'", "label": "specialized"}
+{"text": "实战里的 LLMFactory 是怎么统一管理模型的", "label": "specialized"}
+{"text": "我们项目里的 get_llm 函数在哪里定义", "label": "specialized"}
+{"text": "招标资料里的 DeepSeek 模型是怎么配置 base_url 的", "label": "specialized"}
+{"text": "实战项目里 init_chat_model 的 model_provider 是什么", "label": "specialized"}
+{"text": "我们招标资料里的 retry.py 支持几次重试", "label": "specialized"}
+{"text": "评审专家讲的 AgentFallbackHandler 在什么情况下触发", "label": "specialized"}
+{"text": "实战里的系统兜底响应是怎么处理的", "label": "specialized"}
+{"text": "我们项目里 with_retry 装饰器怎么使用", "label": "specialized"}
+{"text": "招标资料里的 thread_id 格式是什么", "label": "specialized"}
+{"text": "实战里的 qa_sessions 表存了哪些字段", "label": "specialized"}
+{"text": "我们招标资料里对话摘要是什么时候触发压缩的", "label": "specialized"}
+{"text": "评审专家演示的 memory.py 是怎么初始化 MemorySaver 的", "label": "specialized"}
+{"text": "实战项目里的 Node 节点是怎么注册进 StateGraph 的", "label": "specialized"}
+{"text": "我们招标资料里的条件边路由逻辑在哪个文件", "label": "specialized"}
+{"text": "招标资料里的 QAState 有哪些字段", "label": "specialized"}
+{"text": "实战里的 load_memory_and_embed 节点做了什么", "label": "specialized"}
+{"text": "我们项目里的 generate_rag_node 是怎么流式输出的", "label": "specialized"}
+{"text": "评审专家说的 save_memory_node 保存的是什么", "label": "specialized"}
+{"text": "实战中的双轨评估答辩 Agent 是怎么实现的", "label": "specialized"}
+{"text": "我们招标资料里的 Orchestrator 怎么判断走哪个 Agent", "label": "specialized"}
+{"text": "招标资料里的 unified_chat 接口是做什么用的", "label": "specialized"}
+{"text": "实战项目里 LangGraph 图是在哪里编译的", "label": "specialized"}
+{"text": "我们招标资料里的 Agent 图编译时为什么不传 interrupt_before", "label": "specialized"}
+{"text": "评审专家演示的三轨并行评分是什么意思", "label": "specialized"}
+{"text": "实战里的 Send API 在评分 Agent 里怎么用的", "label": "specialized"}
+{"text": "我们项目里 prompts.py 里有哪些模板", "label": "specialized"}
+{"text": "招标资料里的 DeepSeek-Coder 用在哪个 Agent 里", "label": "specialized"}
+{"text": "实战项目里的 structured_llm 是怎么绑定输出结构的", "label": "specialized"}
+{"text": "我们招标资料里的评分状态流转有哪几个阶段", "label": "specialized"}
+{"text": "评审专家讲的 bid_review_reviews 表里有哪些字段", "label": "specialized"}
+{"text": "实战中的答辩评分是怎么做双轨对比的", "label": "specialized"}
+{"text": "我们招标资料里的 defense_sessions 表结构是什么", "label": "specialized"}
+{"text": "招标资料里的 pending_review 状态是什么时候进入的", "label": "specialized"}
+{"text": "实战项目里 HitL 人工审核接口是什么", "label": "specialized"}
+{"text": "我们电商项目里的商品分类模型是怎么训练的", "label": "specialized"}
+{"text": "评审专家演示的电商推荐系统用了哪种协同过滤", "label": "specialized"}
+{"text": "实战里的商品搜索是基于向量检索还是关键词检索", "label": "specialized"}
+{"text": "我们招标资料里的电商评价情感分析是怎么实现的", "label": "specialized"}
+{"text": "招标资料里的用户行为序列模型在哪个条款讲的", "label": "specialized"}
+{"text": "实战项目里的商品详情页推荐算法是什么", "label": "specialized"}
+{"text": "我们电商项目里的订单状态机是怎么设计的", "label": "specialized"}
+{"text": "评审专家说的电商冷启动问题是怎么解决的", "label": "specialized"}
+{"text": "招标资料里的商品多级分类树是怎么存储的", "label": "specialized"}
+{"text": "实战里的用户画像特征是怎么构建的", "label": "specialized"}
+{"text": "我们电商项目里的实时推荐是用什么框架实现的", "label": "specialized"}
+{"text": "招标资料里的商品 Embedding 是怎么训练的", "label": "specialized"}
+{"text": "实战项目里的购物车和订单数据库表是怎么设计的", "label": "specialized"}
+{"text": "我们招标资料里的电商促销活动规则引擎在哪里", "label": "specialized"}
+{"text": "评审专家演示的商品评论爬取脚本在哪个文件", "label": "specialized"}
+{"text": "招标资料里的电商库存管理是怎么防超卖的", "label": "specialized"}
+{"text": "实战里的商品图文多模态检索是怎么实现的", "label": "specialized"}
+{"text": "我们电商项目里的用户留存分析脚本在哪", "label": "specialized"}
+{"text": "招标资料里的价格预测模型用的是什么算法", "label": "specialized"}
+{"text": "实战项目里的商品标签自动打标逻辑在哪里", "label": "specialized"}
+{"text": "我们招标资料里电商 A/B 测试框架是怎么设计的", "label": "specialized"}
+{"text": "评审专家讲的电商漏斗分析模型在哪个模块", "label": "specialized"}
+{"text": "招标资料里的商品相似度计算用的是余弦相似度吗", "label": "specialized"}
+{"text": "实战里的电商 DAU/MAU 统计是怎么实现的", "label": "specialized"}
+{"text": "我们招标资料里的电商知识图谱是怎么构建的", "label": "specialized"}
+{"text": "评审专家演示的商品描述自动生成是用哪个模型", "label": "specialized"}
+{"text": "招标资料里的电商搜索词纠错功能怎么实现的", "label": "specialized"}
+{"text": "实战项目里的用户分群是用什么聚类算法", "label": "specialized"}
+{"text": "我们电商招标资料里的风控规则引擎在哪个文件", "label": "specialized"}
+{"text": "招标资料里的商品类目预测准确率是多少", "label": "specialized"}
+{"text": "实战里的电商商品去重逻辑是怎么做的", "label": "specialized"}
+{"text": "我们招标资料里的电商用户意图识别模型怎么训练", "label": "specialized"}
+{"text": "评审专家说的电商流量预测模型在哪里实现", "label": "specialized"}
+{"text": "招标资料里的商品点击率预估 CTR 模型是哪种", "label": "specialized"}
+{"text": "实战项目里的电商供应链管理模块在哪里", "label": "specialized"}
+{"text": "我们招标资料里的商品评分系统是怎么设计的", "label": "specialized"}
+{"text": "评审专家讲的电商多渠道数据融合怎么实现的", "label": "specialized"}
+{"text": "招标资料里的购物意图预测在什么场景触发", "label": "specialized"}
+{"text": "实战里的商品属性抽取是用 NER 还是规则", "label": "specialized"}
+{"text": "我们招标资料里的电商实时竞价模型是什么算法", "label": "specialized"}
+{"text": "招标资料里的商品主图质量检测是怎么做的", "label": "specialized"}
+{"text": "实战项目里的用户购买转化模型在哪里训练", "label": "specialized"}
+{"text": "我们电商招标资料里的智能客服机器人是怎么接入的", "label": "specialized"}
+{"text": "评审专家演示的商品问答系统用了 RAG 吗", "label": "specialized"}
+{"text": "招标资料里的电商 LLM 应用场景有哪些", "label": "specialized"}
+{"text": "实战里的商品评论摘要生成是用什么模型", "label": "specialized"}
+{"text": "我们电商项目里的多模态商品理解是怎么实现的", "label": "specialized"}
+{"text": "评审专家说的个性化搜索排序是怎么做的", "label": "specialized"}
+{"text": "招标资料里的 RAG 系统怎么处理多轮对话上下文", "label": "specialized"}
+{"text": "实战里的 QA Agent 记忆摘要是用 DeepSeek 压缩的吗", "label": "specialized"}
+{"text": "我们招标资料里的 RAG 问答节点有几个", "label": "specialized"}
+{"text": "评审专家讲的 RAG 低置信度时怎么走 Web 搜索兜底", "label": "specialized"}
+{"text": "招标资料里的 TenderAgent 整体架构图在哪里", "label": "specialized"}
+{"text": "实战里的四大 Agent 是共用一个 LLM 实例吗", "label": "specialized"}
+{"text": "我们招标资料里的 FastAPI 接口是怎么注册 Router 的", "label": "specialized"}
+{"text": "评审专家演示的 SSE 推流格式是什么样的", "label": "specialized"}
+{"text": "招标资料里的 JWT 鉴权中间件在哪里挂载", "label": "specialized"}
+{"text": "实战里的 agent_type 有哪些取值", "label": "specialized"}
+{"text": "我们招标资料里的 PostgreSQL 建表脚本叫什么", "label": "specialized"}
+{"text": "招标资料里的多租户隔离是靠 tenant_id 实现的吗", "label": "specialized"}
+{"text": "实战项目里的 migrations.py 是怎么幂等执行的", "label": "specialized"}
+{"text": "我们招标资料里的 RAG 系统是如何处理 PDF 的", "label": "specialized"}
+{"text": "评审专家说的 BGE-M3 dense 和 sparse 维度分别是多少", "label": "specialized"}
+{"text": "招标资料里的 HNSW 建图参数是怎么选的", "label": "specialized"}
+{"text": "实战里的 Milvus 是用 docker-compose 启动的吗", "label": "specialized"}
+{"text": "我们招标资料里的 PostgreSQL 端口为什么不是默认的 5432", "label": "specialized"}
+{"text": "评审专家演示的 seed_data.py 初始化了哪些测试数据", "label": "specialized"}
+{"text": "招标资料里的 Langfuse 集成是可选的吗", "label": "specialized"}
+{"text": "实战项目里的 verify_env.py 检查了哪些依赖", "label": "specialized"}
+{"text": "我们招标资料里的 conda 环境名叫什么", "label": "specialized"}
+{"text": "评审专家讲的 langchain==1.2.10 为什么要锁版本", "label": "specialized"}
+{"text": "招标资料里的 init_chat_model 为什么不能用 deepseek/ 前缀", "label": "specialized"}
+{"text": "实战里的 MCP 知识库服务器在哪个文件", "label": "specialized"}
+{"text": "我们招标资料里的 RAG 项目 Milvus 端口是多少", "label": "specialized"}
+{"text": "评审专家说的 TenderAgent 支持哪些国产大模型", "label": "specialized"}
+{"text": "招标资料里的 operation_logs 表记录了什么信息", "label": "specialized"}
+{"text": "实战里的异步 SQLAlchemy 是怎么配置连接池的", "label": "specialized"}
+{"text": "我们招标资料里的 RAG Agent 怎么做多路召回的", "label": "specialized"}
+{"text": "评审专家演示的电商项目评论数据是从哪里获取的", "label": "specialized"}
+{"text": "招标资料里的电商商品分类任务用了几层神经网络", "label": "specialized"}
+{"text": "实战里的 Agent 结果为什么要持久化到数据库", "label": "specialized"}
+{"text": "我们招标资料里答辩 Agent 的打分维度有哪些", "label": "specialized"}
+{"text": "评审专家说的资质材料 Agent 是并行还是串行打分的", "label": "specialized"}
+{"text": "招标资料里的 BGE-Reranker 加载失败时怎么降级", "label": "specialized"}
+{"text": "实战项目里的 config.py 里有哪些必填项", "label": "specialized"}
+{"text": "我们招标资料里的 LangGraph 版本是多少", "label": "specialized"}
+{"text": "评审专家讲的 pydantic-settings 是怎么读取 .env.local 的", "label": "specialized"}
+{"text": "招标资料里的 AsyncSessionLocal 是在哪里创建的", "label": "specialized"}
+{"text": "实战里的 bcrypt 密码加密在哪里用的", "label": "specialized"}
+{"text": "我们招标资料里的 RAG 分块大小默认是多少", "label": "specialized"}
+{"text": "评审专家说的 document_id 为什么是幂等更新的键", "label": "specialized"}
+{"text": "招标资料里的 SparseFLOATVector 是什么格式存进 Milvus 的", "label": "specialized"}
+{"text": "实战项目里的 QA 问答接口是 POST 还是 GET", "label": "specialized"}
+{"text": "我们招标资料里的 Agent 图是在 lifespan 里初始化的吗", "label": "specialized"}
+{"text": "评审专家演示的 TenderAgent 前端框架是 Vue3 吗", "label": "specialized"}
+{"text": "招标资料里的电商 Agent 是如何处理用户投诉的", "label": "specialized"}
+{"text": "实战里的商品问答 Agent 用的是 RAG 还是直接 LLM", "label": "specialized"}
+{"text": "我们电商招标资料里的 LLM 推荐理由生成是怎么做的", "label": "specialized"}
+{"text": "评审专家说的电商项目 Agent 编排是 LangGraph 还是 LangChain", "label": "specialized"}
+{"text": "招标资料里的客服 Agent 怎么判断要不要转人工", "label": "specialized"}
+{"text": "实战项目里的电商多轮对话状态是怎么保存的", "label": "specialized"}
+{"text": "LlamaFactory 怎么做 Qwen VL 微调?", "label": "specialized"}

+ 61 - 0
TenderAgent完整代码/backend/core/exceptions.py

@@ -0,0 +1,61 @@
+# backend/core/exceptions.py
+# TenderAgent 统一异常体系:所有自定义异常都继承同一个基类,
+# 便于「一次捕获全部」以及按「可重试 / 不可重试」分类处理。
+
+
+class TenderAgentBaseError(Exception):
+    """所有 TenderAgent 自定义异常的基类(继承 Python 内置的 Exception)。
+    比普通异常多带两样上下文:是哪个 Agent 出的错、以及任意细节字典。"""
+    def __init__(self, message: str, agent_type: str = "", details: dict = None):
+        # message:错误描述文字;agent_type:哪个 Agent(qa/bid_review/...);details:额外细节
+        super().__init__(message)          # 调用父类 Exception 的初始化,把错误消息存好
+        self.agent_type = agent_type       # 记录出错的 Agent 类型,方便日志与排查
+        self.details = details or {}       # 记录细节字典;传 None 时用空字典 {} 兜底,避免后续访问报错
+
+
+class LLMAPIError(TenderAgentBaseError):
+    """大模型 API 调用失败(超时 / 限流 / 网络错误)。属于【可重试】异常。"""
+    pass                                   # 无需额外逻辑,直接继承基类的能力即可
+
+
+class AgentExecutionError(TenderAgentBaseError):
+    """Agent 业务逻辑执行失败。"""
+    pass
+
+
+class PipelineError(TenderAgentBaseError):
+    """多 Agent Pipeline(流水线编排)失败。"""
+    pass
+
+
+class IntentRouteError(TenderAgentBaseError):
+    """意图识别路由失败(没判断出该交给哪个 Agent)。"""
+    pass
+
+
+class MilvusConnectionError(TenderAgentBaseError):
+    """Milvus 向量库连接失败。属于【可重试】异常。"""
+    pass
+
+
+class FileParseError(TenderAgentBaseError):
+    """文件解析失败(Word / PDF)。"""
+    pass
+
+
+class InvalidInputError(TenderAgentBaseError):
+    """用户输入不合法。属于【不可重试】异常(重试也不会变合法)。"""
+    pass
+
+
+class AuthenticationError(TenderAgentBaseError):
+    """认证失败。属于【不可重试】异常。"""
+    pass
+if __name__ == '__main__':
+    # 抛出一个带上下文的异常,并用基类捕获
+    try:
+        raise LLMAPIError("DeepSeek 超时", agent_type="qa", details={"timeout": 30})
+    except Exception as e:
+        print("捕获:", type(e).__name__, "| msg:", e, "| agent:", e.agent_type, "| details:", e.details)
+
+    print("LLMAPIError 是 TenderAgentBaseError 子类:", issubclass(LLMAPIError, TenderAgentBaseError))

+ 401 - 0
TenderAgent完整代码/backend/core/knowledge_base.py

@@ -0,0 +1,401 @@
+import hashlib
+import time
+from dataclasses import dataclass, field
+from typing import Optional
+import os
+backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+print(f'backend_path: {backend_path}')
+
+from backend.config import get_settings
+from backend.core.logger import get_logger
+
+
+logger = get_logger(__name__)
+
+
+# ──────────────────────────────────────────────────────────────
+# BGE-M3 本地嵌入模型(进程内单例,dense + sparse 双输出)
+# ──────────────────────────────────────────────────────────────
+
+class BGEMEmbedder:
+    """
+    BGE-M3 本地嵌入模型单例。
+
+    一次推理同时输出:
+      - dense 向量(1024 维浮点数组,用于语义相似度检索)
+      - sparse 向量({token_id: weight} 字典,用于关键词精确检索)
+
+    进程内单例:首次调用 get_instance() 时加载模型(约5-15秒),
+    后续调用直接返回同一实例,不重复加载。
+
+    用法:
+        embedder = BGEMEmbedder.get_instance()
+        dense, sparse = embedder.encode_query("什么是 Spring IOC?")
+    """
+
+    _instance: Optional["BGEMEmbedder"] = None   # 单例持有
+
+    def __init__(self, model_path: str):
+        # ── 兼容性补丁:FlagEmbedding 1.3.x 依赖 transformers 内部函数 ──
+        # transformers>=5.0 移除了 is_torch_fx_available,
+        # 但当前锁定 transformers==4.51.0 不受影响。
+        # 此补丁作为保险,避免未来升级时报 ImportError。
+        import importlib.util as _ilu
+        from transformers.utils import import_utils as _tf_iu
+        if not hasattr(_tf_iu, "is_torch_fx_available"):
+            _tf_iu.is_torch_fx_available = (
+                lambda: _ilu.find_spec("torch.fx") is not None
+            )
+
+        import torch
+        from FlagEmbedding import BGEM3FlagModel
+
+        logger.info("bge_m3.loading", model_path=model_path)
+
+        # ── fp16 仅在 CUDA 上启用,MPS(Apple M系列)不启用 ──
+        # MPS 在 BGE-M3 attention 矩阵乘法上会触发 LLVM ERROR,
+        # CPU 模式下用 fp32,速度稍慢但稳定。
+        # _device = "cuda:0" if torch.cuda.is_available() else "cpu"
+        _use_fp16 = torch.cuda.is_available()
+
+        self._model = BGEM3FlagModel(
+            model_name_or_path=model_path,
+            use_fp16=_use_fp16,
+            # device=_device,
+        )
+        logger.info("bge_m3.loaded", use_fp16=_use_fp16)
+
+    @classmethod
+    def get_instance(cls) -> "BGEMEmbedder":
+        """获取单例(首次调用时加载模型,后续复用)"""
+        if cls._instance is None:
+            bge_m3_model_path = os.path.join(backend_path, get_settings().bge_m3_model_path)
+            cls._instance = BGEMEmbedder(bge_m3_model_path)
+        return cls._instance
+    def encode(
+        self,
+        texts: list[str],
+        batch_size: int = 12,
+    ) -> tuple[list[list[float]], list[dict]]:
+        """
+        批量编码文本,同时返回 dense 和 sparse 两种向量。
+
+        Args:
+            texts:      待编码的文本列表
+            batch_size: 单次推理批大小,越大速度越快但显存占用越多;
+                        12 是 16GB 显存 / 统一内存下的经验值
+
+        Returns:
+            (dense_vecs, sparse_vecs)
+              dense_vecs:  list of 1024-dim float 向量,每项对应 texts[i]
+              sparse_vecs: list of {token_id: weight} 字典,每项对应 texts[i]
+        """
+        output = self._model.encode(
+            texts,
+            batch_size=batch_size,
+            max_length=8192,            # BGE-M3 支持最长 8192 token,覆盖大多数 chunk
+            return_dense=True,          # 输出稠密语义向量
+            return_sparse=True,         # 输出稀疏关键词向量
+            return_colbert_vecs=False,  # ColBERT 多向量表示,本项目不用
+        )
+        # print(f'output: {output}')
+        # print("=="*40)
+        dense_vecs = output["dense_vecs"].tolist()   # numpy → Python list
+        # print(f'dense_vecs: {dense_vecs}')
+        # print("==" * 40)
+        # sparse: numpy.float16 → Python float
+        # 必须转换!LangGraph MemorySaver 用 msgpack 序列化 State,
+        # msgpack 不支持 numpy.float16,会在运行时抛 TypeError。
+        sparse_vecs = [{int(k): float(v) for k, v in d.items()}
+                       for d in output["lexical_weights"]]
+        # print(f'sparse_vecs: {sparse_vecs}')
+        return dense_vecs, sparse_vecs
+
+    def encode_query(self, text: str) -> tuple[list[float], dict]:
+        """
+        编码单条查询,返回 (dense_vec, sparse_vec)。
+
+        查询时调用此方法(而非 encode),batch_size=1 避免不必要的 padding。
+
+        Returns:
+            (dense_vec, sparse_vec)
+              dense_vec:  1024-dim float 列表
+              sparse_vec: {token_id: weight} 字典
+        """
+        dense_list, sparse_list = self.encode([text], batch_size=1)
+        return dense_list[0], sparse_list[0]
+
+
+@dataclass
+class DocumentChunk:
+    """
+    准备写入 Milvus 的单个文档块,字段与 Milvus Schema 一一对应。
+
+    id:               全局唯一 ID(MD5 of content + document_id + chunk_index)
+    content:          chunk 文本(Contextual RAG 模式下含 LLM 生成的上下文描述前缀)
+    embedding:        Dense 向量(BGE-M3,1024 维)
+    sparse_embedding: Sparse 向量({token_id: weight},BGE-M3 lexical weights)
+    source_name:      来源标注(检索结果展示用,如 "Java讲义 > 第3章 > 3.1 IOC")
+    """
+    id:               str
+    content:          str
+    embedding:        list[float]
+    sparse_embedding: dict
+    tender_document_id:        str
+    document_id:      str
+    source_name:      str
+    chunk_type:       str                  # "text" / "code" / "table"
+    chunk_index:      int
+    version:          str
+    tenant_id:        str = "tenant_default"
+    updated_at:       int = field(default_factory=lambda: int(time.time()))
+
+def generate_chunk_id(content: str, document_id: str, chunk_index: int) -> str:
+    """
+    生成 chunk 全局唯一 ID(MD5 散列)。
+
+    用 document_id + chunk_index + content 前缀组合,确保:
+    - 同一文档不同位置的 chunk 不冲突
+    - 内容不变时 ID 稳定(幂等重建时不会重复插入)
+
+    注意:此函数后续会作为 KnowledgeBaseClient 的静态方法重新出现(5.6),
+    届时 build_knowledge_base.py 会改为调用 KnowledgeBaseClient.generate_chunk_id()。
+    """
+    raw = f"{document_id}_{chunk_index}_{content[:50]}"
+    # print(f'raw: {raw}')
+    return hashlib.md5(raw.encode()).hexdigest()
+
+
+import hashlib
+import time
+from dataclasses import dataclass, field
+from typing import Optional
+
+from pymilvus import MilvusClient, AnnSearchRequest, WeightedRanker
+
+from backend.config import get_settings
+from backend.core.logger import get_logger,configure_logging
+configure_logging()
+logger = get_logger(__name__)
+
+COLLECTION_NAME = "knowledge_domain"
+
+
+class KnowledgeBaseClient:
+    """
+    Milvus 知识库客户端(MilvusClient 版)。
+
+    单 Collection 设计(knowledge_domain),按 tenant_id 字段过滤实现多租户隔离。
+    本节实现写入方法,5.6 节追加检索方法。
+
+    单例连接:_client 是类变量,整个进程只创建一次 MilvusClient 连接。
+    """
+
+    _client: Optional["MilvusClient"] = None
+    _loaded: bool = False
+
+    # HNSW 搜索时的候选集大小,精度/速度平衡点(5.6 节 _hybrid_search 使用)
+    ANN_EF = 64
+
+    def __init__(self):
+        if KnowledgeBaseClient._client is None:
+            settings = get_settings()
+            uri = f"http://{settings.milvus_host}:{settings.milvus_port}"
+            KnowledgeBaseClient._client = MilvusClient(uri=uri)
+            logger.info("milvus.connected", uri=uri)
+
+        if not KnowledgeBaseClient._loaded:
+            try:
+                KnowledgeBaseClient._client.load_collection(COLLECTION_NAME)
+            except Exception:
+                pass   # init_milvus.py 尚未运行时忽略
+            KnowledgeBaseClient._loaded = True
+
+    # ── 写入:批量 Upsert ────────────────────────────────────
+
+    def upsert_chunks(self, chunks: list) -> int:
+        """
+        批量写入文档块(Upsert:primary key 存在则更新,不存在则插入)。
+
+        MilvusClient 行格式写入:每行一个 dict,key = 字段名,字段顺序无关。
+        """
+        if not chunks:
+            return 0
+        # print(f'len(chunks):{len(chunks)}')
+        # print(f'chunks[0]:{chunks[0]}')
+        data = [
+            {
+                "id":               c.id,
+                "embedding":        c.embedding,
+                "sparse_embedding": c.sparse_embedding,
+                "content":          c.content[:4096],
+                "chunk_index":      c.chunk_index,
+                "document_id":      c.document_id,
+                "tender_document_id":        c.tender_document_id,
+                "tenant_id":        c.tenant_id,
+                "source_name":      c.source_name,
+                "chunk_type":       c.chunk_type,
+                "version":          c.version,
+                "updated_at":       c.updated_at,
+            }
+            for c in chunks
+        ]
+
+        self._client.upsert(collection_name=COLLECTION_NAME, data=data)
+        logger.info("knowledge_base.chunks_upserted", count=len(chunks))
+        return len(chunks)
+
+        # ── 写入:删除指定文档的所有 chunk ──────────────────────
+
+    def delete_document_chunks(self, document_id: str) -> None:
+        """
+        删除指定文档的所有 chunk(文档更新时先删后插,幂等重建)。
+        对 document_id 转义,防止 filter 表达式注入。
+        """
+        safe_id = document_id.replace('"', '\\"')
+        self._client.delete(
+            collection_name=COLLECTION_NAME,
+            filter=f'document_id == "{safe_id}"',
+        )
+        logger.info("knowledge_base.document_deleted", document_id=document_id)
+
+    @staticmethod
+    def generate_chunk_id(content: str, document_id: str, chunk_index: int) -> str:
+        """生成 chunk 唯一 ID(MD5)。内容+位置不变则 ID 不变,支持幂等 upsert。"""
+        raw = f"{document_id}_{chunk_index}_{content[:50]}"
+        return hashlib.md5(raw.encode()).hexdigest()
+
+        # ── 检索配置 ─────────────────────────────────────────────
+
+    VECTOR_TOP_K = 10  # Hybrid 召回的候选数量,传给 Reranker 精排
+
+    def _hybrid_search(
+            self,
+            query_embedding: list[float],
+            query_sparse: dict,
+            top_k: int,
+            filters: Optional[str] = None,
+    ) -> list[dict]:
+        """
+        对 knowledge_domain 做 Hybrid 检索(Dense + Sparse → WeightedRanker 融合)。
+
+        两个 AnnSearchRequest 分别构造 Dense 和 Sparse 检索请求,
+        由 Milvus 在服务端并行执行后,用 WeightedRanker 加权融合排序。
+
+        Args:
+            query_embedding: Dense Query 向量(1024 维,来自 encode_query)
+            query_sparse:    Sparse Query 向量({token_id: weight},来自 encode_query)
+            top_k:           每路召回数量(融合后同样取 top_k)
+            filters:         Milvus bool 表达式,如 'tenant_id == "xxx"'
+
+        Returns:
+            候选文档列表,每项含 "content" / "score" / "metadata"。
+            score 是 WeightedRanker 的加权排序信号,不是概率,
+            直接交给 5.7 节的 Reranker 做精细打分。
+        """
+        try:
+            # ── Dense ANN 检索请求 ─────────────────────────────────────
+            # COSINE 度量匹配 BGE-M3 dense 向量(L2 归一化后等价于余弦相似度)
+            # ef=64:HNSW 搜索时的候选集大小,越大精度越高,64 是精度/速度平衡点
+            dense_req = AnnSearchRequest(
+                data=[query_embedding],
+                anns_field="embedding",
+                param={
+                    "metric_type": "COSINE",
+                    "params": {"ef": self.ANN_EF},
+                },
+                limit=top_k,
+                expr=filters,
+            )
+
+            # ── Sparse 关键词检索请求 ──────────────────────────────────
+            # IP(内积)是 BGE-M3 lexical_weights 的标准度量
+            sparse_req = AnnSearchRequest(
+                data=[query_sparse],
+                anns_field="sparse_embedding",
+                param={"metric_type": "IP"},
+                limit=top_k,
+                expr=filters,
+            )
+
+            output_fields = [
+                "content", "source_name", "chunk_type",
+                "tender_document_id", "document_id", "chunk_index",
+            ]
+
+            # ── WeightedRanker(0.7, 0.3) ──────────────────────────────
+            # 第一个权重对应第一个请求(Dense),第二个对应第二个请求(Sparse)
+            # 两路结果在 Milvus 服务端并行检索,融合后返回
+            results = self._client.hybrid_search(
+                collection_name=COLLECTION_NAME,
+                reqs=[dense_req, sparse_req],
+                ranker=WeightedRanker(0.7, 0.3),
+                limit=top_k,
+                output_fields=output_fields,
+            )
+            # print(f'results: {results}')
+            # print(f'results: {type(results)}')
+            # print(f'results: {results[0]}')
+            # print(f'results: {len(results[0])}')
+            candidates = []
+            for hit in results[0]:
+                # print(f'hit-->{hit}')
+                candidates.append({
+                    "content": hit["entity"].get("content") or "",
+                    "score":   hit.get("distance") or 0.0,
+                    "metadata": {
+                        "source_name": hit["entity"].get("source_name") or "",
+                        "chunk_type":  hit["entity"].get("chunk_type")  or "text",
+                        "tender_document_id":   hit["entity"].get("tender_document_id")   or "",
+                        "document_id": hit["entity"].get("document_id") or "",
+                        "chunk_index": hit["entity"].get("chunk_index") or 0,
+                    },
+                })
+            logger.info(
+                "knowledge_base.hybrid_search_done",
+                candidates=len(candidates),
+            )
+            return candidates
+        except Exception as e:
+            logger.error("knowledge_base.hybrid_search_failed", error=str(e))
+            return []
+
+    @staticmethod
+    def _build_filter(tenant_id: str, tender_document_id: Optional[str] = None) -> str:
+        """
+        构建 Milvus bool 过滤表达式。
+        对 tenant_id / tender_document_id 做转义,防止 filter 表达式注入。
+        """
+        safe_tenant = tenant_id.replace('"', '\\"')
+        expr = f'tenant_id == "{safe_tenant}"'
+        if tender_document_id:
+            safe_tender_document = tender_document_id.replace('"', '\\"')
+            expr += f' and tender_document_id == "{safe_tender_document}"'
+        return expr
+if __name__ == '__main__':
+    # bge_model = BGEMEmbedder(model_path = "/Users/ligang/Desktop/LiveTenderAgent/backend/models/embedding/bge-m3")
+    # print(bge_model._model)
+    # # 验证设备
+    # device = next(bge_model._model.model.parameters()).device
+    # print(f'device: {device}')
+    # model = BGEMEmbedder.get_instance()
+    # dense, sparse = model.encode_query(text="商品聚合多模态大模型项目主要讲的是什么内容")
+    # # print(f'result: {len(result[0])}')
+    # # print("*"*80)
+    # # print(f'result: {result[1]}')
+    # # print(generate_chunk_id(content="AI是人工智能", document_id="AI", chunk_index=0))
+    kb = KnowledgeBaseClient()
+    # results = kb._hybrid_search(dense, sparse, top_k=5)
+    # print(f'results[0]: {results[0]}')
+
+    from scripts.build_knowledge_base import load_document,split_documents, embed_chunks
+    file_path = os.path.join(os.path.dirname(backend_path), "samples", "sample2.md")
+    # 加载文档
+    docs = load_document(file_path)
+    # 切分文档
+    chunks = split_documents(docs, file_path)
+    # 变成向量
+    embed_docs = embed_chunks(chunks, tender_document_id="01", document_id="02")
+    # 存储到集合中
+    kb.upsert_chunks(embed_docs)
+    # kb.delete_document_chunks(document_id="02")

+ 181 - 0
TenderAgent完整代码/backend/core/llm_factory.py

@@ -0,0 +1,181 @@
+# backend/core/llm_factory.py
+# LLM Factory:统一封装大模型调用,按 Agent 类型路由。
+# 规矩:所有 Agent 必须通过此模块获取模型,禁止直接调用 init_chat_model。
+
+from typing import Type, Any                          # 类型注解用:Type 表示「某个类本身」,Any 表示任意类型
+from pydantic import BaseModel                        # 结构化输出的 Schema 都是它的子类
+import httpx                                          # HTTP 客户端库(用来自定义网络行为)
+from langchain.chat_models import init_chat_model     # 2.3 学的:创建聊天模型(1.x 写法)
+from langchain_core.language_models import BaseChatModel  # 聊天模型的基类(类型注解用)
+from langchain_core.runnables import Runnable         # 「可运行对象」基类,结构化模型属于它
+
+from backend.config import get_settings               # 读配置(API Key、base_url 等)
+from backend.core.logger import get_logger            # 结构化日志
+
+logger = get_logger(__name__)                         # 本模块的日志器,name 用当前模块名
+
+# ── 自定义 httpx 客户端:绕过系统代理 ───────────────────────────
+# 背景:Windows 系统代理或 HTTPS_PROXY 环境变量会被 httpx 默认探测到,
+#       导致 DeepSeek 请求经代理后 TLS 握手失败。DeepSeek 国内可直连,无需代理。
+# trust_env=False 表示:完全忽略系统代理和相关环境变量。
+_HTTP_ASYNC_CLIENT = httpx.AsyncClient(               # 异步客户端(给 ainvoke/astream 用)
+    trust_env=False,
+    timeout=httpx.Timeout(120.0, connect=15.0),       # 总超时 120 秒,建立连接超时 15 秒
+)
+_HTTP_SYNC_CLIENT = httpx.Client(                     # 同步客户端(给 invoke 用)
+    trust_env=False,
+    timeout=httpx.Timeout(120.0, connect=15.0),
+)
+# ── Agent 类型 → 模型标识符 的路由表 ────────────────────────────
+# 想给某类业务换模型(对应模型厂商),只改这里一行即可。
+# 这里的_AGENT_MODEL_ROUTING键对应的是业务名称,值是每个业务选择的哪个模型厂商
+_AGENT_MODEL_ROUTING: dict[str, str] = {
+    "qa":               "deepseek-chat",   # 智能问答
+    "bid_review_subjective":  "deepseek-chat",   # 投标文件-方案响应项评分
+    "bid_review_code":        "deepseek-chat",   # 投标文件-技术方案项评分(coder 已并入 chat)
+    "qualification":           "deepseek-chat",   # 资质审查
+    "defense":        "deepseek-chat",   # 供应商答辩
+    "intent":           "deepseek-chat",   # 意图识别
+    "summarize":        "deepseek-chat",   # 对话摘要压缩
+}
+
+
+# # 模型标识符(实际模型) → DeepSeek API 实际接受的 model 名称
+_MODEL_ID_MAP: dict[str, str] = {
+    "deepseek-chat": "deepseek-v4-pro", # 这里面值:可以改成:deepseek_v4-pro
+}
+# 这里的_MODEL_ID_MAP键对应模型的厂商,值是真实的模型
+
+class LLMFactory:
+    """大模型工厂(统一获取模型的唯一入口)。
+    用 @classmethod 定义方法,意味着不用创建对象、直接用 LLMFactory.get_llm(...) 调用。
+
+    用法:
+        llm = LLMFactory.get_llm("qa")                              # 普通模型
+        structured = LLMFactory.get_structured_llm("qualification", 某Schema)  # 结构化输出模型
+        response = await llm.ainvoke(messages)
+    """
+
+    _instances: dict[str, BaseChatModel] = {}   # 类变量:模型实例缓存(缓存键 → 模型),全类共享
+
+    @classmethod
+    def _get_settings(cls):
+        """内部小工具:取配置对象。"""
+        return get_settings()
+
+
+
+    @classmethod
+    def _build_model_kwargs(cls, model_key: str) -> dict[str, Any]:
+        """内部方法:组装 init_chat_model 需要的所有参数(DeepSeek 走 OpenAI 兼容接口)。"""
+        settings = cls._get_settings()             # 取配置
+        model_id = _MODEL_ID_MAP[model_key]        # 把模型标识符转成 API 实际的 model 名
+
+        return {
+            "model": model_id,                     # 模型名,如 "deepseek-chat"
+            "model_provider": "openai",            # 强制走 langchain-openai(DeepSeek 兼容 OpenAI 接口)
+            "temperature": 0,                      # 默认 0:评分/评分要稳定输出
+            "api_key": settings.deepseek_api_key,  # 来自 .env.local
+            "base_url": settings.deepseek_base_url,# DeepSeek 接口地址
+            "max_retries": 0,                      # 模型层不重试;重试统一由 retry.py(3.5)管
+            "http_async_client": _HTTP_ASYNC_CLIENT,  # 用上面绕过代理的异步客户端
+            "http_client": _HTTP_SYNC_CLIENT,         # 同步客户端
+        }
+    @classmethod
+    def get_llm(
+        cls,
+        agent_type: str,             # Agent 类型,必须在路由表里
+        temperature: float = 0,      # 温度:对话类可传 0.3~0.7,评分类保持 0
+        streaming: bool = False,     # 是否流式输出(问答/答辩对话用)
+    ) -> BaseChatModel:
+        """按 Agent 类型获取模型实例(带缓存)。
+        相同 (模型, 温度, 是否流式) 的组合只会创建一次,之后复用。"""
+        if agent_type not in _AGENT_MODEL_ROUTING:        # 校验:不认识的类型直接报错(早暴露问题)
+            raise ValueError(
+                f"未知 agent_type: '{agent_type}',"
+                f"可用类型:{list(_AGENT_MODEL_ROUTING.keys())}"
+            )
+        model_key = _AGENT_MODEL_ROUTING[agent_type]  # 查路由表,拿到模型标识符(模型厂商)
+        # print(f'model_key: {model_key}')
+        # 用「模型_温度_是否流式」拼一个缓存键:不同组合各缓存一份
+        cache_key = f"{model_key}_{temperature}_{streaming}"
+        # print(f'cache_key: {cache_key}')
+        if cache_key not in cls._instances:  # 缓存里没有才新建
+            # print(f'cache_key: {cache_key}')
+            kwargs = cls._build_model_kwargs(model_key)  # 组装基础参数
+            # print(f'kwargs: {kwargs}')
+            kwargs["temperature"] = temperature           # 覆盖温度
+            kwargs["streaming"] = streaming               # 设置是否流式
+            kwargs["extra_body"] = {"thinking":{"type":"disabled"}}# 如果你想用deepseek-v4-pro/deepseek-v4-flash,可以强制关闭思考模式
+            # print(f'kwargs: {kwargs}')
+            llm = init_chat_model(**kwargs)               # 真正创建模型(** 表示把字典展开成关键字参数)
+            cls._instances[cache_key] = llm               # 存进缓存
+            logger.info(  # 记一条结构化日志,便于观察
+                "llm_factory.model_initialized",
+                agent_type=agent_type, model_key=model_key,
+                temperature=temperature, streaming=streaming,
+            )
+        return cls._instances[cache_key]
+
+    @classmethod
+    def get_structured_llm(
+        cls,
+        agent_type: str,
+        output_schema: Type[BaseModel],   # 期望的输出结构(一个 Pydantic 模型类)
+        temperature: float = 0,
+    ) -> Runnable:
+        """获取「绑定了结构化输出 Schema」的模型。
+        调用它的 ainvoke 后,直接返回一个 output_schema 类型的对象(不是文本)。"""
+        llm = cls.get_llm(agent_type, temperature=temperature)             # 先拿普通模型
+        # 绑定 Pydantic 结构;method="function_calling" 是 DeepSeek 必须的(回顾 2.3)
+        return llm.with_structured_output(output_schema, method="function_calling")
+
+    @classmethod
+    def clear_cache(cls) -> None:
+        """清空模型实例缓存(测试时用)。"""
+        cls._instances.clear()
+        logger.info("llm_factory.cache_cleared")
+
+# ── 模块级便捷函数(Agent 代码里的推荐写法)────────────────────────
+# 比写 LLMFactory.get_llm(...) 更简洁,直接 from llm_factory import get_llm 即可。
+
+def get_llm(agent_type: str, temperature: float = 0, streaming: bool = False) -> BaseChatModel:
+    """LLMFactory.get_llm 的便捷入口。"""
+    return LLMFactory.get_llm(agent_type, temperature=temperature, streaming=streaming)
+
+
+def get_structured_llm(agent_type: str, output_schema: Type[BaseModel]) -> Runnable:
+    """LLMFactory.get_structured_llm 的便捷入口。"""
+    return LLMFactory.get_structured_llm(agent_type, output_schema)
+
+if __name__ == '__main__':
+
+    import asyncio
+    from langchain_core.messages import HumanMessage, SystemMessage
+    from pydantic import Field
+
+    async def main():
+        # llm = LLMFactory.get_llm("qa")  # 通过工厂拿模型
+        # resp = await llm.ainvoke([HumanMessage(content="用一句话介绍 Python")])
+        # print("DeepSeek 回复:", resp.text)  # .text 取文本(回顾 2.3)
+        class PersonInfo(BaseModel):
+            name: str = Field(description="姓名")
+            age: int = Field(description="年龄(整数)")
+
+        llm_struct = get_structured_llm("qa", PersonInfo)
+        messages = [SystemMessage(content="你负责从文本中抽取任务信息"),
+                    HumanMessage(content="我叫小明,今年25岁")]
+
+        result = await llm_struct.ainvoke(messages)
+        print(f"{result}")
+        print(f"{result.name}")
+        print(f"{result.model_dump()}")
+
+
+    asyncio.run(main())
+
+
+
+
+
+

+ 96 - 0
TenderAgent完整代码/backend/core/logger.py

@@ -0,0 +1,96 @@
+# backend/core/logger.py
+# 全项目的日志工具:在标准库 logging 之上做一层包装,
+# 支持「事件名 + 键值对」的结构化日志写法。
+
+import logging                            # Python 标准库的日志模块(你用过的那个)
+import sys                                # 用于把日志输出到标准输出 stdout
+import os
+# print(sys.path)
+tenderagents_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+# print(f'tenderagents_path-->{tenderagents_path}')
+sys.path.append(tenderagents_path)
+from backend.config import get_settings   # 读取配置(需要里面的 log_level 日志级别)
+
+
+class _Logger:
+    """日志包装类。
+    作用:让我们能用 logger.info("事件名", key=value) 这种「结构化」写法,
+    而不必每次手动拼字符串。内部仍委托给标准库 logging 真正输出。
+    (前缀下划线 _ 表示它是「内部类」,外部应通过下面的 get_logger() 来获取实例)"""
+
+    def __init__(self, name: str):
+        # 构造方法:name 通常传 __name__(当前模块名),这样日志里能看出是哪个模块打的
+        self._log = logging.getLogger(name)   # 拿到标准库的 logger 实例并存起来,后续都用它输出
+
+    def _fmt(self, event: str, **kw) -> str:
+        """内部方法:把「事件名 + 关键字参数」格式化成一行可读字符串。
+        例:_fmt("user.login", user_id="u1") → "user.login | user_id='u1'"
+        (**kw 表示接收任意多个 key=value 形式的关键字参数,收进一个字典 kw)"""
+        if kw:                                 # 如果传了关键字参数(即 key=value)
+            # 把每个键值对拼成 "key=value"({v!r} 表示用 repr 显示值,字符串会带引号),
+            # 再用空格连接,最前面加上事件名和分隔符 " | "
+            return event + " | " + " ".join(f"{k}={v!r}" for k, v in kw.items())
+        return event                           # 没有关键字参数时,只返回事件名本身
+
+    def debug(self, event: str, **kw):
+        """DEBUG 级别(最详细,调试用)"""
+        self._log.debug(self._fmt(event, **kw))     # 先格式化,再交给标准库以 debug 级别输出
+
+    def info(self, event: str, *args, **kw):
+        """INFO 级别(常规信息)。
+        兼容两种写法:① logger.info("事件", key=value);② logger.info("模板 %s", 值)。
+        (*args 接收任意多个「位置参数」,用于支持第二种老写法)"""
+        if args:                               # 如果传了位置参数,说明是「模板 + 值」的老写法
+            self._log.info(event, *args)       # 直接交给标准库做 % 占位符格式化
+        else:                                  # 否则按结构化写法处理
+            self._log.info(self._fmt(event, **kw))
+
+    def warning(self, event: str, *args, **kw):
+        """WARNING 级别(警告:不影响运行但需注意)"""
+        if args:
+            self._log.warning(event, *args)
+        else:
+            self._log.warning(self._fmt(event, **kw))
+
+    def error(self, event: str, **kw):
+        """ERROR 级别(错误)。支持传 exc_info=True 把异常堆栈一并打印。"""
+        exc_info = kw.pop("exc_info", False)   # 从 kw 中取出 exc_info 参数(没有则默认 False),并移除它
+        self._log.error(self._fmt(event, **kw), exc_info=exc_info)
+
+    def critical(self, event: str, **kw):
+        """CRITICAL 级别(严重错误)"""
+        self._log.critical(self._fmt(event, **kw))
+
+
+def configure_logging() -> None:
+    """全局日志配置函数:整个应用只在启动时(main.py)调用一次。
+    负责设定日志格式、级别,并压低第三方库的噪音日志。"""
+    settings = get_settings()                  # 读配置,拿到 log_level(如 "INFO")
+    # getattr(logging, "INFO", 兜底) 把字符串级别转成 logging 模块里的常量 logging.INFO;
+    # .upper() 先转大写;万一配置写错找不到该常量,就用 logging.INFO 兜底
+    level = getattr(logging, settings.log_level.upper(), logging.INFO)
+    logging.basicConfig(                       # 标准库的「一键全局配置」
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",  # 格式:时间 [级别] 模块名: 内容
+        stream=sys.stdout,                     # 把日志打到标准输出(控制台)
+        level=level,                           # 全局生效的日志级别
+        force=True,                            # 强制覆盖已有的日志配置(否则重复调用 basicConfig 会失效)
+    )
+    # 下面把几个第三方库的日志级别单独调到 WARNING,屏蔽它们大量的 INFO 噪音,避免刷屏
+    logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)  # SQLAlchemy 的 SQL 执行日志
+    logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)    # SQLAlchemy 连接池日志
+    logging.getLogger("httpx").setLevel(logging.WARNING)              # httpx HTTP 客户端日志
+    logging.getLogger("httpcore").setLevel(logging.WARNING)           # httpcore 底层日志
+
+
+def get_logger(name: str) -> _Logger:
+    """对外的工厂函数:每个模块用 get_logger(__name__) 拿到自己的日志器。"""
+    return _Logger(name)                       # 返回一个 _Logger 实例(包装了标准库 logger)
+if __name__ == '__main__':
+    configure_logging()  # 全局配置一次
+    log = get_logger("demo")
+    # log.info("user.login", user_id="u1", role="bidder")
+    # log.warning("cache.miss", key="abc")
+    try:
+        result = 1/0
+    except Exception as e:
+        log.error("计算错误", operation="divide", exc_info=True)

+ 202 - 0
TenderAgent完整代码/backend/core/memory.py

@@ -0,0 +1,202 @@
+# backend/core/memory.py
+
+from typing import Optional
+from langgraph.checkpoint.memory import MemorySaver
+from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
+
+from backend.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+# 每个 Agent 独立的 MemorySaver 实例
+# 不同 Agent 的 State schema 不同,共用同一个 MemorySaver 会导致
+# msgpack 序列化时 schema 字段冲突,必须隔离
+_memory_savers: dict[str, MemorySaver] = {}
+
+
+def get_memory_saver(agent_type: str = "default") -> MemorySaver:
+    """
+    获取指定 Agent 类型的 MemorySaver 单例。
+
+    本地阶段使用内存存储(进程重启后历史丢失)。
+    生产阶段替换为 AsyncPostgresSaver 即可持久化,业务代码无需修改。
+
+    Args:
+        agent_type: Agent 标识符,如 "qa" / "bid_review" / "qualification" / "defense"
+
+    Returns:
+        MemorySaver 实例,传给 StateGraph.compile(checkpointer=...)
+    """
+    if agent_type not in _memory_savers:
+        _memory_savers[agent_type] = MemorySaver()
+        logger.info("memory.saver_initialized", agent=agent_type)
+    return _memory_savers[agent_type]
+
+
+def build_thread_id(bidder_id: str, session_id: str) -> str:
+    """
+    构建 LangGraph Checkpointer 使用的 thread_id。
+
+    格式:bidder_{bidder_id}_session_{session_id}
+    同一投标人的不同会话有独立的历史,互不干扰。
+
+    BidReviewple:
+        build_thread_id("abc123", "xyz789")
+        → "bidder_abc123_session_xyz789"
+    """
+    return f"bidder_{bidder_id}_session_{session_id}"
+
+
+def build_config(bidder_id: str, session_id: str) -> dict:
+    """
+    构建 LangGraph 调用所需的 config 字典。
+
+    用法:
+        config = build_config(bidder_id, session_id)
+        result = await graph.ainvoke(state, config=config)
+
+    Returns:
+        {"configurable": {"thread_id": "bidder_xxx_session_yyy"}}
+    """
+    return {
+        "configurable": {
+            "thread_id": build_thread_id(bidder_id, session_id),
+        }
+    }
+
+
+def trim_messages_to_window(
+    messages: list[BaseMessage],
+    window_size: int = 10,
+) -> list[BaseMessage]:
+    """
+    滑动窗口裁剪:保留最近 window_size 轮对话。
+    SystemMessage 始终保留在最前,不受 window_size 限制。
+
+    Args:
+        messages:    当前完整消息列表
+        window_size: 保留的对话轮数(1轮 = 1 Human + 1 AI),默认 10 轮
+
+    Returns:
+        裁剪后的消息列表
+    """
+    system_messages  = [m for m in messages if isinstance(m, SystemMessage)]
+    # print(f'system_messages: {system_messages}')
+    dialogue_messages = [m for m in messages if not isinstance(m, SystemMessage)]
+    # print(f'dialogue_messages: {dialogue_messages}')
+    max_dialogue_messages = window_size * 2   # 1轮 = 2条消息
+
+    if len(dialogue_messages) <= max_dialogue_messages:
+        return messages   # 未超出窗口,不裁剪
+
+    trimmed = dialogue_messages[-max_dialogue_messages:]
+
+    logger.info(
+        "memory.window_trimmed",
+        original=len(dialogue_messages),
+        kept=len(trimmed),
+        window_size=window_size,
+    )
+
+    return system_messages + trimmed
+
+def should_trigger_summary(
+    messages: list[BaseMessage],
+    threshold: int = 10,
+) -> bool:
+    """
+    判断对话轮数是否超过阈值,决定是否触发摘要压缩。
+
+    Args:
+        messages:  当前消息列表
+        threshold: 触发压缩的轮数阈值,默认 10 轮
+
+    Returns:
+        True → 需要压缩
+    """
+    dialogue_count = sum(1 for m in messages if isinstance(m, (HumanMessage, AIMessage)))
+    # print(f'dialogue_count: {dialogue_count}')
+    # rounds = dialogue_count // 2 # 每轮2条
+    #
+    return dialogue_count % ( threshold * 2 ) == 0 # 每轮 2 条,dialogue_count / 2 = 实际轮数
+
+async def compress_to_summary(
+    messages: list[BaseMessage],
+    existing_summary: Optional[str] = None,
+) -> str:
+    """
+    将历史对话压缩为结构化投标人画像摘要。
+
+    增量压缩:传入 existing_summary 防止已记录内容被重复写入。
+
+    Args:
+        messages:         待压缩的历史消息列表
+        existing_summary: 上次的摘要(可选)
+
+    Returns:
+        压缩后的摘要文本
+    """
+    from langchain_core.messages import HumanMessage as LCHuman
+    from backend.core.llm_factory import get_llm
+
+    SUMMARY_PROMPT = """请将以下投标人对话压缩为结构化投标人画像摘要。
+
+【压缩规则】
+必须保留:投标人明确不理解的评审要点 / 反复出现的薄弱点 / 项目背景新增信息
+选择性保留:已掌握评审要点(简短标注)/ 学习进度信息
+可以丢弃:已在上次摘要记录的内容 / 闲聊 / 已解决且理解的问题
+
+【上一次摘要】
+{previous_summary}
+
+【本次新增对话】
+{new_conversations}
+
+请直接输出摘要文本,不要加任何前缀。"""
+
+    conversation_text = "\n".join([
+        f"{'投标人' if isinstance(m, HumanMessage) else 'AI'}:{m.text if hasattr(m, 'text') and not callable(m.text) else str(m.content)}"
+        for m in messages
+        if isinstance(m, (HumanMessage, AIMessage))
+    ])
+
+    prompt_text = SUMMARY_PROMPT.format(
+        previous_summary=existing_summary or "(无上次摘要)",
+        new_conversations=conversation_text,
+    )
+
+    llm = get_llm("summarize")
+    response = await llm.ainvoke([LCHuman(content=prompt_text)])
+    summary = (
+        response.content if isinstance(response.content, str)
+        else str(response.content)
+    ).strip()
+
+    logger.info(
+        "memory.summary_generated",
+        input_messages=len(messages),
+        summary_length=len(summary),
+    )
+    return summary
+
+if __name__ == '__main__':
+    # print(build_config(bidder_id="abc123", session_id="xyz789"))
+    # print(get_memory_saver(agent_type="qa"))
+    # ── 测试 2:滑动窗口裁剪 ─────────────────────────────────────
+    messages = [SystemMessage(content="你是评审助手")]
+    print(messages[0].text)
+    print(messages[0].content)
+    for i in range(1, 7):  # 模拟 6 轮对话
+        messages.append(HumanMessage(content=f"问题{i}"))
+        messages.append(AIMessage(content=f"回答{i}"))
+    # # print(f'messages: {messages}')
+    # # trimmed = trim_messages_to_window(messages, window_size=3)
+    # # print(f'trimmed: {trimmed}')
+    # print(should_trigger_summary(messages, threshold=10))
+
+    import asyncio
+    async def main():
+        if should_trigger_summary(messages, threshold=3):
+            summary = await compress_to_summary(messages)
+            print(f'summary:{summary}')
+    asyncio.run(main())

+ 446 - 0
TenderAgent完整代码/backend/core/orchestrator.py

@@ -0,0 +1,446 @@
+# backend/core/orchestrator.py
+# Orchestrator:Agent 编排服务
+# 职责:接收 API 层请求 → 意图路由 → 单 Agent 直达 / 多 Agent 串联 Pipeline
+
+from __future__ import annotations
+
+import asyncio
+from enum import Enum
+from typing import Any, Optional
+from pydantic import BaseModel, Field
+from langchain_core.messages import HumanMessage, AIMessage
+
+from backend.core.logger import get_logger
+from backend.core.retry import with_retry          # 重试降级装饰器(来自 3.5,不在本章重讲)
+from backend.core.exceptions import (               # 统一异常体系(来自 3.3,不在本章重讲)
+    AgentExecutionError,
+    PipelineError,
+    IntentRouteError,
+)
+
+logger = get_logger(__name__)
+
+
+class ExecutionMode(str, Enum):
+    """执行模式:决定一个请求怎么跑。"""
+    SINGLE   = "single"     # 单 Agent 直达(最常见)
+    PIPELINE = "pipeline"   # 多 Agent 串联(如投标全链路)
+    CLARIFY  = "clarify"    # 澄清对话(意图不明,需追问)
+
+
+class AgentType(str, Enum):
+    """四个 Agent 的类型标识(继承 str,可直接当字符串用)。"""
+    QA        = "qa"         # 智能问答(第 5 章)
+    BID_REVIEW      = "bid_review"       # 投标文件评分(第 6 章)
+    QUALIFICATION    = "qualification"     # 资质审查(第 4 章)
+    DEFENSE = "defense"  # 供应商答辩(第 7 章)
+
+class AgentRequest(BaseModel):
+    """所有 Agent 请求的统一入参 Schema"""
+    bidder_id:    str = Field(..., description="发起请求的投标人 ID")
+    tenant_id:     str = Field(default="tenant_default", description="租户 ID")
+    session_id:    str = Field(..., description="会话 ID,用于 thread_id 拼接")
+    agent_type:    AgentType = Field(..., description="目标 Agent 类型")
+    user_message:  str = Field(..., description="用户输入的原始文本")
+    context:       dict[str, Any] = Field(default_factory=dict, description="附加上下文(文件路径/历史数据等)")
+    pipeline_mode: bool = Field(default=False, description="是否强制走串联 Pipeline")
+
+    @property
+    def thread_id(self) -> str:
+        """LangGraph Checkpointer 使用的线程 ID(与各 Agent 的 build_thread_id 格式一致)"""
+        return f"bidder_{self.bidder_id}_session_{self.session_id}"
+
+
+class AgentResponse(BaseModel):
+    """所有 Agent 响应的统一出参 Schema"""
+    success:       bool = Field(..., description="执行是否成功")
+    agent_type:    AgentType = Field(..., description="实际执行的 Agent 类型")
+    content:       str = Field(default="", description="主要文本响应内容")
+    structured:    Optional[dict[str, Any]] = Field(default=None, description="结构化数据(评分/报告等)")
+    fallback_used: bool = Field(default=False, description="是否触发了降级处理")
+    error_msg:     Optional[str] = Field(default=None, description="失败时的错误信息")
+    metadata:      dict[str, Any] = Field(default_factory=dict, description="附加元数据")
+
+
+class PipelineResult(BaseModel):
+    """多 Agent 串联 Pipeline 的聚合结果(8.3 用到)"""
+    steps:    list[AgentResponse] = Field(default_factory=list, description="各步骤结果列表")
+    combined: dict[str, Any] = Field(default_factory=dict, description="聚合后的最终数据")
+
+class Orchestrator:
+    """
+    Agent 编排服务。
+
+    职责:
+        - 单 Agent 模式:直接调用对应 Agent 的 LangGraph 图
+        - 串联 Pipeline 模式:按顺序调用多个 Agent,前序输出注入后序输入
+        - 澄清对话模式:返回结构化追问,等待用户补充信息后重新路由
+
+    所有 Agent 图在首次使用时懒加载,避免启动时全量加载占用资源。
+    """
+
+    def __init__(self):
+        # Agent 图注册表(懒加载,key=AgentType,value=编译后的 LangGraph)
+        self._agent_graphs: dict[AgentType, Any] = {}
+
+        # 串联 Pipeline 定义(触发条件 → Agent 执行顺序)
+        self._pipelines: dict[str, list[AgentType]] = {
+            "job_preparation": [AgentType.QUALIFICATION, AgentType.DEFENSE],  # 投标全链路:先审资质材料再答辩
+        }
+
+        logger.info("orchestrator.initialized")
+
+    def _get_agent_graph(self, agent_type: AgentType) -> Any:
+        """
+        懒加载 Agent 的 LangGraph 编译图。
+
+        读取/写入:self._agent_graphs(图缓存字典)
+        首次访问某 Agent 时才 import 并 build 它的图,之后复用缓存。
+        各 Agent 图在对应条款(第 4-7 章)已实现,此处只负责按需取用。
+        """
+        if agent_type not in self._agent_graphs:        # 缓存里没有 → 首次加载
+            if agent_type == AgentType.QA:
+                from backend.agents.qa.graph import build_qa_graph          # 第 5 章
+                self._agent_graphs[agent_type] = build_qa_graph()
+
+            elif agent_type == AgentType.BID_REVIEW:
+                from backend.agents.bid_review.graph import build_bid_review_graph      # 第 6 章
+                self._agent_graphs[agent_type] = build_bid_review_graph()
+
+            elif agent_type == AgentType.QUALIFICATION:
+                from backend.agents.qualification.graph import build_qualification_graph  # 第 4 章
+                self._agent_graphs[agent_type] = build_qualification_graph()
+
+            elif agent_type == AgentType.DEFENSE:
+                from backend.agents.defense.graph import build_defense_graph  # 第 7 章
+                self._agent_graphs[agent_type] = build_defense_graph()
+
+            else:
+                raise ValueError(f"未知 AgentType: {agent_type}")
+
+            logger.info(
+                "orchestrator.agent_graph_loaded",
+                agent_type=agent_type.value,
+            )
+
+        return self._agent_graphs[agent_type]           # 返回(缓存的)编译图
+
+    async def handle(self, request: AgentRequest) -> AgentResponse:
+        """
+        统一请求处理入口。
+
+        读取 request:pipeline_mode(决定单 Agent 还是 Pipeline)、agent_type、bidder_id
+        返回:AgentResponse(统一响应格式,无论成功失败都返回它,不向上抛异常)
+
+        流程:
+            1. 判断执行模式(单 Agent / Pipeline)
+            2. 分发到对应执行方法
+            3. 返回统一格式的 AgentResponse
+        """
+        logger.info(
+            "orchestrator.handle_start",
+            agent_type=request.agent_type.value,
+            bidder_id=request.bidder_id,
+            pipeline_mode=request.pipeline_mode,
+        )
+
+        try:
+            if request.pipeline_mode:                    # 强制串联模式
+                # 前端"投标全流程辅导"按钮等直接触发
+                result = await self._run_pipeline(request)        # 跑多 Agent(8.3)
+                return self._aggregate_pipeline(result, request)  # 聚合成统一响应(8.3)
+            else:                                        # 默认:单 Agent 直达
+                return await self._run_single_agent(request)
+
+        except Exception as e:                           # 兜底:任何异常都转成失败响应,不抛给上层
+            logger.error(
+                "orchestrator.handle_failed",
+                agent_type=request.agent_type.value,
+                error=str(e),
+                exc_info=True,
+            )
+            return AgentResponse(
+                success=False,
+                agent_type=request.agent_type,
+                content="系统处理请求时遇到问题,请稍后再试。",  # 给用户的友好兜底文案
+                error_msg=str(e),
+            )
+
+    async def _run_single_agent(self, request: AgentRequest) -> AgentResponse:
+        """
+        单 Agent 直达模式:直接调用目标 Agent 的 LangGraph 图。
+
+        读取 request:agent_type / user_message / bidder_id / tenant_id / session_id / context
+        返回:AgentResponse(从 Agent 图最终 State 提取 content + structured + fallback_used)
+        使用 with_retry 包装,自动处理重试和降级(来自 3.5)。
+        """
+        graph = self._get_agent_graph(request.agent_type)    # 懒加载取出目标 Agent 图
+
+        # 构建 LangGraph 输入 State(统一三件套 + context 展开)
+        initial_state = {
+            "messages": [HumanMessage(content=request.user_message)],  # 用户消息
+            "bidder_id": request.bidder_id,
+            "tenant_id": request.tenant_id,
+            "session_id": request.session_id,
+            **request.context,                           # 附加上下文(如文件路径、资质材料结果)平铺进 State
+        }
+
+        config = {                                       # LangGraph 运行配置
+            "configurable": {
+                "thread_id": request.thread_id,          # 用 thread_id 命中 MemorySaver 检查点
+            }
+        }
+
+        @with_retry(agent_type=request.agent_type.value)  # 给本次调用套上三层兜底(重试→降级→系统兜底)
+        async def _invoke():
+            return await graph.ainvoke(initial_state, config=config)  # 真正跑 Agent 图
+
+        result_state = await _invoke()                   # 执行(失败时 with_retry 自动处理)
+        # print(f'result_state66666 = {result_state}')
+        # 从最终 State 提取响应内容:取最后一条消息的文本
+        last_message = result_state["messages"][-1]
+        content = last_message.text if hasattr(last_message, "text") else str(last_message.content)
+
+        return AgentResponse(
+            success=True,
+            agent_type=request.agent_type,
+            content=content,                             # 主文本响应
+            structured=result_state.get("structured_output"),   # 结构化数据(报告/评分,若有)
+            fallback_used=result_state.get("fallback_used", False),  # 是否走了降级
+        )
+
+    async def _run_pipeline(self, request: AgentRequest) -> PipelineResult:
+        """
+        多 Agent 串联 Pipeline 模式。
+
+        读取 request:context["pipeline_key"](选哪条 Pipeline)、bidder_id 等
+        返回:PipelineResult(steps=各步 AgentResponse 列表)
+
+        前序 Agent 的 structured_output 自动注入后序 Agent 的 context,
+        已完成步骤的结果持久化保存,失败不丢弃前序成果。
+        """
+        # 根据 context 中的 pipeline_key 选择对应 Pipeline(默认投标全链路)
+        pipeline_key = request.context.get("pipeline_key", "job_preparation")
+
+        if pipeline_key not in self._pipelines:          # 未知 key → 抛 PipelineError(来自 3.3)
+            raise PipelineError(f"未知 Pipeline 类型: {pipeline_key}")
+
+        agent_sequence = self._pipelines[pipeline_key]   # 取出该 Pipeline 的 Agent 执行顺序
+        result = PipelineResult()                        # 准备收集各步结果
+
+        # 初始上下文:从请求的 context 拷一份,后续逐步往里塞前序结果
+        current_context = dict(request.context)
+
+        for idx, agent_type in enumerate(agent_sequence):  # 按序执行每个 Agent
+            # 为这一步构造独立的 AgentRequest(session_id 加 _step{idx} 后缀,避免检查点串台)
+            step_request = AgentRequest(
+                bidder_id=request.bidder_id,
+                tenant_id=request.tenant_id,
+                session_id=f"{request.session_id}_step{idx}",
+                agent_type=agent_type,
+                user_message=request.user_message,
+                context=current_context,                 # 带上累积的上下文(含前序结果)
+                pipeline_mode=False,                     # 单步内不再触发 Pipeline,防止递归
+            )
+
+            logger.info(
+                "orchestrator.pipeline_step_start",
+                step=idx + 1,
+                total=len(agent_sequence),
+                agent_type=agent_type.value,
+            )
+
+            step_response = await self._run_single_agent(step_request)  # 复用单 Agent 直达跑这一步
+            print(f'step_response77777={step_response}')
+            result.steps.append(step_response)           # 记录本步结果
+
+            if not step_response.success:                # 本步失败 → 保留已完成成果,终止后续
+                logger.warning(
+                    "orchestrator.pipeline_step_failed",
+                    step=idx + 1,
+                    agent_type=agent_type.value,
+                    error=step_response.error_msg,
+                )
+                break                                    # 不再跑后面的步骤(但前面的结果已保存)
+
+            # ★ 上下文传递:把本步的结构化输出注入累积上下文,供下一步使用
+            if step_response.structured:
+                current_context[f"{agent_type.value}_result"] = step_response.structured
+                logger.info(
+                    "orchestrator.pipeline_context_passed",
+                    from_agent=agent_type.value,
+                    keys=list(step_response.structured.keys()),
+                )
+
+                if agent_type == AgentType.QUALIFICATION:
+                    review_id = step_response.structured.get("review_id")
+                    if review_id:
+                        current_context["qualification_review_id"] = review_id  # Defense 期望的 key
+                    score = step_response.structured.get("weighted_score", 0)
+                    if score < 60:
+                        logger.info(
+                            "orchestrator.pipeline_threshold_not_met",
+                            score=score,
+                            threshold=60,
+                        )
+                        break  # 资质材料不合格,终止 Pipeline,不进入答辩
+
+        return result                                    # 返回各步聚合前的原始结果
+    def _aggregate_pipeline(
+        self,
+        pipeline_result: PipelineResult,                # 各步结果
+        request: AgentRequest,                          # 原始请求
+    ) -> AgentResponse:
+        """
+        聚合 Pipeline 各步骤结果,返回统一 AgentResponse。
+
+        读取 pipeline_result.steps(各步 AgentResponse)
+        返回 AgentResponse:content=各步文本拼接,structured=combined(含每步明细)
+        combined 字段包含所有步骤的结构化数据。
+        """
+        combined = {}                                    # 汇总每步的结构化明细
+        all_contents = []                                # 收集每步的文本,最后拼接
+        any_success = False                              # 只要有一步成功,整体就算部分成功
+
+        for idx, step in enumerate(pipeline_result.steps):
+            step_key = f"step_{idx + 1}"                 # step_1 / step_2 ...
+            combined[step_key] = {                       # 记录这一步的元信息
+                "agent_type": step.agent_type.value,
+                "success": step.success,
+                "structured": step.structured,
+            }
+            if step.success:
+                any_success = True
+                if step.content:
+                    all_contents.append(step.content)    # 成功且有文本 → 收进待拼接列表
+
+        return AgentResponse(
+            success=any_success,                         # 任一步成功即 True
+            agent_type=request.agent_type,
+            content="\n\n---\n\n".join(all_contents),    # 各步文本用分隔线拼成一段
+            structured=combined,                         # 结构化里带每步明细
+            fallback_used=any(s.fallback_used for s in pipeline_result.steps),  # 任一步降级即标记
+        )
+
+# ──────────────────────────────────────────────────────────────
+# 模块级单例(应用生命周期内复用)
+# ──────────────────────────────────────────────────────────────
+_orchestrator_instance: Optional[Orchestrator] = None    # 全局唯一实例(初始为空)
+
+
+def get_orchestrator() -> Orchestrator:
+    """获取 Orchestrator 单例(FastAPI 依赖注入使用)"""
+    global _orchestrator_instance
+    if _orchestrator_instance is None:                   # 第一次调用才创建
+        _orchestrator_instance = Orchestrator()
+    return _orchestrator_instance                        # 之后都返回同一个实例
+
+if __name__ == "__main__":
+    # 离线测试:Schema / 单例 / 懒加载缓存 / 聚合逻辑,无需启动任何服务
+    # 运行:conda activate edu_agent && python backend/core/orchestrator.py
+    def section(t):
+        print("\n" + "=" * 60 + f"\n  {t}\n" + "=" * 60)
+
+    # ── 测试①:AgentRequest schema + thread_id 属性 ─────────────
+    # section("① AgentRequest schema + thread_id")
+    req = AgentRequest(
+        bidder_id="stu-001",
+        session_id="sess-abc",
+        agent_type=AgentType.QA,
+        user_message="什么是AI?",
+    )
+    # assert req.tenant_id == "tenant_default",    "tenant_id 默认值错误"
+    # assert req.pipeline_mode is False,           "pipeline_mode 默认值错误"
+    # assert req.context == {},                    "context 默认值错误"
+    # assert req.thread_id == "bidder_stu-001_session_sess-abc"
+    # print(f"thread_id     : {req.thread_id}")
+    # print(f"tenant_id     : {req.tenant_id}")
+    # print(f"pipeline_mode : {req.pipeline_mode}")
+    #
+    # # ── 测试②:AgentResponse schema 默认值 ──────────────────────
+    # section("② AgentResponse schema 默认值")
+    # resp = AgentResponse(success=True, agent_type=AgentType.QA, content="回答")
+    # assert resp.fallback_used is False
+    # assert resp.structured is None
+    # assert resp.error_msg is None
+    # assert resp.content == "回答"
+    # print(f"fallback_used : {resp.fallback_used}")
+    # print(f"structured    : {resp.structured}")
+    # print(f"error_msg     : {resp.error_msg}")
+    #
+    # # ── 测试③:get_orchestrator 模块级单例 ──────────────────────
+    # section("③ get_orchestrator 单例")
+    # a = get_orchestrator()
+    # b = get_orchestrator()
+    # assert a is b, "单例返回了不同实例"
+    # print(f"a is b : {a is b}")
+    #
+    # # ── 测试④:_get_agent_graph 懒加载 + 缓存 ───────────────────
+    # section("④ _get_agent_graph 懒加载 + 缓存(需 env)")
+    orch = Orchestrator()
+    # # assert AgentType.QA not in orch._agent_graphs, "初始状态不应有缓存"
+    import asyncio
+    g1 = asyncio.run(orch.handle(req))
+    print("*"*80)
+    print(f'g1-->{g1}')
+    # import asyncio
+    #
+    # print(asyncio.run(g1))
+    # assert AgentType.QA in orch._agent_graphs,     "首次加载后应写入缓存"
+    # g2 = orch._get_agent_graph(AgentType.QA)
+    # assert g1 is g2,                               "第二次应命中缓存返回同一对象"
+    # print(f"首次加载写入缓存  : {AgentType.QA in orch._agent_graphs}")
+    # print(f"第二次命中缓存    : g1 is g2 = {g1 is g2}")
+    #
+    # # ── 测试⑤:_aggregate_pipeline 三种场景 ────────────────────
+    # section("⑤ _aggregate_pipeline:全成功 / 资质材料不合格 / 含降级")
+    # orch2 = Orchestrator()
+    # # base_req = AgentRequest(
+    # #     bidder_id="stu-001", session_id="sess-abc",
+    # #     agent_type=AgentType.QUALIFICATION, user_message="帮我准备投标",
+    # #     pipeline_mode=True,
+    # # )
+    # #
+    # # # 5a:两步全成功
+    # pr_ok = PipelineResult(steps=[
+    #     AgentResponse(success=True, agent_type=AgentType.QUALIFICATION,
+    #                   content="资质评分 78 分…",
+    #                   structured={"weighted_score": 78}),
+    #     AgentResponse(success=True, agent_type=AgentType.DEFENSE,
+    #                   content="供应商答辩报告 76 分…",
+    #                   structured={"overall_score": 76}),
+    # ])
+    # agg = orch2._aggregate_pipeline(pr_ok, base_req)
+    # print(agg)
+    # assert agg.success is True
+    # assert "---" in agg.content
+    # assert set(agg.structured.keys()) == {"step_1", "step_2"}
+    # assert agg.fallback_used is False
+    # print(f"全成功   : success={agg.success}, steps={list(agg.structured.keys())}")
+    #
+    # # 5b:仅资质材料一步(评分 < 60,门槛终止,未启动答辩)
+    # pr_partial = PipelineResult(steps=[
+    #     AgentResponse(success=True, agent_type=AgentType.QUALIFICATION,
+    #                   content="资质评分 45 分,需改进",
+    #                   structured={"weighted_score": 45}),
+    # ])
+    # agg2 = orch2._aggregate_pipeline(pr_partial, base_req)
+    # assert agg2.success is True            # 资质材料步成功,整体仍为 True
+    # assert "step_2" not in agg2.structured # 答辩未启动
+    # print(f"门槛终止 : success={agg2.success}, steps={list(agg2.structured.keys())}")
+    #
+    # # 5c:含降级标记
+    # pr_fallback = PipelineResult(steps=[
+    #     AgentResponse(success=True, agent_type=AgentType.QUALIFICATION,
+    #                   content="资质审查(降级)", structured={"weighted_score": 70},
+    #                   fallback_used=True),
+    #     AgentResponse(success=True, agent_type=AgentType.DEFENSE,
+    #                   content="答辩报告", structured={}),
+    # ])
+    # agg3 = orch2._aggregate_pipeline(pr_fallback, base_req)
+    # assert agg3.fallback_used is True
+    # print(f"含降级   : fallback_used={agg3.fallback_used}")
+    #
+    # print("\n✅ 所有离线测试通过")
+
+

+ 385 - 0
TenderAgent完整代码/backend/core/query_classifier.py

@@ -0,0 +1,385 @@
+import json
+import os
+import random
+from pathlib import Path
+from typing import Any, Optional
+
+import torch
+
+from backend.config import get_settings
+from backend.core.logger import get_logger
+backend_path = os.path.dirname(os.path.dirname(__file__))
+
+logger = get_logger(__name__)
+
+LABEL2ID = {"general": 0, "specialized": 1}
+ID2LABEL  = {0: "general",  1: "specialized"}
+
+# general 侧置信度阈值设为 0.85(偏高):
+# 专业问题被误判为通用问题的代价更高——LLM 会用自身知识回答,
+# 可能与招标资料内容矛盾。宁可多走一次 RAG,不要漏掉招标资料相关问题。
+GENERAL_CONFIDENCE_THRESHOLD = 0.85
+
+class QueryClassifier:
+    """
+    QA Query 二分类器:general / specialized。
+
+    训练阶段:
+        qc = QueryClassifier()
+        qc.train("backend/training_data.jsonl", output_dir="models/classifier")
+
+    推理阶段:
+        qc = QueryClassifier("models/classifier")
+        label, conf = qc.classify("什么是 Spring IOC?")
+    """
+
+    _instance: Optional["QueryClassifier"] = None
+    def __init__(self, model_path: Optional[str] = None):
+        """
+        Args:
+            model_path:
+                - 传入路径 → 加载该路径的模型(可用于加载基座或任意微调模型)
+                - None(默认)→ 加载训练好的微调模型(FINETUNED_CLASSIFIER_PATH)
+        """
+        settings = get_settings()
+
+        if model_path:
+            # 显式传入:用于训练时加载基座,或临时切换其他模型
+            model_id = model_path
+            self._is_finetuned = False if model_path == os.path.join(backend_path, settings.classifier_model_path) else True
+        else:
+            # 默认:加载微调模型(假设已训练完成)
+            model_id = os.path.join(backend_path, settings.finetuned_classifier_path)
+            # print(f'finetuned classifier path: {model_id}')
+            self._is_finetuned = True
+        device = 0 if torch.cuda.is_available() else -1
+        # device = "cuda:0" if torch.cuda.is_available() else "cpu"
+        from transformers import pipeline as hf_pipeline
+        self._pipeline = hf_pipeline(task="text-classification",
+                                     model=model_id,
+                                     device=device,
+                                     top_k=None,
+                                     truncation=True,
+                                     max_length=128,)
+        logger.info("query_classifier.loaded", model_id=model_id, finetuned=self._is_finetuned)
+
+    @classmethod
+    def get_instance(cls) -> "QueryClassifier":
+        """获取单例(首次调用时懒加载)"""
+        if cls._instance is None:
+            cls._instance = cls()
+        return cls._instance
+
+        # ── 训练 ─────────────────────────────────────────────────
+
+    def train(
+            self,
+            data_path: str,
+            output_dir: str,
+            epochs: int = 8,
+            batch_size: int = 64,
+            lr: float = 2e-5,
+            max_length: int = 128,
+            val_ratio: float = 0.1,
+            test_ratio: float = 0.1,
+            seed: int = 42,
+    ) -> None:
+        """
+        微调当前加载的基座模型,训练完自动保存到 output_dir。
+
+        Args:
+            data_path:  训练数据路径(JSONL,每行 {"text": ..., "label": "general/specialized"})
+            output_dir: 微调模型保存目录(训练完可直接用此路径初始化新实例)
+            epochs:     训练轮数(默认 8)
+            batch_size: 训练批大小(默认 64)
+            lr:         学习率(默认 2e-5)
+            max_length: Token 最大长度(默认 128,Query 分类用不到长文本)
+            val_ratio:  验证集比例(默认 0.1)
+            test_ratio: 测试集比例(默认 0.1)
+            seed:       随机种子(默认 42)
+        """
+        # 训练库仅在此方法内 import,推理路径不加载这些依赖
+        import numpy as np
+        from datasets import Dataset
+        from sklearn.metrics import accuracy_score, precision_recall_fscore_support
+        from transformers import (
+            AutoModelForSequenceClassification,
+            AutoTokenizer,
+            DataCollatorWithPadding,
+            EarlyStoppingCallback,
+            Trainer,
+            TrainingArguments,
+            set_seed,
+        )
+
+        set_seed(seed)
+
+        # ── 加载数据 ──────────────────────────────────────────
+        rows = self._load_jsonl(data_path)
+
+        train_rows, val_rows, test_rows = self._stratified_split(
+            rows, val_ratio=val_ratio, test_ratio=test_ratio, seed=seed
+        )
+        logger.info("query_classifier.split",
+                    train=len(train_rows), val=len(val_rows), test=len(test_rows))
+
+        # ── Tokenizer + Model ─────────────────────────────────
+        # 从当前 pipeline 取 model_id,保持一致
+        model_id = self._pipeline.model.config._name_or_path
+        tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
+        model = AutoModelForSequenceClassification.from_pretrained(
+            model_id,
+            num_labels=2,
+            label2id=LABEL2ID,
+            id2label=ID2LABEL,
+            ignore_mismatched_sizes=True,   # 分类头从2类随机初始化
+        )
+        # print(f'model-->{model}')
+        # ── 数据集 ────────────────────────────────────────────
+        def to_dataset(rows_: list[dict]) -> Dataset:
+            return Dataset.from_dict({
+                "text":  [r["text"]              for r in rows_],
+                "label": [LABEL2ID[r["label"]]   for r in rows_],
+            })
+
+        def tokenize(batch):
+            # print(f"batch: {batch}")
+            # print("*"*80)
+            return tokenizer(batch["text"], truncation=True, max_length=max_length)
+
+        train_ds = to_dataset(train_rows).map(tokenize, batched=True,remove_columns=["text"])
+        val_ds   = to_dataset(val_rows).map(tokenize,   batched=True, remove_columns=["text"])
+        test_ds  = to_dataset(test_rows).map(tokenize,  batched=True, remove_columns=["text"])
+
+        # print(f"train_ds-->{train_ds}")
+        # print(f"val_ds-->{val_ds}")
+        # print(f"test_ds-->{test_ds}")
+
+        # ── 评估指标 ──────────────────────────────────────────
+        def compute_metrics(eval_pred):
+            # print(f"eval_pred-->{eval_pred}")
+            logits, labels = eval_pred
+            # print(f'labels-->{labels}')
+            # print(f'logits-->{logits}')
+            # print(f'logits-->{logits.shape}')
+            preds = np.argmax(logits, axis=-1)
+            acc = accuracy_score(labels, preds)
+            _, _, f1, _ = precision_recall_fscore_support(
+                labels, preds, average="macro", zero_division=0
+            )
+            return {"accuracy": float(acc), "f1_macro": float(f1)}
+
+        # ── TrainingArguments ─────────────────────────────────
+        use_cuda = torch.cuda.is_available()
+        checkpoint_dir = str(Path(output_dir) / "_checkpoints")
+
+        train_args = TrainingArguments(
+            output_dir=checkpoint_dir,
+            eval_strategy="epoch",
+            save_strategy="epoch",
+            load_best_model_at_end=True,     # 训练结束自动加载最优 checkpoint
+            metric_for_best_model="f1_macro",
+            greater_is_better=True,
+            num_train_epochs=epochs,
+            per_device_train_batch_size=batch_size,
+            per_device_eval_batch_size=batch_size * 2,
+            learning_rate=lr,
+            warmup_ratio=0.1,
+            weight_decay=0.01,
+            save_total_limit=1,
+            logging_steps=20,
+            fp16=use_cuda,
+            report_to="none",
+            seed=seed,
+        )
+
+        trainer = Trainer(
+            model=model,
+            args=train_args,
+            train_dataset=train_ds,
+            eval_dataset=val_ds,
+            data_collator=DataCollatorWithPadding(tokenizer),# 按照一个批次中最长样本补齐
+            compute_metrics=compute_metrics,
+            callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
+        )
+        # ── 训练 + 测试集评估 ──────────────────────────────────
+        trainer.train()
+        test_metrics = trainer.evaluate(test_ds)
+        # print("*"*80)
+        # print(f'test_metrics: {test_metrics}')
+        logger.info("query_classifier.test_metrics", **{
+            k: round(v, 4) for k, v in test_metrics.items() if k.startswith("eval_")
+        })
+
+        # ── 保存 ──────────────────────────────────────────────
+        Path(output_dir).mkdir(parents=True, exist_ok=True)
+        trainer.save_model(output_dir)
+        tokenizer.save_pretrained(output_dir)
+        logger.info("query_classifier.saved", output_dir=output_dir)
+        print(f"\n✅ 微调完成,模型保存到:{output_dir}")
+        print(f"   下次使用:QueryClassifier('{output_dir}')")
+    # ── 私有工具方法 ─────────────────────────────────────────
+
+    @staticmethod
+    def _load_jsonl(path: str) -> list[dict]:
+        rows: list[dict] = []
+        with open(path, "r", encoding="utf-8") as f:
+            for line_no, line in enumerate(f, 1):
+
+                line = line.strip()
+                if not line:
+                    continue
+                obj = json.loads(line)
+
+                text  = (obj.get("text") or "").strip()
+                label = obj.get("label")
+                if not text:
+                    raise ValueError(f"第 {line_no} 行 text 为空")
+                if label not in LABEL2ID:
+                    raise ValueError(f"第 {line_no} 行 label 非法: {label!r}")
+                rows.append({"text": text, "label": label})
+        if not rows:
+            raise ValueError(f"训练数据为空:{path}")
+        return rows
+
+    @staticmethod
+    def _stratified_split(
+            rows: list[dict],  # 原始数据列表,每个元素是 {"text": ..., "label": "general"/"specialized"}
+            val_ratio: float,  # 验证集比例(如 0.1)
+            test_ratio: float,  # 测试集比例(如 0.1)
+            seed: int,  # 随机种子,保证可复现
+    ) -> tuple[list[dict], list[dict], list[dict]]:  # 返回 (训练集, 验证集, 测试集)
+        """按标签做分层切分,保证训练/验证/测试集的类别比例一致。"""
+        # 设置随机数种子,使得每次运行切分结果相同(便于复现实验)
+        random.seed(seed)
+        # 创建一个空字典,用于按标签(label)分组存放数据
+        # key 为标签名(如 "general"),value 为该标签对应的所有数据列表
+        buckets: dict[str, list[dict]] = {}
+        # 遍历每一条数据,将其放入对应标签的桶中
+        for row in rows:
+            # setdefault:如果桶中已有该标签,则返回已有的列表;否则新建一个空列表并返回
+            # 然后 .append(row) 把当前数据追加到该列表中
+            buckets.setdefault(row["label"], []).append(row)
+        # 调试打印
+        # print(f'buckets-->{len(buckets)}')
+        # print(f'buckets-->{buckets}')
+        # 初始化三个列表,分别存放训练集、验证集、测试集的数据
+        train_rows, val_rows, test_rows = [], [], []
+        # 遍历每个标签组(每个桶),对每个类别单独进行切分,保证各类别在三个集合中比例一致
+        for label, group in buckets.items():
+            # 随机打乱当前组内的数据顺序,防止原始顺序带来的偏差
+            random.shuffle(group)
+            # 获取当前组的总样本数
+            n = len(group)
+            # 计算测试集应取的数量:至少取 1 条(max(1, ...) 防止小样本时取到 0)
+            n_test = max(1, int(n * test_ratio))
+            # 计算验证集应取的数量:至少取 1 条
+            n_val = max(1, int(n * val_ratio))
+            # 从打乱后的组中取前 n_test 条作为测试集
+            test_rows.extend(group[:n_test])
+            # 接着取接下来的 n_val 条作为验证集
+            val_rows.extend(group[n_test:n_test + n_val])
+            # 剩余的全部作为训练集
+            train_rows.extend(group[n_test + n_val:])
+        # 最后将训练集整体再打乱一次,避免训练时类别或样本顺序过于集中
+        random.shuffle(train_rows)
+
+        # 返回三个集合
+        return train_rows, val_rows, test_rows
+
+    def classify(self, text: str) -> tuple[str, float]:
+        """
+        对 query 做 general / specialized 二分类。
+
+        Returns:
+            (label, confidence)
+            label:      "general" 或 "specialized"
+            confidence: 对应标签的置信度 [0, 1]
+
+        分类规则:
+            P(general) >= 0.85 → ("general",      P(general))
+            其余               → ("specialized",  1 - P(general))
+
+        兜底:标签名不匹配时保守返回 ("specialized", 0.5)。
+        """
+        raw_outputs: list[dict] = self._pipeline(text)[0]
+        # print(f'raw_outputs-->{raw_outputs}')
+
+        # 查找 general 标签的分数(兼容大小写和 LABEL_0 格式)
+        general_score: Optional[float] = None
+        for item in raw_outputs:
+            lbl = item["label"].lower()
+            if lbl in ("general", "label_0"):
+                general_score = item["score"]
+                break
+
+        if general_score is None:
+            logger.warning("query_classifier.unexpected_labels",
+                           labels=[x["label"] for x in raw_outputs])
+            return "specialized", 0.5
+
+        if general_score >= GENERAL_CONFIDENCE_THRESHOLD:
+            label, confidence = "general", general_score
+        else:
+            label, confidence = "specialized", 1.0 - general_score
+
+        logger.info("query_classifier.result",
+                    text_preview=text[:50],
+                    label=label,
+                    confidence=round(confidence, 4))
+        return label, confidence
+
+
+# ── 模块级单例(供 nodes.py import 调用)───────────────────────
+
+_classifier: Optional[QueryClassifier] = None
+
+
+# ── 模块级单例(供 nodes.py import 调用)───────────────────────
+
+def get_query_classifier() -> QueryClassifier:
+    """获取 QueryClassifier 单例"""
+    global _classifier
+    if _classifier is None:
+        _classifier = QueryClassifier.get_instance()
+    return _classifier
+if __name__ == '__main__':
+    import sys
+
+    sys.path.insert(0, str(__file__).split("/backend/")[0])
+    from dotenv import load_dotenv
+
+    load_dotenv(".env.local")
+
+    # ── 步骤一:微调模型(首次运行,后续跳过)──────────────────
+    # 无参初始化 → 加载基座 MiniLM(settings.classifier_model_path)
+    qc_base = QueryClassifier(
+        model_path=os.path.join(backend_path, get_settings().classifier_model_path)
+    )
+    qc_base.train(
+        data_path="/Users/Admin/Documents/传智招投标/TenderAgent配套资料/TenderAgent完整代码/backend/core/data/training_data.jsonl",
+        output_dir=os.path.join(backend_path, get_settings().finetuned_classifier_path),
+        epochs=8,
+        batch_size=64,
+    )
+    # 输出:✅ 微调完成,模型保存到:models/classifier/...
+
+    # ── 步骤二:加载微调模型,测试推理 ─────────────────────────
+    qc = QueryClassifier()  # 无参 → 加载微调后模型
+
+    test_cases = [
+        # 通用问题:LLM 自身知识已覆盖,预期 general
+        ("什么是面向对象编程?", "general"),
+        ("Python 中 list 和 tuple 有什么区别?", "general"),
+        # 专业问题:涉及本项目专属内容,预期 specialized
+        ("商品聚合大模型中双塔召回怎么实现?", "specialized"),
+        ("LlamaFactory 怎么做 Qwen VL 微调?", "specialized"),
+        ("Hard Negative Sampling 在大模型微调中的作用是什么?", "specialized"),
+    ]
+
+    print(f"\n{'Query':<44} {'预期':<12} {'预测':<12} {'置信度'}")
+    print("-" * 78)
+    for text, expected in test_cases:
+        label, conf = qc.classify(text)
+        mark = "✓" if label == expected else "✗"
+        print(f"{text:<44} {expected:<12} {label:<12} {conf:.4f}  {mark}")

+ 184 - 0
TenderAgent完整代码/backend/core/reranker.py

@@ -0,0 +1,184 @@
+
+import os
+from dataclasses import dataclass
+from typing import Optional
+
+import torch
+from sentence_transformers import CrossEncoder
+
+from backend.config import get_settings
+from backend.core.logger import get_logger
+
+logger = get_logger(__name__)
+backend_path = os.path.dirname(os.path.dirname(__file__))
+RERANK_MAX_INPUT_CHARS = 512   # 截断过长文档,防止超出 CrossEncoder max_length=512
+
+
+@dataclass
+class RankedDocument:
+    """精排后的单个文档结果"""
+    content:        str    # 文档文本
+    score:          float  # BGE-Reranker 输出的相关性概率 [0, 1]
+    original_index: int    # 在原始召回列表中的位置(0 起)
+    metadata:       dict   # 来源元数据(source_name / chunk_type / tender_document_id 等)
+
+
+class BGEReranker:
+    """
+    BGE-Reranker-v2-m3 精排服务(单例)。
+
+    对 Hybrid 召回的候选文档做 CrossEncoder 精排,
+    直接返回 [0, 1] 置信度,无需额外归一化。
+
+    用法:
+        reranker = BGEReranker.get_instance()
+        docs, confidence = reranker.rerank_with_confidence(
+            query="什么是 Spring IOC?",
+            documents=candidates,
+            top_k=3,
+        )
+    """
+
+    _instance: Optional["BGEReranker"] = None
+
+    def __init__(self):
+        os.environ["ACCELERATE_USE_META_DEVICE"] = "0"
+        settings = get_settings()
+        model_path = os.path.join(backend_path, settings.reranker_model_path)
+
+        use_local = (
+            os.path.exists(model_path)
+            and os.path.isdir(model_path)
+            and any(f.endswith((".bin", ".safetensors", ".json")) for f in os.listdir(model_path))
+        )
+        # print(f'use_local: {use_local}')
+        model_id = model_path if use_local else "BAAI/bge-reranker-v2-m3"
+        device = "cuda" if torch.cuda.is_available() else "cpu"
+
+        logger.info("reranker.loading", model_id=model_id, device=device)
+        self._model = CrossEncoder(model_id, device=device, max_length=512)
+        # print(f'self._model is {self._model}')
+        logger.info("reranker.loaded", model_id=model_id)
+    @classmethod
+    def get_instance(cls) -> "BGEReranker":
+        """获取单例,首次调用时加载模型"""
+        if cls._instance is None:
+            cls._instance = cls()
+        return cls._instance
+
+    def rerank_with_confidence(
+            self,
+            query: str,
+            documents: list[dict],
+            top_k: int = 3,
+    ) -> tuple[list[RankedDocument], float]:
+        """
+        精排并返回置信度。
+
+        Args:
+            query:     用户 Query
+            documents: 候选文档列表,每项含 "content" 字段
+            top_k:     返回文档数量
+
+        Returns:
+            (ranked_docs, confidence)
+            ranked_docs:  按相关性降序排列的 RankedDocument,长度 <= top_k
+            confidence:   Top-1 文档的 BGE 相关性概率 [0, 1],
+                          ≥ 0.75 → 高置信度,直接走 LLM 生成;
+                          < 0.75 → 低置信度,触发 Web 兜底
+        """
+        if not documents:
+            return [], 0.0
+
+        # CrossEncoder 输入:(query, document) 对,截断过长文档
+        pairs = [
+            (query, (doc.get("content") or "")[:RERANK_MAX_INPUT_CHARS])
+            for doc in documents
+        ]
+        # print(f'pairs[0]: {pairs[0]}')
+        # print(f'len(pairs): {len(pairs)}')
+        # CrossEncoder 默认 sigmoid 激活,predict() 直接输出 [0, 1] 概率
+        scores: list[float] = self._model.predict(pairs).tolist()
+        # print(f'scores: {scores}')
+        ranked = sorted(
+            [
+                RankedDocument(
+                    content=documents[i].get("content", ""),
+                    score=scores[i],
+                    original_index=i,
+                    metadata=documents[i].get("metadata", {}),
+                )
+                for i in range(len(documents))
+            ],
+            key=lambda x: x.score,
+            reverse=True,
+        )
+        # print(f'ranked: {ranked}')
+        top_results = ranked[:top_k]
+        confidence = top_results[0].score if top_results else 0.0
+
+        logger.info(
+            "reranker.done",
+            candidates=len(documents),
+            top_k=top_k,
+            confidence=round(confidence, 4),
+        )
+
+        return top_results, confidence
+
+# backend/core/reranker.py(续,追加到文件末尾)
+
+def retrieve(
+    query: str,
+    tenant_id: str,
+    tender_document_id: Optional[str] = None,
+    recall_top_k: int = 10,
+    rerank_top_k: int = 3,
+) -> tuple[list[RankedDocument], float]:
+    """
+    Hybrid 召回 → BGE 精排一体化 Pipeline。
+
+    调用方只需传 Query 文本和租户/招标资料参数,向量化在内部完成。
+
+    Args:
+        query:        用户 Query 文本
+        tenant_id:    租户 ID(Milvus 过滤条件)
+        tender_document_id:    招标资料 ID(可选,进一步缩小检索范围)
+        recall_top_k: Hybrid 召回数量(默认 10,送给精排)
+        rerank_top_k: 精排后返回数量(默认 3,送给 LLM)
+
+    Returns:
+        (ranked_docs, confidence)
+        ranked_docs:  精排后 Top-rerank_top_k 文档
+        confidence:   Top-1 文档的 BGE 置信度 [0, 1]
+    """
+    from backend.core.knowledge_base import BGEMEmbedder, KnowledgeBaseClient  # 延迟导入,避免循环依赖
+
+    # ── 第一步:向量化(在 Pipeline 内部完成,调用方无需感知)──────
+    embedder = BGEMEmbedder.get_instance()
+    dense_vec, sparse_vec = embedder.encode_query(query)
+
+    # ── 第二步:Hybrid 召回 ─────────────────────────────────────────
+    kb = KnowledgeBaseClient()
+    filters = kb._build_filter(tenant_id, tender_document_id)
+    candidates = kb._hybrid_search(
+        query_embedding=dense_vec,
+        query_sparse=sparse_vec,
+        top_k=recall_top_k,
+        filters=filters,
+    )
+
+    if not candidates:
+        logger.info("retrieve.empty", query_preview=query[:50])
+        return [], 0.0
+
+    # ── 第三步:精排 ────────────────────────────────────────────────
+    reranker = BGEReranker.get_instance()
+    return reranker.rerank_with_confidence(query, candidates, top_k=rerank_top_k)
+
+if __name__ == '__main__':
+    query = "商品聚合多模态大模型项目主要讲的是什么内容"
+    results = retrieve(query, tenant_id="tenant_default")
+    print(f"results[0]: {results[0]}")
+    print("*"*80)
+    print(f"results[1]: {results[1]}")

+ 225 - 0
TenderAgent完整代码/backend/core/retry.py

@@ -0,0 +1,225 @@
+# backend/core/retry.py
+# 三层兜底机制:自动重试 → Agent 级降级 → 系统级兜底
+
+import asyncio                                   # 异步:用于超时控制和等待
+from functools import wraps                      # @wraps:装饰器里保留原函数的名字/文档
+from typing import Callable, Any, Optional       # 类型注解:可调用对象 / 任意 / 可选
+
+from backend.core.exceptions import (            # 引入 3.3 定义的异常(已去掉 Judge0 的 Sandbox 异常)
+    LLMAPIError,
+    MilvusConnectionError,
+    InvalidInputError,
+    AuthenticationError,
+)
+from backend.core.logger import get_logger,configure_logging
+logger = get_logger(__name__)
+
+# ── 异常分类 ───────────────────────────────────────────────
+# 可重试:多半是短暂故障(网络抖动、超时),重试一下可能就好
+RETRYABLE_ERRORS = (
+    LLMAPIError,
+    MilvusConnectionError,
+    TimeoutError,
+    ConnectionError,
+)
+# 不可重试:重试也没用(输入非法、认证失败),应立即抛出
+NON_RETRYABLE_ERRORS = (
+    InvalidInputError,
+    AuthenticationError,
+)
+
+MAX_RETRIES = 2                  # 最多重试 2 次(加上首次 = 共 3 次尝试)
+RETRY_DELAYS = [1.0, 3.0]        # 第 1 次重试前等 1 秒,第 2 次前等 3 秒
+TIMEOUT_PER_ATTEMPT = 120.0       # 单次调用最多等 30 秒,超时算失败
+
+
+
+def with_retry(agent_type: str = ""):
+    """三层兜底装饰器工厂。给异步函数套上「重试 → 降级 → 系统兜底」三层保护。
+
+    用法:
+        @with_retry(agent_type="qa")
+        async def _invoke():
+            return await graph.ainvoke(state, config=config)
+    """
+    def decorator(func: Callable) -> Callable:       # 中间层:接收被装饰的函数
+        @wraps(func)                                 # 保留原函数的元信息(名字、docstring)
+        async def wrapper(*args, **kwargs) -> Any:   # 最内层:真正的执行逻辑
+
+            # ── 第一层:自动重试 ──────────────────────────
+            last_error: Optional[Exception] = None   # 记录最后一次的错误,留给后面降级用
+            for attempt in range(MAX_RETRIES + 1):   # 循环 3 次:attempt = 0, 1, 2
+                try:
+                    # 给单次调用套一个超时;超过 30 秒就抛 TimeoutError
+                    result = await asyncio.wait_for(
+                        func(*args, **kwargs),
+                        timeout=TIMEOUT_PER_ATTEMPT,
+                    )
+                    if attempt > 0:                  # 如果是重试后成功的,记一条日志
+                        logger.info("retry.succeeded", agent_type=agent_type, attempt=attempt + 1)
+                    return result                    # 成功,直接返回,结束
+
+                except NON_RETRYABLE_ERRORS as e:    # 不可重试异常:立即抛出,不再重试
+                    logger.warning("retry.non_retryable_error", agent_type=agent_type, error=str(e))
+                    raise                            # 原样抛出,交给上层处理
+
+                except Exception as e:               # 其它(可重试)异常
+                    last_error = e                   # 记下来
+                    if attempt < MAX_RETRIES:        # 还没到上限:等待后重试
+                        delay = RETRY_DELAYS[attempt]
+                        logger.warning(
+                            "retry.attempt_failed", agent_type=agent_type,
+                            attempt=attempt + 1, max_retries=MAX_RETRIES, delay=delay, error=str(e),
+                        )
+                        await asyncio.sleep(delay)   # 等 1s 或 3s 再重试
+                    else:                            # 到上限了:记录失败,跳出循环去降级
+                        logger.error("retry.all_attempts_failed", agent_type=agent_type, error=str(e))
+
+            # ── 第二层:Agent 级降级 ──────────────────────
+            try:
+                fallback_result = await AgentFallbackHandler.handle(  # 按 agent_type 找降级策略
+                    agent_type=agent_type, original_error=last_error,
+                )
+                logger.info("retry.fallback_succeeded", agent_type=agent_type)
+                return fallback_result               # 降级成功,返回降级结果
+            except Exception as fallback_error:      # 连降级都失败
+                logger.error("retry.fallback_failed", agent_type=agent_type, error=str(fallback_error))
+
+            # ── 第三层:系统级兜底 ────────────────────────
+            logger.error("retry.system_fallback", agent_type=agent_type, original_error=str(last_error))
+            return _system_fallback_response(agent_type)  # 最后的保底,永远不会再失败
+        return wrapper
+    return decorator
+
+
+
+class AgentFallbackHandler:
+    """第二层降级:各 Agent 的专项降级策略(尽量保留核心功能,退化为更简单的实现)。"""
+
+    @classmethod
+    async def handle(cls, agent_type: str, original_error: Exception) -> Any:
+        """根据 agent_type 选择对应的降级策略。"""
+        fallback_map = {                              # 类型 → 降级方法 的映射表
+            "qa":               cls._qa_fallback,
+            "bid_review_code":        cls._bid_review_code_fallback,
+            "bid_review_subjective":  cls._bid_review_subjective_fallback,
+            "qualification":           cls._qualification_fallback,
+            "defense":        cls._defense_fallback,
+        }
+        handler = fallback_map.get(agent_type)        # 查表
+        if handler:
+            return await handler()
+        raise original_error                          # 没有对应降级策略,原样抛出(交给系统兜底)
+
+    @classmethod
+    async def _qa_fallback(cls) -> dict:
+        """问答降级:知识库或模型不可用,返回提示语。"""
+        logger.info("fallback.qa_service_unavailable")
+        return {
+            "fallback_used": True,
+            "content": "⚠️ 知识库检索暂时不可用,请稍后重试或直接联系评审专家提问。",
+            "structured_output": None,
+        }
+
+    @classmethod
+    async def _bid_review_code_fallback(cls) -> dict:
+        """代码评分降级:评分服务不可用,标记需评审专家人工复核。"""
+        logger.info("fallback.bid_review_code_basic")
+        return {
+            "fallback_used": True,
+            "needs_expert_review": True,
+            "fallback_note": "代码评分服务暂时不可用,已标记为需评审专家人工复核。",
+        }
+
+    @classmethod
+    async def _bid_review_subjective_fallback(cls) -> dict:
+        """方案响应项评分降级:标记需专家复核。"""
+        logger.info("fallback.bid_review_subjective_basic")
+        return {
+            "fallback_used": True,
+            "needs_expert_review": True,
+            "fallback_note": "AI 评分服务暂时不可用,已标记为需评审专家人工评分。",
+        }
+
+    @classmethod
+    async def _qualification_fallback(cls) -> dict:
+        """资质审查降级:提示服务不可用 / 检查文件。"""
+        logger.info("fallback.qualification_service_unavailable")
+        return {
+            "fallback_used": True,
+            "content": "资质审查服务暂时不可用,请稍后重试。如持续失败,请检查上传的 PDF 文件是否完整。",
+            "structured_output": None,
+        }
+
+    @classmethod
+    async def _defense_fallback(cls) -> dict:
+        """答辩降级:跳过深度分析,返回基础反馈。"""
+        logger.info("fallback.defense_basic_feedback")
+        return {
+            "fallback_used": True,
+            "content": "答辩评估服务暂时不可用,已记录本次答辩对话,请稍后查看报告。",
+            "structured_output": None,
+        }
+
+
+def _system_fallback_response(agent_type: str) -> dict:
+    """第三层:系统级兜底。所有降级都失败后返回它,保证用户始终能收到响应。"""
+    messages = {                                      # 按 agent_type 给不同的友好提示
+        "qa":        "非常抱歉,智能问答服务暂时不可用,请稍后再试,或直接联系评审专家提问。",
+        "bid_review":      "非常抱歉,投标文件评分服务暂时不可用,您的提交已保存,待服务恢复后将自动处理。",
+        "qualification":    "非常抱歉,资质审查服务暂时不可用,请稍后重新上传。",
+        "defense": "非常抱歉,供应商答辩服务暂时不可用,请稍后重新开始。",
+    }
+    content = messages.get(agent_type, "服务暂时不可用,请稍后再试。")  # 找不到就用通用提示
+    return {
+        "messages": [],
+        "content": content,
+        "fallback_used": True,
+        "system_fallback": True,                      # 标记:走到了最后一层系统兜底
+        "structured_output": None,
+    }
+if __name__ == '__main__':
+    RETRY_DELAYS = [0.0, 0.0]  # 测试时把等待时间清零,不用真等 1s/3s
+    configure_logging()
+    calls = {"n": 0}
+
+    # ── ① 正常成功 ──────────────────────────────────────────────
+    @with_retry(agent_type="qa")
+    async def ok():
+        return "success"
+    # # ── ② 重试后成功 ────────────────────────────────────────────
+    @with_retry(agent_type="qa")
+    async def fail_twice_then_ok():
+        calls["n"] += 1
+        if calls["n"] < 3:
+            raise LLMAPIError("网络抖动")
+        return "recovered"
+
+    # # ── ③ 不可重试异常立即抛出 ──────────────────────────────────
+    @with_retry(agent_type="qa")
+    async def non_retryable():
+        raise InvalidInputError("输入非法")
+
+
+    # ── ④ 三次全败 + 无降级策略 → 第三层系统兜底 ────────────────
+    @with_retry(agent_type="")  # 空 agent_type → fallback_map 里找不到 → 直接系统兜底
+    async def always_fail():
+        raise LLMAPIError("一直失败")
+
+
+    # ── ⑤ 三次全败 + 有降级策略 → 第二层 Agent 降级 ─────────────
+    # 以 qa 为代表;qualification / defense / bid_review_code / bid_review_subjective 结构完全相同
+    @with_retry(agent_type="qa")
+    async def qa_node_always_fail():
+        raise LLMAPIError("Milvus 连接超时")
+
+
+    async def main():
+        # result = await ok()
+        # print(f'result: {result}')
+        # result = await fail_twice_then_ok()
+        # result = await non_retryable()
+        # result = await always_fail()
+        result = await qa_node_always_fail()
+        print(f'result-->{result}')
+    asyncio.run(main())

+ 64 - 0
TenderAgent完整代码/backend/db/migrations.py

@@ -0,0 +1,64 @@
+# backend/db/migrations.py
+#
+# 启动时自动执行的 Schema 补丁(全部幂等,可重复运行)。
+# 规则:
+#   - 只写 ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS 等幂等 DDL
+#   - 禁止写 DROP / TRUNCATE 等破坏性变更
+#   - 每次 init_db.sql 新增字段,同步在 _MIGRATIONS 里追加一条
+
+from sqlalchemy import text
+from backend.dependencies import AsyncSessionLocal
+from backend.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+# ── 所有需要补丁的 DDL,按时间顺序追加。SQL 必须幂等(IF NOT EXISTS)──
+_MIGRATIONS: list[tuple[str, str]] = [
+    (
+        "bid_review_submissions.weak_points",
+        "ALTER TABLE bid_review_submissions ADD COLUMN IF NOT EXISTS weak_points JSONB",
+    ),
+    (
+        "bid_review_reviews.knowledge_tag",
+        "ALTER TABLE bid_review_reviews ADD COLUMN IF NOT EXISTS knowledge_tag VARCHAR(128)",
+    ),
+    (
+        "bid_review_submissions.weak_points_summary",
+        "ALTER TABLE bid_review_submissions ADD COLUMN IF NOT EXISTS weak_points_summary TEXT",
+    ),
+    (
+        "idx_bid_review_submissions_bidder_created",
+        "CREATE INDEX IF NOT EXISTS idx_bid_review_submissions_bidder_created "
+        "ON bid_review_submissions (bidder_id, created_at DESC)",
+    ),
+    (
+        "idx_qualification_reviews_bidder_created",
+        "CREATE INDEX IF NOT EXISTS idx_qualification_reviews_bidder_created "
+        "ON qualification_reviews (bidder_id, created_at DESC)",
+    ),
+    (
+        "idx_defense_sessions_bidder_created",
+        "CREATE INDEX IF NOT EXISTS idx_defense_sessions_bidder_created "
+        "ON defense_sessions (bidder_id, created_at DESC)",
+    ),
+]
+
+
+async def run_migrations() -> None:
+    """
+    在应用启动时执行所有 Schema 补丁。
+    单条失败只记录警告,不阻断启动流程。
+    """
+    async with AsyncSessionLocal() as session:
+        for desc, sql in _MIGRATIONS:
+            try:
+                await session.execute(text(sql))
+                await session.commit()
+                logger.debug("db.migration_applied", column=desc)
+            except Exception as e:
+                await session.rollback()
+                err = str(e)
+                if "already exists" not in err:
+                    logger.warning("db.migration_failed", column=desc, error=err)
+
+    logger.info("db.migrations_done", count=len(_MIGRATIONS))

+ 90 - 0
TenderAgent完整代码/backend/dependencies.py

@@ -0,0 +1,90 @@
+# backend/dependencies.py(数据库部分)
+from typing import AsyncGenerator
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials  # 解析 Authorization: Bearer 头
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from jose import JWTError, jwt                       # python-jose:JWT 的编解码库
+import os,sys
+tenderagents_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(tenderagents_path)
+from backend.config import get_settings
+
+settings = get_settings()
+
+# ── 创建异步引擎(连接池)──
+engine = create_async_engine(
+    settings.database_url,        # 来自 config.py,最终来自 .env.local
+    pool_size=10,                 # 连接池基础大小
+    max_overflow=20,              # 高峰时最多再额外开 20 个连接
+    echo=False,                   # True 会打印所有 SQL,调试时可临时打开
+)
+
+# ── 会话工厂 ──
+AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+    """FastAPI 依赖:获取异步数据库会话,自动提交 / 回滚 / 关闭"""
+    async with AsyncSessionLocal() as session:
+        try:
+            yield session            # 把会话交给接口使用(回顾 2.1.6 / 2.5)
+            await session.commit()   # 接口正常结束 → 自动提交
+        except Exception:
+            await session.rollback() # 出错 → 自动回滚
+            raise
+
+# ── JWT 鉴权 ───────────────────────────────────────────────────
+bearer_scheme = HTTPBearer()   # FastAPI 安全方案:自动从请求头解析 "Authorization: Bearer <token>"
+
+
+async def get_current_user(
+    # Depends(bearer_scheme):FastAPI 自动取出 Bearer Token;没带或格式错会直接 401
+    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
+) -> dict:
+    """FastAPI 依赖:验证 JWT Token,返回当前用户信息。
+    返回 {"user_id": str, "role": str, "tenant_id": str};Token 无效则抛 401。"""
+    # 预先准备好「401 凭证无效」异常,多处复用
+    # print(f'credentials: {credentials}')
+    # print(f'credentials.credentials: {credentials.credentials}')
+    credentials_exception = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="无效的认证凭证",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
+    try:
+        # 用密钥和算法解码 Token;若签名不对/过期,会抛 JWTError
+        payload = jwt.decode(
+            credentials.credentials,            # 实际的 token 字符串
+            settings.jwt_secret_key,            # 验签密钥(和签发时同一个)
+            algorithms=[settings.jwt_algorithm],
+        )
+        user_id: str = payload.get("sub")                                  # 标准字段 sub = 用户ID
+        role: str = payload.get("role", "bidder")                         # 角色
+        tenant_id: str = payload.get("tenant_id", settings.default_tenant_id)  # 租户
+
+        if not user_id:                         # Token 里没有用户ID,视为无效
+            raise credentials_exception
+
+    except JWTError:                            # 解码失败(签名错/过期等)
+        raise credentials_exception
+
+    return {"user_id": user_id, "role": role, "tenant_id": tenant_id}
+if __name__ == '__main__':
+    ...
+    # test_get_db.py(项目根目录)
+    import asyncio
+    from sqlalchemy import text
+    async def main():
+        # 模拟 FastAPI 调用依赖:迭代一次拿到 session,跑一条查询
+        async for db in get_db():
+            r = await db.execute(text(
+                "SELECT current_database(), "
+                "count(*) FROM information_schema.tables WHERE table_schema = :s"
+            ), {"s": "public"})
+            row = r.fetchone()
+            print("get_db 连到库:", row[0])
+            print("public 下表数量:", row[1])
+
+
+    asyncio.run(main())
+    print("get_db 测试通过 ✅")

+ 104 - 0
TenderAgent完整代码/backend/main.py

@@ -0,0 +1,104 @@
+# backend/main.py
+
+import os
+import sys
+
+# Windows conda 专用:把 base 的 Library/bin 加入 DLL 搜索路径,
+# 让 _lzma.pyd 能找到 liblzma.dll(非 Windows 跳过,不影响 Linux/Mac)
+if sys.platform == "win32":
+    _conda_base_lib_bin = os.path.normpath(
+        os.path.join(os.path.dirname(sys.executable), "..", "..", "Library", "bin")
+    )
+    if os.path.isdir(_conda_base_lib_bin):
+        os.add_dll_directory(_conda_base_lib_bin)
+
+from contextlib import asynccontextmanager
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from backend.config import get_settings                          # 配置(第 3 章)
+from backend.core.logger import configure_logging, get_logger    # 日志(3.3)
+from backend.api.router import api_router                        # 8.6.1 聚合的总路由
+
+settings = get_settings()
+
+# MCP Sub-Apps(第 5.11 章实现)在 lifespan 定义前创建,
+# 这样 lifespan 与下面的 mount 共享同一实例
+from backend.mcp.knowledge_base_server import mcp as kb_mcp      # 知识库 MCP(5.11)
+from backend.mcp.web_search_server import mcp as ws_mcp          # 联网搜索 MCP(5.11)
+
+_kb_app = kb_mcp.streamable_http_app()                          # 把 MCP 变成 ASGI 子应用
+_ws_app = ws_mcp.streamable_http_app()
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    configure_logging()                                  # 初始化结构化日志(3.3)
+    logger = get_logger(__name__)
+    logger.info("app.starting | env=%s port=%s", settings.app_env, settings.app_port)
+
+    # ① DB Schema 自动迁移(幂等,每次启动执行)—— 第 3 章的建表/迁移
+    try:
+        from backend.db.migrations import run_migrations
+        await run_migrations()
+    except Exception as e:
+        logger.warning("app.migrations_failed | error=%s", e)   # 迁移失败只告警,不拦启动
+
+    # ② 并行预热三个本地模型(首次加载慢,提前热好,避免首个请求卡顿)
+    import asyncio
+    try:
+        from backend.core.reranker import BGEReranker            # 重排序模型(5.8)
+        from backend.core.query_classifier import QueryClassifier # 意图分类器(5.9)
+        from backend.core.knowledge_base import BGEMEmbedder      # BGE-M3 嵌入(5.4)
+
+        loop = asyncio.get_running_loop()
+        await asyncio.gather(                                    # 三个模型并行加载(各跑在线程池里)
+            loop.run_in_executor(None, BGEReranker.get_instance),
+            loop.run_in_executor(None, QueryClassifier.get_instance),
+            loop.run_in_executor(None, BGEMEmbedder.get_instance),
+        )
+        logger.info("app.local_models_warmed_up")
+    except Exception as e:
+        logger.warning("app.local_models_warmup_failed | error=%s", e)  # 预热失败也不拦启动
+
+    # ③ 驱动 MCP Server 的 lifespan(mount 不会自动调用子应用的 lifespan,需手动嵌套进入)
+    async with _kb_app.router.lifespan_context(_kb_app):
+        async with _ws_app.router.lifespan_context(_ws_app):
+            logger.info("app.started")
+            yield                                               # ← 应用运行期间停在这里
+
+            # ── 关闭时执行 ──
+            logger.info("app.shutting_down")
+            from backend.core.llm_factory import LLMFactory      # LLM 工厂(3.4)
+            LLMFactory.clear_cache()                            # 清缓存
+            logger.info("app.shutdown_complete")
+
+app = FastAPI(
+    title="TenderAgent API",
+    description="AI 助教智能体系统 API",
+    version="1.0.0",
+    docs_url="/docs",                                    # Swagger 文档
+    redoc_url="/redoc",                                  # ReDoc 文档
+    lifespan=lifespan,                                   # 挂上面的生命周期钩子
+)
+
+# CORS:允许前端开发端口跨域访问(3000/5173/8080)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[
+        "http://localhost:3000", "http://localhost:5173", "http://localhost:8080",
+        "http://127.0.0.1:3000", "http://127.0.0.1:5173", "http://127.0.0.1:8080",
+    ],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+app.include_router(api_router, prefix="/api/v1")        # 挂总路由(8.6.1),所有业务接口在 /api/v1 下
+
+app.mount("/mcp/kb",         _kb_app)                   # 挂知识库 MCP 子应用(5.11)
+app.mount("/mcp/web-search", _ws_app)                   # 挂联网搜索 MCP 子应用(5.11)
+
+
+@app.get("/health", tags=["系统"])                       # 健康检查(运维探活用)
+async def health_check():
+    return {"status": "ok", "env": settings.app_env}

+ 12 - 0
TenderAgent完整代码/backend/mcp/check_hello.py

@@ -0,0 +1,12 @@
+import asyncio, sys
+sys.path.insert(0, ".")
+
+from backend.mcp.client import call_mcp_tool, list_mcp_tools
+
+async def main():
+    base = "http://localhost:8009"
+    print("已注册工具:", [t["name"] for t in await list_mcp_tools(base)])
+    result = await call_mcp_tool(base, "add", {"a": 3, "b": 5})
+    print("add(3, 5) =", result)      # 预期:8
+
+asyncio.run(main())

+ 17 - 0
TenderAgent完整代码/backend/mcp/check_kg_client.py

@@ -0,0 +1,17 @@
+import asyncio, sys
+from backend.mcp.client import call_mcp_tool,list_mcp_tools
+
+async def main():
+    base = "http://localhost:8000/mcp/kb"          # 独立模式:直接指向端口,无路径前缀
+    tools = await list_mcp_tools(base)
+    print(f"已注册工具:{[t['name'] for t in tools]}\n")
+    results = await call_mcp_tool(
+        server_url=base,
+        tool_name="search_knowledge_base",
+        arguments={"query": "跨境电商项目具体内容讲的是什么", "tenant_id": "tenant_default"},
+    )
+    print(f"命中 {len(results)} 条")
+    print(results)
+    print(type(results))
+
+asyncio.run(main())

+ 22 - 0
TenderAgent完整代码/backend/mcp/check_web_client.py

@@ -0,0 +1,22 @@
+# scripts/test_mcp_search.py
+# 前提:python backend/mcp/web_search_server.py 已在终端 1 运行
+import asyncio, sys
+from backend.mcp.client import call_mcp_tool
+
+async def main():
+    base = "http://localhost:8000/mcp/search"          # 独立模式:直接指向端口,无路径前缀
+
+    results = await call_mcp_tool(
+        server_url=base,
+        tool_name="web_search",
+        arguments={"query": "什么是牛奶", "max_results": 1},
+    )
+    print(f"搜索结果 {len(results)} 条")
+    print(results)
+    for i, r in enumerate(results, 1):
+        print(f"[{i}] {r}")
+        print(f"[{i}] {r['title']}")
+        print(f"     {r['url']}")
+        print(f"     {r['snippet'][:80]}...\n")
+
+asyncio.run(main())

+ 118 - 0
TenderAgent完整代码/backend/mcp/client.py

@@ -0,0 +1,118 @@
+# backend/mcp/client.py
+
+import json
+from typing import Any
+
+import httpx
+
+from backend.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+async def call_mcp_tool(
+    server_url: str,
+    tool_name: str,
+    arguments: dict[str, Any],
+    timeout: float = 30.0,
+) -> Any:
+    """
+    调用 stateless MCP Server 的单个工具。
+
+    stateless_http=True 的 Server 每次 POST 完全自包含,无需先发 initialize 握手。
+
+    Args:
+        server_url: MCP Server 基础 URL,例如 "http://localhost:8000/mcp/kb"
+        tool_name:  工具名称,与 @mcp.tool() 注册名一致
+        arguments:  工具参数字典
+        timeout:    请求超时(秒)
+
+    Returns:
+        工具返回值(JSON 反序列化后的 Python 对象)
+
+    Raises:
+        httpx.HTTPStatusError:  Server 返回 4xx/5xx
+        ValueError:             JSON-RPC 错误(工具内部异常)
+        httpx.TimeoutException: 请求超时
+    """
+    payload = {
+        "jsonrpc": "2.0",
+        "id":      1,
+        "method":  "tools/call",
+        "params": {
+            "name":      tool_name,
+            "arguments": arguments,
+        },
+    }
+
+    # json_response=True 的 Server 要求客户端声明 Accept: application/json,
+    # 否则 Server 返回 -32600 "Not Acceptable" 错误
+    headers = {
+        "Content-Type": "application/json",
+        "Accept":        "application/json",
+    }
+
+    # trust_env=False:禁止 httpx 读取 HTTP_PROXY / ALL_PROXY 等环境变量和系统代理。
+    # macOS 系统代理或 PyCharm/Charles 等工具设置的代理不会排除 localhost,
+    # 导致本地 MCP 调用被路由到代理后返回 502。对 localhost 的内部调用永远不需要代理。
+    async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
+        resp = await client.post(f"{server_url}/mcp", json=payload, headers=headers)
+        resp.raise_for_status()
+
+    data = resp.json()
+    # print(f"data:{data}")
+    # JSON-RPC 错误信封
+    if "error" in data:
+        raise ValueError(
+            f"MCP tool '{tool_name}' error: {data['error'].get('message', str(data['error']))}"
+        )
+
+    # FastMCP 对 list[dict] 的序列化行为:
+    #   - 每个 dict 单独放进一个 TextContent 条目(最常见)
+    #   - 或整个列表序列化成一个 TextContent 条目(部分版本)
+    # 必须遍历所有 content 条目,不能只取 content[0],否则多条结果只返回第一条。
+    # print("*"*80)
+    content = data.get("result", {}).get("content", [])
+    # print(f'content:{len(content)}')
+    # print(f'content:{content[0]}')
+    if not content:
+        return []
+
+    items = []
+    for item in content:
+        if not isinstance(item, dict):
+            continue
+        text = item.get("text", "")
+        if not text:
+            continue
+        try:
+            parsed = json.loads(text)
+            # print(f'parsed:{parsed}')
+            # print(f'parsed:{type(parsed)}')
+            # break
+        except (json.JSONDecodeError, TypeError):
+            parsed = text
+        if isinstance(parsed, list):
+            # 整个列表在一个 TextContent 里,直接返回
+            return parsed
+        items.append(parsed)
+
+    return items if items else content
+
+
+async def list_mcp_tools(server_url: str, timeout: float = 10.0) -> list[dict]:
+    """
+    列出 MCP Server 提供的所有工具(调试 / 验证用)。
+
+    Returns:
+        工具列表,每项含 name / description / inputSchema
+    """
+    payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
+    headers = {"Content-Type": "application/json", "Accept": "application/json"}
+
+    async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
+        resp = await client.post(f"{server_url}/mcp", json=payload, headers=headers)
+        resp.raise_for_status()
+    print(f"resp.json()-->{resp.json()}")
+    return resp.json().get("result", {}).get("tools", [])
+

+ 18 - 0
TenderAgent完整代码/backend/mcp/hello_mcp.py

@@ -0,0 +1,18 @@
+# backend/mcp/hello_server.py —— 最小 MCP Server(评审演示,不依赖任何模型)
+
+from mcp.server.fastmcp import FastMCP
+
+# 创建一个 MCP Server 实例(三个参数含义见上面的 FastMCP 小节)
+mcp = FastMCP(name="HelloServer", stateless_http=True, json_response=True)
+
+
+@mcp.tool()                          # 就这一个装饰器,把下面的函数注册成了一个 MCP 工具
+async def add(a: int, b: int) -> int:
+    """把两个整数相加(演示 MCP 工具的最小形态)"""   # docstring 会成为工具描述
+    return a + b                     # 返回值由 FastMCP 自动包成 JSON 回给调用方
+
+
+if __name__ == "__main__":
+    import uvicorn
+    # streamable_http_app() 把 mcp 变成标准 ASGI 应用,uvicorn 直接跑;端点为 /mcp
+    uvicorn.run(mcp.streamable_http_app(), host="0.0.0.0", port=8009)

+ 85 - 0
TenderAgent完整代码/backend/mcp/knowledge_base_server.py

@@ -0,0 +1,85 @@
+# backend/mcp/knowledge_base_server.py
+
+import asyncio
+import os
+import sys
+
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP(
+    name="TenderAgent-KnowledgeBase",
+    stateless_http=True,
+    json_response=True,
+)
+
+
+@mcp.tool()
+async def search_knowledge_base(
+    query: str,
+    tenant_id: str,
+    tender_document_id: str | None = None,
+    top_k: int = 3,
+) -> list[dict]:
+    """
+    Hybrid semantic + keyword search over the TenderAgent Milvus knowledge base.
+
+    Uses BGE-M3 dual-vector encoding combined with Milvus hybrid_search
+    and WeightedRanker fusion, followed by BGE-Reranker cross-encoder reranking.
+
+    Args:
+        query:     Search query in Chinese or English
+        tenant_id: Tenant identifier for data isolation (e.g. "tenant_default")
+        tender_document_id: Optional tender_document UUID to restrict search scope
+        top_k:     Number of documents to return after reranking (default: 3)
+
+    Returns:
+        List of ranked documents, each with:
+        - content:            chunk text
+        - source_name:        document name and chapter
+        - score:              reranker confidence score [0, 1]
+        - confidence:         top document confidence score
+        - is_high_confidence: whether confidence >= 0.75
+    """
+    from backend.core.reranker import retrieve
+    from backend.core.logger import get_logger
+
+    logger = get_logger(__name__)
+
+    # retrieve() 内部包含 BGE-M3 编码(CPU-bound)和 CrossEncoder 推理(CPU-bound),
+    # 两者都是同步阻塞操作,用 run_in_executor 避免阻塞 async 事件循环
+    loop = asyncio.get_running_loop()
+    ranked_docs, confidence = await loop.run_in_executor(
+        None,
+        lambda: retrieve(
+            query=query,
+            tenant_id=tenant_id,
+            tender_document_id=tender_document_id,
+            rerank_top_k=top_k,
+        ),
+    )
+
+    logger.info(
+        "kb_mcp.search_done",
+        query_preview=query[:50],
+        hits=len(ranked_docs),
+        confidence=round(confidence, 4),
+    )
+
+    return [
+        {
+            "content":            doc.content,
+            "source_name":        doc.metadata.get("source_name", ""),
+            "score":              round(doc.score, 6),
+            "confidence":         round(confidence, 4),
+            "is_high_confidence": confidence >= 0.75,
+        }
+        for doc in ranked_docs
+    ]
+
+
+# ── 独立运行入口 ──────────────────────────────────────────────────
+if __name__ == "__main__":
+    import uvicorn
+    port= 8003
+    print(f"Knowledge Base MCP Server → http://localhost:{port}/mcp")
+    uvicorn.run(mcp.streamable_http_app(), host="0.0.0.0", port=port)

+ 109 - 0
TenderAgent完整代码/backend/mcp/web_search_server.py

@@ -0,0 +1,109 @@
+import asyncio
+import os
+import sys
+
+import httpx
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP(
+    name="TenderAgent-WebSearch",
+    stateless_http=True,
+    json_response=True,
+)
+
+
+async def _search_tavily(query: str, max_results: int, api_key: str) -> list[dict]:
+    """Tavily 搜索(结构化结果,需 API key,https://tavily.com)"""
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        resp = await client.post(
+            "https://api.tavily.com/search",
+            json={
+                "api_key":             api_key,
+                "query":               query,
+                "max_results":         max_results,
+                "include_answer":      False,
+                "include_raw_content": False,
+            },
+        )
+        resp.raise_for_status()
+    # print(f'resp.json()-->{resp.json()}')
+    # print(f'results-->{len(resp.json().get("results", []))}')
+    # print(f'results[0]-->{resp.json().get("results", [])[0]}')
+    return [
+        {
+            "title":   r.get("title", ""),
+            "url":     r.get("url", ""),
+            "snippet": r.get("content", "")[:500],
+            "content": r.get("content", ""),
+        }
+        for r in resp.json().get("results", [])
+    ]
+async def _search_duckduckgo(query: str, max_results: int) -> list[dict]:
+    """DuckDuckGo 搜索(免费,无需 API key)
+
+    duckduckgo-search 6.x+ 移除了 AsyncDDGS,统一使用同步 DDGS。
+    用 asyncio.to_thread 包装避免阻塞事件循环(与 run_in_executor 效果相同,写法更简洁)。
+    """
+    from duckduckgo_search import DDGS
+
+    def _sync_search() -> list[dict]:
+        results = []
+        with DDGS() as ddgs:
+            for r in ddgs.text(query, max_results=max_results):
+                results.append({
+                    "title":   r.get("title", ""),
+                    "url":     r.get("href", ""),
+                    "snippet": r.get("body", "")[:500],
+                    "content": r.get("body", ""),
+                })
+        return results
+
+    return await asyncio.to_thread(_sync_search)
+# ── MCP 工具 ──────────────────────────────────────────────────────
+
+@mcp.tool()
+async def web_search(
+    query: str,
+    max_results: int = 5,
+) -> list[dict]:
+    """
+    Search the web for current information not available in the knowledge base.
+
+    Automatically selects the best available backend:
+    - Tavily (preferred): structured results; requires TAVILY_API_KEY in .env.local
+    - DuckDuckGo (fallback): free, no API key required
+
+    Args:
+        query:       Search query string
+        max_results: Maximum number of results to return (default: 5)
+
+    Returns:
+        List of search results, each with title / url / snippet / content
+    """
+    from backend.config import get_settings
+    from backend.core.logger import get_logger
+
+    logger = get_logger(__name__)
+    settings = get_settings()
+
+    # 优先 Tavily,失败后降级 DuckDuckGo
+    if settings.tavily_api_key:
+        try:
+            results = await _search_tavily(query, max_results, settings.tavily_api_key)
+            logger.info("web_search_mcp.tavily_done", hits=len(results))
+            return results
+        except Exception as e:
+            logger.warning("web_search_mcp.tavily_failed", error=str(e))
+
+    try:
+        results = await _search_duckduckgo(query, max_results)
+        logger.info("web_search_mcp.ddgs_done", hits=len(results))
+        return results
+    except Exception as e:
+        logger.error("web_search_mcp.ddgs_failed", error=str(e))
+        return []
+if __name__ == '__main__':
+    import uvicorn
+    port = 8002
+    print(f"Web Search MCP Server → http://localhost:{port}/mcp")
+    uvicorn.run(mcp.streamable_http_app(), host="0.0.0.0", port=port)

+ 344 - 0
TenderAgent完整代码/backend/models/classifier/all-MiniLM-L6-v2/train_script.py

@@ -0,0 +1,344 @@
+"""
+Train script for a single file
+
+Need to set the TPU address first:
+export XRT_TPU_CONFIG="localservice;0;localhost:51011"
+"""
+
+import torch.multiprocessing as mp
+import threading
+import time
+import random
+import sys
+import argparse
+import gzip
+import json
+import logging
+import tqdm
+import torch
+from torch import nn
+from torch.utils.data import DataLoader
+import torch
+import torch_xla
+import torch_xla.core
+import torch_xla.core.functions
+import torch_xla.core.xla_model as xm
+import torch_xla.distributed.xla_multiprocessing as xmp
+import torch_xla.distributed.parallel_loader as pl
+import os
+from shutil import copyfile
+
+
+from transformers import (
+    AdamW,
+    AutoModel,
+    AutoTokenizer,
+    get_linear_schedule_with_warmup,
+    set_seed,
+)
+
+class AutoModelForSentenceEmbedding(nn.Module):
+    def __init__(self, model_name, tokenizer, normalize=True):
+        super(AutoModelForSentenceEmbedding, self).__init__()
+
+        self.model = AutoModel.from_pretrained(model_name)
+        self.normalize = normalize
+        self.tokenizer = tokenizer
+
+    def forward(self, **kwargs):
+        model_output = self.model(**kwargs)
+        embeddings = self.mean_pooling(model_output, kwargs['attention_mask'])
+        if self.normalize:
+            embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)
+
+        return embeddings
+
+    def mean_pooling(self, model_output, attention_mask):
+        token_embeddings = model_output[0]  # First element of model_output contains all token embeddings
+        input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
+        return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)
+
+    def save_pretrained(self, output_path):
+        if xm.is_master_ordinal():
+            self.tokenizer.save_pretrained(output_path)
+            self.model.config.save_pretrained(output_path)
+
+        xm.save(self.model.state_dict(), os.path.join(output_path, "pytorch_model.bin"))
+       
+
+
+
+def train_function(index, args, queue):
+    tokenizer = AutoTokenizer.from_pretrained(args.model)
+    model = AutoModelForSentenceEmbedding(args.model, tokenizer)
+    
+  
+    ### Train Loop
+    device = xm.xla_device()
+    model = model.to(device)
+
+    # Instantiate optimizer
+    optimizer = AdamW(params=model.parameters(), lr=2e-5, correct_bias=True)
+
+    lr_scheduler = get_linear_schedule_with_warmup(
+        optimizer=optimizer,
+        num_warmup_steps=500,
+        num_training_steps=args.steps,
+    )
+    
+    # Now we train the model
+    cross_entropy_loss = nn.CrossEntropyLoss()
+    max_grad_norm = 1
+
+    model.train()
+   
+    for global_step in tqdm.trange(args.steps, disable=not xm.is_master_ordinal()):
+        #### Get the batch data
+        batch = queue.get()
+        #print(index, "batch {}x{}".format(len(batch), ",".join([str(len(b)) for b in batch])))
+        
+
+        if len(batch[0]) == 2: #(anchor, positive)
+            text1 = tokenizer([b[0] for b in batch], return_tensors="pt", max_length=args.max_length, truncation=True, padding="max_length")
+            text2 = tokenizer([b[1] for b in batch], return_tensors="pt", max_length=args.max_length, truncation=True, padding="max_length")
+
+            ### Compute embeddings
+            embeddings_a = model(**text1.to(device))
+            embeddings_b = model(**text2.to(device))
+            
+            ### Gather all embedings 
+            embeddings_a = torch_xla.core.functions.all_gather(embeddings_a)
+            embeddings_b = torch_xla.core.functions.all_gather(embeddings_b)
+
+            ### Compute similarity scores 512 x 512
+            scores = torch.mm(embeddings_a, embeddings_b.transpose(0, 1)) * args.scale
+        
+            ### Compute cross-entropy loss
+            labels = torch.tensor(range(len(scores)), dtype=torch.long, device=embeddings_a.device)  # BidReviewple a[i] should match with b[i]
+            
+            ## Symmetric loss as in CLIP
+            loss = (cross_entropy_loss(scores, labels) + cross_entropy_loss(scores.transpose(0, 1), labels)) / 2
+
+        else:   #(anchor, positive, negative)
+            text1 = tokenizer([b[0] for b in batch], return_tensors="pt", max_length=args.max_length, truncation=True, padding="max_length")
+            text2 = tokenizer([b[1] for b in batch], return_tensors="pt", max_length=args.max_length, truncation=True, padding="max_length")
+            text3 = tokenizer([b[2] for b in batch], return_tensors="pt", max_length=args.max_length, truncation=True, padding="max_length")
+
+            embeddings_a  = model(**text1.to(device))
+            embeddings_b1 = model(**text2.to(device))
+            embeddings_b2 = model(**text3.to(device))
+
+            embeddings_a  = torch_xla.core.functions.all_gather(embeddings_a)
+            embeddings_b1 = torch_xla.core.functions.all_gather(embeddings_b1)
+            embeddings_b2 = torch_xla.core.functions.all_gather(embeddings_b2)
+
+            embeddings_b = torch.cat([embeddings_b1, embeddings_b2])
+
+            ### Compute similarity scores 512 x 1024
+            scores = torch.mm(embeddings_a, embeddings_b.transpose(0, 1)) * args.scale
+        
+            ### Compute cross-entropy loss
+            labels = torch.tensor(range(len(scores)), dtype=torch.long, device=embeddings_a.device)  # BidReviewple a[i] should match with b[i]
+            
+            ## One-way loss
+            loss = cross_entropy_loss(scores, labels)
+
+        
+        # Backward pass
+        optimizer.zero_grad()
+        loss.backward()
+        torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
+        
+        xm.optimizer_step(optimizer, barrier=True)
+        lr_scheduler.step()
+
+
+        #Save model
+        if (global_step+1) % args.save_steps == 0:
+            output_path = os.path.join(args.output, str(global_step+1))
+            xm.master_print("save model: "+output_path)
+            model.save_pretrained(output_path)
+          
+            
+    output_path = os.path.join(args.output, "final")
+    xm.master_print("save model final: "+ output_path)
+    model.save_pretrained(output_path)
+
+
+def produce_data(args, queue, filepaths, dataset_indices):
+    global_batch_size = args.batch_size*args.nprocs    #Global batch size
+    size_per_dataset = int(global_batch_size / args.datasets_per_batch)    #How many datasets per batch
+    num_same_dataset = int(size_per_dataset / args.batch_size)
+    print("producer", "global_batch_size", global_batch_size)
+    print("producer", "size_per_dataset", size_per_dataset)
+    print("producer", "num_same_dataset", num_same_dataset)
+    
+    datasets = []
+    for filepath in filepaths:
+        if "reddit_" in filepath:       #Special dataset class for Reddit files
+            data_obj = RedditDataset(filepath)
+        else:
+            data_obj = Dataset(filepath)
+        datasets.append(iter(data_obj)) 
+    
+    # Store if dataset is in a 2 col or 3 col format
+    num_cols = {idx: len(next(dataset)) for idx, dataset in enumerate(datasets)}
+
+    while True:
+        texts_in_batch = set()
+        batch_format = None     #2 vs 3 col format for this batch
+        
+        #Add data from several sub datasets
+        for _ in range(args.datasets_per_batch):
+            valid_dataset = False   #Check that datasets have the same 2/3 col format
+            while not valid_dataset:
+                data_idx = random.choice(dataset_indices)
+                if batch_format is None:
+                    batch_format = num_cols[data_idx]
+                    valid_dataset = True
+                else:   #Check that this dataset has the same format
+                    valid_dataset = (batch_format == num_cols[data_idx])
+            
+            #Get data from this dataset
+            dataset = datasets[data_idx]
+            for _ in range(num_same_dataset):
+                for _ in range(args.nprocs):
+                    batch_device = []   #A batch for one device
+                    while len(batch_device) < args.batch_size:
+                        sample = next(dataset)
+                        in_batch = False
+                        for text in sample:
+                            if text in texts_in_batch:
+                                in_batch = True
+                                break
+                        
+                        if not in_batch:
+                            for text in sample:
+                                texts_in_batch.add(text)
+                            batch_device.append(sample)
+
+                    queue.put(batch_device)
+                      
+
+class RedditDataset:
+    """
+    A class that handles the reddit data files
+    """
+    def __init__(self, filepath):
+        self.filepath = filepath
+
+    def __iter__(self):
+        while True:
+            with gzip.open(self.filepath, "rt") as fIn:
+                    for line in fIn:
+                        data = json.loads(line)
+
+                        if "response" in data and "context" in data:
+                            yield [data["response"], data["context"]]
+
+class Dataset:
+    """
+    A class that handles one dataset
+    """
+    def __init__(self, filepath):
+        self.filepath = filepath
+
+    def __iter__(self):
+        max_dataset_size = 10*1000*1000    #Cache small datasets in memory
+        dataset = []
+        data_format = None
+
+        while dataset is None or len(dataset) == 0:
+            with gzip.open(self.filepath, "rt") as fIn:
+                for line in fIn:
+                    data = json.loads(line)
+                    if isinstance(data, dict):
+                        data = data['texts']
+
+                    if data_format is None:
+                        data_format = len(data)
+                    
+                    #Ensure that all entries are of the same 2/3 col format
+                    assert len(data) == data_format
+
+                    if dataset is not None:
+                        dataset.append(data)
+                        if len(dataset) >= max_dataset_size:
+                            dataset = None
+
+                    yield data
+                
+        # Data loaded. Now stream to the queue
+        # Shuffle for each epoch
+        while True:
+            random.shuffle(dataset)
+            for data in dataset:
+                yield data
+                
+               
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--model', default='nreimers/MiniLM-L6-H384-uncased')
+    parser.add_argument('--steps', type=int, default=2000)
+    parser.add_argument('--save_steps', type=int, default=10000)
+    parser.add_argument('--batch_size', type=int, default=64)
+    parser.add_argument('--max_length', type=int, default=128)
+    parser.add_argument('--nprocs', type=int, default=8)
+    parser.add_argument('--datasets_per_batch', type=int, default=2, help="Number of datasets per batch")
+    parser.add_argument('--scale', type=float, default=20, help="Use 20 for cossim, and 1 when you work with unnormalized embeddings with dot product")
+    parser.add_argument('--data_folder', default="/data", help="Folder with your dataset files")
+    parser.add_argument('data_config', help="A data_config.json file")
+    parser.add_argument('output')
+    args = parser.parse_args()
+
+    # Ensure global batch size is divisble by data_sample_size
+    assert (args.batch_size*args.nprocs) % args.datasets_per_batch == 0
+
+    logging.info("Output: "+args.output)
+    if os.path.exists(args.output):
+        print("Output folder already exists.")
+        input("Continue?")
+
+    # Write train script to output path
+    os.makedirs(args.output, exist_ok=True)
+
+    data_config_path = os.path.join(args.output, 'data_config.json')
+    copyfile(args.data_config, data_config_path)
+
+    train_script_path = os.path.join(args.output, 'train_script.py')
+    copyfile(__file__, train_script_path)
+    with open(train_script_path, 'a') as fOut:
+        fOut.write("\n\n# Script was called via:\n#python " + " ".join(sys.argv))
+
+
+
+    #Load data config
+    with open(args.data_config) as fIn:
+        data_config = json.load(fIn)
+
+    queue = mp.Queue(maxsize=100*args.nprocs)
+    
+    filepaths = []
+    dataset_indices = []
+    for idx, data in enumerate(data_config):
+        filepaths.append(os.path.join(os.path.expanduser(args.data_folder), data['name']))
+        dataset_indices.extend([idx]*data['weight'])
+
+    # Start producer
+    p = mp.Process(target=produce_data, args=(args, queue, filepaths, dataset_indices))
+    p.start()
+
+    # Run training
+    print("Start processes:", args.nprocs)
+    xmp.spawn(train_function, args=(args, queue), nprocs=args.nprocs, start_method='fork')
+    print("Training done")
+    print("It might be that not all processes exit automatically. In that case you must manually kill this process.")
+    print("With 'pkill python' you can kill all remaining python processes")
+    p.kill()
+    exit()
+
+
+
+# Script was called via:
+#python train_many_data_files_v2.py --steps 1000000 --batch_size 128 --model nreimers/MiniLM-L6-H384-uncased train_data_configs/all_datasets_v4.json output/all_datasets_v4_MiniLM-L6-H384-uncased-batch128

+ 31 - 0
TenderAgent完整代码/backend/new_main.py

@@ -0,0 +1,31 @@
+import uvicorn
+from fastapi import FastAPI
+# from backend.api.v1 import qualification
+from backend.api.v1 import qa
+from backend.api.v1 import auth
+from backend.api.v1 import bid_review
+from backend.api.v1 import defense
+from backend.mcp.knowledge_base_server import mcp as kb_mcp
+from backend.mcp.web_search_server import mcp as ws_mcp
+from contextlib import asynccontextmanager
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # app.mount() 不会自动传播子应用 lifespan,必须在这里手动启动
+    # _session_manager.run() 初始化 anyio task group,MCP 请求依赖它
+    async with ws_mcp._session_manager.run():
+        async with kb_mcp._session_manager.run():
+            yield
+app = FastAPI(lifespan=lifespan)
+app.include_router(auth.router,   prefix="/api/v1/auth")
+# app.include_router(qa.router,   prefix="/api/v1/qa")
+app.include_router(bid_review.router, prefix="/api/v1/bid_review")
+app.include_router(defense.router, prefix="/api/v1/defense")
+# app.include_router(qualification.router,   prefix="/api/v1/qualification")
+app.mount("/mcp/kb",     kb_mcp.streamable_http_app())
+app.mount("/mcp/web-search", ws_mcp.streamable_http_app())
+
+
+if __name__ == '__main__':
+    uvicorn.run(app, host="0.0.0.0", port=8006)
+

+ 23 - 0
TenderAgent完整代码/check_env.py

@@ -0,0 +1,23 @@
+import asyncio
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+from pymilvus import connections, utility
+
+DB_URL = "postgresql+asyncpg://tenderagent_user:123456@localhost:5433/tenderagent"
+
+async def check_postgres():
+    engine = create_async_engine(DB_URL)
+    async with engine.connect() as conn:
+        r = await conn.execute(text("SELECT 1"))
+        print("✅ PostgreSQL 连通:", r.scalar())
+    await engine.dispose()
+
+def check_milvus():
+    connections.connect(alias="default", host="localhost", port="19531")
+    print("✅ Milvus 连通,已有集合:", utility.list_collections())
+
+async def main():
+    await check_postgres()
+    check_milvus()
+
+asyncio.run(main())

+ 110 - 0
TenderAgent完整代码/docker-compose.yml

@@ -0,0 +1,110 @@
+# docker-compose.yml — TenderAgent 本地开发环境(精简版)
+# 启动:docker-compose --env-file .env.local up -d
+version: "3.9"
+
+services:
+
+  # ── PostgreSQL 主数据库(对外 5433,本机 5432 已占用)──
+  postgres:
+    image: postgres:15-alpine
+    container_name: edu_agent_postgres
+    restart: unless-stopped
+    environment:
+      POSTGRES_DB: ${DB_NAME:-tenderagent}
+      POSTGRES_USER: ${DB_USER}
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+      PGTZ: Asia/Shanghai
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+      # 容器首次启动时自动执行建表脚本(见 3.2 节)
+      - ./scripts/init_db.sql:/docker-entrypoint-initdb.d/01_init_db.sql:ro
+    ports:
+      - "5433:5432"
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME:-tenderagent}"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  # ── etcd(Milvus 元数据存储,仅内部使用,不对外暴露端口)──
+  etcd:
+    image: quay.io/coreos/etcd:v3.5.14
+    container_name: edu_agent_etcd
+    restart: unless-stopped
+    environment:
+      ETCD_AUTO_COMPACTION_MODE: revision
+      ETCD_AUTO_COMPACTION_RETENTION: "1000"
+      ETCD_QUOTA_BACKEND_BYTES: "4294967296"
+    command: >
+      etcd
+      --advertise-client-urls=http://127.0.0.1:2379
+      --listen-client-urls=http://0.0.0.0:2379
+      --data-dir=/etcd
+    volumes:
+      - etcd_data:/etcd
+
+  # ── MinIO(Milvus 向量文件存储,仅内部使用,不对外暴露端口)──
+  minio:
+    image: minio/minio:latest
+    container_name: edu_agent_minio
+    restart: unless-stopped
+    command: server /data
+    environment:
+      MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
+      MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
+    volumes:
+      - minio_data:/data
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+      interval: 15s
+      timeout: 5s
+      retries: 5
+
+  # ── Milvus 向量数据库(对外 19531,本机 19530 已占用)──
+  milvus:
+    image: milvusdb/milvus:v2.4.0
+    container_name: edu_agent_milvus
+    restart: unless-stopped
+    command: ["milvus", "run", "standalone"]
+    environment:
+      ETCD_ENDPOINTS: etcd:2379                 # 连内部 etcd
+      MINIO_ADDRESS: minio:9000                 # 连内部 MinIO
+      MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:-minioadmin}
+      MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:-minioadmin}
+    volumes:
+      - milvus_data:/var/lib/milvus
+    ports:
+      - "19531:19530"
+    depends_on:
+      etcd:
+        condition: service_started
+      minio:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
+      interval: 15s
+      timeout: 10s
+      retries: 10
+  # ── Attu(Milvus 可视化管理界面,对外 30000)──
+  attu:
+    image: zilliz/attu:v2.4.12
+    container_name: edu_agent_attu
+    restart: unless-stopped
+    environment:
+      # Attu 在 Docker 内部访问 Milvus,必须使用服务名 milvus:19530,不能写 localhost:19531
+      MILVUS_URL: milvus:19530
+      SERVER_PORT: "3000"
+      TZ: Asia/Shanghai
+    ports:
+      # 不写 127.0.0.1,默认绑定 0.0.0.0,外部可通过 http://192.168.88.100:30000 访问
+      - "30000:3000"
+    depends_on:
+      milvus:
+        condition: service_healthy
+
+# ── 数据卷(持久化,重启容器数据不丢失)──
+volumes:
+  postgres_data:
+  etcd_data:
+  minio_data:
+  milvus_data:

+ 13 - 0
TenderAgent完整代码/frontend/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>TenderAgent — AI 评审辅助系统</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 2163 - 0
TenderAgent完整代码/frontend/package-lock.json

@@ -0,0 +1,2163 @@
+{
+  "name": "edu-agent-frontend",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "edu-agent-frontend",
+      "version": "0.1.0",
+      "dependencies": {
+        "@element-plus/icons-vue": "^2.3.1",
+        "axios": "^1.7.2",
+        "element-plus": "^2.7.2",
+        "highlight.js": "^11.10.0",
+        "markdown-it": "^14.1.0",
+        "pinia": "^2.1.7",
+        "uuid": "^10.0.0",
+        "vue": "^3.4.21",
+        "vue-router": "^4.3.0"
+      },
+      "devDependencies": {
+        "@types/markdown-it": "^14.1.2",
+        "@types/uuid": "^10.0.0",
+        "@vitejs/plugin-vue": "^5.0.4",
+        "typescript": "^5.4.0",
+        "vite": "^5.2.0",
+        "vue-tsc": "^2.0.6"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz",
+      "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.7"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
+      "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+      "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+      "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz",
+      "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz",
+      "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.5",
+        "@floating-ui/utils": "^0.2.11"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.11",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz",
+      "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.8",
+      "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+      "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+      "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+      "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+      "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+      "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+      "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+      "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+      "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+      "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+      "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+      "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+      "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+      "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+      "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+      "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+      "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+      "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+      "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+      "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+      "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+      "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+      "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+      "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+      "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+      "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+      "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.24",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
+      "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "node_modules/@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/uuid": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz",
+      "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.21",
+      "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+      "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
+      "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "2.4.15"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz",
+      "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.15",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz",
+      "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.15",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
+      "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.7",
+        "@vue/shared": "3.5.39",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-core/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
+      "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.39",
+        "@vue/shared": "3.5.39"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
+      "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.7",
+        "@vue/compiler-core": "3.5.39",
+        "@vue/compiler-dom": "3.5.39",
+        "@vue/compiler-ssr": "3.5.39",
+        "@vue/shared": "3.5.39",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.15",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
+      "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.39",
+        "@vue/shared": "3.5.39"
+      }
+    },
+    "node_modules/@vue/compiler-vue2": {
+      "version": "2.7.16",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+      "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/language-core": {
+      "version": "2.2.12",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz",
+      "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.15",
+        "@vue/compiler-dom": "^3.5.0",
+        "@vue/compiler-vue2": "^2.7.16",
+        "@vue/shared": "^3.5.0",
+        "alien-signals": "^1.0.3",
+        "minimatch": "^9.0.3",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz",
+      "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.39"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
+      "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.39",
+        "@vue/shared": "3.5.39"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
+      "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.39",
+        "@vue/runtime-core": "3.5.39",
+        "@vue/shared": "3.5.39",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
+      "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.39",
+        "@vue/shared": "3.5.39"
+      },
+      "peerDependencies": {
+        "vue": "3.5.39"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz",
+      "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
+      "license": "MIT"
+    },
+    "node_modules/@vueuse/core": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
+      "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.21",
+        "@vueuse/metadata": "14.3.0",
+        "@vueuse/shared": "14.3.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz",
+      "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "14.3.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz",
+      "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/alien-signals": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
+      "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "license": "Python-2.0"
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.18.1",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz",
+      "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.16.0",
+        "form-data": "^4.0.5",
+        "https-proxy-agent": "^5.0.1",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
+      "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.21",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
+      "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+      "license": "MIT"
+    },
+    "node_modules/de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/element-plus": {
+      "version": "2.14.2",
+      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.2.tgz",
+      "integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^4.2.0",
+        "@element-plus/icons-vue": "^2.3.2",
+        "@floating-ui/dom": "^1.7.6",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+        "@types/lodash": "^4.17.24",
+        "@types/lodash-es": "^4.17.12",
+        "@vueuse/core": "14.3.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.20",
+        "lodash": "^4.18.1",
+        "lodash-es": "^4.18.1",
+        "lodash-unified": "^1.0.3",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0",
+        "vue-component-type-helpers": "^3.3.3"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.7"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz",
+      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.4",
+        "mime-types": "^2.1.35"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/highlight.js": {
+      "version": "11.11.1",
+      "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
+      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/linkify-it": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.1.tgz",
+      "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/markdown-it"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/markdown-it": {
+      "version": "14.2.0",
+      "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.2.0.tgz",
+      "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/markdown-it"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "^4.4.0",
+        "linkify-it": "^5.0.1",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+      "license": "MIT"
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.9",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.15",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz",
+      "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/pinia": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz",
+      "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.3",
+        "vue-demi": "^0.14.10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.15",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
+      "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.62.2",
+        "@rollup/rollup-android-arm64": "4.62.2",
+        "@rollup/rollup-darwin-arm64": "4.62.2",
+        "@rollup/rollup-darwin-x64": "4.62.2",
+        "@rollup/rollup-freebsd-arm64": "4.62.2",
+        "@rollup/rollup-freebsd-x64": "4.62.2",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+        "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+        "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+        "@rollup/rollup-linux-arm64-musl": "4.62.2",
+        "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+        "@rollup/rollup-linux-loong64-musl": "4.62.2",
+        "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+        "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+        "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+        "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+        "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+        "@rollup/rollup-linux-x64-gnu": "4.62.2",
+        "@rollup/rollup-linux-x64-musl": "4.62.2",
+        "@rollup/rollup-openbsd-x64": "4.62.2",
+        "@rollup/rollup-openharmony-arm64": "4.62.2",
+        "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+        "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+        "@rollup/rollup-win32-x64-gnu": "4.62.2",
+        "@rollup/rollup-win32-x64-msvc": "4.62.2",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+      "license": "MIT"
+    },
+    "node_modules/uuid": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
+      "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
+      "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vue": {
+      "version": "3.5.39",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz",
+      "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.39",
+        "@vue/compiler-sfc": "3.5.39",
+        "@vue/runtime-dom": "3.5.39",
+        "@vue/server-renderer": "3.5.39",
+        "@vue/shared": "3.5.39"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-component-type-helpers": {
+      "version": "3.3.5",
+      "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz",
+      "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==",
+      "license": "MIT"
+    },
+    "node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "2.2.12",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
+      "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "2.4.15",
+        "@vue/language-core": "2.2.12"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    }
+  }
+}

+ 30 - 0
TenderAgent完整代码/frontend/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "edu-agent-frontend",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue": "^3.4.21",
+    "vue-router": "^4.3.0",
+    "pinia": "^2.1.7",
+    "element-plus": "^2.7.2",
+    "@element-plus/icons-vue": "^2.3.1",
+    "axios": "^1.7.2",
+    "markdown-it": "^14.1.0",
+    "highlight.js": "^11.10.0",
+    "uuid": "^10.0.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.4",
+    "vite": "^5.2.0",
+    "typescript": "^5.4.0",
+    "vue-tsc": "^2.0.6",
+    "@types/markdown-it": "^14.1.2",
+    "@types/uuid": "^10.0.0"
+  }
+}

+ 3 - 0
TenderAgent完整代码/frontend/src/App.vue

@@ -0,0 +1,3 @@
+<template>
+  <router-view />
+</template>

+ 22 - 0
TenderAgent完整代码/frontend/src/api/auth.ts

@@ -0,0 +1,22 @@
+import client from './client'
+
+export interface LoginRequest {
+  username: string
+  password: string
+}
+
+export interface LoginResponse {
+  access_token: string
+  token_type: string
+  expires_in: number
+  role: string
+  user_id: string
+}
+
+export const authApi = {
+  login: (data: LoginRequest) =>
+    client.post<LoginResponse>('/auth/login', data),
+
+  me: () =>
+    client.get('/auth/me'),
+}

+ 108 - 0
TenderAgent完整代码/frontend/src/api/bid_review.ts

@@ -0,0 +1,108 @@
+import client from './client'
+
+export interface BidReviewSubmitResponse {
+  submission_id: string
+  status: string
+  message: string
+}
+
+export interface PendingReviewItem {
+  submission_id: string
+  bidder_name: string
+  bid_review_title: string
+  submitted_at: string
+  pre_review: {
+    total_score: number
+    full_score: number
+    needs_review_count: number
+  }
+  weak_points: Array<{ tag: string; wrong_count: number; total_count?: number; question_nos?: number[]; suggestion?: string }>
+}
+
+export interface ReviewDetail {
+  submission_id: string
+  bidder_id: string
+  pre_review_summary: {
+    total_score: number
+    full_score: number
+    by_question: Array<{
+      question_id: string
+      question_no: number
+      question_type: string
+      full_score: number
+      score: number
+      content?: string
+      bidder_response: string
+      correct_answer?: string
+      ai_feedback: string
+      needs_review: boolean
+      point_results?: Array<{
+        point_score: number
+        point_desc: string
+        earned: boolean
+        missing?: string
+      }>
+      test_cases_passed?: number
+      test_cases_total?: number
+      sandbox_skipped?: boolean
+      quality_feedback?: string[]
+      expert_comment?: string
+      final_score?: number
+    }>
+  }
+  weak_points: Array<{ tag: string; wrong_count: number; total_count?: number; question_nos?: number[]; suggestion?: string }>
+  weak_points_summary: string
+}
+
+export interface ConfirmRequest {
+  action: 'approve' | 'modify'
+  modifications: Array<{
+    question_id: string
+    new_score?: number
+    comment?: string
+  }>
+}
+
+export interface ConfirmResponse {
+  submission_id: string
+  status: string
+  final_score: number
+  full_score: number
+  score_rate: number
+  weak_points: Array<{ tag: string; wrong_count: number; total_count?: number; question_nos?: number[]; suggestion?: string }>
+  weak_points_summary: string
+}
+
+export interface MySubmissionItem {
+  submission_id: string
+  bid_review_id: string
+  bid_review_title: string
+  status: string
+  submitted_at: string
+}
+
+export const bid_reviewApi = {
+  listMySubmissions: () =>
+    client.get<{ items: MySubmissionItem[] }>('/bid_review/my-submissions'),
+
+  submit: (bid_reviewId: string, file: File) => {
+    const form = new FormData()
+    form.append('bid_review_id', bid_reviewId)
+    form.append('file', file)
+    return client.post<BidReviewSubmitResponse>('/bid_review/submit', form, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+    })
+  },
+
+  getPendingReviews: () =>
+    client.get<{ items: PendingReviewItem[]; total: number }>('/bid_review/pending-reviews'),
+
+  getSubmissionReview: (submissionId: string) =>
+    client.get<ReviewDetail>(`/bid_review/my-submissions/${submissionId}`),
+
+  getSubmissionReviewExpert: (submissionId: string) =>
+    client.get<ReviewDetail>(`/bid_review/submissions/${submissionId}/review`),
+
+  confirmReview: (submissionId: string, data: ConfirmRequest) =>
+    client.post<ConfirmResponse>(`/bid_review/submissions/${submissionId}/confirm`, data),
+}

+ 47 - 0
TenderAgent完整代码/frontend/src/api/client.ts

@@ -0,0 +1,47 @@
+import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
+import { ElMessage } from 'element-plus'
+import router from '@/router'
+import { useAuthStore } from '@/stores/auth'
+
+const client = axios.create({
+  baseURL: (import.meta.env.VITE_API_BASE_URL ?? '') + '/api/v1',
+  timeout: 30_000,  // 缩短超时时间,避免长时间占用连接
+})
+
+// 请求拦截器:注入 JWT(懒加载 store 避免循环依赖)
+client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
+  const token = localStorage.getItem('edu-agent-token')
+  if (token) config.headers.Authorization = `Bearer ${token}`
+  return config
+})
+
+// 响应拦截器:统一错误处理
+client.interceptors.response.use(
+  res => res,
+  (error: AxiosError<{ detail: string }>) => {
+    const status = error.response?.status
+    const detail = error.response?.data?.detail
+
+    if (status === 401) {
+      // 同时清除 Pinia store(响应式 token)和 localStorage,
+      // 否则 auth.isLoggedIn 仍为 true,router.push('/login') 会被导航守卫重定向回 /dashboard
+      useAuthStore().logout()
+      router.push('/login').catch(() => {})
+      return Promise.reject(error)
+    }
+
+    if (status === 403) {
+      ElMessage.error('权限不足')
+    } else if (status === 429) {
+      ElMessage.warning('请求过于频繁,请稍后再试')
+    } else if (status && status >= 500) {
+      ElMessage.error(detail ?? '服务异常,请稍后重试')
+    } else if (detail) {
+      ElMessage.error(detail)
+    }
+
+    return Promise.reject(error)
+  },
+)
+
+export default client

+ 172 - 0
TenderAgent完整代码/frontend/src/api/defense.ts

@@ -0,0 +1,172 @@
+import client from './client'
+
+export interface StartSessionRequest {
+  target_position: string
+  qualification_review_id?: string | null
+}
+
+export interface StartSessionResponse {
+  session_id: string
+  target_position: string
+  status: string
+  message: string
+}
+
+export interface ChatRequest {
+  message: string
+}
+
+export interface ChatResponse {
+  session_id: string
+  reply: string
+  current_stage: string
+  total_turns: number
+  is_finished: boolean
+  report_summary?: {
+    overall_score: number
+    strengths: string[]
+    improvements: string[]
+  }
+}
+
+export interface ChatDonePayload {
+  type: 'done'
+  reply: string          // AI 本轮回复全文(generate_response_node 用 ainvoke,token 不流式)
+  current_stage: string
+  total_turns: number
+  is_finished: boolean
+  report_summary?: {
+    overall_score: number
+    strengths: string[]
+    improvements: string[]
+  }
+}
+
+export interface ChatStreamCallbacks {
+  onToken: (chunk: string) => void
+  onDone: (payload: ChatDonePayload) => void
+  onError: (err: Error) => void
+}
+
+export interface DimensionEval {
+  dimension: string
+  score: number
+  comment: string
+}
+
+export interface DefenseReport {
+  session_id: string
+  target_position: string
+  overall_score: number
+  dimensions: DimensionEval[]
+  strengths: string[]
+  improvements: string[]
+  overall_comment: string
+  recommended_topics: string[]
+  next_step_advice: string
+}
+
+export interface SessionListItem {
+  session_id: string
+  target_position: string
+  overall_score?: number
+  status: string
+  finished_at?: string
+  created_at: string
+}
+
+export const defenseApi = {
+  startSession: (data: StartSessionRequest) =>
+    client.post<StartSessionResponse>('/defense/sessions', data),
+
+  chat: (sessionId: string, data: ChatRequest) =>
+    client.post<ChatResponse>(`/defense/sessions/${sessionId}/chat`, data),
+
+  /**
+   * 流式对话(SSE)。
+   * 使用 fetch + ReadableStream 消费,支持实时 token 渲染。
+   * onToken 在每个 token 到达时触发,onDone 在流结束时附带最终状态。
+   */
+  chatStream(sessionId: string, message: string, callbacks: ChatStreamCallbacks): void {
+    const token = localStorage.getItem('edu-agent-token') || ''
+    // 直接请求后端,不经过 Vite proxy(避免 proxy 缓冲 SSE 帧,同 QAChatView)
+    const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? 'http://localhost:8000'
+    // 标记 done/error 事件是否已分发,用于流结束时的兜底处理
+    let doneCalled = false
+
+    const dispatch = (line: string) => {
+      if (!line.startsWith('data: ')) return
+      try {
+        const payload = JSON.parse(line.slice(6))
+        if (payload.type === 'token') {
+          callbacks.onToken(payload.content)
+        } else if (payload.type === 'done') {
+          doneCalled = true
+          callbacks.onDone(payload as ChatDonePayload)
+        } else if (payload.type === 'error') {
+          doneCalled = true
+          callbacks.onError(new Error(payload.message ?? '流式输出异常'))
+        }
+      } catch {
+        // 忽略心跳、空行等非 JSON 内容
+      }
+    }
+
+    fetch(`${apiBase}/api/v1/defense/sessions/${sessionId}/chat/stream`, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({ message }),
+    })
+      .then(async (resp) => {
+        if (!resp.ok || !resp.body) {
+          callbacks.onError(new Error(`HTTP ${resp.status}`))
+          return
+        }
+
+        const reader = resp.body.getReader()
+        const decoder = new TextDecoder()
+        let buffer = ''
+
+        while (true) {
+          const { done, value } = await reader.read()
+          if (done) break
+
+          buffer += decoder.decode(value, { stream: true })
+          // SSE 每条消息以 "\n\n" 结尾,逐段解析
+          const parts = buffer.split('\n\n')
+          buffer = parts.pop() ?? ''   // 末尾不完整的部分留到下次
+
+          for (const part of parts) {
+            for (const line of part.split('\n')) {
+              dispatch(line)
+            }
+          }
+        }
+
+        // 流关闭后处理 buffer 中可能残留的最后一帧
+        // (极少数情况:最后一个 chunk 末尾没有完整 \n\n)
+        if (buffer.trim()) {
+          for (const line of buffer.split('\n')) {
+            dispatch(line)
+          }
+        }
+
+        // 兜底:若连接关闭时 done/error 均未触发,主动通知前端解除等待
+        if (!doneCalled) {
+          callbacks.onError(new Error('连接已断开,未收到完整响应'))
+        }
+      })
+      .catch((err) => {
+        if (!doneCalled) callbacks.onError(err)
+      })
+  },
+
+  getReport: (sessionId: string) =>
+    client.get<DefenseReport>(`/defense/sessions/${sessionId}/report`),
+
+  listSessions: () =>
+    client.get<{ items: SessionListItem[]; total: number }>('/defense/sessions'),
+}

+ 31 - 0
TenderAgent完整代码/frontend/src/api/qa.ts

@@ -0,0 +1,31 @@
+import client from './client'
+
+export interface ChatRequest {
+  session_id: string
+  tender_document_id?: string | null
+  message: string
+}
+
+export interface ChatResponse {
+  session_id: string
+  answer: string
+  answer_mode: 'rag' | 'llm_direct'
+  confidence: number
+  sources: string[]
+  fallback_used: boolean
+}
+
+export interface HistoryResponse {
+  session_id: string
+  messages: Array<{ role: string; content: string; created_at: string }>
+  summary: string | null
+  total_turns: number
+}
+
+export const qaApi = {
+  chat: (data: ChatRequest) =>
+    client.post<ChatResponse>('/qa/chat', data),
+
+  getHistory: (sessionId: string) =>
+    client.get<HistoryResponse>(`/qa/sessions/${sessionId}/history`),
+}

+ 67 - 0
TenderAgent完整代码/frontend/src/api/qualification.ts

@@ -0,0 +1,67 @@
+import client from './client'
+
+export interface QualificationUploadResponse {
+  review_id: string
+  status: string
+  message: string
+}
+
+export interface DimensionScore {
+  key: string
+  dimension: string
+  score: number
+  weight: number
+  issues: string[]
+  suggestions: string[]
+}
+
+export interface IssueItem {
+  priority: 'high' | 'medium' | 'low'
+  dimension: string
+  description: string
+  location: string
+  suggestion: string
+}
+
+export interface QualificationSummary {
+  highlights: string[]
+  core_improvements: string[]
+  overall_comment: string
+  fit_assessment: string
+}
+
+export interface ReviewDetail {
+  review_id: string
+  status: 'processing' | 'done' | 'failed'
+  error_msg?: string
+  weighted_score?: number
+  dimension_scores?: DimensionScore[]
+  issues?: IssueItem[]
+  summary?: QualificationSummary
+}
+
+export interface ReviewListItem {
+  review_id: string
+  status: string
+  created_at: string
+  weighted_score?: number
+}
+
+export const qualificationApi = {
+  upload: (file: File) => {
+    const form = new FormData()
+    form.append('file', file)
+    return client.post<QualificationUploadResponse>('/qualification/upload', form, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+    })
+  },
+
+  getReview: (reviewId: string) =>
+    client.get<ReviewDetail>(`/qualification/reviews/${reviewId}`),
+
+  listReviews: () =>
+    client.get<{ items: ReviewListItem[]; total: number }>('/qualification/reviews'),
+
+  deleteReview: (reviewId: string) =>
+    client.delete(`/qualification/reviews/${reviewId}`),
+}

+ 78 - 0
TenderAgent完整代码/frontend/src/components/chat/ChatBubble.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="chat-bubble" :class="role">
+    <div class="avatar">
+      <span v-if="role === 'assistant'">🤖</span>
+      <span v-else>👤</span>
+    </div>
+    <div class="bubble-body">
+      <div class="bubble-content">
+        <slot />
+      </div>
+      <div v-if="role === 'assistant' && sources?.length" class="sources">
+        <el-collapse>
+          <el-collapse-item title="参考来源" name="sources">
+            <ul>
+              <li v-for="(src, i) in sources" :key="i">{{ src }}</li>
+            </ul>
+          </el-collapse-item>
+        </el-collapse>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+defineProps<{
+  role: 'user' | 'assistant'
+  sources?: string[]
+}>()
+</script>
+
+<style scoped>
+.chat-bubble {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 16px;
+}
+.chat-bubble.user {
+  flex-direction: row-reverse;
+}
+.avatar {
+  font-size: 24px;
+  flex-shrink: 0;
+  width: 36px;
+  height: 36px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.bubble-body {
+  max-width: 72%;
+}
+.bubble-content {
+  padding: 10px 14px;
+  border-radius: 12px;
+  font-size: 14px;
+  line-height: 1.6;
+  word-break: break-word;
+}
+.user .bubble-content {
+  background: #1677ff;
+  color: #fff;
+  border-bottom-right-radius: 4px;
+}
+.assistant .bubble-content {
+  background: #fff;
+  border: 1px solid #f0f0f0;
+  border-bottom-left-radius: 4px;
+}
+.sources {
+  margin-top: 6px;
+  font-size: 12px;
+}
+.sources ul {
+  margin: 0;
+  padding-left: 16px;
+  color: #595959;
+}
+</style>

+ 53 - 0
TenderAgent完整代码/frontend/src/components/chat/MarkdownRenderer.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="md-content" v-html="rendered" />
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import MarkdownIt from 'markdown-it'
+import hljs from 'highlight.js'
+
+const props = defineProps<{ content: string }>()
+
+const md = new MarkdownIt({
+  html: false,
+  linkify: true,
+  typographer: true,
+  highlight(str, lang) {
+    if (lang && hljs.getLanguage(lang)) {
+      try {
+        return (
+          '<pre class="hljs"><code>' +
+          hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
+          '</code></pre>'
+        )
+      } catch { /* ignore */ }
+    }
+    return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'
+  },
+})
+
+const rendered = computed(() => md.render(props.content))
+</script>
+
+<style scoped>
+.md-content :deep(p) { margin: 0 0 8px; }
+.md-content :deep(p:last-child) { margin-bottom: 0; }
+.md-content :deep(pre.hljs) {
+  border-radius: 6px;
+  padding: 12px;
+  overflow-x: auto;
+  font-size: 13px;
+  margin: 8px 0;
+}
+.md-content :deep(code:not(pre code)) {
+  background: #f5f5f5;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-size: 13px;
+}
+.md-content :deep(ul), .md-content :deep(ol) {
+  padding-left: 20px;
+  margin: 4px 0;
+}
+</style>

+ 223 - 0
TenderAgent完整代码/frontend/src/components/chat/PipelinePlanCard.vue

@@ -0,0 +1,223 @@
+<template>
+  <div class="pipeline-card">
+    <!-- 标题栏 -->
+    <div class="pipeline-header">
+      <el-icon class="pipeline-icon"><Share /></el-icon>
+      <span class="pipeline-title">{{ title }}</span>
+      <el-tag type="warning" size="small" class="pipeline-tag">多 Agent 协同</el-tag>
+    </div>
+
+    <!-- 简介 -->
+    <p class="pipeline-intro">{{ intro }}</p>
+
+    <!-- 步骤列表 -->
+    <div class="pipeline-steps">
+      <div
+        v-for="(step, index) in steps"
+        :key="step.step"
+        class="step-wrapper"
+      >
+        <!-- 步骤卡片 -->
+        <div class="step-card">
+          <div class="step-left">
+            <div class="step-badge" :class="`step-badge--${step.agent_type}`">
+              {{ agentIcon[step.agent_type] ?? '🤖' }}
+            </div>
+            <div class="step-info">
+              <div class="step-label">
+                <span class="step-num">Step {{ step.step }}</span>
+                <span class="step-name">{{ step.label }}</span>
+              </div>
+              <div class="step-desc">{{ step.desc }}</div>
+              <div class="step-tip">
+                <el-icon><InfoFilled /></el-icon>
+                {{ step.tip }}
+              </div>
+            </div>
+          </div>
+          <el-button
+            type="primary"
+            :plain="step.step > 1"
+            size="small"
+            class="step-btn"
+            @click="router.push(step.action_url)"
+          >
+            {{ step.action_label }} →
+          </el-button>
+        </div>
+
+        <!-- 步骤间箭头(最后一步不显示) -->
+        <div v-if="index < steps.length - 1" class="step-arrow">
+          <el-icon><ArrowDown /></el-icon>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+import { Share, InfoFilled, ArrowDown } from '@element-plus/icons-vue'
+
+defineProps<{
+  title: string
+  intro: string
+  steps: Array<{
+    step: number
+    agent_type: string
+    label: string
+    desc: string
+    action_label: string
+    action_url: string
+    tip: string
+  }>
+}>()
+
+const router = useRouter()
+
+const agentIcon: Record<string, string> = {
+  qualification:    '📄',
+  defense: '🎤',
+  bid_review:      '📝',
+  qa:        '🤖',
+}
+</script>
+
+<style scoped>
+.pipeline-card {
+  background: linear-gradient(135deg, #fffbe6 0%, #fff7e6 100%);
+  border: 1px solid #ffd666;
+  border-radius: 8px;
+  padding: 12px 16px;
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+
+.pipeline-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 8px;
+  font-weight: 600;
+  color: #d46b08;
+}
+
+.pipeline-icon {
+  font-size: 15px;
+}
+
+.pipeline-title {
+  flex: 1;
+  font-size: 14px;
+}
+
+.pipeline-tag {
+  font-size: 11px;
+}
+
+.pipeline-intro {
+  margin: 0 0 12px;
+  color: #595959;
+  line-height: 1.6;
+  font-size: 12.5px;
+}
+
+.pipeline-steps {
+  display: flex;
+  flex-direction: column;
+}
+
+.step-wrapper {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}
+
+.step-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #fff;
+  border: 1px solid #ffe58f;
+  border-radius: 6px;
+  padding: 10px 12px;
+  gap: 12px;
+}
+
+.step-left {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  flex: 1;
+}
+
+.step-badge {
+  width: 36px;
+  height: 36px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+  flex-shrink: 0;
+  background: #fff7e6;
+}
+
+.step-badge--qualification    { background: #e6f4ff; }
+.step-badge--defense { background: #fff0f6; }
+.step-badge--bid_review      { background: #f6ffed; }
+.step-badge--qa        { background: #f0f7ff; }
+
+.step-info {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  flex: 1;
+}
+
+.step-label {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.step-num {
+  font-size: 11px;
+  color: #8c8c8c;
+  background: #f5f5f5;
+  padding: 1px 5px;
+  border-radius: 3px;
+}
+
+.step-name {
+  font-weight: 600;
+  color: #262626;
+  font-size: 13px;
+}
+
+.step-desc {
+  color: #595959;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.step-tip {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  color: #d46b08;
+  font-size: 11.5px;
+}
+
+.step-btn {
+  flex-shrink: 0;
+}
+
+.step-arrow {
+  display: flex;
+  justify-content: center;
+  padding: 4px 0;
+  color: #ffa940;
+  font-size: 16px;
+}
+</style>

+ 116 - 0
TenderAgent完整代码/frontend/src/components/chat/RoutingDecisionCard.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="routing-card">
+    <div class="routing-header">
+      <el-icon class="routing-icon"><Connection /></el-icon>
+      <span class="routing-title">路由决策</span>
+      <el-tag :type="confidenceTagType" size="small" class="confidence-tag">
+        {{ Math.round(confidence * 100) }}% 置信度
+      </el-tag>
+    </div>
+    <div class="routing-body">
+      <div class="routing-agent">
+        <span class="label">识别意图</span>
+        <el-tag :type="agentTagType" size="small">
+          {{ agentIcon }} {{ agentDisplay }}
+        </el-tag>
+      </div>
+      <div class="routing-reason">
+        <span class="label">判断依据</span>
+        <span class="reason-text">{{ reason }}</span>
+      </div>
+      <div class="routing-mode">
+        <span class="label">执行模式</span>
+        <span class="mode-text">{{ modeDisplay }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { Connection } from '@element-plus/icons-vue'
+
+const props = defineProps<{
+  agentType: string
+  agentDisplay: string
+  confidence: number
+  reason: string
+  executionMode: string
+}>()
+
+const agentIcon: Record<string, string> = {
+  qa: '🤖', bid_review: '📝', qualification: '📄', defense: '🎤',
+}
+
+const agentTagType = computed(() => {
+  const map: Record<string, string> = {
+    qa: 'success', bid_review: 'warning', qualification: 'info', defense: 'danger',
+  }
+  return map[props.agentType] ?? 'info'
+})
+
+const confidenceTagType = computed(() => {
+  if (props.confidence >= 0.85) return 'success'
+  if (props.confidence >= 0.65) return 'warning'
+  return 'danger'
+})
+
+const modeDisplay = computed(() => {
+  const map: Record<string, string> = {
+    single:   '单 Agent 直达',
+    pipeline: '多 Agent 协同',
+    clarify:  '需要澄清',
+  }
+  return map[props.executionMode] ?? props.executionMode
+})
+</script>
+
+<style scoped>
+.routing-card {
+  background: #f0f7ff;
+  border: 1px solid #bae0ff;
+  border-radius: 8px;
+  padding: 10px 14px;
+  margin-bottom: 4px;
+  font-size: 13px;
+}
+.routing-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 8px;
+  font-weight: 500;
+  color: #1677ff;
+}
+.routing-icon {
+  font-size: 14px;
+}
+.routing-title {
+  flex: 1;
+}
+.confidence-tag {
+  font-size: 11px;
+}
+.routing-body {
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+}
+.routing-agent,
+.routing-reason,
+.routing-mode {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+}
+.label {
+  color: #8c8c8c;
+  min-width: 52px;
+  flex-shrink: 0;
+}
+.reason-text,
+.mode-text {
+  color: #262626;
+  line-height: 1.5;
+}
+</style>

+ 97 - 0
TenderAgent完整代码/frontend/src/components/defense/StageProgressBar.vue

@@ -0,0 +1,97 @@
+<template>
+  <div class="stage-bar">
+    <div
+      v-for="(stage, i) in stages"
+      :key="stage.key"
+      class="stage-item"
+      :class="{
+        done: stageOrder.indexOf(stage.key) < currentIndex,
+        active: stage.key === current,
+        pending: stageOrder.indexOf(stage.key) > currentIndex,
+      }"
+    >
+      <div class="stage-dot">
+        <el-icon v-if="stageOrder.indexOf(stage.key) < currentIndex"><Check /></el-icon>
+        <span v-else>{{ i + 1 }}</span>
+      </div>
+      <div class="stage-label">{{ stage.label }}</div>
+      <div v-if="i < stages.length - 1" class="stage-line" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { Check } from '@element-plus/icons-vue'
+
+const stages = [
+  { key: 'warmup', label: '热身' },
+  { key: 'tech_base', label: '技术基础' },
+  { key: 'project', label: '项目深挖' },
+  { key: 'closing', label: '总结' },
+  { key: 'finished', label: '完成' },
+]
+
+const stageOrder = stages.map(s => s.key)
+
+const props = defineProps<{ current: string }>()
+
+const currentIndex = computed(() => stageOrder.indexOf(props.current))
+</script>
+
+<style scoped>
+.stage-bar {
+  display: flex;
+  align-items: center;
+  padding: 12px 0;
+  position: relative;
+}
+.stage-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  position: relative;
+  flex: 1;
+}
+.stage-dot {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  font-weight: 500;
+  border: 2px solid #d9d9d9;
+  background: #fff;
+  color: #8c8c8c;
+  z-index: 1;
+}
+.stage-item.done .stage-dot {
+  background: #52c41a;
+  border-color: #52c41a;
+  color: #fff;
+}
+.stage-item.active .stage-dot {
+  background: #1677ff;
+  border-color: #1677ff;
+  color: #fff;
+}
+.stage-label {
+  font-size: 12px;
+  margin-top: 4px;
+  color: #8c8c8c;
+}
+.stage-item.active .stage-label { color: #1677ff; font-weight: 500; }
+.stage-item.done .stage-label { color: #52c41a; }
+.stage-line {
+  position: absolute;
+  top: 14px;
+  left: 50%;
+  width: 100%;
+  height: 2px;
+  background: #d9d9d9;
+  z-index: 0;
+}
+.stage-item.done .stage-line { background: #52c41a; }
+</style>

+ 133 - 0
TenderAgent完整代码/frontend/src/components/layout/AppLayout.vue

@@ -0,0 +1,133 @@
+<template>
+  <el-container class="app-layout">
+    <el-aside width="200px" class="sidebar-aside">
+      <Sidebar />
+    </el-aside>
+    <el-container direction="vertical">
+      <el-header class="app-header">
+        <span class="header-title">TenderAgent</span>
+        <div class="header-right">
+          <span class="username">{{ auth.user?.username ?? auth.user?.userId }}</span>
+          <el-dropdown @command="handleCommand">
+            <el-button text :icon="ArrowDown" />
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="logout">退出登录</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </div>
+      </el-header>
+      <!-- 路由懒加载进度条:点击菜单项后立即可见,消除"没反应"假象 -->
+      <div class="nav-progress-bar" :class="{ active: navigating }" />
+      <el-main class="app-main">
+        <!-- keep-alive 仅保留 QAChatView 状态,导航离开时冻结而非销毁,
+             保证会话列表和消息记录在切换 Agent 后完整保留。
+             routerViewKey 变化(错误恢复)时会销毁缓存,这是可接受的折中。 -->
+        <router-view v-slot="{ Component }" :key="routerViewKey">
+          <keep-alive :include="['QAChatView']">
+            <component :is="Component" />
+          </keep-alive>
+        </router-view>
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<script setup lang="ts">
+import { ref, onErrorCaptured } from 'vue'
+import { ArrowDown } from '@element-plus/icons-vue'
+import { useRouter } from 'vue-router'
+import { useAuthStore } from '@/stores/auth'
+import Sidebar from './Sidebar.vue'
+
+const auth = useAuthStore()
+const router = useRouter()
+
+// 路由切换期间(含懒加载 JS chunk 下载)显示顶部进度条
+const navigating = ref(false)
+router.beforeEach(() => { navigating.value = true })
+router.afterEach(() => { navigating.value = false })
+
+// 错误边界:捕获 RouterView 及所有子组件的渲染崩溃。
+// 当 RouterView 的 componentUpdateFn 因 DOM 状态异常(el = null)抛出时,
+// 通过更换 key 强制 RouterView 重新挂载,使其渲染当前正确路由,避免页面永久卡死。
+const routerViewKey = ref(0)
+let lastRecoveryAt = 0
+onErrorCaptured((_err, _instance, _info) => {
+  const now = Date.now()
+  // 500ms 冷却,防止同一错误循环触发
+  if (now - lastRecoveryAt > 500) {
+    lastRecoveryAt = now
+    routerViewKey.value++
+  }
+  return false // 阻止错误继续向上冒泡
+})
+
+function handleCommand(cmd: string) {
+  if (cmd === 'logout') {
+    auth.logout()
+    router.push('/login')
+  }
+}
+</script>
+
+<style scoped>
+.app-layout {
+  height: 100vh;
+}
+.sidebar-aside {
+  background: #001529;
+  overflow: hidden;
+}
+.app-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #fff;
+  border-bottom: 1px solid #f0f0f0;
+  padding: 0 24px;
+  height: 56px;
+}
+.header-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #1677ff;
+}
+.header-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.username {
+  font-size: 14px;
+  color: #595959;
+}
+/* 路由进度条:高度 3px,懒加载期间动态扫描动画 */
+.nav-progress-bar {
+  height: 3px;
+  background: transparent;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+.nav-progress-bar.active {
+  background: #e8f0fe;
+}
+.nav-progress-bar.active::after {
+  content: '';
+  display: block;
+  height: 100%;
+  width: 40%;
+  background: #1677ff;
+  animation: nav-scan 0.9s ease-in-out infinite;
+}
+@keyframes nav-scan {
+  0%   { transform: translateX(-100%); }
+  100% { transform: translateX(350%); }
+}
+.app-main {
+  background: #f5f7fa;
+  overflow-y: auto;
+  padding: 24px;
+}
+</style>

+ 125 - 0
TenderAgent完整代码/frontend/src/components/layout/Sidebar.vue

@@ -0,0 +1,125 @@
+<template>
+  <div class="sidebar">
+    <div class="logo">
+      <span>🎓 TenderAgent</span>
+    </div>
+    <nav class="nav-list">
+      <RouterLink to="/dashboard" class="nav-item" :class="{ 'nav-item--active': isActive('/dashboard') }">
+        <el-icon><House /></el-icon>
+        <span>首页</span>
+      </RouterLink>
+
+      <RouterLink to="/qa" class="nav-item" :class="{ 'nav-item--active': isActive('/qa') }">
+        <el-icon><ChatDotRound /></el-icon>
+        <span>智能问答</span>
+      </RouterLink>
+
+      <RouterLink to="/bid_review" class="nav-item" :class="{ 'nav-item--active': isActive('/bid_review') }">
+        <el-icon><Document /></el-icon>
+        <span>投标文件评分</span>
+      </RouterLink>
+
+      <RouterLink to="/qualification" class="nav-item" :class="{ 'nav-item--active': isActive('/qualification') }">
+        <el-icon><Postcard /></el-icon>
+        <span>资质审查</span>
+      </RouterLink>
+
+      <RouterLink to="/defense" class="nav-item" :class="{ 'nav-item--active': isActive('/defense') }">
+        <el-icon><Microphone /></el-icon>
+        <span>供应商答辩</span>
+      </RouterLink>
+
+      <!-- 评审专家端菜单(仅 expert/admin 可见) -->
+      <template v-if="auth.isExpert">
+        <div class="nav-divider" />
+        <RouterLink to="/expert/bid_review-review" class="nav-item" :class="{ 'nav-item--active': isActive('/expert/bid_review-review') }">
+          <el-icon><EditPen /></el-icon>
+          <span>评分确认</span>
+        </RouterLink>
+        <RouterLink to="/expert/knowledge-pending" class="nav-item" :class="{ 'nav-item--active': isActive('/expert/knowledge-pending') }">
+          <el-icon><Collection /></el-icon>
+          <span>知识库待补充</span>
+        </RouterLink>
+      </template>
+    </nav>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useRoute } from 'vue-router'
+import {
+  House, ChatDotRound, Document, Postcard,
+  Microphone, EditPen, Collection,
+} from '@element-plus/icons-vue'
+import { useAuthStore } from '@/stores/auth'
+
+const auth = useAuthStore()
+const route = useRoute()
+
+// 仅用于子路由高亮(纯视觉反馈,不参与导航逻辑)
+// RouterLink 的 active-class 基于路由记录层级,不覆盖平级子路由(如 /qualification/:id)
+function isActive(prefix: string) {
+  return route.path === prefix || route.path.startsWith(prefix + '/')
+}
+</script>
+
+<style scoped>
+.sidebar {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.logo {
+  height: 56px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  font-size: 16px;
+  font-weight: 600;
+  border-bottom: 1px solid #ffffff1a;
+  flex-shrink: 0;
+}
+
+.nav-list {
+  display: flex;
+  flex-direction: column;
+  padding: 4px 0;
+  flex: 1;
+}
+
+.nav-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 13px 20px;
+  color: #ffffffa6;
+  text-decoration: none;
+  font-size: 14px;
+  cursor: pointer;
+  transition: background-color 0.2s, color 0.2s;
+  user-select: none;
+}
+
+.nav-item:hover {
+  background-color: #ffffff14;
+  color: #fff;
+}
+
+.nav-item--active {
+  background-color: #1677ff;
+  color: #fff;
+}
+
+.nav-item .el-icon {
+  font-size: 16px;
+  flex-shrink: 0;
+}
+
+.nav-divider {
+  border: none;
+  border-top: 1px solid #ffffff1a;
+  margin: 8px 0;
+}
+</style>

+ 75 - 0
TenderAgent完整代码/frontend/src/components/qualification/DimensionScoreCard.vue

@@ -0,0 +1,75 @@
+<template>
+  <el-card class="dimension-card">
+    <div class="dim-header">
+      <span class="dim-name">{{ dimension }}</span>
+      <div class="dim-score">
+        <span class="score-val">{{ score }}</span>
+        <span class="score-max">/100</span>
+      </div>
+    </div>
+    <el-progress
+      :percentage="score"
+      :color="progressColor"
+      :stroke-width="8"
+      :show-text="false"
+      style="margin: 8px 0"
+    />
+    <div class="dim-weight">权重 {{ (weight * 100).toFixed(0) }}%</div>
+
+    <div v-if="issues.length" class="issue-list">
+      <div v-for="(issue, i) in issues" :key="i" class="issue-item">
+        <el-icon color="#ff4d4f"><Warning /></el-icon>
+        <span>{{ issue }}</span>
+      </div>
+    </div>
+
+    <div v-if="suggestions.length" class="suggestion-list">
+      <div v-for="(s, i) in suggestions" :key="i" class="suggestion-item">
+        <el-icon color="#52c41a"><CircleCheck /></el-icon>
+        <span>{{ s }}</span>
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { Warning, CircleCheck } from '@element-plus/icons-vue'
+
+const props = defineProps<{
+  dimension: string
+  score: number
+  weight: number
+  issues: string[]
+  suggestions: string[]
+}>()
+
+const progressColor = computed(() => {
+  if (props.score >= 80) return '#52c41a'
+  if (props.score >= 60) return '#faad14'
+  return '#ff4d4f'
+})
+</script>
+
+<style scoped>
+.dimension-card { height: 100%; }
+.dim-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.dim-name { font-weight: 500; font-size: 14px; }
+.dim-score { display: flex; align-items: baseline; gap: 2px; }
+.score-val { font-size: 22px; font-weight: 700; color: #1677ff; }
+.score-max { font-size: 13px; color: #8c8c8c; }
+.dim-weight { font-size: 12px; color: #8c8c8c; margin-bottom: 8px; }
+.issue-list, .suggestion-list { margin-top: 8px; }
+.issue-item, .suggestion-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 6px;
+  font-size: 13px;
+  line-height: 1.5;
+  margin-bottom: 4px;
+}
+</style>

+ 100 - 0
TenderAgent完整代码/frontend/src/composables/useSSEChat.ts

@@ -0,0 +1,100 @@
+import { ref } from 'vue'
+import { useAuthStore } from '@/stores/auth'
+
+export interface SSEToken {
+  type: 'token' | 'meta' | 'done' | 'error'
+  content?: string
+  answer_mode?: string
+  confidence?: number
+  sources?: string[]
+  fallback_used?: boolean
+  message?: string
+}
+
+export function useSSEChat() {
+  const streamingContent = ref('')
+  const isStreaming = ref(false)
+  const lastMeta = ref<Omit<SSEToken, 'type' | 'content'> | null>(null)
+  const error = ref<string | null>(null)
+
+  async function sendStream(
+    sessionId: string,
+    message: string,
+    tender_documentId?: string | null,
+    onToken?: (token: string) => void,
+  ) {
+    isStreaming.value = true
+    streamingContent.value = ''
+    lastMeta.value = null
+    error.value = null
+
+    const auth = useAuthStore()
+    const baseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'
+
+    try {
+      const response = await fetch(`${baseUrl}/qa/chat/stream`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${auth.token}`,
+        },
+        body: JSON.stringify({
+          session_id: sessionId,
+          message,
+          tender_document_id: tender_documentId ?? null,
+        }),
+      })
+
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`)
+      }
+
+      const reader = response.body!.getReader()
+      const decoder = new TextDecoder()
+      let buffer = ''
+
+      while (true) {
+        const { done, value } = await reader.read()
+        if (done) break
+
+        buffer += decoder.decode(value, { stream: true })
+        const lines = buffer.split('\n')
+        buffer = lines.pop() ?? ''
+
+        for (const line of lines) {
+          if (!line.startsWith('data:')) continue
+          const raw = line.slice(5).trim()
+          if (!raw) continue
+
+          try {
+            const evt: SSEToken = JSON.parse(raw)
+            if (evt.type === 'token' && evt.content) {
+              streamingContent.value += evt.content
+              onToken?.(evt.content)
+            } else if (evt.type === 'meta') {
+              lastMeta.value = {
+                answer_mode: evt.answer_mode,
+                confidence: evt.confidence,
+                sources: evt.sources,
+                fallback_used: evt.fallback_used,
+              }
+            } else if (evt.type === 'done') {
+              isStreaming.value = false
+            } else if (evt.type === 'error') {
+              error.value = evt.message ?? '流式响应出错'
+              isStreaming.value = false
+            }
+          } catch {
+            // 忽略非 JSON 行
+          }
+        }
+      }
+    } catch (e: unknown) {
+      error.value = e instanceof Error ? e.message : '连接失败'
+    } finally {
+      isStreaming.value = false
+    }
+  }
+
+  return { streamingContent, isStreaming, lastMeta, error, sendStream }
+}

+ 28 - 0
TenderAgent完整代码/frontend/src/main.ts

@@ -0,0 +1,28 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import 'element-plus/dist/index.css'
+import 'highlight.js/styles/github.css'
+
+import App from './App.vue'
+import router from './router'
+
+const app = createApp(App)
+
+// 注册所有 Element Plus 图标
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
+
+// 全局错误兜底:防止 Vue 调度器或组件更新过程中的错误以 unhandled rejection 形式
+// 崩溃整个应用。具体恢复逻辑由 AppLayout 的 onErrorCaptured 处理。
+app.config.errorHandler = (err, _instance, info) => {
+  console.error('[TenderAgent] Vue error:', info, err)
+}
+
+app.use(createPinia())
+app.use(router)
+app.use(ElementPlus, { locale: undefined })
+
+app.mount('#app')

+ 109 - 0
TenderAgent完整代码/frontend/src/router/index.ts

@@ -0,0 +1,109 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useAuthStore } from '@/stores/auth'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/login',
+      name: 'login',
+      component: () => import('@/views/LoginView.vue'),
+      meta: { public: true },
+    },
+    {
+      path: '/',
+      component: () => import('@/components/layout/AppLayout.vue'),
+      meta: { requiresAuth: true },
+      children: [
+        {
+          path: '',
+          redirect: '/dashboard',
+        },
+        {
+          path: 'dashboard',
+          name: 'dashboard',
+          component: () => import('@/views/DashboardView.vue'),
+        },
+        // AI 助手(统一入口)
+        {
+          path: 'chat',
+          name: 'chat',
+          component: () => import('@/views/UnifiedChatView.vue'),
+        },
+        // QA 问答
+        {
+          path: 'qa',
+          name: 'qa',
+          component: () => import('@/views/qa/QAChatView.vue'),
+        },
+        // 投标文件评分
+        {
+          path: 'bid_review',
+          name: 'bid_review',
+          component: () => import('@/views/bid_review/BidReviewSubmitView.vue'),
+        },
+        {
+          path: 'bid_review/:submissionId',
+          name: 'bid_review-result',
+          component: () => import('@/views/bid_review/BidReviewResultView.vue'),
+        },
+        // 资质审查
+        {
+          path: 'qualification',
+          name: 'qualification',
+          component: () => import('@/views/qualification/QualificationUploadView.vue'),
+        },
+        {
+          path: 'qualification/:reviewId',
+          name: 'qualification-report',
+          component: () => import('@/views/qualification/QualificationReportView.vue'),
+        },
+        // 供应商答辩
+        {
+          path: 'defense',
+          name: 'defense',
+          component: () => import('@/views/defense/DefenseSetupView.vue'),
+        },
+        {
+          path: 'defense/:sessionId',
+          name: 'defense-chat',
+          component: () => import('@/views/defense/DefenseChatView.vue'),
+        },
+        // 评审专家端(需要 expert/admin 角色)
+        {
+          path: 'expert/bid_review-review',
+          name: 'expert-bid_review-review',
+          component: () => import('@/views/expert/BidReviewReviewView.vue'),
+          meta: { requiresExpert: true },
+        },
+        {
+          path: 'expert/knowledge-pending',
+          name: 'expert-knowledge-pending',
+          component: () => import('@/views/expert/KnowledgePendingView.vue'),
+          meta: { requiresExpert: true },
+        },
+      ],
+    },
+    {
+      path: '/:pathMatch(.*)*',
+      redirect: '/dashboard',
+    },
+  ],
+})
+
+router.beforeEach((to, _from, next) => {
+  const auth = useAuthStore()
+
+  if (to.meta.public) {
+    if (auth.isLoggedIn && to.name === 'login') return next('/dashboard')
+    return next()
+  }
+
+  if (!auth.isLoggedIn) return next('/login')
+
+  if (to.meta.requiresExpert && !auth.isExpert) return next('/dashboard')
+
+  next()
+})
+
+export default router

+ 41 - 0
TenderAgent完整代码/frontend/src/stores/auth.ts

@@ -0,0 +1,41 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+interface UserInfo {
+  userId: string
+  role: 'bidder' | 'expert' | 'admin'
+  tenantId: string
+  username?: string
+}
+
+export const useAuthStore = defineStore('auth', () => {
+  const token = ref<string | null>(localStorage.getItem('edu-agent-token'))
+  const user = ref<UserInfo | null>((() => {
+    try {
+      return JSON.parse(localStorage.getItem('edu-agent-user') ?? 'null')
+    } catch {
+      return null
+    }
+  })())
+
+  const isLoggedIn = computed(() => !!token.value)
+  const isExpert = computed(() =>
+    user.value?.role === 'expert' || user.value?.role === 'admin'
+  )
+
+  function login(accessToken: string, userInfo: UserInfo) {
+    token.value = accessToken
+    user.value = userInfo
+    localStorage.setItem('edu-agent-token', accessToken)
+    localStorage.setItem('edu-agent-user', JSON.stringify(userInfo))
+  }
+
+  function logout() {
+    token.value = null
+    user.value = null
+    localStorage.removeItem('edu-agent-token')
+    localStorage.removeItem('edu-agent-user')
+  }
+
+  return { token, user, isLoggedIn, isExpert, login, logout }
+})

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików