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/config.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""配置管理模块:加载、合并、校验 TOML 配置文件与环境变量。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else:
|
|
14
|
+
try:
|
|
15
|
+
import tomllib
|
|
16
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
17
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
18
|
+
|
|
19
|
+
import tomli_w
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# 常量
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
GLOBAL_CONFIG_DIR = Path.home() / ".acm"
|
|
27
|
+
GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.toml"
|
|
28
|
+
PROJECT_CONFIG_FILE = ".acm.toml"
|
|
29
|
+
|
|
30
|
+
DEFAULT_COMMIT_TYPES: list[str] = [
|
|
31
|
+
"feat", "fix", "docs", "style", "refactor", "perf", "test", "chore",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
|
|
35
|
+
"qwen": {
|
|
36
|
+
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
37
|
+
"env_key": "DASHSCOPE_API_KEY",
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# 数据类
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class LLMConfig:
|
|
48
|
+
provider: str = ""
|
|
49
|
+
api_key: str = ""
|
|
50
|
+
model: str = ""
|
|
51
|
+
base_url: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CommitConfig:
|
|
56
|
+
language: str = "zh-CN"
|
|
57
|
+
max_diff_lines: int = 500
|
|
58
|
+
types: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class PromptConfig:
|
|
63
|
+
system: str = ""
|
|
64
|
+
extra: str = ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ACMConfig:
|
|
69
|
+
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
70
|
+
commit: CommitConfig = field(default_factory=CommitConfig)
|
|
71
|
+
prompt: PromptConfig = field(default_factory=PromptConfig)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# 加载与合并
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
79
|
+
"""从文件加载 TOML,文件不存在时返回空 dict。"""
|
|
80
|
+
if not path.is_file():
|
|
81
|
+
return {}
|
|
82
|
+
with open(path, "rb") as f:
|
|
83
|
+
return tomllib.load(f)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
87
|
+
"""深度合并两个 dict,override 覆盖 base。"""
|
|
88
|
+
merged = base.copy()
|
|
89
|
+
for key, val in override.items():
|
|
90
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(val, dict):
|
|
91
|
+
merged[key] = _deep_merge(merged[key], val)
|
|
92
|
+
else:
|
|
93
|
+
merged[key] = val
|
|
94
|
+
return merged
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _dict_to_config(data: dict[str, Any]) -> ACMConfig:
|
|
98
|
+
"""将 dict 转换为 ACMConfig 数据类。"""
|
|
99
|
+
llm_data = data.get("llm", {})
|
|
100
|
+
commit_data = data.get("commit", {})
|
|
101
|
+
prompt_data = data.get("prompt", {})
|
|
102
|
+
|
|
103
|
+
return ACMConfig(
|
|
104
|
+
llm=LLMConfig(
|
|
105
|
+
provider=llm_data.get("provider", ""),
|
|
106
|
+
api_key=llm_data.get("api_key", ""),
|
|
107
|
+
model=llm_data.get("model", ""),
|
|
108
|
+
base_url=llm_data.get("base_url", ""),
|
|
109
|
+
),
|
|
110
|
+
commit=CommitConfig(
|
|
111
|
+
language=commit_data.get("language", "zh-CN"),
|
|
112
|
+
max_diff_lines=commit_data.get("max_diff_lines", 500),
|
|
113
|
+
types=commit_data.get("types", []),
|
|
114
|
+
),
|
|
115
|
+
prompt=PromptConfig(
|
|
116
|
+
system=prompt_data.get("system", ""),
|
|
117
|
+
extra=prompt_data.get("extra", ""),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_api_key(config: ACMConfig) -> str:
|
|
123
|
+
"""按优先级解析 API Key:环境变量 > 提供商专用环境变量 > 配置文件。"""
|
|
124
|
+
# 1. 通用环境变量
|
|
125
|
+
env_key = os.environ.get("ACM_API_KEY", "")
|
|
126
|
+
if env_key:
|
|
127
|
+
return env_key
|
|
128
|
+
|
|
129
|
+
# 2. 提供商专用环境变量
|
|
130
|
+
provider = config.llm.provider.lower()
|
|
131
|
+
if provider in PROVIDER_DEFAULTS:
|
|
132
|
+
provider_env = PROVIDER_DEFAULTS[provider].get("env_key", "")
|
|
133
|
+
if provider_env:
|
|
134
|
+
val = os.environ.get(provider_env, "")
|
|
135
|
+
if val:
|
|
136
|
+
return val
|
|
137
|
+
|
|
138
|
+
# 3. 配置文件
|
|
139
|
+
return config.llm.api_key
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_base_url(config: ACMConfig) -> str:
|
|
143
|
+
"""解析 base_url:配置文件值优先,否则使用提供商默认值。"""
|
|
144
|
+
if config.llm.base_url:
|
|
145
|
+
return config.llm.base_url
|
|
146
|
+
provider = config.llm.provider.lower()
|
|
147
|
+
if provider in PROVIDER_DEFAULTS:
|
|
148
|
+
return PROVIDER_DEFAULTS[provider].get("base_url", "")
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def load_config(project_dir: str | Path | None = None) -> ACMConfig:
|
|
153
|
+
"""加载并合并配置,返回最终的 ACMConfig。
|
|
154
|
+
|
|
155
|
+
优先级(高 → 低):项目级配置 > 全局配置 > 默认值。
|
|
156
|
+
API Key 额外叠加环境变量优先级。
|
|
157
|
+
"""
|
|
158
|
+
global_data = _load_toml(GLOBAL_CONFIG_FILE)
|
|
159
|
+
|
|
160
|
+
if project_dir is None:
|
|
161
|
+
project_dir = Path.cwd()
|
|
162
|
+
else:
|
|
163
|
+
project_dir = Path(project_dir)
|
|
164
|
+
project_path = project_dir / PROJECT_CONFIG_FILE
|
|
165
|
+
project_data = _load_toml(project_path)
|
|
166
|
+
|
|
167
|
+
merged = _deep_merge(global_data, project_data)
|
|
168
|
+
config = _dict_to_config(merged)
|
|
169
|
+
|
|
170
|
+
# 解析 API Key 和 base_url
|
|
171
|
+
config.llm.api_key = _resolve_api_key(config)
|
|
172
|
+
config.llm.base_url = _resolve_base_url(config)
|
|
173
|
+
|
|
174
|
+
return config
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# 校验
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
class ConfigError(Exception):
|
|
182
|
+
"""配置错误。"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def validate_config(config: ACMConfig) -> None:
|
|
186
|
+
"""校验必填配置项,缺失时抛出 ConfigError。"""
|
|
187
|
+
if not config.llm.api_key:
|
|
188
|
+
raise ConfigError("未配置 API Key,请运行 acm init 进行初始化")
|
|
189
|
+
if not config.llm.model:
|
|
190
|
+
raise ConfigError("未配置模型名称,请运行 acm init 或编辑配置文件")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# 获取所有 commit types
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def get_commit_types(config: ACMConfig) -> list[str]:
|
|
198
|
+
"""返回标准类型 + 用户自定义类型(去重)。"""
|
|
199
|
+
all_types = list(DEFAULT_COMMIT_TYPES)
|
|
200
|
+
for t in config.commit.types:
|
|
201
|
+
if t not in all_types:
|
|
202
|
+
all_types.append(t)
|
|
203
|
+
return all_types
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# 写入配置文件
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def save_config(config: ACMConfig, path: Path) -> None:
|
|
211
|
+
"""将配置写入 TOML 文件。"""
|
|
212
|
+
data: dict[str, Any] = {
|
|
213
|
+
"llm": {
|
|
214
|
+
"provider": config.llm.provider,
|
|
215
|
+
"api_key": config.llm.api_key,
|
|
216
|
+
"model": config.llm.model,
|
|
217
|
+
"base_url": config.llm.base_url,
|
|
218
|
+
},
|
|
219
|
+
"commit": {
|
|
220
|
+
"language": config.commit.language,
|
|
221
|
+
"max_diff_lines": config.commit.max_diff_lines,
|
|
222
|
+
"types": config.commit.types,
|
|
223
|
+
},
|
|
224
|
+
"prompt": {
|
|
225
|
+
"system": config.prompt.system,
|
|
226
|
+
"extra": config.prompt.extra,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
with open(path, "wb") as f:
|
|
231
|
+
tomli_w.dump(data, f)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def config_set(key: str, value: str, path: Path) -> None:
|
|
235
|
+
"""设置单个配置项,key 格式为 'section.field',例如 'llm.model'。"""
|
|
236
|
+
data = _load_toml(path)
|
|
237
|
+
parts = key.split(".", 1)
|
|
238
|
+
if len(parts) != 2:
|
|
239
|
+
raise ConfigError(f"无效的配置键: {key},格式应为 section.field")
|
|
240
|
+
section, field_name = parts
|
|
241
|
+
|
|
242
|
+
if section not in data:
|
|
243
|
+
data[section] = {}
|
|
244
|
+
|
|
245
|
+
# 尝试将数值字符串转为 int
|
|
246
|
+
try:
|
|
247
|
+
value_typed: Any = int(value)
|
|
248
|
+
except ValueError:
|
|
249
|
+
value_typed = value
|
|
250
|
+
|
|
251
|
+
data[section][field_name] = value_typed
|
|
252
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
with open(path, "wb") as f:
|
|
254
|
+
tomli_w.dump(data, f)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def config_get(key: str, config: ACMConfig) -> str:
|
|
258
|
+
"""获取单个配置项的值。"""
|
|
259
|
+
parts = key.split(".", 1)
|
|
260
|
+
if len(parts) != 2:
|
|
261
|
+
raise ConfigError(f"无效的配置键: {key},格式应为 section.field")
|
|
262
|
+
section, field_name = parts
|
|
263
|
+
|
|
264
|
+
section_obj = getattr(config, section, None)
|
|
265
|
+
if section_obj is None:
|
|
266
|
+
raise ConfigError(f"未知的配置节: {section}")
|
|
267
|
+
|
|
268
|
+
value = getattr(section_obj, field_name, None)
|
|
269
|
+
if value is None:
|
|
270
|
+
raise ConfigError(f"未知的配置项: {key}")
|
|
271
|
+
|
|
272
|
+
return str(value)
|
acm/feedback.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""反馈学习模块:记录用户反馈,按相似度匹配注入 prompt,动态优化后续生成。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from acm.config import GLOBAL_CONFIG_DIR
|
|
11
|
+
|
|
12
|
+
FEEDBACK_FILE = GLOBAL_CONFIG_DIR / "feedback.json"
|
|
13
|
+
MAX_FEEDBACK_RECORDS = 20
|
|
14
|
+
MAX_INJECT_RECORDS = 5
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FeedbackRecord:
|
|
19
|
+
"""一条反馈记录。"""
|
|
20
|
+
original_message: str # LLM 原始生成的 message
|
|
21
|
+
feedback: str # 用户的反馈内容
|
|
22
|
+
improved_message: str # 改进后的 message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_feedback(path: Path | None = None) -> list[FeedbackRecord]:
|
|
26
|
+
"""加载历史反馈记录。"""
|
|
27
|
+
fp = path or FEEDBACK_FILE
|
|
28
|
+
if not fp.is_file():
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
with open(fp, "r", encoding="utf-8") as f:
|
|
32
|
+
data = json.load(f)
|
|
33
|
+
return [
|
|
34
|
+
FeedbackRecord(
|
|
35
|
+
original_message=r["original_message"],
|
|
36
|
+
feedback=r["feedback"],
|
|
37
|
+
improved_message=r["improved_message"],
|
|
38
|
+
)
|
|
39
|
+
for r in data
|
|
40
|
+
]
|
|
41
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_feedback(records: list[FeedbackRecord], path: Path | None = None) -> None:
|
|
46
|
+
"""保存反馈记录到文件。"""
|
|
47
|
+
fp = path or FEEDBACK_FILE
|
|
48
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
data = [asdict(r) for r in records]
|
|
50
|
+
with open(fp, "w", encoding="utf-8") as f:
|
|
51
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def add_feedback(
|
|
55
|
+
original_message: str,
|
|
56
|
+
feedback: str,
|
|
57
|
+
improved_message: str,
|
|
58
|
+
path: Path | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""添加一条反馈记录,超过上限时淘汰最早的记录。"""
|
|
61
|
+
records = load_feedback(path)
|
|
62
|
+
records.append(FeedbackRecord(
|
|
63
|
+
original_message=original_message,
|
|
64
|
+
feedback=feedback,
|
|
65
|
+
improved_message=improved_message,
|
|
66
|
+
))
|
|
67
|
+
# 保留最近 N 条
|
|
68
|
+
if len(records) > MAX_FEEDBACK_RECORDS:
|
|
69
|
+
records = records[-MAX_FEEDBACK_RECORDS:]
|
|
70
|
+
save_feedback(records, path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def clear_feedback(path: Path | None = None) -> int:
|
|
74
|
+
"""清空所有反馈记录,返回清除的条数。"""
|
|
75
|
+
fp = path or FEEDBACK_FILE
|
|
76
|
+
records = load_feedback(fp)
|
|
77
|
+
count = len(records)
|
|
78
|
+
if fp.is_file():
|
|
79
|
+
fp.unlink()
|
|
80
|
+
return count
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_type_scope(message: str) -> tuple[str, set[str]]:
|
|
84
|
+
"""从 commit message 中提取 type 和 scope 集合。
|
|
85
|
+
|
|
86
|
+
例如 'feat(auth, api): 描述' -> ('feat', {'auth', 'api'})
|
|
87
|
+
"""
|
|
88
|
+
m = re.match(r"^(\w+)\(([^)]*)\)", message)
|
|
89
|
+
if not m:
|
|
90
|
+
return ("", set())
|
|
91
|
+
msg_type = m.group(1)
|
|
92
|
+
scopes = {s.strip() for s in m.group(2).split(",") if s.strip()}
|
|
93
|
+
return (msg_type, scopes)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def match_feedback(
|
|
97
|
+
diff_content: str,
|
|
98
|
+
records: list[FeedbackRecord] | None = None,
|
|
99
|
+
path: Path | None = None,
|
|
100
|
+
max_results: int = MAX_INJECT_RECORDS,
|
|
101
|
+
) -> list[FeedbackRecord]:
|
|
102
|
+
"""从历史反馈中筛选与当前 diff 最相关的记录。
|
|
103
|
+
|
|
104
|
+
匹配策略(按优先级):
|
|
105
|
+
1. 反馈内容中的关键词在 diff 中出现 -> 高相关
|
|
106
|
+
2. improved_message 的 type 在 diff 路径中能推断 -> 中相关
|
|
107
|
+
3. 兜底:最近的记录优先
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
diff_content: 当前的 diff 内容。
|
|
111
|
+
records: 反馈记录列表,None 时从文件加载。
|
|
112
|
+
path: 反馈文件路径。
|
|
113
|
+
max_results: 最多返回的条数。
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
筛选后的反馈记录列表。
|
|
117
|
+
"""
|
|
118
|
+
if records is None:
|
|
119
|
+
records = load_feedback(path)
|
|
120
|
+
if not records:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
diff_lower = diff_content.lower()
|
|
124
|
+
|
|
125
|
+
scored: list[tuple[float, int, FeedbackRecord]] = []
|
|
126
|
+
for idx, r in enumerate(records):
|
|
127
|
+
score = 0.0
|
|
128
|
+
|
|
129
|
+
# 策略1:反馈关键词出现在 diff 中
|
|
130
|
+
feedback_words = set(re.findall(r"[\w\u4e00-\u9fff]+", r.feedback.lower()))
|
|
131
|
+
for word in feedback_words:
|
|
132
|
+
if len(word) >= 2 and word in diff_lower:
|
|
133
|
+
score += 2.0
|
|
134
|
+
|
|
135
|
+
# 策略2:improved_message 的 scope 出现在 diff 路径中
|
|
136
|
+
_, scopes = _parse_type_scope(r.improved_message)
|
|
137
|
+
for scope in scopes:
|
|
138
|
+
if scope.lower() in diff_lower:
|
|
139
|
+
score += 1.5
|
|
140
|
+
|
|
141
|
+
# 策略3:recency bonus(越新的记录分数越高)
|
|
142
|
+
score += idx * 0.1
|
|
143
|
+
|
|
144
|
+
scored.append((score, idx, r))
|
|
145
|
+
|
|
146
|
+
# 按分数降序排列
|
|
147
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
148
|
+
return [r for _, _, r in scored[:max_results]]
|
acm/generator.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Commit Message 生成核心逻辑:调用 LLM、解析响应、反馈重新生成。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Generator
|
|
7
|
+
|
|
8
|
+
from acm.config import ACMConfig
|
|
9
|
+
from acm.llm.base import LLMBase
|
|
10
|
+
from acm.prompt import build_system_prompt, build_user_prompt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GenerateError(Exception):
|
|
14
|
+
"""生成 commit message 时的错误。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _clean_message(raw: str) -> str:
|
|
18
|
+
"""清理 LLM 返回的原始内容,去除 markdown 标记等。"""
|
|
19
|
+
text = raw.strip()
|
|
20
|
+
# 去除可能的 ```commit 或 ``` 包裹
|
|
21
|
+
text = re.sub(r"^```[\w]*\n?", "", text)
|
|
22
|
+
text = re.sub(r"\n?```$", "", text)
|
|
23
|
+
return text.strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommitGenerator:
|
|
27
|
+
"""Commit message 生成器,支持反馈多次重新生成。"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, llm: LLMBase, config: ACMConfig):
|
|
30
|
+
self._llm = llm
|
|
31
|
+
self._config = config
|
|
32
|
+
self._system_prompt: str = ""
|
|
33
|
+
self._history: list[dict[str, str]] = []
|
|
34
|
+
|
|
35
|
+
def generate(self, diff_content: str) -> str:
|
|
36
|
+
"""首次生成 commit message。
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
diff_content: git diff 内容(可能已截断)。
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
生成的 commit message。
|
|
43
|
+
"""
|
|
44
|
+
self._system_prompt = build_system_prompt(self._config, diff_content=diff_content)
|
|
45
|
+
user_prompt = build_user_prompt(diff_content)
|
|
46
|
+
self._history = [{"role": "user", "content": user_prompt}]
|
|
47
|
+
|
|
48
|
+
raw = self._llm.chat(self._system_prompt, user_prompt)
|
|
49
|
+
if not raw:
|
|
50
|
+
raise GenerateError("LLM 返回了空内容,请检查模型配置")
|
|
51
|
+
|
|
52
|
+
message = _clean_message(raw)
|
|
53
|
+
self._history.append({"role": "assistant", "content": message})
|
|
54
|
+
return message
|
|
55
|
+
|
|
56
|
+
def stream_generate(self, diff_content: str) -> Generator[str, None, str]:
|
|
57
|
+
"""流式首次生成,逐块 yield 内容,最终 return 完整 message。
|
|
58
|
+
|
|
59
|
+
用法::
|
|
60
|
+
|
|
61
|
+
gen = generator.stream_generate(diff)
|
|
62
|
+
try:
|
|
63
|
+
while True:
|
|
64
|
+
chunk = next(gen)
|
|
65
|
+
# 实时显示 chunk
|
|
66
|
+
except StopIteration as e:
|
|
67
|
+
final_message = e.value
|
|
68
|
+
"""
|
|
69
|
+
self._system_prompt = build_system_prompt(self._config, diff_content=diff_content)
|
|
70
|
+
user_prompt = build_user_prompt(diff_content)
|
|
71
|
+
self._history = [{"role": "user", "content": user_prompt}]
|
|
72
|
+
|
|
73
|
+
chunks: list[str] = []
|
|
74
|
+
for chunk in self._llm.stream_chat(self._system_prompt, user_prompt):
|
|
75
|
+
chunks.append(chunk)
|
|
76
|
+
yield chunk
|
|
77
|
+
|
|
78
|
+
raw = "".join(chunks)
|
|
79
|
+
if not raw:
|
|
80
|
+
raise GenerateError("LLM 返回了空内容,请检查模型配置")
|
|
81
|
+
|
|
82
|
+
message = _clean_message(raw)
|
|
83
|
+
self._history.append({"role": "assistant", "content": message})
|
|
84
|
+
return message
|
|
85
|
+
|
|
86
|
+
def stream_regenerate(self, feedback: str) -> Generator[str, None, str]:
|
|
87
|
+
"""流式反馈重新生成,逐块 yield 内容,最终 return 完整 message。"""
|
|
88
|
+
feedback_prompt = f"用户对上一次生成结果的反馈:{feedback}\n请根据反馈重新生成 commit message。"
|
|
89
|
+
self._history.append({"role": "user", "content": feedback_prompt})
|
|
90
|
+
|
|
91
|
+
chunks: list[str] = []
|
|
92
|
+
for chunk in self._llm.stream_chat_with_history(self._system_prompt, self._history):
|
|
93
|
+
chunks.append(chunk)
|
|
94
|
+
yield chunk
|
|
95
|
+
|
|
96
|
+
raw = "".join(chunks)
|
|
97
|
+
if not raw:
|
|
98
|
+
raise GenerateError("LLM 返回了空内容,请检查模型配置")
|
|
99
|
+
|
|
100
|
+
message = _clean_message(raw)
|
|
101
|
+
self._history.append({"role": "assistant", "content": message})
|
|
102
|
+
return message
|
|
103
|
+
|
|
104
|
+
def regenerate(self, feedback: str) -> str:
|
|
105
|
+
"""根据用户反馈重新生成(非流式)。
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
feedback: 用户的反馈意见。
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
重新生成的 commit message。
|
|
112
|
+
"""
|
|
113
|
+
feedback_prompt = f"用户对上一次生成结果的反馈:{feedback}\n请根据反馈重新生成 commit message。"
|
|
114
|
+
self._history.append({"role": "user", "content": feedback_prompt})
|
|
115
|
+
|
|
116
|
+
raw = self._llm.chat_with_history(self._system_prompt, self._history)
|
|
117
|
+
if not raw:
|
|
118
|
+
raise GenerateError("LLM 返回了空内容,请检查模型配置")
|
|
119
|
+
|
|
120
|
+
message = _clean_message(raw)
|
|
121
|
+
self._history.append({"role": "assistant", "content": message})
|
|
122
|
+
return message
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def history(self) -> list[dict[str, str]]:
|
|
126
|
+
"""返回当前对话历史(只读)。"""
|
|
127
|
+
return list(self._history)
|