springbootAI 1.8.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 (175) hide show
  1. spring/__init__.py +66 -0
  2. spring/ai/__init__.py +78 -0
  3. spring/ai/advisors.py +139 -0
  4. spring/ai/annotations.py +74 -0
  5. spring/ai/autoconfig.py +481 -0
  6. spring/ai/core.py +391 -0
  7. spring/ai/etl.py +188 -0
  8. spring/ai/memory.py +109 -0
  9. spring/ai/observability.py +129 -0
  10. spring/ai/providers.py +789 -0
  11. spring/ai/resilience.py +258 -0
  12. spring/ai/tools.py +106 -0
  13. spring/ai/vectorstore.py +303 -0
  14. spring/annotations/__init__.py +188 -0
  15. spring/annotations/cache.py +126 -0
  16. spring/annotations/cloud.py +207 -0
  17. spring/annotations/conditional.py +272 -0
  18. spring/annotations/core.py +864 -0
  19. spring/annotations/messaging.py +107 -0
  20. spring/aop/__init__.py +4 -0
  21. spring/aop/cloud_aop.py +404 -0
  22. spring/aop/comprehensive_aop.py +1015 -0
  23. spring/aop/method_interceptor.py +19 -0
  24. spring/aop/proxy_factory.py +55 -0
  25. spring/cloud/__init__.py +76 -0
  26. spring/cloud/discovery.py +364 -0
  27. spring/cloud/feign.py +469 -0
  28. spring/cloud/gateway.py +452 -0
  29. spring/cloud/load_balancer.py +149 -0
  30. spring/cloud/seata.py +557 -0
  31. spring/cloud/sentinel.py +525 -0
  32. spring/cloud/tracer.py +337 -0
  33. spring/config/__init__.py +21 -0
  34. spring/config/binding.py +206 -0
  35. spring/config/config_loader.py +405 -0
  36. spring/context/__init__.py +13 -0
  37. spring/context/application_context.py +589 -0
  38. spring/context/bean_definition.py +70 -0
  39. spring/context/bean_factory.py +1052 -0
  40. spring/context/registry.py +58 -0
  41. spring/context/scanner.py +106 -0
  42. spring/core/__init__.py +3 -0
  43. spring/core/graceful_shutdown.py +196 -0
  44. spring/core/typing_utils.py +50 -0
  45. spring/csv/__init__.py +52 -0
  46. spring/csv/annotations.py +402 -0
  47. spring/csv/converters.py +69 -0
  48. spring/csv/easy_csv.py +95 -0
  49. spring/csv/exceptions.py +27 -0
  50. spring/csv/reader.py +195 -0
  51. spring/csv/writer.py +155 -0
  52. spring/data/__init__.py +54 -0
  53. spring/data/page.py +181 -0
  54. spring/data/repository.py +274 -0
  55. spring/data/specification.py +228 -0
  56. spring/datasource/__init__.py +66 -0
  57. spring/datasource/annotations.py +133 -0
  58. spring/datasource/context.py +69 -0
  59. spring/datasource/dynamic.py +148 -0
  60. spring/event/__init__.py +7 -0
  61. spring/event/publisher.py +69 -0
  62. spring/excel/__init__.py +51 -0
  63. spring/excel/annotations.py +405 -0
  64. spring/excel/converters.py +231 -0
  65. spring/excel/easy_excel.py +94 -0
  66. spring/excel/exceptions.py +31 -0
  67. spring/excel/reader.py +254 -0
  68. spring/excel/style.py +95 -0
  69. spring/excel/writer.py +197 -0
  70. spring/i18n/__init__.py +97 -0
  71. spring/i18n/accessor.py +94 -0
  72. spring/i18n/auto_config.py +177 -0
  73. spring/i18n/holder.py +106 -0
  74. spring/i18n/locale.py +152 -0
  75. spring/i18n/locale_resolver.py +367 -0
  76. spring/i18n/message_source.py +250 -0
  77. spring/i18n/middleware.py +79 -0
  78. spring/i18n/properties.py +168 -0
  79. spring/i18n/sources.py +255 -0
  80. spring/logging/__init__.py +1 -0
  81. spring/logging/loguru_logger.py +228 -0
  82. spring/main.py +378 -0
  83. spring/messaging/__init__.py +1 -0
  84. spring/messaging/rabbitmq.py +302 -0
  85. spring/monitoring/__init__.py +1 -0
  86. spring/monitoring/prometheus.py +199 -0
  87. spring/orm/__init__.py +258 -0
  88. spring/orm/database.py +222 -0
  89. spring/orm/ddl_auto.py +1217 -0
  90. spring/orm/migration.py +419 -0
  91. spring/orm/mybatis_integration.py +400 -0
  92. spring/orm/pymybatis/__init__.py +86 -0
  93. spring/orm/pymybatis/annotations/__init__.py +30 -0
  94. spring/orm/pymybatis/annotations/annotations.py +332 -0
  95. spring/orm/pymybatis/cache/__init__.py +47 -0
  96. spring/orm/pymybatis/cache/cache.py +371 -0
  97. spring/orm/pymybatis/cache/redis_cache.py +434 -0
  98. spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
  99. spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
  100. spring/orm/pymybatis/configuration.py +525 -0
  101. spring/orm/pymybatis/core/__init__.py +10 -0
  102. spring/orm/pymybatis/core/sql_session.py +1382 -0
  103. spring/orm/pymybatis/core/sql_session_factory.py +76 -0
  104. spring/orm/pymybatis/dialect/__init__.py +9 -0
  105. spring/orm/pymybatis/dialect/dialect.py +445 -0
  106. spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
  107. spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
  108. spring/orm/pymybatis/interceptor/__init__.py +31 -0
  109. spring/orm/pymybatis/interceptor/interceptor.py +427 -0
  110. spring/orm/pymybatis/mapper/__init__.py +9 -0
  111. spring/orm/pymybatis/mapper/mapper.py +540 -0
  112. spring/orm/pymybatis/metrics/__init__.py +41 -0
  113. spring/orm/pymybatis/metrics/metrics.py +595 -0
  114. spring/orm/pymybatis/pool/__init__.py +9 -0
  115. spring/orm/pymybatis/pool/connection_pool.py +711 -0
  116. spring/orm/pymybatis/security/__init__.py +19 -0
  117. spring/orm/pymybatis/security/access_control.py +415 -0
  118. spring/orm/pymybatis/security/password_encoder.py +293 -0
  119. spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
  120. spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
  121. spring/orm/pymybatis/transaction/__init__.py +9 -0
  122. spring/orm/pymybatis/transaction/transaction.py +288 -0
  123. spring/orm/pymybatis/type_handler/__init__.py +37 -0
  124. spring/orm/pymybatis/type_handler/type_handler.py +473 -0
  125. spring/orm/pymybatis/version.py +9 -0
  126. spring/orm/pymybatis/xml_parser/__init__.py +9 -0
  127. spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
  128. spring/retry/__init__.py +12 -0
  129. spring/retry/retry_annotations.py +71 -0
  130. spring/retry/retry_decorator.py +155 -0
  131. spring/scheduling/__init__.py +3 -0
  132. spring/scheduling/scheduler.py +389 -0
  133. spring/security/__init__.py +39 -0
  134. spring/security/jwt_utils.py +281 -0
  135. spring/security/replay_protection.py +206 -0
  136. spring/security/secret_manager.py +226 -0
  137. spring/security/security_aop.py +248 -0
  138. spring/security/security_context.py +172 -0
  139. spring/test/__init__.py +45 -0
  140. spring/test/slicing.py +341 -0
  141. spring/tracing/__init__.py +11 -0
  142. spring/tracing/skywalking.py +229 -0
  143. spring/tx/__init__.py +52 -0
  144. spring/tx/events.py +172 -0
  145. spring/tx/synchronization.py +143 -0
  146. spring/utils/__init__.py +5 -0
  147. spring/utils/banner.py +32 -0
  148. spring/utils/logger.py +73 -0
  149. spring/utils/redis_client.py +526 -0
  150. spring/validation/__init__.py +55 -0
  151. spring/validation/aop.py +141 -0
  152. spring/validation/constraints.py +357 -0
  153. spring/validation/exceptions.py +55 -0
  154. spring/validation/validator.py +139 -0
  155. spring/web/__init__.py +12 -0
  156. spring/web/actuator.py +319 -0
  157. spring/web/exception_handler.py +61 -0
  158. spring/web/health.py +399 -0
  159. spring/web/interceptor.py +91 -0
  160. spring/web/result.py +44 -0
  161. spring/web/swagger.py +601 -0
  162. spring/web/web_context.py +755 -0
  163. spring/websocket/__init__.py +86 -0
  164. spring/websocket/annotations.py +169 -0
  165. spring/websocket/broker.py +238 -0
  166. spring/websocket/exceptions.py +26 -0
  167. spring/websocket/handler.py +243 -0
  168. spring/websocket/router.py +526 -0
  169. spring/websocket/session.py +216 -0
  170. springbootai-1.8.0.dist-info/METADATA +2796 -0
  171. springbootai-1.8.0.dist-info/RECORD +175 -0
  172. springbootai-1.8.0.dist-info/WHEEL +5 -0
  173. springbootai-1.8.0.dist-info/entry_points.txt +2 -0
  174. springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
  175. springbootai-1.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,258 @@
1
+ """
2
+ AI 调用韧性 - 复用框架 spring.retry 的 @Retryable 机制 + 复用 spring.aop 的 CircuitBreaker 状态机。
3
+
4
+ 为 LLM HTTP 调用提供:
5
+ 1. 重试:复用 spring.retry.retry_decorator.retry() 便捷装饰器,对网络瞬态错误重试
6
+ 2. 熔断:CLOSED/OPEN/HALF_OPEN 状态机,复用框架 Redis 持久化电路状态(同 spring.aop.comprehensive_aop.circuit_breaker_decorator),
7
+ Redis 不可用时降级本地内存。跨实例共享熔断状态,多副本一致性。
8
+ """
9
+ import functools
10
+ import json
11
+ import logging
12
+ import threading
13
+ import time
14
+ from typing import Any, Callable, Dict, Optional, Tuple, Type
15
+
16
+ logger = logging.getLogger("Spring.AI.Resilience")
17
+
18
+ # 复用框架重试基础设施
19
+ try:
20
+ from spring.retry.retry_decorator import retry as _retry_decorator
21
+ from spring.retry.retry_annotations import Backoff
22
+ _RETRY_AVAILABLE = True
23
+ except ImportError:
24
+ _RETRY_AVAILABLE = False
25
+
26
+
27
+ class CircuitState:
28
+ CLOSED = "CLOSED"
29
+ OPEN = "OPEN"
30
+ HALF_OPEN = "HALF_OPEN"
31
+
32
+
33
+ class AICircuitBreaker:
34
+ """
35
+ 熔断器 - 复用框架 spring.aop.comprehensive_aop 的 CircuitBreaker 状态机策略。
36
+
37
+ 状态流转:
38
+ CLOSED --失败数>=threshold--> OPEN
39
+ OPEN --经过recovery_timeout--> HALF_OPEN
40
+ HALF_OPEN --成功--> CLOSED
41
+ HALF_OPEN --失败--> OPEN
42
+
43
+ 状态存储策略(与框架 circuit_breaker_decorator 一致):
44
+ - 优先用 Redis hash 持久化(`circuit_breaker:ai:{name}`),跨实例共享
45
+ - Redis 不可用时降级本地内存
46
+ """
47
+
48
+ _local_cache: Dict[str, dict] = {} # 类级本地缓存的回退
49
+
50
+ def __init__(self, failure_threshold: int = 5,
51
+ recovery_timeout: float = 30.0,
52
+ fallback: Optional[Callable] = None,
53
+ name: str = "default",
54
+ redis_client=None):
55
+ self.failure_threshold = failure_threshold
56
+ self.recovery_timeout = recovery_timeout
57
+ self.fallback = fallback
58
+ self.name = name
59
+ self._redis_client = redis_client
60
+ self._redis_key = f"circuit_breaker:ai:{name}"
61
+ self._lock = threading.Lock()
62
+ # 本地状态(读透 Redis 缓存)
63
+ self._state = CircuitState.CLOSED
64
+ self._failures = 0
65
+ self._last_failure_time = 0.0
66
+
67
+ def _redis_available(self) -> bool:
68
+ """Redis 是否可用"""
69
+ if self._redis_client is None:
70
+ return False
71
+ try:
72
+ rc = (self._redis_client.get_client()
73
+ if hasattr(self._redis_client, "get_client")
74
+ else self._redis_client)
75
+ return rc is not None
76
+ except Exception:
77
+ return False
78
+
79
+ def _raw_redis(self):
80
+ """获取原生 Redis 客户端"""
81
+ if self._redis_client is None:
82
+ return None
83
+ return (self._redis_client.get_client()
84
+ if hasattr(self._redis_client, "get_client")
85
+ else self._redis_client)
86
+
87
+ def _sync_from_redis(self):
88
+ """从 Redis 同步状态到本地(读取透)"""
89
+ r = self._raw_redis()
90
+ if r is None:
91
+ return
92
+ try:
93
+ state_data = r.hgetall(self._redis_key)
94
+ if state_data:
95
+ state = (state_data.get("state", b"CLOSED")
96
+ if isinstance(state_data, dict) else {})
97
+ if isinstance(state, bytes):
98
+ state = state.decode()
99
+ self._state = state if isinstance(state, str) else "CLOSED"
100
+ self._failures = int(state_data.get("failures", 0) if isinstance(
101
+ state_data.get("failures"), (int, bytes, str)) else 0)
102
+ lf = state_data.get("last_failure_time", 0)
103
+ self._last_failure_time = float(lf) if isinstance(lf, (int, float, bytes, str)) else 0.0
104
+ except Exception:
105
+ # Redis 失败,保持本地状态
106
+ pass
107
+
108
+ def _sync_to_redis(self):
109
+ """将本地状态同步到 Redis"""
110
+ r = self._raw_redis()
111
+ if r is None:
112
+ return
113
+ try:
114
+ r.hset(self._redis_key, mapping={
115
+ "state": self._state,
116
+ "failures": str(self._failures),
117
+ "last_failure_time": str(self._last_failure_time),
118
+ })
119
+ except Exception:
120
+ pass
121
+
122
+ @property
123
+ def state(self) -> str:
124
+ with self._lock:
125
+ self._refresh_state()
126
+ return self._state
127
+
128
+ def _refresh_state(self):
129
+ # 先从 Redis 同步(如果可用)
130
+ if self._redis_available():
131
+ self._sync_from_redis()
132
+ if (self._state == CircuitState.OPEN and
133
+ time.time() - self._last_failure_time > self.recovery_timeout):
134
+ self._state = CircuitState.HALF_OPEN
135
+ logger.info("AI 熔断器[%s] 进入 HALF_OPEN,尝试放行探测请求", self.name)
136
+ if self._redis_available():
137
+ self._sync_to_redis()
138
+
139
+ def allow(self) -> bool:
140
+ """是否放行请求"""
141
+ with self._lock:
142
+ self._refresh_state()
143
+ return self._state in (CircuitState.CLOSED, CircuitState.HALF_OPEN)
144
+
145
+ def record_success(self):
146
+ with self._lock:
147
+ if self._state == CircuitState.HALF_OPEN:
148
+ logger.info("AI 熔断器[%s] HALF_OPEN -> CLOSED(探测成功)", self.name)
149
+ self._state = CircuitState.CLOSED
150
+ self._failures = 0
151
+ if self._redis_available():
152
+ self._sync_to_redis()
153
+
154
+ def record_failure(self):
155
+ with self._lock:
156
+ self._failures += 1
157
+ self._last_failure_time = time.time()
158
+ if self._state == CircuitState.HALF_OPEN:
159
+ self._state = CircuitState.OPEN
160
+ logger.warning("AI 熔断器[%s] HALF_OPEN -> OPEN(探测失败)", self.name)
161
+ elif self._failures >= self.failure_threshold:
162
+ self._state = CircuitState.OPEN
163
+ logger.warning("AI 熔断器[%s] CLOSED -> OPEN(失败数=%d)",
164
+ self.name, self._failures)
165
+ if self._redis_available():
166
+ self._sync_to_redis()
167
+
168
+ def call(self, func: Callable, *args, **kwargs):
169
+ """经熔断器执行函数"""
170
+ from spring.ai.observability import ai_metrics
171
+ _provider = kwargs.pop("_cb_provider", "unknown")
172
+ if not self.allow():
173
+ ai_metrics.record_circuit_state(_provider, self._state)
174
+ if self.fallback:
175
+ return self.fallback(*args, **kwargs)
176
+ raise CircuitOpenError(
177
+ f"AI 熔断器[{self.name}] 处于 {self._state} 状态,拒绝请求(失败数={self._failures})")
178
+ try:
179
+ result = func(*args, **kwargs)
180
+ self.record_success()
181
+ ai_metrics.record_circuit_state(_provider, CircuitState.CLOSED)
182
+ return result
183
+ except Exception as exc:
184
+ # 调用方自行决定哪些异常计入失败
185
+ if isinstance(exc, TransientError):
186
+ self.record_failure()
187
+ ai_metrics.record_circuit_state(_provider, self._state)
188
+ raise
189
+
190
+
191
+ class TransientError(Exception):
192
+ """瞬态错误(网络抖动/超时/429),应触发重试与熔断计数"""
193
+
194
+
195
+ class CircuitOpenError(Exception):
196
+ """熔断器开启"""
197
+
198
+
199
+ def resilient_call(func: Callable,
200
+ max_retries: int = 3,
201
+ retry_delay_ms: int = 500,
202
+ retry_exceptions: Tuple[Type[Exception], ...] = (Exception,),
203
+ circuit_breaker: Optional[AICircuitBreaker] = None,
204
+ count_as_failure_exc: Tuple[Type[Exception], ...] = (Exception,),
205
+ provider: str = "unknown",
206
+ ) -> Callable:
207
+ """
208
+ 为 LLM HTTP 调用注入重试 + 熔断。
209
+
210
+ - 重试:复用 spring.retry.retry_decorator.retry(不可用时降级为简单循环)
211
+ - 熔断:经 AICircuitBreaker.call 放行,count_as_failure_exc 内异常计入失败
212
+ - provider:透传给熔断器指标,确保 ai_circuit_breaker_state label 可区分
213
+ """
214
+ count_as_failure_exc = count_as_failure_exc or (Exception,)
215
+
216
+ @functools.wraps(func)
217
+ def wrapper(*args, **kwargs):
218
+ # 1. 构造带重试的核心函数
219
+ if _RETRY_AVAILABLE:
220
+ retried = _retry_decorator(
221
+ max_retries=max_retries, delay=retry_delay_ms,
222
+ exceptions=retry_exceptions,
223
+ )(func)
224
+ else:
225
+ retried = func
226
+
227
+ # 2. 把 provider 注入 kwargs 让 AICircuitBreaker 可读到
228
+ # 调用实际函数前 pop 掉,避免透传
229
+ kwargs["_cb_provider"] = provider
230
+
231
+ # 3. 无熔断器 → 直接重试调用
232
+ if circuit_breaker is None:
233
+ kwargs.pop("_cb_provider", None)
234
+ return retried(*args, **kwargs)
235
+
236
+ # 4. 有熔断器 → 经熔断放行,失败计入熔断
237
+ if not circuit_breaker.allow():
238
+ from spring.ai.observability import ai_metrics
239
+ kwargs.pop("_cb_provider", None)
240
+ ai_metrics.record_circuit_state(provider, circuit_breaker.state)
241
+ if circuit_breaker.fallback:
242
+ return circuit_breaker.fallback(*args, **kwargs)
243
+ raise CircuitOpenError("AI 熔断器处于 OPEN 状态,拒绝请求")
244
+
245
+ try:
246
+ kwargs.pop("_cb_provider", None)
247
+ result = retried(*args, **kwargs)
248
+ circuit_breaker.record_success()
249
+ from spring.ai.observability import ai_metrics
250
+ ai_metrics.record_circuit_state(provider, CircuitState.CLOSED)
251
+ return result
252
+ except count_as_failure_exc as exc:
253
+ circuit_breaker.record_failure()
254
+ from spring.ai.observability import ai_metrics
255
+ ai_metrics.record_circuit_state(provider, circuit_breaker.state)
256
+ raise
257
+
258
+ return wrapper
spring/ai/tools.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ 工具/函数调用注册表 - 从 Python 函数签名自动生成 tool schema,执行模型发起的工具调用。
3
+
4
+ 不依赖 OpenAI function-calling 协议细节,生成通用 schema,由 Provider 适配层转换为
5
+ 各模型要求的格式。
6
+ """
7
+ import inspect
8
+ import json
9
+ from typing import Any, Callable, Dict, List, Optional
10
+
11
+
12
+ _PY_TO_JSON_TYPE = {
13
+ str: "string",
14
+ int: "integer",
15
+ float: "number",
16
+ bool: "boolean",
17
+ list: "array",
18
+ dict: "object",
19
+ }
20
+
21
+
22
+ class ToolDefinition:
23
+ """工具定义"""
24
+
25
+ def __init__(self, name: str, description: str, func: Callable,
26
+ parameters: Dict[str, Any], return_type: str = "string"):
27
+ self.name = name
28
+ self.description = description
29
+ self.func = func
30
+ self.parameters = parameters
31
+ self.return_type = return_type
32
+
33
+ def to_schema(self) -> Dict[str, Any]:
34
+ """生成 OpenAI 风格的 function schema"""
35
+ return {
36
+ "type": "function",
37
+ "function": {
38
+ "name": self.name,
39
+ "description": self.description,
40
+ "parameters": {
41
+ "type": "object",
42
+ "properties": self.parameters,
43
+ "required": [
44
+ p for p, m in self.parameters.items()
45
+ if m.get("__required", True)
46
+ ],
47
+ },
48
+ },
49
+ }
50
+
51
+
52
+ class ToolRegistry:
53
+ """工具注册表 - 管理所有可被 LLM 调用的工具"""
54
+
55
+ def __init__(self):
56
+ self._tools: Dict[str, ToolDefinition] = {}
57
+
58
+ def register(self, name: str, func: Callable,
59
+ description: str = "", return_description: str = "") -> ToolDefinition:
60
+ """注册工具,从签名自动推断参数 schema"""
61
+ sig = inspect.signature(func)
62
+ properties: Dict[str, Any] = {}
63
+ for pname, param in sig.parameters.items():
64
+ if pname in ("self", "cls"):
65
+ continue
66
+ py_type = param.annotation if param.annotation is not inspect.Parameter.empty else str
67
+ json_type = _PY_TO_JSON_TYPE.get(py_type, "string")
68
+ required = param.default is inspect.Parameter.empty
69
+ prop = {"type": json_type, "__required": required}
70
+ properties[pname] = prop
71
+
72
+ # 返回类型
73
+ ret_type = "string"
74
+ if sig.return_annotation is not inspect.Signature.empty:
75
+ ret_type = _PY_TO_JSON_TYPE.get(sig.return_annotation, "string")
76
+
77
+ desc = description or (func.__doc__ or "").strip().split("\n")[0]
78
+ tool = ToolDefinition(name=name, description=desc, func=func,
79
+ parameters=properties, return_type=ret_type)
80
+ self._tools[name] = tool
81
+ return tool
82
+
83
+ def get(self, name: str) -> Optional[ToolDefinition]:
84
+ return self._tools.get(name)
85
+
86
+ def names(self) -> List[str]:
87
+ return list(self._tools.keys())
88
+
89
+ def schemas(self) -> List[Dict[str, Any]]:
90
+ """返回所有工具的 schema(供 Provider 注入模型)"""
91
+ return [t.to_schema() for t in self._tools.values()]
92
+
93
+ def execute(self, name: str, arguments: Dict[str, Any]) -> Any:
94
+ """按名称执行工具"""
95
+ tool = self._tools.get(name)
96
+ if tool is None:
97
+ raise KeyError(f"工具未注册: {name}")
98
+ if isinstance(arguments, str):
99
+ arguments = json.loads(arguments)
100
+ return tool.func(**arguments)
101
+
102
+ def clear(self) -> None:
103
+ self._tools.clear()
104
+
105
+ def __len__(self) -> int:
106
+ return len(self._tools)
@@ -0,0 +1,303 @@
1
+ """
2
+ 向量存储抽象与内存实现 - 为 RAG 提供文档向量的存储与相似度检索。
3
+
4
+ 生产环境可替换为 PGVector / Milvus / Chroma 等实现(实现同一 VectorStore 接口)。
5
+ """
6
+ import json
7
+ import math
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ @dataclass
14
+ class Document:
15
+ """向量文档"""
16
+ id: str
17
+ content: str
18
+ embedding: List[float] = field(default_factory=list)
19
+ metadata: Dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass
23
+ class SearchRequest:
24
+ """检索请求"""
25
+ query: str
26
+ embedding: Optional[List[float]] = None
27
+ top_k: int = 4
28
+ similarity_threshold: float = 0.0
29
+ filter_expression: Optional[str] = None
30
+
31
+
32
+ class VectorStore(ABC):
33
+ """向量存储抽象"""
34
+
35
+ @abstractmethod
36
+ def add(self, documents: List[Document]) -> None:
37
+ """写入文档(需已包含 embedding)"""
38
+
39
+ @abstractmethod
40
+ def similarity_search(self, request: SearchRequest) -> List[Document]:
41
+ """相似度检索"""
42
+
43
+
44
+ def cosine_similarity(a: List[float], b: List[float]) -> float:
45
+ """余弦相似度"""
46
+ if not a or not b or len(a) != len(b):
47
+ return 0.0
48
+ dot = sum(x * y for x, y in zip(a, b))
49
+ na = math.sqrt(sum(x * x for x in a))
50
+ nb = math.sqrt(sum(y * y for y in b))
51
+ if na == 0 or nb == 0:
52
+ return 0.0
53
+ return dot / (na * nb)
54
+
55
+
56
+ class SimpleInMemoryVectorStore(VectorStore):
57
+ """内存向量存储 - 开发/测试用,余弦相似度"""
58
+
59
+ def __init__(self, embedding_model=None):
60
+ self._docs: List[Document] = []
61
+ self._embedding_model = embedding_model
62
+
63
+ def add(self, documents: List[Document]) -> None:
64
+ for doc in documents:
65
+ if not doc.embedding and self._embedding_model and doc.content:
66
+ doc.embedding = self._embedding_model.embed_one(doc.content)
67
+ self._docs.append(doc)
68
+
69
+ def add_texts(self, texts: List[str],
70
+ metadatas: Optional[List[Dict]] = None) -> None:
71
+ for i, text in enumerate(texts):
72
+ meta = metadatas[i] if metadatas and i < len(metadatas) else {}
73
+ self.add([Document(id=f"doc-{len(self._docs)}", content=text,
74
+ metadata=meta)])
75
+
76
+ def similarity_search(self, request: SearchRequest) -> List[Document]:
77
+ emb = request.embedding
78
+ if emb is None and self._embedding_model and request.query:
79
+ emb = self._embedding_model.embed_one(request.query)
80
+ if emb is None:
81
+ return []
82
+
83
+ scored = []
84
+ for doc in self._docs:
85
+ if not doc.embedding:
86
+ continue
87
+ score = cosine_similarity(emb, doc.embedding)
88
+ if score >= request.similarity_threshold:
89
+ scored.append((score, doc))
90
+ scored.sort(key=lambda x: x[0], reverse=True)
91
+ return [d for _, d in scored[:request.top_k]]
92
+
93
+ def count(self) -> int:
94
+ return len(self._docs)
95
+
96
+ def clear(self) -> None:
97
+ self._docs.clear()
98
+
99
+
100
+ class LangChainVectorStore(VectorStore):
101
+ """
102
+ LangChain 向量存储适配器 - 包装 langchain 生态的 VectorStore(FAISS/Chroma 等)。
103
+
104
+ 设计原则:能用 LangChain 就用 LangChain(不做重复造轮子)。本类不自行实现
105
+ 向量索引与检索,而是包装一个外部 langchain 向量存储实例(须提供
106
+ add_texts / similarity_search_by_vector),把框架统一的 VectorStore 接口
107
+ 映射到 langchain 的成熟实现。需要先安装对应 langchain 向量库(如
108
+ langchain_community.vectorstores.FAISS / langchain_chroma)并自行构建实例传入。
109
+ """
110
+
111
+ def __init__(self, langchain_store=None, embedding_model=None):
112
+ self._store = langchain_store
113
+ self._embedding_model = embedding_model
114
+
115
+ def add(self, documents: List[Document]) -> None:
116
+ if self._store is None:
117
+ return
118
+ self._store.add_texts(
119
+ [d.content for d in documents],
120
+ metadatas=[d.metadata for d in documents],
121
+ )
122
+
123
+ def add_texts(self, texts: List[str],
124
+ metadatas: Optional[List[Dict]] = None) -> None:
125
+ if self._store is None:
126
+ return
127
+ self._store.add_texts(texts, metadatas=metadatas or [{}] * len(texts))
128
+
129
+ def similarity_search(self, request: SearchRequest) -> List[Document]:
130
+ if self._store is None:
131
+ return []
132
+ emb = request.embedding
133
+ if emb is None and self._embedding_model and request.query:
134
+ emb = self._embedding_model.embed_one(request.query)
135
+ if emb is None:
136
+ return []
137
+ docs = self._store.similarity_search_by_vector(emb, k=request.top_k)
138
+ result: List[Document] = []
139
+ for i, d in enumerate(docs):
140
+ result.append(Document(
141
+ id=getattr(d, "id", "") or f"langchain-{i}",
142
+ content=getattr(d, "page_content", str(d)),
143
+ embedding=emb,
144
+ metadata=getattr(d, "metadata", {}) or {},
145
+ ))
146
+ return result
147
+
148
+ def count(self) -> int:
149
+ return 0 if self._store is None else getattr(self._store, "count", lambda: 0)()
150
+
151
+ def clear(self) -> None:
152
+ if self._store is None:
153
+ return
154
+ deleter = getattr(self._store, "delete_collection", None) \
155
+ or getattr(self._store, "clear", None)
156
+ if deleter:
157
+ deleter()
158
+
159
+
160
+ def _safe_json_loads(val: Any) -> Optional[dict]:
161
+ """安全 JSON 解析(兼容 str/bytes/dict)"""
162
+ if isinstance(val, dict):
163
+ return val
164
+ if isinstance(val, bytes):
165
+ try:
166
+ val = val.decode(errors="ignore")
167
+ except Exception:
168
+ return None
169
+ if not isinstance(val, str):
170
+ return None
171
+ try:
172
+ return json.loads(val)
173
+ except (json.JSONDecodeError, TypeError):
174
+ return None
175
+
176
+
177
+ class RedisVectorStore(VectorStore):
178
+ """
179
+ Redis 向量存储 - 持久化 + 跨实例共享。
180
+
181
+ 复用框架 spring.utils.redis_client.RedisClient 封装(与 RedisChatMemory 统一接口):
182
+ 优先用框架封装的 hash_set/hash_get_all/delete_key(自动 JSON 序列化/反序列化);
183
+ 若传入原生 redis.Redis 或测试 FakeRedis(仅有 hset/hgetall/delete),自动降级原生接口。
184
+
185
+ 用 Redis hash 存储文档(id -> JSON{content,embedding,metadata}),
186
+ 检索时拉取全部并在 Python 端计算余弦相似度。
187
+ 适合中小规模(< 10 万文档)多副本部署;更大规模建议接入 RediSearch FTVECTOR。
188
+
189
+ max_scan 参数限制单次检索扫描上限,防止数据量过大时 OOM。
190
+ """
191
+
192
+ KEY_PREFIX = "springpy:ai:vectorstore:"
193
+
194
+ def __init__(self, redis_client=None, collection: str = "default",
195
+ embedding_model=None, max_scan: int = 10000):
196
+ self._client = redis_client
197
+ self.collection = collection
198
+ self._embedding_model = embedding_model
199
+ self.max_scan = max_scan
200
+
201
+ def _key(self) -> str:
202
+ return f"{self.KEY_PREFIX}{self.collection}"
203
+
204
+ @staticmethod
205
+ def _is_framework_client(client) -> bool:
206
+ """是否框架 RedisClient 封装(提供 hash_set/hash_get_all)"""
207
+ return (client is not None and hasattr(client, "hash_set")
208
+ and hasattr(client, "hash_get_all"))
209
+
210
+ @staticmethod
211
+ def _raw_client(client):
212
+ """原生 redis 客户端:框架封装取内部 client,否则透传"""
213
+ return client.get_client() if hasattr(client, "get_client") else client
214
+
215
+ def add(self, documents: List[Document]) -> None:
216
+ if self._client is None:
217
+ return
218
+ for doc in documents:
219
+ if not doc.embedding and self._embedding_model and doc.content:
220
+ doc.embedding = self._embedding_model.embed_one(doc.content)
221
+ record = {
222
+ "id": doc.id, "content": doc.content,
223
+ "embedding": doc.embedding or [], "metadata": doc.metadata,
224
+ }
225
+ if self._is_framework_client(self._client):
226
+ # 复用框架 RedisClient 封装(自动 JSON 序列化)
227
+ self._client.hash_set(self._key(), doc.id, record)
228
+ else:
229
+ # 降级:原生 redis 接口(兼容 redis.Redis / 测试 FakeRedis)
230
+ try:
231
+ self._raw_client(self._client).hset(
232
+ self._key(), doc.id,
233
+ json.dumps(record, ensure_ascii=False))
234
+ except Exception:
235
+ pass
236
+
237
+ def add_texts(self, texts: List[str],
238
+ metadatas: Optional[List[Dict]] = None,
239
+ ids: Optional[List[str]] = None) -> None:
240
+ for i, text in enumerate(texts):
241
+ doc_id = ids[i] if ids and i < len(ids) else f"doc-{i}"
242
+ meta = metadatas[i] if metadatas and i < len(metadatas) else {}
243
+ self.add([Document(id=doc_id, content=text, metadata=meta)])
244
+
245
+ def _all_docs(self, max_scan: Optional[int] = None) -> List[Document]:
246
+ if self._client is None:
247
+ return []
248
+ max_scan = max_scan if max_scan is not None else self.max_scan
249
+ # max_scan <= 0 表示无限制(count() 场景)
250
+ if self._is_framework_client(self._client):
251
+ # 框架封装:hash_get_all 已自动 JSON 反序列化
252
+ raw = self._client.hash_get_all(self._key()) or {}
253
+ else:
254
+ try:
255
+ raw = self._raw_client(self._client).hgetall(self._key()) or {}
256
+ except Exception:
257
+ return []
258
+ docs: List[Document] = []
259
+ scanned = 0
260
+ for field, val in raw.items():
261
+ if max_scan > 0 and scanned >= max_scan:
262
+ break
263
+ d = _safe_json_loads(val)
264
+ if not d:
265
+ continue
266
+ docs.append(Document(
267
+ id=d.get("id", field if isinstance(field, str) else str(field)),
268
+ content=d.get("content", ""),
269
+ embedding=d.get("embedding", []),
270
+ metadata=d.get("metadata", {}),
271
+ ))
272
+ scanned += 1
273
+ return docs
274
+
275
+ def similarity_search(self, request: SearchRequest) -> List[Document]:
276
+ emb = request.embedding
277
+ if emb is None and self._embedding_model and request.query:
278
+ emb = self._embedding_model.embed_one(request.query)
279
+ if emb is None:
280
+ return []
281
+ scored = []
282
+ for doc in self._all_docs():
283
+ if not doc.embedding:
284
+ continue
285
+ score = cosine_similarity(emb, doc.embedding)
286
+ if score >= request.similarity_threshold:
287
+ scored.append((score, doc))
288
+ scored.sort(key=lambda x: x[0], reverse=True)
289
+ return [d for _, d in scored[:request.top_k]]
290
+
291
+ def count(self) -> int:
292
+ return len(self._all_docs(max_scan=0)) # 0 = 无限制,count 需准确
293
+
294
+ def clear(self) -> None:
295
+ if self._client is None:
296
+ return
297
+ if self._is_framework_client(self._client):
298
+ self._client.delete_key(self._key())
299
+ else:
300
+ try:
301
+ self._raw_client(self._client).delete(self._key())
302
+ except Exception:
303
+ pass