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

feat(offline): 问题排查助手离线 Q&A 模式和容器化部署

离线模式:OFFLINE_MODE 环境变量控制,跳过 Claude API 直接返回 TF-IDF 匹配结果
容器化:Dockerfile + docker-compose.yml + requirements.txt + .dockerignore
同进程依赖:补 cryptography/paramiko(service_monitor 子包依赖,PRD §4.6)

详细变更:
- utils/offline_config.py:离线开关读取(env OFFLINE_MODE → bool,进程级缓存)
- services/ai_service.py:新增 build_offline_response(),兼容原始/格式化两种输入
- routes/troubleshoot.py:troubleshoot/analyze/analyze_stream/health 四处离线分支
  - 离线流式保留 SSE 协议,单帧推送后 close(前端无需改动)
  - 离线跳过缓存读写(<1s 无需缓存)
  - health 响应新增 offline_mode 字段
- tests/test_offline_mode.py:22 用例全绿(离线配置/响应构建/路由/回归)
- Dockerfile:python:3.10-slim,gunicorn 多 worker,WORKDIR /app/web
- docker-compose.yml:restart always,records/users/config 只读挂载,logs 持久化
- .dockerignore:排除 搜索向量.json(强制 TF-IDF),排除测试/缓存/临时文件
- requirements.txt:flask/gunicorn/scikit-learn/scipy/numpy/jieba/werkzeug/requests
  + cryptography/paramiko(同进程 service_monitor 硬依赖)
- .env.example:补 OFFLINE_MODE 说明
- PRD v2.2 + 计划执行文档(增加 §4.6 同进程依赖耦合说明 + T8 任务)

测试:新增 22 用例全绿,回归 218 全绿(问题排查助手 167 + service_monitor 51)
隔离:未改 service_monitor / service_manage 代码,仅补其依赖到 requirements
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 c6287b3b
# 向量文件:离线镜像不打包,强制容器内走 TF-IDF(PRD §4.4)
# 若打包进容器,SearchEngine 会静默走向量模式,与离线用 TF-IDF 的设计矛盾
搜索向量.json
**/搜索向量.json
deploy/搜索向量.json
# 版本控制与工具
.git
.gitignore
.claude
# 文档与临时文件
Docs
临时目录
HANDOFF.md
# Python 缓存
**/__pycache__
**/*.pyc
**/*.pyo
# 测试与覆盖率
skill/code/tests
skill/code/.pytest_cache
.coverage
htmlcov
# 本地数据与日志(挂载方式注入,不入镜像)
logs
cache
......@@ -20,6 +20,14 @@ SECRET_KEY=your_secret_key_here
CLAUDE_API_BASE=https://office.ubainsyun.com:8400
CLAUDE_API_KEY=your_api_key_here
# ============================================
# 离线模式开关(问题排查助手)
# true = 离线模式,跳过 Claude API,直接返回 TF-IDF 匹配结果
# false = 在线模式(默认),调用 Claude API 做 AI 分析
# 现场内网/离线部署时设为 true
# ============================================
OFFLINE_MODE=true
# ============================================
# 数据库配置(如需要)
# ============================================
......@@ -27,3 +35,11 @@ CLAUDE_API_KEY=your_api_key_here
# DB_PORT=3306
# DB_USER=root
# DB_PASSWORD=your_db_password
# ============================================
# 服务监测模块配置
# ============================================
# SSH 凭据加密密钥(Fernet)。
# 不设则派生自 SECRET_KEY;生产建议单独设置一个 44 字节 base64 密钥
# 生成方法:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MONITOR_ENC_KEY=
\ No newline at end of file
FROM python:3.10-slim
# 工作目录设为 /app/web,与 utils/paths.py 的 SCRIPT_DIR 推导一致
# gunicorn 直接加载 server 模块(server.py:107 已有模块级 app = create_app())
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
# 启动:gunicorn 多 worker
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8088", "server:app"]
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}
# 离线模式开关:true=跳过 Claude API(离线 Q&A),false=在线调 API
- OFFLINE_MODE=${OFFLINE_MODE:-true}
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
# 同进程 service_monitor 子包依赖(PRD §4.6)
# cryptography 模块级导入,缺失则 create_app() 启动即崩,连累问题排查助手
# paramiko 延迟导入(SSH 监测时),缺失则用到时崩
# 版本以本地实测通过为准(service_monitor 51 用例 + 问题排查助手 167 用例全绿)
cryptography==46.0.7
paramiko==4.0.0
# -*- coding: utf-8 -*-
"""
离线模式单元测试(PRD §3 离线部署方案)
覆盖:
T1 utils/offline_config.py 的 is_offline_mode / reset_for_test
T2 services/ai_service.build_offline_response
T3 routes/troubleshoot.py 三个路由 + health 的离线分支
回归:OFFLINE_MODE 未设置时行为不变(不调 AI)
隔离:本测试只动问题排查助手相关文件,不触碰 service_manage / service_monitor。
"""
import json
import pytest
import utils.offline_config as offline_config
# ============================================================
# Fixture:离线开关切换
# ============================================================
@pytest.fixture
def offline_on(monkeypatch):
"""开启离线模式"""
monkeypatch.setenv('OFFLINE_MODE', 'true')
offline_config.reset_for_test()
yield
offline_config.reset_for_test()
@pytest.fixture
def offline_off(monkeypatch):
"""关闭离线模式(在线)"""
monkeypatch.delenv('OFFLINE_MODE', raising=False)
offline_config.reset_for_test()
yield
offline_config.reset_for_test()
# ============================================================
# T1:offline_config
# ============================================================
class TestOfflineConfig:
def test_offline_true(self, offline_on):
assert offline_config.is_offline_mode() is True
def test_offline_not_set_defaults_online(self, offline_off):
assert offline_config.is_offline_mode() is False
@pytest.mark.parametrize('val,expected', [
('true', True), ('TRUE', True), ('1', True), ('yes', True), ('YES', True),
('false', False), ('0', False), ('', False), ('random', False),
])
def test_value_parsing(self, monkeypatch, val, expected):
if val == '':
monkeypatch.delenv('OFFLINE_MODE', raising=False)
else:
monkeypatch.setenv('OFFLINE_MODE', val)
offline_config.reset_for_test()
assert offline_config.is_offline_mode() is expected
offline_config.reset_for_test()
def test_cache_persists(self, monkeypatch):
"""首次读取后缓存,中途改环境变量不生效"""
monkeypatch.setenv('OFFLINE_MODE', 'true')
offline_config.reset_for_test()
assert offline_config.is_offline_mode() is True
# 中途改环境变量,缓存不变
monkeypatch.setenv('OFFLINE_MODE', 'false')
assert offline_config.is_offline_mode() is True
offline_config.reset_for_test()
# ============================================================
# T2:build_offline_response
# ============================================================
class TestBuildOfflineResponse:
def test_empty_cases(self):
from services.ai_service import build_offline_response
result = build_offline_response([])
assert result['offline'] is True
assert '未匹配到相关案例' in result['response']
assert '离线模式' in result['response']
def test_with_cases_raw_format(self):
"""原始格式:search engine 返回的结构(含 record)"""
from services.ai_service import build_offline_response
cases = [
{
'rank': 1, 'score': 0.92,
'record': {
'project': '厦门银行',
'title': '门口屏 MQTT 绑定失败',
'phenomenon': '绑定时报 MQTT 连接错误',
'solution': '升级门口屏到 5.0+ 版本',
'file': 'record_001.md',
},
},
{
'rank': 2, 'score': 0.75,
'record': {
'project': '展厅',
'title': '门口屏不显示会议信息',
'phenomenon': '会议开始后不显示主题',
'solution': '检查 EMQX 容器状态',
'file': 'record_002.md',
},
},
]
result = build_offline_response(cases)
assert result['offline'] is True
text = result['response']
assert '厦门银行' in text
assert '门口屏 MQTT 绑定失败' in text
assert '92%' in text # 相似度格式化
assert '升级门口屏到 5.0+ 版本' in text # solution
assert 'record_001.md' in text # 参考文件
assert '共匹配 2 个' in text
def test_with_cases_formatted_format(self):
"""已格式化格式:前端 analyze 入参(无 solution,直接字段)"""
from services.ai_service import build_offline_response
cases = [
{
'rank': 1, 'score': 0.80,
'project': '展厅', 'title': '测试问题', 'phenomenon': '现象A', 'file': 'r.md',
},
]
result = build_offline_response(cases)
assert '展厅' in result['response']
assert '80%' in result['response']
# 无 solution 时不报错
def test_max_five_cases(self):
from services.ai_service import build_offline_response
cases = [{'rank': i, 'score': 0.5, 'record': {'title': f'c{i}'}} for i in range(10)]
result = build_offline_response(cases)
# 最多展示 5 个案例标题(### 案例N),但总数标注为 10
assert result['response'].count('### 案例') == 5
assert '共匹配 10 个' in result['response']
# ============================================================
# T3:路由离线分支
# ============================================================
class TestRoutesOffline:
def test_health_has_offline_field(self, client, offline_on):
resp = client.get('/api/health')
assert resp.status_code == 200
data = resp.get_json()
assert 'offline_mode' in data
assert data['offline_mode'] is True
def test_health_online_mode(self, client, offline_off):
resp = client.get('/api/health')
data = resp.get_json()
assert data['offline_mode'] is False
def test_troubleshoot_offline_skips_api(self, auth_client, offline_on, monkeypatch):
"""离线模式不调 Claude API(mock 抛异常验证不被调用)"""
import services.ai_service as ai_service
def _explode(*a, **kw):
raise AssertionError("离线模式不应调用 Claude API")
monkeypatch.setattr(ai_service, 'call_claude_api', _explode)
resp = auth_client.post('/api/troubleshoot', json={
'query': '门口屏MQTT绑定失败',
'project_name': '',
})
assert resp.status_code == 200
data = resp.get_json()
assert data['success'] is True
assert data.get('offline') is True
assert data['api_time'] == 0.0
assert len(data['matched_cases']) > 0
assert '离线模式' in data['response']
def test_troubleshoot_online_calls_api(self, auth_client, offline_off):
"""在线模式回归:调 AI(已被 conftest mock),返回正常分析"""
resp = auth_client.post('/api/troubleshoot', json={
'query': '门口屏MQTT绑定失败',
})
assert resp.status_code == 200
data = resp.get_json()
assert data['success'] is True
# 在线模式无 offline 字段(或为 False),有 AI 响应
assert data.get('offline') is not True
assert 'response' in data
def test_analyze_offline(self, auth_client, offline_on, monkeypatch):
import services.ai_service as ai_service
def _explode(*a, **kw):
raise AssertionError("离线模式不应调用 Claude API")
monkeypatch.setattr(ai_service, 'call_claude_api', _explode)
resp = auth_client.post('/api/analyze', json={
'query': '门口屏问题',
'matched_cases': [
{'rank': 1, 'score': 0.9, 'project': '厦门银行',
'title': 'MQTT失败', 'phenomenon': '绑定报错', 'file': 'r.md'},
],
})
assert resp.status_code == 200
data = resp.get_json()
assert data['success'] is True
assert data.get('offline') is True
assert '厦门银行' in data['response']
def test_analyze_stream_offline_sse(self, auth_client, offline_on, monkeypatch):
"""离线流式仍走 SSE,单帧推送,含 offline 标记"""
import services.ai_service as ai_service
def _explode(*a, **kw):
raise AssertionError("离线模式不应调用 Claude API")
monkeypatch.setattr(ai_service, 'call_claude_api_stream', _explode)
resp = auth_client.get('/api/analyze/stream?query=门口屏MQTT')
assert resp.status_code == 200
assert 'text/event-stream' in resp.content_type
body = resp.get_data(as_text=True)
# 至少有 start / matched_cases / chunk / done 四类事件
assert '"type": "start"' in body
assert '"type": "matched_cases"' in body
assert '"type": "chunk"' in body
assert '"type": "done"' in body
# 离线标记
assert '"offline": true' in body
# 不应出现 error
assert '"type": "error"' not in body
......@@ -16,11 +16,12 @@ from flask import Blueprint, request, jsonify, Response, session, render_templat
import container
from decorators import page_login_required
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, build_offline_response
from utils.audit import log_audit
from utils.response import error_response
from utils.error_codes import ErrorCodes
from utils.logger import get_logger
from utils.offline_config import is_offline_mode
logger = get_logger(__name__)
......@@ -76,6 +77,29 @@ def troubleshoot():
project_filter=project_name if project_name else None,
)
# ============================================================
# 【离线模式】跳过 Claude API,直接返回搜索结果(PRD §3.4)
# ============================================================
if is_offline_mode():
result = build_offline_response(matched_cases_data)
log_audit({
'timestamp': datetime.now().isoformat(),
'project': project_name,
'system_type': system_type,
'apk_product': apk_product,
'query': query,
'matched_count': len(matched_cases_data),
'api_time': 0.0,
'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
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases_data)
......@@ -162,6 +186,30 @@ def analyze():
return jsonify(error_response(ErrorCodes.INVALID_PARAM, '请输入问题描述')), 400
try:
# ============================================================
# 【离线模式】跳过 Claude API(PRD §3.4)
# ============================================================
if is_offline_mode():
# analyze 收到的 matched_cases 是前端传来的已格式化数据
# 直接用于离线响应(可能缺失 solution,但 title/phenomenon/score 等齐全)
result = build_offline_response(matched_cases)
log_audit({
'timestamp': datetime.now().isoformat(),
'project': project_name,
'system_type': system_type,
'apk_product': apk_product,
'query': query,
'matched_count': len(matched_cases),
'api_time': 0.0,
'mode': 'offline_analyze',
})
return jsonify({
'success': True,
'response': result['response'],
'offline': True,
'api_time': 0.0,
})
# 1. 构建 Prompt(使用前端传来的匹配案例数据)
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases)
......@@ -227,6 +275,8 @@ def analyze_stream():
yield f'data: {json.dumps({"type": "error", "message": "请输入问题描述"}, ensure_ascii=False)}\n\n'
return Response(error_gen(), mimetype='text/event-stream')
# 离线模式:跳过缓存,直接走离线分支(<1s 无需缓存)
if not is_offline_mode():
# 检查缓存
cache = container.get_cache_manager()
cached_result = cache.get(project_name, system_type, apk_product, query)
......@@ -256,7 +306,7 @@ def analyze_stream():
try:
# 1. 发送开始标记
yield f'data: {json.dumps({"type": "start", "message": "正在分析..."}, ensure_ascii=False)}\n\n'
yield f'data: {json.dumps({"type": "start", "message": "正在分析...", "offline": is_offline_mode()}, ensure_ascii=False)}\n\n'
# 2. 搜索匹配案例
engine = container.get_search_engine()
......@@ -264,7 +314,28 @@ def analyze_stream():
matched_cases = format_matched_cases(matched_cases_data)
# 3. 发送匹配案例
yield f'data: {json.dumps({"type": "matched_cases", "cases": matched_cases}, ensure_ascii=False)}\n\n'
yield f'data: {json.dumps({"type": "matched_cases", "cases": matched_cases, "offline": is_offline_mode()}, ensure_ascii=False)}\n\n'
# ============================================================
# 【离线模式】单帧推送搜索结果后结束(PRD §3.4)
# ============================================================
if is_offline_mode():
result = build_offline_response(matched_cases_data)
# 单帧推送完整响应文本
yield f'data: {json.dumps({"type": "chunk", "content": result["response"], "offline": True}, ensure_ascii=False)}\n\n'
api_time = time.time() - start_time
yield f'data: {json.dumps({"type": "done", "api_time": round(api_time, 2), "offline": True}, ensure_ascii=False)}\n\n'
log_audit({
'timestamp': datetime.now().isoformat(),
'project': project_name,
'system_type': system_type,
'apk_product': apk_product,
'query': query,
'matched_count': len(matched_cases),
'api_time': round(api_time, 2),
'mode': 'offline_stream',
})
return
# 4. 构建 Prompt
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases_data)
......@@ -339,6 +410,7 @@ def health_check():
'status': 'ok',
'timestamp': datetime.now().isoformat(),
'version': '1.3.0',
'offline_mode': is_offline_mode(),
'knowledge_base': {
'total_records': len(engine.records),
'last_update': engine.index.get('last_update', 'unknown'),
......
......@@ -353,6 +353,74 @@ def call_claude_api_stream(prompt, model=None):
yield chunk
# ============================================================
# 离线模式响应构建(PRD §3.1)
# ============================================================
def build_offline_response(matched_cases):
"""构建离线模式响应文本(跳过 Claude API,直接格式化匹配结果)
接受两种输入格式:
1. 原始格式:engine.search() 返回的列表,每项含 record dict(有 solution)
2. 已格式化:前端 /api/analyze 传回的列表,每项含直接字段(无 solution)
Args:
matched_cases: 匹配结果列表,每项至少含 score/title/phenomenon
Returns:
dict: {"response": str(Markdown 文本), "offline": True}
"""
if not matched_cases:
return {
"response": (
"## 📋 离线排查参考(基于知识库匹配)\n\n"
"> 💡 当前为离线模式,结果基于历史知识库匹配,无 AI 分析。\n\n"
"未匹配到相关案例。"
),
"offline": True,
}
# 提取案例字段(兼容两种输入格式)
def _get(case, key):
"""从原始格式或格式化格式提取字段"""
if 'record' in case and isinstance(case['record'], dict):
# 原始格式:case.record.key
return case['record'].get(key, '') or case.get(key, '')
# 已格式化格式:case.key
return case.get(key, '')
parts = [
"## 📋 离线排查参考(基于知识库匹配)\n",
"> 💡 当前为离线模式,结果基于历史知识库匹配,无 AI 分析。\n",
]
for i, c in enumerate(matched_cases[:5], 1):
score = c.get('score', 0)
title = _get(c, 'title') or '未记录'
project = _get(c, 'project') or '未记录'
phenomenon = _get(c, 'phenomenon') or title
solution = _get(c, 'solution') or ''
filename = _get(c, 'file') or _get(c, 'filename') or '未记录'
parts.extend([
f"---\n",
f"### 案例{i}(相似度:{score:.0%})\n",
f"- **项目**:{project}\n",
f"- **标题**:{title}\n",
f"- **现象**:{phenomenon}\n",
])
if solution:
parts.append(f"- **解决方案**:{solution}\n")
parts.append(f"- **参考文件**:{filename}\n")
parts.append(f"\n> 共匹配 {len(matched_cases)} 个相关案例。")
return {
"response": "".join(parts),
"offline": True,
}
def split_mock_response(text, chunk_size=20):
"""将模拟响应分割成小块,模拟流式返回"""
for i in range(0, len(text), chunk_size):
......
# -*- coding: utf-8 -*-
"""
offline_config.py — 离线模式开关读取(问题排查助手专用)
从环境变量 OFFLINE_MODE 读取离线模式开关,启动时读取一次。
仅此模块直接读环境变量,其他文件统一调用 is_offline_mode()。
设计原则:
- 进程级缓存:启动时读取一次,后续不改(热更新本期不做,PRD §3.2)
- 隔离边界:仅归问题排查助手使用,不污染 container.py 或其他模块状态
- 测试可重置:提供 reset_for_test() 供 pytest 刷新缓存
"""
import os
from utils.logger import get_logger
logger = get_logger(__name__)
_CACHE = None # None = 未初始化,True/False = 已缓存
def is_offline_mode():
"""返回是否离线模式
从环境变量 OFFLINE_MODE 读取:
true / 1 / yes(不区分大小写) → True(离线)
False / 未设置 / 其他值 → False(在线,默认)
首次调用后结果缓存到进程退出,重启或测试调 reset_for_test() 才刷新。
"""
global _CACHE
if _CACHE is None:
raw = os.environ.get('OFFLINE_MODE', '').strip().lower()
_CACHE = raw in ('true', '1', 'yes')
logger.info("离线模式: %s (OFFLINE_MODE=%r)", _CACHE, raw or '<未设置>')
return _CACHE
def reset_for_test():
"""测试用:重置缓存,下次调 is_offline_mode() 重新读环境变量"""
global _CACHE
_CACHE = None
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论