acm-cli 0.1.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.
- acm/__init__.py +3 -0
- acm/cli.py +472 -0
- acm/config.py +272 -0
- acm/feedback.py +148 -0
- acm/generator.py +127 -0
- acm/git.py +193 -0
- acm/llm/__init__.py +25 -0
- acm/llm/base.py +58 -0
- acm/llm/openai_compat.py +78 -0
- acm/llm/qwen.py +18 -0
- acm/prompt.py +178 -0
- acm/splitter.py +92 -0
- acm/ui.py +279 -0
- acm_cli-0.1.0.dist-info/METADATA +369 -0
- acm_cli-0.1.0.dist-info/RECORD +18 -0
- acm_cli-0.1.0.dist-info/WHEEL +4 -0
- acm_cli-0.1.0.dist-info/entry_points.txt +2 -0
- acm_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
acm/git.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Git 操作封装:仓库检测、staged 文件、diff 获取、智能截断、commit 执行。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitError(Exception):
|
|
10
|
+
"""Git 操作相关错误。"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# 数据类
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FileChange:
|
|
19
|
+
"""单个文件的变更信息。"""
|
|
20
|
+
path: str
|
|
21
|
+
status: str # A/M/D/R 等
|
|
22
|
+
insertions: int = 0
|
|
23
|
+
deletions: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DiffResult:
|
|
28
|
+
"""diff 结果。"""
|
|
29
|
+
files: list[FileChange]
|
|
30
|
+
diff_content: str # 完整或截断后的 diff
|
|
31
|
+
stat_summary: str # git diff --stat 的输出
|
|
32
|
+
truncated: bool # 是否被截断
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# 底层 Git 命令执行
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _run_git(*args: str, cwd: str | None = None) -> str:
|
|
40
|
+
"""执行 git 命令并返回 stdout。失败时抛出 GitError。"""
|
|
41
|
+
cmd = ["git"] + list(args)
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
cmd,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
cwd=cwd,
|
|
48
|
+
timeout=30,
|
|
49
|
+
)
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
raise GitError("未找到 git 命令,请确保已安装 Git")
|
|
52
|
+
except subprocess.TimeoutExpired:
|
|
53
|
+
raise GitError(f"git 命令超时: {' '.join(cmd)}")
|
|
54
|
+
|
|
55
|
+
if result.returncode != 0:
|
|
56
|
+
stderr = result.stderr.strip()
|
|
57
|
+
raise GitError(f"git 命令执行失败: {stderr}")
|
|
58
|
+
|
|
59
|
+
return result.stdout
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# 公开 API
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def is_git_repo(cwd: str | None = None) -> bool:
|
|
67
|
+
"""检测当前目录是否为 Git 仓库。"""
|
|
68
|
+
try:
|
|
69
|
+
_run_git("rev-parse", "--is-inside-work-tree", cwd=cwd)
|
|
70
|
+
return True
|
|
71
|
+
except GitError:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def has_unstaged_changes(cwd: str | None = None) -> bool:
|
|
76
|
+
"""检测工作区是否有未暂存的修改。"""
|
|
77
|
+
output = _run_git("diff", "--name-only", cwd=cwd)
|
|
78
|
+
return bool(output.strip())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def has_untracked_files(cwd: str | None = None) -> bool:
|
|
82
|
+
"""检测是否有未跟踪的新文件。"""
|
|
83
|
+
output = _run_git("ls-files", "--others", "--exclude-standard", cwd=cwd)
|
|
84
|
+
return bool(output.strip())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def add_all(cwd: str | None = None) -> str:
|
|
88
|
+
"""执行 git add -A,将所有变更添加到暂存区。"""
|
|
89
|
+
return _run_git("add", "-A", cwd=cwd)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_staged_files(cwd: str | None = None) -> list[FileChange]:
|
|
93
|
+
"""获取暂存区的文件列表及变更状态。"""
|
|
94
|
+
# --name-status 输出格式: "M\tfile.py"
|
|
95
|
+
output = _run_git("diff", "--cached", "--name-status", cwd=cwd)
|
|
96
|
+
files = []
|
|
97
|
+
for line in output.strip().splitlines():
|
|
98
|
+
if not line.strip():
|
|
99
|
+
continue
|
|
100
|
+
parts = line.split("\t", 1)
|
|
101
|
+
if len(parts) == 2:
|
|
102
|
+
status, path = parts
|
|
103
|
+
files.append(FileChange(path=path, status=status))
|
|
104
|
+
return files
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_staged_diff(cwd: str | None = None) -> str:
|
|
108
|
+
"""获取暂存区的完整 diff 内容。"""
|
|
109
|
+
return _run_git("diff", "--cached", cwd=cwd)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_staged_stat(cwd: str | None = None) -> str:
|
|
113
|
+
"""获取暂存区的 stat 摘要。"""
|
|
114
|
+
return _run_git("diff", "--cached", "--stat", cwd=cwd)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_staged_numstat(cwd: str | None = None) -> list[FileChange]:
|
|
118
|
+
"""获取暂存区每个文件的增删行数。"""
|
|
119
|
+
output = _run_git("diff", "--cached", "--numstat", cwd=cwd)
|
|
120
|
+
files = []
|
|
121
|
+
for line in output.strip().splitlines():
|
|
122
|
+
if not line.strip():
|
|
123
|
+
continue
|
|
124
|
+
parts = line.split("\t")
|
|
125
|
+
if len(parts) == 3:
|
|
126
|
+
ins_str, del_str, path = parts
|
|
127
|
+
ins = int(ins_str) if ins_str != "-" else 0
|
|
128
|
+
dels = int(del_str) if del_str != "-" else 0
|
|
129
|
+
files.append(FileChange(path=path, status="M", insertions=ins, deletions=dels))
|
|
130
|
+
return files
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_diff_result(max_diff_lines: int = 500, cwd: str | None = None) -> DiffResult:
|
|
134
|
+
"""构建 diff 结果,超过阈值时进行智能截断。
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
max_diff_lines: diff 行数阈值,超过则截断。
|
|
138
|
+
cwd: 工作目录。
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
DiffResult 包含文件列表、diff 内容、stat 摘要、是否截断。
|
|
142
|
+
"""
|
|
143
|
+
files = get_staged_files(cwd=cwd)
|
|
144
|
+
stat_summary = get_staged_stat(cwd=cwd)
|
|
145
|
+
full_diff = get_staged_diff(cwd=cwd)
|
|
146
|
+
|
|
147
|
+
# 合并增删行数信息
|
|
148
|
+
numstat_files = get_staged_numstat(cwd=cwd)
|
|
149
|
+
numstat_map = {f.path: f for f in numstat_files}
|
|
150
|
+
for f in files:
|
|
151
|
+
if f.path in numstat_map:
|
|
152
|
+
f.insertions = numstat_map[f.path].insertions
|
|
153
|
+
f.deletions = numstat_map[f.path].deletions
|
|
154
|
+
|
|
155
|
+
diff_lines = full_diff.splitlines()
|
|
156
|
+
truncated = len(diff_lines) > max_diff_lines
|
|
157
|
+
|
|
158
|
+
if truncated:
|
|
159
|
+
# 截断 diff,保留前 max_diff_lines 行 + stat 摘要
|
|
160
|
+
truncated_diff = "\n".join(diff_lines[:max_diff_lines])
|
|
161
|
+
diff_content = (
|
|
162
|
+
f"{truncated_diff}\n\n"
|
|
163
|
+
f"... (diff 内容过长,已截断,共 {len(diff_lines)} 行) ...\n\n"
|
|
164
|
+
f"完整文件变更摘要:\n{stat_summary}"
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
diff_content = full_diff
|
|
168
|
+
|
|
169
|
+
return DiffResult(
|
|
170
|
+
files=files,
|
|
171
|
+
diff_content=diff_content,
|
|
172
|
+
stat_summary=stat_summary,
|
|
173
|
+
truncated=truncated,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def commit(message: str, cwd: str | None = None) -> str:
|
|
178
|
+
"""执行 git commit。返回 git 输出。"""
|
|
179
|
+
return _run_git("commit", "-m", message, cwd=cwd)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def commit_files(files: list[str], message: str, cwd: str | None = None) -> str:
|
|
183
|
+
"""对指定文件执行 git commit(用于智能拆分提交)。
|
|
184
|
+
|
|
185
|
+
先 reset HEAD 取消全部暂存,再 add 指定文件,最后 commit。
|
|
186
|
+
"""
|
|
187
|
+
# 先取消所有暂存
|
|
188
|
+
_run_git("reset", "HEAD", cwd=cwd)
|
|
189
|
+
# 暂存指定文件
|
|
190
|
+
_run_git("add", "--", *files, cwd=cwd)
|
|
191
|
+
# 提交
|
|
192
|
+
result = _run_git("commit", "-m", message, cwd=cwd)
|
|
193
|
+
return result
|
acm/llm/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""LLM 提供商模块:工厂函数用于根据配置创建提供商实例。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from acm.config import ACMConfig
|
|
6
|
+
from acm.llm.base import LLMBase
|
|
7
|
+
from acm.llm.openai_compat import OpenAICompatProvider
|
|
8
|
+
from acm.llm.qwen import QwenProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_llm(config: ACMConfig) -> LLMBase:
|
|
12
|
+
"""根据配置创建 LLM 提供商实例。"""
|
|
13
|
+
provider = config.llm.provider.lower()
|
|
14
|
+
api_key = config.llm.api_key
|
|
15
|
+
model = config.llm.model
|
|
16
|
+
base_url = config.llm.base_url
|
|
17
|
+
|
|
18
|
+
if provider == "qwen":
|
|
19
|
+
return QwenProvider(api_key=api_key, model=model, base_url=base_url)
|
|
20
|
+
|
|
21
|
+
# 其他未知提供商均视为 OpenAI 兼容接口
|
|
22
|
+
return OpenAICompatProvider(api_key=api_key, model=model, base_url=base_url)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["LLMBase", "OpenAICompatProvider", "QwenProvider", "create_llm"]
|
acm/llm/base.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""LLM 提供商基类。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Generator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LLMBase(ABC):
|
|
10
|
+
"""LLM 提供商抽象基类。"""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def chat(self, system_prompt: str, user_prompt: str) -> str:
|
|
14
|
+
"""发送对话请求,返回助手回复的文本内容。
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
system_prompt: 系统提示词。
|
|
18
|
+
user_prompt: 用户提示词。
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
LLM 生成的文本内容。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def chat_with_history(
|
|
26
|
+
self,
|
|
27
|
+
system_prompt: str,
|
|
28
|
+
messages: list[dict[str, str]],
|
|
29
|
+
) -> str:
|
|
30
|
+
"""带历史消息的对话请求(用于反馈重新生成)。
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
system_prompt: 系统提示词。
|
|
34
|
+
messages: 消息历史,每条格式为 {"role": "user"|"assistant", "content": "..."}。
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
LLM 生成的文本内容。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def stream_chat(
|
|
41
|
+
self, system_prompt: str, user_prompt: str
|
|
42
|
+
) -> Generator[str, None, None]:
|
|
43
|
+
"""流式对话请求,逐块 yield 生成内容。
|
|
44
|
+
|
|
45
|
+
默认实现回退到非流式接口,子类可覆盖以实现真正的 streaming。
|
|
46
|
+
"""
|
|
47
|
+
yield self.chat(system_prompt, user_prompt)
|
|
48
|
+
|
|
49
|
+
def stream_chat_with_history(
|
|
50
|
+
self,
|
|
51
|
+
system_prompt: str,
|
|
52
|
+
messages: list[dict[str, str]],
|
|
53
|
+
) -> Generator[str, None, None]:
|
|
54
|
+
"""带历史的流式对话请求。
|
|
55
|
+
|
|
56
|
+
默认实现回退到非流式接口,子类可覆盖。
|
|
57
|
+
"""
|
|
58
|
+
yield self.chat_with_history(system_prompt, messages)
|
acm/llm/openai_compat.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""OpenAI 兼容接口通用实现,适用于所有兼容 OpenAI Chat API 的提供商。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generator
|
|
6
|
+
|
|
7
|
+
from openai import OpenAI
|
|
8
|
+
|
|
9
|
+
from acm.llm.base import LLMBase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAICompatProvider(LLMBase):
|
|
13
|
+
"""通用 OpenAI 兼容 API 提供商。"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, api_key: str, model: str, base_url: str = ""):
|
|
16
|
+
self._model = model
|
|
17
|
+
kwargs: dict = {"api_key": api_key}
|
|
18
|
+
if base_url:
|
|
19
|
+
kwargs["base_url"] = base_url
|
|
20
|
+
self._client = OpenAI(**kwargs)
|
|
21
|
+
|
|
22
|
+
def chat(self, system_prompt: str, user_prompt: str) -> str:
|
|
23
|
+
response = self._client.chat.completions.create(
|
|
24
|
+
model=self._model,
|
|
25
|
+
messages=[
|
|
26
|
+
{"role": "system", "content": system_prompt},
|
|
27
|
+
{"role": "user", "content": user_prompt},
|
|
28
|
+
],
|
|
29
|
+
temperature=0.3,
|
|
30
|
+
)
|
|
31
|
+
return response.choices[0].message.content or ""
|
|
32
|
+
|
|
33
|
+
def chat_with_history(
|
|
34
|
+
self,
|
|
35
|
+
system_prompt: str,
|
|
36
|
+
messages: list[dict[str, str]],
|
|
37
|
+
) -> str:
|
|
38
|
+
full_messages = [{"role": "system", "content": system_prompt}] + messages
|
|
39
|
+
response = self._client.chat.completions.create(
|
|
40
|
+
model=self._model,
|
|
41
|
+
messages=full_messages, # type: ignore[arg-type]
|
|
42
|
+
temperature=0.3,
|
|
43
|
+
)
|
|
44
|
+
return response.choices[0].message.content or ""
|
|
45
|
+
|
|
46
|
+
def stream_chat(
|
|
47
|
+
self, system_prompt: str, user_prompt: str
|
|
48
|
+
) -> Generator[str, None, None]:
|
|
49
|
+
stream = self._client.chat.completions.create(
|
|
50
|
+
model=self._model,
|
|
51
|
+
messages=[
|
|
52
|
+
{"role": "system", "content": system_prompt},
|
|
53
|
+
{"role": "user", "content": user_prompt},
|
|
54
|
+
],
|
|
55
|
+
temperature=0.3,
|
|
56
|
+
stream=True,
|
|
57
|
+
)
|
|
58
|
+
for chunk in stream:
|
|
59
|
+
delta = chunk.choices[0].delta.content
|
|
60
|
+
if delta:
|
|
61
|
+
yield delta
|
|
62
|
+
|
|
63
|
+
def stream_chat_with_history(
|
|
64
|
+
self,
|
|
65
|
+
system_prompt: str,
|
|
66
|
+
messages: list[dict[str, str]],
|
|
67
|
+
) -> Generator[str, None, None]:
|
|
68
|
+
full_messages = [{"role": "system", "content": system_prompt}] + messages
|
|
69
|
+
stream = self._client.chat.completions.create(
|
|
70
|
+
model=self._model,
|
|
71
|
+
messages=full_messages, # type: ignore[arg-type]
|
|
72
|
+
temperature=0.3,
|
|
73
|
+
stream=True,
|
|
74
|
+
)
|
|
75
|
+
for chunk in stream:
|
|
76
|
+
delta = chunk.choices[0].delta.content
|
|
77
|
+
if delta:
|
|
78
|
+
yield delta
|
acm/llm/qwen.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""通义千问(Qwen)适配,复用 OpenAI 兼容接口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from acm.llm.openai_compat import OpenAICompatProvider
|
|
6
|
+
|
|
7
|
+
QWEN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QwenProvider(OpenAICompatProvider):
|
|
11
|
+
"""通义千问提供商,自动设置 base_url。"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, api_key: str, model: str, base_url: str = ""):
|
|
14
|
+
super().__init__(
|
|
15
|
+
api_key=api_key,
|
|
16
|
+
model=model,
|
|
17
|
+
base_url=base_url or QWEN_BASE_URL,
|
|
18
|
+
)
|
acm/prompt.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Prompt 模板管理:内置 system prompt、用户自定义合并、diff 内容组装。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from acm.config import ACMConfig, get_commit_types
|
|
6
|
+
from acm.feedback import FeedbackRecord, load_feedback, match_feedback
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# 内置默认 System Prompt
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
DEFAULT_SYSTEM_PROMPT = """\
|
|
13
|
+
你是一个专业的 Git Commit Message 生成助手。你的任务是根据提供的代码 diff 内容,生成规范的 commit message。
|
|
14
|
+
|
|
15
|
+
## 规则
|
|
16
|
+
|
|
17
|
+
1. **格式**:`<type>(<scope>): <描述>`
|
|
18
|
+
2. **type** 必须从以下列表中选择:{types}
|
|
19
|
+
3. **scope** 根据变更文件所在的目录/模块自动推断,使用最能代表变更范围的模块名
|
|
20
|
+
4. **描述**使用{language},简洁明了,不超过 50 个字符
|
|
21
|
+
5. **合并同类变更**:如果多个文件的变更属于同一类操作(如都是优化导入、移除未使用代码、统一格式调整等),应合并为一条 message,scope 用逗号分隔列出所有涉及的模块,而不是为每个文件单独生成描述。例如:
|
|
22
|
+
- `refactor(cve_service, cve_serializer): 优化导入和移除未使用模块`
|
|
23
|
+
- `style(auth, api, utils): 统一代码格式化`
|
|
24
|
+
6. 如果变更涉及多个不同类型的修改(非同类操作):
|
|
25
|
+
- 标题行用逗号分隔 scope,如 `feat(auth,api): 描述`
|
|
26
|
+
- 标题行后空一行,再用列表形式分别描述每个模块的变更
|
|
27
|
+
7. 只输出 commit message 本身,不要包含任何解释、前缀或 markdown 标记
|
|
28
|
+
|
|
29
|
+
## 输出格式示例
|
|
30
|
+
|
|
31
|
+
单域变更:
|
|
32
|
+
```
|
|
33
|
+
feat(auth): 添加用户登录功能
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
多文件同类变更(合并):
|
|
37
|
+
```
|
|
38
|
+
refactor(cve_service, cve_serializer): 优化导入和移除未使用模块
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
多域不同类变更:
|
|
42
|
+
```
|
|
43
|
+
feat(auth,api): 添加用户认证模块
|
|
44
|
+
|
|
45
|
+
- auth: 实现基于JWT的登录认证逻辑
|
|
46
|
+
- api: 新增 /login 和 /logout 接口
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Prompt 构建
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def build_system_prompt(config: ACMConfig, diff_content: str = "") -> str:
|
|
56
|
+
"""构建 system prompt,支持用户自定义覆盖。
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
config: ACM 配置。
|
|
60
|
+
diff_content: 当前 diff 内容,用于智能匹配相关反馈。
|
|
61
|
+
"""
|
|
62
|
+
if config.prompt.system:
|
|
63
|
+
# 用户完全自定义
|
|
64
|
+
return config.prompt.system
|
|
65
|
+
|
|
66
|
+
types = get_commit_types(config)
|
|
67
|
+
types_str = ", ".join(types)
|
|
68
|
+
|
|
69
|
+
language_map = {
|
|
70
|
+
"zh-CN": "中文",
|
|
71
|
+
"en": "English",
|
|
72
|
+
"ja": "日本語",
|
|
73
|
+
}
|
|
74
|
+
language = language_map.get(config.commit.language, config.commit.language)
|
|
75
|
+
|
|
76
|
+
prompt = DEFAULT_SYSTEM_PROMPT.format(types=types_str, language=language)
|
|
77
|
+
|
|
78
|
+
if config.prompt.extra:
|
|
79
|
+
prompt += f"\n\n## 额外指令\n\n{config.prompt.extra}"
|
|
80
|
+
|
|
81
|
+
# 智能匹配并注入相关历史反馈
|
|
82
|
+
feedback_section = _build_feedback_section(diff_content=diff_content)
|
|
83
|
+
if feedback_section:
|
|
84
|
+
prompt += feedback_section
|
|
85
|
+
|
|
86
|
+
return prompt
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_feedback_section(
|
|
90
|
+
records: list[FeedbackRecord] | None = None,
|
|
91
|
+
diff_content: str = "",
|
|
92
|
+
) -> str:
|
|
93
|
+
"""根据历史反馈记录构建学习示例段落。
|
|
94
|
+
|
|
95
|
+
当提供 diff_content 时,使用智能匹配选择最相关的反馈;
|
|
96
|
+
否则回退到全量注入。
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
records: 反馈记录列表,None 时从文件加载/匹配。
|
|
100
|
+
diff_content: 当前 diff 内容,用于匹配相关反馈。
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
格式化后的反馈段落,无记录时返回空字符串。
|
|
104
|
+
"""
|
|
105
|
+
if records is None:
|
|
106
|
+
if diff_content:
|
|
107
|
+
records = match_feedback(diff_content)
|
|
108
|
+
else:
|
|
109
|
+
records = load_feedback()
|
|
110
|
+
if not records:
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
lines = ["\n\n## 用户偏好(从历史反馈中学习)\n"]
|
|
114
|
+
lines.append("以下是用户曾经的修正记录,请学习用户的偏好并应用到后续生成中:\n")
|
|
115
|
+
for i, r in enumerate(records, 1):
|
|
116
|
+
lines.append(f"{i}. 原始: `{r.original_message}`")
|
|
117
|
+
lines.append(f" 反馈: {r.feedback}")
|
|
118
|
+
lines.append(f" 改进: `{r.improved_message}`")
|
|
119
|
+
return "\n".join(lines) + "\n"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_user_prompt(diff_content: str, feedback: str = "") -> str:
|
|
123
|
+
"""构建 user prompt,包含 diff 内容和可选的用户反馈。"""
|
|
124
|
+
prompt = f"请根据以下 git diff 内容生成 commit message:\n\n```diff\n{diff_content}\n```"
|
|
125
|
+
|
|
126
|
+
if feedback:
|
|
127
|
+
prompt += f"\n\n用户对上一次生成结果的反馈:{feedback}\n请根据反馈重新生成。"
|
|
128
|
+
|
|
129
|
+
return prompt
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_split_system_prompt(config: ACMConfig) -> str:
|
|
133
|
+
"""构建用于智能拆分提交的 system prompt。"""
|
|
134
|
+
types = get_commit_types(config)
|
|
135
|
+
types_str = ", ".join(types)
|
|
136
|
+
|
|
137
|
+
language_map = {
|
|
138
|
+
"zh-CN": "中文",
|
|
139
|
+
"en": "English",
|
|
140
|
+
}
|
|
141
|
+
language = language_map.get(config.commit.language, config.commit.language)
|
|
142
|
+
|
|
143
|
+
return f"""\
|
|
144
|
+
你是一个专业的 Git 提交拆分助手。你的任务是分析 git diff 内容,将变更按照逻辑关联性拆分为多个独立的原子提交。
|
|
145
|
+
|
|
146
|
+
## 规则
|
|
147
|
+
|
|
148
|
+
1. 每个提交组包含逻辑上相关联的文件
|
|
149
|
+
2. 每个提交组对应一个 commit message,格式为:`<type>(<scope>): <描述>`
|
|
150
|
+
3. type 从以下列表中选择:{types_str}
|
|
151
|
+
4. 描述使用{language}
|
|
152
|
+
5. 输出严格使用 JSON 格式,不要包含任何其他内容
|
|
153
|
+
|
|
154
|
+
## 输出 JSON 格式
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
[
|
|
158
|
+
{{
|
|
159
|
+
"files": ["path/to/file1.py", "path/to/file2.py"],
|
|
160
|
+
"message": "feat(auth): 添加用户登录功能"
|
|
161
|
+
}},
|
|
162
|
+
{{
|
|
163
|
+
"files": ["path/to/test_file.py"],
|
|
164
|
+
"message": "test(auth): 添加登录功能单元测试"
|
|
165
|
+
}}
|
|
166
|
+
]
|
|
167
|
+
```
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_split_user_prompt(diff_content: str, files: list[str]) -> str:
|
|
172
|
+
"""构建用于智能拆分的 user prompt。"""
|
|
173
|
+
files_str = "\n".join(f"- {f}" for f in files)
|
|
174
|
+
return (
|
|
175
|
+
f"以下是待提交的文件列表:\n{files_str}\n\n"
|
|
176
|
+
f"以下是 git diff 内容:\n\n```diff\n{diff_content}\n```\n\n"
|
|
177
|
+
f"请将这些变更拆分为多个原子提交。"
|
|
178
|
+
)
|
acm/splitter.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""智能拆分提交:LLM 分析 staged 内容,按逻辑分组为多个原子提交。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from acm.config import ACMConfig
|
|
9
|
+
from acm.llm.base import LLMBase
|
|
10
|
+
from acm.prompt import build_split_system_prompt, build_split_user_prompt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SplitError(Exception):
|
|
14
|
+
"""拆分提交相关错误。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CommitGroup:
|
|
19
|
+
"""一个原子提交组。"""
|
|
20
|
+
files: list[str]
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_split_response(raw: str) -> list[CommitGroup]:
|
|
25
|
+
"""解析 LLM 返回的 JSON 拆分结果。"""
|
|
26
|
+
text = raw.strip()
|
|
27
|
+
# 尝试提取 JSON 块
|
|
28
|
+
if "```" in text:
|
|
29
|
+
# 从 markdown 代码块中提取
|
|
30
|
+
start = text.find("[")
|
|
31
|
+
end = text.rfind("]")
|
|
32
|
+
if start != -1 and end != -1:
|
|
33
|
+
text = text[start:end + 1]
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(text)
|
|
37
|
+
except json.JSONDecodeError as e:
|
|
38
|
+
raise SplitError(f"无法解析 LLM 返回的拆分结果: {e}")
|
|
39
|
+
|
|
40
|
+
if not isinstance(data, list):
|
|
41
|
+
raise SplitError("LLM 返回的拆分结果格式错误,应为 JSON 数组")
|
|
42
|
+
|
|
43
|
+
groups = []
|
|
44
|
+
for item in data:
|
|
45
|
+
if not isinstance(item, dict):
|
|
46
|
+
raise SplitError("拆分结果中的每个元素应为对象")
|
|
47
|
+
files = item.get("files", [])
|
|
48
|
+
message = item.get("message", "")
|
|
49
|
+
if not files or not message:
|
|
50
|
+
raise SplitError("拆分结果中的每个元素必须包含 files 和 message")
|
|
51
|
+
groups.append(CommitGroup(files=files, message=message))
|
|
52
|
+
|
|
53
|
+
return groups
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CommitSplitter:
|
|
57
|
+
"""智能拆分提交。"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, llm: LLMBase, config: ACMConfig):
|
|
60
|
+
self._llm = llm
|
|
61
|
+
self._config = config
|
|
62
|
+
|
|
63
|
+
def split(self, diff_content: str, files: list[str]) -> list[CommitGroup]:
|
|
64
|
+
"""分析 diff 内容并返回拆分建议。
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
diff_content: git diff 内容。
|
|
68
|
+
files: staged 文件路径列表。
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
拆分后的提交组列表。
|
|
72
|
+
"""
|
|
73
|
+
system_prompt = build_split_system_prompt(self._config)
|
|
74
|
+
user_prompt = build_split_user_prompt(diff_content, files)
|
|
75
|
+
|
|
76
|
+
raw = self._llm.chat(system_prompt, user_prompt)
|
|
77
|
+
if not raw:
|
|
78
|
+
raise SplitError("LLM 返回了空内容")
|
|
79
|
+
|
|
80
|
+
groups = _parse_split_response(raw)
|
|
81
|
+
|
|
82
|
+
# 校验:所有文件都应被分配到某个组
|
|
83
|
+
assigned = set()
|
|
84
|
+
for group in groups:
|
|
85
|
+
assigned.update(group.files)
|
|
86
|
+
|
|
87
|
+
missing = set(files) - assigned
|
|
88
|
+
if missing:
|
|
89
|
+
# 将未分配的文件归入最后一组
|
|
90
|
+
groups[-1].files.extend(sorted(missing))
|
|
91
|
+
|
|
92
|
+
return groups
|