repr-cli 0.2.16__py3-none-any.whl → 0.2.17__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.
- repr/__init__.py +1 -1
- repr/api.py +363 -62
- repr/auth.py +47 -38
- repr/change_synthesis.py +478 -0
- repr/cli.py +4099 -280
- repr/config.py +119 -11
- repr/configure.py +889 -0
- repr/cron.py +419 -0
- repr/dashboard/__init__.py +9 -0
- repr/dashboard/build.py +126 -0
- repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
- repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
- repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
- repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
- repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
- repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
- repr/dashboard/dist/favicon.svg +4 -0
- repr/dashboard/dist/index.html +14 -0
- repr/dashboard/manager.py +234 -0
- repr/dashboard/server.py +1298 -0
- repr/db.py +980 -0
- repr/hooks.py +3 -2
- repr/loaders/__init__.py +22 -0
- repr/loaders/base.py +156 -0
- repr/loaders/claude_code.py +287 -0
- repr/loaders/clawdbot.py +313 -0
- repr/loaders/gemini_antigravity.py +381 -0
- repr/mcp_server.py +1196 -0
- repr/models.py +503 -0
- repr/openai_analysis.py +25 -0
- repr/session_extractor.py +481 -0
- repr/storage.py +328 -0
- repr/story_synthesis.py +1296 -0
- repr/templates.py +68 -4
- repr/timeline.py +710 -0
- repr/tools.py +17 -8
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/METADATA +48 -10
- repr_cli-0.2.17.dist-info/RECORD +52 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/WHEEL +1 -1
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/entry_points.txt +1 -0
- repr_cli-0.2.16.dist-info/RECORD +0 -26
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/licenses/LICENSE +0 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/top_level.txt +0 -0
repr/change_synthesis.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Change synthesis - understand file changes across git states.
|
|
3
|
+
|
|
4
|
+
Detects and synthesizes changes across three git states:
|
|
5
|
+
1. Tracked but not staged (working directory)
|
|
6
|
+
2. Staged but not committed (index)
|
|
7
|
+
3. Committed but not pushed (local commits ahead of remote)
|
|
8
|
+
|
|
9
|
+
Uses the tripartite codex format for synthesis:
|
|
10
|
+
- hook: Engagement hook (<60 chars)
|
|
11
|
+
- what: Observable change (1 sentence)
|
|
12
|
+
- value: Why it matters (1 sentence)
|
|
13
|
+
- problem: What was broken/missing (1 sentence)
|
|
14
|
+
- insight: Transferable lesson (1 sentence)
|
|
15
|
+
- show: Visual element (code/before-after)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from git import Repo, InvalidGitRepositoryError
|
|
26
|
+
from pydantic import BaseModel, Field
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChangeState(str, Enum):
|
|
30
|
+
"""Git state of a change."""
|
|
31
|
+
UNSTAGED = "unstaged" # Tracked but not staged
|
|
32
|
+
STAGED = "staged" # Staged but not committed
|
|
33
|
+
UNPUSHED = "unpushed" # Committed but not pushed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FileChange:
|
|
38
|
+
"""A single file change with its state."""
|
|
39
|
+
path: str
|
|
40
|
+
state: ChangeState
|
|
41
|
+
change_type: str # A (added), M (modified), D (deleted), R (renamed)
|
|
42
|
+
insertions: int = 0
|
|
43
|
+
deletions: int = 0
|
|
44
|
+
diff_preview: str = "" # First few lines of diff
|
|
45
|
+
old_path: Optional[str] = None # For renames
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CommitChange:
|
|
50
|
+
"""A commit that hasn't been pushed."""
|
|
51
|
+
sha: str
|
|
52
|
+
message: str
|
|
53
|
+
author: str
|
|
54
|
+
timestamp: datetime
|
|
55
|
+
files: list[FileChange] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ChangeSummary(BaseModel):
|
|
59
|
+
"""LLM-synthesized summary of changes using tripartite codex."""
|
|
60
|
+
hook: str = Field(default="", description="Engagement hook (<60 chars)")
|
|
61
|
+
what: str = Field(default="", description="Observable change (1 sentence)")
|
|
62
|
+
value: str = Field(default="", description="Why it matters (1 sentence)")
|
|
63
|
+
problem: str = Field(default="", description="What was broken/missing")
|
|
64
|
+
insight: str = Field(default="", description="Transferable lesson")
|
|
65
|
+
show: Optional[str] = Field(default=None, description="Visual element")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ChangeReport:
|
|
70
|
+
"""Complete change report across all git states."""
|
|
71
|
+
repo_path: Path
|
|
72
|
+
timestamp: datetime
|
|
73
|
+
|
|
74
|
+
# Changes by state
|
|
75
|
+
unstaged: list[FileChange] = field(default_factory=list)
|
|
76
|
+
staged: list[FileChange] = field(default_factory=list)
|
|
77
|
+
unpushed: list[CommitChange] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
# Optional synthesis (requires LLM)
|
|
80
|
+
summary: Optional[ChangeSummary] = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def total_files(self) -> int:
|
|
84
|
+
"""Total unique files changed."""
|
|
85
|
+
files = set()
|
|
86
|
+
files.update(f.path for f in self.unstaged)
|
|
87
|
+
files.update(f.path for f in self.staged)
|
|
88
|
+
for commit in self.unpushed:
|
|
89
|
+
files.update(f.path for f in commit.files)
|
|
90
|
+
return len(files)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def has_changes(self) -> bool:
|
|
94
|
+
"""Check if there are any changes."""
|
|
95
|
+
return bool(self.unstaged or self.staged or self.unpushed)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_repo(path: Path) -> Optional[Repo]:
|
|
99
|
+
"""Get git repo at path, or None if not a repo."""
|
|
100
|
+
try:
|
|
101
|
+
return Repo(path, search_parent_directories=True)
|
|
102
|
+
except InvalidGitRepositoryError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_unstaged_changes(repo: Repo) -> list[FileChange]:
|
|
107
|
+
"""Get tracked files with unstaged changes (modified in working tree)."""
|
|
108
|
+
changes = []
|
|
109
|
+
|
|
110
|
+
# Get diff between index and working tree (create_patch=True to get diff content)
|
|
111
|
+
diffs = repo.index.diff(None, create_patch=True)
|
|
112
|
+
|
|
113
|
+
for diff in diffs:
|
|
114
|
+
change_type = "M"
|
|
115
|
+
if diff.new_file:
|
|
116
|
+
change_type = "A"
|
|
117
|
+
elif diff.deleted_file:
|
|
118
|
+
change_type = "D"
|
|
119
|
+
elif diff.renamed:
|
|
120
|
+
change_type = "R"
|
|
121
|
+
|
|
122
|
+
# Get diff stats and preview
|
|
123
|
+
insertions = 0
|
|
124
|
+
deletions = 0
|
|
125
|
+
diff_preview = ""
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
diff_text = diff.diff.decode("utf-8", errors="replace") if diff.diff else ""
|
|
129
|
+
lines = diff_text.split("\n")
|
|
130
|
+
insertions = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
|
131
|
+
deletions = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
|
132
|
+
# Preview: content lines (skip @@ headers, +++ and --- lines)
|
|
133
|
+
content_lines = [l for l in lines if (l.startswith("+") or l.startswith("-"))
|
|
134
|
+
and not l.startswith("+++") and not l.startswith("---")]
|
|
135
|
+
diff_preview = "\n".join(content_lines[:15])
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
changes.append(FileChange(
|
|
140
|
+
path=diff.a_path or diff.b_path,
|
|
141
|
+
state=ChangeState.UNSTAGED,
|
|
142
|
+
change_type=change_type,
|
|
143
|
+
insertions=insertions,
|
|
144
|
+
deletions=deletions,
|
|
145
|
+
diff_preview=diff_preview,
|
|
146
|
+
old_path=diff.rename_from if diff.renamed else None,
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
return changes
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_staged_changes(repo: Repo) -> list[FileChange]:
|
|
153
|
+
"""Get staged changes (in index, not yet committed)."""
|
|
154
|
+
changes = []
|
|
155
|
+
|
|
156
|
+
# Get diff between HEAD and index (create_patch=True to get diff content)
|
|
157
|
+
try:
|
|
158
|
+
diffs = repo.head.commit.diff(create_patch=True)
|
|
159
|
+
except ValueError:
|
|
160
|
+
# Empty repo, no HEAD yet
|
|
161
|
+
diffs = repo.index.diff(repo.head.commit, create_patch=True) if repo.head.is_valid() else []
|
|
162
|
+
|
|
163
|
+
for diff in diffs:
|
|
164
|
+
change_type = "M"
|
|
165
|
+
if diff.new_file:
|
|
166
|
+
change_type = "A"
|
|
167
|
+
elif diff.deleted_file:
|
|
168
|
+
change_type = "D"
|
|
169
|
+
elif diff.renamed:
|
|
170
|
+
change_type = "R"
|
|
171
|
+
|
|
172
|
+
insertions = 0
|
|
173
|
+
deletions = 0
|
|
174
|
+
diff_preview = ""
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
diff_text = diff.diff.decode("utf-8", errors="replace") if diff.diff else ""
|
|
178
|
+
lines = diff_text.split("\n")
|
|
179
|
+
insertions = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
|
180
|
+
deletions = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
|
181
|
+
content_lines = [l for l in lines if (l.startswith("+") or l.startswith("-"))
|
|
182
|
+
and not l.startswith("+++") and not l.startswith("---")]
|
|
183
|
+
diff_preview = "\n".join(content_lines[:15])
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
changes.append(FileChange(
|
|
188
|
+
path=diff.b_path or diff.a_path,
|
|
189
|
+
state=ChangeState.STAGED,
|
|
190
|
+
change_type=change_type,
|
|
191
|
+
insertions=insertions,
|
|
192
|
+
deletions=deletions,
|
|
193
|
+
diff_preview=diff_preview,
|
|
194
|
+
old_path=diff.rename_from if diff.renamed else None,
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
return changes
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_unpushed_commits(repo: Repo) -> list[CommitChange]:
|
|
201
|
+
"""Get commits that haven't been pushed to remote."""
|
|
202
|
+
commits = []
|
|
203
|
+
|
|
204
|
+
# Get current branch
|
|
205
|
+
try:
|
|
206
|
+
branch = repo.active_branch
|
|
207
|
+
except TypeError:
|
|
208
|
+
# Detached HEAD
|
|
209
|
+
return commits
|
|
210
|
+
|
|
211
|
+
# Find tracking branch
|
|
212
|
+
tracking = None
|
|
213
|
+
try:
|
|
214
|
+
tracking = branch.tracking_branch()
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
if not tracking:
|
|
219
|
+
# No upstream - check for any remote with same branch name
|
|
220
|
+
for remote in repo.remotes:
|
|
221
|
+
try:
|
|
222
|
+
remote_ref = f"{remote.name}/{branch.name}"
|
|
223
|
+
if remote_ref in [ref.name for ref in repo.refs]:
|
|
224
|
+
tracking = repo.refs[remote_ref]
|
|
225
|
+
break
|
|
226
|
+
except Exception:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if not tracking:
|
|
230
|
+
# No remote tracking, can't determine unpushed
|
|
231
|
+
return commits
|
|
232
|
+
|
|
233
|
+
# Get commits between tracking branch and HEAD
|
|
234
|
+
try:
|
|
235
|
+
commit_range = f"{tracking.name}..HEAD"
|
|
236
|
+
for commit in repo.iter_commits(commit_range):
|
|
237
|
+
# Get file changes for this commit
|
|
238
|
+
file_changes = []
|
|
239
|
+
try:
|
|
240
|
+
parent = commit.parents[0] if commit.parents else None
|
|
241
|
+
if parent:
|
|
242
|
+
diffs = parent.diff(commit)
|
|
243
|
+
else:
|
|
244
|
+
# Initial commit
|
|
245
|
+
diffs = commit.diff(None)
|
|
246
|
+
|
|
247
|
+
for diff in diffs:
|
|
248
|
+
change_type = "M"
|
|
249
|
+
if diff.new_file:
|
|
250
|
+
change_type = "A"
|
|
251
|
+
elif diff.deleted_file:
|
|
252
|
+
change_type = "D"
|
|
253
|
+
elif diff.renamed:
|
|
254
|
+
change_type = "R"
|
|
255
|
+
|
|
256
|
+
file_changes.append(FileChange(
|
|
257
|
+
path=diff.b_path or diff.a_path,
|
|
258
|
+
state=ChangeState.UNPUSHED,
|
|
259
|
+
change_type=change_type,
|
|
260
|
+
old_path=diff.rename_from if diff.renamed else None,
|
|
261
|
+
))
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
commits.append(CommitChange(
|
|
266
|
+
sha=commit.hexsha[:7],
|
|
267
|
+
message=commit.message.split("\n")[0],
|
|
268
|
+
author=commit.author.name,
|
|
269
|
+
timestamp=datetime.fromtimestamp(commit.committed_date),
|
|
270
|
+
files=file_changes,
|
|
271
|
+
))
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
return commits
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def get_change_report(path: Path) -> Optional[ChangeReport]:
|
|
279
|
+
"""
|
|
280
|
+
Get comprehensive change report for a repository.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
path: Path to repository or file within repository
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
ChangeReport with all changes, or None if not a git repo
|
|
287
|
+
"""
|
|
288
|
+
repo = get_repo(path)
|
|
289
|
+
if not repo:
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
repo_path = Path(repo.working_dir)
|
|
293
|
+
|
|
294
|
+
return ChangeReport(
|
|
295
|
+
repo_path=repo_path,
|
|
296
|
+
timestamp=datetime.now(),
|
|
297
|
+
unstaged=get_unstaged_changes(repo),
|
|
298
|
+
staged=get_staged_changes(repo),
|
|
299
|
+
unpushed=get_unpushed_commits(repo),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# =============================================================================
|
|
304
|
+
# LLM Synthesis
|
|
305
|
+
# =============================================================================
|
|
306
|
+
|
|
307
|
+
CHANGE_SYNTHESIS_SYSTEM = """You analyze git changes and create a concise summary using the tripartite codex format.
|
|
308
|
+
|
|
309
|
+
Given changes across three states (unstaged, staged, unpushed commits), synthesize:
|
|
310
|
+
|
|
311
|
+
1. HOOK (<60 chars): Engagement hook that captures the essence of the work.
|
|
312
|
+
Be authentic: "Finally fixing that auth bug", "The refactor nobody asked for", "Making tests actually pass"
|
|
313
|
+
|
|
314
|
+
2. WHAT (1 sentence): The observable change - what would someone see different after these changes?
|
|
315
|
+
|
|
316
|
+
3. VALUE (1 sentence): Why this matters - user impact, technical benefit, or strategic value.
|
|
317
|
+
|
|
318
|
+
4. PROBLEM (1 sentence): What was broken, missing, or needed improvement.
|
|
319
|
+
|
|
320
|
+
5. INSIGHT (1 sentence): A transferable lesson or principle from this work.
|
|
321
|
+
|
|
322
|
+
6. SHOW (optional): A representative code snippet or before/after if it adds clarity.
|
|
323
|
+
|
|
324
|
+
Output valid JSON with these exact fields:
|
|
325
|
+
- "hook": string (<60 chars)
|
|
326
|
+
- "what": string (1 sentence)
|
|
327
|
+
- "value": string (1 sentence)
|
|
328
|
+
- "problem": string (1 sentence)
|
|
329
|
+
- "insight": string (1 sentence)
|
|
330
|
+
- "show": string or null
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
CHANGE_SYNTHESIS_USER = """Synthesize these git changes:
|
|
334
|
+
|
|
335
|
+
UNSTAGED CHANGES (modified but not staged):
|
|
336
|
+
{unstaged}
|
|
337
|
+
|
|
338
|
+
STAGED CHANGES (ready to commit):
|
|
339
|
+
{staged}
|
|
340
|
+
|
|
341
|
+
UNPUSHED COMMITS:
|
|
342
|
+
{unpushed}
|
|
343
|
+
|
|
344
|
+
Output valid JSON with "hook", "what", "value", "problem", "insight", and "show" fields."""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def format_file_changes(changes: list[FileChange]) -> str:
|
|
348
|
+
"""Format file changes for LLM prompt."""
|
|
349
|
+
if not changes:
|
|
350
|
+
return "(none)"
|
|
351
|
+
|
|
352
|
+
lines = []
|
|
353
|
+
for c in changes:
|
|
354
|
+
type_icon = {"A": "+", "M": "~", "D": "-", "R": "→"}.get(c.change_type, "?")
|
|
355
|
+
stats = f"+{c.insertions}/-{c.deletions}" if c.insertions or c.deletions else ""
|
|
356
|
+
lines.append(f" {type_icon} {c.path} {stats}")
|
|
357
|
+
if c.diff_preview:
|
|
358
|
+
preview_lines = c.diff_preview.split("\n")[:5]
|
|
359
|
+
for pl in preview_lines:
|
|
360
|
+
lines.append(f" {pl}")
|
|
361
|
+
|
|
362
|
+
return "\n".join(lines)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def format_commit_changes(commits: list[CommitChange]) -> str:
|
|
366
|
+
"""Format unpushed commits for LLM prompt."""
|
|
367
|
+
if not commits:
|
|
368
|
+
return "(none)"
|
|
369
|
+
|
|
370
|
+
lines = []
|
|
371
|
+
for commit in commits:
|
|
372
|
+
lines.append(f" [{commit.sha}] {commit.message}")
|
|
373
|
+
for f in commit.files[:5]:
|
|
374
|
+
type_icon = {"A": "+", "M": "~", "D": "-", "R": "→"}.get(f.change_type, "?")
|
|
375
|
+
lines.append(f" {type_icon} {f.path}")
|
|
376
|
+
if len(commit.files) > 5:
|
|
377
|
+
lines.append(f" ... and {len(commit.files) - 5} more files")
|
|
378
|
+
|
|
379
|
+
return "\n".join(lines)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
GROUP_EXPLAIN_SYSTEM = """You explain git changes concisely. Given a set of file changes, provide a brief 1-2 sentence summary of what's being changed and why it might matter. Be direct and specific."""
|
|
383
|
+
|
|
384
|
+
GROUP_EXPLAIN_USER = """Explain these {group_type} changes briefly (1-2 sentences):
|
|
385
|
+
|
|
386
|
+
{changes}"""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def explain_group(
|
|
390
|
+
group_type: str,
|
|
391
|
+
file_changes: list[FileChange] = None,
|
|
392
|
+
commit_changes: list[CommitChange] = None,
|
|
393
|
+
client=None, # AsyncOpenAI
|
|
394
|
+
model: str = "gpt-4o-mini",
|
|
395
|
+
) -> str:
|
|
396
|
+
"""
|
|
397
|
+
Explain a single group of changes.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
group_type: "unstaged", "staged", or "unpushed"
|
|
401
|
+
file_changes: List of file changes (for unstaged/staged)
|
|
402
|
+
commit_changes: List of commits (for unpushed)
|
|
403
|
+
client: AsyncOpenAI client
|
|
404
|
+
model: Model to use
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Brief explanation string
|
|
408
|
+
"""
|
|
409
|
+
if commit_changes:
|
|
410
|
+
changes_str = format_commit_changes(commit_changes)
|
|
411
|
+
elif file_changes:
|
|
412
|
+
changes_str = format_file_changes(file_changes)
|
|
413
|
+
else:
|
|
414
|
+
return ""
|
|
415
|
+
|
|
416
|
+
prompt = GROUP_EXPLAIN_USER.format(
|
|
417
|
+
group_type=group_type,
|
|
418
|
+
changes=changes_str,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
response = await client.chat.completions.create(
|
|
422
|
+
model=model,
|
|
423
|
+
messages=[
|
|
424
|
+
{"role": "system", "content": GROUP_EXPLAIN_SYSTEM},
|
|
425
|
+
{"role": "user", "content": prompt},
|
|
426
|
+
],
|
|
427
|
+
temperature=0.7,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return response.choices[0].message.content.strip()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def synthesize_changes(
|
|
435
|
+
report: ChangeReport,
|
|
436
|
+
client, # AsyncOpenAI
|
|
437
|
+
model: str = "gpt-4o-mini",
|
|
438
|
+
) -> ChangeSummary:
|
|
439
|
+
"""
|
|
440
|
+
Use LLM to synthesize a change report into tripartite codex format.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
report: The change report to synthesize
|
|
444
|
+
client: AsyncOpenAI client
|
|
445
|
+
model: Model to use
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
ChangeSummary with synthesized content
|
|
449
|
+
"""
|
|
450
|
+
import json
|
|
451
|
+
|
|
452
|
+
prompt = CHANGE_SYNTHESIS_USER.format(
|
|
453
|
+
unstaged=format_file_changes(report.unstaged),
|
|
454
|
+
staged=format_file_changes(report.staged),
|
|
455
|
+
unpushed=format_commit_changes(report.unpushed),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
response = await client.chat.completions.create(
|
|
459
|
+
model=model,
|
|
460
|
+
messages=[
|
|
461
|
+
{"role": "system", "content": CHANGE_SYNTHESIS_SYSTEM},
|
|
462
|
+
{"role": "user", "content": prompt},
|
|
463
|
+
],
|
|
464
|
+
response_format={"type": "json_object"},
|
|
465
|
+
temperature=0.7,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
content = response.choices[0].message.content
|
|
469
|
+
data = json.loads(content)
|
|
470
|
+
|
|
471
|
+
return ChangeSummary(
|
|
472
|
+
hook=data.get("hook", ""),
|
|
473
|
+
what=data.get("what", ""),
|
|
474
|
+
value=data.get("value", ""),
|
|
475
|
+
problem=data.get("problem", ""),
|
|
476
|
+
insight=data.get("insight", ""),
|
|
477
|
+
show=data.get("show"),
|
|
478
|
+
)
|