devops-analyzer 0.1.0__cp312-cp312-win_amd64.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.
- devops/__init__.py +3 -0
- devops/__main__.py +3 -0
- devops/analysis.py +234 -0
- devops/cli.py +190 -0
- devops/config.py +159 -0
- devops/config_cmd.py +88 -0
- devops/extract.py +215 -0
- devops/models/__init__.py +50 -0
- devops/models/base.py +16 -0
- devops/models/openai.py +30 -0
- devops/models/zhipuai.py +28 -0
- devops/scan/__init__.py +3 -0
- devops/scan/scan.cpp +121 -0
- devops/scan/scan.h +7 -0
- devops/scan/scan_bindings.cpp +15 -0
- devops/scan/scan_native.cp312-win_amd64.pyd +0 -0
- devops_analyzer-0.1.0.dist-info/METADATA +95 -0
- devops_analyzer-0.1.0.dist-info/RECORD +22 -0
- devops_analyzer-0.1.0.dist-info/WHEEL +5 -0
- devops_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
- devops_analyzer-0.1.0.dist-info/licenses/LICENSE +21 -0
- devops_analyzer-0.1.0.dist-info/top_level.txt +1 -0
devops/__init__.py
ADDED
devops/__main__.py
ADDED
devops/analysis.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""调用大模型 API 分析 extract 提取的代码块。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from devops.extract import CodeBlock, extract_blocks_from_paths
|
|
11
|
+
from devops.models import BaseChatModel, get_chat_model
|
|
12
|
+
|
|
13
|
+
MAX_CODE_LINES = 80
|
|
14
|
+
MAX_CHARS_PER_REQUEST = 24_000
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT = """你是一名资深代码审查专家。
|
|
17
|
+
请根据给出的代码块,输出简洁、结构化的中文分析,包含:
|
|
18
|
+
1. 文件/模块职责概述
|
|
19
|
+
2. 主要函数/类的作用
|
|
20
|
+
3. 潜在问题或改进建议(如有)
|
|
21
|
+
4. 关键依赖或调用关系(如能看出)
|
|
22
|
+
|
|
23
|
+
直接输出分析正文,不要重复粘贴完整源码。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _truncate_code(code: str, max_lines: int = MAX_CODE_LINES) -> str:
|
|
27
|
+
lines = code.splitlines()
|
|
28
|
+
if len(lines) <= max_lines:
|
|
29
|
+
return code
|
|
30
|
+
head = "\n".join(lines[:max_lines])
|
|
31
|
+
return f"{head}\n# ... (省略 {len(lines) - max_lines} 行)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_blocks_for_prompt(blocks: list[CodeBlock]) -> str:
|
|
35
|
+
parts: list[str] = []
|
|
36
|
+
for i, block in enumerate(blocks, 1):
|
|
37
|
+
title = block.name or "(anonymous)"
|
|
38
|
+
parts.append(
|
|
39
|
+
f"### 块 {i}: {block.block_type} `{title}` "
|
|
40
|
+
f"(L{block.start_line}-L{block.end_line})\n"
|
|
41
|
+
f"```{block.language}\n{_truncate_code(block.code)}\n```"
|
|
42
|
+
)
|
|
43
|
+
return "\n\n".join(parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _chat(model: BaseChatModel, prompt: str) -> str:
|
|
47
|
+
return model.chat(SYSTEM_PROMPT, prompt, temperature=0.2)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def analyze_blocks(
|
|
51
|
+
blocks: list[CodeBlock],
|
|
52
|
+
*,
|
|
53
|
+
provider: str | None = None,
|
|
54
|
+
api_key: str | None = None,
|
|
55
|
+
model_name: str | None = None,
|
|
56
|
+
base_url: str | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""按文件分批调用大模型,并生成总览。"""
|
|
59
|
+
chat_model = get_chat_model(
|
|
60
|
+
provider=provider,
|
|
61
|
+
api_key=api_key,
|
|
62
|
+
model=model_name,
|
|
63
|
+
base_url=base_url,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if not blocks:
|
|
67
|
+
return {
|
|
68
|
+
"summary": "未提取到任何代码块,请确认目录内有支持的源码文件。",
|
|
69
|
+
"files": [],
|
|
70
|
+
"blocks_count": 0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
by_file: dict[str, list[CodeBlock]] = defaultdict(list)
|
|
74
|
+
for block in blocks:
|
|
75
|
+
by_file[block.file_path].append(block)
|
|
76
|
+
|
|
77
|
+
per_file: list[dict[str, Any]] = []
|
|
78
|
+
file_summaries: list[str] = []
|
|
79
|
+
|
|
80
|
+
for file_path, file_blocks in sorted(by_file.items()):
|
|
81
|
+
body = _format_blocks_for_prompt(file_blocks)
|
|
82
|
+
if len(body) > MAX_CHARS_PER_REQUEST:
|
|
83
|
+
body = body[:MAX_CHARS_PER_REQUEST] + "\n\n...(内容过长已截断)"
|
|
84
|
+
|
|
85
|
+
prompt = (
|
|
86
|
+
f"文件路径: {file_path}\n"
|
|
87
|
+
f"语言: {file_blocks[0].language}\n"
|
|
88
|
+
f"代码块数量: {len(file_blocks)}\n\n"
|
|
89
|
+
f"{body}"
|
|
90
|
+
)
|
|
91
|
+
analysis = _chat(chat_model, prompt)
|
|
92
|
+
per_file.append(
|
|
93
|
+
{
|
|
94
|
+
"file_path": file_path,
|
|
95
|
+
"blocks_count": len(file_blocks),
|
|
96
|
+
"analysis": analysis,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
file_summaries.append(f"【{file_path}】\n{analysis}")
|
|
100
|
+
|
|
101
|
+
overview_prompt = (
|
|
102
|
+
"以下是多个源文件的逐项分析,请生成一份项目级总览(中文):\n"
|
|
103
|
+
"1. 项目整体结构\n"
|
|
104
|
+
"2. 各文件之间的关系\n"
|
|
105
|
+
"3. 共性问题或架构建议\n\n"
|
|
106
|
+
+ "\n\n---\n\n".join(file_summaries)
|
|
107
|
+
)
|
|
108
|
+
if len(overview_prompt) > MAX_CHARS_PER_REQUEST:
|
|
109
|
+
overview_prompt = overview_prompt[:MAX_CHARS_PER_REQUEST] + "\n...(已截断)"
|
|
110
|
+
|
|
111
|
+
summary = (
|
|
112
|
+
_chat(chat_model, overview_prompt)
|
|
113
|
+
if len(per_file) > 1
|
|
114
|
+
else per_file[0]["analysis"]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"summary": summary,
|
|
119
|
+
"files": per_file,
|
|
120
|
+
"blocks_count": len(blocks),
|
|
121
|
+
"files_count": len(per_file),
|
|
122
|
+
"model_provider": chat_model.config.provider,
|
|
123
|
+
"model_name": chat_model.config.model,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def analyze_files(
|
|
128
|
+
paths: list[str],
|
|
129
|
+
*,
|
|
130
|
+
provider: str | None = None,
|
|
131
|
+
api_key: str | None = None,
|
|
132
|
+
model_name: str | None = None,
|
|
133
|
+
base_url: str | None = None,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""从文件路径列表提取代码块并分析。"""
|
|
136
|
+
blocks = extract_blocks_from_paths(paths)
|
|
137
|
+
return analyze_blocks(
|
|
138
|
+
blocks,
|
|
139
|
+
provider=provider,
|
|
140
|
+
api_key=api_key,
|
|
141
|
+
model_name=model_name,
|
|
142
|
+
base_url=base_url,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def analyze_folder(
|
|
147
|
+
folder: str,
|
|
148
|
+
*,
|
|
149
|
+
provider: str | None = None,
|
|
150
|
+
api_key: str | None = None,
|
|
151
|
+
model_name: str | None = None,
|
|
152
|
+
base_url: str | None = None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
"""扫描目录、提取代码块并分析。"""
|
|
155
|
+
from devops.scan import scan_code_files
|
|
156
|
+
|
|
157
|
+
folder_path = Path(folder).resolve()
|
|
158
|
+
paths = scan_code_files(str(folder_path))
|
|
159
|
+
result = analyze_files(
|
|
160
|
+
paths,
|
|
161
|
+
provider=provider,
|
|
162
|
+
api_key=api_key,
|
|
163
|
+
model_name=model_name,
|
|
164
|
+
base_url=base_url,
|
|
165
|
+
)
|
|
166
|
+
result["folder"] = str(folder_path)
|
|
167
|
+
result["project_name"] = folder_path.name
|
|
168
|
+
result["scanned_files_count"] = len(paths)
|
|
169
|
+
result["generated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_readme(result: dict[str, Any]) -> str:
|
|
174
|
+
"""将分析结果渲染为 README Markdown 正文。"""
|
|
175
|
+
project = result.get("project_name") or Path(result.get("folder", ".")).name
|
|
176
|
+
folder = result.get("folder", "")
|
|
177
|
+
generated_at = result.get("generated_at", "")
|
|
178
|
+
scanned = result.get("scanned_files_count", 0)
|
|
179
|
+
blocks = result.get("blocks_count", 0)
|
|
180
|
+
files_count = result.get("files_count", 0)
|
|
181
|
+
summary = result.get("summary", "").strip()
|
|
182
|
+
per_file: list[dict[str, Any]] = result.get("files", [])
|
|
183
|
+
|
|
184
|
+
lines: list[str] = [
|
|
185
|
+
f"# {project}",
|
|
186
|
+
"",
|
|
187
|
+
"> 本文档由代码分析工具自动生成。",
|
|
188
|
+
"",
|
|
189
|
+
"## 概览",
|
|
190
|
+
"",
|
|
191
|
+
summary or "_暂无分析内容。_",
|
|
192
|
+
"",
|
|
193
|
+
"## 统计",
|
|
194
|
+
"",
|
|
195
|
+
"| 指标 | 数值 |",
|
|
196
|
+
"| --- | --- |",
|
|
197
|
+
f"| 项目路径 | `{folder}` |",
|
|
198
|
+
f"| 扫描文件数 | {scanned} |",
|
|
199
|
+
f"| 提取代码块数 | {blocks} |",
|
|
200
|
+
f"| 已分析文件数 | {files_count} |",
|
|
201
|
+
f"| 生成时间 | {generated_at} |",
|
|
202
|
+
f"| 分析模型 | {result.get('model_provider', '')} / {result.get('model_name', '')} |",
|
|
203
|
+
"",
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
if per_file:
|
|
207
|
+
lines.extend(["## 文件分析", ""])
|
|
208
|
+
for item in per_file:
|
|
209
|
+
rel = item.get("file_path", "")
|
|
210
|
+
if folder and rel.startswith(folder):
|
|
211
|
+
rel = rel[len(folder) :].lstrip("/\\")
|
|
212
|
+
count = item.get("blocks_count", 0)
|
|
213
|
+
analysis = (item.get("analysis") or "").strip()
|
|
214
|
+
lines.append(f"### `{rel}`")
|
|
215
|
+
lines.append("")
|
|
216
|
+
lines.append(f"- **代码块数量**: {count}")
|
|
217
|
+
lines.append("")
|
|
218
|
+
lines.append(analysis or "_无分析结果。_")
|
|
219
|
+
lines.append("")
|
|
220
|
+
|
|
221
|
+
lines.append("---")
|
|
222
|
+
lines.append("")
|
|
223
|
+
lines.append("*Generated by Devops code analyzer*")
|
|
224
|
+
lines.append("")
|
|
225
|
+
return "\n".join(lines)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def write_project_readme(folder: str, result: dict[str, Any], filename: str = "README.md") -> Path:
|
|
229
|
+
"""将 Markdown 写入目标项目目录下的 README.md。"""
|
|
230
|
+
folder_path = Path(folder).resolve()
|
|
231
|
+
output_path = folder_path / filename
|
|
232
|
+
markdown = render_readme(result)
|
|
233
|
+
output_path.write_text(markdown, encoding="utf-8")
|
|
234
|
+
return output_path
|
devops/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""终端入口:devops <项目路径> | devops config"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from devops import __version__
|
|
10
|
+
from devops.analysis import analyze_folder, write_project_readme
|
|
11
|
+
from devops.config import config_exists
|
|
12
|
+
from devops.config_cmd import run_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="devops",
|
|
18
|
+
description="扫描项目源码,提取代码块,调用大模型分析并生成 README.md",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"-V",
|
|
22
|
+
"--version",
|
|
23
|
+
action="version",
|
|
24
|
+
version=f"%(prog)s {__version__}",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
28
|
+
|
|
29
|
+
config_parser = subparsers.add_parser(
|
|
30
|
+
"config",
|
|
31
|
+
help="配置模型 API(首次使用前必须执行)",
|
|
32
|
+
)
|
|
33
|
+
config_parser.add_argument(
|
|
34
|
+
"-s",
|
|
35
|
+
"--show",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="查看当前配置(API Key 脱敏)",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
analyze_parser = subparsers.add_parser(
|
|
41
|
+
"analyze",
|
|
42
|
+
help="分析项目并生成 README",
|
|
43
|
+
)
|
|
44
|
+
analyze_parser.add_argument(
|
|
45
|
+
"project",
|
|
46
|
+
nargs="?",
|
|
47
|
+
help="要分析的项目目录路径",
|
|
48
|
+
)
|
|
49
|
+
analyze_parser.add_argument(
|
|
50
|
+
"-o",
|
|
51
|
+
"--output",
|
|
52
|
+
default="README.md",
|
|
53
|
+
help="输出 Markdown 文件名(默认: README.md)",
|
|
54
|
+
)
|
|
55
|
+
analyze_parser.add_argument(
|
|
56
|
+
"--provider",
|
|
57
|
+
choices=["openai", "zhipuai", "deepseek", "moonshot"],
|
|
58
|
+
help="覆盖配置文件中的模型提供商",
|
|
59
|
+
)
|
|
60
|
+
analyze_parser.add_argument(
|
|
61
|
+
"--model",
|
|
62
|
+
dest="model_name",
|
|
63
|
+
help="覆盖配置文件中的模型名称",
|
|
64
|
+
)
|
|
65
|
+
analyze_parser.add_argument(
|
|
66
|
+
"--api-key",
|
|
67
|
+
help="临时指定 API Key(不推荐,会留在 shell 历史)",
|
|
68
|
+
)
|
|
69
|
+
analyze_parser.add_argument(
|
|
70
|
+
"--base-url",
|
|
71
|
+
help="临时指定 API Base URL(OpenAI 兼容接口)",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 兼容: devops /path/to/project(无子命令)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"project_legacy",
|
|
77
|
+
nargs="?",
|
|
78
|
+
help=argparse.SUPPRESS,
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"-o",
|
|
82
|
+
"--output",
|
|
83
|
+
default="README.md",
|
|
84
|
+
help=argparse.SUPPRESS,
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--provider",
|
|
88
|
+
choices=["openai", "zhipuai", "deepseek", "moonshot"],
|
|
89
|
+
help=argparse.SUPPRESS,
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--model",
|
|
93
|
+
dest="model_name",
|
|
94
|
+
help=argparse.SUPPRESS,
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--api-key",
|
|
98
|
+
help=argparse.SUPPRESS,
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--base-url",
|
|
102
|
+
help=argparse.SUPPRESS,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return parser
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run_analyze(args: argparse.Namespace) -> int:
|
|
109
|
+
folder = getattr(args, "project", None) or getattr(args, "project_legacy", None)
|
|
110
|
+
if not folder:
|
|
111
|
+
folder = input("请输入项目文件夹路径: ").strip()
|
|
112
|
+
if not folder:
|
|
113
|
+
print("错误: 未提供项目路径", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
if not config_exists() and not args.api_key:
|
|
117
|
+
print(
|
|
118
|
+
"错误: 尚未配置模型 API。请先运行:\n\n"
|
|
119
|
+
" devops config\n",
|
|
120
|
+
file=sys.stderr,
|
|
121
|
+
)
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
folder_path = Path(folder).expanduser().resolve()
|
|
125
|
+
if not folder_path.is_dir():
|
|
126
|
+
print(f"错误: 不是有效目录: {folder_path}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
from devops.config import load_config
|
|
131
|
+
|
|
132
|
+
cfg = load_config(
|
|
133
|
+
provider=args.provider,
|
|
134
|
+
api_key=args.api_key,
|
|
135
|
+
model=args.model_name,
|
|
136
|
+
base_url=args.base_url,
|
|
137
|
+
)
|
|
138
|
+
print(
|
|
139
|
+
f"正在分析: {folder_path} "
|
|
140
|
+
f"[{cfg.provider} / {cfg.model}]"
|
|
141
|
+
)
|
|
142
|
+
result = analyze_folder(
|
|
143
|
+
str(folder_path),
|
|
144
|
+
provider=args.provider,
|
|
145
|
+
api_key=args.api_key,
|
|
146
|
+
model_name=args.model_name,
|
|
147
|
+
base_url=args.base_url,
|
|
148
|
+
)
|
|
149
|
+
output = write_project_readme(
|
|
150
|
+
str(folder_path), result, filename=args.output
|
|
151
|
+
)
|
|
152
|
+
print(f"已生成: {output}")
|
|
153
|
+
return 0
|
|
154
|
+
except KeyboardInterrupt:
|
|
155
|
+
print("\n已取消", file=sys.stderr)
|
|
156
|
+
return 130
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
print(f"错误: {exc}", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: list[str] | None = None) -> int:
|
|
163
|
+
argv = list(argv) if argv is not None else sys.argv[1:]
|
|
164
|
+
|
|
165
|
+
# devops /path/to/project -> 自动当作 analyze
|
|
166
|
+
if argv and not argv[0].startswith("-") and argv[0] not in ("config", "analyze"):
|
|
167
|
+
argv = ["analyze", *argv]
|
|
168
|
+
|
|
169
|
+
parser = build_parser()
|
|
170
|
+
args = parser.parse_args(argv)
|
|
171
|
+
|
|
172
|
+
if args.command == "config":
|
|
173
|
+
extra = ["--show"] if args.show else []
|
|
174
|
+
return run_config(extra)
|
|
175
|
+
|
|
176
|
+
if args.command == "analyze" or args.project_legacy is not None:
|
|
177
|
+
return _run_analyze(args)
|
|
178
|
+
|
|
179
|
+
parser.print_help()
|
|
180
|
+
print(
|
|
181
|
+
"\n快速开始:\n"
|
|
182
|
+
" 1. devops config # 首次配置 API\n"
|
|
183
|
+
" 2. devops /path/to/project\n",
|
|
184
|
+
file=sys.stderr,
|
|
185
|
+
)
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
raise SystemExit(main())
|
devops/config.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""用户模型 API 配置(~/.devops/config.json)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path.home() / ".devops"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
13
|
+
|
|
14
|
+
# provider -> (默认模型, 环境变量 API Key 备选名, 环境变量 Base URL 备选名)
|
|
15
|
+
PROVIDER_DEFAULTS: dict[str, dict[str, Any]] = {
|
|
16
|
+
"openai": {
|
|
17
|
+
"model": "gpt-4o-mini",
|
|
18
|
+
"base_url": None,
|
|
19
|
+
"api_key_env": ["DEVOPS_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY"],
|
|
20
|
+
"base_url_env": ["DEVOPS_BASE_URL", "OPENAI_BASE_URL", "LLM_BASE_URL"],
|
|
21
|
+
"model_env": ["DEVOPS_MODEL", "OPENAI_MODEL", "LLM_MODEL"],
|
|
22
|
+
},
|
|
23
|
+
"zhipuai": {
|
|
24
|
+
"model": "glm-4-flash",
|
|
25
|
+
"base_url": None,
|
|
26
|
+
"api_key_env": ["DEVOPS_API_KEY", "ZHIPUAI_API_KEY"],
|
|
27
|
+
"base_url_env": ["DEVOPS_BASE_URL"],
|
|
28
|
+
"model_env": ["DEVOPS_MODEL", "ZHIPUAI_MODEL"],
|
|
29
|
+
},
|
|
30
|
+
"deepseek": {
|
|
31
|
+
"model": "deepseek-chat",
|
|
32
|
+
"base_url": "https://api.deepseek.com",
|
|
33
|
+
"api_key_env": ["DEVOPS_API_KEY", "DEEPSEEK_API_KEY"],
|
|
34
|
+
"base_url_env": ["DEVOPS_BASE_URL", "DEEPSEEK_BASE_URL"],
|
|
35
|
+
"model_env": ["DEVOPS_MODEL", "DEEPSEEK_MODEL"],
|
|
36
|
+
},
|
|
37
|
+
"moonshot": {
|
|
38
|
+
"model": "moonshot-v1-8k",
|
|
39
|
+
"base_url": "https://api.moonshot.cn/v1",
|
|
40
|
+
"api_key_env": ["DEVOPS_API_KEY", "MOONSHOT_API_KEY"],
|
|
41
|
+
"base_url_env": ["DEVOPS_BASE_URL", "MOONSHOT_BASE_URL"],
|
|
42
|
+
"model_env": ["DEVOPS_MODEL", "MOONSHOT_MODEL"],
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ModelConfig:
|
|
49
|
+
provider: str
|
|
50
|
+
api_key: str
|
|
51
|
+
model: str
|
|
52
|
+
base_url: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
def masked(self) -> dict[str, Any]:
|
|
55
|
+
key = self.api_key
|
|
56
|
+
if len(key) > 8:
|
|
57
|
+
masked_key = f"{key[:4]}...{key[-4:]}"
|
|
58
|
+
else:
|
|
59
|
+
masked_key = "****"
|
|
60
|
+
return {
|
|
61
|
+
"provider": self.provider,
|
|
62
|
+
"api_key": masked_key,
|
|
63
|
+
"model": self.model,
|
|
64
|
+
"base_url": self.base_url or "(默认)",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _first_env(names: list[str]) -> Optional[str]:
|
|
69
|
+
for name in names:
|
|
70
|
+
value = os.environ.get(name, "").strip()
|
|
71
|
+
if value:
|
|
72
|
+
return value
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_providers() -> list[str]:
|
|
77
|
+
return list(PROVIDER_DEFAULTS.keys())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def config_exists() -> bool:
|
|
81
|
+
return CONFIG_FILE.is_file()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_config_file() -> Optional[dict[str, Any]]:
|
|
85
|
+
if not CONFIG_FILE.is_file():
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
89
|
+
except (json.JSONDecodeError, OSError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save_config(config: ModelConfig) -> Path:
|
|
94
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
data = asdict(config)
|
|
96
|
+
CONFIG_FILE.write_text(
|
|
97
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
CONFIG_FILE.chmod(0o600)
|
|
101
|
+
return CONFIG_FILE
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_config(
|
|
105
|
+
*,
|
|
106
|
+
provider: Optional[str] = None,
|
|
107
|
+
api_key: Optional[str] = None,
|
|
108
|
+
model: Optional[str] = None,
|
|
109
|
+
base_url: Optional[str] = None,
|
|
110
|
+
) -> ModelConfig:
|
|
111
|
+
"""加载配置:CLI 参数 > 配置文件 > 环境变量。"""
|
|
112
|
+
file_data = load_config_file() or {}
|
|
113
|
+
|
|
114
|
+
resolved_provider = (
|
|
115
|
+
provider
|
|
116
|
+
or os.environ.get("DEVOPS_PROVIDER", "").strip()
|
|
117
|
+
or file_data.get("provider", "").strip()
|
|
118
|
+
or "openai"
|
|
119
|
+
).lower()
|
|
120
|
+
|
|
121
|
+
if resolved_provider not in PROVIDER_DEFAULTS:
|
|
122
|
+
supported = ", ".join(list_providers())
|
|
123
|
+
raise ValueError(f"不支持的模型提供商: {resolved_provider},可选: {supported}")
|
|
124
|
+
|
|
125
|
+
defaults = PROVIDER_DEFAULTS[resolved_provider]
|
|
126
|
+
|
|
127
|
+
resolved_api_key = api_key or file_data.get("api_key", "").strip()
|
|
128
|
+
if not resolved_api_key:
|
|
129
|
+
resolved_api_key = _first_env(defaults["api_key_env"]) or ""
|
|
130
|
+
if not resolved_api_key:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"未配置 API Key。请先运行: devops config\n"
|
|
133
|
+
f"或设置环境变量: {defaults['api_key_env'][0]}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
resolved_model = (
|
|
137
|
+
model
|
|
138
|
+
or file_data.get("model", "").strip()
|
|
139
|
+
or _first_env(defaults["model_env"])
|
|
140
|
+
or defaults["model"]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
resolved_base_url = base_url
|
|
144
|
+
if resolved_base_url is None:
|
|
145
|
+
file_base = file_data.get("base_url")
|
|
146
|
+
resolved_base_url = (
|
|
147
|
+
file_base.strip() if isinstance(file_base, str) and file_base.strip() else None
|
|
148
|
+
)
|
|
149
|
+
if resolved_base_url is None:
|
|
150
|
+
resolved_base_url = _first_env(defaults["base_url_env"])
|
|
151
|
+
if resolved_base_url is None:
|
|
152
|
+
resolved_base_url = defaults["base_url"]
|
|
153
|
+
|
|
154
|
+
return ModelConfig(
|
|
155
|
+
provider=resolved_provider,
|
|
156
|
+
api_key=resolved_api_key,
|
|
157
|
+
model=resolved_model,
|
|
158
|
+
base_url=resolved_base_url,
|
|
159
|
+
)
|
devops/config_cmd.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""devops config 子命令:交互式配置模型 API。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from devops.config import (
|
|
9
|
+
CONFIG_FILE,
|
|
10
|
+
ModelConfig,
|
|
11
|
+
list_providers,
|
|
12
|
+
load_config,
|
|
13
|
+
save_config,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _prompt(label: str, default: str = "") -> str:
|
|
18
|
+
if default:
|
|
19
|
+
value = input(f"{label} [{default}]: ").strip()
|
|
20
|
+
return value or default
|
|
21
|
+
return input(f"{label}: ").strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_config_show() -> int:
|
|
25
|
+
try:
|
|
26
|
+
config = load_config()
|
|
27
|
+
except RuntimeError as exc:
|
|
28
|
+
print(str(exc), file=sys.stderr)
|
|
29
|
+
print(f"\n配置文件路径: {CONFIG_FILE}", file=sys.stderr)
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
print("当前模型配置:")
|
|
33
|
+
for key, value in config.masked().items():
|
|
34
|
+
print(f" {key}: {value}")
|
|
35
|
+
print(f"\n配置文件: {CONFIG_FILE}")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_config_interactive() -> int:
|
|
40
|
+
providers = list_providers()
|
|
41
|
+
print("支持的模型提供商:")
|
|
42
|
+
for i, name in enumerate(providers, 1):
|
|
43
|
+
print(f" {i}. {name}")
|
|
44
|
+
print()
|
|
45
|
+
|
|
46
|
+
provider = _prompt("提供商 (openai/zhipuai/deepseek/moonshot)", "openai").lower()
|
|
47
|
+
if provider not in providers:
|
|
48
|
+
print(f"错误: 未知提供商 {provider}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
api_key = getpass.getpass("API Key (输入不可见): ").strip()
|
|
52
|
+
if not api_key:
|
|
53
|
+
print("错误: API Key 不能为空", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
default_models = {
|
|
57
|
+
"openai": "gpt-4o-mini",
|
|
58
|
+
"zhipuai": "glm-4-flash",
|
|
59
|
+
"deepseek": "deepseek-chat",
|
|
60
|
+
"moonshot": "moonshot-v1-8k",
|
|
61
|
+
}
|
|
62
|
+
model = _prompt("模型名称", default_models.get(provider, "gpt-4o-mini"))
|
|
63
|
+
|
|
64
|
+
base_url = ""
|
|
65
|
+
if provider in ("openai",):
|
|
66
|
+
base_url = _prompt("Base URL (留空=官方 OpenAI)", "")
|
|
67
|
+
elif provider in ("deepseek", "moonshot"):
|
|
68
|
+
from devops.config import PROVIDER_DEFAULTS
|
|
69
|
+
default_url = PROVIDER_DEFAULTS[provider]["base_url"] or ""
|
|
70
|
+
base_url = _prompt("Base URL", default_url)
|
|
71
|
+
|
|
72
|
+
config = ModelConfig(
|
|
73
|
+
provider=provider,
|
|
74
|
+
api_key=api_key,
|
|
75
|
+
model=model,
|
|
76
|
+
base_url=base_url or None,
|
|
77
|
+
)
|
|
78
|
+
path = save_config(config)
|
|
79
|
+
print(f"\n配置已保存: {path}")
|
|
80
|
+
print("现在可以运行: devops /path/to/your/project")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_config(argv: list[str] | None = None) -> int:
|
|
85
|
+
args = argv or []
|
|
86
|
+
if "--show" in args or "-s" in args:
|
|
87
|
+
return run_config_show()
|
|
88
|
+
return run_config_interactive()
|
devops/extract.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""用 Tree-sitter 从源码文件中提取代码块。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterator, Optional
|
|
8
|
+
|
|
9
|
+
from tree_sitter import Node, Tree
|
|
10
|
+
from tree_sitter_languages import get_parser
|
|
11
|
+
|
|
12
|
+
from devops.scan import scan_code_files
|
|
13
|
+
|
|
14
|
+
EXT_TO_LANGUAGE: dict[str, str] = {
|
|
15
|
+
".py": "python",
|
|
16
|
+
".pyw": "python",
|
|
17
|
+
".js": "javascript",
|
|
18
|
+
".mjs": "javascript",
|
|
19
|
+
".cjs": "javascript",
|
|
20
|
+
".jsx": "javascript",
|
|
21
|
+
".ts": "typescript",
|
|
22
|
+
".tsx": "typescript",
|
|
23
|
+
".cpp": "cpp",
|
|
24
|
+
".cc": "cpp",
|
|
25
|
+
".cxx": "cpp",
|
|
26
|
+
".h": "cpp",
|
|
27
|
+
".hh": "cpp",
|
|
28
|
+
".hpp": "cpp",
|
|
29
|
+
".hxx": "cpp",
|
|
30
|
+
".c": "c",
|
|
31
|
+
".java": "java",
|
|
32
|
+
".go": "go",
|
|
33
|
+
".rs": "rust",
|
|
34
|
+
".rb": "ruby",
|
|
35
|
+
".php": "php",
|
|
36
|
+
".cs": "c_sharp",
|
|
37
|
+
".swift": "swift",
|
|
38
|
+
".kt": "kotlin",
|
|
39
|
+
".scala": "scala",
|
|
40
|
+
".sh": "bash",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
LANGUAGE_NODE_TYPES: dict[str, tuple[str, ...]] = {
|
|
44
|
+
"python": ("function_definition", "async_function_definition", "class_definition"),
|
|
45
|
+
"javascript": (
|
|
46
|
+
"function_declaration",
|
|
47
|
+
"class_declaration",
|
|
48
|
+
"method_definition",
|
|
49
|
+
),
|
|
50
|
+
"typescript": (
|
|
51
|
+
"function_declaration",
|
|
52
|
+
"class_declaration",
|
|
53
|
+
"method_definition",
|
|
54
|
+
"interface_declaration",
|
|
55
|
+
),
|
|
56
|
+
"cpp": ("function_definition", "class_specifier", "struct_specifier", "namespace_definition"),
|
|
57
|
+
"c": ("function_definition", "struct_specifier"),
|
|
58
|
+
"java": ("method_declaration", "class_declaration", "interface_declaration"),
|
|
59
|
+
"go": ("function_declaration", "method_declaration", "type_declaration"),
|
|
60
|
+
"rust": ("function_item", "impl_item", "struct_item", "enum_item", "trait_item"),
|
|
61
|
+
"ruby": ("method", "class", "module"),
|
|
62
|
+
"php": ("function_definition", "method_declaration", "class_declaration"),
|
|
63
|
+
"c_sharp": ("method_declaration", "class_declaration", "interface_declaration"),
|
|
64
|
+
"kotlin": ("function_declaration", "class_declaration"),
|
|
65
|
+
"scala": ("function_definition", "class_definition", "object_definition"),
|
|
66
|
+
"bash": ("function_definition",),
|
|
67
|
+
"swift": ("function_declaration", "class_declaration", "struct_declaration"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_parser_cache: dict[str, object] = {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class CodeBlock:
|
|
75
|
+
file_path: str
|
|
76
|
+
language: str
|
|
77
|
+
block_type: str
|
|
78
|
+
name: str
|
|
79
|
+
start_line: int
|
|
80
|
+
end_line: int
|
|
81
|
+
code: str
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return asdict(self)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def language_for_path(path: str | Path) -> Optional[str]:
|
|
88
|
+
return EXT_TO_LANGUAGE.get(Path(path).suffix.lower())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_parser(language: str):
|
|
92
|
+
if language not in _parser_cache:
|
|
93
|
+
_parser_cache[language] = get_parser(language)
|
|
94
|
+
return _parser_cache[language]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_source(path: Path) -> Optional[bytes]:
|
|
98
|
+
try:
|
|
99
|
+
return path.read_bytes()
|
|
100
|
+
except OSError:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _line_number(source: bytes, byte_offset: int) -> int:
|
|
105
|
+
return source[:byte_offset].count(b"\n") + 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _declarator_name(node: Node) -> str:
|
|
109
|
+
inner = node.child_by_field_name("declarator")
|
|
110
|
+
if inner is not None:
|
|
111
|
+
return _declarator_name(inner)
|
|
112
|
+
if node.type in ("identifier", "field_identifier", "operator_name"):
|
|
113
|
+
return node.text.decode("utf-8", errors="replace")
|
|
114
|
+
for child in node.children:
|
|
115
|
+
name = _declarator_name(child)
|
|
116
|
+
if name:
|
|
117
|
+
return name
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _node_name(node: Node, language: str) -> str:
|
|
122
|
+
name_node = node.child_by_field_name("name")
|
|
123
|
+
if name_node is not None:
|
|
124
|
+
return name_node.text.decode("utf-8", errors="replace")
|
|
125
|
+
|
|
126
|
+
if language == "python" and node.type == "decorated_definition":
|
|
127
|
+
for child in node.children:
|
|
128
|
+
if child.type in ("function_definition", "class_definition"):
|
|
129
|
+
return _node_name(child, language)
|
|
130
|
+
|
|
131
|
+
if language == "cpp" and node.type == "function_definition":
|
|
132
|
+
decl = node.child_by_field_name("declarator")
|
|
133
|
+
if decl is not None:
|
|
134
|
+
return _declarator_name(decl)
|
|
135
|
+
|
|
136
|
+
for child in node.children:
|
|
137
|
+
if child.type in ("identifier", "type_identifier", "property_identifier"):
|
|
138
|
+
text = child.text.decode("utf-8", errors="replace")
|
|
139
|
+
if text not in ("def", "class", "fn", "function"):
|
|
140
|
+
return text
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _walk_nodes(node: Node) -> Iterator[Node]:
|
|
145
|
+
yield node
|
|
146
|
+
for child in node.children:
|
|
147
|
+
yield from _walk_nodes(child)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_from_tree(
|
|
151
|
+
tree: Tree,
|
|
152
|
+
source: bytes,
|
|
153
|
+
file_path: str,
|
|
154
|
+
language: str,
|
|
155
|
+
) -> list[CodeBlock]:
|
|
156
|
+
target_types = set(LANGUAGE_NODE_TYPES.get(language, ()))
|
|
157
|
+
if not target_types:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
blocks: list[CodeBlock] = []
|
|
161
|
+
seen: set[tuple[int, int]] = set()
|
|
162
|
+
|
|
163
|
+
for node in _walk_nodes(tree.root_node):
|
|
164
|
+
if node.type not in target_types:
|
|
165
|
+
continue
|
|
166
|
+
key = (node.start_byte, node.end_byte)
|
|
167
|
+
if key in seen:
|
|
168
|
+
continue
|
|
169
|
+
seen.add(key)
|
|
170
|
+
|
|
171
|
+
blocks.append(
|
|
172
|
+
CodeBlock(
|
|
173
|
+
file_path=file_path,
|
|
174
|
+
language=language,
|
|
175
|
+
block_type=node.type,
|
|
176
|
+
name=_node_name(node, language),
|
|
177
|
+
start_line=_line_number(source, node.start_byte),
|
|
178
|
+
end_line=_line_number(source, node.end_byte),
|
|
179
|
+
code=source[node.start_byte : node.end_byte].decode(
|
|
180
|
+
"utf-8", errors="replace"
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
blocks.sort(key=lambda b: (b.start_line, b.name))
|
|
186
|
+
return blocks
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def extract_blocks_from_file(path: str | Path) -> list[CodeBlock]:
|
|
190
|
+
p = Path(path)
|
|
191
|
+
language = language_for_path(p)
|
|
192
|
+
if language is None:
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
source = _read_source(p)
|
|
196
|
+
if source is None:
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
tree = _get_parser(language).parse(source)
|
|
201
|
+
except Exception:
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
return _extract_from_tree(tree, source, str(p.resolve()), language)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_blocks_from_paths(paths: list[str]) -> list[CodeBlock]:
|
|
208
|
+
blocks: list[CodeBlock] = []
|
|
209
|
+
for path in paths:
|
|
210
|
+
blocks.extend(extract_blocks_from_file(path))
|
|
211
|
+
return blocks
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def extract_blocks_from_folder(folder: str) -> list[CodeBlock]:
|
|
215
|
+
return extract_blocks_from_paths(scan_code_files(folder))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""多模型提供商统一入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devops.config import ModelConfig, list_providers
|
|
6
|
+
from devops.models.base import BaseChatModel
|
|
7
|
+
from devops.models.openai import OpenAIChatModel
|
|
8
|
+
from devops.models.zhipuai import ZhipuAIChatModel
|
|
9
|
+
|
|
10
|
+
# deepseek / moonshot 使用 OpenAI 兼容协议
|
|
11
|
+
_OPENAI_COMPAT = {"openai", "deepseek", "moonshot"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_chat_model(config: ModelConfig) -> BaseChatModel:
|
|
15
|
+
provider = config.provider.lower()
|
|
16
|
+
if provider in _OPENAI_COMPAT:
|
|
17
|
+
return OpenAIChatModel(config)
|
|
18
|
+
if provider == "zhipuai":
|
|
19
|
+
return ZhipuAIChatModel(config)
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"不支持的提供商: {provider},可选: {', '.join(list_providers())}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_chat_model(
|
|
26
|
+
*,
|
|
27
|
+
provider: str | None = None,
|
|
28
|
+
api_key: str | None = None,
|
|
29
|
+
model: str | None = None,
|
|
30
|
+
base_url: str | None = None,
|
|
31
|
+
) -> BaseChatModel:
|
|
32
|
+
from devops.config import load_config
|
|
33
|
+
|
|
34
|
+
config = load_config(
|
|
35
|
+
provider=provider,
|
|
36
|
+
api_key=api_key,
|
|
37
|
+
model=model,
|
|
38
|
+
base_url=base_url,
|
|
39
|
+
)
|
|
40
|
+
return create_chat_model(config)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"BaseChatModel",
|
|
45
|
+
"OpenAIChatModel",
|
|
46
|
+
"ZhipuAIChatModel",
|
|
47
|
+
"create_chat_model",
|
|
48
|
+
"get_chat_model",
|
|
49
|
+
"list_providers",
|
|
50
|
+
]
|
devops/models/base.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""大模型调用抽象基类。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from devops.config import ModelConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseChatModel(ABC):
|
|
11
|
+
def __init__(self, config: ModelConfig) -> None:
|
|
12
|
+
self.config = config
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def chat(self, system: str, user: str, *, temperature: float = 0.2) -> str:
|
|
16
|
+
raise NotImplementedError
|
devops/models/openai.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""OpenAI 及兼容 OpenAI 协议的服务(DeepSeek、Moonshot、本地 Ollama 等)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from devops.config import ModelConfig
|
|
8
|
+
from devops.models.base import BaseChatModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIChatModel(BaseChatModel):
|
|
12
|
+
def __init__(self, config: ModelConfig) -> None:
|
|
13
|
+
super().__init__(config)
|
|
14
|
+
from openai import OpenAI
|
|
15
|
+
|
|
16
|
+
kwargs: dict[str, Any] = {"api_key": config.api_key}
|
|
17
|
+
if config.base_url:
|
|
18
|
+
kwargs["base_url"] = config.base_url.rstrip("/")
|
|
19
|
+
self._client = OpenAI(**kwargs)
|
|
20
|
+
|
|
21
|
+
def chat(self, system: str, user: str, *, temperature: float = 0.2) -> str:
|
|
22
|
+
response = self._client.chat.completions.create(
|
|
23
|
+
model=self.config.model,
|
|
24
|
+
messages=[
|
|
25
|
+
{"role": "system", "content": system},
|
|
26
|
+
{"role": "user", "content": user},
|
|
27
|
+
],
|
|
28
|
+
temperature=temperature,
|
|
29
|
+
)
|
|
30
|
+
return (response.choices[0].message.content or "").strip()
|
devops/models/zhipuai.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""智谱 AI (GLM) 模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devops.config import ModelConfig
|
|
6
|
+
from devops.models.base import BaseChatModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ZhipuAIChatModel(BaseChatModel):
|
|
10
|
+
def __init__(self, config: ModelConfig) -> None:
|
|
11
|
+
super().__init__(config)
|
|
12
|
+
try:
|
|
13
|
+
from zhipuai import ZhipuAI
|
|
14
|
+
except ImportError as exc:
|
|
15
|
+
raise ImportError("使用智谱 AI 需要安装: pip install zhipuai") from exc
|
|
16
|
+
|
|
17
|
+
self._client = ZhipuAI(api_key=config.api_key)
|
|
18
|
+
|
|
19
|
+
def chat(self, system: str, user: str, *, temperature: float = 0.2) -> str:
|
|
20
|
+
response = self._client.chat.completions.create(
|
|
21
|
+
model=self.config.model,
|
|
22
|
+
messages=[
|
|
23
|
+
{"role": "system", "content": system},
|
|
24
|
+
{"role": "user", "content": user},
|
|
25
|
+
],
|
|
26
|
+
temperature=temperature,
|
|
27
|
+
)
|
|
28
|
+
return (response.choices[0].message.content or "").strip()
|
devops/scan/__init__.py
ADDED
devops/scan/scan.cpp
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#include "scan.h"
|
|
2
|
+
|
|
3
|
+
#include <algorithm>
|
|
4
|
+
#include <cctype>
|
|
5
|
+
#include <filesystem>
|
|
6
|
+
#include <string>
|
|
7
|
+
#include <unordered_set>
|
|
8
|
+
#include <vector>
|
|
9
|
+
|
|
10
|
+
namespace fs = std::filesystem;
|
|
11
|
+
|
|
12
|
+
namespace {
|
|
13
|
+
|
|
14
|
+
// Python 传入 UTF-8 路径,各平台统一用 u8path 解析
|
|
15
|
+
fs::path toPath(const std::string& utf8Path) {
|
|
16
|
+
return fs::u8path(utf8Path);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 返回统一使用正斜杠的路径,便于跨平台在 Python 中使用
|
|
20
|
+
std::string toGenericPath(const fs::path& path) {
|
|
21
|
+
return path.generic_string();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
bool nameEquals(const std::string& a, const std::string& b) {
|
|
25
|
+
#if defined(_WIN32)
|
|
26
|
+
if (a.size() != b.size()) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
for (size_t i = 0; i < a.size(); ++i) {
|
|
30
|
+
if (std::tolower(static_cast<unsigned char>(a[i])) !=
|
|
31
|
+
std::tolower(static_cast<unsigned char>(b[i]))) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
#else
|
|
37
|
+
return a == b;
|
|
38
|
+
#endif
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
bool shouldSkipDir(const fs::path& dir) {
|
|
42
|
+
static const char* skip[] = {
|
|
43
|
+
".git", ".svn", ".hg",
|
|
44
|
+
"node_modules", "vendor",
|
|
45
|
+
"build", "dist", "out", "target", "bin", "obj",
|
|
46
|
+
".idea", ".vscode", ".vs",
|
|
47
|
+
"__pycache__", ".next", ".venv", "venv", "env",
|
|
48
|
+
// Windows / MSVC 常见输出目录
|
|
49
|
+
"Debug", "Release", "x64", "Win32", "CMakeFiles",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const std::string name = dir.filename().string();
|
|
53
|
+
for (const char* entry : skip) {
|
|
54
|
+
if (nameEquals(name, entry)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
bool isCodeFile(const fs::path& path) {
|
|
62
|
+
static const std::unordered_set<std::string> exts = {
|
|
63
|
+
".c", ".cc", ".cpp", ".cxx", ".h", ".hpp",
|
|
64
|
+
".py", ".js", ".ts", ".tsx", ".jsx",
|
|
65
|
+
".java", ".go", ".rs", ".rb", ".php",
|
|
66
|
+
".cs", ".swift", ".m", ".mm",
|
|
67
|
+
".sh", ".sql", ".html", ".css",
|
|
68
|
+
".vue", ".kt", ".scala",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
std::string ext = path.extension().string();
|
|
72
|
+
for (char& c : ext) {
|
|
73
|
+
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
74
|
+
}
|
|
75
|
+
return !ext.empty() && exts.count(ext) > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
} // namespace
|
|
79
|
+
|
|
80
|
+
std::vector<std::string> scanCodeFiles(const std::string& folderPath) {
|
|
81
|
+
std::vector<std::string> result;
|
|
82
|
+
std::error_code ec;
|
|
83
|
+
|
|
84
|
+
const fs::path root = toPath(folderPath);
|
|
85
|
+
if (!fs::exists(root, ec) || !fs::is_directory(root, ec)) {
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fs::recursive_directory_iterator it(
|
|
90
|
+
root,
|
|
91
|
+
fs::directory_options::skip_permission_denied,
|
|
92
|
+
ec);
|
|
93
|
+
const fs::recursive_directory_iterator end;
|
|
94
|
+
|
|
95
|
+
for (; it != end; it.increment(ec)) {
|
|
96
|
+
if (ec) {
|
|
97
|
+
ec.clear();
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fs::directory_entry& entry = *it;
|
|
102
|
+
if (entry.is_directory(ec)) {
|
|
103
|
+
if (shouldSkipDir(entry.path())) {
|
|
104
|
+
it.disable_recursion_pending();
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (entry.is_regular_file(ec) && isCodeFile(entry.path())) {
|
|
110
|
+
fs::path resolved = fs::weakly_canonical(entry.path(), ec);
|
|
111
|
+
if (ec) {
|
|
112
|
+
resolved = entry.path();
|
|
113
|
+
ec.clear();
|
|
114
|
+
}
|
|
115
|
+
result.push_back(toGenericPath(resolved));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
std::sort(result.begin(), result.end());
|
|
120
|
+
return result;
|
|
121
|
+
}
|
devops/scan/scan.h
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#include <pybind11/pybind11.h>
|
|
2
|
+
#include <pybind11/stl.h>
|
|
3
|
+
|
|
4
|
+
#include "scan.h"
|
|
5
|
+
|
|
6
|
+
namespace py = pybind11;
|
|
7
|
+
|
|
8
|
+
PYBIND11_MODULE(scan_native, m) {
|
|
9
|
+
m.doc() = "Scan a project folder and return code file paths";
|
|
10
|
+
m.def(
|
|
11
|
+
"scan_code_files",
|
|
12
|
+
&scanCodeFiles,
|
|
13
|
+
py::arg("folder_path"),
|
|
14
|
+
"Recursively list all code file paths under folder_path.");
|
|
15
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devops-analyzer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 扫描源码、提取代码块、大模型分析并生成 README.md
|
|
5
|
+
Author: ZHUHK
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/example/devops-analyzer
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: tree-sitter<0.22,>=0.21.0
|
|
12
|
+
Requires-Dist: tree-sitter-languages>=1.10.0
|
|
13
|
+
Requires-Dist: openai>=1.40.0
|
|
14
|
+
Provides-Extra: zhipuai
|
|
15
|
+
Requires-Dist: zhipuai>=2.0.0; extra == "zhipuai"
|
|
16
|
+
Provides-Extra: all
|
|
17
|
+
Requires-Dist: zhipuai>=2.0.0; extra == "all"
|
|
18
|
+
Provides-Extra: build
|
|
19
|
+
Requires-Dist: build>=1.2.0; extra == "build"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Devops Analyzer
|
|
23
|
+
|
|
24
|
+
扫描项目源码 → Tree-sitter 提取代码块 → 大模型分析 → 生成 `README.md`。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cd /path/to/Devops
|
|
30
|
+
conda activate devops
|
|
31
|
+
pip install -e .
|
|
32
|
+
|
|
33
|
+
# 使用智谱 AI 时额外安装
|
|
34
|
+
pip install zhipuai
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 首次使用:配置模型 API
|
|
38
|
+
|
|
39
|
+
**必须先配置你自己的 API Key**,再分析项目:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
devops config
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
按提示选择提供商、输入 API Key 和模型名称。配置保存在 `~/.devops/config.json`(仅当前用户可读)。
|
|
46
|
+
|
|
47
|
+
查看当前配置(Key 脱敏显示):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
devops config --show
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 支持的模型提供商
|
|
54
|
+
|
|
55
|
+
| 提供商 | `provider` 值 | 默认模型 | 说明 |
|
|
56
|
+
|--------|---------------|----------|------|
|
|
57
|
+
| OpenAI | `openai` | `gpt-4o-mini` | 官方或自定义 Base URL |
|
|
58
|
+
| 智谱 AI | `zhipuai` | `glm-4-flash` | 需 `pip install zhipuai` |
|
|
59
|
+
| DeepSeek | `deepseek` | `deepseek-chat` | OpenAI 兼容协议 |
|
|
60
|
+
| Moonshot | `moonshot` | `moonshot-v1-8k` | OpenAI 兼容协议 |
|
|
61
|
+
|
|
62
|
+
也可用环境变量覆盖(优先级低于配置文件):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export DEVOPS_PROVIDER=openai
|
|
66
|
+
export DEVOPS_API_KEY=sk-...
|
|
67
|
+
export DEVOPS_MODEL=gpt-4o-mini
|
|
68
|
+
export DEVOPS_BASE_URL=https://api.openai.com/v1 # 可选
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 分析项目
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
devops /path/to/your/project
|
|
75
|
+
|
|
76
|
+
# 或显式子命令
|
|
77
|
+
devops analyze /path/to/your/project
|
|
78
|
+
|
|
79
|
+
# 指定输出文件
|
|
80
|
+
devops /path/to/project -o README.md
|
|
81
|
+
|
|
82
|
+
# 临时覆盖模型(不改配置文件)
|
|
83
|
+
devops /path/to/project --provider deepseek --model deepseek-chat
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 命令一览
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
devops config # 交互式配置 API
|
|
90
|
+
devops config --show # 查看配置
|
|
91
|
+
devops <项目路径> # 分析并生成 README.md
|
|
92
|
+
devops analyze <项目路径>
|
|
93
|
+
devops --help
|
|
94
|
+
devops --version
|
|
95
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
devops/__init__.py,sha256=efMmj0qlEd8t459QNAdKbZFaWNurNByvxNdZew1I2sI,109
|
|
2
|
+
devops/__main__.py,sha256=CqvNIJzp4XxyAaK_QKk5XJzwkYGN6hhlbEm76hbMetE,57
|
|
3
|
+
devops/analysis.py,sha256=yVHV5HRbs6w-cKEhbyFSlUbXv7I-uqmzRxzDGTjOqJg,7745
|
|
4
|
+
devops/cli.py,sha256=uVYJVFGn4QnZSf24i92c3zDiZwG9MKGsk0xevxOvAuY,5422
|
|
5
|
+
devops/config.py,sha256=hOhQrkQHGFxfroik9fomkCpWgDosmBtWBOZEJNSd8XI,4961
|
|
6
|
+
devops/config_cmd.py,sha256=tebWZmX9qq9lz2tuoqgiwHqAPW8BrVHaVDfju80kgtU,2577
|
|
7
|
+
devops/extract.py,sha256=JufuM_3OjQFM4POkQ2nPSiMUBcyJLVirGej_zdZ9Ddo,6450
|
|
8
|
+
devops/models/__init__.py,sha256=pAaRPi5Uj1qrX6KpaJSpgpzckeoYZFhsOIbsepoxngY,1261
|
|
9
|
+
devops/models/base.py,sha256=3vhle8dDC0_8LujFuPOxNQiN0MVy7V9q1I7XKQC5IYM,410
|
|
10
|
+
devops/models/openai.py,sha256=gkrNq-7zRcD3cced0ZiW_dsxirt6lREDQMLZeNSIEAw,1052
|
|
11
|
+
devops/models/zhipuai.py,sha256=4vAyS9AMl2RXczvt8dytHm1cmS1ZACF4J25qWmAyzYo,976
|
|
12
|
+
devops/scan/__init__.py,sha256=dwVWZaan-saFaPY6KIre1-bUrmTvmSfq4z5bzCjr8VY,86
|
|
13
|
+
devops/scan/scan.cpp,sha256=aMzImUcvqFldWYRHa7H-GH5h3BZtZAfGaVlOG4u6HTw,3376
|
|
14
|
+
devops/scan/scan.h,sha256=eCrTNjqxu6jlySNnpC7eyMIknqLkUTHFuc234_HXzoY,211
|
|
15
|
+
devops/scan/scan_bindings.cpp,sha256=q139NtDIy07v78Pq4MMt7BX_-QT4_ob8wn37bw0tNYk,382
|
|
16
|
+
devops/scan/scan_native.cp312-win_amd64.pyd,sha256=kXolVgXTnD0l4QG_uUuaW-otpNZtXzPv4_YzPLUgfNI,181760
|
|
17
|
+
devops_analyzer-0.1.0.dist-info/licenses/LICENSE,sha256=MBvA7WLDGW82KmxFZLLfA7h0VS_jK5cRBq8crggZlDk,1083
|
|
18
|
+
devops_analyzer-0.1.0.dist-info/METADATA,sha256=aYhrwciUIvkOZLSW17CxhKB7h1UuAedeCdZnFTvmFHA,2515
|
|
19
|
+
devops_analyzer-0.1.0.dist-info/WHEEL,sha256=rR5QfsWcZl3mra5AmSD7Fd0dzQxZ3lHCpDo70IkfDK4,101
|
|
20
|
+
devops_analyzer-0.1.0.dist-info/entry_points.txt,sha256=6kfJgW01wJybJetKc0qeIpHlldoiZn4g52Sd8B48p_U,43
|
|
21
|
+
devops_analyzer-0.1.0.dist-info/top_level.txt,sha256=qcdbAA3CortK6Ce66IZXkIMR65YMclmS95JKCGGgTv0,7
|
|
22
|
+
devops_analyzer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZHUHK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devops
|