amfs-http-server 0.3.6__tar.gz → 0.3.8__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.6
3
+ Version: 0.3.8
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.6"
3
+ version = "0.3.8"
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,
@@ -146,6 +148,47 @@ _sse_manager = SSEManager()
146
148
  _bg_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="amfs-bg")
147
149
  _known_agents: set[str] = set()
148
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
+
149
192
  _immutable_trace_store = None
150
193
  try:
151
194
  from amfs_traces.api import mount_pro_routes
@@ -221,6 +264,18 @@ def _get_memory() -> AgentMemory:
221
264
  mem._engine._adapter = adapter
222
265
  mem._propagator._adapter = adapter
223
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
+
224
279
  _memory = mem
225
280
  return _memory
226
281
 
@@ -530,6 +585,21 @@ async def write_entry(
530
585
  shared=req.shared,
531
586
  branch=req.branch,
532
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
+ )
533
603
  try:
534
604
  entry = await _async_adapter.write(entry_obj)
535
605
  _used_async = True
@@ -800,6 +870,111 @@ async def search_entries(
800
870
  return [_entry_to_response(e) for e in results]
801
871
 
802
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
+
803
978
  # ──────────────────────────────────────────────────────────────────────
804
979
  # Stats
805
980
  # ──────────────────────────────────────────────────────────────────────
@@ -814,28 +989,48 @@ async def get_stats(
814
989
 
815
990
  vis = _get_visibility_filter(request)
816
991
  if vis is not None and vis.should_filter():
992
+ # Visibility-scoped stats. This branch must return the SAME shape as
993
+ # MemoryStats (which mem.stats() below produces), because the client
994
+ # parses /stats via MemoryStats.model_validate — any missing field
995
+ # silently defaults (confidence→0.0, outcome→0) and any mis-named key
996
+ # (e.g. "oldest_entry" vs "oldest_entry_at") is dropped to None. The
997
+ # earlier partial dict caused exactly that: correct counts but zeroed
998
+ # confidence/outcome and null timestamps.
999
+ from amfs_core.models import MemoryStats
1000
+
817
1001
  entries = mem.list()
818
1002
  entries = vis.filter_entries(entries)
819
1003
 
820
- entity_set: set[str] = set()
821
- agent_set: set[str] = set()
1004
+ agents: dict[str, int] = {}
1005
+ entities: dict[str, int] = {}
1006
+ confidences: list[float] = []
1007
+ written_ats: list[Any] = []
1008
+ outcome_linked = 0
822
1009
  for e in entries:
823
- entity_set.add(e.entity_path)
824
- agent_set.add(e.provenance.agent_id)
825
-
826
- return {
827
- "total_entries": len(entries),
828
- "total_entities": len(entity_set),
829
- "total_agents": len(agent_set),
830
- "oldest_entry": min(
831
- (e.provenance.written_at for e in entries if e.provenance.written_at),
832
- default=None,
833
- ),
834
- "newest_entry": max(
835
- (e.provenance.written_at for e in entries if e.provenance.written_at),
836
- default=None,
837
- ),
838
- }
1010
+ entities[e.entity_path] = entities.get(e.entity_path, 0) + 1
1011
+ aid = e.provenance.agent_id
1012
+ agents[aid] = agents.get(aid, 0) + 1
1013
+ confidences.append(float(e.confidence))
1014
+ if getattr(e, "outcome_count", 0):
1015
+ outcome_linked += 1
1016
+ wa = getattr(e.provenance, "written_at", None)
1017
+ if wa is not None:
1018
+ written_ats.append(wa)
1019
+
1020
+ scoped = MemoryStats(
1021
+ total_entries=len(entries),
1022
+ total_entities=len(entities),
1023
+ total_agents=len(agents),
1024
+ agents=agents,
1025
+ entities=entities,
1026
+ confidence_avg=(sum(confidences) / len(confidences)) if confidences else 0.0,
1027
+ confidence_min=min(confidences) if confidences else 0.0,
1028
+ confidence_max=max(confidences) if confidences else 0.0,
1029
+ outcome_linked_count=outcome_linked,
1030
+ oldest_entry_at=min(written_ats) if written_ats else None,
1031
+ newest_entry_at=max(written_ats) if written_ats else None,
1032
+ )
1033
+ return json.loads(json.dumps(scoped.model_dump(mode="json"), default=str))
839
1034
 
840
1035
  stats = mem.stats()
841
1036
  return json.loads(json.dumps(stats.model_dump(mode="json"), default=str))