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

refactor(p1-3): server.py 拆分为 routes/services/utils 三层架构

- server.py 从 1443 行精简到 146 行,改为 create_app() 应用工厂模式
- 新增 container.py 依赖容器:集中单例与 config,解决跨模块全局副作用
  (rebuild_search_index 的单例重置改用 container.reset_search_engine)
- 新增 utils/paths.py:路径常量集中(SCRIPT_DIR/PROJECT_ROOT/RECORDS_DIR 等)
- 新增 utils/audit.py:log_audit 迁入
- 新增 utils/record_utils.py:5 个入库纯函数迁入
- 新增 services/ai_service.py:build_prompt/call_claude_api 等迁入
- 新增 services/record_service.py:rebuild_search_index 迁入
- 新增 routes/ 5 个 Blueprint(auth/troubleshoot/cache/export/submit)
- decorators.py:url_for('login') 适配为 url_for('auth.login')
- API 路径与响应格式严格不变(15 端点验证通过)
- 94 单元测试回归全绿
- 回写 P1 需求/计划文档:P1-3 验收勾选、执行记录与问题记录补全
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 15192abf
...@@ -314,14 +314,15 @@ if __name__ == '__main__': ...@@ -314,14 +314,15 @@ if __name__ == '__main__':
| 2.3 | 编写 safety_filter 测试 | 0.3天 | ✅ 完成 | 2.1 | | 2.3 | 编写 safety_filter 测试 | 0.3天 | ✅ 完成 | 2.1 |
| 2.4 | 编写 cache_manager 测试 | 0.2天 | ✅ 完成 | 2.1 | | 2.4 | 编写 cache_manager 测试 | 0.2天 | ✅ 完成 | 2.1 |
| 2.5 | 验证覆盖率 > 80% | 0.1天 | ✅ 完成 | 2.2-2.4 | | 2.5 | 验证覆盖率 > 80% | 0.1天 | ✅ 完成 | 2.2-2.4 |
| 3 | P1-3:架构分层重构 | 2天 | 待开始 | 1,2 | | 3 | P1-3:架构分层重构 | 2天 | ✅ 完成 | 1,2 |
| 3.1 | 创建目录结构 | 0.1天 | 待开始 | - | | 3.1 | 创建目录结构 | 0.1天 | ✅ 完成 | - |
| 3.2 | 提取工具函数到 utils/ | 0.2天 | 待开始 | 3.1 | | 3.2 | 抽取 utils/paths + utils/audit | 0.2天 | ✅ 完成 | 3.1 |
| 3.3 | 提取数据层到 repositories/ | 0.3天 | 待开始 | 3.2 | | 3.3 | 新建 container 依赖容器 | 0.3天 | ✅ 完成 | 3.2 |
| 3.4 | 提取业务层到 services/ | 0.5天 | 待开始 | 3.3 | | 3.4 | 抽取 record_utils + record_service | 0.3天 | ✅ 完成 | 3.3 |
| 3.5 | 提取路由层到 routes/ | 0.5天 | 待开始 | 3.4 | | 3.5 | 抽取 ai_service | 0.4天 | ✅ 完成 | 3.4 |
| 3.6 | 精简 server.py | 0.2天 | 待开始 | 3.5 | | 3.6 | 抽取 routes Blueprint | 0.4天 | ✅ 完成 | 3.5 |
| 3.7 | 功能验证 | 0.2天 | 待开始 | 3.6 | | 3.7 | 精简 server.py 为 app 工厂 | 0.2天 | ✅ 完成 | 3.6 |
| 3.8 | 功能验证 | 0.1天 | ✅ 完成 | 3.7 |
--- ---
...@@ -360,6 +361,13 @@ if __name__ == '__main__': ...@@ -360,6 +361,13 @@ if __name__ == '__main__':
| 2026-07-13 | P1-2.3 编写 safety_filter 测试 | Claude | 完成 | 41 个用例:check_dangerous/is_allowed_operation/check_relevance/extract_query_keywords/filter_output/filter_sensitive_info/filter_output_with_sensitive | | 2026-07-13 | P1-2.3 编写 safety_filter 测试 | Claude | 完成 | 41 个用例:check_dangerous/is_allowed_operation/check_relevance/extract_query_keywords/filter_output/filter_sensitive_info/filter_output_with_sensitive |
| 2026-07-13 | P1-2.4 编写 cache_manager 测试 | Claude | 完成 | 19 个用例:_get_cache_key/get/set/clear_expired/clear_all/get_stats/check_and_clean_if_needed,含损坏文件/不可序列化/超限清理边界 | | 2026-07-13 | P1-2.4 编写 cache_manager 测试 | Claude | 完成 | 19 个用例:_get_cache_key/get/set/clear_expired/clear_all/get_stats/check_and_clean_if_needed,含损坏文件/不可序列化/超限清理边界 |
| 2026-07-13 | P1-2.5 覆盖率验证 | Claude | 完成 | safety_filter 99% / cache_manager 87% / search_engine 85%,三模块均 > 80% | | 2026-07-13 | P1-2.5 覆盖率验证 | Claude | 完成 | safety_filter 99% / cache_manager 87% / search_engine 85%,三模块均 > 80% |
| 2026-07-13 | P1-3.2 抽 utils/paths.py + utils/audit.py | Claude | 完成 | 路径常量集中(SCRIPT_DIR/PROJECT_ROOT/RECORDS_DIR/CONFIG_FILE/AUDIT_LOG_FILE),log_audit 迁入 utils,零行为变化 |
| 2026-07-13 | P1-3.3 新建 container 依赖容器 | Claude | 完成 | 集中单例与 config:get_search_engine/reset_search_engine/get_cache_manager/get_config/get_safety_filter/get_skill_md,解决跨模块全局副作用 |
| 2026-07-13 | P1-3.4 抽 record_utils + record_service | Claude | 完成 | 5 个纯函数迁 utils/record_utils.py;rebuild_search_index 迁 services/record_service.py,global search_engine 改 container.reset_search_engine() |
| 2026-07-13 | P1-3.5 抽 ai_service | Claude | 完成 | build_prompt/call_claude_api/call_claude_api_stream/generate_mock_response/split_mock_response + 2 常量迁 services/ai_service.py,config/logger/get_skill_md 从 container 注入 |
| 2026-07-13 | P1-3.6 抽 routes Blueprint | Claude | 完成 | 5 个 Blueprint(auth/troubleshoot/cache/export/submit),@app.route→@bp.route,decorators url_for('login')→url_for('auth.login') |
| 2026-07-13 | P1-3.7 精简 server.py 为 app 工厂 | Claude | 完成 | create_app() 工厂模式 + 模块级 app 向后兼容;debug 改 FLASK_DEBUG 环境变量;1443→146 行 |
| 2026-07-13 | P1-3.8 功能验证 | Claude | 完成 | 15 端点 url_for 全对、9 个 API test_client 端到端全过、94 测试回归全绿 |
### 5.1 验证结果 ### 5.1 验证结果
...@@ -376,6 +384,13 @@ if __name__ == '__main__': ...@@ -376,6 +384,13 @@ if __name__ == '__main__':
| 全量测试 | `pytest` 全绿 | ✅ 94 passed in 0.90s | | 全量测试 | `pytest` 全绿 | ✅ 94 passed in 0.90s |
| 核心覆盖率 | safety_filter/cache_manager/search_engine > 80% | ✅ 99% / 87% / 85% | | 核心覆盖率 | safety_filter/cache_manager/search_engine > 80% | ✅ 99% / 87% / 85% |
| 索引依赖隔离 | conftest 注入 deploy 副本,SearchEngine 可实例化 | ✅ 357 条记录加载、无 FileNotFoundError | | 索引依赖隔离 | conftest 注入 deploy 副本,SearchEngine 可实例化 | ✅ 357 条记录加载、无 FileNotFoundError |
| 三层架构 | routes/services/utils + container 分离 | ✅ 三层建立、无循环依赖 |
| server.py 精简 | 从 1443 行精简 | ✅ 146 行(精简 90%;未达 < 100 行目标,因保留模块文档与 import 可读性,见问题记录) |
| API 端点不变 | 15 个端点路径与重构前一致 | ✅ /login ///api/troubleshoot 等 15 路径 url_for 验证全过 |
| Blueprint 注册 | 5 个 Blueprint 注册 | ✅ auth/troubleshoot/cache/export/submit |
| app 工厂 | create_app() 可调用、server.app 向后兼容 | ✅ |
| 单例重置闭环 | rebuild_search_index 后单例刷新 | ✅ 新实例 id 不同、357 条重新加载 |
| 全 API 端到端 | test_client 跑 9 个核心 API | ✅ health/projects/categories/login/index/search/troubleshoot/cache(stream)全过 |
### 5.2 问题记录 ### 5.2 问题记录
...@@ -386,6 +401,10 @@ if __name__ == '__main__': ...@@ -386,6 +401,10 @@ if __name__ == '__main__':
| 2026-07-13 | 计划文档 §2.2.3 测试示例 `from safety_filter import SafetyFilter` 错误——该模块无此类,是顶层函数 | 修正示例为 `import safety_filter` + 顶层函数调用;同步修正断言(filter_output 危险行是替换为提示而非删除,断言查原命令不在 + removed_lines 非空) | 已解决 | | 2026-07-13 | 计划文档 §2.2.3 测试示例 `from safety_filter import SafetyFilter` 错误——该模块无此类,是顶层函数 | 修正示例为 `import safety_filter` + 顶层函数调用;同步修正断言(filter_output 危险行是替换为提示而非删除,断言查原命令不在 + removed_lines 非空) | 已解决 |
| 2026-07-13 | `SearchEngine()` 本地初始化抛 FileNotFoundError——SEARCH_INDEX_PATHS 三条路径在本地开发环境均不存在 | conftest.py autouse fixture 用 monkeypatch 把 SEARCH_INDEX_PATHS 指向仓库内 deploy/搜索索引.json 副本(357 条),不复制文件、不污染源码 | 已解决 | | 2026-07-13 | `SearchEngine()` 本地初始化抛 FileNotFoundError——SEARCH_INDEX_PATHS 三条路径在本地开发环境均不存在 | conftest.py autouse fixture 用 monkeypatch 把 SEARCH_INDEX_PATHS 指向仓库内 deploy/搜索索引.json 副本(357 条),不复制文件、不污染源码 | 已解决 |
| 2026-07-13 | `cosine_similarity(v, v)` 浮点精度返回 0.9999999999999998 而非 1.0 | 断言改用 `math.isclose(..., 1.0, rel_tol=1e-9)`,容忍浮点误差(属真实代码行为,非 bug) | 已解决 | | 2026-07-13 | `cosine_similarity(v, v)` 浮点精度返回 0.9999999999999998 而非 1.0 | 断言改用 `math.isclose(..., 1.0, rel_tol=1e-9)`,容忍浮点误差(属真实代码行为,非 bug) | 已解决 |
| 2026-07-13 | `rebuild_search_index` 原用 `global search_engine` 重置 server.py 模块级单例,迁出后该全局已不存在 | 新建 container.reset_search_engine() 重置容器内单例,record_service 调用之;验证:重置后 get_search_engine 返回新实例(id 不同)且重新加载 357 条 | 已解决 |
| 2026-07-13 | `page_login_required` 装饰器用 `url_for('login')`,Blueprint 拆分后端点名变 `auth.login` 会 BuildError | 改 `url_for('auth.login')`;验证:未登录访问 `/` 正确 302 重定向到 /login | 已解决 |
| 2026-07-13 | 计划文档 §2.3 验收要求 server.py < 100 行,实际精简到 146 行未达标 | 原因:保留模块文档字符串、import 段、create_app 工厂的注释以维持可读性。1443→146 已精简 90%,三层架构目标达成。强行删注释到 < 100 损害可维护性,故接受 146 行 | 已接受 |
| 2026-07-13 | 路径常量迁入 utils/paths.py 后,`__file__` 锚定从 web/ 变到 utils/,需修正偏移 | paths.py 用 `Path(__file__).resolve().parent.parent` 锚定 web 目录(parent=utils,parent.parent=web),与原 server.py 的 SCRIPT_DIR 完全一致 | 已解决 |
--- ---
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
| 创建日期 | 2026-07-12 | | 创建日期 | 2026-07-12 |
| 负责人 | 研发组 | | 负责人 | 研发组 |
| 优先级 | P1(高优先级) | | 优先级 | P1(高优先级) |
| 状态 | P1-1、P1-2 已完成 / P1-3 待实施 | | 状态 | P1-1/P1-2/P1-3 全部完成 |
--- ---
...@@ -161,10 +161,10 @@ skill/code/web/ ...@@ -161,10 +161,10 @@ skill/code/web/
#### 验收标准 #### 验收标准
- [ ] server.py 行数 < 100 行 - [~] server.py 行数 < 100 行(实际 146 行,从 1443 精简 90%;保留模块文档与 import 可读性故未强删到 < 100,详见计划文档问题记录)
- [ ] 路由、业务、数据三层分离 - [x] 路由、业务、数据三层分离(routes/services/utils + container)
- [ ] 所有 API 功能正常 - [x] 所有 API 功能正常(15 端点路径不变,9 个 API 端到端验证通过)
- [ ] 代码职责清晰,无循环依赖 - [x] 代码职责清晰,无循环依赖
--- ---
...@@ -232,6 +232,6 @@ skill/code/web/ ...@@ -232,6 +232,6 @@ skill/code/web/
### P1-3 验收 ### P1-3 验收
- [ ] 三层架构建立 - [x] 三层架构建立
- [ ] server.py < 100 行 - [~] server.py < 100 行(实际 146 行,精简 90%,保留可读性)
- [ ] API 功能正常 - [x] API 功能正常
{"timestamp": "2026-07-11T11:26:44.326533", "project": "厦门银行", "system_type": "标准版预定2.0", "apk_product": "门口屏5.0", "query": "门口屏绑定失败,提示 MQTT 连接错误", "matched_count": 5, "api_time": 0.0, "removed_lines": 1}
{"timestamp": "2026-07-11T13:24:53.004305", "project": "厦门银行", "system_type": "标准版预定2.0", "apk_product": "门口屏5.0", "query": "门口屏绑定失败,提示MQTT连接错误", "matched_count": 5, "api_time": 0.02, "removed_lines": 1}
{"timestamp": "2026-07-11T14:18:10.910545", "project": "测试项目", "system_type": "标准版预定2.0", "apk_product": "", "query": "测试入库功能验证-门口屏绑定失败", "matched_count": 0, "api_time": 0.01, "removed_lines": 1}
{"timestamp": "2026-07-11T14:21:49.913640", "action": "submit_record", "record_id": "RC-20260711-001", "project": "测试项目", "recorder": "测试用户"}
{"timestamp": "2026-07-11T14:31:46.254810", "project": "招商局", "system_type": "标准版预定2.0", "apk_product": "门口屏5.0", "query": "门口屏无法绑定会议室,提示接口请求超时", "matched_count": 5, "api_time": 0.01, "removed_lines": 1}
{"timestamp": "2026-07-11T14:33:47.134132", "action": "submit_record", "record_id": "RC-20260711-002", "project": "招商局", "recorder": "Claude"}
{"timestamp": "2026-07-11T17:56:22.255113", "project": "????", "system_type": "?????2.0", "apk_product": "???5.0", "query": "???????,??MQTT????", "matched_count": 1, "api_time": 1.42, "removed_lines": 1}
{"timestamp": "2026-07-12T11:46:43.239843", "project": "厦门银行", "system_type": "标准版预定2.0", "apk_product": "", "query": "门口屏MQTT连接失败", "matched_count": 5, "api_time": 1.7, "removed_lines": 1, "mode": "stream"}
{"timestamp": "2026-07-12T11:56:20.402854", "project": "厦门银行", "system_type": "标准版预定2.0", "apk_product": "", "query": "门口屏MQTT连接失败", "matched_count": 5, "api_time": 0.32, "removed_lines": 1, "mode": "stream"}
# -*- coding: utf-8 -*-
"""
container.py — 依赖容器
集中持有单例与配置,解决原 server.py 中模块级全局变量散布、跨模块单例重置
(rebuild_search_index 的 global search_engine)等副作用问题。
设计原则:
- 单向依赖:services / routes 单向 import container,container 不反向依赖它们
- 懒加载:所有单例首次访问才初始化
- 可重置:search_engine 提供 reset_search_engine(),供 record_service 重建索引后调用
从原 server.py 迁入(行为保持不变):load_config / config / get_search_engine
/ get_safety_filter / get_cache_manager / get_skill_md
"""
import os
import json
from pathlib import Path
from utils.paths import SCRIPT_DIR, CONFIG_FILE
from utils.logger import get_logger
logger = get_logger(__name__)
# ============================================================
# 配置
# ============================================================
def load_config():
"""加载配置文件"""
if CONFIG_FILE.exists():
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {
"claude_api_base": os.environ.get("CLAUDE_API_BASE", ""),
"claude_api_key": os.environ.get("CLAUDE_API_KEY", ""),
"claude_model": "claude-sonnet-4-6",
"system_types": [
{"value": "std20", "label": "标准版预定2.0"},
{"value": "ops", "label": "标准版运维集控系统"},
{"value": "new_unified", "label": "新统一平台"},
{"value": "unified", "label": "统一平台"},
],
}
_config = None
def get_config():
"""获取配置(懒加载单例)"""
global _config
if _config is None:
_config = load_config()
return _config
# ============================================================
# 单例(懒加载)
# ============================================================
_search_engine = None
def get_search_engine():
"""获取搜索引擎实例"""
global _search_engine
if _search_engine is None:
from search_engine import SearchEngine
_search_engine = SearchEngine()
return _search_engine
def reset_search_engine():
"""重置搜索引擎单例(供 record_service.rebuild_search_index 调用,强制下次重建索引)"""
global _search_engine
_search_engine = None
_safety_filter = None
def get_safety_filter():
"""获取安全过滤器模块"""
global _safety_filter
if _safety_filter is None:
import safety_filter as sf
_safety_filter = sf
return _safety_filter
_cache_manager = None
def get_cache_manager():
"""获取缓存管理器实例"""
global _cache_manager
if _cache_manager is None:
from cache_manager import CacheManager
cache_dir = SCRIPT_DIR / "cache"
_cache_manager = CacheManager(cache_dir, expire_hours=24)
return _cache_manager
# ============================================================
# SKILL.md(懒加载,带缓存标志)
# ============================================================
_SKILL_MD = None
_SKILL_MD_LOADED = False
def get_skill_md():
"""加载 Troubleshoot SKILL.md — 按优先级查找(部署环境 → 开发环境)"""
global _SKILL_MD, _SKILL_MD_LOADED
if _SKILL_MD_LOADED:
return _SKILL_MD
_SKILL_MD_LOADED = True
# 路径优先级(部署环境 → 开发环境 → 备用)
skill_paths = [
Path("/opt/troubleshoot/SKILL.md"), # 部署环境(优先)
SCRIPT_DIR.parent / "SKILL.md", # 开发环境:code/SKILL.md
SCRIPT_DIR.parent.parent / "SKILL.md", # 备用:skills/Troubleshoot/SKILL.md
]
for skill_file in skill_paths:
if skill_file.exists():
_SKILL_MD = skill_file.read_text(encoding='utf-8')
# 只保留排查套路部分(不包含 frontmatter)
if '---' in _SKILL_MD:
parts = _SKILL_MD.split('---', 2)
if len(parts) >= 3:
_SKILL_MD = parts[2].strip()
print(f"[SKILL.md] 已加载:{skill_file}")
return _SKILL_MD
_SKILL_MD = ""
print("[SKILL.md] 警告:未找到 SKILL.md 文件")
return _SKILL_MD
...@@ -56,6 +56,6 @@ def page_login_required(f): ...@@ -56,6 +56,6 @@ def page_login_required(f):
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user' not in session: if 'user' not in session:
return redirect(url_for('login')) return redirect(url_for('auth.login'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
\ No newline at end of file
# -*- coding: utf-8 -*-
"""routes 包 — 路由层(Blueprint)"""
# -*- coding: utf-8 -*-
"""
auth.py — 认证与页面路由
从原 server.py 迁入:login / logout / get_user_info / index。
@app.route → @bp.route。
"""
from datetime import datetime
from flask import Blueprint, request, jsonify, render_template, session, redirect
import container
from auth import user_manager
from decorators import login_required, page_login_required
from utils.audit import log_audit
from utils.logger import get_logger
logger = get_logger(__name__)
bp = Blueprint('auth', __name__)
@bp.route('/login', methods=['GET', 'POST'])
def login():
"""登录页面"""
if request.method == 'GET':
# 已登录则直接跳转主页
if 'user' in session:
return redirect('/')
return render_template('login.html')
# POST 请求:处理登录
data = request.get_json() or {}
username = data.get('username', '').strip()
password = data.get('password', '').strip()
remember = data.get('remember', False)
if not username or not password:
return jsonify({
'success': False,
'error': '用户名和密码不能为空'
}), 400
# 验证登录
user = user_manager.authenticate(username, password)
if user:
# 创建 Session
session['user'] = {
'id': user['id'],
'username': user['username'],
'role': user['role']
}
if remember:
session.permanent = True
# 记录审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'user': username,
'role': user['role'],
'action': 'login',
'ip': request.remote_addr,
'result': 'success'
})
return jsonify({
'success': True,
'user': session['user']
})
else:
# 记录失败日志
log_audit({
'timestamp': datetime.now().isoformat(),
'user': username,
'action': 'login',
'ip': request.remote_addr,
'result': 'failed'
})
return jsonify({
'success': False,
'error': '用户名或密码错误,或账号已被锁定'
}), 401
@bp.route('/logout', methods=['POST'])
def logout():
"""注销登录"""
user = session.get('user', {})
# 记录审计日志
if user:
log_audit({
'timestamp': datetime.now().isoformat(),
'user': user.get('username'),
'role': user.get('role'),
'action': 'logout',
'ip': request.remote_addr,
'result': 'success'
})
session.clear()
return jsonify({'success': True})
@bp.route('/api/user/info', methods=['GET'])
@login_required
def get_user_info():
"""获取当前用户信息"""
return jsonify({
'success': True,
'user': session.get('user')
})
@bp.route('/')
@page_login_required
def index():
"""首页"""
return render_template('index.html',
projects=container.get_search_engine().get_projects(),
system_types=container.get_config().get('system_types', []),
)
# -*- coding: utf-8 -*-
"""
cache.py — 缓存管理路由
从原 server.py 迁入:get_cache_stats / clear_cache(均需 admin_required)。
"""
from datetime import datetime
from flask import Blueprint, jsonify
import container
from decorators import admin_required
from utils.audit import log_audit
from utils.logger import get_logger
logger = get_logger(__name__)
bp = Blueprint('cache', __name__)
@bp.route('/api/cache/stats', methods=['GET'])
@admin_required
def get_cache_stats():
"""
获取缓存统计信息(仅管理员)。
返回:
- total_files: 缓存文件总数
- total_size_mb: 缓存总大小(MB)
- oldest: 最早缓存时间
- newest: 最新缓存时间
"""
try:
cache = container.get_cache_manager()
stats = cache.get_stats()
return jsonify({
'success': True,
'stats': stats,
})
except Exception as e:
logger.exception("获取缓存统计接口异常")
return jsonify({
'success': False,
'error': f'获取缓存统计失败:{str(e)}',
}), 500
@bp.route('/api/cache/clear', methods=['POST'])
@admin_required
def clear_cache():
"""
清空所有缓存(仅管理员)。
"""
try:
cache = container.get_cache_manager()
cleared_count = cache.clear_all()
# 审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'action': 'clear_cache',
'cleared_count': cleared_count,
})
return jsonify({
'success': True,
'message': f'已清除 {cleared_count} 个缓存文件',
'cleared_count': cleared_count,
})
except Exception as e:
logger.exception("清空缓存接口异常")
return jsonify({
'success': False,
'error': f'清空缓存失败:{str(e)}',
}), 500
# -*- coding: utf-8 -*-
"""
export.py — 报告导出路由
从原 server.py 迁入:export_report(Word 文档导出)。
"""
from datetime import datetime
from io import BytesIO
from flask import Blueprint, request, jsonify, send_file
from utils.audit import log_audit
from utils.logger import get_logger
logger = get_logger(__name__)
bp = Blueprint('export', __name__)
@bp.route('/api/export', methods=['POST'])
def export_report():
"""
导出排查报告为 Word 文档。
接收参数:
- project_name: 项目名称
- system_type: 系统类型
- apk_product: APK 产品
- query: 问题描述
- matched_cases: 匹配的历史案例
- response: AI 分析结果
返回:
Word 文档文件流
"""
try:
from docx import Document
from docx.shared import Pt, RGBColor
except ImportError:
return jsonify({
'success': False,
'error': '缺少 python-docx 库,请运行: pip install python-docx',
}), 500
data = request.get_json() or {}
# 收集参数
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
query = data.get('query', '').strip()
matched_cases = data.get('matched_cases', [])
response = data.get('response', '').strip()
# 生成 Word 文档
doc = Document()
# 标题
title = doc.add_heading('问题排查报告', 0)
title.alignment = 1 # 居中
# 导出时间
doc.add_paragraph(f'导出时间:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
doc.add_paragraph('')
# 基本信息
doc.add_heading('基本信息', level=1)
doc.add_paragraph(f'项目名称:{project_name}')
doc.add_paragraph(f'系统类型:{system_type}')
if apk_product:
doc.add_paragraph(f'APK 产品:{apk_product}')
doc.add_paragraph('')
# 问题描述
doc.add_heading('问题描述', level=1)
doc.add_paragraph(query)
doc.add_paragraph('')
# 匹配的历史案例
if matched_cases:
doc.add_heading('匹配的历史案例', level=1)
for c in matched_cases:
p = doc.add_paragraph()
rank = c.get('rank', '-')
project = c.get('project', '')
title_text = c.get('title', '')
score = c.get('score', 0)
p.add_run(f'{rank}. ').bold = True
p.add_run(f'{project} - {title_text}(相似度:{score:.0%})')
doc.add_paragraph('')
# AI 分析结果
doc.add_heading('AI 分析结果', level=1)
# 简单渲染 Markdown(粗体、标题、列表)
for line in response.split('\n'):
if line.startswith('### '):
doc.add_heading(line[4:], level=2)
elif line.startswith('## '):
doc.add_heading(line[3:], level=2)
elif line.startswith('- '):
doc.add_paragraph(line[2:], style='List Bullet')
elif line.strip():
doc.add_paragraph(line)
# 保存到内存流
buffer = BytesIO()
doc.save(buffer)
buffer.seek(0)
# 生成文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'排查报告_{project_name}_{timestamp}.docx'
# 审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'action': 'export_report',
'project': project_name,
'filename': filename,
})
# 返回文件流
return send_file(
buffer,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
)
# -*- coding: utf-8 -*-
"""
submit.py — 问题记录提交路由
从原 server.py 迁入:submit_record。
RECORDS_DIR/PROJECT_ROOT 从 utils.paths 取;record 工具从 utils.record_utils 取;
rebuild_search_index 从 services.record_service 取。
"""
from datetime import datetime
from flask import Blueprint, request, jsonify
from utils.paths import RECORDS_DIR, PROJECT_ROOT
from utils.record_utils import (
generate_record_id, build_md_filename, build_markdown_content,
)
from services.record_service import rebuild_search_index
from utils.audit import log_audit
from utils.logger import get_logger
logger = get_logger(__name__)
bp = Blueprint('submit', __name__)
@bp.route('/api/submit', methods=['POST'])
def submit_record():
"""提交问题记录到知识库"""
data = request.get_json() or {}
# 接收字段
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
phenomenon = data.get('phenomenon', '').strip()
troubleshoot_steps = data.get('troubleshoot_steps', '').strip()
root_cause = data.get('root_cause', '').strip()
solution = data.get('solution', '').strip()
recorder = data.get('recorder', '').strip()
# 必填校验
if not project_name:
return jsonify({'success': False, 'error': '项目名称不能为空'}), 400
if not phenomenon:
return jsonify({'success': False, 'error': '问题描述不能为空'}), 400
if not recorder:
return jsonify({'success': False, 'error': '记录人不能为空'}), 400
try:
# 1. 生成 Markdown 文件
record_id = generate_record_id()
md_content = build_markdown_content(
record_id=record_id,
project_name=project_name,
system_type=system_type,
apk_product=apk_product,
phenomenon=phenomenon,
troubleshoot_steps=troubleshoot_steps,
root_cause=root_cause,
solution=solution,
recorder=recorder,
)
md_filename = build_md_filename(record_id, project_name, phenomenon)
md_filepath = RECORDS_DIR / "项目" / md_filename
# 确保目录存在
md_filepath.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
md_filepath.write_text(md_content, encoding='utf-8')
# 2. 重建搜索索引
rebuild_search_index()
# 3. 审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'action': 'submit_record',
'record_id': record_id,
'project': project_name,
'recorder': recorder,
})
return jsonify({
'success': True,
'message': '问题记录已提交到知识库',
'record_id': record_id,
'file': str(md_filepath.relative_to(PROJECT_ROOT)),
})
except Exception as e:
logger.exception("问题入库接口异常")
return jsonify({
'success': False,
'error': f'提交失败:{str(e)}',
}), 500
# -*- coding: utf-8 -*-
"""
troubleshoot.py — 排查相关路由
从原 server.py 迁入:troubleshoot / search_cases / analyze / analyze_stream
/ format_matched_cases / health_check / get_projects / get_categories。
@app.route → @bp.route;全局对象改从 container/ai_service 取。
"""
import json
import time
from datetime import datetime
from flask import Blueprint, request, jsonify, Response
import container
from services.ai_service import build_prompt, call_claude_api, call_claude_api_stream
from utils.audit import log_audit
from utils.logger import get_logger
logger = get_logger(__name__)
bp = Blueprint('troubleshoot', __name__)
def format_matched_cases(matched_cases_data):
"""格式化匹配案例数据为标准返回格式"""
if not matched_cases_data:
return []
return [
{
'rank': c['rank'],
'score': c['score'],
'project': c.get('record', {}).get('project', ''),
'title': c.get('record', {}).get('title', ''),
'file': c.get('record', {}).get('file', ''),
'phenomenon': c.get('record', {}).get('phenomenon', ''),
}
for c in matched_cases_data
]
@bp.route('/api/troubleshoot', methods=['POST'])
def troubleshoot():
"""排查问题 API(原有完整流程,保持兼容)"""
data = request.get_json() or {}
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
query = data.get('query', '').strip()
if not query:
return jsonify({
'success': False,
'error': '请输入问题描述',
}), 400
try:
# 1. 搜索匹配案例
engine = container.get_search_engine()
matched_cases_data = engine.search(
query,
top_k=5,
project_filter=project_name if project_name else None,
)
# 2. 构建 Prompt
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases_data)
# 3. 调用 Claude API
start_time = time.time()
raw_response = call_claude_api(prompt)
api_time = time.time() - start_time
# 4. 安全过滤
sf = container.get_safety_filter()
filter_result = sf.filter_output(raw_response, query)
# 5. 审计日志
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': round(api_time, 2),
'removed_lines': len(filter_result.get('removed_lines', [])),
})
# 6. 返回结果
return jsonify({
'success': True,
'response': filter_result['filtered_text'],
'matched_cases': format_matched_cases(matched_cases_data),
'filter_warnings': filter_result.get('removed_lines', []),
'api_time': round(api_time, 2),
})
except Exception as e:
logger.exception("排查接口服务异常")
return jsonify({
'success': False,
'error': f'服务异常:{str(e)}',
}), 500
@bp.route('/api/search', methods=['POST'])
def search_cases():
"""渐进式排查 — 阶段一:仅搜索匹配案例(快速返回,<3s)"""
data = request.get_json() or {}
project_name = data.get('project_name', '').strip()
query = data.get('query', '').strip()
if not query:
return jsonify({
'success': False,
'error': '请输入问题描述',
}), 400
try:
start_time = time.time()
engine = container.get_search_engine()
matched_cases_data = engine.search(
query,
top_k=5,
project_filter=project_name if project_name else None,
)
search_time = time.time() - start_time
return jsonify({
'success': True,
'matched_cases': format_matched_cases(matched_cases_data),
'matched_count': len(matched_cases_data),
'search_time': round(search_time, 2),
})
except Exception as e:
logger.exception("搜索接口异常")
return jsonify({
'success': False,
'error': f'搜索异常:{str(e)}',
}), 500
@bp.route('/api/analyze', methods=['POST'])
def analyze():
"""渐进式排查 — 阶段二:AI 深度分析(慢速返回,20-30s)"""
data = request.get_json() or {}
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
query = data.get('query', '').strip()
matched_cases = data.get('matched_cases', [])
if not query:
return jsonify({
'success': False,
'error': '请输入问题描述',
}), 400
try:
# 1. 构建 Prompt(使用前端传来的匹配案例数据)
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases)
# 2. 调用 Claude API
start_time = time.time()
raw_response = call_claude_api(prompt)
api_time = time.time() - start_time
# 3. 安全过滤
sf = container.get_safety_filter()
filter_result = sf.filter_output(raw_response, query)
# 4. 审计日志
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),
'removed_lines': len(filter_result.get('removed_lines', [])),
})
return jsonify({
'success': True,
'response': filter_result['filtered_text'],
'filter_warnings': filter_result.get('removed_lines', []),
'api_time': round(api_time, 2),
})
except Exception as e:
logger.exception("分析接口异常")
return jsonify({
'success': False,
'error': f'分析异常:{str(e)}',
}), 500
@bp.route('/api/analyze/stream', methods=['GET'])
def analyze_stream():
"""
流式返回 AI 分析结果(SSE)。
参数通过 URL query string 传递:
- project_name: 项目名称
- system_type: 系统类型
- apk_product: APK 产品
- query: 问题描述
- model: 模型选择(可选)
返回格式(SSE):
data: {"type": "start", "message": "正在分析..."}
data: {"type": "matched_cases", "cases": [...]}
data: {"type": "chunk", "content": "..."}
data: {"type": "done", "api_time": 45.2}
"""
project_name = request.args.get('project_name', '').strip()
system_type = request.args.get('system_type', '').strip()
apk_product = request.args.get('apk_product', '').strip()
query = request.args.get('query', '').strip()
model = request.args.get('model', '') or container.get_config().get('default_model', 'glm-5.1')
if not query:
def error_gen():
yield f'data: {json.dumps({"type": "error", "message": "请输入问题描述"}, ensure_ascii=False)}\n\n'
return Response(error_gen(), mimetype='text/event-stream')
# 检查缓存
cache = container.get_cache_manager()
cached_result = cache.get(project_name, system_type, apk_product, query)
if cached_result:
# 返回缓存结果
def generate_cached():
yield f'data: {json.dumps({"type": "start", "message": "正在分析...", "cached": True}, ensure_ascii=False)}\n\n'
yield f'data: {json.dumps({"type": "matched_cases", "cases": cached_result.get("matched_cases", [])}, ensure_ascii=False)}\n\n'
# 流式推送缓存内容
cached_response = cached_result.get("response", "")
chunk_size = 30
for i in range(0, len(cached_response), chunk_size):
content_chunk = cached_response[i:i + chunk_size]
yield f'data: {json.dumps({"type": "chunk", "content": content_chunk}, ensure_ascii=False)}\n\n'
time.sleep(0.01)
yield f'data: {json.dumps({"type": "done", "api_time": 0.5, "cached": True}, ensure_ascii=False)}\n\n'
return Response(generate_cached(), mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
def generate():
"""生成器:流式返回内容"""
start_time = time.time()
try:
# 1. 发送开始标记
yield f'data: {json.dumps({"type": "start", "message": "正在分析..."}, ensure_ascii=False)}\n\n'
# 2. 搜索匹配案例
engine = container.get_search_engine()
matched_cases_data = engine.search(query, top_k=5, project_filter=project_name if project_name else None)
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'
# 4. 构建 Prompt
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases_data)
# 5. 收集完整响应(用于安全过滤)
full_response = ""
for chunk in call_claude_api_stream(prompt, model=model):
full_response += chunk
# 6. 安全过滤(包含敏感信息过滤)
sf = container.get_safety_filter()
filter_result = sf.filter_output_with_sensitive(full_response, query)
filtered_text = filter_result['filtered_text']
# 7. 存入缓存
cache.set(project_name, system_type, apk_product, query, filtered_text, matched_cases)
# 8. 流式推送过滤后的内容(分块发送,模拟打字效果)
chunk_size = 30 # 每次发送的字符数
for i in range(0, len(filtered_text), chunk_size):
content_chunk = filtered_text[i:i + chunk_size]
yield f'data: {json.dumps({"type": "chunk", "content": content_chunk}, ensure_ascii=False)}\n\n'
time.sleep(0.01) # 小延迟,模拟打字效果
# 9. 发送完成标记
api_time = time.time() - start_time
yield f'data: {json.dumps({"type": "done", "api_time": round(api_time, 2)}, ensure_ascii=False)}\n\n'
# 9. 审计日志
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),
'removed_lines': len(filter_result.get('removed_lines', [])),
'mode': 'stream',
})
except Exception as e:
logger.exception("流式分析接口异常")
yield f'data: {json.dumps({"type": "error", "message": f"分析异常:{str(e)}"}, ensure_ascii=False)}\n\n'
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no', # 禁用 Nginx 缓冲
'Connection': 'keep-alive',
}
)
@bp.route('/api/health', methods=['GET'])
def health_check():
"""
健康检查接口。
返回服务状态、版本信息、知识库统计等。
用于监控系统、负载均衡器健康检查。
"""
try:
engine = container.get_search_engine()
cache = container.get_cache_manager()
cache_stats = cache.get_stats()
return jsonify({
'status': 'ok',
'timestamp': datetime.now().isoformat(),
'version': '1.2.0',
'knowledge_base': {
'total_records': len(engine.records),
'last_update': engine.index.get('last_update', 'unknown'),
},
'cache': {
'total_files': cache_stats.get('total_files', 0),
'total_size_mb': cache_stats.get('total_size_mb', 0),
},
'components': {
'search_engine': 'ok',
'safety_filter': 'ok',
'cache_manager': 'ok',
}
})
except Exception as e:
logger.exception("健康检查接口异常")
return jsonify({
'status': 'error',
'timestamp': datetime.now().isoformat(),
'error': str(e),
}), 500
@bp.route('/api/projects', methods=['GET'])
def get_projects():
"""获取项目列表(用于下拉建议)"""
engine = container.get_search_engine()
return jsonify({
'success': True,
'projects': engine.get_projects(),
})
@bp.route('/api/categories', methods=['GET'])
def get_categories():
"""获取分类列表"""
engine = container.get_search_engine()
return jsonify({
'success': True,
'categories': engine.get_categories(),
})
...@@ -46,34 +46,10 @@ logger = get_logger(__name__) ...@@ -46,34 +46,10 @@ logger = get_logger(__name__)
# 配置 # 配置
# ============================================================ # ============================================================
SCRIPT_DIR = Path(__file__).resolve().parent # .../web # 路径常量集中管理(utils/paths.py)—— P1-3 步骤1 抽取
DATA_DIR = SCRIPT_DIR.parent # 部署时即 /opt/troubleshoot(web 上级目录) from utils.paths import (
SCRIPT_DIR, DATA_DIR, PROJECT_ROOT, RECORDS_DIR, CONFIG_FILE, AUDIT_LOG_FILE,
# 项目根目录(优先使用环境变量,否则自动检测) )
PROJECT_ROOT = os.environ.get('TROUBLESHOOT_ROOT')
if PROJECT_ROOT:
PROJECT_ROOT = Path(PROJECT_ROOT)
elif (DATA_DIR / "Docs" / "PRD" / "问题知识库").exists():
# 开发环境:项目仓库中
PROJECT_ROOT = DATA_DIR # code/ 的上级即项目根
# 但 DATA_DIR 指向 code/,需要再往上找。
# 实际项目根:从 code/web/ 往上 4 级 → 仓库根
for _ in range(4):
if (PROJECT_ROOT / "CLAUDE.md").exists():
break
PROJECT_ROOT = PROJECT_ROOT.parent
else:
# 部署环境:PROJECT_ROOT 即 DATA_DIR(/opt/troubleshoot)
PROJECT_ROOT = DATA_DIR
# 问题记录输出目录
RECORDS_DIR = PROJECT_ROOT / "Docs" / "PRD" / "问题知识库" / "问题记录"
if not RECORDS_DIR.exists():
# 部署环境降级:使用 DATA_DIR 下的 问题记录/
RECORDS_DIR = DATA_DIR / "问题记录"
# 系统 API 配置(从环境变量或配置文件读取)
CONFIG_FILE = SCRIPT_DIR / "config.json"
# ============================================================ # ============================================================
# 初始化 # 初始化
...@@ -83,1344 +59,68 @@ CONFIG_FILE = SCRIPT_DIR / "config.json" ...@@ -83,1344 +59,68 @@ CONFIG_FILE = SCRIPT_DIR / "config.json"
if hasattr(sys.stdout, 'reconfigure'): if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8') sys.stdout.reconfigure(encoding='utf-8')
app = Flask(
__name__,
template_folder=str(SCRIPT_DIR / "templates"),
static_folder=str(SCRIPT_DIR / "static"),
)
# ============================================================
# Session 配置
# ============================================================
app.secret_key = os.environ.get('SECRET_KEY')
if not app.secret_key:
import secrets
app.secret_key = secrets.token_hex(32)
print("[警告] SECRET_KEY 环境变量未设置,已生成随机密钥")
print("[提示] 生产环境请设置 SECRET_KEY 环境变量")
app.config['SESSION_TYPE'] = 'filesystem'
app.config['PERMANENT_SESSION_LIFETIME'] = 86400 # 24小时
# 加载配置
def load_config():
"""加载配置文件"""
if CONFIG_FILE.exists():
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {
"claude_api_base": os.environ.get("CLAUDE_API_BASE", ""),
"claude_api_key": os.environ.get("CLAUDE_API_KEY", ""),
"claude_model": "claude-sonnet-4-6",
"system_types": [
{"value": "std20", "label": "标准版预定2.0"},
{"value": "ops", "label": "标准版运维集控系统"},
{"value": "new_unified", "label": "新统一平台"},
{"value": "unified", "label": "统一平台"},
],
}
config = load_config()
# 延迟导入搜索引擎和过滤器
search_engine = None
safety_filter = None
cache_manager = None
def get_search_engine():
"""获取搜索引擎实例"""
global search_engine
if search_engine is None:
from search_engine import SearchEngine
search_engine = SearchEngine()
return search_engine
def get_safety_filter():
"""获取安全过滤器模块"""
global safety_filter
if safety_filter is None:
import safety_filter as sf
safety_filter = sf
return safety_filter
def get_cache_manager():
"""获取缓存管理器实例"""
global cache_manager
if cache_manager is None:
from cache_manager import CacheManager
cache_dir = SCRIPT_DIR / "cache"
cache_manager = CacheManager(cache_dir, expire_hours=24)
return cache_manager
# 延迟加载 SKILL.md
SKILL_MD = None
SKILL_MD_LOADED = False
def get_skill_md():
"""加载 Troubleshoot SKILL.md — 按优先级查找(部署环境 → 开发环境)"""
global SKILL_MD, SKILL_MD_LOADED
if SKILL_MD_LOADED:
return SKILL_MD
SKILL_MD_LOADED = True
# 路径优先级(部署环境 → 开发环境 → 备用)
skill_paths = [
Path("/opt/troubleshoot/SKILL.md"), # 部署环境(优先)
SCRIPT_DIR.parent / "SKILL.md", # 开发环境:code/SKILL.md
SCRIPT_DIR.parent.parent / "SKILL.md", # 备用:skills/Troubleshoot/SKILL.md
]
for skill_file in skill_paths:
if skill_file.exists():
SKILL_MD = skill_file.read_text(encoding='utf-8')
# 只保留排查套路部分(不包含 frontmatter)
if '---' in SKILL_MD:
parts = SKILL_MD.split('---', 2)
if len(parts) >= 3:
SKILL_MD = parts[2].strip()
print(f"[SKILL.md] 已加载:{skill_file}")
return SKILL_MD
SKILL_MD = ""
print("[SKILL.md] 警告:未找到 SKILL.md 文件")
return SKILL_MD
# ============================================================
# 排查域映射表(P1 Prompt 增强优化)
# ============================================================
# 系统类型 → 重点排查域映射
SYSTEM_TYPE_DOMAINS = {
"标准版预定2.0": "EMQX、Nacos、门口屏、无纸化、数据库、Redis",
"标准版运维集控系统": "协议透传、中间件、设备管理、MQTT",
"新统一平台": "统一门户、SMC、腾讯会议、单点登录",
"统一平台": "用户中心、权限管理、数据同步",
}
# APK 产品 → 特性关注映射
APK_PRODUCT_HINTS = {
"门口屏5.0": "设备主动上报心跳,内置默认 MQTT 凭据,只同步参会人人脸",
"门口屏4.0": "Web 端主动刷新,并发量过大时可能断连,建议升级到 5.0",
"无纸化": "座位编排需与会议室绑定一致,平板缓存需定期清理",
"桌牌": "绑定授权码后才能使用,检查 JWT 校验是否正常",
}
# ============================================================
# Prompt 构建
# ============================================================
def build_prompt(query, project_name, system_type, apk_product, matched_cases):
"""构建发送给 Claude 的完整 Prompt"""
# 基础系统提示
system_prompt = """你是一个自动化测试团队的问题排查专家助手。你面对的是现场实施同事,他们可能不熟悉 Linux 命令和系统架构。
## 核心规则(必须遵守)
1. **只读操作原则**:给出的排查步骤必须是只读操作(查看状态、查看日志、探测连通性、查看配置)
2. **安全原则**:绝对不允许给出修改、删除、重启、停止、写入等操作
3. **相关性原则**:每步操作必须直接关联用户描述的问题,不允许泛化
4. **排序原则**:步骤按最可能→最不可能排序
5. **概率原则**:给出各可能根因的概率估计
6. **语言原则**:用简单易懂的语言,说明每步"查什么"、"查到什么说明是什么问题"
7. **危险操作原则**:如果需要执行危险操作来修复,用"请联系研发组执行:[具体操作]"代替
## 禁止的命令(违反则整条回复作废)
- 删除类:rm、docker rm、DROP、DELETE、TRUNCATE
- 重启类:reboot、systemctl restart、docker restart、shutdown
- 停止类:systemctl stop、docker stop、kill、pkill
- 修改类:chmod 777、chown、写入配置文件、iptables 修改
- 清空类:FLUSHALL、FLUSHDB
## 允许的命令(可放心输出)
- 查看状态:docker ps、systemctl status、ss -tln
- 查看日志:docker logs、tail、journalctl
- 连通性测试:nc -z、curl、ping
- 系统信息:df -h、free -h、date、ps aux
- 配置查看:cat、grep、ls
"""
# 构建用户查询
user_query_parts = []
if project_name:
user_query_parts.append(f"项目名称:{project_name}")
if system_type:
user_query_parts.append(f"系统类型:{system_type}")
# 系统类型排查域提示
domain_hint = SYSTEM_TYPE_DOMAINS.get(system_type, "")
if domain_hint:
user_query_parts.append(f"重点排查域:{domain_hint}")
if apk_product:
user_query_parts.append(f"APK产品:{apk_product}")
# APK 产品特性提示
apk_hint = APK_PRODUCT_HINTS.get(apk_product, "")
if apk_hint:
user_query_parts.append(f"{apk_product} 特性:{apk_hint}")
user_query_parts.append(f"问题描述:{query}")
user_query = "\n".join(user_query_parts)
# 构建匹配案例
cases_text = ""
if matched_cases:
cases_text = "## 历史相似案例\n\n"
for c in matched_cases[:5]:
rec = c.get('record', {})
cases_text += f"### 案例 {c['rank']}(相似度:{c['score']:.0%})\n"
if rec.get('project'):
cases_text += f"项目:{rec['project']}\n"
cases_text += f"现象:{rec.get('phenomenon', rec.get('title', '未知'))}\n\n"
cases_text += "---\n\n"
# 排查套路参考(截取前 5000 字符,避免过长)
skill_text = get_skill_md()
if len(skill_text) > 5000:
skill_text = skill_text[:5000] + "\n\n... (完整排查套路见 SKILL.md)"
# 组装完整 Prompt
full_prompt = f"""{system_prompt}
---
{cases_text}
## 排查套路参考
{skill_text}
---
## 用户查询
{user_query}
---
请根据以上信息,生成排查步骤和根因分析。输出格式:
### 📋 匹配到的历史案例
(简要列出匹配的案例,如:厦门银行-门口屏MQTT绑定失败)
### 🔬 排查步骤(只读操作)
Step 1: ...
Step 2: ...
...
### 🎯 最可能的根因(按概率排序)
- XX%:根因描述
- XX%:根因描述
...
### ⚠️ 注意事项
(提醒现场同事注意的事项)
"""
return full_prompt
# ============================================================
# Claude API 调用
# ============================================================
def call_claude_api(prompt):
"""调用 Claude API — 支持 HTTP API 或 CLI 两种方式"""
import requests
# 从配置获取 API 信息
api_base = config.get('claude_api_base', '')
api_key = config.get('claude_api_key', '')
model = config.get('claude_model', 'glm-5.1')
# 方式1: HTTP API(优先)
if api_base and api_key:
try:
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
data = {
'model': model,
'messages': [{'role': 'user', 'content': prompt}],
'max_tokens': 4096,
}
resp = requests.post(
f'{api_base}/v1/chat/completions',
headers=headers,
json=data,
timeout=60,
)
if resp.status_code == 200:
result = resp.json()
return result['choices'][0]['message']['content']
else:
logger.error(f"API 错误: {resp.status_code} - {resp.text[:200]}")
except requests.exceptions.RequestException as e:
logger.error(f"HTTP API 调用失败(网络): {e}")
except (KeyError, ValueError) as e:
logger.error(f"HTTP API 响应解析失败: {e}")
except Exception as e:
logger.exception(f"HTTP API 调用未知异常: {e}")
# 方式2: CLI(降级)
import subprocess
import tempfile
try:
with tempfile.NamedTemporaryFile(
mode='w', suffix='.txt', delete=False, encoding='utf-8'
) as f:
f.write(prompt)
temp_path = f.name
result = subprocess.run( def create_app():
['claude', '--print', '-p', f'@{temp_path}'], """Flask 应用工厂 — 创建并配置 app,注册所有 Blueprint。
capture_output=True,
text=True,
timeout=120,
encoding='utf-8',
)
import os as _os 模块级 `app = create_app()` 保持 `server.app` 引用向后兼容;
_os.unlink(temp_path) 工厂模式便于未来测试 fixture 与多实例部署。
if result.returncode == 0:
return result.stdout.strip()
else:
logger.error(f"Claude CLI 错误:{result.stderr}")
return generate_mock_response(prompt)
except FileNotFoundError:
logger.warning("未找到 claude CLI,降级为模拟响应")
return generate_mock_response(prompt)
except subprocess.TimeoutExpired as e:
logger.error(f"Claude CLI 调用超时: {e}")
return generate_mock_response(prompt)
except Exception as e:
logger.exception(f"Claude 调用失败: {e}")
return generate_mock_response(prompt)
def generate_mock_response(prompt):
"""生成模拟响应(用于开发测试)"""
return """### 📋 匹配到的历史案例
1. 厦门银行-门口屏MQTT绑定失败(相似度:92%
2. 展厅-门口屏不显示会议信息(相似度:75%
### 🔬 排查步骤(只读操作)
Step 1: 检查门口屏版本
命令:在门口屏设备上查看版本号
说明:确认是否为最新版本(5.0+),旧版本可能缺少默认 MQTT 凭据
Step 2: 查看设备日志
命令:查看门口屏日志文件
说明:查找 MQTT 连接相关的错误信息
Step 3: 检查 EMQX 容器状态
命令:docker ps -a | grep uemqx
说明:确认 EMQX 容器是否正常运行
Step 4: 检查 MQTT 端口连通性
命令:nc -z <服务器IP> 1883
说明:测试 MQTT 端口是否可达
### 🎯 最可能的根因(按概率排序)
- 70%:旧版门口屏没有默认 MQTT 账号密码
- 20%:EMQX 容器未启动或异常
- 10%:授权码变化导致无效 token
### ⚠️ 注意事项
- 排查时只读操作,不要随意重启服务
- 更新门口屏包后务必重启设备 APP
- 如需执行 docker restart 等危险操作,请联系研发组
"""
def call_claude_api_stream(prompt, model=None):
"""
流式调用 Claude API。
参数:
prompt: 提示文本
model: 模型 ID(可选,默认使用配置中的模型)
返回:生成器,每次 yield 一个文本片段
""" """
import requests app = Flask(
__name__,
api_base = config.get('claude_api_base', '') template_folder=str(SCRIPT_DIR / "templates"),
api_key = config.get('claude_api_key', '') static_folder=str(SCRIPT_DIR / "static"),
if model is None:
model = config.get('default_model', 'glm-5.1')
# 方式1: HTTP API 流式(优先)
if api_base and api_key:
try:
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
data = {
'model': model,
'messages': [{'role': 'user', 'content': prompt}],
'max_tokens': 4096,
'stream': True, # 启用流式返回
}
resp = requests.post(
f'{api_base}/v1/chat/completions',
headers=headers,
json=data,
stream=True, # requests 流式响应
timeout=120,
)
if resp.status_code == 200:
for line in resp.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith('data: '):
data_str = line[6:] # 去掉 "data: " 前缀
if data_str == '[DONE]':
break
try:
chunk = json.loads(data_str)
delta = chunk.get('choices', [{}])[0].get('delta', {})
content = delta.get('content', '')
if content:
yield content
except json.JSONDecodeError as e:
# 单个 chunk 解析失败不中断整个流,仅记录 debug
logger.debug(f"流式响应 chunk 解析失败(已跳过): {e}")
else:
logger.error(f"API 错误: {resp.status_code}")
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
except requests.exceptions.RequestException as e:
logger.error(f"流式 API 调用失败(网络): {e}")
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
except Exception as e:
logger.exception(f"流式 API 调用失败: {e}")
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
else:
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
def split_mock_response(text, chunk_size=20):
"""将模拟响应分割成小块,模拟流式返回"""
for i in range(0, len(text), chunk_size):
yield text[i:i + chunk_size]
# ============================================================
# API 路由
# ============================================================
# ============================================================
# 登录认证接口
# ============================================================
@app.route('/login', methods=['GET', 'POST'])
def login():
"""登录页面"""
if request.method == 'GET':
# 已登录则直接跳转主页
if 'user' in session:
return redirect('/')
return render_template('login.html')
# POST 请求:处理登录
data = request.get_json() or {}
username = data.get('username', '').strip()
password = data.get('password', '').strip()
remember = data.get('remember', False)
if not username or not password:
return jsonify({
'success': False,
'error': '用户名和密码不能为空'
}), 400
# 验证登录
user = user_manager.authenticate(username, password)
if user:
# 创建 Session
session['user'] = {
'id': user['id'],
'username': user['username'],
'role': user['role']
}
if remember:
session.permanent = True
# 记录审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'user': username,
'role': user['role'],
'action': 'login',
'ip': request.remote_addr,
'result': 'success'
})
return jsonify({
'success': True,
'user': session['user']
})
else:
# 记录失败日志
log_audit({
'timestamp': datetime.now().isoformat(),
'user': username,
'action': 'login',
'ip': request.remote_addr,
'result': 'failed'
})
return jsonify({
'success': False,
'error': '用户名或密码错误,或账号已被锁定'
}), 401
@app.route('/logout', methods=['POST'])
def logout():
"""注销登录"""
user = session.get('user', {})
# 记录审计日志
if user:
log_audit({
'timestamp': datetime.now().isoformat(),
'user': user.get('username'),
'role': user.get('role'),
'action': 'logout',
'ip': request.remote_addr,
'result': 'success'
})
session.clear()
return jsonify({'success': True})
@app.route('/api/user/info', methods=['GET'])
@login_required
def get_user_info():
"""获取当前用户信息"""
return jsonify({
'success': True,
'user': session.get('user')
})
@app.route('/')
@page_login_required
def index():
"""首页"""
return render_template('index.html',
projects=get_search_engine().get_projects(),
system_types=config.get('system_types', []),
) )
# Session 配置
app.secret_key = os.environ.get('SECRET_KEY')
if not app.secret_key:
import secrets
app.secret_key = secrets.token_hex(32)
print("[警告] SECRET_KEY 环境变量未设置,已生成随机密钥")
print("[提示] 生产环境请设置 SECRET_KEY 环境变量")
app.config['SESSION_TYPE'] = 'filesystem'
app.config['PERMANENT_SESSION_LIFETIME'] = 86400 # 24小时
# 注册 Blueprint(url_prefix 留空,保持 API 路径不变)
from routes.auth import bp as auth_bp
from routes.troubleshoot import bp as troubleshoot_bp
from routes.cache import bp as cache_bp
from routes.export import bp as export_bp
from routes.submit import bp as submit_bp
app.register_blueprint(auth_bp)
app.register_blueprint(troubleshoot_bp)
app.register_blueprint(cache_bp)
app.register_blueprint(export_bp)
app.register_blueprint(submit_bp)
return app
# 模块级 app(启动即创建,保持 server.app 向后兼容)
app = create_app()
@app.route('/api/troubleshoot', methods=['POST']) # 加载配置
def troubleshoot():
"""排查问题 API(原有完整流程,保持兼容)"""
data = request.get_json() or {}
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
query = data.get('query', '').strip()
if not query:
return jsonify({
'success': False,
'error': '请输入问题描述',
}), 400
try:
# 1. 搜索匹配案例
engine = get_search_engine()
matched_cases_data = engine.search(
query,
top_k=5,
project_filter=project_name if project_name else None,
)
# 2. 构建 Prompt
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases_data)
# 3. 调用 Claude API
start_time = time.time()
raw_response = call_claude_api(prompt)
api_time = time.time() - start_time
# 4. 安全过滤
sf = get_safety_filter()
filter_result = sf.filter_output(raw_response, query)
# 5. 审计日志
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': round(api_time, 2),
'removed_lines': len(filter_result.get('removed_lines', [])),
})
# 6. 返回结果
return jsonify({
'success': True,
'response': filter_result['filtered_text'],
'matched_cases': format_matched_cases(matched_cases_data),
'filter_warnings': filter_result.get('removed_lines', []),
'api_time': round(api_time, 2),
})
except Exception as e:
logger.exception("排查接口服务异常")
return jsonify({
'success': False,
'error': f'服务异常:{str(e)}',
}), 500
@app.route('/api/search', methods=['POST'])
def search_cases():
"""渐进式排查 — 阶段一:仅搜索匹配案例(快速返回,<3s)"""
data = request.get_json() or {}
project_name = data.get('project_name', '').strip()
query = data.get('query', '').strip()
if not query:
return jsonify({
'success': False,
'error': '请输入问题描述',
}), 400
try:
start_time = time.time()
engine = get_search_engine()
matched_cases_data = engine.search(
query,
top_k=5,
project_filter=project_name if project_name else None,
)
search_time = time.time() - start_time
return jsonify({
'success': True,
'matched_cases': format_matched_cases(matched_cases_data),
'matched_count': len(matched_cases_data),
'search_time': round(search_time, 2),
})
except Exception as e:
logger.exception("搜索接口异常")
return jsonify({
'success': False,
'error': f'搜索异常:{str(e)}',
}), 500
@app.route('/api/analyze', methods=['POST'])
def analyze():
"""渐进式排查 — 阶段二:AI 深度分析(慢速返回,20-30s)"""
data = request.get_json() or {}
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
query = data.get('query', '').strip()
matched_cases = data.get('matched_cases', [])
if not query:
return jsonify({
'success': False,
'error': '请输入问题描述',
}), 400
try:
# 1. 构建 Prompt(使用前端传来的匹配案例数据)
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases)
# 2. 调用 Claude API
start_time = time.time()
raw_response = call_claude_api(prompt)
api_time = time.time() - start_time
# 3. 安全过滤
sf = get_safety_filter()
filter_result = sf.filter_output(raw_response, query)
# 4. 审计日志
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),
'removed_lines': len(filter_result.get('removed_lines', [])),
})
return jsonify({
'success': True,
'response': filter_result['filtered_text'],
'filter_warnings': filter_result.get('removed_lines', []),
'api_time': round(api_time, 2),
})
except Exception as e:
logger.exception("分析接口异常")
return jsonify({
'success': False,
'error': f'分析异常:{str(e)}',
}), 500
@app.route('/api/analyze/stream', methods=['GET'])
def analyze_stream():
"""
流式返回 AI 分析结果(SSE)。
参数通过 URL query string 传递:
- project_name: 项目名称
- system_type: 系统类型
- apk_product: APK 产品
- query: 问题描述
- model: 模型选择(可选)
返回格式(SSE):
data: {"type": "start", "message": "正在分析..."}
data: {"type": "matched_cases", "cases": [...]}
data: {"type": "chunk", "content": "..."}
data: {"type": "done", "api_time": 45.2}
"""
from flask import Response
project_name = request.args.get('project_name', '').strip()
system_type = request.args.get('system_type', '').strip()
apk_product = request.args.get('apk_product', '').strip()
query = request.args.get('query', '').strip()
model = request.args.get('model', '') or config.get('default_model', 'glm-5.1')
if not query:
def error_gen():
yield f'data: {json.dumps({"type": "error", "message": "请输入问题描述"}, ensure_ascii=False)}\n\n'
return Response(error_gen(), mimetype='text/event-stream')
# 检查缓存
cache = get_cache_manager()
cached_result = cache.get(project_name, system_type, apk_product, query)
if cached_result:
# 返回缓存结果
def generate_cached():
yield f'data: {json.dumps({"type": "start", "message": "正在分析...", "cached": True}, ensure_ascii=False)}\n\n'
yield f'data: {json.dumps({"type": "matched_cases", "cases": cached_result.get("matched_cases", [])}, ensure_ascii=False)}\n\n'
# 流式推送缓存内容
cached_response = cached_result.get("response", "")
chunk_size = 30
for i in range(0, len(cached_response), chunk_size):
content_chunk = cached_response[i:i + chunk_size]
yield f'data: {json.dumps({"type": "chunk", "content": content_chunk}, ensure_ascii=False)}\n\n'
time.sleep(0.01)
yield f'data: {json.dumps({"type": "done", "api_time": 0.5, "cached": True}, ensure_ascii=False)}\n\n'
return Response(generate_cached(), mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
def generate():
"""生成器:流式返回内容"""
start_time = time.time()
try:
# 1. 发送开始标记
yield f'data: {json.dumps({"type": "start", "message": "正在分析..."}, ensure_ascii=False)}\n\n'
# 2. 搜索匹配案例
engine = get_search_engine()
matched_cases_data = engine.search(query, top_k=5, project_filter=project_name if project_name else None)
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'
# 4. 构建 Prompt
prompt = build_prompt(query, project_name, system_type, apk_product, matched_cases_data)
# 5. 收集完整响应(用于安全过滤)
full_response = ""
for chunk in call_claude_api_stream(prompt, model=model):
full_response += chunk
# 6. 安全过滤(包含敏感信息过滤)
sf = get_safety_filter()
filter_result = sf.filter_output_with_sensitive(full_response, query)
filtered_text = filter_result['filtered_text']
# 7. 存入缓存
cache.set(project_name, system_type, apk_product, query, filtered_text, matched_cases)
# 8. 流式推送过滤后的内容(分块发送,模拟打字效果)
chunk_size = 30 # 每次发送的字符数
for i in range(0, len(filtered_text), chunk_size):
content_chunk = filtered_text[i:i + chunk_size]
yield f'data: {json.dumps({"type": "chunk", "content": content_chunk}, ensure_ascii=False)}\n\n'
time.sleep(0.01) # 小延迟,模拟打字效果
# 9. 发送完成标记
api_time = time.time() - start_time
yield f'data: {json.dumps({"type": "done", "api_time": round(api_time, 2)}, ensure_ascii=False)}\n\n'
# 9. 审计日志
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),
'removed_lines': len(filter_result.get('removed_lines', [])),
'mode': 'stream',
})
except Exception as e:
logger.exception("流式分析接口异常")
yield f'data: {json.dumps({"type": "error", "message": f"分析异常:{str(e)}"}, ensure_ascii=False)}\n\n'
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no', # 禁用 Nginx 缓冲
'Connection': 'keep-alive',
}
)
def format_matched_cases(matched_cases_data):
"""格式化匹配案例数据为标准返回格式"""
if not matched_cases_data:
return []
return [
{
'rank': c['rank'],
'score': c['score'],
'project': c.get('record', {}).get('project', ''),
'title': c.get('record', {}).get('title', ''),
'file': c.get('record', {}).get('file', ''),
'phenomenon': c.get('record', {}).get('phenomenon', ''),
}
for c in matched_cases_data
]
@app.route('/api/health', methods=['GET'])
def health_check():
"""
健康检查接口。
返回服务状态、版本信息、知识库统计等。
用于监控系统、负载均衡器健康检查。
"""
try:
engine = get_search_engine()
cache = get_cache_manager()
cache_stats = cache.get_stats()
return jsonify({
'status': 'ok',
'timestamp': datetime.now().isoformat(),
'version': '1.2.0',
'knowledge_base': {
'total_records': len(engine.records),
'last_update': engine.index.get('last_update', 'unknown'),
},
'cache': {
'total_files': cache_stats.get('total_files', 0),
'total_size_mb': cache_stats.get('total_size_mb', 0),
},
'components': {
'search_engine': 'ok',
'safety_filter': 'ok',
'cache_manager': 'ok',
}
})
except Exception as e:
logger.exception("健康检查接口异常")
return jsonify({
'status': 'error',
'timestamp': datetime.now().isoformat(),
'error': str(e),
}), 500
@app.route('/api/projects', methods=['GET'])
def get_projects():
"""获取项目列表(用于下拉建议)"""
engine = get_search_engine()
return jsonify({
'success': True,
'projects': engine.get_projects(),
})
@app.route('/api/categories', methods=['GET'])
def get_categories():
"""获取分类列表"""
engine = get_search_engine()
return jsonify({
'success': True,
'categories': engine.get_categories(),
})
# ============================================================
# 缓存管理接口
# ============================================================
@app.route('/api/cache/stats', methods=['GET'])
@admin_required
def get_cache_stats():
"""
获取缓存统计信息(仅管理员)。
返回:
- total_files: 缓存文件总数
- total_size_mb: 缓存总大小(MB)
- oldest: 最早缓存时间
- newest: 最新缓存时间
"""
try:
cache = get_cache_manager()
stats = cache.get_stats()
return jsonify({
'success': True,
'stats': stats,
})
except Exception as e:
logger.exception("获取缓存统计接口异常")
return jsonify({
'success': False,
'error': f'获取缓存统计失败:{str(e)}',
}), 500
@app.route('/api/cache/clear', methods=['POST'])
@admin_required
def clear_cache():
"""
清空所有缓存(仅管理员)。
"""
try:
cache = get_cache_manager()
cleared_count = cache.clear_all()
# 审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'action': 'clear_cache',
'cleared_count': cleared_count,
})
return jsonify({
'success': True,
'message': f'已清除 {cleared_count} 个缓存文件',
'cleared_count': cleared_count,
})
except Exception as e:
logger.exception("清空缓存接口异常")
return jsonify({
'success': False,
'error': f'清空缓存失败:{str(e)}',
}), 500
# ============================================================
# 导出功能接口
# ============================================================
@app.route('/api/export', methods=['POST'])
def export_report():
"""
导出排查报告为 Word 文档。
接收参数:
- project_name: 项目名称
- system_type: 系统类型
- apk_product: APK 产品
- query: 问题描述
- matched_cases: 匹配的历史案例
- response: AI 分析结果
返回:
Word 文档文件流
"""
try:
from docx import Document
from docx.shared import Pt, RGBColor
except ImportError:
return jsonify({
'success': False,
'error': '缺少 python-docx 库,请运行: pip install python-docx',
}), 500
data = request.get_json() or {}
# 收集参数
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
query = data.get('query', '').strip()
matched_cases = data.get('matched_cases', [])
response = data.get('response', '').strip()
# 生成 Word 文档
doc = Document()
# 标题
title = doc.add_heading('问题排查报告', 0)
title.alignment = 1 # 居中
# 导出时间
doc.add_paragraph(f'导出时间:{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
doc.add_paragraph('')
# 基本信息
doc.add_heading('基本信息', level=1)
doc.add_paragraph(f'项目名称:{project_name}')
doc.add_paragraph(f'系统类型:{system_type}')
if apk_product:
doc.add_paragraph(f'APK 产品:{apk_product}')
doc.add_paragraph('')
# 问题描述
doc.add_heading('问题描述', level=1)
doc.add_paragraph(query)
doc.add_paragraph('')
# 匹配的历史案例
if matched_cases:
doc.add_heading('匹配的历史案例', level=1)
for c in matched_cases:
p = doc.add_paragraph()
rank = c.get('rank', '-')
project = c.get('project', '')
title_text = c.get('title', '')
score = c.get('score', 0)
p.add_run(f'{rank}. ').bold = True
p.add_run(f'{project} - {title_text}(相似度:{score:.0%})')
doc.add_paragraph('')
# AI 分析结果
doc.add_heading('AI 分析结果', level=1)
# 简单渲染 Markdown(粗体、标题、列表)
for line in response.split('\n'):
if line.startswith('### '):
doc.add_heading(line[4:], level=2)
elif line.startswith('## '):
doc.add_heading(line[3:], level=2)
elif line.startswith('- '):
doc.add_paragraph(line[2:], style='List Bullet')
elif line.strip():
doc.add_paragraph(line)
# 保存到内存流
buffer = BytesIO()
doc.save(buffer)
buffer.seek(0)
# 生成文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'排查报告_{project_name}_{timestamp}.docx'
# 审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'action': 'export_report',
'project': project_name,
'filename': filename,
})
# 返回文件流
return send_file(
buffer,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
)
@app.route('/api/submit', methods=['POST'])
def submit_record():
"""提交问题记录到知识库"""
data = request.get_json() or {}
# 接收字段
project_name = data.get('project_name', '').strip()
system_type = data.get('system_type', '').strip()
apk_product = data.get('apk_product', '').strip()
phenomenon = data.get('phenomenon', '').strip()
troubleshoot_steps = data.get('troubleshoot_steps', '').strip()
root_cause = data.get('root_cause', '').strip()
solution = data.get('solution', '').strip()
recorder = data.get('recorder', '').strip()
# 必填校验
if not project_name:
return jsonify({'success': False, 'error': '项目名称不能为空'}), 400
if not phenomenon:
return jsonify({'success': False, 'error': '问题描述不能为空'}), 400
if not recorder:
return jsonify({'success': False, 'error': '记录人不能为空'}), 400
try:
# 1. 生成 Markdown 文件
record_id = generate_record_id()
md_content = build_markdown_content(
record_id=record_id,
project_name=project_name,
system_type=system_type,
apk_product=apk_product,
phenomenon=phenomenon,
troubleshoot_steps=troubleshoot_steps,
root_cause=root_cause,
solution=solution,
recorder=recorder,
)
md_filename = build_md_filename(record_id, project_name, phenomenon)
md_filepath = RECORDS_DIR / "项目" / md_filename
# 确保目录存在
md_filepath.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
md_filepath.write_text(md_content, encoding='utf-8')
# 2. 重建搜索索引
rebuild_search_index()
# 3. 审计日志
log_audit({
'timestamp': datetime.now().isoformat(),
'action': 'submit_record',
'record_id': record_id,
'project': project_name,
'recorder': recorder,
})
return jsonify({
'success': True,
'message': '问题记录已提交到知识库',
'record_id': record_id,
'file': str(md_filepath.relative_to(PROJECT_ROOT)),
})
except Exception as e:
logger.exception("问题入库接口异常")
return jsonify({
'success': False,
'error': f'提交失败:{str(e)}',
}), 500
# ============================================================ # ============================================================
# 入库辅助函数 # 配置与单例(已迁至 container.py —— P1-3 步骤2)
# ============================================================ # ============================================================
def generate_record_id(): from container import (
"""生成记录 ID:RC-YYYYMMDD-NNNN""" load_config, get_config, get_search_engine, reset_search_engine,
today = datetime.now().strftime('%Y%m%d') get_safety_filter, get_cache_manager, get_skill_md,
# 查找今天已有的记录数 )
project_dir = RECORDS_DIR / "项目"
if project_dir.exists():
today_files = list(project_dir.glob(f"RC-{today}-*.md"))
seq = len(today_files) + 1
else:
seq = 1
return f"RC-{today}-{seq:03d}"
def build_md_filename(record_id, project_name, phenomenon):
"""构建 Markdown 文件名"""
# 从 phenomenon 提取关键词
keywords = extract_keywords_from_text(phenomenon)
keyword_str = keywords[0] if keywords else "问题"
# 清理文件名
keyword_str = re.sub(r'[\\/:*?"<>|\r\n\t]', '', keyword_str)[:20]
return f"{record_id}-{project_name}-{keyword_str}.md"
def extract_keywords_from_text(text):
"""从文本提取关键词"""
if not text:
return []
text = text.lower()
keywords = []
tech_keywords = [
"redis", "mysql", "nacos", "emqx", "mqtt", "docker",
"nginx", "java", "python", "smc", "exchange",
"门口屏", "无纸化", "桌牌", "人脸", "签到", "白名单",
"wss", "websocket", "授权", "激活", "token", "证书",
"时区", "ntp", "磁盘", "内存", "oom",
"配置", "config", "短信", "邮件", "同步",
"钉钉", "企业微信", "welink", "oa",
"连接失败", "启动失败", "未授权", "绑定失败",
]
for kw in tech_keywords:
if kw in text:
keywords.append(kw)
return keywords[:3]
def categorize_text(text):
"""自动分类"""
if not text:
return ["其他"]
text = text.lower()
categories = []
rules = {
"服务/容器异常": ["docker", "容器", "服务", "500", "502", "启动失败", "exited"],
"设备终端-门口屏": ["门口屏", "门ロ屏"],
"设备终端-无纸化": ["无纸化"],
"设备终端-桌牌": ["桌牌"],
"MQTT/EMQX": ["mqtt", "emqx", "wss", "websocket"],
"数据库": ["mysql", "redis", "数据库", "连接失败"],
"配置文件": ["配置", "config", "不生效"],
"前端/UI": ["页面", "前端", "ui", "显示"],
"部署/升级": ["部署", "升级", "更新"],
"网络/SSH": ["网络", "ssh", "端口", "不通"],
"授权/激活": ["授权", "激活", "token", "jwt", "证书"],
}
for cat, kws in rules.items():
for kw in kws:
if kw in text:
categories.append(cat)
break
return categories if categories else ["其他"]
def build_markdown_content(record_id, project_name, system_type, apk_product,
phenomenon, troubleshoot_steps, root_cause, solution, recorder):
"""构建 Markdown 内容"""
today = datetime.now().strftime('%Y-%m-%d')
categories = categorize_text(phenomenon + " " + (root_cause or ""))
keywords = extract_keywords_from_text(phenomenon + " " + (root_cause or "") + " " + (solution or ""))
lines = []
# Frontmatter
lines.append("---")
lines.append(f'id: "{record_id}"')
lines.append(f'date: "{today}"')
lines.append(f'source: "项目"')
lines.append(f'project: "{project_name}"')
lines.append(f'category: [{", ".join(categories)}]')
lines.append(f'status: "未解决"')
lines.append(f'keywords: [{", ".join(keywords)}]')
lines.append(f'recorder: "{recorder}"')
lines.append("---")
lines.append("")
# 标题
lines.append(f"# [{project_name}] {phenomenon}")
lines.append("")
# 现象
lines.append("## 现象")
lines.append(phenomenon)
lines.append("")
# 系统类型和 APK 产品(如有)
if system_type:
lines.append(f"**系统类型**:{system_type}")
if apk_product:
lines.append(f"**APK 产品**:{apk_product}")
if system_type or apk_product:
lines.append("")
# 排查过程
if troubleshoot_steps:
lines.append("## 排查过程")
lines.append(troubleshoot_steps)
lines.append("")
# 根因
if root_cause:
lines.append("## 根因")
lines.append(root_cause)
lines.append("")
# 解决方案
if solution:
lines.append("## 解决方案")
lines.append(solution)
lines.append("")
# 元信息
lines.append("## 元信息")
lines.append(f"- 记录人:{recorder}")
lines.append(f"- 记录时间:{today}")
return "\n".join(lines)
def rebuild_search_index():
"""重建搜索索引"""
# 复用 build_index.py 的逻辑
import subprocess
build_index_script = SCRIPT_DIR.parent / "build_index.py"
if build_index_script.exists():
result = subprocess.run(
[sys.executable, str(build_index_script)],
capture_output=True,
text=True,
timeout=60,
cwd=str(PROJECT_ROOT),
)
if result.returncode != 0:
logger.error(f"索引重建失败:{result.stderr}")
# 重新加载搜索引擎
global search_engine
search_engine = None
get_search_engine()
# 模块级 config(启动即加载,保持原行为)
config = get_config()
# ============================================================ # ============================================================
# 审计日志 # Prompt 构建与 Claude API 调用(已迁至 services/ai_service.py —— P1-3 步骤4)
# ============================================================ # ============================================================
AUDIT_LOG_FILE = SCRIPT_DIR / "audit.log" from services.ai_service import (
build_prompt, call_claude_api, call_claude_api_stream,
def log_audit(entry): generate_mock_response, split_mock_response,
"""记录审计日志""" )
line = json.dumps(entry, ensure_ascii=False)
with open(AUDIT_LOG_FILE, 'a', encoding='utf-8') as f:
f.write(line + '\n')
# ============================================================ # ============================================================
...@@ -1436,9 +136,12 @@ if __name__ == '__main__': ...@@ -1436,9 +136,12 @@ if __name__ == '__main__':
print(f"🌐 服务地址:http://localhost:8088") print(f"🌐 服务地址:http://localhost:8088")
print("="*50) print("="*50)
# debug 从环境变量读取(默认开发环境开启,生产用 FLASK_DEBUG=0 关闭)
debug = os.environ.get('FLASK_DEBUG', '1') == '1'
app.run( app.run(
host='0.0.0.0', host='0.0.0.0',
port=8088, port=8088,
debug=True, debug=debug,
threaded=True, threaded=True,
) )
\ No newline at end of file
# -*- coding: utf-8 -*-
"""services 包 — 业务逻辑层"""
# -*- coding: utf-8 -*-
"""
ai_service.py — Claude API 调用与 Prompt 构建(业务层)
从原 server.py 迁入:build_prompt / call_claude_api / call_claude_api_stream
/ generate_mock_response / split_mock_response + SYSTEM_TYPE_DOMAINS
/ APK_PRODUCT_HINTS 常量。
依赖注入:config 与 get_skill_md 从 container 取,logger 自建子 logger。
行为保持不变(含降级 mock 响应逻辑)。
"""
import json
import container
from utils.logger import get_logger
logger = get_logger(__name__)
# ============================================================
# 排查域映射表(P1 Prompt 增强优化)
# ============================================================
# 系统类型 → 重点排查域映射
SYSTEM_TYPE_DOMAINS = {
"标准版预定2.0": "EMQX、Nacos、门口屏、无纸化、数据库、Redis",
"标准版运维集控系统": "协议透传、中间件、设备管理、MQTT",
"新统一平台": "统一门户、SMC、腾讯会议、单点登录",
"统一平台": "用户中心、权限管理、数据同步",
}
# APK 产品 → 特性关注映射
APK_PRODUCT_HINTS = {
"门口屏5.0": "设备主动上报心跳,内置默认 MQTT 凭据,只同步参会人人脸",
"门口屏4.0": "Web 端主动刷新,并发量过大时可能断连,建议升级到 5.0",
"无纸化": "座位编排需与会议室绑定一致,平板缓存需定期清理",
"桌牌": "绑定授权码后才能使用,检查 JWT 校验是否正常",
}
# ============================================================
# Prompt 构建
# ============================================================
def build_prompt(query, project_name, system_type, apk_product, matched_cases):
"""构建发送给 Claude 的完整 Prompt"""
# 基础系统提示
system_prompt = """你是一个自动化测试团队的问题排查专家助手。你面对的是现场实施同事,他们可能不熟悉 Linux 命令和系统架构。
## 核心规则(必须遵守)
1. **只读操作原则**:给出的排查步骤必须是只读操作(查看状态、查看日志、探测连通性、查看配置)
2. **安全原则**:绝对不允许给出修改、删除、重启、停止、写入等操作
3. **相关性原则**:每步操作必须直接关联用户描述的问题,不允许泛化
4. **排序原则**:步骤按最可能→最不可能排序
5. **概率原则**:给出各可能根因的概率估计
6. **语言原则**:用简单易懂的语言,说明每步"查什么"、"查到什么说明是什么问题"
7. **危险操作原则**:如果需要执行危险操作来修复,用"请联系研发组执行:[具体操作]"代替
## 禁止的命令(违反则整条回复作废)
- 删除类:rm、docker rm、DROP、DELETE、TRUNCATE
- 重启类:reboot、systemctl restart、docker restart、shutdown
- 停止类:systemctl stop、docker stop、kill、pkill
- 修改类:chmod 777、chown、写入配置文件、iptables 修改
- 清空类:FLUSHALL、FLUSHDB
## 允许的命令(可放心输出)
- 查看状态:docker ps、systemctl status、ss -tln
- 查看日志:docker logs、tail、journalctl
- 连通性测试:nc -z、curl、ping
- 系统信息:df -h、free -h、date、ps aux
- 配置查看:cat、grep、ls
"""
# 构建用户查询
user_query_parts = []
if project_name:
user_query_parts.append(f"项目名称:{project_name}")
if system_type:
user_query_parts.append(f"系统类型:{system_type}")
# 系统类型排查域提示
domain_hint = SYSTEM_TYPE_DOMAINS.get(system_type, "")
if domain_hint:
user_query_parts.append(f"重点排查域:{domain_hint}")
if apk_product:
user_query_parts.append(f"APK产品:{apk_product}")
# APK 产品特性提示
apk_hint = APK_PRODUCT_HINTS.get(apk_product, "")
if apk_hint:
user_query_parts.append(f"{apk_product} 特性:{apk_hint}")
user_query_parts.append(f"问题描述:{query}")
user_query = "\n".join(user_query_parts)
# 构建匹配案例
cases_text = ""
if matched_cases:
cases_text = "## 历史相似案例\n\n"
for c in matched_cases[:5]:
rec = c.get('record', {})
cases_text += f"### 案例 {c['rank']}(相似度:{c['score']:.0%})\n"
if rec.get('project'):
cases_text += f"项目:{rec['project']}\n"
cases_text += f"现象:{rec.get('phenomenon', rec.get('title', '未知'))}\n\n"
cases_text += "---\n\n"
# 排查套路参考(截取前 5000 字符,避免过长)
skill_text = container.get_skill_md()
if len(skill_text) > 5000:
skill_text = skill_text[:5000] + "\n\n... (完整排查套路见 SKILL.md)"
# 组装完整 Prompt
full_prompt = f"""{system_prompt}
---
{cases_text}
## 排查套路参考
{skill_text}
---
## 用户查询
{user_query}
---
请根据以上信息,生成排查步骤和根因分析。输出格式:
### 📋 匹配到的历史案例
(简要列出匹配的案例,如:厦门银行-门口屏MQTT绑定失败)
### 🔬 排查步骤(只读操作)
Step 1: ...
Step 2: ...
...
### 🎯 最可能的根因(按概率排序)
- XX%:根因描述
- XX%:根因描述
...
### ⚠️ 注意事项
(提醒现场同事注意的事项)
"""
return full_prompt
# ============================================================
# Claude API 调用
# ============================================================
def call_claude_api(prompt):
"""调用 Claude API — 支持 HTTP API 或 CLI 两种方式"""
import requests
# 从配置获取 API 信息
config = container.get_config()
api_base = config.get('claude_api_base', '')
api_key = config.get('claude_api_key', '')
model = config.get('claude_model', 'glm-5.1')
# 方式1: HTTP API(优先)
if api_base and api_key:
try:
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
data = {
'model': model,
'messages': [{'role': 'user', 'content': prompt}],
'max_tokens': 4096,
}
resp = requests.post(
f'{api_base}/v1/chat/completions',
headers=headers,
json=data,
timeout=60,
)
if resp.status_code == 200:
result = resp.json()
return result['choices'][0]['message']['content']
else:
logger.error(f"API 错误: {resp.status_code} - {resp.text[:200]}")
except requests.exceptions.RequestException as e:
logger.error(f"HTTP API 调用失败(网络): {e}")
except (KeyError, ValueError) as e:
logger.error(f"HTTP API 响应解析失败: {e}")
except Exception as e:
logger.exception(f"HTTP API 调用未知异常: {e}")
# 方式2: CLI(降级)
import subprocess
import tempfile
try:
with tempfile.NamedTemporaryFile(
mode='w', suffix='.txt', delete=False, encoding='utf-8'
) as f:
f.write(prompt)
temp_path = f.name
result = subprocess.run(
['claude', '--print', '-p', f'@{temp_path}'],
capture_output=True,
text=True,
timeout=120,
encoding='utf-8',
)
import os as _os
_os.unlink(temp_path)
if result.returncode == 0:
return result.stdout.strip()
else:
logger.error(f"Claude CLI 错误:{result.stderr}")
return generate_mock_response(prompt)
except FileNotFoundError:
logger.warning("未找到 claude CLI,降级为模拟响应")
return generate_mock_response(prompt)
except subprocess.TimeoutExpired as e:
logger.error(f"Claude CLI 调用超时: {e}")
return generate_mock_response(prompt)
except Exception as e:
logger.exception(f"Claude 调用失败: {e}")
return generate_mock_response(prompt)
def generate_mock_response(prompt):
"""生成模拟响应(用于开发测试)"""
return """### 📋 匹配到的历史案例
1. 厦门银行-门口屏MQTT绑定失败(相似度:92%
2. 展厅-门口屏不显示会议信息(相似度:75%
### 🔬 排查步骤(只读操作)
Step 1: 检查门口屏版本
命令:在门口屏设备上查看版本号
说明:确认是否为最新版本(5.0+),旧版本可能缺少默认 MQTT 凭据
Step 2: 查看设备日志
命令:查看门口屏日志文件
说明:查找 MQTT 连接相关的错误信息
Step 3: 检查 EMQX 容器状态
命令:docker ps -a | grep uemqx
说明:确认 EMQX 容器是否正常运行
Step 4: 检查 MQTT 端口连通性
命令:nc -z <服务器IP> 1883
说明:测试 MQTT 端口是否可达
### 🎯 最可能的根因(按概率排序)
- 70%:旧版门口屏没有默认 MQTT 账号密码
- 20%:EMQX 容器未启动或异常
- 10%:授权码变化导致无效 token
### ⚠️ 注意事项
- 排查时只读操作,不要随意重启服务
- 更新门口屏包后务必重启设备 APP
- 如需执行 docker restart 等危险操作,请联系研发组
"""
def call_claude_api_stream(prompt, model=None):
"""
流式调用 Claude API。
参数:
prompt: 提示文本
model: 模型 ID(可选,默认使用配置中的模型)
返回:生成器,每次 yield 一个文本片段
"""
import requests
config = container.get_config()
api_base = config.get('claude_api_base', '')
api_key = config.get('claude_api_key', '')
if model is None:
model = config.get('default_model', 'glm-5.1')
# 方式1: HTTP API 流式(优先)
if api_base and api_key:
try:
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
data = {
'model': model,
'messages': [{'role': 'user', 'content': prompt}],
'max_tokens': 4096,
'stream': True, # 启用流式返回
}
resp = requests.post(
f'{api_base}/v1/chat/completions',
headers=headers,
json=data,
stream=True, # requests 流式响应
timeout=120,
)
if resp.status_code == 200:
for line in resp.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith('data: '):
data_str = line[6:] # 去掉 "data: " 前缀
if data_str == '[DONE]':
break
try:
chunk = json.loads(data_str)
delta = chunk.get('choices', [{}])[0].get('delta', {})
content = delta.get('content', '')
if content:
yield content
except json.JSONDecodeError as e:
# 单个 chunk 解析失败不中断整个流,仅记录 debug
logger.debug(f"流式响应 chunk 解析失败(已跳过): {e}")
else:
logger.error(f"API 错误: {resp.status_code}")
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
except requests.exceptions.RequestException as e:
logger.error(f"流式 API 调用失败(网络): {e}")
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
except Exception as e:
logger.exception(f"流式 API 调用失败: {e}")
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
else:
# 降级:返回模拟响应
for chunk in split_mock_response(generate_mock_response(prompt)):
yield chunk
def split_mock_response(text, chunk_size=20):
"""将模拟响应分割成小块,模拟流式返回"""
for i in range(0, len(text), chunk_size):
yield text[i:i + chunk_size]
# -*- coding: utf-8 -*-
"""
record_service.py — 问题记录业务逻辑
从原 server.py 迁入 rebuild_search_index。原实现用 `global search_engine` 重置
server.py 模块级单例;现改为通过 container.reset_search_engine() 重置容器内单例,
保持"提交记录后强制下次访问重建索引"的语义不变。
"""
import sys
import subprocess
from utils.paths import SCRIPT_DIR, PROJECT_ROOT
from utils.logger import get_logger
import container
logger = get_logger(__name__)
def rebuild_search_index():
"""重建搜索索引"""
# 复用 build_index.py 的逻辑
build_index_script = SCRIPT_DIR.parent / "build_index.py"
if build_index_script.exists():
result = subprocess.run(
[sys.executable, str(build_index_script)],
capture_output=True,
text=True,
timeout=60,
cwd=str(PROJECT_ROOT),
)
if result.returncode != 0:
logger.error(f"索引重建失败:{result.stderr}")
# 重新加载搜索引擎(通过 container 重置单例)
container.reset_search_engine()
container.get_search_engine()
# -*- coding: utf-8 -*-
"""
audit.py — 审计日志记录
从原 server.py 迁入(行为保持不变)。路径从 utils.paths.AUDIT_LOG_FILE 取,
避免硬编码 SCRIPT_DIR。
"""
import json
from utils.paths import AUDIT_LOG_FILE
def log_audit(entry):
"""记录审计日志"""
line = json.dumps(entry, ensure_ascii=False)
with open(AUDIT_LOG_FILE, 'a', encoding='utf-8') as f:
f.write(line + '\n')
# -*- coding: utf-8 -*-
"""
paths.py — 路径常量集中管理
把原散落在 server.py 顶部的路径常量集中到此,避免各层各自重算 Path(__file__).parent
导致路径偏移。所有层统一 from utils.paths import SCRIPT_DIR, ... 取用。
路径锚定:utils/paths.py 位于 .../web/utils/paths.py
- SCRIPT_DIR = Path(__file__).resolve().parent.parent → .../web(与原 server.py 一致)
- DATA_DIR = SCRIPT_DIR.parent → 部署时即 /opt/troubleshoot
"""
import os
from pathlib import Path
# ============================================================
# 基础路径
# ============================================================
SCRIPT_DIR = Path(__file__).resolve().parent.parent # .../web
DATA_DIR = SCRIPT_DIR.parent # 部署时即 /opt/troubleshoot(web 上级目录)
# ============================================================
# 项目根目录(优先使用环境变量,否则自动检测)
# ============================================================
PROJECT_ROOT = os.environ.get('TROUBLESHOOT_ROOT')
if PROJECT_ROOT:
PROJECT_ROOT = Path(PROJECT_ROOT)
elif (DATA_DIR / "Docs" / "PRD" / "问题知识库").exists():
# 开发环境:项目仓库中
PROJECT_ROOT = DATA_DIR # code/ 的上级即项目根
# 但 DATA_DIR 指向 code/,需要再往上找。
# 实际项目根:从 code/web/ 往上 4 级 → 仓库根
for _ in range(4):
if (PROJECT_ROOT / "CLAUDE.md").exists():
break
PROJECT_ROOT = PROJECT_ROOT.parent
else:
# 部署环境:PROJECT_ROOT 即 DATA_DIR(/opt/troubleshoot)
PROJECT_ROOT = DATA_DIR
# ============================================================
# 派生路径
# ============================================================
# 问题记录输出目录
RECORDS_DIR = PROJECT_ROOT / "Docs" / "PRD" / "问题知识库" / "问题记录"
if not RECORDS_DIR.exists():
# 部署环境降级:使用 DATA_DIR 下的 问题记录/
RECORDS_DIR = DATA_DIR / "问题记录"
# 系统 API 配置文件
CONFIG_FILE = SCRIPT_DIR / "config.json"
# 审计日志文件
AUDIT_LOG_FILE = SCRIPT_DIR / "audit.log"
# -*- coding: utf-8 -*-
"""
record_utils.py — 问题记录工具函数
从原 server.py 迁入的纯函数(入库辅助):generate_record_id / build_md_filename
/ extract_keywords_from_text / categorize_text / build_markdown_content。
仅 generate_record_id 依赖 RECORDS_DIR(从 utils.paths 取),其余为纯函数。
"""
import re
from datetime import datetime
from utils.paths import RECORDS_DIR
def generate_record_id():
"""生成记录 ID:RC-YYYYMMDD-NNNN"""
today = datetime.now().strftime('%Y%m%d')
# 查找今天已有的记录数
project_dir = RECORDS_DIR / "项目"
if project_dir.exists():
today_files = list(project_dir.glob(f"RC-{today}-*.md"))
seq = len(today_files) + 1
else:
seq = 1
return f"RC-{today}-{seq:03d}"
def build_md_filename(record_id, project_name, phenomenon):
"""构建 Markdown 文件名"""
# 从 phenomenon 提取关键词
keywords = extract_keywords_from_text(phenomenon)
keyword_str = keywords[0] if keywords else "问题"
# 清理文件名
keyword_str = re.sub(r'[\\/:*?"<>|\r\n\t]', '', keyword_str)[:20]
return f"{record_id}-{project_name}-{keyword_str}.md"
def extract_keywords_from_text(text):
"""从文本提取关键词"""
if not text:
return []
text = text.lower()
keywords = []
tech_keywords = [
"redis", "mysql", "nacos", "emqx", "mqtt", "docker",
"nginx", "java", "python", "smc", "exchange",
"门口屏", "无纸化", "桌牌", "人脸", "签到", "白名单",
"wss", "websocket", "授权", "激活", "token", "证书",
"时区", "ntp", "磁盘", "内存", "oom",
"配置", "config", "短信", "邮件", "同步",
"钉钉", "企业微信", "welink", "oa",
"连接失败", "启动失败", "未授权", "绑定失败",
]
for kw in tech_keywords:
if kw in text:
keywords.append(kw)
return keywords[:3]
def categorize_text(text):
"""自动分类"""
if not text:
return ["其他"]
text = text.lower()
categories = []
rules = {
"服务/容器异常": ["docker", "容器", "服务", "500", "502", "启动失败", "exited"],
"设备终端-门口屏": ["门口屏", "门ロ屏"],
"设备终端-无纸化": ["无纸化"],
"设备终端-桌牌": ["桌牌"],
"MQTT/EMQX": ["mqtt", "emqx", "wss", "websocket"],
"数据库": ["mysql", "redis", "数据库", "连接失败"],
"配置文件": ["配置", "config", "不生效"],
"前端/UI": ["页面", "前端", "ui", "显示"],
"部署/升级": ["部署", "升级", "更新"],
"网络/SSH": ["网络", "ssh", "端口", "不通"],
"授权/激活": ["授权", "激活", "token", "jwt", "证书"],
}
for cat, kws in rules.items():
for kw in kws:
if kw in text:
categories.append(cat)
break
return categories if categories else ["其他"]
def build_markdown_content(record_id, project_name, system_type, apk_product,
phenomenon, troubleshoot_steps, root_cause, solution, recorder):
"""构建 Markdown 内容"""
today = datetime.now().strftime('%Y-%m-%d')
categories = categorize_text(phenomenon + " " + (root_cause or ""))
keywords = extract_keywords_from_text(phenomenon + " " + (root_cause or "") + " " + (solution or ""))
lines = []
# Frontmatter
lines.append("---")
lines.append(f'id: "{record_id}"')
lines.append(f'date: "{today}"')
lines.append(f'source: "项目"')
lines.append(f'project: "{project_name}"')
lines.append(f'category: [{", ".join(categories)}]')
lines.append(f'status: "未解决"')
lines.append(f'keywords: [{", ".join(keywords)}]')
lines.append(f'recorder: "{recorder}"')
lines.append("---")
lines.append("")
# 标题
lines.append(f"# [{project_name}] {phenomenon}")
lines.append("")
# 现象
lines.append("## 现象")
lines.append(phenomenon)
lines.append("")
# 系统类型和 APK 产品(如有)
if system_type:
lines.append(f"**系统类型**:{system_type}")
if apk_product:
lines.append(f"**APK 产品**:{apk_product}")
if system_type or apk_product:
lines.append("")
# 排查过程
if troubleshoot_steps:
lines.append("## 排查过程")
lines.append(troubleshoot_steps)
lines.append("")
# 根因
if root_cause:
lines.append("## 根因")
lines.append(root_cause)
lines.append("")
# 解决方案
if solution:
lines.append("## 解决方案")
lines.append(solution)
lines.append("")
# 元信息
lines.append("## 元信息")
lines.append(f"- 记录人:{recorder}")
lines.append(f"- 记录时间:{today}")
return "\n".join(lines)
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论