amfs-http-server 0.3.6__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.6 → amfs_http_server-0.3.7}/PKG-INFO +1 -1
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/pyproject.toml +1 -1
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/models.py +18 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/server.py +175 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/.gitignore +0 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/auth.py +0 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/openrouter_proxy.py +0 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/pro_provider.py +0 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.3.6 → amfs_http_server-0.3.7}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.3.6 → 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,
|
|
@@ -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
|
# ──────────────────────────────────────────────────────────────────────
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|