amfs-http-server 0.3.5__tar.gz → 0.3.7__tar.gz
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.
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/PKG-INFO +1 -1
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/pyproject.toml +1 -1
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/models.py +18 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/server.py +210 -3
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/.gitignore +0 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/auth.py +0 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/openrouter_proxy.py +0 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/pro_provider.py +0 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.3.5 → amfs_http_server-0.3.7}/src/amfs_http/tenant_middleware.py +0 -0
|
@@ -41,6 +41,24 @@ class SearchRequest(BaseModel):
|
|
|
41
41
|
depth: int = 3
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
class RetrieveRequest(BaseModel):
|
|
45
|
+
"""Semantic (meaning-based) retrieval request.
|
|
46
|
+
|
|
47
|
+
Ranks entries by embedding similarity to the query, blended with recency
|
|
48
|
+
and confidence. entity_path is optional — when omitted, searches across
|
|
49
|
+
everything the caller can see.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
query: str
|
|
53
|
+
entity_path: str | None = None
|
|
54
|
+
min_confidence: float = 0.0
|
|
55
|
+
limit: int = 10
|
|
56
|
+
semantic_weight: float = 0.5
|
|
57
|
+
recency_weight: float = 0.3
|
|
58
|
+
confidence_weight: float = 0.2
|
|
59
|
+
branch: str = "main"
|
|
60
|
+
|
|
61
|
+
|
|
44
62
|
class ContextRequest(BaseModel):
|
|
45
63
|
label: str
|
|
46
64
|
summary: str
|
|
@@ -44,6 +44,7 @@ from amfs_core.models import (
|
|
|
44
44
|
LayerConfig,
|
|
45
45
|
MemoryEntry,
|
|
46
46
|
SearchQuery,
|
|
47
|
+
SemanticQuery,
|
|
47
48
|
)
|
|
48
49
|
from amfs_core.quality import HeuristicQualityEvaluator
|
|
49
50
|
|
|
@@ -56,6 +57,7 @@ from amfs_http.models import (
|
|
|
56
57
|
CreateTeamRequest,
|
|
57
58
|
EventRequest,
|
|
58
59
|
OutcomeRequest,
|
|
60
|
+
RetrieveRequest,
|
|
59
61
|
RunPatternDetectionRequest,
|
|
60
62
|
SearchRequest,
|
|
61
63
|
UpdateTeamMemberRequest,
|
|
@@ -66,6 +68,27 @@ from amfs_http.sse import SSEManager
|
|
|
66
68
|
|
|
67
69
|
logger = logging.getLogger(__name__)
|
|
68
70
|
|
|
71
|
+
|
|
72
|
+
def _server_version() -> str:
|
|
73
|
+
"""Return the deployed amfs-http-server package version.
|
|
74
|
+
|
|
75
|
+
Sourced from installed package metadata so it changes on every release —
|
|
76
|
+
unlike the per-entry ``amfs_version`` schema tag. This is the value to use
|
|
77
|
+
when telling deploys/revisions apart.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
from importlib.metadata import version
|
|
81
|
+
|
|
82
|
+
return version("amfs-http-server")
|
|
83
|
+
except Exception:
|
|
84
|
+
return "unknown"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Set by the deploy pipeline (e.g. the git SHA) so a running revision is
|
|
88
|
+
# identifiable even between version bumps. Empty when not provided.
|
|
89
|
+
_BUILD_SHA = os.environ.get("AMFS_BUILD_SHA", "")
|
|
90
|
+
_SCHEMA_VERSION = MemoryEntry.model_fields["amfs_version"].default
|
|
91
|
+
|
|
69
92
|
# ── Async adapter (hot-path, non-blocking) ──────────────────────────
|
|
70
93
|
_async_adapter = None # AsyncPostgresAdapter | None, set in lifespan
|
|
71
94
|
|
|
@@ -94,7 +117,7 @@ async def _lifespan(application: FastAPI): # noqa: ARG001
|
|
|
94
117
|
app = FastAPI(
|
|
95
118
|
title="AMFS HTTP API",
|
|
96
119
|
description="Agent Memory File System — REST API with SSE support",
|
|
97
|
-
version=
|
|
120
|
+
version=_server_version(),
|
|
98
121
|
lifespan=_lifespan,
|
|
99
122
|
)
|
|
100
123
|
|
|
@@ -125,6 +148,47 @@ _sse_manager = SSEManager()
|
|
|
125
148
|
_bg_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="amfs-bg")
|
|
126
149
|
_known_agents: set[str] = set()
|
|
127
150
|
|
|
151
|
+
# ── Semantic embedder (shared by write-time embedding + /retrieve) ──────
|
|
152
|
+
# One instance for the whole process so write and query vectors come from the
|
|
153
|
+
# SAME model (otherwise cosine similarity is meaningless). Env-gated and fully
|
|
154
|
+
# crash-safe: if the embedder can't be built, we return None and every caller
|
|
155
|
+
# falls back to lexical behaviour — a bad rollout degrades to the status quo,
|
|
156
|
+
# it never breaks writes or reads.
|
|
157
|
+
_UNSET_EMBEDDER: Any = object()
|
|
158
|
+
_server_embedder: Any = _UNSET_EMBEDDER
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _embeddings_enabled() -> bool:
|
|
162
|
+
return os.environ.get("AMFS_ENABLE_EMBEDDINGS", "true").strip().lower() in (
|
|
163
|
+
"1", "true", "yes", "on",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_server_embedder():
|
|
168
|
+
"""Return the process-wide embedder, or None if disabled/unavailable."""
|
|
169
|
+
global _server_embedder
|
|
170
|
+
if _server_embedder is _UNSET_EMBEDDER:
|
|
171
|
+
_server_embedder = None
|
|
172
|
+
if _embeddings_enabled():
|
|
173
|
+
try:
|
|
174
|
+
from amfs_core.default_embedder import create_default_embedder
|
|
175
|
+
|
|
176
|
+
_server_embedder = create_default_embedder()
|
|
177
|
+
logger.info(
|
|
178
|
+
"Semantic embedder ready: %s",
|
|
179
|
+
type(_server_embedder).__name__,
|
|
180
|
+
)
|
|
181
|
+
except Exception: # noqa: BLE001 - never block startup on the embedder
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Embedder init failed — semantic retrieval disabled, "
|
|
184
|
+
"falling back to lexical search",
|
|
185
|
+
exc_info=True,
|
|
186
|
+
)
|
|
187
|
+
_server_embedder = None
|
|
188
|
+
else:
|
|
189
|
+
logger.info("AMFS_ENABLE_EMBEDDINGS is off — semantic retrieval disabled")
|
|
190
|
+
return _server_embedder
|
|
191
|
+
|
|
128
192
|
_immutable_trace_store = None
|
|
129
193
|
try:
|
|
130
194
|
from amfs_traces.api import mount_pro_routes
|
|
@@ -200,6 +264,18 @@ def _get_memory() -> AgentMemory:
|
|
|
200
264
|
mem._engine._adapter = adapter
|
|
201
265
|
mem._propagator._adapter = adapter
|
|
202
266
|
|
|
267
|
+
# Share the process-wide embedder so mem.write() embeds on the sync
|
|
268
|
+
# fallback path and mem.semantic_search() works. The hot async path embeds
|
|
269
|
+
# explicitly in the write endpoint. Safe no-op when embeddings are disabled.
|
|
270
|
+
embedder = _get_server_embedder()
|
|
271
|
+
if embedder is not None:
|
|
272
|
+
mem._embedder = embedder
|
|
273
|
+
try:
|
|
274
|
+
if hasattr(adapter, "_embedder") and getattr(adapter, "_embedder", None) is None:
|
|
275
|
+
adapter._embedder = embedder
|
|
276
|
+
except Exception: # noqa: BLE001 - adapter embedding is best-effort
|
|
277
|
+
logger.debug("Could not attach embedder to adapter", exc_info=True)
|
|
278
|
+
|
|
203
279
|
_memory = mem
|
|
204
280
|
return _memory
|
|
205
281
|
|
|
@@ -316,14 +392,25 @@ def _ensure_agent_owner(
|
|
|
316
392
|
# ──────────────────────────────────────────────────────────────────────
|
|
317
393
|
|
|
318
394
|
|
|
395
|
+
def _health_payload() -> dict[str, str]:
|
|
396
|
+
payload = {
|
|
397
|
+
"status": "ok",
|
|
398
|
+
"version": _server_version(), # deploy/package version — changes per release
|
|
399
|
+
"schema_version": _SCHEMA_VERSION, # per-entry MemoryEntry schema tag
|
|
400
|
+
}
|
|
401
|
+
if _BUILD_SHA:
|
|
402
|
+
payload["build"] = _BUILD_SHA
|
|
403
|
+
return payload
|
|
404
|
+
|
|
405
|
+
|
|
319
406
|
@app.get("/health")
|
|
320
407
|
async def health() -> dict[str, str]:
|
|
321
|
-
return
|
|
408
|
+
return _health_payload()
|
|
322
409
|
|
|
323
410
|
|
|
324
411
|
@app.get("/api/v1/health")
|
|
325
412
|
async def health_v1() -> dict[str, str]:
|
|
326
|
-
return
|
|
413
|
+
return _health_payload()
|
|
327
414
|
|
|
328
415
|
|
|
329
416
|
@app.get("/api/v1/auth/whoami")
|
|
@@ -498,6 +585,21 @@ async def write_entry(
|
|
|
498
585
|
shared=req.shared,
|
|
499
586
|
branch=req.branch,
|
|
500
587
|
)
|
|
588
|
+
# Write-time embedding for semantic retrieval. The async adapter
|
|
589
|
+
# persists entry.embedding when the pgvector column exists; without
|
|
590
|
+
# this the hot write path stores no vector (embeddings never land).
|
|
591
|
+
# Crash-safe: a failure here just stores the entry without a vector.
|
|
592
|
+
_embedder = _get_server_embedder()
|
|
593
|
+
if _embedder is not None:
|
|
594
|
+
try:
|
|
595
|
+
entry_obj = entry_obj.model_copy(
|
|
596
|
+
update={"embedding": _embedder.embed_value(req.value)}
|
|
597
|
+
)
|
|
598
|
+
except Exception: # noqa: BLE001 - never fail a write on embedding
|
|
599
|
+
logger.warning(
|
|
600
|
+
"write-time embedding failed for %s/%s — storing without vector",
|
|
601
|
+
req.entity_path, req.key, exc_info=True,
|
|
602
|
+
)
|
|
501
603
|
try:
|
|
502
604
|
entry = await _async_adapter.write(entry_obj)
|
|
503
605
|
_used_async = True
|
|
@@ -768,6 +870,111 @@ async def search_entries(
|
|
|
768
870
|
return [_entry_to_response(e) for e in results]
|
|
769
871
|
|
|
770
872
|
|
|
873
|
+
@app.post("/api/v1/retrieve")
|
|
874
|
+
async def retrieve_entries(
|
|
875
|
+
request: Request,
|
|
876
|
+
req: RetrieveRequest,
|
|
877
|
+
_auth: str | None = Depends(verify_api_key),
|
|
878
|
+
) -> list[dict[str, Any]]:
|
|
879
|
+
"""Semantic (meaning-based) retrieval.
|
|
880
|
+
|
|
881
|
+
Ranks entries by embedding similarity to the query, blended with recency
|
|
882
|
+
and confidence, so plain-language queries match paraphrased memories.
|
|
883
|
+
Account isolation comes from the RLS-scoped async adapter; user/room
|
|
884
|
+
visibility is then enforced by UserVisibilityFilter (same as /search).
|
|
885
|
+
|
|
886
|
+
Degrades gracefully to lexical search when the embedder or pgvector column
|
|
887
|
+
is unavailable, so this endpoint never returns worse than /search.
|
|
888
|
+
"""
|
|
889
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
890
|
+
|
|
891
|
+
branch = req.branch or "main"
|
|
892
|
+
vis = _get_visibility_filter(request)
|
|
893
|
+
embedder = _get_server_embedder()
|
|
894
|
+
|
|
895
|
+
pairs: list[tuple[MemoryEntry, float]] = []
|
|
896
|
+
if embedder is not None and _async_adapter is not None:
|
|
897
|
+
# Over-fetch a candidate pool so the recency/confidence blend has
|
|
898
|
+
# headroom beyond pure similarity, then trim to `limit` after scoring.
|
|
899
|
+
pool = max(req.limit * 5, 50)
|
|
900
|
+
sq = SemanticQuery(
|
|
901
|
+
text=req.query,
|
|
902
|
+
entity_path=req.entity_path,
|
|
903
|
+
min_confidence=req.min_confidence,
|
|
904
|
+
limit=pool,
|
|
905
|
+
)
|
|
906
|
+
try:
|
|
907
|
+
pairs = await _async_adapter.semantic_search(sq, embedder, branch=branch)
|
|
908
|
+
except Exception:
|
|
909
|
+
logger.warning("semantic_search failed — falling back to lexical", exc_info=True)
|
|
910
|
+
pairs = []
|
|
911
|
+
|
|
912
|
+
if pairs:
|
|
913
|
+
if vis is not None and vis.should_filter():
|
|
914
|
+
allowed = {id(e) for e in vis.filter_entries([e for e, _ in pairs])}
|
|
915
|
+
pairs = [(e, s) for e, s in pairs if id(e) in allowed]
|
|
916
|
+
|
|
917
|
+
now = _dt.now(_tz.utc)
|
|
918
|
+
half_life = 30.0
|
|
919
|
+
scored: list[tuple[MemoryEntry, float, dict[str, float]]] = []
|
|
920
|
+
for entry, sim in pairs:
|
|
921
|
+
written = getattr(entry.provenance, "written_at", None)
|
|
922
|
+
if written is not None:
|
|
923
|
+
if written.tzinfo is None:
|
|
924
|
+
written = written.replace(tzinfo=_tz.utc)
|
|
925
|
+
age_days = max(0.0, (now - written).total_seconds() / 86400.0)
|
|
926
|
+
recency = 0.5 ** (age_days / half_life)
|
|
927
|
+
else:
|
|
928
|
+
recency = 0.0
|
|
929
|
+
conf = float(entry.confidence)
|
|
930
|
+
score = (
|
|
931
|
+
req.semantic_weight * sim
|
|
932
|
+
+ req.recency_weight * recency
|
|
933
|
+
+ req.confidence_weight * conf
|
|
934
|
+
)
|
|
935
|
+
scored.append((entry, score, {
|
|
936
|
+
"semantic": round(sim, 4),
|
|
937
|
+
"recency": round(recency, 4),
|
|
938
|
+
"confidence": round(conf, 4),
|
|
939
|
+
}))
|
|
940
|
+
|
|
941
|
+
scored.sort(key=lambda t: t[1], reverse=True)
|
|
942
|
+
out: list[dict[str, Any]] = []
|
|
943
|
+
for entry, score, breakdown in scored[: req.limit]:
|
|
944
|
+
data = _entry_to_response(entry)
|
|
945
|
+
data["_score"] = round(score, 4)
|
|
946
|
+
data["_breakdown"] = breakdown
|
|
947
|
+
out.append(data)
|
|
948
|
+
return out
|
|
949
|
+
|
|
950
|
+
# ── Lexical fallback (embedder/pgvector unavailable, or no vector hits) ──
|
|
951
|
+
sq_lex = SearchQuery(
|
|
952
|
+
query=req.query,
|
|
953
|
+
entity_path=req.entity_path,
|
|
954
|
+
min_confidence=req.min_confidence,
|
|
955
|
+
limit=req.limit,
|
|
956
|
+
sort_by="confidence",
|
|
957
|
+
depth=3,
|
|
958
|
+
)
|
|
959
|
+
mem = _get_memory()
|
|
960
|
+
results: list[MemoryEntry] = []
|
|
961
|
+
if _async_adapter is not None:
|
|
962
|
+
try:
|
|
963
|
+
results = await _async_adapter.search(sq_lex, branch=branch)
|
|
964
|
+
except Exception:
|
|
965
|
+
results = []
|
|
966
|
+
if not results:
|
|
967
|
+
try:
|
|
968
|
+
results = mem._adapter.search(sq_lex, branch=branch)
|
|
969
|
+
except TypeError:
|
|
970
|
+
results = mem._adapter.search(sq_lex)
|
|
971
|
+
except Exception:
|
|
972
|
+
results = []
|
|
973
|
+
if vis is not None and vis.should_filter():
|
|
974
|
+
results = vis.filter_entries(results)
|
|
975
|
+
return [_entry_to_response(e) for e in results]
|
|
976
|
+
|
|
977
|
+
|
|
771
978
|
# ──────────────────────────────────────────────────────────────────────
|
|
772
979
|
# Stats
|
|
773
980
|
# ──────────────────────────────────────────────────────────────────────
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|