lgit-cli 3.7.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.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/api.py
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
"""Async LLM API client and conventional-commit generation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urljoin
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from jinja2 import Template
|
|
19
|
+
|
|
20
|
+
from . import cache as llm_cache
|
|
21
|
+
from . import profile
|
|
22
|
+
from .errors import ApiContextLengthExceeded, ApiError, LgitError
|
|
23
|
+
from .markdown_output import (
|
|
24
|
+
analysis_from_mapping,
|
|
25
|
+
fallback_summary,
|
|
26
|
+
parse_changelog_response,
|
|
27
|
+
parse_compose_binding_markdown,
|
|
28
|
+
parse_compose_intent_markdown,
|
|
29
|
+
parse_conventional_analysis_markdown,
|
|
30
|
+
parse_fast_commit_markdown,
|
|
31
|
+
parse_summary_markdown,
|
|
32
|
+
)
|
|
33
|
+
from .markdown_output import (
|
|
34
|
+
strip_type_prefix as strip_markdown_type_prefix,
|
|
35
|
+
)
|
|
36
|
+
from .models import CommitSummary, ConventionalAnalysis, ConventionalCommit, ResolvedApiMode, resolve_model_name
|
|
37
|
+
from .normalization import post_process_commit_message
|
|
38
|
+
from .validation import is_past_tense_first_word, validate_summary_quality
|
|
39
|
+
|
|
40
|
+
ANTHROPIC_REQUIRED_MAX_TOKENS = 16_384
|
|
41
|
+
_CONTEXT_LENGTH_MARKERS = (
|
|
42
|
+
"context_length_exceeded",
|
|
43
|
+
"context window",
|
|
44
|
+
"maximum context length",
|
|
45
|
+
"exceeds the context",
|
|
46
|
+
"input exceeds",
|
|
47
|
+
"prompt is too long",
|
|
48
|
+
"too many tokens",
|
|
49
|
+
)
|
|
50
|
+
_JSON_CACHE_PREFIX = "\x00json:"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OneShotSource(StrEnum):
|
|
54
|
+
"""Origin of a parsed one-shot response."""
|
|
55
|
+
|
|
56
|
+
TOOL_CALL = "tool_call"
|
|
57
|
+
OUTPUT_JSON_PARSE = "output_json_parse"
|
|
58
|
+
PLAIN_TEXT_CONTENT = "plain_text_content"
|
|
59
|
+
CACHE = "cache"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class OneShotDebug:
|
|
64
|
+
"""Optional raw request/response debug output target."""
|
|
65
|
+
|
|
66
|
+
dir: str | Path | None = None
|
|
67
|
+
prefix: str | None = None
|
|
68
|
+
name: str = "oneshot"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class OneShotSpec:
|
|
73
|
+
"""Complete description of a single LLM tool or markdown request."""
|
|
74
|
+
|
|
75
|
+
operation: str
|
|
76
|
+
model: str | None = None
|
|
77
|
+
prompt_family: str = "custom"
|
|
78
|
+
prompt_variant: str = "default"
|
|
79
|
+
system_prompt: str = ""
|
|
80
|
+
user_prompt: str = ""
|
|
81
|
+
tool_name: str = "create_response"
|
|
82
|
+
tool_description: str = "Return the requested structured response"
|
|
83
|
+
schema: Mapping[str, Any] | None = None
|
|
84
|
+
progress_label: str | None = None
|
|
85
|
+
debug: OneShotDebug | Mapping[str, Any] | str | Path | None = None
|
|
86
|
+
cacheable: bool = True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class OneShotResponse:
|
|
91
|
+
"""Parsed output and metadata from a one-shot LLM request."""
|
|
92
|
+
|
|
93
|
+
output: Any
|
|
94
|
+
source: OneShotSource
|
|
95
|
+
text_content: str | None = None
|
|
96
|
+
stop_reason: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def strict_json_schema(properties: Mapping[str, Any], required: list[str] | tuple[str, ...]) -> dict[str, Any]:
|
|
100
|
+
"""Build a strict object JSON schema with no additional properties."""
|
|
101
|
+
|
|
102
|
+
return {"type": "object", "properties": dict(properties), "required": list(required), "additionalProperties": False}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def run_oneshot(
|
|
106
|
+
config: Any,
|
|
107
|
+
prompt: str | OneShotSpec | Mapping[str, Any] | None = None,
|
|
108
|
+
*,
|
|
109
|
+
spec: OneShotSpec | Mapping[str, Any] | None = None,
|
|
110
|
+
system_prompt: str | None = None,
|
|
111
|
+
model: str | None = None,
|
|
112
|
+
schema: Mapping[str, Any] | None = None,
|
|
113
|
+
schema_name: str = "response",
|
|
114
|
+
tool_name: str | None = None,
|
|
115
|
+
tool_description: str | None = None,
|
|
116
|
+
operation: str | None = None,
|
|
117
|
+
prompt_family: str = "custom",
|
|
118
|
+
prompt_variant: str = "default",
|
|
119
|
+
temperature: float | None = None,
|
|
120
|
+
debug_label: str | None = None,
|
|
121
|
+
debug: OneShotDebug | Mapping[str, Any] | str | Path | None = None,
|
|
122
|
+
markdown_output: bool | None = None,
|
|
123
|
+
cache: bool = True,
|
|
124
|
+
cacheable: bool | None = None,
|
|
125
|
+
**_: Any,
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""Run one LLM request, returning parsed output or a ``OneShotResponse`` for specs."""
|
|
128
|
+
|
|
129
|
+
del temperature
|
|
130
|
+
return_response = isinstance(prompt, OneShotSpec) or spec is not None or isinstance(prompt, Mapping)
|
|
131
|
+
built = _coerce_spec(
|
|
132
|
+
prompt,
|
|
133
|
+
spec=spec,
|
|
134
|
+
system_prompt=system_prompt,
|
|
135
|
+
model=model,
|
|
136
|
+
schema=schema,
|
|
137
|
+
schema_name=schema_name,
|
|
138
|
+
tool_name=tool_name,
|
|
139
|
+
tool_description=tool_description,
|
|
140
|
+
operation=operation,
|
|
141
|
+
prompt_family=prompt_family,
|
|
142
|
+
prompt_variant=prompt_variant,
|
|
143
|
+
debug_label=debug_label,
|
|
144
|
+
debug=debug,
|
|
145
|
+
cacheable=cache if cacheable is None else cacheable,
|
|
146
|
+
)
|
|
147
|
+
if not built.model:
|
|
148
|
+
built = replace(
|
|
149
|
+
built,
|
|
150
|
+
model=resolve_model_name(
|
|
151
|
+
str(getattr(config, "analysis_model", getattr(config, "model", "claude-opus-4.5")))
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
response = await _run_oneshot_response(config, built, markdown_output=markdown_output)
|
|
155
|
+
return response if return_response else response.output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def generate_conventional_analysis(
|
|
159
|
+
config: Any,
|
|
160
|
+
stat: str,
|
|
161
|
+
diff: str,
|
|
162
|
+
scope_candidates: str = "",
|
|
163
|
+
*,
|
|
164
|
+
user_context: str | None = None,
|
|
165
|
+
recent_commits: str | None = None,
|
|
166
|
+
common_scopes: str | None = None,
|
|
167
|
+
project_context: str | None = None,
|
|
168
|
+
debug_output: str | Path | None = None,
|
|
169
|
+
debug_prefix: str | None = None,
|
|
170
|
+
) -> ConventionalAnalysis:
|
|
171
|
+
"""Generate a structured conventional-commit analysis for a diff."""
|
|
172
|
+
|
|
173
|
+
variant = (
|
|
174
|
+
"markdown"
|
|
175
|
+
if bool(getattr(config, "markdown_output", True))
|
|
176
|
+
else str(getattr(config, "analysis_prompt_variant", "default"))
|
|
177
|
+
)
|
|
178
|
+
system_prompt, user_prompt = render_prompt(
|
|
179
|
+
"analysis",
|
|
180
|
+
variant,
|
|
181
|
+
{
|
|
182
|
+
"project_context": project_context or "",
|
|
183
|
+
"types_description": format_types_description(config),
|
|
184
|
+
"stat": stat,
|
|
185
|
+
"scope_candidates": scope_candidates,
|
|
186
|
+
"common_scopes": common_scopes or "",
|
|
187
|
+
"recent_commits": recent_commits or "",
|
|
188
|
+
"diff": diff,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
if user_context:
|
|
192
|
+
user_prompt = f"{user_prompt}\n\n<user_context>\n{user_context}\n</user_context>"
|
|
193
|
+
type_enum = list(getattr(config, "types", {}) or {"chore": None})
|
|
194
|
+
spec = OneShotSpec(
|
|
195
|
+
operation="analysis",
|
|
196
|
+
model=resolve_model_name(str(getattr(config, "analysis_model", getattr(config, "model", "claude-opus-4.5")))),
|
|
197
|
+
prompt_family="analysis",
|
|
198
|
+
prompt_variant=variant,
|
|
199
|
+
system_prompt=system_prompt,
|
|
200
|
+
user_prompt=user_prompt,
|
|
201
|
+
tool_name="create_conventional_analysis",
|
|
202
|
+
tool_description="Create conventional commit analysis from a git diff",
|
|
203
|
+
schema=build_analysis_schema(type_enum, config),
|
|
204
|
+
progress_label="analysis",
|
|
205
|
+
debug=OneShotDebug(debug_output, debug_prefix, "analysis") if debug_output else None,
|
|
206
|
+
cacheable=True,
|
|
207
|
+
)
|
|
208
|
+
response = await _run_oneshot_response(config, spec)
|
|
209
|
+
return _coerce_analysis(response.output, response.text_content, default_type=type_enum[0] if type_enum else "chore")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def strip_type_prefix(summary: str, commit_type: str | None = None, scope: str | None = None) -> str:
|
|
213
|
+
"""Strip Rust-equivalent conventional type prefixes from a summary."""
|
|
214
|
+
|
|
215
|
+
if commit_type is None:
|
|
216
|
+
return strip_markdown_type_prefix(summary)
|
|
217
|
+
text = str(summary).strip()
|
|
218
|
+
commit_type_lower = commit_type.lower()
|
|
219
|
+
prefixes = []
|
|
220
|
+
if scope:
|
|
221
|
+
prefixes.append(f"{commit_type}({scope}): ")
|
|
222
|
+
prefixes.append(f"{commit_type}: ")
|
|
223
|
+
for prefix in prefixes:
|
|
224
|
+
if text.lower().startswith(prefix.lower()):
|
|
225
|
+
return text[len(prefix) :].strip()
|
|
226
|
+
match = re.match(r"^([a-z][a-z0-9-]*)(?:\(([^)]*)\))?:\s+(.*)$", text, re.IGNORECASE)
|
|
227
|
+
if match and match.group(1).lower() == commit_type_lower:
|
|
228
|
+
return match.group(3).strip()
|
|
229
|
+
return text
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def summary_from_holistic_analysis(analysis: ConventionalAnalysis, config: Any, stat: str = "") -> str | None:
|
|
233
|
+
"""Return a hard-limit-validated holistic summary from analysis, or None."""
|
|
234
|
+
|
|
235
|
+
del stat
|
|
236
|
+
if not analysis.summary or not str(analysis.summary).strip():
|
|
237
|
+
return None
|
|
238
|
+
summary = strip_type_prefix(
|
|
239
|
+
str(analysis.summary).strip(),
|
|
240
|
+
str(analysis.commit_type),
|
|
241
|
+
None if analysis.scope is None else str(analysis.scope),
|
|
242
|
+
).rstrip(" .")
|
|
243
|
+
if not summary:
|
|
244
|
+
return None
|
|
245
|
+
return str(CommitSummary.from_raw(summary, max_length=int(getattr(config, "summary_hard_limit", 128))))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def generate_summary_from_analysis(
|
|
249
|
+
config: Any,
|
|
250
|
+
analysis: ConventionalAnalysis,
|
|
251
|
+
stat: str = "",
|
|
252
|
+
*,
|
|
253
|
+
user_context: str | None = None,
|
|
254
|
+
debug_output: str | Path | None = None,
|
|
255
|
+
debug_prefix: str | None = None,
|
|
256
|
+
) -> str:
|
|
257
|
+
"""Generate a concise summary from structured analysis details."""
|
|
258
|
+
|
|
259
|
+
commit_type = str(analysis.commit_type)
|
|
260
|
+
scope = None if analysis.scope is None else str(analysis.scope)
|
|
261
|
+
prefix_len = len(commit_type) + 2 + (len(scope) + 2 if scope else 0)
|
|
262
|
+
chars = max(20, int(getattr(config, "summary_guideline", 72)) - prefix_len)
|
|
263
|
+
variant = (
|
|
264
|
+
"markdown"
|
|
265
|
+
if bool(getattr(config, "markdown_output", True))
|
|
266
|
+
else str(getattr(config, "summary_prompt_variant", "default"))
|
|
267
|
+
)
|
|
268
|
+
details = "\n".join(f"- {detail}" for detail in analysis.body_texts()) or f"- {analysis.summary or ''}"
|
|
269
|
+
system_prompt, user_prompt = render_prompt(
|
|
270
|
+
"summary",
|
|
271
|
+
variant,
|
|
272
|
+
{
|
|
273
|
+
"commit_type": commit_type,
|
|
274
|
+
"scope": scope,
|
|
275
|
+
"chars": chars,
|
|
276
|
+
"user_context": user_context or "",
|
|
277
|
+
"details": details,
|
|
278
|
+
"stat": stat,
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
spec = OneShotSpec(
|
|
282
|
+
operation="summary",
|
|
283
|
+
model=resolve_model_name(str(getattr(config, "summary_model", getattr(config, "model", "claude-haiku-4-5")))),
|
|
284
|
+
prompt_family="summary",
|
|
285
|
+
prompt_variant=variant,
|
|
286
|
+
system_prompt=system_prompt,
|
|
287
|
+
user_prompt=user_prompt,
|
|
288
|
+
tool_name="create_commit_summary",
|
|
289
|
+
tool_description="Compose a git commit summary line from detail statements",
|
|
290
|
+
schema=strict_json_schema(
|
|
291
|
+
{
|
|
292
|
+
"summary": {
|
|
293
|
+
"type": "string",
|
|
294
|
+
"description": f"Single line summary, target {getattr(config, 'summary_guideline', 72)} chars, hard limit {getattr(config, 'summary_hard_limit', 128)}.",
|
|
295
|
+
"maxLength": int(getattr(config, "summary_hard_limit", 128)),
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
["summary"],
|
|
299
|
+
),
|
|
300
|
+
progress_label="summary",
|
|
301
|
+
debug=OneShotDebug(debug_output, debug_prefix, "summary") if debug_output else None,
|
|
302
|
+
cacheable=True,
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
response = await _run_oneshot_response(config, spec)
|
|
306
|
+
summary = _summary_from_output(response.output, response.text_content)
|
|
307
|
+
except Exception:
|
|
308
|
+
summary = ""
|
|
309
|
+
summary = strip_type_prefix(
|
|
310
|
+
summary
|
|
311
|
+
or analysis.summary
|
|
312
|
+
or fallback_summary(
|
|
313
|
+
stat, analysis.body_texts(), limit=int(getattr(config, "summary_hard_limit", 128)), commit_type=commit_type
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
if not validate_summary_quality(summary, commit_type, stat).ok:
|
|
317
|
+
summary = _fallback_summary_for_commit(
|
|
318
|
+
stat, analysis.body_texts(), commit_type, int(getattr(config, "summary_hard_limit", 128))
|
|
319
|
+
)
|
|
320
|
+
return summary[: int(getattr(config, "summary_hard_limit", 128))].rstrip(" .")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def generate_analysis_with_map_reduce(
|
|
324
|
+
config: Any, stat: str, diff: str, scope_candidates: str = "", **kwargs: Any
|
|
325
|
+
) -> ConventionalAnalysis:
|
|
326
|
+
"""Generate analysis directly or through map-reduce for large diffs."""
|
|
327
|
+
|
|
328
|
+
from . import style
|
|
329
|
+
from .map_reduce import run_map_reduce, should_use_map_reduce
|
|
330
|
+
from .tokens import create_token_counter
|
|
331
|
+
|
|
332
|
+
counter = kwargs.pop("counter", None) or create_token_counter(config)
|
|
333
|
+
if should_use_map_reduce(diff, config, counter):
|
|
334
|
+
count_sync = getattr(counter, "count_sync", None)
|
|
335
|
+
token_count = int(count_sync(diff)) if callable(count_sync) else max(1, len(diff) // 4)
|
|
336
|
+
style.print_info(f"Large diff detected ({token_count} tokens), using map-reduce...")
|
|
337
|
+
return await run_map_reduce(config, stat, diff, scope_candidates, counter=counter, **kwargs)
|
|
338
|
+
return await generate_conventional_analysis(config, stat, diff, scope_candidates, **kwargs)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def generate_fast_commit(
|
|
342
|
+
config: Any,
|
|
343
|
+
stat: str,
|
|
344
|
+
diff: str,
|
|
345
|
+
scope_candidates: str = "",
|
|
346
|
+
*,
|
|
347
|
+
user_context: str | None = None,
|
|
348
|
+
debug_output: str | Path | None = None,
|
|
349
|
+
debug_prefix: str | None = None,
|
|
350
|
+
) -> ConventionalCommit:
|
|
351
|
+
"""Generate a complete conventional commit in one model call."""
|
|
352
|
+
|
|
353
|
+
variant = "markdown" if bool(getattr(config, "markdown_output", True)) else "default"
|
|
354
|
+
system_prompt, user_prompt = render_prompt(
|
|
355
|
+
"fast",
|
|
356
|
+
variant,
|
|
357
|
+
{
|
|
358
|
+
"stat": stat,
|
|
359
|
+
"diff": diff,
|
|
360
|
+
"scope_candidates": scope_candidates,
|
|
361
|
+
"user_context": user_context or "",
|
|
362
|
+
"types_description": format_types_description(config),
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
type_enum = list(getattr(config, "types", {}) or {"chore": None})
|
|
366
|
+
spec = OneShotSpec(
|
|
367
|
+
operation="fast",
|
|
368
|
+
model=resolve_model_name(str(getattr(config, "analysis_model", getattr(config, "model", "claude-opus-4.5")))),
|
|
369
|
+
prompt_family="fast",
|
|
370
|
+
prompt_variant=variant,
|
|
371
|
+
system_prompt=system_prompt,
|
|
372
|
+
user_prompt=user_prompt,
|
|
373
|
+
tool_name="create_fast_commit",
|
|
374
|
+
tool_description="Create a compact conventional commit message",
|
|
375
|
+
schema=strict_json_schema(
|
|
376
|
+
{
|
|
377
|
+
"type": {"type": "string", "enum": type_enum, "description": "Conventional commit type"},
|
|
378
|
+
"scope": {"type": "string", "description": "Optional scope. Omit if unclear."},
|
|
379
|
+
"summary": {"type": "string", "description": "Compact past-tense summary, no prefix or period"},
|
|
380
|
+
"details": {"type": "array", "items": {"type": "string"}, "description": "0-3 detail sentences"},
|
|
381
|
+
},
|
|
382
|
+
["type", "summary", "details"],
|
|
383
|
+
),
|
|
384
|
+
progress_label="fast",
|
|
385
|
+
debug=OneShotDebug(debug_output, debug_prefix, "fast") if debug_output else None,
|
|
386
|
+
cacheable=True,
|
|
387
|
+
)
|
|
388
|
+
response = await _run_oneshot_response(config, spec)
|
|
389
|
+
commit = _coerce_fast_commit(
|
|
390
|
+
response.output, response.text_content, default_type=type_enum[0] if type_enum else "chore"
|
|
391
|
+
)
|
|
392
|
+
return post_process_commit_message(commit, config)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _fallback_summary_for_commit(stat: str, details: Iterable[str], commit_type: str, limit: int) -> str:
|
|
396
|
+
details_list = [str(detail) for detail in details]
|
|
397
|
+
candidate = fallback_summary(stat, details_list, limit=limit, commit_type=commit_type)
|
|
398
|
+
if validate_summary_quality(candidate, commit_type, stat).ok:
|
|
399
|
+
return candidate
|
|
400
|
+
first_detail = details_list[0].strip().rstrip(".") if details_list else ""
|
|
401
|
+
cleaned = first_detail or strip_type_prefix(candidate).strip()
|
|
402
|
+
for variant in (commit_type, f"{commit_type}ed", f"{commit_type}d"):
|
|
403
|
+
prefix = f"{variant.lower()} "
|
|
404
|
+
if cleaned.lower().startswith(prefix):
|
|
405
|
+
cleaned = cleaned[len(variant) :].strip()
|
|
406
|
+
break
|
|
407
|
+
verb = {
|
|
408
|
+
"feat": "added",
|
|
409
|
+
"fix": "fixed",
|
|
410
|
+
"refactor": "restructured",
|
|
411
|
+
"docs": "documented",
|
|
412
|
+
"test": "tested",
|
|
413
|
+
"perf": "optimized",
|
|
414
|
+
"build": "updated",
|
|
415
|
+
"ci": "updated",
|
|
416
|
+
"chore": "updated",
|
|
417
|
+
"style": "formatted",
|
|
418
|
+
"revert": "reverted",
|
|
419
|
+
}.get(commit_type, "changed")
|
|
420
|
+
first_word = cleaned.split(maxsplit=1)[0] if cleaned else ""
|
|
421
|
+
prefixed = cleaned if first_word and is_past_tense_first_word(first_word) else f"{verb} {cleaned or 'files'}"
|
|
422
|
+
try:
|
|
423
|
+
return str(CommitSummary.from_raw(prefixed, max_length=limit))
|
|
424
|
+
except LgitError:
|
|
425
|
+
return fallback_summary("", details_list, limit=limit, commit_type=commit_type)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def render_prompt(family: str, variant: str, context: Mapping[str, Any]) -> tuple[str, str]:
|
|
429
|
+
"""Render a prompt through ``lgit.templates`` with resource fallback."""
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
from . import templates
|
|
433
|
+
|
|
434
|
+
rendered = _render_with_template_helper(templates, family, variant, context)
|
|
435
|
+
if rendered is not None:
|
|
436
|
+
return rendered
|
|
437
|
+
except ImportError, AttributeError:
|
|
438
|
+
pass
|
|
439
|
+
template_text = (
|
|
440
|
+
resources.files("lgit.resources").joinpath("prompts", family, f"{variant}.md").read_text(encoding="utf-8")
|
|
441
|
+
)
|
|
442
|
+
system, user = _split_prompt(Template(template_text).render(**context))
|
|
443
|
+
return system, user
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def format_types_description(config: Any) -> str:
|
|
447
|
+
"""Format configured commit-type guidance for prompts."""
|
|
448
|
+
|
|
449
|
+
lines: list[str] = []
|
|
450
|
+
for name, type_config in (getattr(config, "types", {}) or {}).items():
|
|
451
|
+
description = getattr(type_config, "description", "")
|
|
452
|
+
hint = getattr(type_config, "hint", "")
|
|
453
|
+
line = f"- {name}: {description}".rstrip()
|
|
454
|
+
if hint:
|
|
455
|
+
line += f" ({hint})"
|
|
456
|
+
lines.append(line)
|
|
457
|
+
classifier_hint = str(getattr(config, "classifier_hint", "") or "").strip()
|
|
458
|
+
if classifier_hint:
|
|
459
|
+
lines.append(classifier_hint)
|
|
460
|
+
return "\n".join(lines)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def build_analysis_schema(type_enum: list[str], config: Any) -> dict[str, Any]:
|
|
464
|
+
"""Return the strict schema used for conventional analysis calls."""
|
|
465
|
+
|
|
466
|
+
return strict_json_schema(
|
|
467
|
+
{
|
|
468
|
+
"type": {"type": "string", "enum": type_enum, "description": "Conventional commit type"},
|
|
469
|
+
"scope": {"type": "string", "description": "Optional scope. Omit if unclear."},
|
|
470
|
+
"summary": {
|
|
471
|
+
"type": "string",
|
|
472
|
+
"description": "Umbrella commit summary without type/scope prefix or trailing period",
|
|
473
|
+
"maxLength": int(getattr(config, "summary_hard_limit", 128)),
|
|
474
|
+
},
|
|
475
|
+
"details": {
|
|
476
|
+
"type": "array",
|
|
477
|
+
"items": {
|
|
478
|
+
"type": "object",
|
|
479
|
+
"properties": {
|
|
480
|
+
"text": {"type": "string"},
|
|
481
|
+
"changelog_category": {
|
|
482
|
+
"type": "string",
|
|
483
|
+
"enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
|
|
484
|
+
},
|
|
485
|
+
"user_visible": {"type": "boolean"},
|
|
486
|
+
},
|
|
487
|
+
"required": ["text", "user_visible"],
|
|
488
|
+
"additionalProperties": False,
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
"issue_refs": {"type": "array", "items": {"type": "string"}},
|
|
492
|
+
},
|
|
493
|
+
["type", "summary", "details", "issue_refs"],
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def encode_cache_payload(source: OneShotSource | str, output: Any, text_content: str | None = None) -> str | None:
|
|
498
|
+
"""Encode parsed output for stable cache storage."""
|
|
499
|
+
|
|
500
|
+
if str(source) in {OneShotSource.PLAIN_TEXT_CONTENT.value, OneShotSource.OUTPUT_JSON_PARSE.value} and text_content:
|
|
501
|
+
return text_content
|
|
502
|
+
try:
|
|
503
|
+
return _JSON_CACHE_PREFIX + json.dumps(output, ensure_ascii=False, separators=(",", ":"), default=_json_default)
|
|
504
|
+
except TypeError:
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def decode_cache_payload(
|
|
509
|
+
tool_name: str, operation: str, stored: str, markdown_output: bool
|
|
510
|
+
) -> tuple[Any, str | None] | None:
|
|
511
|
+
"""Decode a cached payload using JSON first, then markdown/plain-text fallback."""
|
|
512
|
+
|
|
513
|
+
is_raw = not stored.startswith(_JSON_CACHE_PREFIX)
|
|
514
|
+
payload = stored if is_raw else stored.removeprefix(_JSON_CACHE_PREFIX)
|
|
515
|
+
try:
|
|
516
|
+
output = _parse_json_payload(payload)
|
|
517
|
+
except json.JSONDecodeError, ValueError, TypeError:
|
|
518
|
+
try:
|
|
519
|
+
output = _parse_plain_text(tool_name, payload, markdown_output)
|
|
520
|
+
except json.JSONDecodeError, ValueError, TypeError:
|
|
521
|
+
return None
|
|
522
|
+
if output is None:
|
|
523
|
+
return None
|
|
524
|
+
return output, payload if is_raw else None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def _run_oneshot_response(
|
|
528
|
+
config: Any, spec: OneShotSpec, *, markdown_output: bool | None = None
|
|
529
|
+
) -> OneShotResponse:
|
|
530
|
+
markdown_mode = bool(getattr(config, "markdown_output", True) if markdown_output is None else markdown_output)
|
|
531
|
+
mode = _resolved_mode(config, spec.model or "")
|
|
532
|
+
cache_entry = _build_cache_entry(config, spec, markdown_mode)
|
|
533
|
+
if cache_entry is not None:
|
|
534
|
+
cache_obj, key = cache_entry
|
|
535
|
+
stored = cache_obj.get(key)
|
|
536
|
+
if stored is not None:
|
|
537
|
+
decoded = decode_cache_payload(spec.tool_name, spec.operation, stored, markdown_mode)
|
|
538
|
+
if decoded is not None:
|
|
539
|
+
output, text = decoded
|
|
540
|
+
profile.print_llm_progress(lambda: f"cache hit {spec.operation} ({spec.model})")
|
|
541
|
+
return OneShotResponse(output=output, source=OneShotSource.CACHE, text_content=text)
|
|
542
|
+
|
|
543
|
+
attempts = max(1, int(getattr(config, "max_retries", 3)))
|
|
544
|
+
request_json = ""
|
|
545
|
+
response_text = ""
|
|
546
|
+
last_error: Exception | None = None
|
|
547
|
+
last_retry_from_error = False
|
|
548
|
+
for attempt in range(1, attempts + 1):
|
|
549
|
+
try:
|
|
550
|
+
request, response_text = await _send_oneshot(config, spec, mode, markdown_mode)
|
|
551
|
+
request_json = json.dumps(request, ensure_ascii=False, default=_json_default)
|
|
552
|
+
if not response_text.strip():
|
|
553
|
+
raise _RetryableResponse("empty response body")
|
|
554
|
+
response = _parse_oneshot_response(mode, spec.tool_name, spec.operation, response_text, markdown_mode)
|
|
555
|
+
if cache_entry is not None:
|
|
556
|
+
payload = encode_cache_payload(response.source, response.output, response.text_content)
|
|
557
|
+
if payload is not None:
|
|
558
|
+
cache_entry[0].put(cache_entry[1], spec.model or "", spec.operation, request_json, payload)
|
|
559
|
+
return response
|
|
560
|
+
except ApiContextLengthExceeded:
|
|
561
|
+
raise
|
|
562
|
+
except _RetryableResponse as exc:
|
|
563
|
+
last_error = exc
|
|
564
|
+
last_retry_from_error = False
|
|
565
|
+
except (
|
|
566
|
+
httpx.TimeoutException,
|
|
567
|
+
httpx.TransportError,
|
|
568
|
+
LgitError,
|
|
569
|
+
json.JSONDecodeError,
|
|
570
|
+
ValueError,
|
|
571
|
+
TypeError,
|
|
572
|
+
) as exc:
|
|
573
|
+
_record_failure(config, cache_entry, spec, request_json, response_text, exc)
|
|
574
|
+
last_error = exc
|
|
575
|
+
last_retry_from_error = True
|
|
576
|
+
if attempt < attempts:
|
|
577
|
+
await asyncio.sleep(max(0, int(getattr(config, "initial_backoff_ms", 1000))) / 1000 * (2 ** (attempt - 1)))
|
|
578
|
+
if last_retry_from_error and last_error is not None:
|
|
579
|
+
raise last_error
|
|
580
|
+
raise LgitError(f"Max retries exceeded for {spec.operation}: {last_error}")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
async def _send_oneshot(
|
|
584
|
+
config: Any, spec: OneShotSpec, mode: ResolvedApiMode, markdown_mode: bool
|
|
585
|
+
) -> tuple[dict[str, Any], str]:
|
|
586
|
+
timeout = httpx.Timeout(
|
|
587
|
+
float(getattr(config, "request_timeout_secs", 120)), connect=float(getattr(config, "connect_timeout_secs", 30))
|
|
588
|
+
)
|
|
589
|
+
headers = {"content-type": "application/json"}
|
|
590
|
+
api_key = getattr(config, "api_key", None)
|
|
591
|
+
if mode == ResolvedApiMode.CHAT_COMPLETIONS:
|
|
592
|
+
if api_key:
|
|
593
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
594
|
+
request = _openai_request(config, spec, markdown_mode)
|
|
595
|
+
url = urljoin(
|
|
596
|
+
str(getattr(config, "api_base_url", "http://localhost:4000")).rstrip("/") + "/", "chat/completions"
|
|
597
|
+
)
|
|
598
|
+
else:
|
|
599
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
600
|
+
if api_key:
|
|
601
|
+
headers["x-api-key"] = str(api_key)
|
|
602
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
603
|
+
if _anthropic_prompt_caching_enabled(config):
|
|
604
|
+
headers["anthropic-beta"] = "prompt-caching-2024-07-31"
|
|
605
|
+
request = _anthropic_request(config, spec, markdown_mode)
|
|
606
|
+
url = _anthropic_messages_url(str(getattr(config, "api_base_url", "")))
|
|
607
|
+
_save_debug(spec.debug, "request", request)
|
|
608
|
+
profile.print_llm_progress(lambda: f"query {spec.operation} model={spec.model}")
|
|
609
|
+
start = time.monotonic()
|
|
610
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
611
|
+
response = await client.post(url, headers=headers, json=request)
|
|
612
|
+
text = response.text
|
|
613
|
+
profile.print_llm_progress(
|
|
614
|
+
lambda: (
|
|
615
|
+
f"response {spec.operation} status={response.status_code} elapsed={time.monotonic() - start:.2f}s size={len(text)}B"
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
_save_debug_text(spec.debug, "response", text)
|
|
619
|
+
if not response.is_success and _is_context_length_error(text):
|
|
620
|
+
raise ApiContextLengthExceeded(
|
|
621
|
+
operation=spec.operation, model=spec.model or "", status=response.status_code, body=text
|
|
622
|
+
)
|
|
623
|
+
if 500 <= response.status_code <= 599:
|
|
624
|
+
raise _RetryableResponse(f"server error {response.status_code}: {text}")
|
|
625
|
+
if not response.is_success:
|
|
626
|
+
raise ApiError(status=response.status_code, body=text)
|
|
627
|
+
return request, text
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _openai_request(config: Any, spec: OneShotSpec, markdown_mode: bool) -> dict[str, Any]:
|
|
631
|
+
messages = []
|
|
632
|
+
if spec.system_prompt.strip():
|
|
633
|
+
messages.append({"role": "system", "content": spec.system_prompt})
|
|
634
|
+
messages.append({"role": "user", "content": spec.user_prompt})
|
|
635
|
+
request: dict[str, Any] = {"model": spec.model, "messages": messages}
|
|
636
|
+
if not markdown_mode:
|
|
637
|
+
request["tools"] = [_openai_tool(spec.tool_name, spec.tool_description, spec.schema or {})]
|
|
638
|
+
request["tool_choice"] = {"type": "function", "function": {"name": spec.tool_name}}
|
|
639
|
+
prompt_cache_key = _openai_prompt_cache_key(config, spec)
|
|
640
|
+
if prompt_cache_key:
|
|
641
|
+
request["prompt_cache_key"] = prompt_cache_key
|
|
642
|
+
return request
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _anthropic_request(config: Any, spec: OneShotSpec, markdown_mode: bool) -> dict[str, Any]:
|
|
646
|
+
prompt_caching = _anthropic_prompt_caching_enabled(config)
|
|
647
|
+
request: dict[str, Any] = {
|
|
648
|
+
"model": spec.model,
|
|
649
|
+
"max_tokens": ANTHROPIC_REQUIRED_MAX_TOKENS,
|
|
650
|
+
"messages": [{"role": "user", "content": [_anthropic_text(spec.user_prompt, prompt_caching)]}],
|
|
651
|
+
}
|
|
652
|
+
if spec.system_prompt.strip():
|
|
653
|
+
request["system"] = [_anthropic_text(spec.system_prompt, prompt_caching)]
|
|
654
|
+
if not markdown_mode:
|
|
655
|
+
request["tools"] = [_anthropic_tool(spec.tool_name, spec.tool_description, spec.schema or {}, prompt_caching)]
|
|
656
|
+
request["tool_choice"] = {"type": "tool", "name": spec.tool_name}
|
|
657
|
+
return request
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _parse_oneshot_response(
|
|
661
|
+
mode: ResolvedApiMode, tool_name: str, operation: str, response_text: str, markdown_mode: bool
|
|
662
|
+
) -> OneShotResponse:
|
|
663
|
+
if mode == ResolvedApiMode.CHAT_COMPLETIONS:
|
|
664
|
+
body = json.loads(response_text)
|
|
665
|
+
choices = body.get("choices") or []
|
|
666
|
+
if not choices:
|
|
667
|
+
raise LgitError(f"API returned empty response for {operation}")
|
|
668
|
+
message = choices[0].get("message") or {}
|
|
669
|
+
if refusal := message.get("refusal"):
|
|
670
|
+
raise LgitError(f"Model refused {operation}: {refusal}")
|
|
671
|
+
last_error: Exception | None = None
|
|
672
|
+
for call in message.get("tool_calls") or []:
|
|
673
|
+
function = (call or {}).get("function") or {}
|
|
674
|
+
if str(function.get("name", "")).endswith(tool_name):
|
|
675
|
+
args = str(function.get("arguments") or "").strip()
|
|
676
|
+
if not args:
|
|
677
|
+
last_error = LgitError(f"Model returned empty function arguments for {operation}")
|
|
678
|
+
else:
|
|
679
|
+
try:
|
|
680
|
+
return OneShotResponse(
|
|
681
|
+
_parse_tool_arguments(args, operation), OneShotSource.TOOL_CALL, message.get("content")
|
|
682
|
+
)
|
|
683
|
+
except LgitError as exc:
|
|
684
|
+
last_error = exc
|
|
685
|
+
content = message.get("content")
|
|
686
|
+
if content is not None:
|
|
687
|
+
if not str(content).strip():
|
|
688
|
+
raise _RetryableResponse("empty content")
|
|
689
|
+
return _parse_content_fallback(tool_name, operation, str(content), markdown_mode)
|
|
690
|
+
if last_error is not None:
|
|
691
|
+
raise last_error
|
|
692
|
+
raise LgitError(f"No {operation} found in API response")
|
|
693
|
+
|
|
694
|
+
tool_input, text_content, stop_reason = _extract_anthropic_content(response_text, tool_name)
|
|
695
|
+
if tool_input is not None:
|
|
696
|
+
return OneShotResponse(tool_input, OneShotSource.TOOL_CALL, text_content or None, stop_reason)
|
|
697
|
+
if not text_content.strip():
|
|
698
|
+
raise _RetryableResponse("empty content")
|
|
699
|
+
response = _parse_content_fallback(tool_name, operation, text_content, markdown_mode)
|
|
700
|
+
return OneShotResponse(response.output, response.source, response.text_content, stop_reason)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _parse_content_fallback(tool_name: str, operation: str, content: str, markdown_mode: bool) -> OneShotResponse:
|
|
704
|
+
try:
|
|
705
|
+
return OneShotResponse(_parse_json_payload(content), OneShotSource.OUTPUT_JSON_PARSE, content)
|
|
706
|
+
except (json.JSONDecodeError, ValueError, TypeError) as json_error:
|
|
707
|
+
try:
|
|
708
|
+
parsed = _parse_plain_text(tool_name, content, markdown_mode)
|
|
709
|
+
except (json.JSONDecodeError, ValueError, TypeError) as markdown_error:
|
|
710
|
+
raise LgitError(f"Failed to parse {operation} plain-text fallback: {markdown_error}") from markdown_error
|
|
711
|
+
if parsed is None:
|
|
712
|
+
raise LgitError(f"Failed to parse {operation} content JSON: {json_error}") from json_error
|
|
713
|
+
return OneShotResponse(parsed, OneShotSource.PLAIN_TEXT_CONTENT, content)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _parse_plain_text(tool_name: str, content: str, markdown_mode: bool) -> Any:
|
|
717
|
+
text = _normalize_plain_text(content)
|
|
718
|
+
if not text:
|
|
719
|
+
return None
|
|
720
|
+
if tool_name == "create_conventional_analysis":
|
|
721
|
+
return _analysis_to_mapping(parse_conventional_analysis_markdown(text))
|
|
722
|
+
if tool_name == "create_fast_commit":
|
|
723
|
+
commit = parse_fast_commit_markdown(text)
|
|
724
|
+
return {
|
|
725
|
+
"type": str(commit.commit_type),
|
|
726
|
+
"scope": None if commit.scope is None else str(commit.scope),
|
|
727
|
+
"summary": str(commit.summary),
|
|
728
|
+
"details": list(commit.body),
|
|
729
|
+
}
|
|
730
|
+
if tool_name == "create_file_observations":
|
|
731
|
+
from .markdown_output import parse_file_observations_markdown
|
|
732
|
+
|
|
733
|
+
return {"files": parse_file_observations_markdown(text)}
|
|
734
|
+
if tool_name == "create_changelog_entries":
|
|
735
|
+
return parse_changelog_response(text)
|
|
736
|
+
if tool_name == "create_compose_intent_plan":
|
|
737
|
+
return parse_compose_intent_markdown(text)
|
|
738
|
+
if tool_name == "bind_compose_hunks":
|
|
739
|
+
return parse_compose_binding_markdown(text)
|
|
740
|
+
if tool_name == "create_commit_summary":
|
|
741
|
+
return {"summary": parse_summary_markdown(text) if markdown_mode else strip_type_prefix(text)}
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _parse_tool_arguments(args: str, operation: str) -> Any:
|
|
746
|
+
try:
|
|
747
|
+
return _parse_json_payload(args)
|
|
748
|
+
except (json.JSONDecodeError, ValueError, TypeError) as exc:
|
|
749
|
+
raise LgitError(f"Failed to parse {operation} tool arguments: {exc}") from exc
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _parse_json_payload(text: str) -> Any:
|
|
753
|
+
candidate = _extract_json_from_content(text)
|
|
754
|
+
return json.loads(candidate)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _extract_json_from_content(content: str) -> str:
|
|
758
|
+
trimmed = _normalize_plain_text(content)
|
|
759
|
+
if not trimmed:
|
|
760
|
+
return trimmed
|
|
761
|
+
|
|
762
|
+
start = trimmed.find("{")
|
|
763
|
+
end = trimmed.rfind("}")
|
|
764
|
+
if start >= 0 and end >= start:
|
|
765
|
+
return trimmed[start : end + 1]
|
|
766
|
+
start = trimmed.find("[")
|
|
767
|
+
end = trimmed.rfind("]")
|
|
768
|
+
if start >= 0 and end >= start:
|
|
769
|
+
return trimmed[start : end + 1]
|
|
770
|
+
return trimmed
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _normalize_plain_text(content: str) -> str:
|
|
774
|
+
trimmed = content.strip()
|
|
775
|
+
fenced = re.search(r"```(?:json|markdown|md)?\s*(.*?)```", trimmed, re.IGNORECASE | re.DOTALL)
|
|
776
|
+
return fenced.group(1).strip() if fenced else trimmed
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _extract_anthropic_content(response_text: str, tool_name: str) -> tuple[Any | None, str, str | None]:
|
|
780
|
+
value = json.loads(response_text)
|
|
781
|
+
stop_reason = value.get("stop_reason")
|
|
782
|
+
tool_input = None
|
|
783
|
+
text_parts: list[str] = []
|
|
784
|
+
for item in value.get("content") or []:
|
|
785
|
+
item_type = item.get("type", "")
|
|
786
|
+
if item_type == "tool_use" and item.get("name") == tool_name:
|
|
787
|
+
tool_input = item.get("input")
|
|
788
|
+
elif item_type == "text" and isinstance(item.get("text"), str):
|
|
789
|
+
text_parts.append(item["text"])
|
|
790
|
+
return tool_input, "\n".join(text_parts), None if stop_reason is None else str(stop_reason)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _coerce_spec(prompt: str | OneShotSpec | Mapping[str, Any] | None, **kwargs: Any) -> OneShotSpec:
|
|
794
|
+
spec = kwargs.pop("spec", None)
|
|
795
|
+
if isinstance(spec, OneShotSpec):
|
|
796
|
+
return spec
|
|
797
|
+
if isinstance(prompt, OneShotSpec):
|
|
798
|
+
return prompt
|
|
799
|
+
if spec is None and isinstance(prompt, Mapping):
|
|
800
|
+
spec = prompt
|
|
801
|
+
if isinstance(spec, Mapping):
|
|
802
|
+
values = dict(spec)
|
|
803
|
+
if "cache" in values and "cacheable" not in values:
|
|
804
|
+
values["cacheable"] = values.pop("cache")
|
|
805
|
+
return OneShotSpec(**{key: value for key, value in values.items() if key in OneShotSpec.__dataclass_fields__})
|
|
806
|
+
schema = kwargs["schema"] or strict_json_schema({"response": {"type": "string"}}, ["response"])
|
|
807
|
+
schema_name = kwargs["schema_name"]
|
|
808
|
+
inferred_tool = kwargs["tool_name"] or (
|
|
809
|
+
schema_name if schema_name.startswith(("create_", "bind_")) else f"create_{schema_name}"
|
|
810
|
+
)
|
|
811
|
+
return OneShotSpec(
|
|
812
|
+
operation=kwargs["operation"] or schema_name,
|
|
813
|
+
model=resolve_model_name(str(kwargs["model"] or "")) if kwargs["model"] else None,
|
|
814
|
+
prompt_family=kwargs["prompt_family"],
|
|
815
|
+
prompt_variant=kwargs["prompt_variant"],
|
|
816
|
+
system_prompt=kwargs["system_prompt"] or "",
|
|
817
|
+
user_prompt=str(prompt or ""),
|
|
818
|
+
tool_name=inferred_tool,
|
|
819
|
+
tool_description=kwargs["tool_description"] or f"Create {schema_name}",
|
|
820
|
+
schema=schema,
|
|
821
|
+
progress_label=kwargs["operation"] or schema_name,
|
|
822
|
+
debug=_coerce_debug(kwargs["debug"], kwargs["debug_label"] or schema_name),
|
|
823
|
+
cacheable=bool(kwargs["cacheable"]),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _coerce_debug(debug: OneShotDebug | Mapping[str, Any] | str | Path | None, name: str) -> OneShotDebug | None:
|
|
828
|
+
if debug is None:
|
|
829
|
+
return None
|
|
830
|
+
if isinstance(debug, OneShotDebug):
|
|
831
|
+
return debug
|
|
832
|
+
if isinstance(debug, Mapping):
|
|
833
|
+
return OneShotDebug(**debug)
|
|
834
|
+
return OneShotDebug(debug, None, name)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _resolved_mode(config: Any, model: str) -> ResolvedApiMode:
|
|
838
|
+
resolver = getattr(config, "resolve_api_mode", None)
|
|
839
|
+
if callable(resolver):
|
|
840
|
+
return resolver(model)
|
|
841
|
+
return config.resolved_api_mode
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _build_cache_entry(config: Any, spec: OneShotSpec, markdown_mode: bool) -> tuple[Any, str] | None:
|
|
845
|
+
if not spec.cacheable:
|
|
846
|
+
return None
|
|
847
|
+
cache_obj = llm_cache.global_cache()
|
|
848
|
+
if cache_obj is None:
|
|
849
|
+
return None
|
|
850
|
+
mode = str(_resolved_mode(config, spec.model or ""))
|
|
851
|
+
key = llm_cache.compute_key(
|
|
852
|
+
llm_cache.CacheMaterial(
|
|
853
|
+
operation=spec.operation,
|
|
854
|
+
model=spec.model or "",
|
|
855
|
+
tool_name=spec.tool_name,
|
|
856
|
+
tool_description=spec.tool_description,
|
|
857
|
+
system_prompt=spec.system_prompt,
|
|
858
|
+
user_prompt=spec.user_prompt,
|
|
859
|
+
schema=spec.schema or {},
|
|
860
|
+
api_mode=mode,
|
|
861
|
+
markdown_output=markdown_mode,
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
return cache_obj, key
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _record_failure(
|
|
868
|
+
config: Any, cache_entry: tuple[Any, str] | None, spec: OneShotSpec, request: str, response: str, error: Exception
|
|
869
|
+
) -> None:
|
|
870
|
+
sink = llm_cache.global_cache()
|
|
871
|
+
if sink is None and cache_entry is not None:
|
|
872
|
+
sink = cache_entry[0]
|
|
873
|
+
if sink is not None:
|
|
874
|
+
sink.put_failure(
|
|
875
|
+
cache_entry[1] if cache_entry else "", spec.model or "", spec.operation, request, response, str(error)
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _openai_tool(name: str, description: str, schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
880
|
+
if "properties" not in schema:
|
|
881
|
+
raise LgitError("Schema must include top-level properties")
|
|
882
|
+
required = schema.get("required")
|
|
883
|
+
if not isinstance(required, (list, tuple)) or not all(isinstance(value, str) for value in required):
|
|
884
|
+
raise LgitError("Schema must include top-level required array of strings")
|
|
885
|
+
parameters = {
|
|
886
|
+
"type": "object",
|
|
887
|
+
"properties": dict(schema["properties"]),
|
|
888
|
+
"required": list(required),
|
|
889
|
+
"additionalProperties": False,
|
|
890
|
+
}
|
|
891
|
+
return {"type": "function", "function": {"name": name, "description": description, "parameters": parameters}}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _anthropic_tool(name: str, description: str, schema: Mapping[str, Any], cache: bool) -> dict[str, Any]:
|
|
895
|
+
tool = {"name": name, "description": description, "input_schema": dict(schema)}
|
|
896
|
+
if cache:
|
|
897
|
+
tool["cache_control"] = {"type": "ephemeral"}
|
|
898
|
+
return tool
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _anthropic_text(text: str, cache: bool) -> dict[str, Any]:
|
|
902
|
+
content = {"type": "text", "text": text}
|
|
903
|
+
if cache:
|
|
904
|
+
content["cache_control"] = {"type": "ephemeral"}
|
|
905
|
+
return content
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _anthropic_prompt_caching_enabled(config: Any) -> bool:
|
|
909
|
+
return "anthropic.com" in str(getattr(config, "api_base_url", "")).lower()
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _anthropic_messages_url(base_url: str) -> str:
|
|
913
|
+
trimmed = base_url.rstrip("/")
|
|
914
|
+
return f"{trimmed}/messages" if trimmed.endswith("/v1") else f"{trimmed}/v1/messages"
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _openai_prompt_cache_key(config: Any, spec: OneShotSpec) -> str | None:
|
|
918
|
+
base_url = str(getattr(config, "api_base_url", "")).lower()
|
|
919
|
+
if not spec.system_prompt.strip() or "api.openai.com" not in base_url:
|
|
920
|
+
return None
|
|
921
|
+
return f"llm-git:v1:{spec.model}:{spec.prompt_family}:{spec.prompt_variant}"
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _is_context_length_error(body: str) -> bool:
|
|
925
|
+
lower = body.lower()
|
|
926
|
+
return any(marker in lower for marker in _CONTEXT_LENGTH_MARKERS)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _save_debug(debug: OneShotDebug | Mapping[str, Any] | str | Path | None, phase: str, value: Any) -> None:
|
|
930
|
+
if debug is None:
|
|
931
|
+
return
|
|
932
|
+
_save_debug_text(debug, phase, json.dumps(value, ensure_ascii=False, indent=2, default=_json_default))
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _save_debug_text(debug: OneShotDebug | Mapping[str, Any] | str | Path | None, phase: str, text: str) -> None:
|
|
936
|
+
debug_obj = _coerce_debug(debug, "oneshot")
|
|
937
|
+
if debug_obj is None or debug_obj.dir is None:
|
|
938
|
+
return
|
|
939
|
+
directory = Path(debug_obj.dir)
|
|
940
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
941
|
+
prefix = f"{debug_obj.prefix}_" if debug_obj.prefix else ""
|
|
942
|
+
path = directory / f"{prefix}{debug_obj.name}_{phase}.json"
|
|
943
|
+
path.write_text(text, encoding="utf-8")
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _render_with_template_helper(
|
|
947
|
+
templates: Any, family: str, variant: str, context: Mapping[str, Any]
|
|
948
|
+
) -> tuple[str, str] | None:
|
|
949
|
+
helper = getattr(templates, f"render_{family.replace('-', '_')}_prompt", None)
|
|
950
|
+
if not callable(helper):
|
|
951
|
+
return None
|
|
952
|
+
match family:
|
|
953
|
+
case "analysis" | "fast":
|
|
954
|
+
parts = helper(variant=variant, **dict(context))
|
|
955
|
+
case "summary":
|
|
956
|
+
parts = helper(
|
|
957
|
+
variant,
|
|
958
|
+
str(context.get("commit_type", "")),
|
|
959
|
+
str(context.get("scope") or ""),
|
|
960
|
+
str(context.get("chars", "")),
|
|
961
|
+
str(context.get("details", "")),
|
|
962
|
+
str(context.get("stat", "")),
|
|
963
|
+
context.get("user_context"),
|
|
964
|
+
)
|
|
965
|
+
case "map":
|
|
966
|
+
parts = helper(variant, context.get("files", ()), str(context.get("context_header", "")))
|
|
967
|
+
case "reduce":
|
|
968
|
+
parts = helper(
|
|
969
|
+
variant,
|
|
970
|
+
str(context.get("observations", "")),
|
|
971
|
+
str(context.get("stat", "")),
|
|
972
|
+
str(context.get("scope_candidates", "")),
|
|
973
|
+
context.get("types_description"),
|
|
974
|
+
)
|
|
975
|
+
case _:
|
|
976
|
+
return None
|
|
977
|
+
return str(parts.system), str(parts.user)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _split_prompt(text: str) -> tuple[str, str]:
|
|
981
|
+
marker = "======USER======="
|
|
982
|
+
if marker in text:
|
|
983
|
+
system, user = text.split(marker, 1)
|
|
984
|
+
return system.strip(), user.strip()
|
|
985
|
+
return text.strip(), ""
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _coerce_analysis(output: Any, text_content: str | None, *, default_type: str) -> ConventionalAnalysis:
|
|
989
|
+
if isinstance(output, ConventionalAnalysis):
|
|
990
|
+
return output
|
|
991
|
+
if isinstance(output, Mapping):
|
|
992
|
+
return analysis_from_mapping(output, default_type=default_type)
|
|
993
|
+
if text_content:
|
|
994
|
+
return parse_conventional_analysis_markdown(text_content, default_type=default_type)
|
|
995
|
+
return parse_conventional_analysis_markdown(str(output), default_type=default_type)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _summary_from_output(output: Any, text_content: str | None) -> str:
|
|
999
|
+
if isinstance(output, Mapping):
|
|
1000
|
+
value = output.get("summary")
|
|
1001
|
+
if value:
|
|
1002
|
+
return strip_type_prefix(str(value))
|
|
1003
|
+
if isinstance(output, str):
|
|
1004
|
+
return parse_summary_markdown(output)
|
|
1005
|
+
return parse_summary_markdown(text_content or "")
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _coerce_fast_commit(output: Any, text_content: str | None, *, default_type: str) -> ConventionalCommit:
|
|
1009
|
+
if isinstance(output, ConventionalCommit):
|
|
1010
|
+
return output
|
|
1011
|
+
if isinstance(output, Mapping):
|
|
1012
|
+
analysis = analysis_from_mapping(output, default_type=default_type)
|
|
1013
|
+
return ConventionalCommit.from_raw(
|
|
1014
|
+
commit_type=str(analysis.commit_type),
|
|
1015
|
+
scope=None if analysis.scope is None else str(analysis.scope),
|
|
1016
|
+
summary=analysis.summary
|
|
1017
|
+
or fallback_summary(details=analysis.body_texts(), commit_type=str(analysis.commit_type)),
|
|
1018
|
+
body=analysis.body_texts(),
|
|
1019
|
+
)
|
|
1020
|
+
if text_content:
|
|
1021
|
+
return parse_fast_commit_markdown(text_content, default_type=default_type)
|
|
1022
|
+
return parse_fast_commit_markdown(str(output), default_type=default_type)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _analysis_to_mapping(analysis: ConventionalAnalysis) -> dict[str, Any]:
|
|
1026
|
+
return {
|
|
1027
|
+
"type": str(analysis.commit_type),
|
|
1028
|
+
"scope": None if analysis.scope is None else str(analysis.scope),
|
|
1029
|
+
"summary": analysis.summary or "",
|
|
1030
|
+
"details": [
|
|
1031
|
+
{
|
|
1032
|
+
"text": detail.text,
|
|
1033
|
+
**(
|
|
1034
|
+
{"changelog_category": detail.changelog_category.value}
|
|
1035
|
+
if detail.changelog_category is not None
|
|
1036
|
+
else {}
|
|
1037
|
+
),
|
|
1038
|
+
"user_visible": detail.user_visible,
|
|
1039
|
+
}
|
|
1040
|
+
for detail in analysis.details
|
|
1041
|
+
],
|
|
1042
|
+
"issue_refs": list(analysis.issue_refs),
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _json_default(value: Any) -> Any:
|
|
1047
|
+
if hasattr(value, "value"):
|
|
1048
|
+
return value.value
|
|
1049
|
+
if hasattr(value, "__dict__"):
|
|
1050
|
+
return vars(value)
|
|
1051
|
+
return str(value)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
class _RetryableResponse(Exception):
|
|
1055
|
+
pass
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
__all__ = [
|
|
1059
|
+
"OneShotDebug",
|
|
1060
|
+
"OneShotResponse",
|
|
1061
|
+
"OneShotSource",
|
|
1062
|
+
"OneShotSpec",
|
|
1063
|
+
"build_analysis_schema",
|
|
1064
|
+
"decode_cache_payload",
|
|
1065
|
+
"encode_cache_payload",
|
|
1066
|
+
"fallback_summary",
|
|
1067
|
+
"format_types_description",
|
|
1068
|
+
"generate_analysis_with_map_reduce",
|
|
1069
|
+
"generate_conventional_analysis",
|
|
1070
|
+
"generate_fast_commit",
|
|
1071
|
+
"generate_summary_from_analysis",
|
|
1072
|
+
"summary_from_holistic_analysis",
|
|
1073
|
+
"render_prompt",
|
|
1074
|
+
"run_oneshot",
|
|
1075
|
+
"strict_json_schema",
|
|
1076
|
+
"strip_type_prefix",
|
|
1077
|
+
]
|