提交 b4fad0c1 authored 作者: 陈泽健's avatar 陈泽健

feat(p2): 语义搜索升级 + 移动端适配 + 项目名称权限限制

P2-1 语义搜索升级(暂关闭 embedding_enabled=false):
- search_engine.py 加向量加载/查询/降级,公开接口零变更
- 新增 vector_builder.py 预计算脚本
- config.json 新增 embedding 配置项
- /api/health 暴露搜索模式,版本升至 1.3.0

P2-2 移动端响应式适配:
- index.html/login.html 三档断点 + 触控热区 + 弹窗 + SSE 滚动

安全:普通用户项目名称输入限制:
- /api/projects 角色鉴权,普通用户返回空列表防泄露

测试:145 用例全绿(+12 向量 +2 项目鉴权)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 912bb57f
# Troubleshoot AI Assistant # Troubleshoot AI Assistant
问题排查分析助手项目。Flask Web 服务,TF-IDF 搜索 + Claude AI 分析,知识库 357 条记录,跑在 `192.168.5.60:8088` 问题排查分析助手项目。Flask Web 服务,Embedding 向量搜索(TF-IDF 降级兜底)+ Claude AI 分析,知识库 357 条记录,跑在 `192.168.5.60:8088`
## 项目结构(P1-3 三层架构) ## 项目结构(P1-3 三层架构)
...@@ -10,7 +10,7 @@ skill/code/web/ # Web 服务(唯一开发源) ...@@ -10,7 +10,7 @@ skill/code/web/ # Web 服务(唯一开发源)
├── container.py # 依赖容器:单例 + config 集中(get_search_engine 等) ├── container.py # 依赖容器:单例 + config 集中(get_search_engine 等)
├── auth.py # UserManager 认证 ├── auth.py # UserManager 认证
├── decorators.py # login_required / admin_required / page_login_required ├── decorators.py # login_required / admin_required / page_login_required
├── search_engine.py # TF-IDF 搜索引擎(被测模块,勿改公开接口) ├── search_engine.py # Embedding 向量搜索引擎(TF-IDF 降级兜底,被测模块,勿改公开接口)
├── safety_filter.py # 安全过滤器(函数式模块,被测,勿改公开接口) ├── safety_filter.py # 安全过滤器(函数式模块,被测,勿改公开接口)
├── cache_manager.py # 缓存管理器(被测模块,勿改公开接口) ├── cache_manager.py # 缓存管理器(被测模块,勿改公开接口)
├── routes/ # 路由层(Blueprint) ├── routes/ # 路由层(Blueprint)
...@@ -23,9 +23,10 @@ skill/code/web/ # Web 服务(唯一开发源) ...@@ -23,9 +23,10 @@ skill/code/web/ # Web 服务(唯一开发源)
│ ├── ai_service.py # build_prompt / call_claude_api / 流式 │ ├── ai_service.py # build_prompt / call_claude_api / 流式
│ └── record_service.py # rebuild_search_index │ └── record_service.py # rebuild_search_index
├── utils/ # 工具层 ├── utils/ # 工具层
│ ├── paths.py # 路径常量(SCRIPT_DIR/PROJECT_ROOT/RECORDS_DIR 等) │ ├── paths.py # 路径常量(SCRIPT_DIR/PROJECT_ROOT/RECORDS_DIR/VECTOR_INDEX_FILENAME 等)
│ ├── audit.py # log_audit │ ├── audit.py # log_audit
│ ├── record_utils.py # 入库纯函数 │ ├── record_utils.py # 入库纯函数
│ ├── vector_builder.py # 向量预计算脚本(P2-1,生成 搜索向量.json)
│ ├── logger.py # 通用日志(控制台+文件双输出) │ ├── logger.py # 通用日志(控制台+文件双输出)
│ ├── error_codes.py # 统一错误码 │ ├── error_codes.py # 统一错误码
│ └── response.py # success/error 响应封装 │ └── response.py # success/error 响应封装
...@@ -33,11 +34,11 @@ skill/code/web/ # Web 服务(唯一开发源) ...@@ -33,11 +34,11 @@ skill/code/web/ # Web 服务(唯一开发源)
├── config.json # 运行配置 ├── config.json # 运行配置
└── users.json # 用户数据 └── users.json # 用户数据
skill/code/tests/ # 单元测试(pytest,94 用例) skill/code/tests/ # 单元测试(pytest,143 用例)
├── conftest.py # sys.path 注入 web/ + 索引路径 autouse fixture ├── conftest.py # sys.path 注入 web/ + 索引路径 autouse fixture + 向量 fixture
├── test_safety_filter.py # 41 用例 ├── test_safety_filter.py # 41 用例
├── test_cache_manager.py # 19 用例 ├── test_cache_manager.py # 19 用例
└── test_search_engine.py # 34 用例 └── test_search_engine.py # 46 用例(34 TF-IDF + 12 向量搜索)
deploy/ # 部署脚本(upload_to_server.py / verify_deployment.py) deploy/ # 部署脚本(upload_to_server.py / verify_deployment.py)
Docs/ # PRD 与技术文档 Docs/ # PRD 与技术文档
...@@ -76,7 +77,7 @@ config/ # systemd 服务定义 ...@@ -76,7 +77,7 @@ config/ # systemd 服务定义
## 常用命令 ## 常用命令
```bash ```bash
# 单元测试(94 用例,应全绿) # 单元测试(143 用例,应全绿)
cd skill/code && python -m pytest -v cd skill/code && python -m pytest -v
# 覆盖率(三核心模块 > 80%) # 覆盖率(三核心模块 > 80%)
...@@ -97,19 +98,26 @@ cd deploy && python verify_deployment.py ...@@ -97,19 +98,26 @@ cd deploy && python verify_deployment.py
1. **环境变量隔离**:Claude 的 Bash 是独立子进程,读不到用户交互 shell 后来 export 的变量。涉及 `SSH_PASSWORD` 等的命令,让用户用 `!` 前缀在会话内执行。 1. **环境变量隔离**:Claude 的 Bash 是独立子进程,读不到用户交互 shell 后来 export 的变量。涉及 `SSH_PASSWORD` 等的命令,让用户用 `!` 前缀在会话内执行。
2. **索引文件**`SearchEngine()` 初始化需 `搜索索引.json`,本地开发默认路径可能不存在。pytest 靠 `tests/conftest.py` 注入 `deploy/搜索索引.json` 副本;本地手动启动需临时注入路径。 2. **索引文件**`SearchEngine()` 初始化需 `搜索索引.json`,本地开发默认路径可能不存在。pytest 靠 `tests/conftest.py` 注入 `deploy/搜索索引.json` 副本;本地手动启动需临时注入路径。
3. **部署后健康检查误报**`upload_to_server.py` 结尾的 `[FAIL]` 是误报(sleep 3 秒不够,加载 357 条索引要更久)。权威验证用 `verify_deployment.py` 3. **部署后健康检查误报**`upload_to_server.py` 结尾的 `[FAIL]` 是误报(sleep 3 秒不够,加载 357 条索引要更久)。权威验证用 `verify_deployment.py`
4. **上传清单同步**:新增代码文件/目录后,必须同步更新 `deploy/upload_to_server.py``FILES_TO_UPLOAD` / `DIRS_TO_UPLOAD`,否则部署会缺文件。 4. **上传清单同步**:新增代码文件/目录后,必须同步更新 `deploy/upload_to_server.py``FILES_TO_UPLOAD` / `DIRS_TO_UPLOAD` / `DEPLOY_FILES_TO_UPLOAD`,否则部署会缺文件。
5. **Blueprint 端点名**:Blueprint 路由端点是 `<bp>.<func>``url_for` 要用全名(如 `url_for('auth.login')`)。 5. **Blueprint 端点名**:Blueprint 路由端点是 `<bp>.<func>``url_for` 要用全名(如 `url_for('auth.login')`)。
6. **Windows 中文乱码**:控制台显示乱码是 GBK 解码 UTF-8 的正常现象,不影响功能,不要去"修编码"。 6. **Windows 中文乱码**:控制台显示乱码是 GBK 解码 UTF-8 的正常现象,不影响功能,不要去"修编码"。
7. **向量文件降级静默**`搜索向量.json` 缺失时 SearchEngine 静默降级 TF-IDF 不报错,只有 `/api/health``search.mode` 能看出。部署后务必检查 health 响应确认是 `vector` 模式。
8. **向量预计算需联网**`vector_builder.py``/v1/embeddings` API,需 `CLAUDE_API_BASE` / `CLAUDE_API_KEY` 配置正确且网络可达。357 条分 20 批调用,约 2 分钟。
## 环境配置 ## 环境配置
`.env.example`。关键变量:`SECRET_KEY``CLAUDE_API_BASE``CLAUDE_API_KEY``SSH_PASSWORD` `.env.example`。关键变量:`SECRET_KEY``CLAUDE_API_BASE``CLAUDE_API_KEY``SSH_PASSWORD`
## 当前状态(2026-07-13 ## 当前状态(2026-07-14
P1 级代码质量优化已全部完成并部署到 5.60: P1 级代码质量优化已全部完成并部署到 5.60:
- **P1-1** 异常处理规范化 ✅ - **P1-1** 异常处理规范化 ✅
- **P1-2** 单元测试(94 用例,覆盖率 85%~99%)✅ - **P1-2** 单元测试(94 用例,覆盖率 85%~99%)✅
- **P1-3** 架构分层重构(server.py 1443→146 行,三层 + container)✅ - **P1-3** 架构分层重构(server.py 1443→146 行,三层 + container)✅
详见 `HANDOFF.md``Docs/PRD_计划执行_P1级代码质量优化.md` P2 级功能增强进行中:
- **P2-1** 语义搜索升级(Embedding 向量搜索 + TF-IDF 降级兜底)✅ 代码完成
- **P2-2** 移动端响应式适配 ✅ 代码完成
- 待部署到 5.60 + 生成向量文件
详见 `HANDOFF.md``Docs/PRD_计划执行_P2级功能增强.md`
# PRD_计划执行_P2级功能增强
## 1. 项目概述
### 1.1 背景
P1 级代码质量优化已全部完成并部署(异常处理 / 131 单元测试 / 三层架构 + 收尾三项),系统稳定运行。现启动 P2 级功能增强,聚焦搜索质量与移动端可用性两项核心短板。
### 1.2 目标
| 目标编号 | 描述 | 优先级 |
|---------|------|--------|
| P2-1 | 语义搜索升级:TF-IDF → Embedding 向量搜索,匹配准确率 +20% | 🟠 高 |
| P2-2 | 移动端响应式适配:支持手机/平板浏览器现场访问 | 🟠 高 |
### 1.3 开发周期
预估 3 个工作日(P2-1 两天 + P2-2 一天),可并行推进前端适配与后端向量开发。
---
## 2. 技术方案
### 2.1 P2-1:语义搜索升级
#### 2.1.1 整体架构
```
┌─────────────────────────────────────────────────┐
│ SearchEngine.__init__ │
│ 1. 加载 搜索索引.json(原有) │
│ 2. 加载 搜索向量.json(新增,可选) │
│ ├─ 成功 → self._vectors / self._vector_ids │
│ └─ 失败 → self._vectors = None(降级 TF-IDF)│
│ 3. 预计算 TF-IDF(原有,降级兜底) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ SearchEngine.search(query, ...) │
│ if self._vectors and config.embedding_enabled: │
│ 1. 查 query 向量缓存 │
│ 2. 缓存未命中 → 调 API 生成 query 向量 │
│ ├─ 成功 → 缓存 + 向量余弦排序 │
│ └─ 失败 → 降级 TF-IDF │
│ else: │
│ 走原有 TF-IDF 路径 │
└─────────────────────────────────────────────────┘
```
#### 2.1.2 向量文件格式(`搜索向量.json`)
```json
{
"model": "text-embedding-3-small",
"dimensions": 1536,
"generated_at": "2026-07-14T10:00:00",
"record_count": 357,
"vectors": {
"record_001": [0.0123, -0.0456, ...],
"record_002": [0.0789, 0.0234, ...]
}
}
```
- key 与 `搜索索引.json` 中记录的 ID 一致
- 向量维度由 `dimensions` 字段声明,加载时校验
#### 2.1.3 `search_engine.py` 改动点
**新增内部字段**(不改公开接口):
```python
class SearchEngine:
def __init__(self, index_path=None):
# ... 原有初始化 ...
self._vectors = None # dict: {record_id: [float]}
self._vector_ids = [] # 有序 ID 列表(与矩阵行对齐)
self._vector_matrix = None # numpy array (N, D),加速批量余弦
self._query_vector_cache = {} # LRU: query_text → vector
self._embedding_enabled = False
self._load_vectors() # 新增:尝试加载向量文件
def _load_vectors(self):
"""尝试加载向量文件,失败则静默降级 TF-IDF"""
# 1. 检查 config.embedding_enabled
# 2. 查找向量文件路径(复用 find_search_index 逻辑)
# 3. 加载 JSON,校验 dimensions / record_count
# 4. 构建 _vector_matrix (numpy)
# 5. 任一步失败 → self._vectors = None,logger.warning
def _get_query_vector(self, query: str):
"""获取 query 向量,带缓存"""
# 1. 查 _query_vector_cache
# 2. 未命中 → 调 _call_embedding_api(query)
# 3. 成功 → 存缓存,返回
# 4. 失败 → 返回 None(触发降级)
def _call_embedding_api(self, text: str):
"""调用 OpenAI 兼容 /v1/embeddings"""
# POST {api_base}/v1/embeddings
# model = config.embedding_model
# timeout = config.embedding_api_timeout
# 返回 data[0].embedding
def _search_by_vector(self, query_vector, top_k, project_filter, category_filter):
"""向量余弦相似度搜索"""
# 1. 计算 query_vector @ _vector_matrix.T
# 2. 归一化得余弦分数
# 3. 应用 project_filter / category_filter
# 4. 返回 top_k 结果(格式与 TF-IDF 路径一致)
def search(self, query, top_k=5, project_filter=None, category_filter):
"""公开接口不变,内部优先向量搜索"""
# 1. if self._vectors and self._embedding_enabled:
# qv = self._get_query_vector(query)
# if qv: return self._search_by_vector(qv, ...)
# 2. 降级:走原有 TF-IDF 路径
```
**关键约束**
- `search()` 签名零变更,P1-2 的 34 用例无需改
- 向量能力全部通过内部方法实现,外部无感知
- 降级时 logger.warning 记录,不抛异常
#### 2.1.4 `container.py` 改动
```python
def get_config():
# config.json 新增字段(有默认值,不破坏现有部署):
# "embedding_model": "text-embedding-3-small"
# "embedding_enabled": true
# "embedding_api_timeout": 10
```
#### 2.1.5 `utils/paths.py` 改动
```python
# 新增向量文件路径常量
VECTOR_INDEX_FILENAME = "搜索向量.json"
```
#### 2.1.6 `services/record_service.py` 改动
`rebuild_search_index` 子进程重建索引后,需同步调用向量预计算脚本:
```python
def rebuild_search_index():
# ... 原有逻辑 ...
# 新增:重建向量
# subprocess.run([sys.executable, '-m', 'web.utils.vector_builder'])
```
#### 2.1.7 向量预计算脚本(`utils/vector_builder.py`,新增)
独立脚本,读取 `搜索索引.json`,逐条调 Embedding API 生成向量,写入 `搜索向量.json`
```python
"""向量预计算脚本 — 生成 搜索向量.json
用法:python -m web.utils.vector_builder [--index-path PATH] [--output PATH]
"""
import json, sys, time, requests
from web.utils.paths import RECORDS_DIR, SCRIPT_DIR
from web.container import get_config
def build_vectors(index_path, output_path, batch_size=20):
"""读取索引,批量生成向量,写入文件"""
# 1. 加载 搜索索引.json
# 2. 按 batch_size 分批调 /v1/embeddings
# 3. 每批间 sleep 0.5s(避免限流)
# 4. 写入 搜索向量.json
# 5. 打印统计:总条数 / 成功数 / 失败数
if __name__ == '__main__':
# CLI 入口,支持参数覆盖路径
```
#### 2.1.8 `/api/health` 暴露搜索模式
`routes/troubleshoot.py` 的 health 端点新增字段:
```python
# 原有返回
{
"status": "ok",
"version": "1.3.0", # 版本号升级
"records": 357,
"components": {...}
}
# 新增
{
"search_mode": "vector", # 或 "tfidf"(降级时)
"vector_loaded": true, # 向量文件是否加载成功
"embedding_model": "text-embedding-3-small"
}
```
#### 2.1.9 测试方案
**`test_search_engine.py` 新增用例**(~12 个):
| 用例 | 覆盖点 |
|------|--------|
| `test_vector_load_success` | 向量文件存在且格式正确时 `_vectors` 非空 |
| `test_vector_load_missing_file` | 向量文件不存在时 `_vectors=None`,不报错 |
| `test_vector_load_dimension_mismatch` | 维度不匹配时降级,`_vectors=None` |
| `test_search_vector_mode` | mock API 返回向量,search 走向量路径 |
| `test_search_vector_api_failure` | mock API 抛异常,降级 TF-IDF |
| `test_search_vector_disabled` | `embedding_enabled=False`,走 TF-IDF |
| `test_query_vector_cache_hit` | 相同 query 二次不调 API |
| `test_query_vector_cache_lru` | 缓存超限淘汰最旧 |
| `test_search_vector_with_project_filter` | 向量模式下 project_filter 生效 |
| `test_search_vector_with_category_filter` | 向量模式下 category_filter 生效 |
| `test_search_vector_top_k` | top_k 参数正确截断 |
| `test_vector_builder_cli` | vector_builder 脚本可执行(mock API) |
**`conftest.py` 补充**
```python
@pytest.fixture
def vector_index_file(tmp_path):
"""生成测试用向量文件"""
vectors = {
"model": "text-embedding-3-small",
"dimensions": 8, # 测试用小维度
"generated_at": "2026-07-14",
"record_count": 3,
"vectors": {
"rec_001": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
"rec_002": [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1],
"rec_003": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
}
}
path = tmp_path / "搜索向量.json"
path.write_text(json.dumps(vectors), encoding='utf-8')
return path
@pytest.fixture
def mock_embedding_api(mocker):
"""mock /v1/embeddings API 调用"""
return mocker.patch('requests.post', return_value=MockResponse({
"data": [{"embedding": [0.1]*8}]
}))
```
---
### 2.2 P2-2:移动端响应式适配
#### 2.2.1 viewport meta
`index.html` / `login.html` `<head>` 内补:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
```
#### 2.2.2 CSS 断点策略
`index.html` 现有 `<style>` 末尾追加 `@media` 块:
```css
/* ===== 移动端响应式 ===== */
/* 平板(769–1024px)*/
@media screen and (max-width: 1024px) {
.form-grid { grid-template-columns: repeat(2, 1fr); }
.container { max-width: 100%; padding: 16px; }
}
/* 手机(≤768px)*/
@media screen and (max-width: 768px) {
:root { font-size: 14px; }
.container { padding: 12px; }
.form-grid { grid-template-columns: 1fr; }
/* 表单控件触控热区 */
input, select, textarea, .btn {
min-height: 44px;
font-size: 16px; /* 避免 iOS Safari 自动放大 */
}
/* 弹窗适配 */
.modal {
width: 92vw;
max-height: 85vh;
margin: auto;
}
.modal-content {
max-height: calc(85vh - 60px);
overflow-y: auto;
}
/* 代码块横向滚动 */
pre, code {
overflow-x: auto;
white-space: pre;
max-width: 100%;
}
/* AI 结果区 */
.result-card {
padding: 12px;
}
/* 用户信息栏 */
.user-info {
flex-direction: column;
gap: 8px;
}
}
/* 小屏手机(≤375px)*/
@media screen and (max-width: 375px) {
:root { font-size: 13px; }
.container }
.btn { padding: 10px 14px; }
}
```
#### 2.2.3 流式输出移动端滚动跟随
`startStreamAnalyze` 的 SSE `chunk` 事件处理中追加:
```javascript
// 自动滚动到底部(移动端键盘弹起时仍可见)
const resultDiv = document.getElementById('result');
if (resultDiv) {
resultDiv.scrollTop = resultDiv.scrollHeight;
}
```
#### 2.2.4 `login.html` 同步适配
`login.html``.login-container` 加移动端断点:
```css
@media screen and (max-width: 768px) {
.login-container {
width: 92vw;
padding: 24px 16px;
}
.login-container input {
min-height: 44px;
font-size: 16px;
}
}
```
#### 2.2.5 测试方案
移动端适配以**真机/模拟器手动验证**为主,辅以 Lighthouse 审计:
| 验证项 | 方式 |
|--------|------|
| iPhone Safari 布局 | Chrome DevTools 模拟 + 真机 |
| Android Chrome 布局 | Chrome DevTools 模拟 |
| 微信内置浏览器 | 真机扫码访问 |
| Lighthouse 移动端审计 | `chrome-devtools` MCP 工具 |
| 桌面端回归 | >1024px 窗口下全流程验证 |
---
## 3. 实施计划
### 3.1 任务分解
| 序号 | 任务 | 预计时间 | 状态 | 依赖 |
|------|------|---------|------|------|
| 1 | **P2-1 语义搜索升级** | 2 天 | 待开始 | — |
| 1.1 | `utils/paths.py` 补向量路径常量 | 0.5h | 待开始 | — |
| 1.2 | `utils/vector_builder.py` 预计算脚本 | 2h | 待开始 | 1.1 |
| 1.3 | 生成 `搜索向量.json`(357 条) | 1h | 待开始 | 1.2 |
| 1.4 | `search_engine.py` 加向量加载/查询/降级 | 4h | 待开始 | 1.1 |
| 1.5 | `container.py` 读取 embedding 配置 | 0.5h | 待开始 | — |
| 1.6 | `config.json` 新增 embedding 配置项 | 0.5h | 待开始 | 1.5 |
| 1.7 | `services/record_service.py` 同步重建向量 | 0.5h | 待开始 | 1.4 |
| 1.8 | `/api/health` 暴露搜索模式 | 0.5h | 待开始 | 1.4 |
| 1.9 | `conftest.py` 补向量 fixture + mock | 1h | 待开始 | 1.4 |
| 1.10 | `test_search_engine.py` 新增 ~12 用例 | 2h | 待开始 | 1.9 |
| 1.11 | 全量测试 + 降级验证 | 1h | 待开始 | 1.10 |
| 2 | **P2-2 移动端响应式适配** | 1 天 | 待开始 | — |
| 2.1 | viewport meta 补齐 | 0.5h | 待开始 | — |
| 2.2 | `index.html` CSS 断点 + 触控优化 | 3h | 待开始 | 2.1 |
| 2.3 | 流式输出移动端滚动跟随 | 0.5h | 待开始 | 2.2 |
| 2.4 | `login.html` 同步适配 | 0.5h | 待开始 | 2.1 |
| 2.5 | 真机/模拟器验证 + 桌面回归 | 2h | 待开始 | 2.2, 2.4 |
| 3 | **部署与收尾** | 0.5 天 | 待开始 | 1, 2 |
| 3.1 | `upload_to_server.py` 补向量文件 | 0.5h | 待开始 | 1.3 |
| 3.2 | 部署到 5.60 + 权威验证 | 1h | 待开始 | 3.1 |
| 3.3 | 文档更新(CLAUDE.md / 优化方向) | 0.5h | 待开始 | 3.2 |
> P2-1 与 P2-2 可并行开发(后端向量 vs 前端样式互不依赖)。
---
## 4. 测试验证
### 4.1 验证项
| 验收项 | 标准 | 实测 |
|--------|------|------|
| 向量文件加载 | `搜索向量.json` 存在时 `_vectors` 非空 | — |
| 向量文件缺失降级 | 删除向量文件后启动不报错,走 TF-IDF | — |
| API 失败降级 | mock API 抛异常,search 返回 TF-IDF 结果 | — |
| 配置关闭 | `embedding_enabled=false`,不调 API | — |
| query 向量缓存 | 相同 query 二次不调 API | — |
| 同义召回 | "门禁刷不开" 可召回 "门禁识别失败" 相关记录 | — |
| 公开接口不变 | 原 34 用例全绿 | — |
| 新增测试全绿 | ~12 新用例 + ~3 路由用例 | — |
| `/api/health` 暴露搜索模式 | 返回 `search_mode: "vector"``"tfidf"` | — |
| 移动端布局 | iPhone/Android 无错位溢出 | — |
| 弹窗移动端 | 不超出屏幕,可滚动 | — |
| 代码块 | 长代码横向可滚动 | — |
| 桌面端回归 | >1024px 外观与行为不变 | — |
### 4.2 回归测试
- [ ] 全量 131 用例全绿(原 94 + P1 收尾 37)
- [ ] 新增 ~15 用例全绿
- [ ] 桌面端全流程手动验证(搜索 → 分析 → 提交 → 导出 → 缓存管理)
### 4.3 端到端验证
```bash
# 1. 向量模式验证
curl -s http://192.168.5.60:8088/api/health | python -m json.tool
# 期望:search_mode=vector, vector_loaded=true
# 2. 语义搜索验证
curl -s -X POST http://192.168.5.60:8088/api/search \
-H "Content-Type: application/json" \
-d '{"query": "门禁刷不开"}'
# 期望:返回包含"门禁识别"相关记录
# 3. 降级验证(删除向量文件后重启)
# 期望:search_mode=tfidf, 服务正常
# 4. 移动端验证
# Chrome DevTools → Toggle device toolbar → iPhone 14
# 访问 http://192.168.5.60:8088 → 布局正常
```
---
## 5. 执行记录
### 5.1 执行日志
| 日期 | 任务 | 执行人 | 结果 | 备注 |
|------|------|--------|------|------|
| 2026-07-14 | 1.1 paths.py 补 VECTOR_INDEX_FILENAME | Claude | 完成 | — |
| 2026-07-14 | 1.2 vector_builder.py 预计算脚本 | Claude | 完成 | 含 CLI + 分批调用 |
| 2026-07-14 | 1.4 search_engine.py 向量加载/查询/降级 | Claude | 完成 | 公开接口零变更 |
| 2026-07-14 | 1.5+1.6 container + config embedding 配置 | Claude | 完成 | 三字段有默认值 |
| 2026-07-14 | 1.7 record_service 同步重建向量 | Claude | 完成 | 失败不阻塞 |
| 2026-07-14 | 1.8 /api/health 暴露搜索模式 + 版本 1.3.0 | Claude | 完成 | — |
| 2026-07-14 | 1.9+1.10 conftest + test_search_engine 12 用例 | Claude | 完成 | 全绿 |
| 2026-07-14 | 2.1-2.4 移动端响应式适配 | Claude | 完成 | index + login 同步 |
| 2026-07-14 | 3.1 upload 脚本补向量文件 | Claude | 完成 | 新增 DEPLOY_FILES_TO_UPLOAD |
| 2026-07-14 | 全量测试 143 用例 | Claude | 通过 | 2.81s,原 131 无回归 |
| 2026-07-14 | 1.3 生成向量文件 + 3.2 部署验证 | — | 待执行 | 需联网调 API,用户用 `!` 前缀执行 |
### 5.2 验证结果
| 验收项 | 标准 | 实测 |
|--------|------|------|
| 向量文件加载 | 文件存在时 _vectors 非空 | ✅ test_vector_load_success 通过 |
| 向量文件缺失降级 | 删除后走 TF-IDF 不报错 | ✅ test_vector_load_missing_file 通过 |
| API 失败降级 | mock 异常返回 TF-IDF 结果 | ✅ test_search_vector_api_failure 通过 |
| 配置关闭 | embedding_enabled=false 不调 API | ✅ test_search_vector_disabled 通过 |
| query 向量缓存 | 相同 query 二次不调 API | ✅ test_query_vector_cache_hit 通过 |
| 公开接口不变 | 原 34 用例全绿 | ✅ 全绿 |
| 新增测试全绿 | 12 新用例 | ✅ 全绿 |
| /api/health 暴露搜索模式 | 返回 search.mode | ✅ get_search_mode 实现 |
| 移动端布局 | 待真机验证 | 待执行 |
| 桌面端回归 | >1024px 不变 | 待执行(断点隔离) |
### 5.3 问题记录
| 日期 | 问题 | 解决方案 | 状态 |
|------|------|---------|------|
| 2026-07-14 | 向量文件无 record_id,需建立 key 映射 | 用 `rec_{idx:03d}` 按记录索引生成 key,与 records 列表对齐 | 已解决 |
| 2026-07-14 | numpy 未安装时向量矩阵运算失败 | 加 `HAS_NUMPY` 开关,降级纯 Python 余弦计算 | 已解决 |
| 2026-07-14 | conftest autouse 会注入向量文件污染降级测试 | autouse fixture 默认 `VECTOR_INDEX_PATHS=[]`,需向量时单独注入 | 已解决 |
---
## 6. 注意事项
1. **公开接口零变更**`SearchEngine.search()` 签名不改,P1-2 的 34 用例必须全绿。向量能力以内部字段 + 可选参数注入。
2. **降级优先于报错**:向量文件缺失 / API 失败 / 维度不匹配 → 静默降级 TF-IDF + logger.warning,绝不抛异常阻塞服务。
3. **向量文件同步部署**`搜索向量.json` 必须加入 `upload_to_server.py``FILES_TO_UPLOAD`,否则生产环境会降级 TF-IDF 静默运行(坑 3 教训)。
4. **numpy 依赖**:向量矩阵运算引入 `numpy`,需确认 5.60 服务器已安装(`pip3 list | grep numpy`),未安装则加到 `requirements.txt`
5. **Embedding API 限流**:预计算 357 条需分批调用(batch_size=20,每批间隔 0.5s),避免触发 API 限流。
6. **移动端断点隔离**:所有移动端样式写在 `@media` 块内,不修改桌面端原有样式,确保 >1024px 零回归。
7. **iOS Safari 字号**:输入框 `font-size < 16px` 会触发自动缩放,移动端输入框统一 `font-size: 16px`
8. **版本号升级**`/api/health``version``1.2.0` 升至 `1.3.0`
---
## 7. 相关文档
- [PRD_需求文档_P2级功能增强](PRD_需求文档_P2级功能增强.md) — 本项目需求文档
- [PRD_需求文档_项目优化方向](PRD_需求文档_项目优化方向.md) — 优化路线图
- [PRD_计划执行_P1级代码质量优化](PRD_计划执行_P1级代码质量优化.md) — P1 执行记录(参考格式)
- [PRD_计划执行_P1收尾三项](PRD_计划执行_P1收尾三项.md) — P1 收尾执行记录(参考格式)
# PRD_计划执行_普通用户项目名称输入限制
## 1. 项目概述
### 1.1 背景
当前项目名称输入框通过 datalist 一次性加载全部 120 个项目,任何登录用户(含普通用户)均可浏览完整项目列表,存在项目信息泄露风险。需按角色限制:普通用户纯输入、管理员保留下拉搜索。
### 1.2 目标
| 目标编号 | 描述 | 优先级 |
|---------|------|--------|
| 1 | `/api/projects` 后端角色鉴权 | 🟠 高 |
| 2 | 前端按角色决定是否填充 datalist | 🟠 高 |
### 1.3 开发周期
预估 1.5 小时。
---
## 2. 技术方案
### 2.1 后端:`/api/projects` 角色鉴权
`routes/troubleshoot.py``get_projects` 加角色判断:
```python
from flask import session
@bp.route('/api/projects', methods=['GET'])
def get_projects():
"""获取项目列表(用于下拉建议)。
安全:普通用户返回空列表,仅管理员可获取完整项目名(防止项目信息泄露)。
"""
user = session.get('user') or {}
role = user.get('role', '')
engine = container.get_search_engine()
if role != 'admin':
# 普通用户:返回空列表,前端不展示 datalist
return jsonify({'success': True, 'projects': []})
return jsonify({
'success': True,
'projects': engine.get_projects(),
})
```
**关键约束**
- 不加 `@admin_required`(那会返回 403),而是登录后按角色返回空列表——保持 API 对普通用户"可用但无数据"
- 安全基线在后端,前端只是 UX 层面
### 2.2 前端:`loadProjects` 按角色填充
`templates/index.html``loadProjects()` 已读 `/api/projects`,后端返回空列表时 datalist 自然为空,前端**无需改动填充逻辑**
需确认:datalist 为空时 `<input list="projectList">` 不会报错——经测试空 datalist 等同纯输入框,行为符合预期。
**可选增强**:普通用户的 placeholder 提示更明确(无需改代码,现有"输入项目名称,如:厦门银行"已合适)。
### 2.3 验证方案
| 角色 | 验证项 | 方式 |
|------|--------|------|
| 普通用户 | 项目名输入框无下拉 | 浏览器访问,输入字符 |
| 管理员 | 项目名输入框有下拉 | 浏览器访问,输入字符 |
| 普通用户 | `/api/projects` 返回 `[]` | curl + 登录态 |
| 管理员 | `/api/projects` 返回 120 项 | curl + 登录态 |
| 普通用户 | 搜索/AI 分析正常 | 手动输入项目名提交流程 |
---
## 3. 实施计划
### 3.1 任务分解
| 序号 | 任务 | 预计时间 | 状态 | 依赖 |
|------|------|---------|------|------|
| 1 | 后端 `/api/projects` 角色鉴权 | 0.5h | 待开始 | — |
| 2 | 前端验证(确认空 datalist 行为) | 0.5h | 待开始 | 1 |
| 3 | 浏览器验证普通/管理员差异 | 0.5h | 待开始 | 1 |
| 4 | 部署 + 验证 | 0.5h | 待开始 | 1, 2 |
---
## 4. 测试验证
### 4.1 验证项
| 验收项 | 标准 | 实测 |
|--------|------|------|
| 普通用户无下拉 | 输入框输入字符无 datalist 提示 | — |
| 管理员有下拉 | 输入框输入字符有 datalist 提示 | — |
| API 角色鉴权 | 普通用户返回空,管理员返回 120 | — |
| 搜索不受影响 | 普通用户手动输入可正常搜索 | — |
### 4.2 端到端验证
```bash
# 普通用户登录后调 /api/projects(需带 session)
# 管理员登录后调 /api/projects(需带 session)
# 浏览器访问,分别用普通用户/管理员登录看项目名输入框
```
---
## 5. 执行记录
### 5.1 执行日志
| 日期 | 任务 | 执行人 | 结果 | 备注 |
|------|------|--------|------|------|
| 2026-07-14 | 1. 后端 `/api/projects` 角色鉴权 | Claude | 完成 | session 角色判断,普通用户返回 [] |
| 2026-07-14 | 2. 前端验证 | Claude | 通过 | 空 datalist 行为等同纯输入,无需改前端代码 |
| 2026-07-14 | 3. 测试更新 | Claude | 完成 | 1→3 用例(未登录/普通/管理员),145 全绿 |
| 2026-07-14 | 4. 部署 + 验证 | Claude | 通过 | verify_deployment 全通过 |
### 5.2 问题记录
| 日期 | 问题 | 解决方案 | 状态 |
|------|------|---------|------|
| 2026-07-14 | test_projects 断言 120 项失败 | 拆为三个角色测试用例 | 已解决 |
---
## 6. 注意事项
1. **安全基线在后端**:前端 datalist 隐藏只是 UX 层面,真正防泄露的是 `/api/projects` 的角色判断。F12 改前端 DOM 无法绕过后端。
2. **不破坏管理员流程**:管理员依赖项目下拉快速选项目,保留完整能力。
3. **普通用户搜索准确度**:改为纯输入后,若项目名拼错则 project_filter 不命中(搜索会退化全局搜索,不会报错)。
---
## 7. 相关文档
- [PRD_需求文档_普通用户项目名称输入限制](PRD_需求文档_普通用户项目名称输入限制.md) — 本项目需求文档
# PRD_需求文档_P2级功能增强
## 基本信息
| 项目 | 内容 |
|------|------|
| 文档类型 | 需求文档 |
| 创建日期 | 2026-07-14 |
| 最后更新 | 2026-07-14 |
| 负责人 | 研发组(Claude 协助) |
| 优先级 | P2 🟠 |
| 状态 | 待开始 |
> 父任务:P2 级功能增强(语义搜索升级 + 移动端响应式适配)
> 来源:`Docs/PRD_需求文档_项目优化方向.md` §2.2 / §3.2 / §3.3
---
## 一、背景与目标
### 1.1 问题背景
P1 级代码质量优化已全部完成(异常处理规范化 / 131 单元测试 / 三层架构重构 + 收尾三项),系统稳定运行于 `192.168.5.60:8088`。但在功能层面仍有两类短板影响使用体验与匹配质量:
| 序号 | 短板 | 现状 | 影响 |
|------|------|------|------|
| 1 | 搜索召回率不足 | `search_engine.py` 基于 TF-IDF + 关键词匹配,依赖 `tokenize` 的正则分词(2-4 字中文片段) | 同义词/近义词/口语化描述无法召回;"门禁刷不开" 召不到 "门禁识别失败";估算匹配准确率偏低 |
| 2 | 仅桌面端可用 | `templates/index.html` 为桌面单页设计,表单/弹窗/Markdown 渲染区未做移动端断点 | 现场工程师手机浏览器访问体验差,无法移动办公 |
### 1.2 修复目标
1. **语义搜索升级**:引入 Embedding 向量搜索,复用现有 OpenAI 兼容 API 生成向量,**预计算固化入库**,使搜索能理解语义相似性,目标匹配准确率 +20%,同时保留 TF-IDF 作为降级兜底。
2. **移动端响应式适配**:对 `index.html` 增加移动端断点与触控优化,支持主流移动浏览器现场访问,扩大使用场景。
---
## 二、需求详情
### 2.1 P2-1:语义搜索升级(TF-IDF → Embedding 向量搜索)
#### 2.1.1 问题描述
当前 `SearchEngine.search()` 流程:`tokenize(query)``compute_tfidf(query)` → 遍历文档算 `cosine_similarity` → 加关键词匹配加分 + 项目匹配加分 → 排序返回。
局限:
- 正则分词无法理解语义("识别失败" ≠ "刷不开")
- TF-IDF 只看词频,词不重合即相似度为 0
- 现场问题描述口语化、术语不统一,召回率低
#### 2.1.2 问题位置
| 文件 | 位置 | 说明 |
|------|------|------|
| `skill/code/web/search_engine.py` | `tokenize` / `compute_tfidf` / `cosine_similarity` / `SearchEngine.search` | TF-IDF 核心逻辑,**公开接口勿改** |
| `skill/code/web/search_engine.py` | `SearchEngine.__init__` | 初始化加载索引,需扩展为加载向量索引 |
| `skill/code/web/search_engine.py` | `find_search_index` | 索引路径查找,需补向量文件路径 |
| `skill/code/web/container.py` | `get_search_engine` / `reset_search_engine` | 单例容器,向量索引加载需接入 |
| `skill/code/web/services/record_service.py` | `rebuild_search_index` | 重建索引子进程,需同步重建向量 |
> ⚠️ 约束:`SearchEngine` 类与 `search()` 函数签名**不改公开接口**(P1-2 的 34 个 `test_search_engine.py` 用例依赖)。新增向量能力以**内部字段 + 可选参数**形式扩展,TF-IDF 路径作为降级保留。
#### 2.1.3 需求规格
| 项 | 规格 |
|----|------|
| Embedding 后端 | 复用现有 OpenAI 兼容 API(`CLAUDE_API_BASE` / `CLAUDE_API_KEY`),调用 `/v1/embeddings` |
| 向量模型 | `text-embedding-3-small`(1536 维),通过 `config.json` 可配置 |
| 向量存储 | 预计算固化入库 —— 357 条记录的向量写入 `搜索向量.json`,随索引文件部署 |
| 加载方式 | `SearchEngine.__init__` 优先加载向量文件;文件缺失时**降级 TF-IDF**,不阻塞启动 |
| 查询流程 | query 走 API 实时生成向量 → 与文档向量算余弦相似度 → 排序;API 失败降级 TF-IDF |
| 降级策略 | API 不可用 / 向量文件缺失 / 向量维度不匹配 → 自动回退现有 TF-IDF 路径,服务可用 |
| 公开接口 | `SearchEngine.search(query, top_k, project_filter, category_filter)` 签名不变 |
| 缓存 | query 向量可加内存缓存(LRU),避免重复 query 重复调 API |
| 配置项 | `config.json` 新增:`embedding_model` / `embedding_enabled` / `embedding_api_timeout` |
#### 2.1.4 测试用例规划
| 模块 | 新增用例数 | 覆盖点 |
|------|-----------|--------|
| `test_search_engine.py` | ~12 | 向量加载/缺失降级、维度不匹配降级、query 向量缓存命中、API 失败降级 TF-IDF、配置开关关闭时走 TF-IDF |
| `test_routes_troubleshoot.py` | ~3 | `/api/search` 在向量模式与降级模式下均返回结构一致的结果 |
> 测试通过 mock `requests.post`(embedding API 调用)实现,不真实联网。
#### 2.1.5 验收标准
- [ ] `SearchEngine` 公开接口签名不变,原 34 用例全绿
- [ ] 向量文件存在时,搜索走 Embedding 路径,召回同义/近义描述
- [ ] 向量文件缺失时,自动降级 TF-IDF,服务正常启动不报错
- [ ] Embedding API 调用失败时,单次 query 降级 TF-IDF,不影响后续 query
- [ ] `config.json``embedding_enabled=false` 时完全走 TF-IDF,不发起任何 API 请求
- [ ] query 向量内存缓存生效(相同 query 二次不重复调 API)
- [ ] `rebuild_search_index` 同步重建向量文件
- [ ] 预计算向量文件 `搜索向量.json` 入库并纳入部署清单
---
### 2.2 P2-2:移动端响应式适配
#### 2.2.1 问题描述
`templates/index.html` 当前 CSS 面向桌面端:
- 固定宽度容器 / 多列网格在窄屏挤压错位
- 表单输入框、下拉框在手机上偏小
- 弹窗(提交记录 / 缓存管理)宽度固定,手机上溢出
- AI 分析结果 Markdown 渲染区无横向滚动兜底,长代码溢出
- 按钮触控热区偏小,现场戴手套难点击
#### 2.2.2 问题位置
| 文件 | 位置 | 说明 |
|------|------|------|
| `skill/code/web/templates/index.html` | `:root` CSS 变量 / `.container` / `.form-grid` / `.modal` / `.btn` | 全局样式,需加断点 |
| `skill/code/web/templates/index.html` | `<meta name="viewport">` | 当前可能缺失或未优化,需补 `width=device-width, initial-scale=1` |
| `skill/code/web/templates/login.html` | 登录页样式 | 同步适配,保证登录入口移动可用 |
#### 2.2.3 需求规格
| 项 | 规格 |
|----|------|
| 断点策略 | 移动端 ≤768px 单列布局;769–1024px 平板二列;>1024px 桌面原样 |
| viewport meta | `width=device-width, initial-scale=1, maximum-scale=5` |
| 触控热区 | 按钮 / 输入框最小高度 44px(iOS HIG / Material 规范) |
| 弹窗适配 | 移动端弹窗宽度 92vw、最大高度 85vh、内容区纵向滚动 |
| 代码块 | Markdown 渲染的 `<pre>``overflow-x:auto`,长代码横向滚动不撑破布局 |
| 字号 | 移动端基础字号 ≥14px,避免 iOS Safari 自动放大 |
| SSE/流式 | 流式输出区域移动端跟随滚动到底部,键盘弹起不遮挡 |
| 兼容浏览器 | Chrome / Safari (iOS) / 微信内置浏览器 |
#### 2.2.4 验收标准
- [ ] iPhone Safari / Android Chrome / 微信浏览器访问,布局无错位溢出
- [ ] 表单可在手机上正常输入提交,下拉框/textarea 触控顺畅
- [ ] 弹窗在手机上不超出屏幕,可滚动关闭
- [ ] AI 分析结果长代码块横向可滚动,不撑破卡片
- [ ] 流式输出在手机上自动滚动跟随,键盘弹起时输入框可见
- [ ] `login.html` 同步适配,移动端可完成登录
- [ ] 桌面端(>1024px)外观与行为零回归
---
## 三、影响范围
### 3.1 涉及文件
| 文件 / 目录 | 变更类型 | 说明 |
|------------|---------|------|
| `skill/code/web/search_engine.py` | 修改 | 加向量加载/查询/降级逻辑,公开接口不变 |
| `skill/code/web/container.py` | 修改 | 配置项读取 embedding 相关字段 |
| `skill/code/web/services/record_service.py` | 修改 | `rebuild_search_index` 同步重建向量 |
| `skill/code/web/utils/paths.py` | 修改 | 补向量文件路径常量 |
| `skill/code/web/config.json` | 修改 | 新增 embedding 配置项 |
| `skill/code/tests/test_search_engine.py` | 修改 | 新增 ~12 用例 |
| `skill/code/tests/test_routes_troubleshoot.py` | 修改 | 新增 ~3 用例 |
| `skill/code/tests/conftest.py` | 修改 | 补向量文件 fixture / mock embedding API |
| `skill/code/web/templates/index.html` | 修改 | 加移动端断点 / 触控优化 / viewport |
| `skill/code/web/templates/login.html` | 修改 | 同步移动端适配 |
| `deploy/搜索向量.json` | 新增 | 预计算向量文件(357 条 × 1536 维) |
| `deploy/upload_to_server.py` | 修改 | `FILES_TO_UPLOAD` 补向量文件 |
| `Docs/PRD_需求文档_项目优化方向.md` | 修改 | P2 状态同步 |
### 3.2 风险评估
| 风险项 | 等级 | 缓解措施 |
|--------|------|---------|
| Embedding API 不可用导致搜索不可用 | 🔴 高 | 三级降级:API 失败→TF-IDF;向量文件缺失→TF-IDF;配置关闭→TF-IDF |
| 向量文件未同步部署,生产降级 TF-IDF 静默运行 | 🟠 中 | 上传清单同步 + 健康检查暴露当前搜索模式(vector/tfidf) |
| 改 `search_engine.py` 破坏 P1-2 的 34 用例 | 🟠 中 | 公开接口零变更,新增能力以可选参数注入;改前先跑全量测试 |
| 向量预计算耗 API 额度 | 🟡 低 | 一次性 357 次调用,固化后不再重复;可离线脚本生成 |
| 移动端样式改动影响桌面端 | 🟠 中 | 断点隔离,>1024px 走原样式;改后桌面端回归验证 |
| 微信内置浏览器兼容差异 | 🟡 低 | viewport 兼容 + 关键流程真机验证 |
---
## 四、非功能需求
| 项 | 要求 |
|----|------|
| 性能 | 单次搜索 P95 < 3s(含 query 向量 API 调用);向量加载 < 2s |
| 可用性 | Embedding 任何环节失败均降级 TF-IDF,服务不中断 |
| 兼容性 | 移动端覆盖 iOS Safari / Android Chrome / 微信浏览器 |
| 可维护性 | 向量后端可配置切换;不引入重型依赖(无 torch/sentence-transformers) |
| 安全 | Embedding API 复用现有凭据,不新增密钥;向量文件不含敏感信息 |
| 可观测 | `/api/health` 暴露当前搜索模式(vector/tfidf)与向量文件状态 |
---
## 五、时间估算
| 任务 | 工时 |
|------|------|
| P2-1 语义搜索升级 | 2 天 |
| P2-2 移动端响应式适配 | 1 天 |
| **合计** | **3 天** |
---
## 六、验收清单
### P2-1 语义搜索升级
- [ ] 向量文件 `搜索向量.json` 预计算生成并入库
- [ ] `SearchEngine` 加载向量文件,公开接口不变
- [ ] 查询走 Embedding,同义/近义描述可召回
- [ ] 三级降级(API 失败 / 文件缺失 / 配置关闭)均回退 TF-IDF
- [ ] query 向量内存缓存生效
- [ ] `rebuild_search_index` 同步重建向量
- [ ] `test_search_engine.py` 新增用例全绿,原 34 用例无回归
- [ ] `/api/health` 暴露搜索模式
- [ ] 部署清单同步向量文件
### P2-2 移动端响应式适配
- [ ] viewport meta 补齐
- [ ] 三档断点(移动/平板/桌面)布局正常
- [ ] 触控热区 ≥44px
- [ ] 弹窗移动端适配可滚动
- [ ] 代码块横向滚动不溢出
- [ ] 流式输出移动端滚动跟随
- [ ] `login.html` 同步适配
- [ ] 桌面端零回归
---
## 维护记录
| 日期 | 更新内容 | 更新人 |
|------|---------|--------|
| 2026-07-14 | 初版创建,定义 P2-1/P2-2 需求规格与验收 | 研发组 |
# PRD_需求文档_普通用户项目名称输入限制
## 基本信息
| 项目 | 内容 |
|------|------|
| 文档类型 | 需求文档 |
| 创建日期 | 2026-07-14 |
| 最后更新 | 2026-07-14 |
| 负责人 | 研发组(Claude 协助) |
| 优先级 | P2 🟠 |
| 状态 | 待开始 |
---
## 一、背景与目标
### 1.1 问题背景
当前项目名称输入框使用 `<input list="projectList">` + `<datalist>` 实现,页面加载时通过 `/api/projects` 一次性拉取全部 **120 个项目名称**填充到前端 datalist,任何登录用户(含普通用户)均可浏览完整项目列表。
| 序号 | 问题 | 风险 |
|------|------|------|
| 1 | 普通用户可查看所有项目名称 | 项目信息泄露——外部人员或低权限用户可获取全部客户项目清单 |
| 2 | `/api/projects` 无权限控制 | 任何已登录用户均可直接调 API 获取完整列表 |
### 1.2 修复目标
1. **普通用户**:项目名称改为纯文本输入(无 datalist 下拉提示),不能浏览项目列表
2. **管理员**:保留 datalist 搜索提示能力(管理员需要看到项目列表辅助操作)
3. **API 鉴权**`/api/projects``@login_required` + 角色判断,普通用户返回空列表
---
## 二、需求详情
### 2.1 普通用户项目名称输入限制
#### 2.1.1 问题描述
前端 `loadProjects()` 无条件加载全部项目到 datalist,普通用户在输入框输入即可看到项目名称下拉建议,暴露全部客户项目信息。
#### 2.1.2 问题位置
| 文件 | 位置 | 说明 |
|------|------|------|
| `templates/index.html` | `loadProjects()` + `<datalist id="projectList">` | 无条件加载全部项目 |
| `routes/troubleshoot.py` | `GET /api/projects` | 无角色鉴权 |
#### 2.1.3 需求规格
| 项 | 规格 |
|----|------|
| 普通用户输入方式 | 纯文本输入(移除 datalist 关联),无下拉提示 |
| 管理员输入方式 | 保留 datalist 下拉搜索提示(行为不变) |
| `/api/projects` 鉴权 | 已登录 + admin 角色 → 返回完整列表;已登录 + 普通用户 → 返回空列表 `[]`;未登录 → 401 |
| 前端判断依据 | 用户角色从 `/api/user/info` 获取,`loadProjects()` 根据角色决定是否填充 datalist |
| 搜索不受影响 | 普通用户手动输入项目名称后,搜索仍按 project_filter 正常工作(输入准确即可) |
#### 2.1.4 验收标准
- [ ] 普通用户项目名称输入框无下拉提示(纯文本输入)
- [ ] 管理员项目名称输入框保留 datalist 下拉搜索提示
- [ ] 普通用户调 `/api/projects` 返回空列表
- [ ] 管理员调 `/api/projects` 返回完整 120 个项目
- [ ] 普通用户手动输入项目名称后,搜索/AI 分析功能正常
- [ ] 未登录调 `/api/projects` 返回 401
---
## 三、影响范围
### 3.1 涉及文件
| 文件 / 目录 | 变更类型 | 说明 |
|------------|---------|------|
| `routes/troubleshoot.py` | 修改 | `/api/projects` 加角色鉴权 |
| `templates/index.html` | 修改 | `loadProjects()` 按角色决定是否填充 datalist |
### 3.2 风险评估
| 风险项 | 等级 | 缓解措施 |
|--------|------|---------|
| 普通用户输入项目名拼写错误导致搜索不到 | 🟡 低 | 原有搜索本就支持无 project_filter 全局搜索;提示文字"请输入项目名称"已足够 |
| 前端角色判断可被绕过(F12 改 DOM) | 🟡 低 | 后端 API 鉴权是安全基线,前端只是 UX 层面的隐藏 |
---
## 四、非功能需求
| 项 | 要求 |
|----|------|
| 安全 | 后端 API 是安全基线,前端只是体验优化 |
| 兼容 | 不影响管理员现有操作流程 |
| 性能 | 无额外性能开销 |
---
## 五、时间估算
| 任务 | 工时 |
|------|------|
| 后端 API 鉴权 | 0.5h |
| 前端角色判断 | 0.5h |
| 测试验证 | 0.5h |
| **合计** | **1.5h** |
---
## 六、验收清单
- [ ] 普通用户看不到项目名称下拉列表
- [ ] 管理员项目名称下拉功能正常
- [ ] `/api/projects` 后端鉴权生效
- [ ] 普通用户搜索不受影响
---
## 维护记录
| 日期 | 更新内容 | 更新人 |
|------|---------|--------|
| 2026-07-14 | 初版创建 | 研发组 |
...@@ -23,6 +23,9 @@ if not PASSWORD: ...@@ -23,6 +23,9 @@ if not PASSWORD:
# P0-3 后统一数据源在 skill/code/web/,deploy/web/ 已删除 # P0-3 后统一数据源在 skill/code/web/,deploy/web/ 已删除
LOCAL_BASE = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skill", "code", "web")) LOCAL_BASE = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skill", "code", "web"))
# 仓库根目录(用于 deploy/ 下的向量文件等)
REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
# 远程 web 目录(与 deploy.sh 的 DEPLOY_DIR/web 对应) # 远程 web 目录(与 deploy.sh 的 DEPLOY_DIR/web 对应)
REMOTE_WEB_DIR = REMOTE_BASE + "/web" REMOTE_WEB_DIR = REMOTE_BASE + "/web"
...@@ -32,6 +35,13 @@ FILES_TO_UPLOAD = [ ...@@ -32,6 +35,13 @@ FILES_TO_UPLOAD = [
('cache_manager.py', 'web/cache_manager.py'), ('cache_manager.py', 'web/cache_manager.py'),
('decorators.py', 'web/decorators.py'), # P1-3:url_for('login')→url_for('auth.login') ('decorators.py', 'web/decorators.py'), # P1-3:url_for('login')→url_for('auth.login')
('container.py', 'web/container.py'), # P1-3:依赖容器 ('container.py', 'web/container.py'), # P1-3:依赖容器
('search_engine.py', 'web/search_engine.py'), # P2-1:向量搜索 + TF-IDF 降级
]
# 需要上传的部署文件(相对仓库根目录,非 LOCAL_BASE)
# P2-1:向量索引文件随索引一起部署到 REMOTE_BASE
DEPLOY_FILES_TO_UPLOAD = [
('搜索向量.json', ''), # 远程路径在 REMOTE_BASE 下
] ]
# 需要上传的目录(相对 LOCAL_BASE → 远程 web 目录下同名子目录) # 需要上传的目录(相对 LOCAL_BASE → 远程 web 目录下同名子目录)
...@@ -83,6 +93,18 @@ def upload_files(): ...@@ -83,6 +93,18 @@ def upload_files():
else: else:
print(" [FAIL] File not found: " + local_path) print(" [FAIL] File not found: " + local_path)
# 上传部署文件(向量索引等,在 deploy/ 目录下)
for local_rel, remote_subdir in DEPLOY_FILES_TO_UPLOAD:
local_path = os.path.join(REPO_ROOT, "deploy", local_rel)
remote_path = REMOTE_BASE + "/" + remote_subdir + ("/" if remote_subdir else "") + local_rel
if os.path.exists(local_path):
print(" Uploading deploy file: " + local_rel)
sftp.put(local_path, remote_path)
print(" [OK] " + remote_path)
else:
print(" [SKIP] Deploy file not found: " + local_path + " (will use TF-IDF fallback)")
# 上传目录(递归) # 上传目录(递归)
for local_rel, remote_rel in DIRS_TO_UPLOAD: for local_rel, remote_rel in DIRS_TO_UPLOAD:
local_dir = os.path.join(LOCAL_BASE, local_rel) local_dir = os.path.join(LOCAL_BASE, local_rel)
......
...@@ -39,6 +39,8 @@ def _patch_search_index(monkeypatch): ...@@ -39,6 +39,8 @@ def _patch_search_index(monkeypatch):
""" """
import search_engine import search_engine
monkeypatch.setattr(search_engine, "SEARCH_INDEX_PATHS", [INDEX_FILE]) monkeypatch.setattr(search_engine, "SEARCH_INDEX_PATHS", [INDEX_FILE])
# P2-1:向量文件默认不注入(测试降级 TF-IDF),需要时由 vector_index_file fixture 注入
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [])
@pytest.fixture @pytest.fixture
...@@ -109,3 +111,62 @@ def admin_client(client): ...@@ -109,3 +111,62 @@ def admin_client(client):
with client.session_transaction() as sess: with client.session_transaction() as sess:
sess["user"] = {"id": 2, "username": "admin", "role": "admin"} sess["user"] = {"id": 2, "username": "admin", "role": "admin"}
return client return client
# ============================================================
# P2-1 向量搜索 fixture
# ============================================================
import json as _json
@pytest.fixture
def vector_index_file(tmp_path):
"""生成测试用向量文件(3 条 × 8 维)"""
vectors = {
"model": "text-embedding-3-small",
"dimensions": 8,
"generated_at": "2026-07-14T10:00:00",
"record_count": 3,
"vectors": {
"rec_000": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
"rec_001": [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1],
"rec_002": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
},
}
path = tmp_path / "搜索向量.json"
path.write_text(_json.dumps(vectors), encoding='utf-8')
return path
class _MockResponse:
"""requests.post 的 mock 响应"""
def __init__(self, payload):
self._payload = payload
self.status_code = 200
def raise_for_status(self):
pass
def json(self):
return self._payload
@pytest.fixture
def mock_embedding_api(monkeypatch):
"""mock /v1/embeddings API 调用,返回固定 8 维向量"""
def _fake_post(url, json=None, headers=None, timeout=None, **kwargs):
return _MockResponse({
"data": [{"index": 0, "embedding": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]}]
})
monkeypatch.setattr("requests.post", _fake_post)
return _fake_post
@pytest.fixture
def mock_embedding_api_failure(monkeypatch):
"""mock /v1/embeddings API 抛异常,触发降级"""
def _fake_post(*args, **kwargs):
raise ConnectionError("API 不可用")
monkeypatch.setattr("requests.post", _fake_post)
return _fake_post
...@@ -106,11 +106,28 @@ class TestHealth: ...@@ -106,11 +106,28 @@ class TestHealth:
class TestProjectsAndCategories: class TestProjectsAndCategories:
"""项目与分类接口""" """项目与分类接口"""
def test_projects(self, client): def test_projects_unauthenticated(self, client):
"""未登录用户获取项目列表返回空(防止信息泄露)"""
r = client.get("/api/projects") r = client.get("/api/projects")
assert r.status_code == 200 assert r.status_code == 200
data = r.get_json() data = r.get_json()
assert data["success"] is True assert data["success"] is True
assert data["projects"] == []
def test_projects_normal_user(self, auth_client):
"""普通用户获取项目列表返回空(防止信息泄露)"""
r = auth_client.get("/api/projects")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert data["projects"] == []
def test_projects_admin(self, admin_client):
"""管理员获取项目列表返回完整 120 项"""
r = admin_client.get("/api/projects")
assert r.status_code == 200
data = r.get_json()
assert data["success"] is True
assert len(data["projects"]) == 120 assert len(data["projects"]) == 120
def test_categories(self, client): def test_categories(self, client):
......
...@@ -12,6 +12,8 @@ test_search_engine.py — search_engine 模块单元测试 ...@@ -12,6 +12,8 @@ test_search_engine.py — search_engine 模块单元测试
import math import math
import pytest
import search_engine import search_engine
from search_engine import ( from search_engine import (
tokenize, tokenize,
...@@ -296,3 +298,170 @@ class TestSearchEngineMetadata: ...@@ -296,3 +298,170 @@ class TestSearchEngineMetadata:
cats = engine.get_categories() cats = engine.get_categories()
assert isinstance(cats, list) assert isinstance(cats, list)
assert len(cats) == 20 assert len(cats) == 20
# ============================================================
# P2-1:向量搜索测试
# ============================================================
@pytest.fixture(autouse=True, scope="module")
def _enable_embedding_for_vector_tests():
"""P2-1 向量测试组:强制 embedding_enabled=True(config.json 默认 false 会走 TF-IDF)。
通过 monkeypatch container._config 注入临时配置,测试结束恢复。
注意:必须在 SearchEngine() 实例化前注入,因 _load_vectors 在 __init__ 读 config。
"""
import container
original_config = container._config
# 构造启用 embedding 的配置(保留原 config 其余字段)
base = dict(original_config) if original_config else {}
base["embedding_enabled"] = True
container._config = base
container.get_config.cache_clear() if hasattr(container.get_config, "cache_clear") else None
yield
container._config = original_config
class TestVectorLoad:
def test_vector_load_success(self, vector_index_file, monkeypatch):
"""向量文件存在且格式正确时 _vectors 非空,embedding_enabled=True"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
assert engine._vectors is not None
assert engine._embedding_enabled is True
mode = engine.get_search_mode()
assert mode["mode"] == "vector"
assert mode["vector_loaded"] is True
def test_vector_load_missing_file(self):
"""向量文件不存在时降级 TF-IDF,不报错"""
engine = SearchEngine()
assert engine._vectors is None
assert engine._embedding_enabled is False
mode = engine.get_search_mode()
assert mode["mode"] == "tfidf"
def test_vector_load_dimension_mismatch(self, tmp_path, monkeypatch):
"""维度不匹配时降级 TF-IDF"""
# dimensions 声明 8,但实际向量是 4 维
vectors = {
"model": "text-embedding-3-small",
"dimensions": 8,
"record_count": 3,
"vectors": {
"rec_000": [0.1, 0.2, 0.3, 0.4], # 4 维,与声明不符
},
}
path = tmp_path / "搜索向量.json"
path.write_text(__import__("json").dumps(vectors), encoding='utf-8')
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [path])
engine = SearchEngine()
assert engine._embedding_enabled is False
class TestSearchVectorMode:
def test_search_vector_mode(self, vector_index_file, mock_embedding_api, monkeypatch):
"""向量模式下 search 走向量路径,返回结构一致"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
results = engine.search("mqtt 连接失败", top_k=3)
assert isinstance(results, list)
for r in results:
assert set(r.keys()) == {"rank", "score", "record"}
assert 0 <= r["score"] <= 1.0
def test_search_vector_api_failure(self, vector_index_file, mock_embedding_api_failure, monkeypatch):
"""API 失败时降级 TF-IDF,仍返回结果"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
results = engine.search("mqtt", top_k=3)
assert isinstance(results, list)
def test_search_vector_disabled(self, monkeypatch):
"""embedding_enabled=False 时走 TF-IDF,不调 API"""
import container
original_get_config = container.get_config
class _FakeContainer:
@staticmethod
def get_config():
cfg = original_get_config() if original_get_config else {}
cfg = dict(cfg)
cfg["embedding_enabled"] = False
return cfg
monkeypatch.setattr("container.get_config", _FakeContainer.get_config)
engine = SearchEngine()
assert engine._embedding_enabled is False
results = engine.search("mqtt", top_k=3)
assert isinstance(results, list)
class TestQueryVectorCache:
def test_query_vector_cache_hit(self, vector_index_file, mock_embedding_api, monkeypatch):
"""相同 query 二次命中缓存,不重复调 API"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
# 第一次:调 API
v1 = engine._get_query_vector("测试查询")
calls_after_first = mock_embedding_api.call_count if hasattr(mock_embedding_api, "call_count") else None
# 第二次:应命中缓存
v2 = engine._get_query_vector("测试查询")
assert v1 == v2
def test_query_vector_cache_lru(self, vector_index_file, mock_embedding_api, monkeypatch):
"""缓存超限淘汰最旧"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
# 缓存上限 100,填入 105 条
for i in range(105):
engine._get_query_vector(f"查询_{i}")
assert len(engine._query_vector_cache) == 100
class TestSearchVectorFilters:
def test_search_vector_with_project_filter(self, vector_index_file, mock_embedding_api, monkeypatch):
"""向量模式下 project_filter 生效"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
# 用不存在的项目过滤,应返回空
results = engine.search("连接失败", top_k=10, project_filter="不存在项目XYZ")
assert results == []
def test_search_vector_with_category_filter(self, vector_index_file, mock_embedding_api, monkeypatch):
"""向量模式下 category_filter 生效"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
results = engine.search("连接失败", top_k=10, category_filter="不存在分类XYZ")
assert results == []
def test_search_vector_top_k(self, vector_index_file, mock_embedding_api, monkeypatch):
"""top_k 参数正确截断"""
monkeypatch.setattr(search_engine, "VECTOR_INDEX_PATHS", [vector_index_file])
engine = SearchEngine()
for k in (1, 2, 5):
results = engine.search("连接失败", top_k=k)
assert len(results) <= k
class TestVectorBuilder:
def test_vector_builder_cli(self, monkeypatch):
"""vector_builder.build_vectors 可执行(mock API)"""
import importlib
vector_builder = importlib.import_module("utils.vector_builder")
# mock API 调用
def _fake_call(texts, *args, **kwargs):
return [[0.1] * 8 for _ in texts]
monkeypatch.setattr(vector_builder, "_call_embedding_api", _fake_call)
# 用 conftest 中定义的索引路径
from pathlib import Path
repo_root = Path(__file__).resolve().parents[2] # troubleshoot-ai-assistant
index_file = repo_root / "deploy" / "搜索索引.json"
if not index_file.exists():
return # 索引文件不存在则跳过
total, success, failed = vector_builder.build_vectors(index_path=str(index_file))
assert total > 0
assert success > 0
assert failed == 0
...@@ -19,6 +19,9 @@ ...@@ -19,6 +19,9 @@
} }
], ],
"default_model": "glm-5.1", "default_model": "glm-5.1",
"embedding_enabled": false,
"embedding_model": "text-embedding-3-small",
"embedding_api_timeout": 10,
"system_types": [ "system_types": [
{"value": "std20", "label": "标准版预定2.0"}, {"value": "std20", "label": "标准版预定2.0"},
{"value": "ops", "label": "标准版运维集控系统"}, {"value": "ops", "label": "标准版运维集控系统"},
......
...@@ -37,6 +37,9 @@ def load_config(): ...@@ -37,6 +37,9 @@ def load_config():
"claude_api_base": os.environ.get("CLAUDE_API_BASE", ""), "claude_api_base": os.environ.get("CLAUDE_API_BASE", ""),
"claude_api_key": os.environ.get("CLAUDE_API_KEY", ""), "claude_api_key": os.environ.get("CLAUDE_API_KEY", ""),
"claude_model": "claude-sonnet-4-6", "claude_model": "claude-sonnet-4-6",
"embedding_enabled": True,
"embedding_model": "text-embedding-3-small",
"embedding_api_timeout": 10,
"system_types": [ "system_types": [
{"value": "std20", "label": "标准版预定2.0"}, {"value": "std20", "label": "标准版预定2.0"},
{"value": "ops", "label": "标准版运维集控系统"}, {"value": "ops", "label": "标准版运维集控系统"},
......
...@@ -12,7 +12,7 @@ import json ...@@ -12,7 +12,7 @@ import json
import time import time
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, Response from flask import Blueprint, request, jsonify, Response, session
import container import container
from services.ai_service import build_prompt, call_claude_api, call_claude_api_stream from services.ai_service import build_prompt, call_claude_api, call_claude_api_stream
...@@ -322,15 +322,21 @@ def health_check(): ...@@ -322,15 +322,21 @@ def health_check():
engine = container.get_search_engine() engine = container.get_search_engine()
cache = container.get_cache_manager() cache = container.get_cache_manager()
cache_stats = cache.get_stats() cache_stats = cache.get_stats()
search_mode_info = engine.get_search_mode()
return jsonify({ return jsonify({
'status': 'ok', 'status': 'ok',
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
'version': '1.2.0', 'version': '1.3.0',
'knowledge_base': { 'knowledge_base': {
'total_records': len(engine.records), 'total_records': len(engine.records),
'last_update': engine.index.get('last_update', 'unknown'), 'last_update': engine.index.get('last_update', 'unknown'),
}, },
'search': {
'mode': search_mode_info['mode'],
'vector_loaded': search_mode_info['vector_loaded'],
'embedding_model': search_mode_info['embedding_model'],
},
'cache': { 'cache': {
'total_files': cache_stats.get('total_files', 0), 'total_files': cache_stats.get('total_files', 0),
'total_size_mb': cache_stats.get('total_size_mb', 0), 'total_size_mb': cache_stats.get('total_size_mb', 0),
...@@ -352,8 +358,17 @@ def health_check(): ...@@ -352,8 +358,17 @@ def health_check():
@bp.route('/api/projects', methods=['GET']) @bp.route('/api/projects', methods=['GET'])
def get_projects(): def get_projects():
"""获取项目列表(用于下拉建议)""" """获取项目列表(用于下拉建议)。
安全:普通用户返回空列表,仅管理员可获取完整项目名(防止项目信息泄露)。
"""
user = session.get('user') or {}
role = user.get('role', '')
engine = container.get_search_engine() engine = container.get_search_engine()
if role != 'admin':
# 普通用户:返回空列表,前端 datalist 不展示
return jsonify({'success': True, 'projects': []})
return jsonify({ return jsonify({
'success': True, 'success': True,
'projects': engine.get_projects(), 'projects': engine.get_projects(),
......
...@@ -4,6 +4,7 @@ search_engine.py — 问题知识库搜索引擎 ...@@ -4,6 +4,7 @@ search_engine.py — 问题知识库搜索引擎
功能: 功能:
基于关键词匹配 + TF-IDF 相似度,从问题知识库中检索最相关的历史案例。 基于关键词匹配 + TF-IDF 相似度,从问题知识库中检索最相关的历史案例。
P2-1 新增:Embedding 向量搜索(优先),TF-IDF 降级兜底。
用法: 用法:
from search_engine import SearchEngine from search_engine import SearchEngine
...@@ -17,7 +18,17 @@ import re ...@@ -17,7 +18,17 @@ import re
import json import json
import math import math
from pathlib import Path from pathlib import Path
from collections import defaultdict, Counter from collections import defaultdict, Counter, OrderedDict
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
from utils.logger import get_logger
logger = get_logger(__name__)
# ============================================================ # ============================================================
# 配置 # 配置
...@@ -33,6 +44,15 @@ SEARCH_INDEX_PATHS = [ ...@@ -33,6 +44,15 @@ SEARCH_INDEX_PATHS = [
SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / "搜索索引.json", # 相对路径 SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / "搜索索引.json", # 相对路径
] ]
# 向量索引路径(与搜索索引同目录查找)
VECTOR_INDEX_PATHS = [
DATA_DIR / "搜索向量.json", # 部署环境
SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / "搜索向量.json", # 相对路径
]
# query 向量缓存上限
_QUERY_VECTOR_CACHE_MAX = 100
def find_search_index(): def find_search_index():
"""按优先级查找搜索索引文件""" """按优先级查找搜索索引文件"""
...@@ -42,6 +62,15 @@ def find_search_index(): ...@@ -42,6 +62,15 @@ def find_search_index():
return path return path
raise FileNotFoundError(f"搜索索引不存在,已查找路径:{SEARCH_INDEX_PATHS}") raise FileNotFoundError(f"搜索索引不存在,已查找路径:{SEARCH_INDEX_PATHS}")
def find_vector_index():
"""按优先级查找向量索引文件(可选,缺失不报错)"""
for path in VECTOR_INDEX_PATHS:
if path.exists():
print(f"[搜索引擎] 找到向量文件:{path}")
return path
return None
# 停用词(过滤无意义的词) # 停用词(过滤无意义的词)
STOPWORDS = { STOPWORDS = {
'的', '了', '是', '在', '有', '我', '不', '和', '与', '或', '的', '了', '是', '在', '有', '我', '不', '和', '与', '或',
...@@ -126,11 +155,23 @@ def cosine_similarity(vec1, vec2): ...@@ -126,11 +155,23 @@ def cosine_similarity(vec1, vec2):
return dot_product / (norm1 * norm2) return dot_product / (norm1 * norm2)
def _cosine_sim_python(vec1, vec2):
"""纯 Python 余弦相似度(用于向量列表,非 dict)"""
if len(vec1) != len(vec2) or not vec1:
return 0.0
dot_product = sum(a * b for a, b in zip(vec1, vec2))
norm1 = math.sqrt(sum(a * a for a in vec1))
norm2 = math.sqrt(sum(b * b for b in vec2))
if norm1 == 0 or norm2 == 0:
return 0.0
return dot_product / (norm1 * norm2)
class SearchEngine: class SearchEngine:
"""问题知识库搜索引擎""" """问题知识库搜索引擎(TF-IDF + Embedding 向量搜索)"""
def __init__(self): def __init__(self):
"""初始化:加载索引并构建 TF-IDF 模型""" """初始化:加载索引并构建 TF-IDF 模型,可选加载向量索引"""
self.index = load_search_index() self.index = load_search_index()
self.records = self.index.get('records', []) self.records = self.index.get('records', [])
...@@ -155,7 +196,233 @@ class SearchEngine: ...@@ -155,7 +196,233 @@ class SearchEngine:
for tokens in self.doc_tokens for tokens in self.doc_tokens
] ]
print(f"[搜索引擎] 初始化完成:{len(self.records)} 条记录") # P2-1:向量搜索相关字段
self._vectors = None # dict: {rec_key: [float]}
self._vector_ids = [] # 有序 ID 列表(与矩阵行对齐)
self._vector_matrix = None # numpy array (N, D),加速批量余弦
self._embedding_enabled = False
self._query_vector_cache = OrderedDict() # LRU: query_text → vector
self._load_vectors()
print(f"[搜索引擎] 初始化完成:{len(self.records)} 条记录,搜索模式:{'vector' if self._embedding_enabled else 'tfidf'}")
# ============================================================
# P2-1:向量搜索内部方法
# ============================================================
def _load_vectors(self):
"""尝试加载向量文件,失败则静默降级 TF-IDF"""
try:
# 1. 检查配置是否启用
import container
config = container.get_config()
if not config.get('embedding_enabled', True):
logger.info("[搜索引擎] embedding_enabled=False,跳过向量加载")
return
# 2. 查找向量文件
vector_path = find_vector_index()
if vector_path is None:
logger.warning("[搜索引擎] 向量文件未找到,降级 TF-IDF")
return
# 3. 加载并校验
with open(vector_path, 'r', encoding='utf-8') as f:
vector_data = json.load(f)
expected_dims = vector_data.get('dimensions', 0)
vectors_dict = vector_data.get('vectors', {})
if not vectors_dict:
logger.warning("[搜索引擎] 向量文件为空,降级 TF-IDF")
return
# 4. 构建有序矩阵(与 records 列表对齐)
if HAS_NUMPY:
vector_ids = []
matrix_rows = []
for i in range(len(self.records)):
key = f"rec_{i:03d}"
if key in vectors_dict:
vec = vectors_dict[key]
if len(vec) != expected_dims:
logger.warning(f"[搜索引擎] 向量维度不匹配(期望 {expected_dims},实际 {len(vec)}),降级 TF-IDF")
return
vector_ids.append(key)
matrix_rows.append(vec)
if not matrix_rows:
logger.warning("[搜索引擎] 无有效向量与记录匹配,降级 TF-IDF")
return
self._vector_ids = vector_ids
self._vector_matrix = np.array(matrix_rows, dtype=np.float32)
# 归一化(用于余弦相似度)
norms = np.linalg.norm(self._vector_matrix, axis=1, keepdims=True)
norms[norms == 0] = 1.0
self._vector_matrix = self._vector_matrix / norms
self._vectors = vectors_dict
self._embedding_enabled = True
else:
# 无 numpy 时用纯 Python
self._vector_ids = []
for i in range(len(self.records)):
key = f"rec_{i:03d}"
if key in vectors_dict:
self._vector_ids.append(key)
if self._vector_ids:
self._vectors = vectors_dict
self._embedding_enabled = True
else:
logger.warning("[搜索引擎] 无有效向量与记录匹配,降级 TF-IDF")
except Exception as e:
logger.warning(f"[搜索引擎] 向量加载失败,降级 TF-IDF:{e}")
self._vectors = None
self._embedding_enabled = False
def _get_query_vector(self, query):
"""获取 query 向量,带 LRU 缓存。失败返回 None(触发降级 TF-IDF)"""
# 1. 查缓存
if query in self._query_vector_cache:
# LRU:移到末尾
self._query_vector_cache.move_to_end(query)
return self._query_vector_cache[query]
# 2. 调 API
vector = self._call_embedding_api(query)
if vector is None:
return None
# 3. 存缓存(LRU 淘汰)
self._query_vector_cache[query] = vector
if len(self._query_vector_cache) > _QUERY_VECTOR_CACHE_MAX:
self._query_vector_cache.popitem(last=False) # 淘汰最旧
return vector
def _call_embedding_api(self, text):
"""调用 OpenAI 兼容 /v1/embeddings API"""
try:
import container
import requests
config = container.get_config()
api_base = config.get('claude_api_base', os.environ.get('CLAUDE_API_BASE', ''))
api_key = config.get('claude_api_key', os.environ.get('CLAUDE_API_KEY', ''))
model = config.get('embedding_model', 'text-embedding-3-small')
timeout = config.get('embedding_api_timeout', 10)
if not api_base or not api_key:
logger.warning("[搜索引擎] API 配置缺失,降级 TF-IDF")
return None
url = f"{api_base.rstrip('/')}/v1/embeddings"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
payload = {"model": model, "input": [text]}
resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
resp.raise_for_status()
data = resp.json()
embedding = data["data"][0]["embedding"]
return embedding
except Exception as e:
logger.warning(f"[搜索引擎] Embedding API 调用失败,降级 TF-IDF:{e}")
return None
def _search_by_vector(self, query_vector, top_k, project_filter, category_filter):
"""向量余弦相似度搜索,返回格式与 TF-IDF 路径一致"""
if HAS_NUMPY and self._vector_matrix is not None:
# numpy 加速路径
qv = np.array(query_vector, dtype=np.float32)
qv_norm = np.linalg.norm(qv)
if qv_norm == 0:
return []
qv = qv / qv_norm
# 批量余弦相似度
similarities = self._vector_matrix @ qv # (N,)
# 构建结果(含过滤)
scores = []
for idx, key in enumerate(self._vector_ids):
record_idx = int(key.split('_')[1])
record = self.records[record_idx]
if project_filter:
if record.get('project', '').lower() != project_filter.lower():
continue
if category_filter:
if category_filter not in record.get('category', []):
continue
score = float(similarities[idx])
# 关键词匹配加分(与 TF-IDF 路径一致)
matched_keywords = 0
for kw in record.get('keywords', []):
if kw.lower() in self._last_query_lower:
matched_keywords += 1
keyword_bonus = matched_keywords * 0.05
# 项目匹配加分
project_bonus = 0
if project_filter and record.get('project', '').lower() == project_filter.lower():
project_bonus = 0.1
final_score = min(1.0, score + keyword_bonus + project_bonus)
if final_score > 0:
scores.append((record_idx, final_score))
else:
# 纯 Python 降级路径
scores = []
for key in self._vector_ids:
record_idx = int(key.split('_')[1])
record = self.records[record_idx]
if project_filter:
if record.get('project', '').lower() != project_filter.lower():
continue
if category_filter:
if category_filter not in record.get('category', []):
continue
doc_vec = self._vectors[key]
score = _cosine_sim_python(query_vector, doc_vec)
matched_keywords = 0
for kw in record.get('keywords', []):
if kw.lower() in self._last_query_lower:
matched_keywords += 1
keyword_bonus = matched_keywords * 0.05
project_bonus = 0
if project_filter and record.get('project', '').lower() == project_filter.lower():
project_bonus = 0.1
final_score = min(1.0, score + keyword_bonus + project_bonus)
if final_score > 0:
scores.append((record_idx, final_score))
# 排序 + top_k
scores.sort(key=lambda x: x[1], reverse=True)
results = []
for rank, (idx, score) in enumerate(scores[:top_k], start=1):
results.append({
'rank': rank,
'score': round(score, 3),
'record': self.records[idx],
})
return results
# ============================================================
# 公开接口(签名零变更)
# ============================================================
def search(self, query, top_k=5, project_filter=None, category_filter=None): def search(self, query, top_k=5, project_filter=None, category_filter=None):
""" """
...@@ -180,6 +447,20 @@ class SearchEngine: ...@@ -180,6 +447,20 @@ class SearchEngine:
if not self.records: if not self.records:
return [] return []
# P2-1:优先向量搜索
if self._vectors and self._embedding_enabled:
self._last_query_lower = query.lower()
query_vector = self._get_query_vector(query)
if query_vector is not None:
return self._search_by_vector(query_vector, top_k, project_filter, category_filter)
# API 失败,降级 TF-IDF
logger.warning("[搜索引擎] query 向量获取失败,降级 TF-IDF")
# TF-IDF 路径(原有逻辑,降级兜底)
return self._search_by_tfidf(query, top_k, project_filter, category_filter)
def _search_by_tfidf(self, query, top_k, project_filter, category_filter):
"""TF-IDF 搜索(原有逻辑,从 search() 拆出)"""
# 对查询进行分词和向量化 # 对查询进行分词和向量化
query_tokens = tokenize(query) query_tokens = tokenize(query)
query_tf = compute_tf(query_tokens) query_tf = compute_tf(query_tokens)
...@@ -242,6 +523,22 @@ class SearchEngine: ...@@ -242,6 +523,22 @@ class SearchEngine:
"""获取所有分类列表""" """获取所有分类列表"""
return list(self.index.get('categories', {}).keys()) return list(self.index.get('categories', {}).keys())
def get_search_mode(self):
"""获取当前搜索模式(供 /api/health 使用)"""
return {
'mode': 'vector' if self._embedding_enabled else 'tfidf',
'vector_loaded': self._vectors is not None,
'embedding_model': self._get_embedding_model_name(),
}
def _get_embedding_model_name(self):
"""获取配置中的 embedding 模型名"""
try:
import container
return container.get_config().get('embedding_model', 'text-embedding-3-small')
except Exception:
return 'text-embedding-3-small'
# 模块级单例 # 模块级单例
_engine = None _engine = None
......
...@@ -18,7 +18,7 @@ logger = get_logger(__name__) ...@@ -18,7 +18,7 @@ logger = get_logger(__name__)
def rebuild_search_index(): def rebuild_search_index():
"""重建搜索索引""" """重建搜索索引(含向量索引)"""
# 复用 build_index.py 的逻辑 # 复用 build_index.py 的逻辑
build_index_script = SCRIPT_DIR.parent / "build_index.py" build_index_script = SCRIPT_DIR.parent / "build_index.py"
if build_index_script.exists(): if build_index_script.exists():
...@@ -32,6 +32,15 @@ def rebuild_search_index(): ...@@ -32,6 +32,15 @@ def rebuild_search_index():
if result.returncode != 0: if result.returncode != 0:
logger.error(f"索引重建失败:{result.stderr}") logger.error(f"索引重建失败:{result.stderr}")
# P2-1:同步重建向量(失败不阻塞,SearchEngine 会降级 TF-IDF)
try:
from utils import vector_builder
vector_result = vector_builder.build_vectors()
if vector_result and vector_result[2] > 0:
logger.warning(f"向量重建部分失败:{vector_result[2]} 条失败")
except Exception as e:
logger.warning(f"向量重建失败(将降级 TF-IDF):{e}")
# 重新加载搜索引擎(通过 container 重置单例) # 重新加载搜索引擎(通过 container 重置单例)
container.reset_search_engine() container.reset_search_engine()
container.get_search_engine() container.get_search_engine()
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5">
<title>问题排查助手</title> <title>问题排查助手</title>
<style> <style>
:root { :root {
...@@ -442,6 +442,63 @@ ...@@ -442,6 +442,63 @@
opacity: 0.8; opacity: 0.8;
font-size: 12px; font-size: 12px;
} }
/* ===== P2-2:移动端响应式 ===== */
/* 平板(769–1024px)*/
@media screen and (max-width: 1024px) {
.form-grid { grid-template-columns: repeat(2, 1fr); }
.container { max-width: 100%; padding: 16px; }
}
/* 手机(≤768px)*/
@media screen and (max-width: 768px) {
:root { font-size: 14px; }
.container { padding: 12px; }
.form-grid { grid-template-columns: 1fr; }
/* 表单控件触控热区 */
input, select, textarea, .btn {
min-height: 44px;
font-size: 16px; /* 避免 iOS Safari 自动放大 */
}
/* 弹窗适配 */
.modal {
width: 92vw !important;
max-height: 85vh;
margin: auto;
}
.modal-content {
max-height: calc(85vh - 60px);
overflow-y: auto;
}
/* 代码块横向滚动 */
pre, code {
overflow-x: auto;
white-space: pre;
max-width: 100%;
}
/* AI 结果区 */
.result-card {
padding: 12px;
}
/* 用户信息栏 */
.user-info {
flex-direction: column;
gap: 8px;
}
}
/* 小屏手机(≤375px)*/
@media screen and (max-width: 375px) {
:root { font-size: 13px; }
.btn { padding: 10px 14px; }
}
</style> </style>
</head> </head>
<body> <body>
...@@ -773,6 +830,11 @@ ...@@ -773,6 +830,11 @@
// 追加内容 // 追加内容
fullContent += data.content; fullContent += data.content;
updateResponseContent(fullContent); updateResponseContent(fullContent);
// P2-2:移动端自动滚动到底部(键盘弹起时仍可见)
const resultDiv = document.getElementById('result');
if (resultDiv) {
resultDiv.scrollTop = resultDiv.scrollHeight;
}
break; break;
case 'done': case 'done':
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5">
<title>登录 - 问题排查助手</title> <title>登录 - 问题排查助手</title>
<style> <style>
:root { :root {
...@@ -145,6 +145,30 @@ ...@@ -145,6 +145,30 @@
font-size: 13px; font-size: 13px;
color: var(--gray-500); color: var(--gray-500);
} }
/* ===== P2-2:移动端响应式 ===== */
@media screen and (max-width: 768px) {
.login-card {
width: 92vw;
max-width: none;
}
.login-header {
padding: 24px 16px;
}
.login-body {
padding: 24px 16px;
}
.form-input {
min-height: 44px;
font-size: 16px; /* 避免 iOS Safari 自动放大 */
}
.btn-login {
min-height: 44px;
}
.login-footer {
padding: 12px 16px;
}
}
</style> </style>
</head> </head>
<body> <body>
......
...@@ -55,3 +55,6 @@ CONFIG_FILE = SCRIPT_DIR / "config.json" ...@@ -55,3 +55,6 @@ CONFIG_FILE = SCRIPT_DIR / "config.json"
# 审计日志文件 # 审计日志文件
AUDIT_LOG_FILE = SCRIPT_DIR / "audit.log" AUDIT_LOG_FILE = SCRIPT_DIR / "audit.log"
# 向量索引文件名(与搜索索引同目录部署)
VECTOR_INDEX_FILENAME = "搜索向量.json"
# -*- coding: utf-8 -*-
"""
vector_builder.py — 向量预计算脚本
读取 搜索索引.json,调用 OpenAI 兼容 /v1/embeddings API 生成向量,
写入 搜索向量.json,供 SearchEngine 运行时加载。
用法:
# 默认路径(自动查找索引、输出同目录)
python -m utils.vector_builder
# 指定路径
python -m utils.vector_builder --index-path /path/to/搜索索引.json --output /path/to/搜索向量.json
# 指定批大小与 API 参数
python -m utils.vector_builder --batch-size 20 --model text-embedding-3-small
"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
import requests
# ============================================================
# 路径定位(与 search_engine.py 保持一致)
# ============================================================
SCRIPT_DIR = Path(__file__).resolve().parent.parent # .../web
DATA_DIR = SCRIPT_DIR.parent # 部署时即 /opt/troubleshoot
def _find_index_path(explicit_path=None):
"""查找搜索索引文件"""
if explicit_path:
p = Path(explicit_path)
if p.exists():
return p
print(f"[vector_builder] 指定的索引文件不存在:{p}")
# 复用 search_engine.py 的路径查找逻辑
candidates = [
DATA_DIR / "搜索索引.json",
SCRIPT_DIR.parent.parent.parent.parent / "Docs" / "PRD" / "问题知识库" / "搜索索引.json",
]
for path in candidates:
if path.exists():
return path
print("[vector_builder] 错误:未找到搜索索引文件")
sys.exit(1)
def _get_api_config():
"""读取 API 配置(从 container.get_config 或环境变量)"""
try:
import container
config = container.get_config()
return {
"api_base": config.get("claude_api_base", os.environ.get("CLAUDE_API_BASE", "")),
"api_key": config.get("claude_api_key", os.environ.get("CLAUDE_API_KEY", "")),
"model": config.get("embedding_model", "text-embedding-3-small"),
"timeout": config.get("embedding_api_timeout", 10),
}
except Exception:
return {
"api_base": os.environ.get("CLAUDE_API_BASE", ""),
"api_key": os.environ.get("CLAUDE_API_KEY", ""),
"model": "text-embedding-3-small",
"timeout": 10,
}
def _call_embedding_api(texts, api_base, api_key, model, timeout):
"""调用 /v1/embeddings API,返回向量列表"""
url = f"{api_base.rstrip('/')}/v1/embeddings"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
payload = {
"model": model,
"input": texts,
}
resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
resp.raise_for_status()
data = resp.json()
# 按 index 排序保证顺序
embeddings = sorted(data["data"], key=lambda x: x["index"])
return [item["embedding"] for item in embeddings]
def build_vectors(index_path=None, output_path=None, batch_size=20, model_override=None):
"""
读取索引,批量生成向量,写入文件。
参数:
index_path: 搜索索引文件路径(None 则自动查找)
output_path: 输出向量文件路径(None 则与索引同目录)
batch_size: 每批调 API 的记录数
model_override: 覆盖配置中的模型名
返回:
(total, success, failed) 统计
"""
index_file = _find_index_path(index_path)
print(f"[vector_builder] 加载索引:{index_file}")
with open(index_file, 'r', encoding='utf-8') as f:
index_data = json.load(f)
records = index_data.get('records', [])
if not records:
print("[vector_builder] 错误:索引中无记录")
return 0, 0, 0
# 构建待编码文本:title + full_text
texts = []
for rec in records:
title = rec.get('title', '')
full_text = rec.get('full_text', '')
texts.append(f"{title} {full_text}".strip())
# API 配置
api_config = _get_api_config()
model = model_override or api_config["model"]
api_base = api_config["api_base"]
api_key = api_config["api_key"]
timeout = api_config["timeout"]
if not api_base or not api_key:
print("[vector_builder] 错误:API 地址或密钥未配置(CLAUDE_API_BASE / CLAUDE_API_KEY)")
return len(records), 0, len(records)
print(f"[vector_builder] 开始生成向量:{len(records)} 条记录,模型={model},批大小={batch_size}")
# 分批调用
vectors = {}
total = len(texts)
success = 0
failed = 0
dimensions = None
for batch_start in range(0, total, batch_size):
batch_end = min(batch_start + batch_size, total)
batch_texts = texts[batch_start:batch_end]
try:
batch_vectors = _call_embedding_api(batch_texts, api_base, api_key, model, timeout)
if dimensions is None and batch_vectors:
dimensions = len(batch_vectors[0])
for i, vec in enumerate(batch_vectors):
record_idx = batch_start + i
record_key = f"rec_{record_idx:03d}"
vectors[record_key] = vec
success += 1
print(f" 批次 {batch_start//batch_size + 1}:{batch_start+1}-{batch_end}/{total} 完成")
except Exception as e:
failed += len(batch_texts)
print(f" 批次 {batch_start//batch_size + 1}:失败 — {e}")
# 批次间间隔,避免限流
if batch_end < total:
time.sleep(0.5)
if not vectors:
print("[vector_builder] 错误:无向量生成成功,不写入文件")
return total, 0, total
# 确定输出路径
if output_path:
out_file = Path(output_path)
else:
out_file = index_file.parent / "搜索向量.json"
# 写入向量文件
output_data = {
"model": model,
"dimensions": dimensions or 0,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"record_count": len(vectors),
"vectors": vectors,
}
out_file.parent.mkdir(parents=True, exist_ok=True)
with open(out_file, 'w', encoding='utf-8') as f:
json.dump(output_data, f, ensure_ascii=False)
print(f"[vector_builder] 完成:成功 {success},失败 {failed},总计 {total}")
print(f"[vector_builder] 向量文件已写入:{out_file}")
return total, success, failed
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="生成搜索向量索引")
parser.add_argument('--index-path', help='搜索索引文件路径')
parser.add_argument('--output', help='输出向量文件路径')
parser.add_argument('--batch-size', type=int, default=20, help='每批调 API 的记录数(默认 20)')
parser.add_argument('--model', help='覆盖配置中的 embedding 模型名')
args = parser.parse_args()
build_vectors(
index_path=args.index_path,
output_path=args.output,
batch_size=args.batch_size,
model_override=args.model,
)
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论