# 计划执行文档 — 问题排查助手离线部署方案

> 配套 PRD：`PRD_需求文档_离线部署方案_问题排查助手.md`（v2.1）
> 版本：1.0 ｜ 日期：2026-07-16 ｜ 分支：troubleshoot-ai-assistant
> 执行顺序：T1 → T2 → T3 → T4 → T5 → T6（验收）→ T7（部署）

---

## 0. 执行概述

### 目标
为问题排查助手实现离线 Q&A 模式：在 `OFFLINE_MODE=true` 时跳过 Claude API，直接返回 TF-IDF 匹配结果；并提供 Docker 容器化部署能力。

### 改动范围（严格遵守 §3.7 隔离红线）
| 类型 | 文件 | 说明 |
|------|------|------|
| 新增 | `skill/code/web/utils/offline_config.py` | 读取 `OFFLINE_MODE` 环境变量 |
| 修改 | `skill/code/web/services/ai_service.py` | 新增 `build_offline_response()` |
| 修改 | `skill/code/web/routes/troubleshoot.py` | 三路由 + health 增加离线分支 |
| 新增 | `Dockerfile` | 仓库根目录 |
| 新增 | `docker-compose.yml` | 仓库根目录 |
| 新增 | `requirements.txt` | 仓库根目录 |
| 新增 | `.dockerignore` | 排除向量文件等 |
| 新增 | `.env.example` 补充 `OFFLINE_MODE` | 文档同步 |
| 新增 | `skill/code/tests/test_offline_mode.py` | 离线模式单元测试 |
| **禁止修改** | `routes/service_manage.py`、`routes/service_monitor.py` | 隔离红线 |

### 关键决策（已锁定）
1. 搜索用 **TF-IDF**（不调向量 API，省费）
2. 流式接口 **保留 SSE**，离线单帧推送后 close
3. 镜像 **兼容在线模式**（保留 requests）
4. `OFFLINE_MODE` 走 **环境变量**（与 SECRET_KEY 一致）
5. 离线镜像 **不打包 `搜索向量.json`**

---

## 1. 任务分解与实施计划

### T1：离线配置读取工具（utils 层）
**文件**：`skill/code/web/utils/offline_config.py`（新增）

**职责**：集中读取 `OFFLINE_MODE` 环境变量，返回 bool。隔离在 utils 层，供 routes/services 调用，不污染 container 单例边界。

**实现要点**：
```python
# -*- coding: utf-8 -*-
"""offline_config.py — 离线模式开关读取（问题排查助手专用）

从环境变量 OFFLINE_MODE 读取，启动时读取一次。
true/1/yes（不区分大小写）→ True，其余 → False（默认在线）。
"""
import os

_CACHE = None  # 进程级缓存，启动时读一次

def is_offline_mode():
    """返回是否离线模式"""
    global _CACHE
    if _CACHE is None:
        raw = os.environ.get('OFFLINE_MODE', '').strip().lower()
        _CACHE = raw in ('true', '1', 'yes')
    return _CACHE

def reset_for_test():
    """测试用：重置缓存"""
    global _CACHE
    _CACHE = None
```

**验收**：
- `OFFLINE_MODE=true` → `True`
- `OFFLINE_MODE=False` / 未设置 → `False`
- `reset_for_test()` 后重新读取

**约束**：仅此模块读 `OFFLINE_MODE`，其他文件统一调用 `is_offline_mode()`。

---

### T2：离线响应构建（services 层）
**文件**：`skill/code/web/services/ai_service.py`（修改，新增函数）

**职责**：新增 `build_offline_response(matched_cases_data)`，将 TF-IDF 匹配结果格式化为离线响应。

**实现要点**：
```python
def build_offline_response(matched_cases_data):
    """构建离线模式响应（跳过 Claude API）

    返回 dict，供 /api/troubleshoot 与 /api/analyze 复用：
      {
        "response": str,        # 格式化文本（标题+横幅+案例列表）
        "matched_cases": list,  # 复用 troubleshoot.py 的 format_matched_cases
        "offline": True,
        "api_time": 0.0,
      }
    """
    # 横幅 + 案例详情拼接（参照 PRD §3.1 展示格式）
    ...
```

**案例格式参照 PRD §3.1**：标题「📋 离线排查参考（基于知识库匹配）」+ 横幅「当前为离线模式，结果基于历史知识库匹配」+ 每个案例（相似度/项目/标题/现象/解决方案/参考文件）。

**关键决策**：`build_offline_response` 只负责生成 `response` 文本，`matched_cases` 列表仍由路由层的 `format_matched_cases()` 复用现有逻辑，避免重复格式化代码。

**验收**：
- 输入 5 条匹配结果，输出含 5 个案例详情的文本
- 输入空列表，返回「未匹配到相关案例」兜底
- 不调用任何网络/API

**约束**：不改 `build_prompt` / `call_claude_api` 等现有函数签名。

---

### T3：路由层离线分支（routes 层）
**文件**：`skill/code/web/routes/troubleshoot.py`（修改）

**职责**：三个路由 + health 增加 `OFFLINE_MODE` 分支。

#### T3.1 `/api/troubleshoot`（第 57 行 `troubleshoot()`）
在「1. 搜索」之后插入分支：
```python
# 1. 搜索匹配案例
engine = container.get_search_engine()
matched_cases_data = engine.search(...)

# 【新增】离线模式：跳过 Claude API
if is_offline_mode():
    result = build_offline_response(matched_cases_data)
    log_audit({... 'mode': 'offline' ...})
    return jsonify({
        'success': True,
        'response': result['response'],
        'matched_cases': format_matched_cases(matched_cases_data),
        'offline': True,
        'api_time': 0.0,
    })

# 2. 构建 Prompt（在线模式继续原逻辑）
...
```

#### T3.2 `/api/analyze`（第 150 行 `analyze()`）
该接口入参带 `matched_cases`（前端已搜索过）。离线分支：
```python
if is_offline_mode():
    # analyze 入参的 matched_cases 是前端传来的 dict 列表，需重新喂给 build_offline_response
    result = build_offline_response(matched_cases)
    log_audit({... 'mode': 'offline' ...})
    return jsonify({
        'success': True,
        'response': result['response'],
        'offline': True,
        'api_time': 0.0,
    })
```
**注意**：analyze 的 `matched_cases` 是前端传来的已格式化 dict（含 rank/score/project/title/file/phenomenon），与 troubleshoot 的 `matched_cases_data`（含 record 原始结构）不同。`build_offline_response` 需兼容两种输入，或路由层做适配转换。**实施时以 `build_offline_response` 统一接收「已格式化 dict 列表」为准**，troubleshoot 路由先调 `format_matched_cases` 再传入。

#### T3.3 `/api/analyze/stream`（第 201 行 `analyze_stream()`）
离线模式**保留 SSE 协议**，单帧推送后结束：
```python
def generate():
    yield f'data: {json.dumps({"type": "start", "message": "正在分析...", "offline": True}, ensure_ascii=False)}\n\n'
    # 搜索
    engine = container.get_search_engine()
    matched_cases_data = engine.search(...)
    matched_cases = format_matched_cases(matched_cases_data)
    yield f'data: {json.dumps({"type": "matched_cases", "cases": matched_cases, "offline": True}, ensure_ascii=False)}\n\n'
    # 离线：单帧推送完整结果
    result = build_offline_response(matched_cases)
    yield f'data: {json.dumps({"type": "chunk", "content": result["response"], "offline": True}, ensure_ascii=False)}\n\n'
    yield f'data: {json.dumps({"type": "done", "api_time": 0.0, "offline": True}, ensure_ascii=False)}\n\n'
    log_audit({... 'mode': 'offline_stream' ...})
```
在 `generate()` 开头判断 `is_offline_mode()` 走离线生成器，否则走原在线逻辑。**缓存检查仍在离线分支之前**（离线结果也可缓存，但本期离线 <1s 可不缓存——决策：离线模式跳过缓存读写，直接返回）。

#### T3.4 `/api/health`（第 324 行 `health_check()`）
响应体新增 `offline_mode` 字段：
```python
from utils.offline_config import is_offline_mode
...
return jsonify({
    'status': 'ok',
    ...
    'offline_mode': is_offline_mode(),   # 【新增】
    ...
})
```

**验收**：
- `OFFLINE_MODE=true` 时三个路由均不触发 `call_claude_api` / `call_claude_api_stream`
- `OFFLINE_MODE` 未设置时三个路由行为与改造前完全一致（回归）
- health 响应含 `offline_mode` 字段

**约束**：不改 `format_matched_cases`、`search_cases`、`get_projects`、`get_categories`。

---

### T4：单元测试
**文件**：`skill/code/tests/test_offline_mode.py`（新增）

**用例清单**（预计 8-10 例）：
| # | 用例 | 验证点 |
|---|------|--------|
| 1 | `is_offline_mode` 读 `true` | 返回 True |
| 2 | `is_offline_mode` 未设置 | 返回 False |
| 3 | `build_offline_response` 有匹配 | 含案例详情文本 |
| 4 | `build_offline_response` 空匹配 | 兜底文案 |
| 5 | `/api/troubleshoot` 离线 | 返回 `offline:true`，不调 API |
| 6 | `/api/analyze` 离线 | 返回 `offline:true` |
| 7 | `/api/analyze/stream` 离线 | SSE 含 offline 字段，单帧 |
| 8 | `/api/health` | 含 `offline_mode` 字段 |
| 9 | 在线模式回归 | `OFFLINE_MODE` 未设时行为不变 |

**依赖 conftest.py**：复用现有 `sys.path` 注入与索引路径 fixture；新增 `monkeypatch.setenv('OFFLINE_MODE', 'true')` + 调 `reset_for_test()` fixture。

**验收**：`cd skill/code && python -m pytest tests/test_offline_mode.py -v` 全绿，且原有 143 用例不回归。

---

### T5：容器化部署文件
**文件**（仓库根目录新增）：

#### T5.1 `requirements.txt`
```text
flask==3.0.3
gunicorn==22.0.0
scikit-learn==1.4.2
scipy==1.13.0
numpy==1.26.4
jieba==0.42.1
werkzeug==3.0.3
requests==2.32.3
cryptography==46.0.7
paramiko==4.0.0
```
> - 版本以本地实测通过为准（service_monitor 51 + 问题排查助手 167 用例全绿）。
> - `cryptography` / `paramiko` 为同进程 service_monitor 子包依赖，见 T8 与 PRD §4.6。

#### T5.2 `Dockerfile`
```dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY skill/code/web/ /app/web/
COPY skill/code/SKILL.md /app/SKILL.md
EXPOSE 8088
ENV PYTHONIOENCODING=utf-8
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8088", "server:app"]
```
> gunicorn 加载 `server:app`——已确认 `server.py:107` 有模块级 `app = create_app()`，gunicorn 需以 `/app/web` 为工作目录，故 CMD 前需 `WORKDIR /app/web`（**实施时调整：将 WORKDIR 设为 `/app/web`，代码拷到 `/app/web/`，gunicorn 在该目录加载 `server` 模块**）。

**修正**：
```dockerfile
FROM python:3.10-slim
WORKDIR /app/web
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY skill/code/web/ /app/web/
COPY skill/code/SKILL.md /app/SKILL.md
EXPOSE 8088
ENV PYTHONIOENCODING=utf-8
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8088", "server:app"]
```

#### T5.3 `docker-compose.yml`
```yaml
version: '3.8'
services:
  troubleshoot:
    build: .
    ports:
      - "8088:8088"
    restart: always
    volumes:
      - ./config.json:/app/web/config.json:ro
      - ./users.json:/app/web/users.json:ro
      - ./records:/app/web/records:ro
      - ./logs:/app/logs
    environment:
      - PYTHONIOENCODING=utf-8
      - SECRET_KEY=${SECRET_KEY}
      - OFFLINE_MODE=${OFFLINE_MODE:-true}
```

#### T5.4 `.dockerignore`
```text
# 排除向量文件，强制容器内走 TF-IDF（PRD §4.4）
搜索向量.json
**/搜索向量.json
deploy/搜索向量.json

# 其他无关项
.git
.claude
Docs
临时目录
HANDOFF.md
**/__pycache__
**/*.pyc
```

#### T5.5 `.env.example` 补充
在现有 `.env.example` 增加：
```bash
# 离线模式开关（true=跳过 Claude API，false=在线调 API）
OFFLINE_MODE=true
```

**验收**：四个文件齐全，`.dockerignore` 确认排除向量文件。

---

### T6：本地容器构建与验证
**前置**：本地需有 Docker 环境。

**步骤**：
1. `docker build -t troubleshoot:offline .`
2. `docker run -d -p 8089:8088 -e OFFLINE_MODE=true -e SECRET_KEY=test troubleshoot:offline`
3. `curl http://localhost:8089/api/health` → 验证 `offline_mode: true` 且 `search.mode: tfidf`
4. `curl -X POST http://localhost:8089/api/troubleshoot -d '{"query":"门口屏MQTT绑定失败"}' -H 'Content-Type: application/json'` → 验证 `offline: true`、`response` 含案例、`api_time < 1s`
5. 验证容器内**不存在** `搜索向量.json`（`docker exec <cid> ls /app/web/records/`）

**验收**：上述 5 项全过。若本地无 Docker，则跳过 T6，直接在 5.60 实测。

---

### T7：部署到 5.60 + 权威验证
**说明**：5.60 现为裸机部署（`upload_to_server.py` 走 SSH），本期容器化是**新增能力**，不立即替换现有裸机部署。

**步骤**（用 `!` 前缀执行，避免环境变量读不到）：
1. 本地构建：`docker build -t troubleshoot:offline .`
2. 导出：`docker save troubleshoot:offline | gzip > troubleshoot-offline.tar.gz`
3. 传到 5.60：`! scp troubleshoot-offline.tar.gz ubains@192.168.5.60:/opt/`（需 `SSH_PASSWORD`）
4. 5.60 加载：`docker load < troubleshoot-offline.tar.gz`
5. 启动：`docker compose up -d`（需在 5.60 准备 `docker-compose.yml` + `.env`）
6. 权威验证：`cd deploy && python verify_deployment.py`（确认 `offline_mode` 与 `search.mode` 字段）

**验收**：
- 5.60 上容器健康检查返回 `offline_mode: true`、`search.mode: tfidf`
- `/api/troubleshoot` 离线响应 <1s
- 现有裸机部署（8088）不受影响（容器映射到其他端口或停机窗口操作）

**注意**：若 5.60 未装 Docker，T7 无法执行，记录为「待现场环境就绪」。

---

### T8：同进程 service_monitor 依赖补齐（PRD §4.6）
**背景**：问题排查助手与 service_monitor 同处一个 Flask 进程（`server.py::create_app()` 一次性注册全部 Blueprint）。service_monitor 子包的 `utils/crypto.py` 模块级 `from cryptography.fernet import Fernet` 在 `create_app()` 阶段执行；若镜像缺 `cryptography`，**进程启动即崩，问题排查助手连带不可用**。`paramiko` 为函数内延迟导入（`executor.py:249`），启动不崩但用 SSH 监测时崩。

**文件**：`requirements.txt`（修改）

**改动**：补两行
```text
cryptography==42.0.5
paramiko==3.4.0
```

**验收**：
- 镜像构建后 `python -c "import cryptography, paramiko"` 成功
- 容器启动 `create_app()` 不再因 service_monitor 导入失败而崩
- 问题排查助手离线路由行为不受影响（回归 T4 用例仍全绿）

**约束**：
- 仅改 `requirements.txt`，**不改 service_monitor 源码**（§3.7 隔离红线）
- 不拆镜像 / 不拆进程（超出 PRD 范围，且破坏 service_monitor 独立子包设计）
- 容错 try/except（`server.py` 注册时跳过失败模块）**本期不做**——`server.py` 正随 service_monitor 重构变动，避免文件冲突，待重构稳定后另行评估

---

## 2. 验收标准（对照 PRD §5 非功能需求）

| PRD 要求 | 验收方式 | 对应任务 |
|----------|----------|----------|
| 配置切换改 `OFFLINE_MODE` 即可 | 改环境变量重启容器，行为切换 | T3/T6 |
| 离线 TF-IDF 逻辑不变 | 单测 + health `search.mode:tfidf` | T4/T6 |
| 离线排查 <1 秒 | `/api/troubleshoot` 离线响应计时 | T6 |
| 前端标注"离线模式" | 前端读 `offline:true` 显示横幅（前端改动另计，本期后端就绪即可） | T3 |
| 流式保留 SSE | `/api/analyze/stream` 离线仍返回 `text/event-stream` | T3.3 |
| Dockerfile+compose+requirements 齐全 | 文件存在 + 构建成功 | T5/T6 |
| 进程守护 restart always | compose `restart: always` | T5.3 |
| 日志持久化 | `./logs` volume 挂载 | T5.3 |
| 镜像兼容在线 | `OFFLINE_MODE=false` 时可调 Claude API（依赖 requests 已装） | T5.1 |

---

## 3. 测试计划

### 3.1 单元测试（T4）
- 新增 `test_offline_mode.py`，目标 ≥8 用例
- 回归：`python -m pytest -v` 全部 ≥151 用例全绿（原 143 + 新增）

### 3.2 集成测试（T6 本地容器）
- health 字段校验
- 三个路由离线返回校验
- 向量文件不存在校验

### 3.3 回归测试（隔离红线验证）
- 离线改造后，手动调用服务管理/服务监测路由，确认响应结构、状态码不变
- `service_manage.py` / `service_monitor.py` 文件 git diff 为空（零改动）

### 3.4 部署验证（T7）
- `verify_deployment.py` 通过
- 容器 `docker ps` 状态 running，restart 策略 always

---

## 4. 风险评估

| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| `requirements.txt` 版本号在 slim 镜像装不上（scipy 编译） | 中 | T6 本地先构建验证；必要时改用 `python:3.10` 非 slim 或预编译 wheel |
| gunicorn WORKDIR 路径不对，加载不到 `server` 模块 | 中 | T5.2 已将 WORKDIR 设为 `/app/web`，T6 构建后实测 `docker run` 启动日志 |
| analyze 路由入参 `matched_cases` 格式与 troubleshoot 不一致，导致 `build_offline_response` 兼容问题 | 中 | T3.2 已标注，实施时统一接收「已格式化 dict 列表」，troubleshoot 先 format 再传入 |
| 5.60 无 Docker 环境，T7 无法执行 | 中 | T7 标注「待现场就绪」，不阻塞 T1-T6 验收 |
| 离线模式下 `cache_manager` 空跑产生无意义缓存文件 | 低 | T3.3 决策：离线跳过缓存读写 |
| 前端未适配 `offline:true` 横幅显示 | 低 | 本期后端就绪即可，前端适配另行处理 |
| 同进程 service_monitor 的 `cryptography` 模块级导入失败，连累问题排查助手启动 | 中 | T8 补依赖入 requirements；不改 service_monitor 源码 |

---

## 5. 实施记录

| 任务 | 状态 | 完成时间 | 备注 |
|------|------|----------|------|
| T1 离线配置工具 | ✅ 完成 | 2026-07-16 | 新增 `utils/offline_config.py` |
| T2 离线响应构建 | ✅ 完成 | 2026-07-16 | `ai_service.py` 新增 `build_offline_response()`，兼容两种输入格式 |
| T3 路由离线分支 | ✅ 完成 | 2026-07-16 | troubleshoot/analyze/analyze_stream/health 四处改完，离线跳过缓存 |
| T4 单元测试 | ✅ 完成 | 2026-07-16 | 新增 22 用例全绿，回归 167 全绿 |
| T5 容器化文件 | ✅ 完成 | 2026-07-16 | Dockerfile/compose/requirements/.dockerignore/.env.example |
| T6 本地容器验证 | ⏸️ 暂缓 | — | 待你本地有 Docker 时验证 |
| T7 部署 5.60 | ⏸️ 暂缓 | — | 5.60 有 Docker 但当前网络不可达，待现场执行 |
| T8 同进程依赖补齐 | ✅ 完成 | 2026-07-16 | requirements 补 cryptography/paramiko，回归测试通过 |

---

## 6. 后续工作（本期不做）

- 前端 index.html 适配 `offline:true` 横幅与 `analysis:null` 提示
- 服务管理/服务监测模块的容器化方案（另立 PRD）
- 三模块是否共用镜像底座的统筹决策
- 本地小模型推理（Ollama）
- 配置热更新（`OFFLINE_MODE` 改后免重启）
- `deploy/upload_to_server.py` 是否改为推送镜像而非裸文件（容器化全面落地后）

---

## 7. 附录

### 7.1 关键代码定位
- 离线开关：`utils/offline_config.py::is_offline_mode`
- 离线响应：`services/ai_service.py::build_offline_response`
- 路由分支：`routes/troubleshoot.py`（troubleshoot / analyze / analyze_stream / health）
- gunicorn 入口：`server.py:107` `app = create_app()`

### 7.2 PRD 对照
本计划严格对照 PRD v2.1：
- §3.1 离线 Q&A → T2
- §3.2 环境变量开关 → T1
- §3.4 API 行为变更 → T3
- §3.5 health 字段 → T3.4
- §3.6 代码分层 → T1/T2/T3
- §3.7 模块隔离 → 全程约束（禁改 service_manage / service_monitor）
- §4.1-4.5 容器化 → T5
- §4.4 向量文件不打包 → T5.4 `.dockerignore`
- §5 非功能需求 → 第 2 节验收
- §6 约束 → 全程遵守
