adaptive-memory-engine 0.1.6__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.
- adaptive_memory_engine-0.1.6.dist-info/METADATA +228 -0
- adaptive_memory_engine-0.1.6.dist-info/RECORD +72 -0
- adaptive_memory_engine-0.1.6.dist-info/WHEEL +4 -0
- adaptive_memory_engine-0.1.6.dist-info/entry_points.txt +3 -0
- adaptive_memory_engine-0.1.6.dist-info/licenses/LICENSE +21 -0
- ame/__init__.py +1 -0
- ame/agent/__init__.py +1 -0
- ame/agent/mcp.py +474 -0
- ame/agent/memory_api.py +141 -0
- ame/agent/results.py +30 -0
- ame/bronze/schema.py +17 -0
- ame/bronze/store.py +38 -0
- ame/cli/__init__.py +1 -0
- ame/cli/main.py +903 -0
- ame/connectors/base.py +30 -0
- ame/connectors/contract.py +199 -0
- ame/connectors/github.py +66 -0
- ame/connectors/google.py +464 -0
- ame/connectors/google_oauth.py +156 -0
- ame/connectors/jira.py +66 -0
- ame/connectors/json_helpers.py +43 -0
- ame/connectors/markdown.py +116 -0
- ame/connectors/notion.py +59 -0
- ame/connectors/oauth_callback.py +102 -0
- ame/connectors/oauth_provider.py +250 -0
- ame/connectors/obsidian.py +19 -0
- ame/connectors/router.py +155 -0
- ame/connectors/slack.py +66 -0
- ame/connectors/slack_oauth.py +417 -0
- ame/connectors/sync_history.py +73 -0
- ame/context_budget.py +106 -0
- ame/core/config.py +77 -0
- ame/core/corpus.py +17 -0
- ame/core/errors.py +18 -0
- ame/core/paths.py +111 -0
- ame/core/state.py +57 -0
- ame/export/obsidian.py +123 -0
- ame/gold/builder.py +300 -0
- ame/gold/ontology.py +80 -0
- ame/gold/resolver.py +91 -0
- ame/gold/schema.py +40 -0
- ame/gold/store.py +45 -0
- ame/hardware/profiler.py +85 -0
- ame/hardware/tier.py +27 -0
- ame/hermes/__init__.py +3 -0
- ame/hermes/memory.py +209 -0
- ame/models/download.py +243 -0
- ame/models/ollama.py +60 -0
- ame/models/registry.py +101 -0
- ame/models/router.py +22 -0
- ame/pipeline.py +155 -0
- ame/query/diff.py +40 -0
- ame/query/engine.py +919 -0
- ame/query/memory_os.py +313 -0
- ame/query/mql.py +84 -0
- ame/query/multihop.py +264 -0
- ame/query/result.py +20 -0
- ame/sdk.py +52 -0
- ame/security.py +145 -0
- ame/silver/extractor.py +414 -0
- ame/silver/llm_extractor.py +181 -0
- ame/silver/prompts.py +56 -0
- ame/silver/rationale.py +140 -0
- ame/silver/schema.py +51 -0
- ame/silver/store.py +59 -0
- ame/storage/custom_kg.py +33 -0
- ame/storage/lightrag_adapter.py +362 -0
- ame/validation/confidence.py +5 -0
- ame/validation/grounding.py +10 -0
- ame/validation/type_gate.py +22 -0
- ame/writeback.py +173 -0
- memory/__init__.py +3 -0
ame/hermes/memory.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from ame.bronze.schema import BronzeDocument
|
|
10
|
+
from ame.core.corpus import require_corpus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
PersonalMemoryType = Literal[
|
|
14
|
+
"Project",
|
|
15
|
+
"Idea",
|
|
16
|
+
"Decision",
|
|
17
|
+
"Task",
|
|
18
|
+
"Person",
|
|
19
|
+
"Meeting",
|
|
20
|
+
"Document",
|
|
21
|
+
"Email",
|
|
22
|
+
"Action",
|
|
23
|
+
"Preference",
|
|
24
|
+
"Routine",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PersonalMemory(BaseModel):
|
|
29
|
+
id: str
|
|
30
|
+
type: PersonalMemoryType
|
|
31
|
+
title: str
|
|
32
|
+
content: str
|
|
33
|
+
tags: list[str] = Field(default_factory=list)
|
|
34
|
+
source: str = "hermes"
|
|
35
|
+
source_id: str | None = None
|
|
36
|
+
source_url: str | None = None
|
|
37
|
+
metadata: dict[str, str | int | float | bool | list[str] | None] = Field(default_factory=dict)
|
|
38
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HermesRecallResult(BaseModel):
|
|
42
|
+
query: str
|
|
43
|
+
memories: list[PersonalMemory] = Field(default_factory=list)
|
|
44
|
+
sources: list[str] = Field(default_factory=list)
|
|
45
|
+
confidence: float = 0.0
|
|
46
|
+
reasoning_depth: int = 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HermesMemoryStore:
|
|
50
|
+
def __init__(self, corpus_id: str):
|
|
51
|
+
self.corpus_root = require_corpus(corpus_id)
|
|
52
|
+
self.path = self.corpus_root / "personal" / "memories.jsonl"
|
|
53
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
def write(
|
|
56
|
+
self,
|
|
57
|
+
memory_type: PersonalMemoryType,
|
|
58
|
+
title: str,
|
|
59
|
+
content: str,
|
|
60
|
+
tags: list[str] | None = None,
|
|
61
|
+
source: str = "hermes",
|
|
62
|
+
source_id: str | None = None,
|
|
63
|
+
source_url: str | None = None,
|
|
64
|
+
metadata: dict[str, str | int | float | bool | list[str] | None] | None = None,
|
|
65
|
+
) -> PersonalMemory:
|
|
66
|
+
memory = PersonalMemory(
|
|
67
|
+
id=f"personal_{len(self.list()) + 1:06d}",
|
|
68
|
+
type=memory_type,
|
|
69
|
+
title=title,
|
|
70
|
+
content=content,
|
|
71
|
+
tags=tags or [],
|
|
72
|
+
source=source,
|
|
73
|
+
source_id=source_id,
|
|
74
|
+
source_url=source_url,
|
|
75
|
+
metadata=metadata or {},
|
|
76
|
+
)
|
|
77
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
78
|
+
fh.write(memory.model_dump_json() + "\n")
|
|
79
|
+
return memory
|
|
80
|
+
|
|
81
|
+
def write_from_bronze(self, doc: BronzeDocument) -> PersonalMemory:
|
|
82
|
+
existing = next((memory for memory in self.list() if memory.source_id == doc.source_id), None)
|
|
83
|
+
if existing:
|
|
84
|
+
return existing
|
|
85
|
+
memory_type = self._memory_type(doc)
|
|
86
|
+
title = str(doc.metadata.get("title") or doc.source_id)
|
|
87
|
+
tags = sorted({doc.source_type, str(doc.metadata.get("google_service") or ""), str(doc.metadata.get("connector") or "")} - {""})
|
|
88
|
+
return self.write(
|
|
89
|
+
memory_type,
|
|
90
|
+
title,
|
|
91
|
+
doc.content,
|
|
92
|
+
tags=tags,
|
|
93
|
+
source=str(doc.metadata.get("connector") or doc.source_type),
|
|
94
|
+
source_id=doc.source_id,
|
|
95
|
+
source_url=_optional_text(doc.metadata.get("original_url")),
|
|
96
|
+
metadata=_personal_metadata(doc.metadata),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def import_bronze(self, docs: list[BronzeDocument]) -> list[PersonalMemory]:
|
|
100
|
+
return [self.write_from_bronze(doc) for doc in docs]
|
|
101
|
+
|
|
102
|
+
def list(self) -> list[PersonalMemory]:
|
|
103
|
+
if not self.path.exists():
|
|
104
|
+
return []
|
|
105
|
+
return [PersonalMemory.model_validate_json(line) for line in self.path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
106
|
+
|
|
107
|
+
def recall(self, query: str) -> HermesRecallResult:
|
|
108
|
+
normalized = query.casefold()
|
|
109
|
+
memories = [
|
|
110
|
+
memory
|
|
111
|
+
for memory in self.list()
|
|
112
|
+
if normalized in memory.title.casefold()
|
|
113
|
+
or normalized in memory.content.casefold()
|
|
114
|
+
or any(normalized in tag.casefold() for tag in memory.tags)
|
|
115
|
+
or normalized in memory.type.casefold()
|
|
116
|
+
]
|
|
117
|
+
return self._result(query, memories)
|
|
118
|
+
|
|
119
|
+
def recall_project(self, project: str) -> HermesRecallResult:
|
|
120
|
+
return self.recall(project)
|
|
121
|
+
|
|
122
|
+
def recall_person(self, person: str) -> HermesRecallResult:
|
|
123
|
+
return self.recall(person)
|
|
124
|
+
|
|
125
|
+
def recall_decisions(self, topic: str) -> HermesRecallResult:
|
|
126
|
+
result = self.recall(topic)
|
|
127
|
+
result.memories = [memory for memory in result.memories if memory.type == "Decision"]
|
|
128
|
+
result.sources = self._sources(result.memories)
|
|
129
|
+
result.confidence = self._confidence(result.memories)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def recall_next_actions(self) -> HermesRecallResult:
|
|
133
|
+
memories = [
|
|
134
|
+
memory
|
|
135
|
+
for memory in self.list()
|
|
136
|
+
if memory.type in {"Task", "Action"} or "next" in memory.content.casefold() or "priority" in memory.content.casefold()
|
|
137
|
+
]
|
|
138
|
+
return self._result("next_actions", memories)
|
|
139
|
+
|
|
140
|
+
def recall_timeline(self, date_from: str | None = None, date_to: str | None = None) -> HermesRecallResult:
|
|
141
|
+
memories = []
|
|
142
|
+
for memory in self.list():
|
|
143
|
+
occurred = _memory_time(memory)
|
|
144
|
+
if date_from and occurred and occurred < date_from:
|
|
145
|
+
continue
|
|
146
|
+
if date_to and occurred and occurred > date_to:
|
|
147
|
+
continue
|
|
148
|
+
memories.append(memory)
|
|
149
|
+
memories.sort(key=lambda memory: _memory_time(memory) or memory.created_at.isoformat(), reverse=True)
|
|
150
|
+
query = f"timeline:{date_from or '*'}..{date_to or '*'}"
|
|
151
|
+
return self._result(query, memories)
|
|
152
|
+
|
|
153
|
+
def _result(self, query: str, memories: list[PersonalMemory]) -> HermesRecallResult:
|
|
154
|
+
return HermesRecallResult(
|
|
155
|
+
query=query,
|
|
156
|
+
memories=memories,
|
|
157
|
+
sources=self._sources(memories),
|
|
158
|
+
confidence=self._confidence(memories),
|
|
159
|
+
reasoning_depth=max(1, min(4, len({memory.type for memory in memories}) or 1)),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _sources(self, memories: list[PersonalMemory]) -> list[str]:
|
|
163
|
+
return [memory.source_id or memory.source for memory in memories if memory.source_id or memory.source]
|
|
164
|
+
|
|
165
|
+
def _confidence(self, memories: list[PersonalMemory]) -> float:
|
|
166
|
+
if not memories:
|
|
167
|
+
return 0.0
|
|
168
|
+
return min(0.95, 0.62 + min(len(memories), 5) * 0.06)
|
|
169
|
+
|
|
170
|
+
def _memory_type(self, doc: BronzeDocument) -> PersonalMemoryType:
|
|
171
|
+
requested = doc.metadata.get("memory_type")
|
|
172
|
+
allowed = set(PersonalMemoryType.__args__) # type: ignore[attr-defined]
|
|
173
|
+
if isinstance(requested, str) and requested in allowed:
|
|
174
|
+
return requested # type: ignore[return-value]
|
|
175
|
+
source_map: dict[str, PersonalMemoryType] = {
|
|
176
|
+
"google_drive": "Document",
|
|
177
|
+
"gmail": "Email",
|
|
178
|
+
"google_calendar": "Meeting",
|
|
179
|
+
"google_sheets": "Document",
|
|
180
|
+
"jira": "Task",
|
|
181
|
+
"github": "Document",
|
|
182
|
+
"slack": "Decision",
|
|
183
|
+
}
|
|
184
|
+
return source_map.get(doc.source_type, "Document")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _personal_metadata(metadata: dict) -> dict[str, str | int | float | bool | list[str] | None]:
|
|
188
|
+
allowed: dict[str, str | int | float | bool | list[str] | None] = {}
|
|
189
|
+
for key, value in metadata.items():
|
|
190
|
+
if isinstance(value, str | int | float | bool) or value is None:
|
|
191
|
+
allowed[key] = value
|
|
192
|
+
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
193
|
+
allowed[key] = value
|
|
194
|
+
return allowed
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _optional_text(value) -> str | None:
|
|
198
|
+
if value is None:
|
|
199
|
+
return None
|
|
200
|
+
text = str(value)
|
|
201
|
+
return text or None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _memory_time(memory: PersonalMemory) -> str | None:
|
|
205
|
+
for key in ["occurred_at", "start", "date", "modified_at", "created_at"]:
|
|
206
|
+
value = memory.metadata.get(key)
|
|
207
|
+
if value:
|
|
208
|
+
return str(value)
|
|
209
|
+
return memory.created_at.isoformat()
|
ame/models/download.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import urllib.request
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
import json
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from ame.hardware.profiler import HardwareProfile
|
|
13
|
+
from ame.models.router import ModelPlan
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelDownloadError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModelInstallPlan(BaseModel):
|
|
21
|
+
tier: str
|
|
22
|
+
mode: str
|
|
23
|
+
ollama_installed: bool
|
|
24
|
+
required_models: list[str] = Field(default_factory=list)
|
|
25
|
+
installed_models: list[str] = Field(default_factory=list)
|
|
26
|
+
installed_models_source: str | None = None
|
|
27
|
+
missing_models: list[str] = Field(default_factory=list)
|
|
28
|
+
pull_commands: list[list[str]] = Field(default_factory=list)
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ModelPullResult(BaseModel):
|
|
33
|
+
model: str
|
|
34
|
+
command: list[str]
|
|
35
|
+
status: Literal["planned", "skipped", "success", "failed"]
|
|
36
|
+
transport: Literal["planned", "ollama_http", "ollama_cli"] = "planned"
|
|
37
|
+
output: str = ""
|
|
38
|
+
error: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
CommandRunner = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
|
42
|
+
JsonGetter = Callable[[str], dict]
|
|
43
|
+
JsonPoster = Callable[[str, dict], dict]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OllamaModelInstaller:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
runner: CommandRunner | None = None,
|
|
50
|
+
*,
|
|
51
|
+
host: str = "http://127.0.0.1:11434",
|
|
52
|
+
get_json: JsonGetter | None = None,
|
|
53
|
+
post_json: JsonPoster | None = None,
|
|
54
|
+
allow_cli_list: bool = False,
|
|
55
|
+
):
|
|
56
|
+
self.runner = runner or self._run
|
|
57
|
+
self.host = host.rstrip("/")
|
|
58
|
+
self.get_json = get_json or self._get_json
|
|
59
|
+
self.post_json = post_json or self._post_json
|
|
60
|
+
self.allow_cli_list = allow_cli_list
|
|
61
|
+
|
|
62
|
+
def install_plan(self, plan: ModelPlan, profile: HardwareProfile) -> ModelInstallPlan:
|
|
63
|
+
required = self.required_ollama_models(plan)
|
|
64
|
+
error = None
|
|
65
|
+
source = None
|
|
66
|
+
try:
|
|
67
|
+
installed, source = self.installed_models_with_source() if profile.ollama_installed else ([], None)
|
|
68
|
+
except ModelDownloadError as exc:
|
|
69
|
+
installed = []
|
|
70
|
+
error = str(exc)
|
|
71
|
+
missing = [model for model in required if not is_model_installed(model, installed)]
|
|
72
|
+
return ModelInstallPlan(
|
|
73
|
+
tier=plan.tier,
|
|
74
|
+
mode=plan.mode,
|
|
75
|
+
ollama_installed=profile.ollama_installed,
|
|
76
|
+
required_models=required,
|
|
77
|
+
installed_models=installed,
|
|
78
|
+
installed_models_source=source,
|
|
79
|
+
missing_models=missing,
|
|
80
|
+
pull_commands=[["ollama", "pull", model] for model in missing],
|
|
81
|
+
error=error,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def required_ollama_models(self, plan: ModelPlan) -> list[str]:
|
|
85
|
+
models = plan.models
|
|
86
|
+
seen: set[str] = set()
|
|
87
|
+
required: list[str] = []
|
|
88
|
+
for role in [models.extract, models.verify, models.synthesize, models.embed]:
|
|
89
|
+
if role.runtime != "ollama":
|
|
90
|
+
continue
|
|
91
|
+
if role.model in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(role.model)
|
|
94
|
+
required.append(role.model)
|
|
95
|
+
return required
|
|
96
|
+
|
|
97
|
+
def installed_models(self) -> list[str]:
|
|
98
|
+
models, _source = self.installed_models_with_source()
|
|
99
|
+
return models
|
|
100
|
+
|
|
101
|
+
def installed_models_with_source(self) -> tuple[list[str], str]:
|
|
102
|
+
http_error: ModelDownloadError | None = None
|
|
103
|
+
try:
|
|
104
|
+
return self._installed_models_from_http(), "ollama_http"
|
|
105
|
+
except ModelDownloadError as exc:
|
|
106
|
+
http_error = exc
|
|
107
|
+
if not self.allow_cli_list:
|
|
108
|
+
raise http_error
|
|
109
|
+
if shutil.which("ollama") is None:
|
|
110
|
+
raise ModelDownloadError("Ollama HTTP API is unavailable and ollama CLI was not found.")
|
|
111
|
+
result = self.runner(["ollama", "list"])
|
|
112
|
+
if result.returncode != 0:
|
|
113
|
+
raise ModelDownloadError(_clip_one_line(result.stderr or result.stdout or "ollama list failed"))
|
|
114
|
+
return _parse_ollama_list(result.stdout), "ollama_cli"
|
|
115
|
+
|
|
116
|
+
def pull(self, models: list[str], *, execute: bool = False, installed: list[str] | None = None) -> list[ModelPullResult]:
|
|
117
|
+
installed_set = set(installed or [])
|
|
118
|
+
results: list[ModelPullResult] = []
|
|
119
|
+
if execute and shutil.which("ollama") is None:
|
|
120
|
+
raise ModelDownloadError("Ollama is not installed. Install Ollama first, then run this command again.")
|
|
121
|
+
for model in models:
|
|
122
|
+
command = ["ollama", "pull", model]
|
|
123
|
+
if is_model_installed(model, list(installed_set)):
|
|
124
|
+
results.append(
|
|
125
|
+
ModelPullResult(
|
|
126
|
+
model=model,
|
|
127
|
+
command=command,
|
|
128
|
+
status="skipped",
|
|
129
|
+
transport="planned",
|
|
130
|
+
output="already installed",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
if not execute:
|
|
135
|
+
results.append(ModelPullResult(model=model, command=command, status="planned"))
|
|
136
|
+
continue
|
|
137
|
+
results.append(self._pull(model, command))
|
|
138
|
+
return results
|
|
139
|
+
|
|
140
|
+
def _run(self, command: list[str]) -> subprocess.CompletedProcess[str]:
|
|
141
|
+
return subprocess.run(command, capture_output=True, text=True, check=False)
|
|
142
|
+
|
|
143
|
+
def _installed_models_from_http(self) -> list[str]:
|
|
144
|
+
try:
|
|
145
|
+
payload = self.get_json(f"{self.host}/api/tags")
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
raise ModelDownloadError(f"Ollama HTTP API is unavailable: {_clip(str(exc))}") from exc
|
|
148
|
+
rows = payload.get("models")
|
|
149
|
+
if not isinstance(rows, list):
|
|
150
|
+
raise ModelDownloadError("Ollama HTTP API response did not include models.")
|
|
151
|
+
models: list[str] = []
|
|
152
|
+
for row in rows:
|
|
153
|
+
if not isinstance(row, dict):
|
|
154
|
+
continue
|
|
155
|
+
model = row.get("model") or row.get("name")
|
|
156
|
+
if model:
|
|
157
|
+
models.append(str(model))
|
|
158
|
+
return models
|
|
159
|
+
|
|
160
|
+
def _pull_http(self, model: str, command: list[str]) -> ModelPullResult:
|
|
161
|
+
try:
|
|
162
|
+
payload = self.post_json(f"{self.host}/api/pull", {"name": model, "stream": False})
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
return ModelPullResult(
|
|
165
|
+
model=model,
|
|
166
|
+
command=command,
|
|
167
|
+
status="failed",
|
|
168
|
+
transport="ollama_http",
|
|
169
|
+
error=_clip(str(exc)),
|
|
170
|
+
)
|
|
171
|
+
error = payload.get("error")
|
|
172
|
+
if error:
|
|
173
|
+
return ModelPullResult(model=model, command=command, status="failed", transport="ollama_http", error=str(error))
|
|
174
|
+
return ModelPullResult(model=model, command=command, status="success", transport="ollama_http", output=json.dumps(payload))
|
|
175
|
+
|
|
176
|
+
def _pull(self, model: str, command: list[str]) -> ModelPullResult:
|
|
177
|
+
http_result = self._pull_http(model, command)
|
|
178
|
+
if http_result.status == "success":
|
|
179
|
+
return http_result
|
|
180
|
+
return self._pull_cli(model, command, http_result.error)
|
|
181
|
+
|
|
182
|
+
def _pull_cli(self, model: str, command: list[str], http_error: str = "") -> ModelPullResult:
|
|
183
|
+
result = self.runner(command)
|
|
184
|
+
if result.returncode == 0:
|
|
185
|
+
return ModelPullResult(
|
|
186
|
+
model=model,
|
|
187
|
+
command=command,
|
|
188
|
+
status="success",
|
|
189
|
+
transport="ollama_cli",
|
|
190
|
+
output=result.stdout.strip(),
|
|
191
|
+
)
|
|
192
|
+
error = result.stderr.strip() or result.stdout.strip() or "ollama pull failed"
|
|
193
|
+
if http_error:
|
|
194
|
+
error = f"HTTP failed: {http_error}; CLI failed: {_clip(error)}"
|
|
195
|
+
return ModelPullResult(
|
|
196
|
+
model=model,
|
|
197
|
+
command=command,
|
|
198
|
+
status="failed",
|
|
199
|
+
transport="ollama_cli",
|
|
200
|
+
error=_clip(error),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _get_json(self, url: str) -> dict:
|
|
204
|
+
with urllib.request.urlopen(url, timeout=5) as response:
|
|
205
|
+
return json.loads(response.read().decode("utf-8"))
|
|
206
|
+
|
|
207
|
+
def _post_json(self, url: str, payload: dict) -> dict:
|
|
208
|
+
data = json.dumps(payload).encode("utf-8")
|
|
209
|
+
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
|
210
|
+
with urllib.request.urlopen(request, timeout=None) as response:
|
|
211
|
+
return json.loads(response.read().decode("utf-8"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _parse_ollama_list(output: str) -> list[str]:
|
|
215
|
+
models: list[str] = []
|
|
216
|
+
for line in output.splitlines():
|
|
217
|
+
stripped = line.strip()
|
|
218
|
+
if not stripped or stripped.casefold().startswith("name"):
|
|
219
|
+
continue
|
|
220
|
+
models.append(stripped.split()[0])
|
|
221
|
+
return models
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_model_installed(required: str, installed: list[str]) -> bool:
|
|
225
|
+
if required in installed:
|
|
226
|
+
return True
|
|
227
|
+
if ":" not in required and f"{required}:latest" in installed:
|
|
228
|
+
return True
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _clip(value: str, limit: int = 500) -> str:
|
|
233
|
+
if len(value) <= limit:
|
|
234
|
+
return value
|
|
235
|
+
return value[:limit].rstrip() + "..."
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _clip_one_line(value: str, limit: int = 180) -> str:
|
|
239
|
+
for line in value.splitlines():
|
|
240
|
+
stripped = line.strip()
|
|
241
|
+
if stripped:
|
|
242
|
+
return _clip(stripped, limit)
|
|
243
|
+
return "ollama command failed"
|
ame/models/ollama.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ame.core.errors import LlmClientError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OllamaClient:
|
|
14
|
+
def __init__(self, model: str | None = None, base_url: str | None = None, timeout: int = 120):
|
|
15
|
+
self.model = model or os.environ.get("AME_OLLAMA_MODEL", "qwen3:8b")
|
|
16
|
+
self.base_url = (base_url or os.environ.get("AME_OLLAMA_URL", "http://127.0.0.1:11434")).rstrip("/")
|
|
17
|
+
self.timeout = timeout
|
|
18
|
+
|
|
19
|
+
def complete_json(self, prompt: str, payload: dict) -> dict:
|
|
20
|
+
request_payload = {
|
|
21
|
+
"model": self.model,
|
|
22
|
+
"prompt": f"{prompt}\n\n입력 JSON:\n{json.dumps(payload, ensure_ascii=False)}",
|
|
23
|
+
"format": "json",
|
|
24
|
+
"stream": False,
|
|
25
|
+
}
|
|
26
|
+
request = urllib.request.Request(
|
|
27
|
+
f"{self.base_url}/api/generate",
|
|
28
|
+
data=json.dumps(request_payload).encode("utf-8"),
|
|
29
|
+
headers={"Content-Type": "application/json"},
|
|
30
|
+
method="POST",
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
34
|
+
raw: dict[str, Any] = json.loads(response.read().decode("utf-8"))
|
|
35
|
+
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
|
36
|
+
raise LlmClientError(f"Ollama request failed: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
text = raw.get("response")
|
|
39
|
+
if not isinstance(text, str):
|
|
40
|
+
raise LlmClientError("Ollama response did not include a JSON response string.")
|
|
41
|
+
return self._parse_response_json(text)
|
|
42
|
+
|
|
43
|
+
def _parse_response_json(self, text: str) -> dict:
|
|
44
|
+
text = text.strip()
|
|
45
|
+
if text.startswith("```"):
|
|
46
|
+
text = re.sub(r"\A```(?:json)?\s*", "", text, flags=re.IGNORECASE)
|
|
47
|
+
text = re.sub(r"\s*```\Z", "", text)
|
|
48
|
+
try:
|
|
49
|
+
parsed = json.loads(text)
|
|
50
|
+
except json.JSONDecodeError as exc:
|
|
51
|
+
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
|
52
|
+
if not match:
|
|
53
|
+
raise LlmClientError(f"Ollama returned invalid JSON: {text[:200]}") from exc
|
|
54
|
+
try:
|
|
55
|
+
parsed = json.loads(match.group(0))
|
|
56
|
+
except json.JSONDecodeError as nested_exc:
|
|
57
|
+
raise LlmClientError(f"Ollama returned invalid JSON: {text[:200]}") from nested_exc
|
|
58
|
+
if not isinstance(parsed, dict):
|
|
59
|
+
raise LlmClientError("Ollama JSON response must be an object.")
|
|
60
|
+
return parsed
|
ame/models/registry.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from ame.hardware.tier import Tier
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelRole(BaseModel):
|
|
13
|
+
model: str
|
|
14
|
+
runtime: str = "ollama"
|
|
15
|
+
fallback: str | None = None
|
|
16
|
+
dim: int | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TierModels(BaseModel):
|
|
20
|
+
extract: ModelRole
|
|
21
|
+
verify: ModelRole
|
|
22
|
+
synthesize: ModelRole
|
|
23
|
+
embed: ModelRole
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_REGISTRY: dict[str, Any] = {
|
|
27
|
+
"T1": {
|
|
28
|
+
"extract": {"model": "qwen3:8b"},
|
|
29
|
+
"verify": {"model": "qwen3:8b"},
|
|
30
|
+
"synthesize": {"model": "qwen3:8b", "fallback": "api"},
|
|
31
|
+
"embed": {"model": "nomic-embed-text"},
|
|
32
|
+
},
|
|
33
|
+
"T2": {
|
|
34
|
+
"extract": {"model": "qwen3:14b-q4"},
|
|
35
|
+
"verify": {"model": "qwen3:14b-q4"},
|
|
36
|
+
"synthesize": {"model": "qwen3:14b-q4"},
|
|
37
|
+
"embed": {"model": "bge-m3", "runtime": "local"},
|
|
38
|
+
},
|
|
39
|
+
"T3": {
|
|
40
|
+
"extract": {"model": "qwen3:30b-a3b"},
|
|
41
|
+
"verify": {"model": "qwen3:30b-a3b"},
|
|
42
|
+
"synthesize": {"model": "qwen3:32b-q4"},
|
|
43
|
+
"embed": {"model": "bge-m3", "runtime": "local"},
|
|
44
|
+
},
|
|
45
|
+
"T4": {
|
|
46
|
+
"extract": {"model": "qwen3:32b-q5"},
|
|
47
|
+
"verify": {"model": "qwen3:32b-q5"},
|
|
48
|
+
"synthesize": {"model": "qwen3:32b-q5"},
|
|
49
|
+
"embed": {"model": "bge-m3", "runtime": "local"},
|
|
50
|
+
},
|
|
51
|
+
"T5": {
|
|
52
|
+
"extract": {"model": "qwen3:70b-q4"},
|
|
53
|
+
"verify": {"model": "qwen3:70b-q4"},
|
|
54
|
+
"synthesize": {"model": "qwen3:70b-q4"},
|
|
55
|
+
"embed": {"model": "bge-m3-large", "runtime": "local"},
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ModelRegistry:
|
|
61
|
+
def __init__(self, data: dict[str, Any] | None = None):
|
|
62
|
+
self.data = {tier: self._normalize_tier(models) for tier, models in (data or DEFAULT_REGISTRY).items()}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_yaml(cls, path: Path) -> "ModelRegistry":
|
|
66
|
+
if not path.exists():
|
|
67
|
+
return cls()
|
|
68
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
69
|
+
return cls(data.get("tiers", data))
|
|
70
|
+
|
|
71
|
+
def for_tier(self, tier: Tier) -> TierModels:
|
|
72
|
+
return TierModels.model_validate(self.data[tier.value])
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
return self.data
|
|
76
|
+
|
|
77
|
+
def write_cache(self, path: Path) -> Path:
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
path.write_text(yaml.safe_dump({"version": 1, "tiers": self.data}, sort_keys=False), encoding="utf-8")
|
|
80
|
+
return path
|
|
81
|
+
|
|
82
|
+
def _normalize_tier(self, tier_data: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
+
normalized = dict(tier_data)
|
|
84
|
+
if "embedding" in normalized and "embed" not in normalized:
|
|
85
|
+
normalized["embed"] = normalized.pop("embedding")
|
|
86
|
+
for role in ["extract", "verify", "synthesize", "embed"]:
|
|
87
|
+
value = normalized.get(role)
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
normalized[role] = {"model": value}
|
|
90
|
+
return normalized
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_default_registry(path: Path | None = None) -> ModelRegistry:
|
|
94
|
+
if path is not None:
|
|
95
|
+
return ModelRegistry.from_yaml(path)
|
|
96
|
+
|
|
97
|
+
registry_path = Path("configs/model-registry.yaml")
|
|
98
|
+
if registry_path.exists():
|
|
99
|
+
return ModelRegistry.from_yaml(registry_path)
|
|
100
|
+
|
|
101
|
+
return ModelRegistry()
|
ame/models/router.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from ame.hardware.profiler import HardwareProfile
|
|
6
|
+
from ame.models.registry import ModelRegistry, TierModels
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ModelPlan(BaseModel):
|
|
10
|
+
tier: str
|
|
11
|
+
models: TierModels
|
|
12
|
+
mode: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelRouter:
|
|
16
|
+
def __init__(self, registry: ModelRegistry):
|
|
17
|
+
self.registry = registry
|
|
18
|
+
|
|
19
|
+
def plan(self, profile: HardwareProfile) -> ModelPlan:
|
|
20
|
+
models = self.registry.for_tier(profile.tier)
|
|
21
|
+
mode = "full-local" if profile.ollama_installed else "deterministic"
|
|
22
|
+
return ModelPlan(tier=profile.tier.value, models=models, mode=mode)
|