goodmem-nlweb 0.3.0__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.
- goodmem_nlweb/__init__.py +40 -0
- goodmem_nlweb/_connection.py +70 -0
- goodmem_nlweb/_ids.py +56 -0
- goodmem_nlweb/_results.py +110 -0
- goodmem_nlweb/_schema.py +135 -0
- goodmem_nlweb/_spaces.py +124 -0
- goodmem_nlweb/_typing.py +49 -0
- goodmem_nlweb/filters.py +138 -0
- goodmem_nlweb/provider.py +424 -0
- goodmem_nlweb/py.typed +0 -0
- goodmem_nlweb-0.3.0.dist-info/METADATA +232 -0
- goodmem_nlweb-0.3.0.dist-info/RECORD +14 -0
- goodmem_nlweb-0.3.0.dist-info/WHEEL +4 -0
- goodmem_nlweb-0.3.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""GoodMem as an NLWeb retrieval provider.
|
|
2
|
+
|
|
3
|
+
Registered by configuration -- NLWeb imports the class by path, so this
|
|
4
|
+
package never has to be added to NLWeb's own source tree::
|
|
5
|
+
|
|
6
|
+
retrieval:
|
|
7
|
+
default:
|
|
8
|
+
import_path: goodmem_nlweb
|
|
9
|
+
class_name: GoodMemRetrievalProvider
|
|
10
|
+
base_url: https://localhost:8080
|
|
11
|
+
api_key: gm_…
|
|
12
|
+
space_name: nlweb
|
|
13
|
+
|
|
14
|
+
NLWeb speaks Schema.org and filters by *site*; GoodMem stores text and
|
|
15
|
+
metadata. ``goodmem_nlweb._schema`` documents the mapping.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from goodmem_nlweb._connection import GoodMemConnection
|
|
19
|
+
from goodmem_nlweb._schema import schema_to_text, to_memory_fields
|
|
20
|
+
from goodmem_nlweb._spaces import GoodMemSpaceError
|
|
21
|
+
from goodmem_nlweb.provider import (
|
|
22
|
+
GoodMemObjectLookupProvider,
|
|
23
|
+
GoodMemRetrievalProvider,
|
|
24
|
+
GoodMemUploadError,
|
|
25
|
+
upload_documents,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "0.3.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"GoodMemConnection",
|
|
32
|
+
"GoodMemObjectLookupProvider",
|
|
33
|
+
"GoodMemRetrievalProvider",
|
|
34
|
+
"GoodMemSpaceError",
|
|
35
|
+
"GoodMemUploadError",
|
|
36
|
+
"__version__",
|
|
37
|
+
"schema_to_text",
|
|
38
|
+
"to_memory_fields",
|
|
39
|
+
"upload_documents",
|
|
40
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""SDK client ownership.
|
|
2
|
+
|
|
3
|
+
Ported from the AutoGen integration; the cancellation-token plumbing is
|
|
4
|
+
removed because NLWeb has no equivalent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from goodmem import AsyncGoodmem
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
from goodmem_nlweb._typing import AsyncGoodmemClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GoodMemConnection:
|
|
18
|
+
"""Owns an ``AsyncGoodmem`` client, or borrows a caller-supplied one.
|
|
19
|
+
|
|
20
|
+
An injected client keeps its own server, credentials and TLS settings;
|
|
21
|
+
this class never closes it. Otherwise one client is created lazily and
|
|
22
|
+
closed by :meth:`close`. There is no process-wide client cache.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
base_url: str | None = None,
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
verify_ssl: bool | str = True,
|
|
31
|
+
timeout: float = 60.0,
|
|
32
|
+
client: AsyncGoodmem | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._base_url = base_url
|
|
35
|
+
self._api_key = api_key
|
|
36
|
+
self._verify_ssl = verify_ssl
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
self._injected = client
|
|
39
|
+
self._owned: AsyncGoodmem | None = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def owns_client(self) -> bool:
|
|
43
|
+
return self._injected is None
|
|
44
|
+
|
|
45
|
+
def client(self) -> AsyncGoodmemClient:
|
|
46
|
+
if self._injected is not None:
|
|
47
|
+
return cast(AsyncGoodmemClient, self._injected)
|
|
48
|
+
if self._owned is None:
|
|
49
|
+
if not self._base_url or not self._api_key:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"GoodMem base_url and api_key are required when no client is injected."
|
|
52
|
+
)
|
|
53
|
+
self._owned = AsyncGoodmem(
|
|
54
|
+
base_url=self._base_url.rstrip("/"),
|
|
55
|
+
api_key=self._api_key,
|
|
56
|
+
verify=self._verify_ssl,
|
|
57
|
+
timeout=self._timeout,
|
|
58
|
+
)
|
|
59
|
+
return cast(AsyncGoodmemClient, self._owned)
|
|
60
|
+
|
|
61
|
+
async def close(self) -> None:
|
|
62
|
+
if self._owned is not None:
|
|
63
|
+
await self._owned.close()
|
|
64
|
+
self._owned = None
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> Self:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
70
|
+
await self.close()
|
goodmem_nlweb/_ids.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The one check a GoodMem id passes before it can reach a URL path.
|
|
2
|
+
|
|
3
|
+
The goodmem SDK builds request paths by interpolating ids unencoded
|
|
4
|
+
(``f"/v1/spaces/{space_id}/memories"``, ``f"/v1/memories/{id}"``), and httpx
|
|
5
|
+
resolves dot segments before it sends. A space id of ``../spaces/<other>``
|
|
6
|
+
is therefore a request against a different space, and ``<id>#x`` or
|
|
7
|
+
``<id>?x=1`` cuts the path short -- measured, not assumed; see
|
|
8
|
+
``tests/test_ids.py``. Neither the client nor the server can be relied on to
|
|
9
|
+
stop that, so it is stopped here, before any request is made.
|
|
10
|
+
|
|
11
|
+
Every GoodMem id (space, memory, embedder, reranker, ...) is a UUID, so the
|
|
12
|
+
rule is simple: a canonical UUID is accepted and lowercased, and anything
|
|
13
|
+
else is refused.
|
|
14
|
+
|
|
15
|
+
Where it is called:
|
|
16
|
+
|
|
17
|
+
* configured ids are checked when the provider is built, so a bad option
|
|
18
|
+
fails when NLWeb loads it rather than on the first request;
|
|
19
|
+
* an id that goes into a URL path is checked again immediately before the
|
|
20
|
+
SDK call that uses it, whatever its source -- configuration, or the
|
|
21
|
+
server's own answer.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from typing import Any
|
|
28
|
+
import uuid
|
|
29
|
+
|
|
30
|
+
# fullmatch, not ^...$: in a Python regex `$` also matches before a trailing
|
|
31
|
+
# newline, which would let "<uuid>\n" through.
|
|
32
|
+
_CANONICAL_UUID = re.compile(
|
|
33
|
+
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_uuid(value: Any, field: str) -> str:
|
|
38
|
+
"""Return ``value`` as a lowercase canonical UUID, or raise ``ValueError``.
|
|
39
|
+
|
|
40
|
+
A ``uuid.UUID`` is accepted as-is. The looser spellings ``uuid.UUID()``
|
|
41
|
+
parses -- braces, ``urn:uuid:``, no hyphens -- are refused: an id that is
|
|
42
|
+
about to become a path segment has exactly one acceptable form.
|
|
43
|
+
|
|
44
|
+
The refused value is not repeated in the message. A mistyped option can
|
|
45
|
+
be a credential, and this message ends up in logs.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(value, uuid.UUID):
|
|
48
|
+
return str(value)
|
|
49
|
+
if isinstance(value, str) and _CANONICAL_UUID.fullmatch(value):
|
|
50
|
+
return value.lower()
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"{field} must be a UUID (8-4-4-4-12 hexadecimal digits). GoodMem ids "
|
|
53
|
+
"are UUIDs, and any other value is refused before a request is made "
|
|
54
|
+
"because the SDK places ids in the URL path unencoded, where a value "
|
|
55
|
+
"such as '../' would send the request to a different endpoint."
|
|
56
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Retrieval event handling: statuses, chunk/memory joining, scores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from goodmem.models.good_mem_status import GoodMemStatus
|
|
9
|
+
from goodmem.models.retrieve_memory_event import RetrieveMemoryEvent
|
|
10
|
+
|
|
11
|
+
# Notices that carry no loss of results.
|
|
12
|
+
#
|
|
13
|
+
# FEATURE_DISABLED is informational unconditionally. The server defines it as
|
|
14
|
+
# "feature disabled due to missing configuration" (common.proto, under
|
|
15
|
+
# "Informational status messages (non-error)"): the caller did not configure
|
|
16
|
+
# an optional feature, so nothing the caller asked for is missing. A feature
|
|
17
|
+
# that was requested and could not be delivered arrives as a different code
|
|
18
|
+
# (NOT_FOUND, RERANKING_FAILED, ...). Retrieval status contract, Q1.
|
|
19
|
+
_INFORMATIONAL_CODES = frozenset({"LLM_CAPABILITY_INFERRED", "FEATURE_DISABLED"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_informational(status: GoodMemStatus) -> bool:
|
|
23
|
+
"""True for notices that do not indicate incomplete retrieval.
|
|
24
|
+
|
|
25
|
+
An unrecognized code is deliberately not informational: the SDK decodes
|
|
26
|
+
codes it does not know as ``None``, and a status from a newer server must
|
|
27
|
+
be surfaced rather than assumed harmless.
|
|
28
|
+
"""
|
|
29
|
+
return status.code is not None and status.code in _INFORMATIONAL_CODES
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def classify(events: Sequence[RetrieveMemoryEvent]) -> tuple[list[dict[str, Any]], bool]:
|
|
33
|
+
"""Split statuses into what to report and whether results are incomplete.
|
|
34
|
+
|
|
35
|
+
Known informational notices are dropped. Known failures mark the result
|
|
36
|
+
degraded. Codes this SDK does not recognize are surfaced as ``UNKNOWN``
|
|
37
|
+
and mark the result degraded, but never discard chunks and never raise:
|
|
38
|
+
a newer server must not be able to break retrieval.
|
|
39
|
+
"""
|
|
40
|
+
surfaced: list[dict[str, Any]] = []
|
|
41
|
+
degraded = False
|
|
42
|
+
for event in events:
|
|
43
|
+
status = event.status
|
|
44
|
+
if status is None or is_informational(status):
|
|
45
|
+
continue
|
|
46
|
+
entry = status.model_dump(exclude_none=True)
|
|
47
|
+
if status.code is None:
|
|
48
|
+
entry["code"] = "UNKNOWN"
|
|
49
|
+
entry["unrecognized"] = True
|
|
50
|
+
surfaced.append(entry)
|
|
51
|
+
degraded = True
|
|
52
|
+
return surfaced, degraded
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def hits_from_events(
|
|
56
|
+
events: Iterable[RetrieveMemoryEvent], *, reranked: bool
|
|
57
|
+
) -> list[dict[str, Any]]:
|
|
58
|
+
"""Join chunks to their memory definitions by UUID, ignoring event order.
|
|
59
|
+
|
|
60
|
+
Deduplicates by ``chunk_id``: two chunks of one memory are two distinct
|
|
61
|
+
results, and collapsing them by ``memory_id`` would drop matching content.
|
|
62
|
+
|
|
63
|
+
Server ordering and raw scores are preserved. ``score_kind`` records where
|
|
64
|
+
a score came from, because a reranker score and a vector score are not on
|
|
65
|
+
the same scale and must not be compared or thresholded together.
|
|
66
|
+
"""
|
|
67
|
+
events = list(events)
|
|
68
|
+
memories = {
|
|
69
|
+
event.memory_definition.memory_id: event.memory_definition
|
|
70
|
+
for event in events
|
|
71
|
+
if event.memory_definition is not None
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
hits: list[dict[str, Any]] = []
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
for event in events:
|
|
77
|
+
item = event.retrieved_item
|
|
78
|
+
if item is None or item.chunk is None:
|
|
79
|
+
continue
|
|
80
|
+
reference = item.chunk
|
|
81
|
+
chunk = reference.chunk
|
|
82
|
+
if chunk is None or not chunk.chunk_text:
|
|
83
|
+
continue
|
|
84
|
+
if chunk.chunk_id in seen:
|
|
85
|
+
continue
|
|
86
|
+
seen.add(chunk.chunk_id)
|
|
87
|
+
|
|
88
|
+
memory = memories.get(chunk.memory_id) or item.memory
|
|
89
|
+
metadata: dict[str, Any] = dict(getattr(memory, "metadata", None) or {})
|
|
90
|
+
hits.append(
|
|
91
|
+
{
|
|
92
|
+
"chunk_text": chunk.chunk_text,
|
|
93
|
+
"chunk_id": chunk.chunk_id,
|
|
94
|
+
"memory_id": chunk.memory_id,
|
|
95
|
+
"space_id": getattr(memory, "space_id", None),
|
|
96
|
+
"source": getattr(memory, "original_content_ref", None) or chunk.memory_id,
|
|
97
|
+
"score": reference.relevance_score,
|
|
98
|
+
"score_kind": "reranker" if reranked else "vector",
|
|
99
|
+
# The memory's own metadata, which is what a caller stored.
|
|
100
|
+
"metadata": metadata,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
return hits
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def abstract_reply(events: Iterable[RetrieveMemoryEvent]) -> dict[str, Any] | None:
|
|
107
|
+
for event in events:
|
|
108
|
+
if event.abstract_reply is not None:
|
|
109
|
+
return event.abstract_reply.model_dump(exclude_none=True)
|
|
110
|
+
return None
|
goodmem_nlweb/_schema.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Mapping between Schema.org objects and GoodMem memories.
|
|
2
|
+
|
|
3
|
+
NLWeb speaks Schema.org: every retrieved item is ``(url, raw_schema_object,
|
|
4
|
+
site)``. GoodMem stores text and metadata. The mapping is:
|
|
5
|
+
|
|
6
|
+
=================== ==========================================
|
|
7
|
+
NLWeb GoodMem
|
|
8
|
+
=================== ==========================================
|
|
9
|
+
``url`` ``metadata["url"]`` -- also the identity
|
|
10
|
+
``site`` ``metadata["site"]`` -- what NLWeb filters on
|
|
11
|
+
``raw_schema_object````metadata["schema_json"]`` (a JSON string)
|
|
12
|
+
(text to embed) the memory's ``original_content``
|
|
13
|
+
=================== ==========================================
|
|
14
|
+
|
|
15
|
+
The embedded text is *not* the raw JSON. Embedding a JSON blob buries the
|
|
16
|
+
words a query would match under punctuation and key names, so the text is
|
|
17
|
+
extracted from the fields a reader would actually read. The untouched object
|
|
18
|
+
is kept alongside, because NLWeb returns it verbatim to the caller.
|
|
19
|
+
|
|
20
|
+
This mirrors how NLWeb's own Qdrant provider stores things: a payload with
|
|
21
|
+
``url``, ``schema_json``, ``name`` and ``site``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
#: Schema.org fields worth embedding, in the order a reader would meet them.
|
|
30
|
+
_TEXT_FIELDS = (
|
|
31
|
+
"name",
|
|
32
|
+
"headline",
|
|
33
|
+
"alternateName",
|
|
34
|
+
"description",
|
|
35
|
+
"abstract",
|
|
36
|
+
"text",
|
|
37
|
+
"articleBody",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
MAX_EMBED_CHARS = 8000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def schema_to_text(schema: Any) -> str:
|
|
44
|
+
"""Extract the human-readable text from a Schema.org object.
|
|
45
|
+
|
|
46
|
+
Falls back to a compact JSON rendering only when no known text field is
|
|
47
|
+
present, because retrieving nothing is worse than embedding a blob.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(schema, str):
|
|
50
|
+
try:
|
|
51
|
+
schema = json.loads(schema)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return schema[:MAX_EMBED_CHARS]
|
|
54
|
+
if isinstance(schema, list):
|
|
55
|
+
return "\n\n".join(schema_to_text(item) for item in schema)[:MAX_EMBED_CHARS]
|
|
56
|
+
if not isinstance(schema, dict):
|
|
57
|
+
return str(schema)[:MAX_EMBED_CHARS]
|
|
58
|
+
|
|
59
|
+
parts: list[str] = []
|
|
60
|
+
for field in _TEXT_FIELDS:
|
|
61
|
+
value = schema.get(field)
|
|
62
|
+
if isinstance(value, str) and value.strip():
|
|
63
|
+
parts.append(value.strip())
|
|
64
|
+
if not parts:
|
|
65
|
+
return json.dumps(schema, ensure_ascii=False)[:MAX_EMBED_CHARS]
|
|
66
|
+
return "\n\n".join(parts)[:MAX_EMBED_CHARS]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def schema_name(schema: Any) -> str:
|
|
70
|
+
"""The display name NLWeb shows for an item."""
|
|
71
|
+
if isinstance(schema, str):
|
|
72
|
+
try:
|
|
73
|
+
schema = json.loads(schema)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return ""
|
|
76
|
+
if isinstance(schema, list):
|
|
77
|
+
return schema_name(schema[0]) if schema else ""
|
|
78
|
+
if not isinstance(schema, dict):
|
|
79
|
+
return ""
|
|
80
|
+
for field in ("name", "headline", "alternateName"):
|
|
81
|
+
value = schema.get(field)
|
|
82
|
+
if isinstance(value, str) and value.strip():
|
|
83
|
+
return value.strip()
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def schema_url(schema: Any, fallback: str = "") -> str:
|
|
88
|
+
"""The URL NLWeb uses as an item's identity."""
|
|
89
|
+
if isinstance(schema, str):
|
|
90
|
+
try:
|
|
91
|
+
schema = json.loads(schema)
|
|
92
|
+
except ValueError:
|
|
93
|
+
return fallback
|
|
94
|
+
if isinstance(schema, list):
|
|
95
|
+
return schema_url(schema[0], fallback) if schema else fallback
|
|
96
|
+
if not isinstance(schema, dict):
|
|
97
|
+
return fallback
|
|
98
|
+
for field in ("url", "@id", "identifier"):
|
|
99
|
+
value = schema.get(field)
|
|
100
|
+
if isinstance(value, str) and value.strip():
|
|
101
|
+
return value.strip()
|
|
102
|
+
return fallback
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def to_memory_fields(
|
|
106
|
+
document: Any, *, site: str = "", url: str = ""
|
|
107
|
+
) -> tuple[str, dict[str, Any]]:
|
|
108
|
+
"""Return ``(text_to_embed, metadata)`` for one Schema.org document.
|
|
109
|
+
|
|
110
|
+
``document`` may be the object itself, a JSON string, or an NLWeb-style
|
|
111
|
+
wrapper carrying ``url`` / ``site`` / ``schema_json`` keys.
|
|
112
|
+
"""
|
|
113
|
+
schema: Any = document
|
|
114
|
+
if isinstance(document, dict) and (
|
|
115
|
+
"schema_json" in document or "raw_schema_object" in document
|
|
116
|
+
):
|
|
117
|
+
schema = document.get("schema_json") or document.get("raw_schema_object")
|
|
118
|
+
site = document.get("site") or site
|
|
119
|
+
url = document.get("url") or url
|
|
120
|
+
|
|
121
|
+
resolved_url = url or schema_url(schema)
|
|
122
|
+
if not resolved_url:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"Every NLWeb document needs a url: it is the item's identity and "
|
|
125
|
+
"the key NLWeb deduplicates on. Pass url= or include one in the "
|
|
126
|
+
"Schema.org object."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
raw = schema if isinstance(schema, str) else json.dumps(schema, ensure_ascii=False)
|
|
130
|
+
return schema_to_text(schema), {
|
|
131
|
+
"url": resolved_url,
|
|
132
|
+
"site": site,
|
|
133
|
+
"name": schema_name(schema),
|
|
134
|
+
"schema_json": raw,
|
|
135
|
+
}
|
goodmem_nlweb/_spaces.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Resolving a space by name.
|
|
2
|
+
|
|
3
|
+
Attaching by name is idempotent reuse: an existing space with the requested
|
|
4
|
+
embedder is returned as-is, and one built on a different embedder is an error
|
|
5
|
+
rather than a silent mismatch. Retrieval against the wrong embedder returns
|
|
6
|
+
plausible-looking nonsense, so it must never happen quietly.
|
|
7
|
+
|
|
8
|
+
This matches the ActivePieces connector's behaviour. GoodMem does not enforce
|
|
9
|
+
unique space names, so an ambiguous name is also an error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from goodmem_nlweb._typing import AsyncGoodmemClient
|
|
17
|
+
|
|
18
|
+
# A name is not unique, so the lookup is bounded rather than unbounded.
|
|
19
|
+
MAX_CANDIDATES = 200
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GoodMemSpaceError(RuntimeError):
|
|
23
|
+
"""A space could not be resolved unambiguously."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def find_by_name(client: AsyncGoodmemClient, name: str) -> list[Any]:
|
|
27
|
+
"""Return every space named exactly ``name``.
|
|
28
|
+
|
|
29
|
+
``name_filter`` is a server-side substring match, so each candidate is
|
|
30
|
+
re-checked for an exact name. Iterating the SDK's ``Page`` follows
|
|
31
|
+
pagination for us; ``max_items`` bounds it.
|
|
32
|
+
"""
|
|
33
|
+
page = await client.spaces.list(name_filter=name, max_items=MAX_CANDIDATES)
|
|
34
|
+
return [s async for s in page if getattr(s, "name", None) == name]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def embedder_ids(space: Any) -> list[str]:
|
|
38
|
+
return [
|
|
39
|
+
e.embedder_id
|
|
40
|
+
for e in (getattr(space, "space_embedders", None) or [])
|
|
41
|
+
if getattr(e, "embedder_id", None)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def resolve(
|
|
46
|
+
client: AsyncGoodmemClient,
|
|
47
|
+
*,
|
|
48
|
+
name: str,
|
|
49
|
+
embedder_id: str | None = None,
|
|
50
|
+
create: bool = False,
|
|
51
|
+
chunking_config: dict[str, Any] | None = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Return the id of the space called ``name``.
|
|
54
|
+
|
|
55
|
+
With ``create=True`` a missing space is created. An existing space is
|
|
56
|
+
reused only when ``embedder_id`` is unset or already configured on it.
|
|
57
|
+
"""
|
|
58
|
+
matches = await find_by_name(client, name)
|
|
59
|
+
if len(matches) > 1:
|
|
60
|
+
raise GoodMemSpaceError(
|
|
61
|
+
f"{len(matches)} spaces are named {name!r}. Pass space_id to choose "
|
|
62
|
+
"one; GoodMem does not require space names to be unique."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if matches:
|
|
66
|
+
space = matches[0]
|
|
67
|
+
configured = embedder_ids(space)
|
|
68
|
+
if embedder_id and embedder_id not in configured:
|
|
69
|
+
raise GoodMemSpaceError(
|
|
70
|
+
f"Space {name!r} ({space.space_id}) uses embedder(s) "
|
|
71
|
+
f"{', '.join(configured) or 'none'}, not {embedder_id}. "
|
|
72
|
+
"Retrieval across mismatched embedders returns meaningless "
|
|
73
|
+
"results. Pick the existing embedder or use another name."
|
|
74
|
+
)
|
|
75
|
+
return str(space.space_id)
|
|
76
|
+
|
|
77
|
+
if not create:
|
|
78
|
+
raise GoodMemSpaceError(
|
|
79
|
+
f"No space named {name!r}. Pass space_id, or set create_space=True "
|
|
80
|
+
"to create it."
|
|
81
|
+
)
|
|
82
|
+
if not embedder_id:
|
|
83
|
+
raise GoodMemSpaceError(
|
|
84
|
+
f"Cannot create space {name!r} without embedder_id. List the "
|
|
85
|
+
"available embedders with the GoodMem SDK or the goodmem CLI."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
request: dict[str, Any] = {
|
|
89
|
+
"name": name,
|
|
90
|
+
"space_embedders": [
|
|
91
|
+
{"embedderId": embedder_id, "defaultRetrievalWeight": 1.0}
|
|
92
|
+
],
|
|
93
|
+
# The server rejects a create without one.
|
|
94
|
+
"default_chunking_config": chunking_config or _DEFAULT_CHUNKING,
|
|
95
|
+
}
|
|
96
|
+
try:
|
|
97
|
+
space = await client.spaces.create(**request)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
# Another process may have created it between the lookup and the
|
|
100
|
+
# create. Re-resolve rather than fail the caller.
|
|
101
|
+
if not _is_conflict(exc):
|
|
102
|
+
raise
|
|
103
|
+
again = await find_by_name(client, name)
|
|
104
|
+
if len(again) == 1:
|
|
105
|
+
return str(again[0].space_id)
|
|
106
|
+
raise
|
|
107
|
+
return str(space.space_id)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_DEFAULT_CHUNKING: dict[str, Any] = {
|
|
111
|
+
"recursive": {
|
|
112
|
+
"chunkSize": 256,
|
|
113
|
+
"chunkOverlap": 25,
|
|
114
|
+
"separators": ["\n\n", "\n", ". ", " ", ""],
|
|
115
|
+
"keepStrategy": "KEEP_END",
|
|
116
|
+
"separatorIsRegex": False,
|
|
117
|
+
"lengthMeasurement": "CHARACTER_COUNT",
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _is_conflict(exc: Exception) -> bool:
|
|
123
|
+
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
|
124
|
+
return status == 409 or "conflict" in type(exc).__name__.lower()
|
goodmem_nlweb/_typing.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Typing for the SDK client.
|
|
2
|
+
|
|
3
|
+
The SDK attaches its API groups (``spaces``, ``memories``, …) to the client
|
|
4
|
+
dynamically, so they carry no annotations and a type checker cannot see them.
|
|
5
|
+
These Protocols describe only the surface this package uses. They add no
|
|
6
|
+
runtime layer: the objects passed around are the SDK's own.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _SpacesAPI(Protocol):
|
|
16
|
+
async def list(self, **kwargs: Any) -> Any: ...
|
|
17
|
+
async def get(self, *, id: str) -> Any: ...
|
|
18
|
+
async def create(self, **kwargs: Any) -> Any: ...
|
|
19
|
+
async def update(self, *, id: str, request: Any) -> Any: ...
|
|
20
|
+
async def delete(self, *, id: str) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _MemoriesAPI(Protocol):
|
|
24
|
+
async def list(self, **kwargs: Any) -> Any: ...
|
|
25
|
+
async def get(self, **kwargs: Any) -> Any: ...
|
|
26
|
+
async def create(self, **kwargs: Any) -> Any: ...
|
|
27
|
+
async def delete(self, *, id: str) -> None: ...
|
|
28
|
+
async def retrieve(self, **kwargs: Any) -> Any: ...
|
|
29
|
+
# Sequence, not list: `list` above shadows the builtin in this scope.
|
|
30
|
+
async def batch_create(self, *, requests: Sequence[Any]) -> Any: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ModelsAPI(Protocol):
|
|
34
|
+
async def list(self, **kwargs: Any) -> Any: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncGoodmemClient(Protocol):
|
|
38
|
+
"""The parts of ``goodmem.AsyncGoodmem`` this package calls."""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def spaces(self) -> _SpacesAPI: ...
|
|
42
|
+
@property
|
|
43
|
+
def memories(self) -> _MemoriesAPI: ...
|
|
44
|
+
@property
|
|
45
|
+
def embedders(self) -> _ModelsAPI: ...
|
|
46
|
+
@property
|
|
47
|
+
def rerankers(self) -> _ModelsAPI: ...
|
|
48
|
+
|
|
49
|
+
async def close(self) -> None: ...
|