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.
@@ -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
@@ -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
+ }
@@ -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()
@@ -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: ...