laminate-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.
- laminate/__init__.py +0 -0
- laminate/categorize.py +280 -0
- laminate/cli.py +91 -0
- laminate/gitlog.py +99 -0
- laminate/llm.py +95 -0
- laminate/render.py +71 -0
- laminate_cli-0.1.0.dist-info/METADATA +29 -0
- laminate_cli-0.1.0.dist-info/RECORD +12 -0
- laminate_cli-0.1.0.dist-info/WHEEL +5 -0
- laminate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- laminate_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
- laminate_cli-0.1.0.dist-info/top_level.txt +1 -0
laminate/__init__.py
ADDED
|
File without changes
|
laminate/categorize.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from typing import Callable, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .gitlog import Commit
|
|
9
|
+
from .llm import get_llm
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
Category = Literal["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
|
|
14
|
+
|
|
15
|
+
DEFAULT_BATCH_SIZE = 15
|
|
16
|
+
|
|
17
|
+
SYSTEM_PROMPT = """You are a precise changelog generator.
|
|
18
|
+
|
|
19
|
+
You will be given a numbered list of raw git commit subjects.
|
|
20
|
+
|
|
21
|
+
Your job is to turn these commits into concise, human-readable changelog entries.
|
|
22
|
+
|
|
23
|
+
Related commits may be GROUPED together when they describe the same user-facing
|
|
24
|
+
change, feature, bug fix, or piece of functionality. A group can contain one
|
|
25
|
+
or multiple commits.
|
|
26
|
+
|
|
27
|
+
Do not group unrelated changes merely because they are similar in implementation.
|
|
28
|
+
|
|
29
|
+
Prefer a small number of meaningful changelog entries over one entry per commit.
|
|
30
|
+
Intermediate implementation commits, refactors, tests, and small follow-up fixes
|
|
31
|
+
may be grouped with the larger change they belong to.
|
|
32
|
+
|
|
33
|
+
For each changelog entry, produce:
|
|
34
|
+
|
|
35
|
+
- "category": exactly one of "Added", "Changed", "Deprecated", "Removed", "Fixed" or "Security"
|
|
36
|
+
- "description": a short, human-readable rewrite of the grouped commits,
|
|
37
|
+
written for someone reading a changelog rather than a git log
|
|
38
|
+
- "commits": a list of the input commit numbers belonging to this entry
|
|
39
|
+
|
|
40
|
+
Category definitions:
|
|
41
|
+
- Added: a new feature, capability, command, option, or user-facing functionality
|
|
42
|
+
- Changed: a modification to existing behavior or functionality
|
|
43
|
+
- Deprecated: functionality being phased out or marked for future removal
|
|
44
|
+
- Removed: functionality that has been removed
|
|
45
|
+
- Fixed: a bug fix or correction to broken behavior
|
|
46
|
+
- Security: a security vulnerability fix or security-related change
|
|
47
|
+
|
|
48
|
+
For descriptions:
|
|
49
|
+
- no ticket numbers
|
|
50
|
+
- no "fix:" or "feat:" prefixes
|
|
51
|
+
- plain sentence case
|
|
52
|
+
- describe the user-facing result rather than implementation details
|
|
53
|
+
|
|
54
|
+
Every input commit number must appear in exactly one group.
|
|
55
|
+
Do not omit commits.
|
|
56
|
+
Do not duplicate commit numbers.
|
|
57
|
+
|
|
58
|
+
Respond with ONLY a JSON array.
|
|
59
|
+
No markdown fences, no commentary, and no extra text before or after the array.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
"category": "Added",
|
|
66
|
+
"description": "Added user authentication and OAuth support.",
|
|
67
|
+
"commits": [1, 2, 4]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"category": "Fixed",
|
|
71
|
+
"description": "Fixed authentication callback handling.",
|
|
72
|
+
"commits": [3]
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CategorizedEntry(BaseModel):
|
|
79
|
+
category: Category
|
|
80
|
+
description: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CategorizeError(Exception):
|
|
84
|
+
"""Raised when a batch can't be categorized at all."""
|
|
85
|
+
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _chunk(items: list, size: int) -> list[list]:
|
|
90
|
+
return [items[i:i + size] for i in range(0, len(items), size)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_prompt(commits: list[Commit]) -> str:
|
|
94
|
+
lines = [
|
|
95
|
+
f"{i + 1}. {c.subject}"
|
|
96
|
+
for i, c in enumerate(commits)
|
|
97
|
+
]
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _strip_fences(text: str) -> str:
|
|
102
|
+
"""Prevents the model from adding markdown fences around the JSON."""
|
|
103
|
+
text = text.strip()
|
|
104
|
+
|
|
105
|
+
if text.startswith("```"):
|
|
106
|
+
newline_idx = text.find("\n")
|
|
107
|
+
if newline_idx != -1:
|
|
108
|
+
text = text[newline_idx + 1:]
|
|
109
|
+
else:
|
|
110
|
+
text = text[3:]
|
|
111
|
+
|
|
112
|
+
if text.endswith("```"):
|
|
113
|
+
text = text.rsplit("```", 1)[0]
|
|
114
|
+
|
|
115
|
+
return text.strip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fallback_entries(commits: list[Commit]) -> list[CategorizedEntry]:
|
|
119
|
+
return [
|
|
120
|
+
CategorizedEntry(
|
|
121
|
+
category="Changed",
|
|
122
|
+
description=c.subject,
|
|
123
|
+
)
|
|
124
|
+
for c in commits
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _categorize_batch(
|
|
129
|
+
commits: list[Commit],
|
|
130
|
+
llm,
|
|
131
|
+
) -> list[CategorizedEntry]:
|
|
132
|
+
"""Categorize and intelligently group a batch of commits."""
|
|
133
|
+
|
|
134
|
+
prompt = _build_prompt(commits)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
response = llm.create_chat_completion(
|
|
138
|
+
messages=[
|
|
139
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
140
|
+
{"role": "user", "content": prompt},
|
|
141
|
+
],
|
|
142
|
+
temperature=0.3,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
text = response["choices"][0]["message"]["content"]
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.warning(f"LLM error: {e}")
|
|
149
|
+
return _fallback_entries(commits)
|
|
150
|
+
|
|
151
|
+
text = _strip_fences(text)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
parsed = json.loads(text)
|
|
155
|
+
|
|
156
|
+
except json.JSONDecodeError as e:
|
|
157
|
+
logger.warning(f"Invalid JSON: {e}")
|
|
158
|
+
return _fallback_entries(commits)
|
|
159
|
+
|
|
160
|
+
if not isinstance(parsed, list):
|
|
161
|
+
logger.warning(
|
|
162
|
+
f"Expected list, got {type(parsed).__name__}"
|
|
163
|
+
)
|
|
164
|
+
return _fallback_entries(commits)
|
|
165
|
+
|
|
166
|
+
entries: list[CategorizedEntry] = []
|
|
167
|
+
used_commits: set[int] = set()
|
|
168
|
+
|
|
169
|
+
for item in parsed:
|
|
170
|
+
try:
|
|
171
|
+
category = item["category"]
|
|
172
|
+
description = item["description"]
|
|
173
|
+
commit_indices = item["commits"]
|
|
174
|
+
|
|
175
|
+
if not isinstance(commit_indices, list):
|
|
176
|
+
raise ValueError("commits must be a list")
|
|
177
|
+
|
|
178
|
+
if not commit_indices:
|
|
179
|
+
raise ValueError("commits list cannot be empty")
|
|
180
|
+
|
|
181
|
+
normalized_indices = []
|
|
182
|
+
|
|
183
|
+
for index in commit_indices:
|
|
184
|
+
if not isinstance(index, int):
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"commit index must be an integer, got {index!r}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if not 1 <= index <= len(commits):
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"commit index {index} is out of range"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if index in used_commits:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"commit {index} appears in multiple groups"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
normalized_indices.append(index)
|
|
200
|
+
|
|
201
|
+
entry = CategorizedEntry(
|
|
202
|
+
category=category,
|
|
203
|
+
description=description,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
entries.append(entry)
|
|
207
|
+
used_commits.update(normalized_indices)
|
|
208
|
+
|
|
209
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
210
|
+
logger.warning(f"Invalid grouped entry: {e}")
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
missing_commits = [
|
|
214
|
+
i
|
|
215
|
+
for i in range(1, len(commits) + 1)
|
|
216
|
+
if i not in used_commits
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
if missing_commits:
|
|
220
|
+
logger.info(
|
|
221
|
+
f"Model omitted {len(missing_commits)} commits; "
|
|
222
|
+
"using fallback entries for them"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
for index in missing_commits:
|
|
226
|
+
entries.append(
|
|
227
|
+
CategorizedEntry(
|
|
228
|
+
category="Changed",
|
|
229
|
+
description=commits[index - 1].subject,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if not entries:
|
|
234
|
+
return _fallback_entries(commits)
|
|
235
|
+
|
|
236
|
+
return entries
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def categorize_commits(
|
|
240
|
+
commits: list[Commit],
|
|
241
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
242
|
+
on_progress: Callable[[int, int, int], None] | None = None,
|
|
243
|
+
) -> list[CategorizedEntry]:
|
|
244
|
+
if not commits:
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
llm = get_llm()
|
|
248
|
+
all_entries: list[CategorizedEntry] = []
|
|
249
|
+
batches = _chunk(commits, batch_size)
|
|
250
|
+
|
|
251
|
+
for i, batch in enumerate(batches):
|
|
252
|
+
if on_progress:
|
|
253
|
+
on_progress(
|
|
254
|
+
i + 1,
|
|
255
|
+
len(batches),
|
|
256
|
+
len(batch),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
all_entries.extend(
|
|
260
|
+
_categorize_batch(batch, llm)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return all_entries
|
|
264
|
+
|
|
265
|
+
# testing purposes only
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
from .gitlog import get_commits
|
|
268
|
+
|
|
269
|
+
commits = get_commits()[:15]
|
|
270
|
+
|
|
271
|
+
if not commits:
|
|
272
|
+
print("No commits found in current directory.")
|
|
273
|
+
else:
|
|
274
|
+
print(f"Categorizing {len(commits)} commits...\n")
|
|
275
|
+
results = categorize_commits(commits)
|
|
276
|
+
for entry in results:
|
|
277
|
+
print(
|
|
278
|
+
f"[{entry.category}] "
|
|
279
|
+
f"{entry.description}"
|
|
280
|
+
)
|
laminate/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.spinner import Spinner
|
|
10
|
+
|
|
11
|
+
from .gitlog import get_commits, GitLogError
|
|
12
|
+
from .categorize import categorize_commits
|
|
13
|
+
from .llm import LLMError
|
|
14
|
+
from .render import render_changelog, write_changelog
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(add_completion=False)
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def generate(
|
|
22
|
+
repo: str = typer.Option(".", help="Path to the git repository."),
|
|
23
|
+
since: str = typer.Option(None, help="Only include commits after this date, e.g. 2026-01-01."),
|
|
24
|
+
until: str = typer.Option(None, help="Only include commits before this date, e.g. 2026-01-01."),
|
|
25
|
+
commit_range: str = typer.Option(None, "--range", help="A git commit range, e.g. v1.0.0..v1.1.0. Overrides --since/--until."),
|
|
26
|
+
output: str = typer.Option("CHANGELOG.md", help="Path to write the generated changelog to."),
|
|
27
|
+
batch_size: int = typer.Option(15, "--batch-size", help="Number of commits sent to the LLM per batch."),
|
|
28
|
+
release: str = typer.Option(None, "--release", help="Version number for this release, e.g. 1.1.0. If omitted, the changelog is headed [Unreleased]."),
|
|
29
|
+
):
|
|
30
|
+
if repo != ".":
|
|
31
|
+
typer.echo(f"Reading commits from '{repo}'...")
|
|
32
|
+
else:
|
|
33
|
+
typer.echo("Reading commits from working directory...")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
commits = get_commits(
|
|
38
|
+
repo_path=repo,
|
|
39
|
+
since=since,
|
|
40
|
+
until=until,
|
|
41
|
+
commit_range=commit_range,
|
|
42
|
+
)
|
|
43
|
+
except GitLogError as e:
|
|
44
|
+
typer.echo(f"Error reading git log: {e}", err=True)
|
|
45
|
+
raise typer.Exit(code=1)
|
|
46
|
+
|
|
47
|
+
if not commits:
|
|
48
|
+
typer.echo("No commits found for the given range - nothing to do.")
|
|
49
|
+
raise typer.Exit(code=0)
|
|
50
|
+
|
|
51
|
+
console.print(f"Found {len(commits)} commits.")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
spinner = Spinner(
|
|
55
|
+
"dots",
|
|
56
|
+
text="Categorizing commits with the local model...",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def progress(current, total, count):
|
|
60
|
+
spinner.update(
|
|
61
|
+
text=f"Categorizing commits... batch {current}/{total} ({count} commits)"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
with Live(spinner, refresh_per_second=10):
|
|
65
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
66
|
+
future = executor.submit(
|
|
67
|
+
categorize_commits,
|
|
68
|
+
commits,
|
|
69
|
+
batch_size=batch_size,
|
|
70
|
+
on_progress=progress,
|
|
71
|
+
)
|
|
72
|
+
entries = future.result()
|
|
73
|
+
|
|
74
|
+
console.print(f"✓ Categorized {len(commits)} commits.")
|
|
75
|
+
|
|
76
|
+
except LLMError as e:
|
|
77
|
+
typer.echo(
|
|
78
|
+
f"Error loading or running the local model: {e}",
|
|
79
|
+
err=True,
|
|
80
|
+
)
|
|
81
|
+
raise typer.Exit(code=1)
|
|
82
|
+
|
|
83
|
+
changelog = render_changelog(entries, version=release)
|
|
84
|
+
write_changelog(changelog, output)
|
|
85
|
+
|
|
86
|
+
typer.echo(f"\nDone. Changelog written to {output}", file=sys.stdout)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
app()
|
laminate/gitlog.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
FIELD_SEP = "\x1f"
|
|
6
|
+
RECORD_SEP = "\x1e"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Commit(BaseModel):
|
|
10
|
+
hash: str
|
|
11
|
+
short_hash: str
|
|
12
|
+
author: str
|
|
13
|
+
date: str # ISO 8601, e.g. 2026-09-30T14:22:01-05:00
|
|
14
|
+
subject: str
|
|
15
|
+
|
|
16
|
+
class GitLogError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
def get_commits(
|
|
20
|
+
repo_path: str = ".",
|
|
21
|
+
since: str | None = None,
|
|
22
|
+
until: str | None = None,
|
|
23
|
+
commit_range: str | None = None,
|
|
24
|
+
) -> list[Commit]:
|
|
25
|
+
"""
|
|
26
|
+
Returns commits from the given repo, newest first.
|
|
27
|
+
|
|
28
|
+
repo_path: path to the git repository (defaults to current directory)
|
|
29
|
+
since / until: date bounds, e.g. since="2026-01-01"
|
|
30
|
+
commit_range: a git revision range, e.g. "v1.0.0..v1.1.0" - if given,
|
|
31
|
+
this takes priority over since/until
|
|
32
|
+
|
|
33
|
+
Raises GitLogError if the path isn't a git repo, the range is
|
|
34
|
+
invalid, or git itself isn't installed/on PATH.
|
|
35
|
+
"""
|
|
36
|
+
pretty_format = FIELD_SEP.join(["%H", "%h", "%an", "%aI", "%s"]) + RECORD_SEP
|
|
37
|
+
|
|
38
|
+
cmd = ["git", "-C", repo_path, "log", f"--pretty=format:{pretty_format}"]
|
|
39
|
+
|
|
40
|
+
if commit_range:
|
|
41
|
+
cmd.append(commit_range)
|
|
42
|
+
else:
|
|
43
|
+
if since:
|
|
44
|
+
cmd.append(f"--since={since}")
|
|
45
|
+
if until:
|
|
46
|
+
cmd.append(f"--until={until}")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
cmd,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
check=True,
|
|
54
|
+
)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
raise GitLogError(
|
|
57
|
+
"git doesn't appear to be installed, or isn't on your PATH."
|
|
58
|
+
)
|
|
59
|
+
except subprocess.CalledProcessError as e:
|
|
60
|
+
raise GitLogError(
|
|
61
|
+
f"git log failed (is '{repo_path}' a git repo, and is the "
|
|
62
|
+
f"commit range valid?): {e.stderr.strip()}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
raw_output = result.stdout
|
|
66
|
+
if not raw_output.strip():
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
commits = []
|
|
70
|
+
for record in raw_output.split(RECORD_SEP):
|
|
71
|
+
record = record.strip()
|
|
72
|
+
if not record:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
fields = record.split(FIELD_SEP)
|
|
76
|
+
if len(fields) != 5:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
full_hash, short_hash, author, date, subject = fields
|
|
80
|
+
commits.append(
|
|
81
|
+
Commit(
|
|
82
|
+
hash=full_hash,
|
|
83
|
+
short_hash=short_hash,
|
|
84
|
+
author=author,
|
|
85
|
+
date=date,
|
|
86
|
+
subject=subject,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return commits
|
|
91
|
+
|
|
92
|
+
# testing purposes only
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
commits = get_commits()
|
|
95
|
+
print(f"Found {len(commits)} commits:\n")
|
|
96
|
+
for c in commits[:10]:
|
|
97
|
+
print(f"{c.short_hash} {c.date} {c.author}: {c.subject}")
|
|
98
|
+
if len(commits) > 10:
|
|
99
|
+
print(f"... and {len(commits) - 10} more")
|
laminate/llm.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from llama_cpp import Llama
|
|
3
|
+
|
|
4
|
+
MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
|
|
5
|
+
|
|
6
|
+
DEFAULT_N_CTX = 4096
|
|
7
|
+
_llm: Llama | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LLMError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _find_model_file() -> Path:
|
|
15
|
+
if not MODELS_DIR.exists():
|
|
16
|
+
raise LLMError(
|
|
17
|
+
f"Models folder not found at {MODELS_DIR}. Create it and "
|
|
18
|
+
"place a .gguf model file inside (see README for a "
|
|
19
|
+
"recommended model and download link)."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
gguf_files = list(MODELS_DIR.glob("*.gguf"))
|
|
23
|
+
|
|
24
|
+
if not gguf_files:
|
|
25
|
+
raise LLMError(
|
|
26
|
+
f"No .gguf model file found in {MODELS_DIR}. Download a "
|
|
27
|
+
"compatible model (see README) and place it in that folder."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if len(gguf_files) > 1:
|
|
31
|
+
names = ", ".join(f.name for f in gguf_files)
|
|
32
|
+
raise LLMError(
|
|
33
|
+
f"Multiple .gguf files found in {MODELS_DIR} ({names}) - "
|
|
34
|
+
"keep only one so Laminate knows which to load."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return gguf_files[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_llm(n_ctx: int = DEFAULT_N_CTX) -> Llama:
|
|
41
|
+
global _llm
|
|
42
|
+
|
|
43
|
+
if _llm is None:
|
|
44
|
+
model_path = _find_model_file()
|
|
45
|
+
try:
|
|
46
|
+
_llm = Llama(
|
|
47
|
+
model_path=str(model_path),
|
|
48
|
+
n_ctx=n_ctx,
|
|
49
|
+
verbose=False,
|
|
50
|
+
)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
raise LLMError(f"Failed to load model at {model_path}: {e}")
|
|
53
|
+
|
|
54
|
+
return _llm
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generate(
|
|
58
|
+
prompt: str,
|
|
59
|
+
system_prompt: str | None = None,
|
|
60
|
+
max_tokens: int = 512,
|
|
61
|
+
temperature: float = 0.3,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Sends a prompt to the local model and returns its text response.
|
|
65
|
+
|
|
66
|
+
system_prompt: optional instruction framing the model's role/task
|
|
67
|
+
(e.g. "You are a precise changelog generator...")
|
|
68
|
+
temperature: kept low by default (0.3) to prevent rambling. Increase the temperature for more "creative" responses.
|
|
69
|
+
"""
|
|
70
|
+
llm = get_llm()
|
|
71
|
+
|
|
72
|
+
messages = []
|
|
73
|
+
if system_prompt:
|
|
74
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
75
|
+
messages.append({"role": "user", "content": prompt})
|
|
76
|
+
|
|
77
|
+
response = llm.create_chat_completion(
|
|
78
|
+
messages=messages,
|
|
79
|
+
max_tokens=max_tokens,
|
|
80
|
+
temperature=temperature,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return response["choices"][0]["message"]["content"].strip()
|
|
84
|
+
|
|
85
|
+
# testing purposes only
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
print("Loading model...")
|
|
88
|
+
get_llm()
|
|
89
|
+
print("Model loaded. Sending a test prompt...\n")
|
|
90
|
+
|
|
91
|
+
reply = generate(
|
|
92
|
+
"Reply with exactly one short sentence confirming you're working.",
|
|
93
|
+
max_tokens=50,
|
|
94
|
+
)
|
|
95
|
+
print(f"Response: {reply}")
|
laminate/render.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
from .categorize import CategorizedEntry
|
|
5
|
+
|
|
6
|
+
CATEGORY_ORDER = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_changelog(entries: list[CategorizedEntry], version: str | None = None) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Renders categorized entries into Keep a Changelog formatted
|
|
12
|
+
markdown.
|
|
13
|
+
|
|
14
|
+
version: if given, the header becomes "## [version] - today's date"
|
|
15
|
+
(for cutting an actual release). If omitted, defaults to the
|
|
16
|
+
standard "## [Unreleased]" header.
|
|
17
|
+
"""
|
|
18
|
+
if version:
|
|
19
|
+
header = f"## [{version}] - {date.today().isoformat()}"
|
|
20
|
+
else:
|
|
21
|
+
header = "## [Unreleased]"
|
|
22
|
+
|
|
23
|
+
lines = [
|
|
24
|
+
"# Changelog",
|
|
25
|
+
"",
|
|
26
|
+
"All notable changes to this project will be documented in this file.",
|
|
27
|
+
"",
|
|
28
|
+
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), "
|
|
29
|
+
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).",
|
|
30
|
+
"",
|
|
31
|
+
header,
|
|
32
|
+
"",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
by_category: dict[str, list[str]] = defaultdict(list)
|
|
36
|
+
for entry in entries:
|
|
37
|
+
by_category[entry.category].append(entry.description)
|
|
38
|
+
|
|
39
|
+
for category in CATEGORY_ORDER:
|
|
40
|
+
if category not in by_category:
|
|
41
|
+
continue
|
|
42
|
+
lines.append(f"### {category}")
|
|
43
|
+
for desc in by_category[category]:
|
|
44
|
+
lines.append(f"- {desc}")
|
|
45
|
+
lines.append("")
|
|
46
|
+
|
|
47
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_changelog(content: str, output_path: str = "CHANGELOG.md") -> None:
|
|
51
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
52
|
+
f.write(content)
|
|
53
|
+
|
|
54
|
+
# testing purposes only
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
|
|
57
|
+
from .gitlog import get_commits
|
|
58
|
+
from .categorize import categorize_commits
|
|
59
|
+
|
|
60
|
+
commits = get_commits()[:10]
|
|
61
|
+
|
|
62
|
+
if not commits:
|
|
63
|
+
print("No commits found in current directory.")
|
|
64
|
+
else:
|
|
65
|
+
print(f"Categorizing {len(commits)} commits...\n")
|
|
66
|
+
entries = categorize_commits(commits)
|
|
67
|
+
changelog = render_changelog(entries)
|
|
68
|
+
|
|
69
|
+
print(changelog)
|
|
70
|
+
write_changelog(changelog)
|
|
71
|
+
print("Wrote changelog.md")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: laminate-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate release-ready changelogs from git history using a local LLM
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: llama-cpp-python
|
|
9
|
+
Requires-Dist: pydantic
|
|
10
|
+
Requires-Dist: typer
|
|
11
|
+
Requires-Dist: rich
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
<div style="text-align: center;">
|
|
15
|
+
<img src="assets/laminate-logo.svg" alt="Laminate Logo" width=200>
|
|
16
|
+
<h1>Laminate</h1>
|
|
17
|
+
<h4><a href="https://apleut.dev">My Website</a> - <a href="changelog.md">Changelog</a></h4>
|
|
18
|
+
<hr></hr>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
> [!NOTE]
|
|
22
|
+
> Laminate is in **extremely early development.** Everything stated here is subject to change. If you want to contribute to Laminate, contact me via email at __me@apleut.dev__.
|
|
23
|
+
|
|
24
|
+
> [!WARNING]
|
|
25
|
+
> When installing requirements on Windows machines, make sure to install `llama-cpp-python` using this command first:
|
|
26
|
+
> `pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu`
|
|
27
|
+
> Otherwise, the path lengths of the source code will be too long for Windows to handle, and it will raise an error.
|
|
28
|
+
|
|
29
|
+
A CLI to write release-ready changelogs for you using llama-cpp-python.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
laminate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
laminate/categorize.py,sha256=YPkkT1GywPJENXY95anTxheoOWB5DmtO5s9lzXuaidQ,7630
|
|
3
|
+
laminate/cli.py,sha256=4phL0uJBMaQst9FpdPBFteg5Duzt1qsax99wqKlKhCc,2959
|
|
4
|
+
laminate/gitlog.py,sha256=dy5SGEvjYeL-5U6CZ2popTr8fbfnWWG0VcZAdgfj9po,2657
|
|
5
|
+
laminate/llm.py,sha256=ykzT1jyxGLtHNaG7cPx6AbIoh6lLdamhxLBKBdqzv_0,2602
|
|
6
|
+
laminate/render.py,sha256=nNZfF-J7LhI9UhICOy_pw66-wQfRdMNcEkr1XrJschM,2169
|
|
7
|
+
laminate_cli-0.1.0.dist-info/licenses/LICENSE,sha256=YF6QR6Vjxcg5b_sYIyqkME7FZYau5TfEUGTG-0JeRK0,35129
|
|
8
|
+
laminate_cli-0.1.0.dist-info/METADATA,sha256=tlQOZEMSM_hfksp6g3_EgcZfD_Obw2lPyB4JjC3c2HE,1196
|
|
9
|
+
laminate_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
laminate_cli-0.1.0.dist-info/entry_points.txt,sha256=uY2f2xhLPxYDUagcq3qBB0K6pD3DmFZnMxbINJ3QeSY,46
|
|
11
|
+
laminate_cli-0.1.0.dist-info/top_level.txt,sha256=GSm5tt407xo7z7F-vi6xxgNZzAXCDwOEFh-OxTWKrJw,9
|
|
12
|
+
laminate_cli-0.1.0.dist-info/RECORD,,
|