devmemory 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.
- devmemory/__init__.py +2 -0
- devmemory/cli.py +99 -0
- devmemory/commands/__init__.py +1 -0
- devmemory/commands/add.py +160 -0
- devmemory/commands/config_cmd.py +44 -0
- devmemory/commands/context.py +285 -0
- devmemory/commands/install.py +200 -0
- devmemory/commands/learn.py +216 -0
- devmemory/commands/search.py +245 -0
- devmemory/commands/status.py +71 -0
- devmemory/commands/sync.py +125 -0
- devmemory/core/__init__.py +1 -0
- devmemory/core/ams_client.py +113 -0
- devmemory/core/config.py +42 -0
- devmemory/core/git_ai_parser.py +362 -0
- devmemory/core/llm_client.py +119 -0
- devmemory/core/memory_formatter.py +445 -0
- devmemory/core/sync_state.py +41 -0
- devmemory/hooks/__init__.py +1 -0
- devmemory/hooks/post_commit.py +6 -0
- devmemory/rules/devmemory-context.mdc +16 -0
- devmemory/rules/devmemory.mdc +203 -0
- devmemory-0.1.0.dist-info/METADATA +383 -0
- devmemory-0.1.0.dist-info/RECORD +27 -0
- devmemory-0.1.0.dist-info/WHEEL +4 -0
- devmemory-0.1.0.dist-info/entry_points.txt +2 -0
- devmemory-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class FileAttribution:
|
|
12
|
+
filepath: str
|
|
13
|
+
prompt_lines: dict[str, list[str]] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PromptData:
|
|
18
|
+
prompt_id: str
|
|
19
|
+
tool: str = ""
|
|
20
|
+
model: str = ""
|
|
21
|
+
human_author: str = ""
|
|
22
|
+
messages: list[dict] = field(default_factory=list)
|
|
23
|
+
total_additions: int = 0
|
|
24
|
+
total_deletions: int = 0
|
|
25
|
+
accepted_lines: int = 0
|
|
26
|
+
overridden_lines: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CommitStats:
|
|
31
|
+
human_additions: int = 0
|
|
32
|
+
ai_additions: int = 0
|
|
33
|
+
ai_accepted: int = 0
|
|
34
|
+
mixed_additions: int = 0
|
|
35
|
+
total_ai_additions: int = 0
|
|
36
|
+
total_ai_deletions: int = 0
|
|
37
|
+
time_waiting_for_ai: float = 0.0
|
|
38
|
+
git_diff_added_lines: int = 0
|
|
39
|
+
git_diff_deleted_lines: int = 0
|
|
40
|
+
tool_model_breakdown: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class CommitNote:
|
|
45
|
+
sha: str
|
|
46
|
+
author_name: str
|
|
47
|
+
author_email: str
|
|
48
|
+
subject: str
|
|
49
|
+
date: str
|
|
50
|
+
files: list[FileAttribution] = field(default_factory=list)
|
|
51
|
+
has_ai_note: bool = False
|
|
52
|
+
raw_note: str = ""
|
|
53
|
+
prompts: dict[str, PromptData] = field(default_factory=dict)
|
|
54
|
+
stats: CommitStats | None = None
|
|
55
|
+
body: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _looks_like_filepath(line: str) -> bool:
|
|
59
|
+
stripped = line.strip()
|
|
60
|
+
if not stripped or len(stripped) < 2:
|
|
61
|
+
return False
|
|
62
|
+
if stripped in ("{", "}", "---", "...", "***"):
|
|
63
|
+
return False
|
|
64
|
+
if stripped.startswith("{") or stripped.startswith("["):
|
|
65
|
+
return False
|
|
66
|
+
if all(c in "-=_~*#<>{}[]()@!$%^&+|\\\"'" for c in stripped):
|
|
67
|
+
return False
|
|
68
|
+
if "/" in stripped or "." in stripped:
|
|
69
|
+
return True
|
|
70
|
+
if re.match(r'^[a-zA-Z0-9_]', stripped) and not stripped.startswith(" "):
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_repo_root() -> str | None:
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
79
|
+
capture_output=True, text=True, check=True,
|
|
80
|
+
)
|
|
81
|
+
return result.stdout.strip()
|
|
82
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_head_sha() -> str | None:
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
["git", "rev-parse", "HEAD"],
|
|
90
|
+
capture_output=True, text=True, check=True,
|
|
91
|
+
)
|
|
92
|
+
return result.stdout.strip()
|
|
93
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_commit_diff(sha: str) -> str:
|
|
98
|
+
try:
|
|
99
|
+
result = subprocess.run(
|
|
100
|
+
["git", "diff", f"{sha}~1..{sha}", "--stat"],
|
|
101
|
+
capture_output=True, text=True, check=True,
|
|
102
|
+
)
|
|
103
|
+
return result.stdout.strip()
|
|
104
|
+
except subprocess.CalledProcessError:
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_commit_diff_full(sha: str) -> str:
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
["git", "diff", f"{sha}~1..{sha}", "--no-color"],
|
|
112
|
+
capture_output=True, text=True, check=True,
|
|
113
|
+
)
|
|
114
|
+
return result.stdout.strip()
|
|
115
|
+
except subprocess.CalledProcessError:
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_per_file_diffs(sha: str) -> dict[str, str]:
|
|
120
|
+
full_diff = get_commit_diff_full(sha)
|
|
121
|
+
if not full_diff:
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
file_diffs: dict[str, str] = {}
|
|
125
|
+
current_file = ""
|
|
126
|
+
current_lines: list[str] = []
|
|
127
|
+
|
|
128
|
+
for line in full_diff.splitlines():
|
|
129
|
+
if line.startswith("diff --git"):
|
|
130
|
+
if current_file and current_lines:
|
|
131
|
+
file_diffs[current_file] = "\n".join(current_lines)
|
|
132
|
+
match = re.search(r' b/(.+)$', line)
|
|
133
|
+
current_file = match.group(1) if match else ""
|
|
134
|
+
current_lines = []
|
|
135
|
+
elif current_file:
|
|
136
|
+
current_lines.append(line)
|
|
137
|
+
|
|
138
|
+
if current_file and current_lines:
|
|
139
|
+
file_diffs[current_file] = "\n".join(current_lines)
|
|
140
|
+
|
|
141
|
+
return file_diffs
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_commit_body(sha: str) -> str:
|
|
145
|
+
try:
|
|
146
|
+
result = subprocess.run(
|
|
147
|
+
["git", "log", "-1", "--format=%b", sha],
|
|
148
|
+
capture_output=True, text=True, check=True,
|
|
149
|
+
)
|
|
150
|
+
return result.stdout.strip()
|
|
151
|
+
except subprocess.CalledProcessError:
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_ai_note(raw_note: str) -> list[FileAttribution]:
|
|
156
|
+
files: list[FileAttribution] = []
|
|
157
|
+
current_file: FileAttribution | None = None
|
|
158
|
+
|
|
159
|
+
for line in raw_note.splitlines():
|
|
160
|
+
stripped = line.strip()
|
|
161
|
+
if not stripped:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
if not line.startswith(" ") and not line.startswith("\t"):
|
|
165
|
+
if _looks_like_filepath(stripped):
|
|
166
|
+
current_file = FileAttribution(filepath=stripped)
|
|
167
|
+
files.append(current_file)
|
|
168
|
+
else:
|
|
169
|
+
current_file = None
|
|
170
|
+
elif current_file is not None:
|
|
171
|
+
parts = stripped.split(None, 1)
|
|
172
|
+
if len(parts) == 2:
|
|
173
|
+
prompt_id, line_ranges = parts
|
|
174
|
+
if re.match(r'^[a-f0-9]+$', prompt_id):
|
|
175
|
+
current_file.prompt_lines[prompt_id] = line_ranges.split(",")
|
|
176
|
+
elif len(parts) == 1 and re.match(r'^[a-f0-9]+$', parts[0]):
|
|
177
|
+
current_file.prompt_lines[parts[0]] = []
|
|
178
|
+
|
|
179
|
+
return files
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_prompt_data(prompt_id: str, commit_sha: str | None = None) -> PromptData | None:
|
|
183
|
+
cmd = ["git-ai", "show-prompt", prompt_id]
|
|
184
|
+
if commit_sha:
|
|
185
|
+
cmd.extend(["--commit", commit_sha])
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
189
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
data = json.loads(result.stdout)
|
|
194
|
+
except (json.JSONDecodeError, ValueError):
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
prompt = data.get("prompt", {})
|
|
198
|
+
agent_id = prompt.get("agent_id", {})
|
|
199
|
+
|
|
200
|
+
messages = prompt.get("messages", [])
|
|
201
|
+
if isinstance(messages, str):
|
|
202
|
+
messages = [{"role": "user", "content": messages}]
|
|
203
|
+
|
|
204
|
+
return PromptData(
|
|
205
|
+
prompt_id=prompt_id,
|
|
206
|
+
tool=agent_id.get("tool", ""),
|
|
207
|
+
model=agent_id.get("model", ""),
|
|
208
|
+
human_author=prompt.get("human_author", ""),
|
|
209
|
+
messages=messages,
|
|
210
|
+
total_additions=prompt.get("total_additions", 0),
|
|
211
|
+
total_deletions=prompt.get("total_deletions", 0),
|
|
212
|
+
accepted_lines=prompt.get("accepted_lines", 0),
|
|
213
|
+
overridden_lines=prompt.get("overriden_lines", 0),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_commit_stats(sha: str) -> CommitStats | None:
|
|
218
|
+
cmd = ["git-ai", "stats", sha, "--json"]
|
|
219
|
+
try:
|
|
220
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
221
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
data = json.loads(result.stdout)
|
|
226
|
+
except (json.JSONDecodeError, ValueError):
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
return CommitStats(
|
|
230
|
+
human_additions=data.get("human_additions", 0),
|
|
231
|
+
ai_additions=data.get("ai_additions", 0),
|
|
232
|
+
ai_accepted=data.get("ai_accepted", 0),
|
|
233
|
+
mixed_additions=data.get("mixed_additions", 0),
|
|
234
|
+
total_ai_additions=data.get("total_ai_additions", 0),
|
|
235
|
+
total_ai_deletions=data.get("total_ai_deletions", 0),
|
|
236
|
+
time_waiting_for_ai=data.get("time_waiting_for_ai", 0.0),
|
|
237
|
+
git_diff_added_lines=data.get("git_diff_added_lines", 0),
|
|
238
|
+
git_diff_deleted_lines=data.get("git_diff_deleted_lines", 0),
|
|
239
|
+
tool_model_breakdown=data.get("tool_model_breakdown", {}),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _collect_prompt_ids(files: list[FileAttribution]) -> set[str]:
|
|
244
|
+
ids: set[str] = set()
|
|
245
|
+
for f in files:
|
|
246
|
+
ids.update(f.prompt_lines.keys())
|
|
247
|
+
return ids
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_ai_note_for_commit(sha: str) -> str:
|
|
251
|
+
try:
|
|
252
|
+
result = subprocess.run(
|
|
253
|
+
["git", "notes", "--ref=ai", "show", sha],
|
|
254
|
+
capture_output=True, text=True, check=True,
|
|
255
|
+
)
|
|
256
|
+
return result.stdout.strip()
|
|
257
|
+
except subprocess.CalledProcessError:
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_commits_since(since_sha: str | None, limit: int = 50) -> list[dict]:
|
|
262
|
+
fmt = "%H|%an|%ae|%s|%aI"
|
|
263
|
+
if since_sha:
|
|
264
|
+
cmd = ["git", "log", f"--format={fmt}", f"{since_sha}..HEAD", f"-{limit}"]
|
|
265
|
+
else:
|
|
266
|
+
cmd = ["git", "log", f"--format={fmt}", f"-{limit}"]
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
270
|
+
except subprocess.CalledProcessError:
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
commits = []
|
|
274
|
+
for line in result.stdout.strip().splitlines():
|
|
275
|
+
if not line.strip():
|
|
276
|
+
continue
|
|
277
|
+
parts = line.split("|", 4)
|
|
278
|
+
if len(parts) < 5:
|
|
279
|
+
continue
|
|
280
|
+
commits.append({
|
|
281
|
+
"sha": parts[0],
|
|
282
|
+
"author_name": parts[1],
|
|
283
|
+
"author_email": parts[2],
|
|
284
|
+
"subject": parts[3],
|
|
285
|
+
"date": parts[4],
|
|
286
|
+
})
|
|
287
|
+
return commits
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _build_commit_note(c: dict, enrich: bool = True) -> CommitNote:
|
|
291
|
+
raw_note = get_ai_note_for_commit(c["sha"])
|
|
292
|
+
has_ai = bool(raw_note)
|
|
293
|
+
files = parse_ai_note(raw_note) if has_ai else []
|
|
294
|
+
|
|
295
|
+
prompts: dict[str, PromptData] = {}
|
|
296
|
+
stats: CommitStats | None = None
|
|
297
|
+
body = ""
|
|
298
|
+
|
|
299
|
+
if has_ai and enrich:
|
|
300
|
+
prompt_ids = _collect_prompt_ids(files)
|
|
301
|
+
for pid in prompt_ids:
|
|
302
|
+
pd = get_prompt_data(pid, commit_sha=c["sha"])
|
|
303
|
+
if pd:
|
|
304
|
+
prompts[pid] = pd
|
|
305
|
+
|
|
306
|
+
stats = get_commit_stats(c["sha"])
|
|
307
|
+
body = get_commit_body(c["sha"])
|
|
308
|
+
|
|
309
|
+
return CommitNote(
|
|
310
|
+
sha=c["sha"],
|
|
311
|
+
author_name=c["author_name"],
|
|
312
|
+
author_email=c["author_email"],
|
|
313
|
+
subject=c["subject"],
|
|
314
|
+
date=c["date"],
|
|
315
|
+
files=files,
|
|
316
|
+
has_ai_note=has_ai,
|
|
317
|
+
raw_note=raw_note,
|
|
318
|
+
prompts=prompts,
|
|
319
|
+
stats=stats,
|
|
320
|
+
body=body,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_ai_notes_since(since_sha: str | None, limit: int = 50) -> list[CommitNote]:
|
|
325
|
+
commits = get_commits_since(since_sha, limit)
|
|
326
|
+
return [_build_commit_note(c) for c in commits]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_latest_commit_note() -> CommitNote | None:
|
|
330
|
+
commits = get_commits_since(None, limit=1)
|
|
331
|
+
if not commits:
|
|
332
|
+
return None
|
|
333
|
+
return _build_commit_note(commits[0])
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def is_git_ai_installed() -> bool:
|
|
337
|
+
try:
|
|
338
|
+
result = subprocess.run(
|
|
339
|
+
["git-ai", "version"],
|
|
340
|
+
capture_output=True, text=True,
|
|
341
|
+
)
|
|
342
|
+
return result.returncode == 0
|
|
343
|
+
except FileNotFoundError:
|
|
344
|
+
try:
|
|
345
|
+
result = subprocess.run(
|
|
346
|
+
["git", "ai", "version"],
|
|
347
|
+
capture_output=True, text=True,
|
|
348
|
+
)
|
|
349
|
+
return result.returncode == 0
|
|
350
|
+
except FileNotFoundError:
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_git_ai_version() -> str:
|
|
355
|
+
for cmd in [["git-ai", "version"], ["git", "ai", "version"]]:
|
|
356
|
+
try:
|
|
357
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
358
|
+
if result.returncode == 0:
|
|
359
|
+
return result.stdout.strip()
|
|
360
|
+
except FileNotFoundError:
|
|
361
|
+
continue
|
|
362
|
+
return "not installed"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import httpx
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _find_env_file() -> Path | None:
|
|
9
|
+
cwd = Path.cwd()
|
|
10
|
+
for d in [cwd, *cwd.parents]:
|
|
11
|
+
env_path = d / ".env"
|
|
12
|
+
if env_path.exists():
|
|
13
|
+
if (d / ".git").exists() or (d / "docker-compose.yml").exists():
|
|
14
|
+
return env_path
|
|
15
|
+
if d == d.parent:
|
|
16
|
+
break
|
|
17
|
+
env_path = cwd / ".env"
|
|
18
|
+
if env_path.exists():
|
|
19
|
+
return env_path
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
24
|
+
env_vars: dict[str, str] = {}
|
|
25
|
+
for line in path.read_text().splitlines():
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if not line or line.startswith("#"):
|
|
28
|
+
continue
|
|
29
|
+
if "=" in line:
|
|
30
|
+
key, _, value = line.partition("=")
|
|
31
|
+
key = key.strip()
|
|
32
|
+
value = value.strip().strip("'\"")
|
|
33
|
+
env_vars[key] = value
|
|
34
|
+
return env_vars
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_env_var(key: str) -> str:
|
|
38
|
+
value = os.environ.get(key, "")
|
|
39
|
+
if value:
|
|
40
|
+
return value
|
|
41
|
+
env_file = _find_env_file()
|
|
42
|
+
if env_file:
|
|
43
|
+
env_vars = _parse_env_file(env_file)
|
|
44
|
+
return env_vars.get(key, "")
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_llm_config() -> tuple[str, str]:
|
|
49
|
+
api_key = _get_env_var("OPENAI_API_KEY")
|
|
50
|
+
model = _get_env_var("GENERATION_MODEL") or "gpt-4o-mini"
|
|
51
|
+
return api_key, model
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SYSTEM_PROMPT = """\
|
|
55
|
+
You are a knowledgebase assistant for a software development project. \
|
|
56
|
+
You answer questions by synthesizing information from the project's memory store, \
|
|
57
|
+
which contains commit histories, code changes, AI prompts, and development context.
|
|
58
|
+
|
|
59
|
+
Given the user's question and retrieved memories from the store, provide a clear, concise answer.
|
|
60
|
+
|
|
61
|
+
Rules:
|
|
62
|
+
- Be concise and direct (2-5 sentences for simple queries, more for complex ones)
|
|
63
|
+
- Reference specific commits (SHA), files, or code patterns when relevant
|
|
64
|
+
- If the memories are not relevant to the question, clearly state: \
|
|
65
|
+
"The available memories don't contain information relevant to this question."
|
|
66
|
+
- Don't fabricate information not present in the memories
|
|
67
|
+
- Focus on answering the question, not describing the memories themselves
|
|
68
|
+
- When memories show a pattern of changes (e.g., multiple fixes to the same file), \
|
|
69
|
+
summarize the evolution rather than listing each change"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LLMError(Exception):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def synthesize_answer(
|
|
77
|
+
query: str,
|
|
78
|
+
memories: list[dict],
|
|
79
|
+
timeout: float = 60.0,
|
|
80
|
+
) -> str | None:
|
|
81
|
+
api_key, model = _get_llm_config()
|
|
82
|
+
|
|
83
|
+
if not api_key:
|
|
84
|
+
raise LLMError("no_api_key")
|
|
85
|
+
|
|
86
|
+
context_parts = []
|
|
87
|
+
for i, mem in enumerate(memories, 1):
|
|
88
|
+
topics_str = ", ".join(mem.get("topics", []))
|
|
89
|
+
header = f"--- Memory {i} (type: {mem['type']}, score: {mem['score']:.3f}"
|
|
90
|
+
if topics_str:
|
|
91
|
+
header += f", topics: {topics_str}"
|
|
92
|
+
header += ") ---"
|
|
93
|
+
context_parts.append(f"{header}\n{mem['text']}")
|
|
94
|
+
|
|
95
|
+
context = "\n\n".join(context_parts)
|
|
96
|
+
|
|
97
|
+
user_msg = f"Question: {query}\n\nRetrieved memories ({len(memories)} results):\n\n{context}"
|
|
98
|
+
|
|
99
|
+
with httpx.Client(timeout=timeout) as client:
|
|
100
|
+
resp = client.post(
|
|
101
|
+
"https://api.openai.com/v1/chat/completions",
|
|
102
|
+
headers={
|
|
103
|
+
"Authorization": f"Bearer {api_key}",
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
},
|
|
106
|
+
json={
|
|
107
|
+
"model": model,
|
|
108
|
+
"messages": [
|
|
109
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
110
|
+
{"role": "user", "content": user_msg},
|
|
111
|
+
],
|
|
112
|
+
"max_completion_tokens": 1000,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
if resp.status_code != 200:
|
|
116
|
+
error_msg = resp.json().get("error", {}).get("message", resp.text[:200])
|
|
117
|
+
raise LLMError(f"OpenAI API error ({resp.status_code}): {error_msg}")
|
|
118
|
+
data = resp.json()
|
|
119
|
+
return data["choices"][0]["message"]["content"]
|