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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amfs-http-server
3
- Version: 0.3.5
3
+ Version: 0.3.7
4
4
  Summary: AMFS HTTP/REST API server with SSE support
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amfs-http-server"
3
- version = "0.3.5"
3
+ version = "0.3.7"
4
4
  description = "AMFS HTTP/REST API server with SSE support"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.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="0.1.0",
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 {"status": "ok"}
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 {"status": "ok"}
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
  # ──────────────────────────────────────────────────────────────────────