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.
Files changed (72) hide show
  1. adaptive_memory_engine-0.1.6.dist-info/METADATA +228 -0
  2. adaptive_memory_engine-0.1.6.dist-info/RECORD +72 -0
  3. adaptive_memory_engine-0.1.6.dist-info/WHEEL +4 -0
  4. adaptive_memory_engine-0.1.6.dist-info/entry_points.txt +3 -0
  5. adaptive_memory_engine-0.1.6.dist-info/licenses/LICENSE +21 -0
  6. ame/__init__.py +1 -0
  7. ame/agent/__init__.py +1 -0
  8. ame/agent/mcp.py +474 -0
  9. ame/agent/memory_api.py +141 -0
  10. ame/agent/results.py +30 -0
  11. ame/bronze/schema.py +17 -0
  12. ame/bronze/store.py +38 -0
  13. ame/cli/__init__.py +1 -0
  14. ame/cli/main.py +903 -0
  15. ame/connectors/base.py +30 -0
  16. ame/connectors/contract.py +199 -0
  17. ame/connectors/github.py +66 -0
  18. ame/connectors/google.py +464 -0
  19. ame/connectors/google_oauth.py +156 -0
  20. ame/connectors/jira.py +66 -0
  21. ame/connectors/json_helpers.py +43 -0
  22. ame/connectors/markdown.py +116 -0
  23. ame/connectors/notion.py +59 -0
  24. ame/connectors/oauth_callback.py +102 -0
  25. ame/connectors/oauth_provider.py +250 -0
  26. ame/connectors/obsidian.py +19 -0
  27. ame/connectors/router.py +155 -0
  28. ame/connectors/slack.py +66 -0
  29. ame/connectors/slack_oauth.py +417 -0
  30. ame/connectors/sync_history.py +73 -0
  31. ame/context_budget.py +106 -0
  32. ame/core/config.py +77 -0
  33. ame/core/corpus.py +17 -0
  34. ame/core/errors.py +18 -0
  35. ame/core/paths.py +111 -0
  36. ame/core/state.py +57 -0
  37. ame/export/obsidian.py +123 -0
  38. ame/gold/builder.py +300 -0
  39. ame/gold/ontology.py +80 -0
  40. ame/gold/resolver.py +91 -0
  41. ame/gold/schema.py +40 -0
  42. ame/gold/store.py +45 -0
  43. ame/hardware/profiler.py +85 -0
  44. ame/hardware/tier.py +27 -0
  45. ame/hermes/__init__.py +3 -0
  46. ame/hermes/memory.py +209 -0
  47. ame/models/download.py +243 -0
  48. ame/models/ollama.py +60 -0
  49. ame/models/registry.py +101 -0
  50. ame/models/router.py +22 -0
  51. ame/pipeline.py +155 -0
  52. ame/query/diff.py +40 -0
  53. ame/query/engine.py +919 -0
  54. ame/query/memory_os.py +313 -0
  55. ame/query/mql.py +84 -0
  56. ame/query/multihop.py +264 -0
  57. ame/query/result.py +20 -0
  58. ame/sdk.py +52 -0
  59. ame/security.py +145 -0
  60. ame/silver/extractor.py +414 -0
  61. ame/silver/llm_extractor.py +181 -0
  62. ame/silver/prompts.py +56 -0
  63. ame/silver/rationale.py +140 -0
  64. ame/silver/schema.py +51 -0
  65. ame/silver/store.py +59 -0
  66. ame/storage/custom_kg.py +33 -0
  67. ame/storage/lightrag_adapter.py +362 -0
  68. ame/validation/confidence.py +5 -0
  69. ame/validation/grounding.py +10 -0
  70. ame/validation/type_gate.py +22 -0
  71. ame/writeback.py +173 -0
  72. 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)