log-ai-compressor 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- log_ai_compressor/__init__.py +20 -0
- log_ai_compressor/__main__.py +6 -0
- log_ai_compressor/ai/__init__.py +112 -0
- log_ai_compressor/ai/client.py +146 -0
- log_ai_compressor/ai/config.py +214 -0
- log_ai_compressor/ai/prompts.py +215 -0
- log_ai_compressor/cli.py +528 -0
- log_ai_compressor/constants.py +229 -0
- log_ai_compressor/core/__init__.py +2 -0
- log_ai_compressor/core/analysis.py +947 -0
- log_ai_compressor/core/clustering.py +341 -0
- log_ai_compressor/core/comparator.py +138 -0
- log_ai_compressor/core/encoding.py +163 -0
- log_ai_compressor/core/filters.py +135 -0
- log_ai_compressor/core/models.py +320 -0
- log_ai_compressor/core/parser.py +362 -0
- log_ai_compressor/core/pipeline.py +579 -0
- log_ai_compressor/core/redact.py +55 -0
- log_ai_compressor/export/__init__.py +11 -0
- log_ai_compressor/export/reporters.py +861 -0
- log_ai_compressor/mcp/__init__.py +10 -0
- log_ai_compressor/mcp/server.py +376 -0
- log_ai_compressor/rules/__init__.py +10 -0
- log_ai_compressor/rules/engine.py +399 -0
- log_ai_compressor/rules/presets/embedded.yaml +69 -0
- log_ai_compressor/rules/presets/generic.yaml +101 -0
- log_ai_compressor/rules/presets/jenkins.yaml +53 -0
- log_ai_compressor/service.py +535 -0
- log_ai_compressor/web/__init__.py +10 -0
- log_ai_compressor/web/jobs.py +207 -0
- log_ai_compressor/web/server.py +389 -0
- log_ai_compressor/web/static/app.js +997 -0
- log_ai_compressor/web/static/index.html +281 -0
- log_ai_compressor/web/static/style.css +444 -0
- log_ai_compressor-2.0.0.dist-info/METADATA +430 -0
- log_ai_compressor-2.0.0.dist-info/RECORD +40 -0
- log_ai_compressor-2.0.0.dist-info/WHEEL +5 -0
- log_ai_compressor-2.0.0.dist-info/entry_points.txt +2 -0
- log_ai_compressor-2.0.0.dist-info/licenses/LICENSE +21 -0
- log_ai_compressor-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""log-ai-compressor:本地日志分析取证台(零数据出网)。
|
|
3
|
+
|
|
4
|
+
核心定位:
|
|
5
|
+
1. 日志压缩投喂大模型 —— 将海量日志压缩为结构化错误报告,适配 LLM 上下文窗口;
|
|
6
|
+
2. 快速故障排查 —— 聚类去重、根因定位、优先级排序,辅助人工快速定位问题;
|
|
7
|
+
3. 本地优先 —— 全部计算在本机完成,日志不出网,不需要账号、云服务或数据上传。
|
|
8
|
+
|
|
9
|
+
分层架构(单向依赖 rules → core → export → 接入层):
|
|
10
|
+
- log_ai_compressor.rules 可插拔解析规则引擎(YAML 配置驱动)
|
|
11
|
+
- log_ai_compressor.core 核心处理层(解析/过滤/聚类/分析/管线/对比,零 UI 依赖)
|
|
12
|
+
- log_ai_compressor.export 导出层(Markdown / JSON / 纯文本 / HTML 报告)
|
|
13
|
+
- log_ai_compressor.web 本地 Web 服务(FastAPI,浏览器界面 + REST + SSE)
|
|
14
|
+
- log_ai_compressor.mcp MCP 服务器(把分析能力暴露给 AI Agent)
|
|
15
|
+
- log_ai_compressor.ai 可选 AI 解读层(OpenAI 兼容端点 / 本地 Ollama,不配 key 可用)
|
|
16
|
+
- log_ai_compressor.cli 命令行入口(run / compare / rules / web / mcp / ai)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "2.0.0"
|
|
20
|
+
__app_name__ = "log-ai-compressor"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""可选 AI 解读层。
|
|
3
|
+
|
|
4
|
+
设计原则
|
|
5
|
+
--------
|
|
6
|
+
1. **可选**:不配置任何 API Key 时,整个工具照常工作(聚类 / 根因 / 异常检测
|
|
7
|
+
/ 导出全是本地算法)。AI 只在压缩结果之上再生成一段人话解读。
|
|
8
|
+
2. **三家通吃**:所有主流厂商都提供 OpenAI 兼容协议,只需换 base_url;
|
|
9
|
+
本地 Ollama 走同一套协议,另外支持它的原生 /api/chat 接口。
|
|
10
|
+
3. **零新增依赖**:只用 httpx(FastAPI 已带),不引入各家 SDK。
|
|
11
|
+
4. **不静默外传**:未配置时前端明确显示「未启用」,不会偷偷把日志发出去。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
from log_ai_compressor.ai.client import LLMError, chat
|
|
18
|
+
from log_ai_compressor.ai.config import (
|
|
19
|
+
AIConfig,
|
|
20
|
+
load_config,
|
|
21
|
+
save_config,
|
|
22
|
+
describe_config,
|
|
23
|
+
)
|
|
24
|
+
from log_ai_compressor.ai.prompts import (
|
|
25
|
+
SYSTEM_ANALYST,
|
|
26
|
+
build_explain_prompt,
|
|
27
|
+
build_cluster_prompt,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AIConfig", "LLMError", "chat", "load_config", "save_config",
|
|
32
|
+
"describe_config", "build_explain_prompt", "build_cluster_prompt",
|
|
33
|
+
"SYSTEM_ANALYST", "explain_result", "explain_cluster", "explain_job",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require_config() -> AIConfig:
|
|
38
|
+
cfg = load_config()
|
|
39
|
+
if not cfg.enabled:
|
|
40
|
+
raise LLMError(
|
|
41
|
+
"AI 解读未启用。请在「AI 设置」里选择一个服务商并填写 API Key,"
|
|
42
|
+
"或改用本地 Ollama(完全离线、零成本)。")
|
|
43
|
+
return cfg
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def explain_result(result, *, cfg: AIConfig | None = None,
|
|
47
|
+
top_n: int = 5, question: str = "") -> str:
|
|
48
|
+
"""对整份分析结果生成 AI 解读。
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
result: core 层 AnalysisResult
|
|
52
|
+
cfg: 可覆盖默认配置
|
|
53
|
+
top_n: 送进上下文的错误簇数量(控制 token 成本)
|
|
54
|
+
question: 用户额外追问(可选)
|
|
55
|
+
"""
|
|
56
|
+
cfg = cfg or _require_config()
|
|
57
|
+
from log_ai_compressor import service as S
|
|
58
|
+
payload = S.result_to_dict(result, top_n=top_n, with_samples=False)
|
|
59
|
+
prompt = build_explain_prompt(payload, question=question)
|
|
60
|
+
return chat(prompt, cfg=cfg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def explain_cluster(result, cluster_id: int, *,
|
|
64
|
+
cfg: AIConfig | None = None, question: str = "") -> str:
|
|
65
|
+
"""针对单个错误簇生成 AI 解读(带典型样例与降噪堆栈)。"""
|
|
66
|
+
cfg = cfg or _require_config()
|
|
67
|
+
from log_ai_compressor import service as S
|
|
68
|
+
from log_ai_compressor.core.analysis import cooccurring_clusters
|
|
69
|
+
target = next((c for c in result.clusters if c.cluster_id == cluster_id), None)
|
|
70
|
+
if target is None:
|
|
71
|
+
raise LLMError(f"错误簇 {cluster_id} 不存在")
|
|
72
|
+
ctx = {
|
|
73
|
+
"stats": S.result_to_dict(result, with_samples=False)["stats"],
|
|
74
|
+
"cluster": S.cluster_to_dict(target, full=True),
|
|
75
|
+
"cooccurring": [
|
|
76
|
+
{"id": o.cluster_id, "hits": hits, "summary": o.summary}
|
|
77
|
+
for o, hits in cooccurring_clusters(target, result.clusters)
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
prompt = build_cluster_prompt(ctx, question=question)
|
|
81
|
+
return chat(prompt, cfg=cfg)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def explain_job(req: Dict[str, Any]) -> Dict[str, Any]:
|
|
85
|
+
"""Web 端点实现:按 job_id 找到结果 → 生成解读。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
req: {"job_id": str, "cluster_id": int|None, "question": str, ...}
|
|
89
|
+
"""
|
|
90
|
+
from log_ai_compressor.web import jobs as J
|
|
91
|
+
|
|
92
|
+
job_id = str(req.get("job_id") or "")
|
|
93
|
+
job = J.REGISTRY.get(job_id)
|
|
94
|
+
if job is None or job.result is None:
|
|
95
|
+
raise LLMError("结果不存在或已被淘汰(请重新分析)")
|
|
96
|
+
|
|
97
|
+
cluster_id = req.get("cluster_id")
|
|
98
|
+
question = str(req.get("question") or "")
|
|
99
|
+
try:
|
|
100
|
+
cfg = load_config(req.get("config") or {})
|
|
101
|
+
if cluster_id not in (None, "", 0):
|
|
102
|
+
text = explain_cluster(job.result, int(cluster_id), cfg=cfg,
|
|
103
|
+
question=question)
|
|
104
|
+
else:
|
|
105
|
+
top_n = int(req.get("top_n") or 5)
|
|
106
|
+
text = explain_result(job.result, cfg=cfg, top_n=top_n,
|
|
107
|
+
question=question)
|
|
108
|
+
except LLMError:
|
|
109
|
+
raise
|
|
110
|
+
except (TypeError, ValueError) as exc:
|
|
111
|
+
raise LLMError(f"参数错误:{exc}")
|
|
112
|
+
return {"text": text, "cluster_id": cluster_id, "config": cfg.to_public()}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""LLM 客户端:OpenAI 兼容协议 + Ollama 原生协议,只依赖 httpx。
|
|
3
|
+
|
|
4
|
+
为什么不用各家官方 SDK
|
|
5
|
+
----------------------
|
|
6
|
+
各家 SDK 语义大同小异(都是 POST /chat/completions),引进来要多 6~8 个
|
|
7
|
+
依赖,且换服务商还要改代码。统一走 OpenAI 兼容协议后,新增一家服务商
|
|
8
|
+
只是往 PROVIDERS 里加一条记录。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from log_ai_compressor.ai.config import AIConfig
|
|
17
|
+
|
|
18
|
+
# Ollama 原生接口(OpenAI 兼容层在部分版本上 /v1 不完整,原生更稳)
|
|
19
|
+
_OLLAMA_CHAT = "/api/chat"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LLMError(RuntimeError):
|
|
23
|
+
"""AI 调用失败(配置问题、网络问题、被拒答、格式异常都归这里)。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _chat_url(cfg: AIConfig) -> str:
|
|
27
|
+
if cfg.native_ollama:
|
|
28
|
+
return f"{cfg.base_url}{_OLLAMA_CHAT}"
|
|
29
|
+
return f"{cfg.base_url}/chat/completions"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _headers(cfg: AIConfig) -> Dict[str, str]:
|
|
33
|
+
headers = {"Content-Type": "application/json"}
|
|
34
|
+
if not cfg.native_ollama and cfg.api_key:
|
|
35
|
+
headers["Authorization"] = f"Bearer {cfg.api_key}"
|
|
36
|
+
return headers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _payload(prompt: str, system: str, cfg: AIConfig,
|
|
40
|
+
stream: bool = False) -> Dict[str, Any]:
|
|
41
|
+
if cfg.native_ollama:
|
|
42
|
+
return {
|
|
43
|
+
"model": cfg.model,
|
|
44
|
+
"messages": [{"role": "system", "content": system},
|
|
45
|
+
{"role": "user", "content": prompt}],
|
|
46
|
+
"stream": stream,
|
|
47
|
+
"options": {
|
|
48
|
+
"temperature": cfg.temperature,
|
|
49
|
+
"num_predict": cfg.max_tokens,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
"model": cfg.model,
|
|
54
|
+
"messages": [{"role": "system", "content": system},
|
|
55
|
+
{"role": "user", "content": prompt}],
|
|
56
|
+
"temperature": cfg.temperature,
|
|
57
|
+
"max_tokens": cfg.max_tokens,
|
|
58
|
+
"stream": stream,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_text(data: Dict[str, Any], native: bool) -> str:
|
|
63
|
+
"""两种协议的响应结构不同,统一取出正文。"""
|
|
64
|
+
if native:
|
|
65
|
+
message = data.get("message") or {}
|
|
66
|
+
text = message.get("content")
|
|
67
|
+
if isinstance(text, list): # 部分版本返回内容块数组
|
|
68
|
+
text = "".join(part.get("text", "") for part in text
|
|
69
|
+
if isinstance(part, dict))
|
|
70
|
+
return (text or "").strip()
|
|
71
|
+
choices = data.get("choices") or []
|
|
72
|
+
if not choices:
|
|
73
|
+
return ""
|
|
74
|
+
message = choices[0].get("message") or {}
|
|
75
|
+
return (message.get("content") or "").strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def chat(prompt: str, *, cfg: Optional[AIConfig] = None,
|
|
79
|
+
system: str = "", history: Optional[List[Dict[str, str]]] = None
|
|
80
|
+
) -> str:
|
|
81
|
+
"""发起一次对话并返回文本。
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
prompt: 用户提示词
|
|
85
|
+
cfg: 配置;None 时从配置文件/环境变量读
|
|
86
|
+
system: 系统提示词
|
|
87
|
+
history: 可选的历史消息 [{"role","content"}, ...]
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
LLMError: 未启用 / 网络失败 / HTTP 报错 / 响应里没有正文
|
|
91
|
+
"""
|
|
92
|
+
from log_ai_compressor.ai.config import load_config
|
|
93
|
+
from log_ai_compressor.ai.prompts import SYSTEM_ANALYST
|
|
94
|
+
|
|
95
|
+
cfg = cfg or load_config()
|
|
96
|
+
if not cfg.enabled:
|
|
97
|
+
raise LLMError(
|
|
98
|
+
"AI 解读未启用:请在「AI 设置」选择服务商并填 API Key,"
|
|
99
|
+
"或改用本地 Ollama(完全离线)。")
|
|
100
|
+
|
|
101
|
+
messages: List[Dict[str, str]] = [{"role": "system",
|
|
102
|
+
"content": system or SYSTEM_ANALYST}]
|
|
103
|
+
if history:
|
|
104
|
+
messages.extend(history)
|
|
105
|
+
messages.append({"role": "user", "content": prompt})
|
|
106
|
+
|
|
107
|
+
body = _payload(prompt, system or SYSTEM_ANALYST, cfg)
|
|
108
|
+
if history:
|
|
109
|
+
body["messages"] = messages
|
|
110
|
+
|
|
111
|
+
url = _chat_url(cfg)
|
|
112
|
+
try:
|
|
113
|
+
with httpx.Client(timeout=cfg.timeout) as client:
|
|
114
|
+
resp = client.post(url, headers=_headers(cfg), json=body)
|
|
115
|
+
except httpx.TimeoutException:
|
|
116
|
+
raise LLMError(f"请求超时({cfg.timeout:.0f}s)。本地 Ollama 场景请确认 "
|
|
117
|
+
f"模型已拉取:ollama pull {cfg.model}")
|
|
118
|
+
except httpx.ConnectError:
|
|
119
|
+
raise LLMError(f"连不上 {url}。若用本地 Ollama,请先启动:ollama serve")
|
|
120
|
+
except httpx.HTTPError as exc:
|
|
121
|
+
raise LLMError(f"网络错误:{exc}")
|
|
122
|
+
|
|
123
|
+
if resp.status_code >= 400:
|
|
124
|
+
detail = ""
|
|
125
|
+
try:
|
|
126
|
+
detail = resp.json().get("error", {}).get("message", "")
|
|
127
|
+
except ValueError:
|
|
128
|
+
detail = resp.text[:200]
|
|
129
|
+
hint = ""
|
|
130
|
+
if resp.status_code in (401, 403):
|
|
131
|
+
hint = " —— 疑似 API Key 无效或无权限"
|
|
132
|
+
elif resp.status_code == 404:
|
|
133
|
+
hint = f" —— 疑似模型名不存在(当前 {cfg.model})或 base_url 配错"
|
|
134
|
+
elif resp.status_code == 429:
|
|
135
|
+
hint = " —— 触发限流或余额不足"
|
|
136
|
+
raise LLMError(f"服务商返回 {resp.status_code}{hint}:{detail or resp.text[:200]}")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
data = resp.json()
|
|
140
|
+
except ValueError:
|
|
141
|
+
raise LLMError(f"响应不是合法 JSON:{resp.text[:200]}")
|
|
142
|
+
|
|
143
|
+
text = _extract_text(data, cfg.native_ollama)
|
|
144
|
+
if not text:
|
|
145
|
+
raise LLMError("服务商返回了空内容(可能被内容安全策略拦截或模型未响应)")
|
|
146
|
+
return text
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""AI 服务商配置:环境变量 + 用户配置文件双来源。
|
|
3
|
+
|
|
4
|
+
优先级按字段区分(详见 load_config 的 docstring):服务商/模型看配置文件,
|
|
5
|
+
Key 优先看环境变量。简单记法:**在界面上选好的东西别被环境变量改掉,
|
|
6
|
+
但 Key 仍可安全地只放在环境变量里。**
|
|
7
|
+
|
|
8
|
+
为什么配置文件放用户主目录而不是项目目录
|
|
9
|
+
--------------------------------------
|
|
10
|
+
项目目录可能被 clone 到只读位置、或多人共用的 CI 上;而 API Key 属于
|
|
11
|
+
用户个人凭据,必须跟着用户走,不该进版本库。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = Path(os.path.expanduser("~")) / ".log-ai-compressor"
|
|
22
|
+
CONFIG_FILE = CONFIG_DIR / "ai.json"
|
|
23
|
+
|
|
24
|
+
# 内置服务商:全部走 OpenAI 兼容协议,只有 Ollama 额外支持原生接口
|
|
25
|
+
PROVIDERS: Dict[str, Dict[str, Any]] = {
|
|
26
|
+
"none": {
|
|
27
|
+
"label": "不启用(仅本地分析)",
|
|
28
|
+
"base_url": "", "model": "", "env_key": "",
|
|
29
|
+
"note": "不配置任何外部服务,零出网、零成本。AI 解读不可用。",
|
|
30
|
+
},
|
|
31
|
+
"deepseek": {
|
|
32
|
+
"label": "DeepSeek(国内直连,最便宜)",
|
|
33
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
34
|
+
"model": "deepseek-chat",
|
|
35
|
+
"env_key": "DEEPSEEK_API_KEY",
|
|
36
|
+
"note": "输入约 $0.14 / 百万 tokens,中文日志理解好,推荐首选。",
|
|
37
|
+
},
|
|
38
|
+
"qwen": {
|
|
39
|
+
"label": "阿里百炼 Qwen(通义千问)",
|
|
40
|
+
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
41
|
+
"model": "qwen-plus",
|
|
42
|
+
"env_key": "DASHSCOPE_API_KEY",
|
|
43
|
+
"note": "国内节点多,企业合规友好。",
|
|
44
|
+
},
|
|
45
|
+
"glm": {
|
|
46
|
+
"label": "智谱 GLM",
|
|
47
|
+
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
|
48
|
+
"model": "glm-4-flash",
|
|
49
|
+
"env_key": "ZHIPU_API_KEY",
|
|
50
|
+
"note": "GLM-4-Flash 近乎免费,中文场景性价比高。",
|
|
51
|
+
},
|
|
52
|
+
"kimi": {
|
|
53
|
+
"label": "月之暗面 Kimi",
|
|
54
|
+
"base_url": "https://api.moonshot.cn/v1",
|
|
55
|
+
"model": "moonshot-v1-32k",
|
|
56
|
+
"env_key": "MOONSHOT_API_KEY",
|
|
57
|
+
"note": "长上下文(128K+),适合超大日志。",
|
|
58
|
+
},
|
|
59
|
+
"openai": {
|
|
60
|
+
"label": "OpenAI",
|
|
61
|
+
"base_url": "https://api.openai.com/v1",
|
|
62
|
+
"model": "gpt-4o-mini",
|
|
63
|
+
"env_key": "OPENAI_API_KEY",
|
|
64
|
+
"note": "国内访问通常需要代理。",
|
|
65
|
+
},
|
|
66
|
+
"ollama": {
|
|
67
|
+
"label": "本地 Ollama(完全离线,零成本)",
|
|
68
|
+
"base_url": "http://127.0.0.1:11434",
|
|
69
|
+
"model": "qwen2.5:7b",
|
|
70
|
+
"env_key": "",
|
|
71
|
+
"note": "数据完全不出本机,无需 API Key。需先 ollama pull 模型。",
|
|
72
|
+
},
|
|
73
|
+
"custom": {
|
|
74
|
+
"label": "自定义 OpenAI 兼容端点",
|
|
75
|
+
"base_url": "", "model": "", "env_key": "OPENAI_API_KEY",
|
|
76
|
+
"note": "任何暴露 /chat/completions 的服务都可以填进来。",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class AIConfig:
|
|
83
|
+
"""一份可直接发起对话的配置。"""
|
|
84
|
+
provider: str = "none"
|
|
85
|
+
base_url: str = ""
|
|
86
|
+
model: str = ""
|
|
87
|
+
api_key: str = ""
|
|
88
|
+
temperature: float = 0.2
|
|
89
|
+
max_tokens: int = 2000
|
|
90
|
+
timeout: float = 120.0
|
|
91
|
+
# 由 provider 推导,custom 时可强制指定
|
|
92
|
+
native_ollama: bool = False
|
|
93
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def enabled(self) -> bool:
|
|
97
|
+
if self.provider == "none" or self.provider == "":
|
|
98
|
+
return False
|
|
99
|
+
if self.provider == "ollama":
|
|
100
|
+
# 本地 Ollama 不需要 key,但要有 base_url
|
|
101
|
+
return bool(self.base_url and self.model)
|
|
102
|
+
return bool(self.base_url and self.model and self.api_key)
|
|
103
|
+
|
|
104
|
+
def masked_key(self) -> str:
|
|
105
|
+
if not self.api_key:
|
|
106
|
+
return ""
|
|
107
|
+
if len(self.api_key) <= 8:
|
|
108
|
+
return "*" * len(self.api_key)
|
|
109
|
+
return f"{self.api_key[:4]}{'*' * 6}{self.api_key[-4:]}"
|
|
110
|
+
|
|
111
|
+
def to_public(self) -> Dict[str, Any]:
|
|
112
|
+
"""给前端的表示:绝不回传明文 Key。"""
|
|
113
|
+
return {
|
|
114
|
+
"provider": self.provider,
|
|
115
|
+
"base_url": self.base_url,
|
|
116
|
+
"model": self.model,
|
|
117
|
+
"has_key": bool(self.api_key),
|
|
118
|
+
"masked_key": self.masked_key(),
|
|
119
|
+
"temperature": self.temperature,
|
|
120
|
+
"max_tokens": self.max_tokens,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_config(overrides: Optional[Dict[str, Any]] = None) -> AIConfig:
|
|
125
|
+
"""合并三层来源得到最终配置。
|
|
126
|
+
|
|
127
|
+
优先级**分字段**决定,不是统一一套:
|
|
128
|
+
|
|
129
|
+
- provider / base_url / model:显式入参 > **配置文件** > 环境变量 > 默认
|
|
130
|
+
理由:Web 界面与 CLI config 是用户最明确的操作,环境变量只是逃生舱;
|
|
131
|
+
若让环境变量压过配置文件,用户在界面上选好的服务商会被一个残留的
|
|
132
|
+
环境变量悄悄改掉。
|
|
133
|
+
- api_key:显式入参 > **环境变量** > 配置文件
|
|
134
|
+
理由:Key 是机密,最常见的用法是「界面上选好服务商与模型,Key 走
|
|
135
|
+
环境变量注入」,不该为了换个 Key 去改配置文件。
|
|
136
|
+
"""
|
|
137
|
+
stored: Dict[str, Any] = {}
|
|
138
|
+
try:
|
|
139
|
+
if CONFIG_FILE.is_file():
|
|
140
|
+
stored = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
# 配置文件损坏不该让程序起不来,退回默认即可
|
|
143
|
+
stored = {}
|
|
144
|
+
|
|
145
|
+
ov = overrides or {}
|
|
146
|
+
|
|
147
|
+
def pick(key: str, env_name: str, default: str = "") -> str:
|
|
148
|
+
value = ov.get(key)
|
|
149
|
+
if value not in (None, ""):
|
|
150
|
+
return str(value)
|
|
151
|
+
value = stored.get(key)
|
|
152
|
+
if value not in (None, ""):
|
|
153
|
+
return str(value)
|
|
154
|
+
return os.environ.get(env_name, default) or default
|
|
155
|
+
|
|
156
|
+
provider = pick("provider", "LOG_AI_PROVIDER", "none")
|
|
157
|
+
preset = PROVIDERS.get(provider, PROVIDERS["none"])
|
|
158
|
+
|
|
159
|
+
base_url = (pick("base_url", "LOG_AI_BASE_URL")
|
|
160
|
+
or preset.get("base_url") or "").rstrip("/")
|
|
161
|
+
model = pick("model", "LOG_AI_MODEL") or preset.get("model") or ""
|
|
162
|
+
|
|
163
|
+
env_key = preset.get("env_key") or ""
|
|
164
|
+
api_key = (str(ov.get("api_key") or "").strip()
|
|
165
|
+
or (os.environ.get(env_key, "").strip() if env_key else "")
|
|
166
|
+
or str(stored.get("api_key") or "").strip())
|
|
167
|
+
|
|
168
|
+
def _num(key: str, default: float) -> float:
|
|
169
|
+
raw = ov.get(key, stored.get(key, default))
|
|
170
|
+
try:
|
|
171
|
+
return float(raw)
|
|
172
|
+
except (TypeError, ValueError):
|
|
173
|
+
return default
|
|
174
|
+
|
|
175
|
+
return AIConfig(
|
|
176
|
+
provider=provider,
|
|
177
|
+
base_url=base_url,
|
|
178
|
+
model=model,
|
|
179
|
+
api_key=api_key,
|
|
180
|
+
temperature=_num("temperature", 0.2),
|
|
181
|
+
max_tokens=int(_num("max_tokens", 2000)),
|
|
182
|
+
timeout=_num("timeout", 120.0),
|
|
183
|
+
native_ollama=(provider == "ollama"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def save_config(cfg: AIConfig) -> None:
|
|
188
|
+
"""落盘到用户配置目录(不写入项目目录,避免误提交)。"""
|
|
189
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
data = asdict(cfg)
|
|
191
|
+
data.pop("native_ollama", None)
|
|
192
|
+
CONFIG_FILE.write_text(
|
|
193
|
+
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def describe_config() -> Dict[str, Any]:
|
|
197
|
+
"""给 /api/health 与前端设置面板用。"""
|
|
198
|
+
cfg = load_config()
|
|
199
|
+
return {
|
|
200
|
+
"available": cfg.enabled,
|
|
201
|
+
"config": cfg.to_public(),
|
|
202
|
+
"config_file": str(CONFIG_FILE),
|
|
203
|
+
"providers": [
|
|
204
|
+
{"key": key,
|
|
205
|
+
"label": meta.get("label", key),
|
|
206
|
+
"note": meta.get("note", ""),
|
|
207
|
+
"env_key": meta.get("env_key", ""),
|
|
208
|
+
"needs_key": bool(meta.get("env_key")) and key != "ollama"}
|
|
209
|
+
for key, meta in PROVIDERS.items()
|
|
210
|
+
],
|
|
211
|
+
"reason": "" if cfg.enabled else (
|
|
212
|
+
"未配置服务商 —— 聚类/根因/异常检测等本地能力不受影响,仅 AI 解读不可用。"
|
|
213
|
+
),
|
|
214
|
+
}
|