# 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 收尾执行记录（参考格式）
