zero-agent-core 0.2.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.
Files changed (104) hide show
  1. agent_core/__init__.py +525 -0
  2. agent_core/access/__init__.py +6 -0
  3. agent_core/access/context.py +45 -0
  4. agent_core/access/guard.py +40 -0
  5. agent_core/acp/README.md +236 -0
  6. agent_core/acp/__init__.py +20 -0
  7. agent_core/acp/server.py +351 -0
  8. agent_core/acp/types.py +86 -0
  9. agent_core/callbacks/__init__.py +17 -0
  10. agent_core/callbacks/manager.py +105 -0
  11. agent_core/checkpoint/__init__.py +19 -0
  12. agent_core/checkpoint/naming.py +93 -0
  13. agent_core/checkpoint/store.py +186 -0
  14. agent_core/checkpoint/travel.py +448 -0
  15. agent_core/compaction/__init__.py +57 -0
  16. agent_core/compaction/middleware.py +428 -0
  17. agent_core/compaction/prompts.py +32 -0
  18. agent_core/compaction/pruner.py +121 -0
  19. agent_core/compaction/region.py +145 -0
  20. agent_core/compaction/spill.py +81 -0
  21. agent_core/compaction/spill_tool.py +42 -0
  22. agent_core/compaction/summarizer.py +95 -0
  23. agent_core/compaction/token_limit.py +74 -0
  24. agent_core/compaction/types.py +93 -0
  25. agent_core/docs.py +15 -0
  26. agent_core/documents/__init__.py +23 -0
  27. agent_core/documents/loaders.py +79 -0
  28. agent_core/documents/splitters.py +94 -0
  29. agent_core/documents/types.py +197 -0
  30. agent_core/errors.py +81 -0
  31. agent_core/guardrails/__init__.py +19 -0
  32. agent_core/guardrails/core.py +186 -0
  33. agent_core/hitl/__init__.py +15 -0
  34. agent_core/hitl/core.py +517 -0
  35. agent_core/mcp/__init__.py +14 -0
  36. agent_core/mcp/client.py +212 -0
  37. agent_core/mcp/errors.py +26 -0
  38. agent_core/middleware/__init__.py +26 -0
  39. agent_core/middleware/base.py +283 -0
  40. agent_core/model/__init__.py +73 -0
  41. agent_core/model/catalog.py +73 -0
  42. agent_core/model/core.py +179 -0
  43. agent_core/model/governance.py +317 -0
  44. agent_core/model/providers/__init__.py +43 -0
  45. agent_core/model/providers/anthropic.py +294 -0
  46. agent_core/model/providers/common.py +28 -0
  47. agent_core/model/providers/factory.py +107 -0
  48. agent_core/model/providers/gemini.py +273 -0
  49. agent_core/model/providers/ollama.py +80 -0
  50. agent_core/model/providers/openai_compatible.py +526 -0
  51. agent_core/model/structured.py +200 -0
  52. agent_core/observability/__init__.py +21 -0
  53. agent_core/observability/core.py +311 -0
  54. agent_core/ports/__init__.py +31 -0
  55. agent_core/ports/memory.py +130 -0
  56. agent_core/ports/model_selection.py +64 -0
  57. agent_core/ports/storage.py +114 -0
  58. agent_core/prompts/__init__.py +19 -0
  59. agent_core/prompts/registry.py +236 -0
  60. agent_core/prompts/templates.py +166 -0
  61. agent_core/protocol/__init__.py +31 -0
  62. agent_core/protocol/capabilities.py +49 -0
  63. agent_core/protocol/messages.py +100 -0
  64. agent_core/protocol/runtime.py +293 -0
  65. agent_core/py.typed +1 -0
  66. agent_core/retrieval/__init__.py +40 -0
  67. agent_core/retrieval/_common.py +121 -0
  68. agent_core/retrieval/chroma.py +269 -0
  69. agent_core/retrieval/core.py +63 -0
  70. agent_core/retrieval/factory.py +72 -0
  71. agent_core/retrieval/memory.py +232 -0
  72. agent_core/retrieval/pgvector.py +239 -0
  73. agent_core/retrieval/types.py +95 -0
  74. agent_core/runtime/__init__.py +28 -0
  75. agent_core/runtime/compat.py +98 -0
  76. agent_core/runtime/react.py +888 -0
  77. agent_core/runtime/service.py +557 -0
  78. agent_core/runtime/store.py +55 -0
  79. agent_core/serialization/__init__.py +21 -0
  80. agent_core/serialization/registry.py +318 -0
  81. agent_core/skills/__init__.py +24 -0
  82. agent_core/skills/errors.py +27 -0
  83. agent_core/skills/loader.py +258 -0
  84. agent_core/skills/registry.py +205 -0
  85. agent_core/skills/types.py +56 -0
  86. agent_core/storage/__init__.py +107 -0
  87. agent_core/storage/_sqlite_utils.py +92 -0
  88. agent_core/storage/context.py +382 -0
  89. agent_core/storage/lease.py +435 -0
  90. agent_core/storage/memory.py +604 -0
  91. agent_core/storage/model_selection.py +360 -0
  92. agent_core/storage/runtime.py +899 -0
  93. agent_core/storage/session.py +904 -0
  94. agent_core/storage/workflow.py +411 -0
  95. agent_core/tools/__init__.py +5 -0
  96. agent_core/tools/executor.py +306 -0
  97. agent_core/workflow/__init__.py +25 -0
  98. agent_core/workflow/durable.py +175 -0
  99. agent_core/workflow/state_machine.py +427 -0
  100. zero_agent_core-0.2.0.dist-info/METADATA +237 -0
  101. zero_agent_core-0.2.0.dist-info/RECORD +104 -0
  102. zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
  103. zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
  104. zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,269 @@
1
+ """Chroma 本地持久化向量存储适配器。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ from agent_core.access import AccessContext
11
+ from agent_core.retrieval._common import (
12
+ decode_record_payload,
13
+ encode_record_payload,
14
+ metadata_matches,
15
+ normalize_record,
16
+ record_accessible,
17
+ validate_vector,
18
+ )
19
+ from agent_core.retrieval.core import VectorStore
20
+ from agent_core.retrieval.types import VectorQuery, VectorRecord, VectorSearchResult
21
+
22
+ _NAMESPACE_KEY = "_agent_core_namespace"
23
+ _USER_KEY = "_agent_core_user_id"
24
+ _TENANT_KEY = "_agent_core_tenant_id"
25
+ _PAYLOAD_KEY = "_agent_core_payload"
26
+ _PUBLIC_OWNER = ""
27
+
28
+
29
+ class ChromaVectorStore(VectorStore):
30
+ """适合本地开发的 Chroma 持久化实现。
31
+
32
+ Chroma 的 Python API 是同步接口,因此所有读写都通过 ``asyncio.to_thread``
33
+ 执行,避免阻塞 Agent 所在的事件循环。
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ path: str | Path = "./chroma_db",
39
+ *,
40
+ collection_name: str = "agent_core",
41
+ require_access: bool = False,
42
+ client: Any | None = None,
43
+ ) -> None:
44
+ owns_client = client is None
45
+ if client is None:
46
+ try:
47
+ import chromadb
48
+ except ImportError as exc:
49
+ raise ImportError(
50
+ "ChromaVectorStore 需要 chromadb,请安装 zero-agent-core[chroma]"
51
+ ) from exc
52
+ client = chromadb.PersistentClient(path=str(Path(path).resolve()))
53
+ # Chroma 各版本的 Client/Collection 泛型和重载差异较大,在 SDK 边界收口为 Any。
54
+ self._client: Any = client
55
+ self._owns_client = owns_client
56
+ self._collection: Any = client.get_or_create_collection(
57
+ name=collection_name,
58
+ metadata={"hnsw:space": "cosine"},
59
+ )
60
+ self._require_access = require_access
61
+
62
+ async def close(self) -> None:
63
+ """关闭由当前 Store 创建的 PersistentClient,释放本地文件句柄。"""
64
+ if not self._owns_client:
65
+ return
66
+ close = getattr(self._client, "close", None)
67
+ if close is not None:
68
+ await asyncio.to_thread(close)
69
+ self._owns_client = False
70
+
71
+ async def upsert(
72
+ self,
73
+ records: Sequence[VectorRecord],
74
+ *,
75
+ access: AccessContext | None = None,
76
+ ) -> list[str]:
77
+ values = [normalize_record(record, access, require_access=self._require_access) for record in records]
78
+ if not values:
79
+ return []
80
+ for record in values:
81
+ validate_vector(record.vector)
82
+ await self._check_existing_access([record.record_id for record in values], access)
83
+ metadatas = [self._metadata(record) for record in values]
84
+ await asyncio.to_thread(
85
+ self._collection.upsert,
86
+ ids=[record.record_id for record in values],
87
+ embeddings=[list(record.vector) for record in values],
88
+ documents=[record.document.content for record in values],
89
+ metadatas=metadatas,
90
+ )
91
+ return [record.record_id for record in values]
92
+
93
+ async def delete(
94
+ self,
95
+ record_ids: Sequence[str],
96
+ *,
97
+ access: AccessContext | None = None,
98
+ ) -> int:
99
+ self._check_required_access(access)
100
+ if not record_ids:
101
+ return 0
102
+ result = await asyncio.to_thread(
103
+ self._collection.get,
104
+ ids=list(record_ids),
105
+ include=["metadatas"],
106
+ )
107
+ ids = self._flat_list(result, "ids")
108
+ metadatas = self._flat_list(result, "metadatas")
109
+ allowed: list[str] = []
110
+ for index, record_id in enumerate(ids):
111
+ metadata = self._mapping_at(metadatas, index)
112
+ self._check_metadata_access(str(record_id), metadata, access)
113
+ allowed.append(str(record_id))
114
+ if allowed:
115
+ await asyncio.to_thread(self._collection.delete, ids=allowed)
116
+ return len(allowed)
117
+
118
+ async def search(self, query: VectorQuery) -> list[VectorSearchResult]:
119
+ self._check_required_access(query.access)
120
+ validate_vector(query.vector)
121
+ count = int(await asyncio.to_thread(self._collection.count))
122
+ if count <= 0:
123
+ return []
124
+ # 没有业务 metadata 过滤时,namespace 和访问范围已下推给 Chroma,
125
+ # 只需取 limit 条;带任意 metadata 过滤时仍全量读取以保持现有精确语义。
126
+ n_results = count if query.metadata_filter else min(count, query.limit)
127
+ result = await asyncio.to_thread(
128
+ self._collection.query,
129
+ query_embeddings=[list(query.vector)],
130
+ n_results=n_results,
131
+ where=self._where(query.namespace, query.access),
132
+ include=["metadatas", "distances", "embeddings"],
133
+ )
134
+ ids = self._first_batch(result, "ids")
135
+ metadatas = self._first_batch(result, "metadatas")
136
+ distances = self._first_batch(result, "distances")
137
+ embeddings = self._first_batch(result, "embeddings")
138
+ matches: list[VectorSearchResult] = []
139
+ for index, record_id in enumerate(ids):
140
+ metadata = self._mapping_at(metadatas, index)
141
+ raw_vector = embeddings[index] if index < len(embeddings) else ()
142
+ vector = tuple(float(cast(Any, value)) for value in cast(Sequence[object], raw_vector))
143
+ record = self._record_from_metadata(str(record_id), metadata, vector)
144
+ combined_metadata = {**record.document.metadata, **record.metadata}
145
+ if not metadata_matches(combined_metadata, query.metadata_filter):
146
+ continue
147
+ if not record_accessible(record, query.access):
148
+ continue
149
+ distance = float(cast(Any, distances[index])) if index < len(distances) else 1.0
150
+ score = 1.0 - distance
151
+ if query.min_score is not None and score < query.min_score:
152
+ continue
153
+ matches.append(VectorSearchResult(record=record, score=score))
154
+ if len(matches) >= query.limit:
155
+ break
156
+ return matches
157
+
158
+ async def _check_existing_access(
159
+ self,
160
+ record_ids: Sequence[str],
161
+ access: AccessContext | None,
162
+ ) -> None:
163
+ if access is None:
164
+ return
165
+ result = await asyncio.to_thread(
166
+ self._collection.get,
167
+ ids=list(record_ids),
168
+ include=["metadatas"],
169
+ )
170
+ ids = self._flat_list(result, "ids")
171
+ metadatas = self._flat_list(result, "metadatas")
172
+ for index, record_id in enumerate(ids):
173
+ self._check_metadata_access(
174
+ str(record_id),
175
+ self._mapping_at(metadatas, index),
176
+ access,
177
+ )
178
+
179
+ def _check_required_access(self, access: AccessContext | None) -> None:
180
+ if self._require_access and access is None:
181
+ raise PermissionError("该 VectorStore 要求提供 AccessContext")
182
+
183
+ @staticmethod
184
+ def _check_metadata_access(
185
+ record_id: str,
186
+ metadata: Mapping[str, object],
187
+ access: AccessContext | None,
188
+ ) -> None:
189
+ if access is None:
190
+ return
191
+ user_id = str(metadata[_USER_KEY]) if metadata.get(_USER_KEY) else None
192
+ tenant_id = str(metadata[_TENANT_KEY]) if metadata.get(_TENANT_KEY) else None
193
+ if not access.can_access(user_id, tenant_id):
194
+ raise PermissionError(f"无权访问向量记录:{record_id}")
195
+
196
+ @staticmethod
197
+ def _metadata(record: VectorRecord) -> dict[str, str]:
198
+ return {
199
+ _NAMESPACE_KEY: record.namespace,
200
+ _USER_KEY: record.user_id or _PUBLIC_OWNER,
201
+ _TENANT_KEY: record.tenant_id or _PUBLIC_OWNER,
202
+ _PAYLOAD_KEY: encode_record_payload(record),
203
+ }
204
+
205
+ @staticmethod
206
+ def _where(namespace: str, access: AccessContext | None) -> dict[str, object]:
207
+ clauses: list[dict[str, object]] = [{_NAMESPACE_KEY: {"$eq": namespace}}]
208
+ if access is not None:
209
+ clauses.append(
210
+ {
211
+ "$or": [
212
+ {_TENANT_KEY: {"$eq": _PUBLIC_OWNER}},
213
+ {_TENANT_KEY: {"$eq": access.tenant_id}},
214
+ ]
215
+ }
216
+ )
217
+ if not access.is_admin:
218
+ clauses.append(
219
+ {
220
+ "$or": [
221
+ {_USER_KEY: {"$eq": _PUBLIC_OWNER}},
222
+ {_USER_KEY: {"$eq": access.user_id}},
223
+ ]
224
+ }
225
+ )
226
+ return clauses[0] if len(clauses) == 1 else {"$and": clauses}
227
+
228
+ @staticmethod
229
+ def _record_from_metadata(
230
+ record_id: str,
231
+ metadata: Mapping[str, object],
232
+ vector: tuple[float, ...],
233
+ ) -> VectorRecord:
234
+ return decode_record_payload(
235
+ metadata.get(_PAYLOAD_KEY),
236
+ record_id=record_id,
237
+ vector=vector,
238
+ namespace=str(metadata.get(_NAMESPACE_KEY, "default")),
239
+ user_id=str(metadata[_USER_KEY]) if metadata.get(_USER_KEY) else None,
240
+ tenant_id=str(metadata[_TENANT_KEY]) if metadata.get(_TENANT_KEY) else None,
241
+ )
242
+
243
+ @staticmethod
244
+ def _flat_list(result: object, key: str) -> list[object]:
245
+ if not isinstance(result, Mapping):
246
+ return []
247
+ return ChromaVectorStore._to_list(result.get(key))
248
+
249
+ @staticmethod
250
+ def _first_batch(result: object, key: str) -> list[object]:
251
+ values = ChromaVectorStore._flat_list(result, key)
252
+ if not values:
253
+ return []
254
+ return ChromaVectorStore._to_list(values[0])
255
+
256
+ @staticmethod
257
+ def _to_list(value: object) -> list[object]:
258
+ if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Iterable):
259
+ return []
260
+ return list(value)
261
+
262
+ @staticmethod
263
+ def _mapping_at(values: Sequence[object], index: int) -> Mapping[str, object]:
264
+ if index >= len(values) or not isinstance(values[index], Mapping):
265
+ raise ValueError("Chroma 返回的向量记录缺少 metadata")
266
+ return cast(Mapping[str, object], values[index])
267
+
268
+
269
+ __all__ = ["ChromaVectorStore"]
@@ -0,0 +1,63 @@
1
+ """检索依赖倒置协议。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ from agent_core.access import AccessContext
9
+ from agent_core.retrieval.types import (
10
+ RetrievalQuery,
11
+ RetrievalResult,
12
+ VectorQuery,
13
+ VectorRecord,
14
+ VectorSearchResult,
15
+ )
16
+
17
+
18
+ @runtime_checkable
19
+ class Embeddings(Protocol):
20
+ """文本向量化协议,厂商 SDK 由扩展适配器实现。"""
21
+
22
+ async def embed_documents(self, texts: Sequence[str]) -> list[list[float]]: ...
23
+
24
+ async def embed_query(self, text: str) -> list[float]: ...
25
+
26
+
27
+ @runtime_checkable
28
+ class Retriever(Protocol):
29
+ """根据查询返回排好序的文档。"""
30
+
31
+ async def retrieve(self, query: RetrievalQuery) -> list[RetrievalResult]: ...
32
+
33
+
34
+ @runtime_checkable
35
+ class VectorStore(Protocol):
36
+ """向量存储端口,不绑定 pgvector、Milvus、Qdrant 或其他实现。"""
37
+
38
+ async def upsert(
39
+ self,
40
+ records: Sequence[VectorRecord],
41
+ *,
42
+ access: AccessContext | None = None,
43
+ ) -> list[str]: ...
44
+
45
+ async def delete(
46
+ self,
47
+ record_ids: Sequence[str],
48
+ *,
49
+ access: AccessContext | None = None,
50
+ ) -> int: ...
51
+
52
+ async def search(self, query: VectorQuery) -> list[VectorSearchResult]: ...
53
+
54
+
55
+ @runtime_checkable
56
+ class Reranker(Protocol):
57
+ """可选的二阶段重排端口。"""
58
+
59
+ async def rerank(
60
+ self,
61
+ query: RetrievalQuery,
62
+ results: Sequence[RetrievalResult],
63
+ ) -> list[RetrievalResult]: ...
@@ -0,0 +1,72 @@
1
+ """按环境和显式配置创建向量存储。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ from agent_core.retrieval.chroma import ChromaVectorStore
9
+ from agent_core.retrieval.core import VectorStore
10
+ from agent_core.retrieval.memory import InMemoryVectorStore
11
+ from agent_core.retrieval.pgvector import PgVectorStore
12
+
13
+ VectorStoreBackend = Literal["memory", "chroma", "pgvector"]
14
+
15
+
16
+ def create_vector_store(
17
+ env: str = "development",
18
+ *,
19
+ backend: str | None = None,
20
+ chroma_path: str | Path = "./chroma_db",
21
+ chroma_client: Any | None = None,
22
+ postgres_url: str | None = None,
23
+ dimension: int | None = None,
24
+ collection_name: str = "agent_core",
25
+ table_name: str = "agent_vector_records",
26
+ require_access: bool = False,
27
+ pg_create_extension: bool = True,
28
+ pg_create_index: bool = True,
29
+ ) -> VectorStore:
30
+ """创建向量存储;显式 backend 优先于环境默认值。
31
+
32
+ 默认映射:测试使用内存,开发使用 Chroma,生产使用 pgvector。
33
+ PostgreSQL 实现返回后仍需调用 ``initialize()``。
34
+ """
35
+ normalized_env = env.strip().lower()
36
+ selected = (backend or _default_backend(normalized_env)).strip().lower()
37
+ selected = {"postgres": "pgvector", "postgresql": "pgvector"}.get(selected, selected)
38
+ if selected == "memory":
39
+ return InMemoryVectorStore(require_access=require_access)
40
+ if selected == "chroma":
41
+ return ChromaVectorStore(
42
+ chroma_path,
43
+ collection_name=collection_name,
44
+ require_access=require_access,
45
+ client=chroma_client,
46
+ )
47
+ if selected == "pgvector":
48
+ if not postgres_url:
49
+ raise ValueError("pgvector 后端需要配置 PostgreSQL 连接地址")
50
+ if dimension is None:
51
+ raise ValueError("pgvector 后端需要配置 embedding dimension")
52
+ return PgVectorStore(
53
+ postgres_url,
54
+ dimension=dimension,
55
+ collection_name=collection_name,
56
+ table_name=table_name,
57
+ require_access=require_access,
58
+ create_extension=pg_create_extension,
59
+ create_index=pg_create_index,
60
+ )
61
+ raise ValueError(f"不支持的向量存储后端:{selected}")
62
+
63
+
64
+ def _default_backend(env: str) -> VectorStoreBackend:
65
+ if env in {"test", "testing"}:
66
+ return "memory"
67
+ if env in {"production", "prod"}:
68
+ return "pgvector"
69
+ return "chroma"
70
+
71
+
72
+ __all__ = ["VectorStoreBackend", "create_vector_store"]
@@ -0,0 +1,232 @@
1
+ """用于开发、测试和小数据集的零依赖检索实现。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable, Sequence
7
+ from dataclasses import replace
8
+ from typing import Any
9
+
10
+ from agent_core.access import AccessContext
11
+ from agent_core.documents import Document
12
+ from agent_core.retrieval._common import check_record_access, normalize_record, validate_vector
13
+ from agent_core.retrieval.core import Embeddings, VectorStore
14
+ from agent_core.retrieval.types import (
15
+ RetrievalQuery,
16
+ RetrievalResult,
17
+ SearchType,
18
+ VectorQuery,
19
+ VectorRecord,
20
+ VectorSearchResult,
21
+ )
22
+
23
+
24
+ def _metadata_matches(metadata: dict[str, Any], expected: dict[str, Any]) -> bool:
25
+ return all(metadata.get(key) == value for key, value in expected.items())
26
+
27
+
28
+ def _cosine_similarity(left: tuple[float, ...], right: tuple[float, ...]) -> float:
29
+ if len(left) != len(right):
30
+ raise ValueError(f"向量维度不一致:{len(left)} != {len(right)}")
31
+ dot = sum(a * b for a, b in zip(left, right, strict=True))
32
+ left_norm = math.sqrt(sum(value * value for value in left))
33
+ right_norm = math.sqrt(sum(value * value for value in right))
34
+ if left_norm == 0 or right_norm == 0:
35
+ return 0.0
36
+ return dot / (left_norm * right_norm)
37
+
38
+
39
+ class InMemoryVectorStore(VectorStore):
40
+ """带 namespace、metadata 和用户/租户过滤的内存向量存储。"""
41
+
42
+ def __init__(self, *, require_access: bool = False) -> None:
43
+ self._records: dict[str, VectorRecord] = {}
44
+ self._dimension: int | None = None
45
+ self._require_access = require_access
46
+
47
+ async def upsert(
48
+ self,
49
+ records: Sequence[VectorRecord],
50
+ *,
51
+ access: AccessContext | None = None,
52
+ ) -> list[str]:
53
+ self._check_required_access(access)
54
+ saved: list[str] = []
55
+ for record in records:
56
+ self._check_dimension(record.vector)
57
+ value = normalize_record(record, access, require_access=self._require_access)
58
+ existing = self._records.get(value.record_id)
59
+ if existing is not None:
60
+ check_record_access(existing, access)
61
+ self._records[value.record_id] = value
62
+ saved.append(value.record_id)
63
+ return saved
64
+
65
+ async def delete(
66
+ self,
67
+ record_ids: Sequence[str],
68
+ *,
69
+ access: AccessContext | None = None,
70
+ ) -> int:
71
+ self._check_required_access(access)
72
+ existing = [self._records[record_id] for record_id in record_ids if record_id in self._records]
73
+ # 先完成整批权限校验,避免中途失败时前面的记录已经被删除。
74
+ for record in existing:
75
+ self._check_record_access(record, access)
76
+ deleted = 0
77
+ for record_id in record_ids:
78
+ target = self._records.get(record_id)
79
+ if target is None:
80
+ continue
81
+ del self._records[record_id]
82
+ deleted += 1
83
+ return deleted
84
+
85
+ async def search(self, query: VectorQuery) -> list[VectorSearchResult]:
86
+ self._check_required_access(query.access)
87
+ if self._dimension is not None and len(query.vector) != self._dimension:
88
+ raise ValueError(f"向量维度不一致:期望 {self._dimension},实际 {len(query.vector)}")
89
+ matches: list[VectorSearchResult] = []
90
+ for record in self._records.values():
91
+ if record.namespace != query.namespace:
92
+ continue
93
+ if not _metadata_matches({**record.document.metadata, **record.metadata}, query.metadata_filter):
94
+ continue
95
+ if not self._record_accessible(record, query.access):
96
+ continue
97
+ score = _cosine_similarity(query.vector, record.vector)
98
+ if query.min_score is not None and score < query.min_score:
99
+ continue
100
+ matches.append(VectorSearchResult(record=record, score=score))
101
+ matches.sort(key=lambda item: (-item.score, item.record.record_id))
102
+ return matches[: query.limit]
103
+
104
+ def _check_dimension(self, vector: tuple[float, ...]) -> None:
105
+ validate_vector(vector)
106
+ if self._dimension is None:
107
+ self._dimension = len(vector)
108
+ elif len(vector) != self._dimension:
109
+ raise ValueError(f"向量维度不一致:期望 {self._dimension},实际 {len(vector)}")
110
+
111
+ def _check_required_access(self, access: AccessContext | None) -> None:
112
+ if self._require_access and access is None:
113
+ raise PermissionError("该 VectorStore 要求提供 AccessContext")
114
+
115
+ @staticmethod
116
+ def _record_accessible(record: VectorRecord, access: AccessContext | None) -> bool:
117
+ return access is None or access.can_access(record.user_id, record.tenant_id)
118
+
119
+ @classmethod
120
+ def _check_record_access(cls, record: VectorRecord, access: AccessContext | None) -> None:
121
+ if not cls._record_accessible(record, access):
122
+ raise PermissionError(f"无权访问向量记录:{record.record_id}")
123
+
124
+
125
+ class EmbeddingRetriever:
126
+ """组合 Embeddings 和 VectorStore 的通用语义 Retriever。"""
127
+
128
+ def __init__(self, embeddings: Embeddings, vector_store: VectorStore) -> None:
129
+ self._embeddings = embeddings
130
+ self._vector_store = vector_store
131
+
132
+ async def add_documents(
133
+ self,
134
+ documents: Sequence[Document],
135
+ *,
136
+ namespace: str = "default",
137
+ access: AccessContext | None = None,
138
+ metadata: dict[str, Any] | None = None,
139
+ ) -> list[str]:
140
+ vectors = await self._embeddings.embed_documents([item.content for item in documents])
141
+ if len(vectors) != len(documents):
142
+ raise ValueError("Embedding 返回数量与文档数量不一致")
143
+ records = [
144
+ VectorRecord(
145
+ record_id=document.document_id,
146
+ vector=tuple(float(value) for value in vector),
147
+ document=document,
148
+ namespace=namespace,
149
+ user_id=access.user_id if access else None,
150
+ tenant_id=access.tenant_id if access else None,
151
+ metadata=dict(metadata or {}),
152
+ )
153
+ for document, vector in zip(documents, vectors, strict=True)
154
+ ]
155
+ return await self._vector_store.upsert(records, access=access)
156
+
157
+ async def retrieve(self, query: RetrievalQuery) -> list[RetrievalResult]:
158
+ if query.search_type != SearchType.SIMILARITY:
159
+ raise ValueError("EmbeddingRetriever 当前只支持 similarity 检索")
160
+ vector = await self._embeddings.embed_query(query.text)
161
+ matches = await self._vector_store.search(
162
+ VectorQuery(
163
+ vector=tuple(float(value) for value in vector),
164
+ limit=query.limit,
165
+ namespace=query.namespace,
166
+ metadata_filter=query.metadata_filter,
167
+ min_score=query.min_score,
168
+ access=query.access,
169
+ )
170
+ )
171
+ return [
172
+ RetrievalResult(
173
+ document=item.record.document,
174
+ score=item.score,
175
+ rank=index,
176
+ metadata={"record_id": item.record.record_id, **item.record.metadata},
177
+ )
178
+ for index, item in enumerate(matches, start=1)
179
+ ]
180
+
181
+
182
+ class KeywordRetriever:
183
+ """零依赖关键词 Retriever,适合测试和小型静态语料。"""
184
+
185
+ def __init__(
186
+ self,
187
+ documents: Iterable[Document] | None = None,
188
+ *,
189
+ require_access: bool = False,
190
+ ) -> None:
191
+ self._documents = list(documents or ())
192
+ self._require_access = require_access
193
+
194
+ def add_documents(self, documents: Iterable[Document]) -> None:
195
+ self._documents.extend(documents)
196
+
197
+ async def retrieve(self, query: RetrievalQuery) -> list[RetrievalResult]:
198
+ if query.search_type != SearchType.KEYWORD:
199
+ raise ValueError("KeywordRetriever 只支持 keyword 检索")
200
+ if self._require_access and query.access is None:
201
+ raise PermissionError("该 Retriever 要求提供 AccessContext")
202
+ terms = [term.casefold() for term in query.text.split() if term.strip()]
203
+ if not terms and query.text.strip():
204
+ terms = [query.text.strip().casefold()]
205
+ results: list[RetrievalResult] = []
206
+ for document in self._documents:
207
+ metadata = document.metadata
208
+ if metadata.get("namespace", "default") != query.namespace:
209
+ continue
210
+ if not _metadata_matches(metadata, query.metadata_filter):
211
+ continue
212
+ if query.access is not None and not query.access.can_access(
213
+ metadata.get("user_id"), metadata.get("tenant_id")
214
+ ):
215
+ continue
216
+ content = document.content.casefold()
217
+ matches = sum(content.count(term) for term in terms)
218
+ if matches == 0:
219
+ continue
220
+ score = matches / max(len(terms), 1)
221
+ if query.min_score is not None and score < query.min_score:
222
+ continue
223
+ results.append(
224
+ RetrievalResult(
225
+ document=document,
226
+ score=score,
227
+ rank=0,
228
+ metadata={"matches": matches},
229
+ )
230
+ )
231
+ results.sort(key=lambda item: (-item.score, item.document.document_id))
232
+ return [replace(item, rank=index) for index, item in enumerate(results[: query.limit], start=1)]