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
spring/ai/core.py ADDED
@@ -0,0 +1,391 @@
1
+ """
2
+ SpringBootAI AI 核心抽象 - 对齐 Spring AI 的 ChatClient / ChatModel / EmbeddingModel / Advisor。
3
+
4
+ 设计原则:
5
+ - 模型调用层 (ChatModel/EmbeddingModel) 屏蔽 Provider 差异,底层可走 LangChain 或原生 HTTP
6
+ - ChatClient 提供链式 API(prompt().user().call().content()),与 Spring AI 风格一致
7
+ - Advisor 封装 RAG / Memory 等横切模式,在模型调用前后介入
8
+ """
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+
14
+ # ==================== 消息与响应 ====================
15
+
16
+ class MessageType:
17
+ SYSTEM = "system"
18
+ USER = "user"
19
+ ASSISTANT = "assistant"
20
+ TOOL = "tool"
21
+
22
+
23
+ @dataclass
24
+ class Message:
25
+ """单条对话消息"""
26
+ content: str
27
+ type: str = MessageType.USER
28
+ name: Optional[str] = None
29
+ metadata: Dict[str, Any] = field(default_factory=dict)
30
+
31
+ @classmethod
32
+ def system(cls, content: str) -> "Message":
33
+ return cls(content=content, type=MessageType.SYSTEM)
34
+
35
+ @classmethod
36
+ def user(cls, content: str) -> "Message":
37
+ return cls(content=content, type=MessageType.USER)
38
+
39
+ @classmethod
40
+ def assistant(cls, content: str) -> "Message":
41
+ return cls(content=content, type=MessageType.ASSISTANT)
42
+
43
+ def to_dict(self) -> Dict[str, str]:
44
+ d = {"role": self.type, "content": self.content}
45
+ if self.name:
46
+ d["name"] = self.name
47
+ return d
48
+
49
+
50
+ @dataclass
51
+ class Generation:
52
+ """单次生成结果"""
53
+ output: Message
54
+ metadata: Dict[str, Any] = field(default_factory=dict)
55
+
56
+
57
+ @dataclass
58
+ class ChatResponse:
59
+ """模型响应"""
60
+ generations: List[Generation] = field(default_factory=list)
61
+ metadata: Dict[str, Any] = field(default_factory=dict)
62
+
63
+ @property
64
+ def output(self) -> Optional[Message]:
65
+ return self.generations[0].output if self.generations else None
66
+
67
+ def content(self) -> str:
68
+ """便捷取值:返回首条生成的文本(对齐 Spring AI 的 call().content())"""
69
+ if self.generations:
70
+ return self.generations[0].output.content
71
+ return ""
72
+
73
+
74
+ # ==================== 模型抽象 ====================
75
+
76
+ def _is_tool_registry(obj) -> bool:
77
+ return obj is not None and hasattr(obj, "schemas") and hasattr(obj, "execute")
78
+
79
+
80
+ class ChatModel(ABC):
81
+ """聊天模型抽象 - 屏蔽 OpenAI/Ollama/LangChain 差异。
82
+
83
+ 函数调用闭环在基类 call() 中实现:Provider 的 _raw_call 把模型请求的
84
+ tool_calls 放入 response.metadata['tool_calls'],基类统一执行→回填→续写。
85
+ """
86
+
87
+ MAX_TOOL_ITERATIONS = 5
88
+
89
+ @abstractmethod
90
+ def _raw_call(self, messages: List[Message],
91
+ tool_registry=None,
92
+ options: Optional[Dict[str, Any]] = None) -> ChatResponse:
93
+ """Provider 实现:单次模型调用。若模型请求工具,将 tool_calls 列表
94
+ 放入返回的 ChatResponse.metadata['tool_calls'](每项含 id/function{name,arguments})。
95
+ tool_registry 用于把工具 schema 注入请求体。"""
96
+
97
+ def call(self, messages: List[Message],
98
+ tool_registry=None,
99
+ options: Optional[Dict[str, Any]] = None) -> ChatResponse:
100
+ """同步调用 - 含函数调用闭环"""
101
+ import json as _json
102
+ from spring.ai.observability import ai_metrics
103
+
104
+ working = list(messages)
105
+ resp = None
106
+ for iteration in range(self.MAX_TOOL_ITERATIONS + 1):
107
+ resp = self._raw_call(working, tool_registry, options)
108
+ resp.metadata = resp.metadata or {}
109
+ resp.metadata["tool_iterations"] = iteration
110
+ tool_calls = resp.metadata.get("tool_calls")
111
+
112
+ # 无工具调用 → 返回最终回复
113
+ if not tool_calls or not _is_tool_registry(tool_registry):
114
+ return resp
115
+
116
+ # 有工具调用 → 追加 assistant 消息 + 执行工具 + 回填
117
+ working.append(resp.output)
118
+ for tc in tool_calls:
119
+ func = tc.get("function", {})
120
+ name = func.get("name", "")
121
+ args_raw = func.get("arguments", "{}")
122
+ try:
123
+ args = (_json.loads(args_raw) if isinstance(args_raw, str)
124
+ else args_raw)
125
+ result = tool_registry.execute(name, args)
126
+ ai_metrics.record_tool_call(name, "success")
127
+ except Exception as exc:
128
+ result = f"工具执行失败: {exc}"
129
+ ai_metrics.record_tool_call(name, "failure")
130
+ working.append(Message(
131
+ content=str(result), type=MessageType.TOOL, name=name,
132
+ metadata={"tool_call_id": tc.get("id", "")},
133
+ ))
134
+
135
+ # 超过最大轮数
136
+ return resp
137
+
138
+ def stream(self, messages: List[Message],
139
+ tool_registry=None,
140
+ options: Optional[Dict[str, Any]] = None):
141
+ """流式调用(SSE delta 生成器),默认降级为单次 yield"""
142
+ yield self._raw_call(messages, tool_registry, options)
143
+
144
+ async def astream(self, messages: List[Message],
145
+ tool_registry=None,
146
+ options: Optional[Dict[str, Any]] = None):
147
+ """异步流式生成器,默认降级为同步 stream"""
148
+ for chunk in self.stream(messages, tool_registry=tool_registry,
149
+ options=options):
150
+ yield chunk
151
+
152
+ async def acall(self, messages: List[Message],
153
+ tool_registry=None,
154
+ options: Optional[Dict[str, Any]] = None) -> ChatResponse:
155
+ """异步调用,默认降级为同步 call(子类可覆盖实现真异步)"""
156
+ import asyncio
157
+ return await asyncio.to_thread(self.call, messages, tool_registry, options)
158
+
159
+
160
+ class EmbeddingModel(ABC):
161
+ """嵌入模型抽象"""
162
+
163
+ @abstractmethod
164
+ def embed(self, texts: List[str]) -> List[List[float]]:
165
+ """批量嵌入"""
166
+
167
+ def embed_one(self, text: str) -> List[float]:
168
+ return self.embed([text])[0]
169
+
170
+
171
+ # ==================== Advisor ====================
172
+
173
+ @dataclass
174
+ class AdvisorRequest:
175
+ """Advisor 请求上下文"""
176
+ messages: List[Message]
177
+ chat_model: ChatModel
178
+ tool_registry: Optional[Any] = None
179
+ context: Dict[str, Any] = field(default_factory=dict)
180
+ options: Optional[Dict[str, Any]] = None
181
+
182
+
183
+ class Advisor(ABC):
184
+ """
185
+ Advisor - 封装 RAG / Memory / 日志等横切模式。
186
+ advise_request 在模型调用前转换请求;advise_response 在调用后转换响应。
187
+ """
188
+ order: int = 0
189
+
190
+ @abstractmethod
191
+ def advise_request(self, request: AdvisorRequest) -> AdvisorRequest:
192
+ """转换请求"""
193
+
194
+ def advise_response(self, response: ChatResponse,
195
+ request: AdvisorRequest) -> ChatResponse:
196
+ """转换响应(默认透传)"""
197
+ return response
198
+
199
+
200
+ # ==================== ChatClient 链式 API ====================
201
+
202
+ class PromptSpec:
203
+ """链式 Prompt 构造器"""
204
+
205
+ def __init__(self, chat_client: "ChatClient"):
206
+ self._client = chat_client
207
+ self._messages: List[Message] = []
208
+ self._advisors: List[Advisor] = list(chat_client.default_advisors)
209
+ # tool_registry:None 或 ToolRegistry 或待注册的可调用对象列表
210
+ self._tool_registry = chat_client.default_tool_registry
211
+ self._pending_tools: List[Any] = []
212
+ self._context: Dict[str, Any] = {}
213
+
214
+ def system(self, text: str) -> "PromptSpec":
215
+ self._messages.append(Message.system(text))
216
+ return self
217
+
218
+ def user(self, text: str) -> "PromptSpec":
219
+ self._messages.append(Message.user(text))
220
+ return self
221
+
222
+ def messages(self, msgs: List[Message]) -> "PromptSpec":
223
+ self._messages.extend(msgs)
224
+ return self
225
+
226
+ def advisors(self, *advisors: Advisor) -> "PromptSpec":
227
+ self._advisors.extend(advisors)
228
+ return self
229
+
230
+ def tools(self, *tools: Any) -> "PromptSpec":
231
+ """注册工具 - 可传入 ToolRegistry 或若干可调用函数"""
232
+ for t in tools:
233
+ if t is None:
234
+ continue
235
+ # 已是 ToolRegistry
236
+ if hasattr(t, "schemas") and hasattr(t, "execute"):
237
+ self._tool_registry = t
238
+ else:
239
+ self._pending_tools.append(t)
240
+ return self
241
+
242
+ def param(self, key: str, value: Any) -> "PromptSpec":
243
+ self._context[key] = value
244
+ return self
245
+
246
+ def _resolve_registry(self):
247
+ """合并默认 registry 与本次 pending 工具"""
248
+ from spring.ai.tools import ToolRegistry
249
+ registry = self._tool_registry
250
+ if self._pending_tools:
251
+ if registry is None:
252
+ registry = ToolRegistry()
253
+ else:
254
+ registry = ToolRegistry() # 不污染默认 registry
255
+ if self._tool_registry is not None:
256
+ for name in self._tool_registry.names():
257
+ td = self._tool_registry.get(name)
258
+ registry.register(name, td.func, td.description)
259
+ for i, func in enumerate(self._pending_tools):
260
+ name = getattr(func, "__name__", f"tool_{i}")
261
+ desc = (func.__doc__ or "").strip().split("\n")[0]
262
+ registry.register(name, func, description=desc)
263
+ return registry
264
+
265
+ def call(self) -> ChatResponse:
266
+ return self._client._execute(
267
+ self._messages, self._advisors,
268
+ self._resolve_registry(), self._context
269
+ )
270
+
271
+ def stream(self):
272
+ """流式调用生成器"""
273
+ yield from self._client._execute_stream(
274
+ self._messages, self._advisors,
275
+ self._resolve_registry(), self._context
276
+ )
277
+
278
+ def content(self) -> str:
279
+ return self.call().content()
280
+
281
+
282
+ class ChatClient:
283
+ """
284
+ ChatClient - Spring AI 风格的链式聊天客户端。
285
+
286
+ 用法:
287
+ client = ChatClient(chat_model).default_system("你是助手").build()
288
+ answer = client.prompt().user("你好").call().content()
289
+ """
290
+
291
+ def __init__(self, chat_model: ChatModel):
292
+ self.chat_model = chat_model
293
+ self._default_system: Optional[str] = None
294
+ self.default_advisors: List[Advisor] = []
295
+ self.default_tool_registry: Optional[Any] = None
296
+
297
+ def default_system(self, text: str) -> "ChatClient":
298
+ self._default_system = text
299
+ return self
300
+
301
+ def default_advisors_set(self, *advisors: Advisor) -> "ChatClient":
302
+ self.default_advisors = list(advisors)
303
+ return self
304
+
305
+ def default_tools_set(self, tool_registry: Any) -> "ChatClient":
306
+ """设置默认 ToolRegistry"""
307
+ self.default_tool_registry = tool_registry
308
+ return self
309
+
310
+ def build(self) -> "ChatClient":
311
+ return self
312
+
313
+ def prompt(self) -> PromptSpec:
314
+ spec = PromptSpec(self)
315
+ if self._default_system:
316
+ spec._messages.insert(0, Message.system(self._default_system))
317
+ return spec
318
+
319
+ def _execute(self, messages: List[Message], advisors: List[Advisor],
320
+ tool_registry, context: Dict[str, Any]) -> ChatResponse:
321
+ # 请求阶段:按 order 升序应用 advisor
322
+ request = AdvisorRequest(
323
+ messages=list(messages), chat_model=self.chat_model,
324
+ tool_registry=tool_registry, context=dict(context),
325
+ )
326
+ for advisor in sorted(advisors, key=lambda a: a.order):
327
+ request = advisor.advise_request(request)
328
+
329
+ # 模型调用(携带 tool_registry 以启用函数调用闭环)
330
+ response = self.chat_model.call(
331
+ request.messages, tool_registry=request.tool_registry,
332
+ options=request.options
333
+ )
334
+
335
+ # 响应阶段:按 order 降序应用 advisor
336
+ for advisor in sorted(advisors, key=lambda a: a.order, reverse=True):
337
+ response = advisor.advise_response(response, request)
338
+ return response
339
+
340
+ def _execute_stream(self, messages: List[Message], advisors: List[Advisor],
341
+ tool_registry, context: Dict[str, Any]):
342
+ # 流式:advisor 先做请求预处理,逐块 yield;全部消费完后再统一回调
343
+ # advise_response(例如 MessageChatMemoryAdvisor 保存会话记忆)。
344
+ # 修复:之前流式模式从不调用 advise_response,导致"流式 + 记忆"时对话
345
+ # 永远不会被持久化。
346
+ request = AdvisorRequest(
347
+ messages=list(messages), chat_model=self.chat_model,
348
+ tool_registry=tool_registry, context=dict(context),
349
+ )
350
+ for advisor in sorted(advisors, key=lambda a: a.order):
351
+ request = advisor.advise_request(request)
352
+
353
+ chunks: List[ChatResponse] = []
354
+ for chunk in self.chat_model.stream(
355
+ request.messages, tool_registry=request.tool_registry,
356
+ options=request.options):
357
+ chunks.append(chunk)
358
+ yield chunk
359
+
360
+ # 聚合全部流式块,回调响应阶段 advisor(触发记忆保存/日志/审计等副作用)
361
+ if chunks:
362
+ combined = ChatResponse(
363
+ generations=[Generation(output=Message.assistant(
364
+ "".join(c.content() for c in chunks)))],
365
+ metadata={"provider": (chunks[-1].metadata or {}).get("provider"),
366
+ "stream": True, "combined": True},
367
+ )
368
+ for advisor in sorted(advisors, key=lambda a: a.order, reverse=True):
369
+ combined = advisor.advise_response(combined, request)
370
+
371
+
372
+ class ChatClientBuilder:
373
+ """ChatClient 构造器 - 对齐 Spring AI 的 ChatClient.Builder"""
374
+
375
+ def __init__(self, chat_model: ChatModel):
376
+ self._client = ChatClient(chat_model)
377
+
378
+ def default_system(self, text: str) -> "ChatClientBuilder":
379
+ self._client.default_system(text)
380
+ return self
381
+
382
+ def default_advisors(self, *advisors: Advisor) -> "ChatClientBuilder":
383
+ self._client.default_advisors_set(*advisors)
384
+ return self
385
+
386
+ def default_tools(self, tool_registry: Any) -> "ChatClientBuilder":
387
+ self._client.default_tools_set(tool_registry)
388
+ return self
389
+
390
+ def build(self) -> ChatClient:
391
+ return self._client.build()
spring/ai/etl.py ADDED
@@ -0,0 +1,188 @@
1
+ """
2
+ 文档 ETL - DocumentReader(读取原始文档) + TextSplitter(切片),为 RAG 入库服务。
3
+
4
+ 对齐 Spring AI 的 DocumentReader / TextSplitter 抽象。
5
+ 设计原则:能用 LangChain 就用 LangChain(不做重复造轮子)——
6
+ 切片逻辑优先委托 `langchain-text-splitters` 的成熟实现(递归分隔符 / 字符分隔),
7
+ 仅当该包未安装时降级为内置实现,保证开箱即用。
8
+ """
9
+ import os
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional
13
+
14
+
15
+ def _has_langchain_splitters() -> bool:
16
+ """探测是否安装了 langchain-text-splitters(切片器专属轻量包)。"""
17
+ try:
18
+ import langchain_text_splitters # noqa: F401
19
+ return True
20
+ except ImportError:
21
+ return False
22
+
23
+
24
+ @dataclass
25
+ class TextDocument:
26
+ """ETL 文档"""
27
+ content: str
28
+ metadata: Dict[str, Any] = field(default_factory=dict)
29
+
30
+ @property
31
+ def source(self) -> str:
32
+ return self.metadata.get("source", "")
33
+
34
+
35
+ def _wrap_langchain_chunks(chunks: List[str],
36
+ doc: TextDocument) -> List[TextDocument]:
37
+ """把 LangChain 切片结果映射为框架 TextDocument,并补齐 chunk_index 元数据。"""
38
+ result: List[TextDocument] = []
39
+ for idx, chunk in enumerate(chunks):
40
+ if not chunk:
41
+ continue
42
+ meta = dict(doc.metadata)
43
+ meta["chunk_index"] = idx
44
+ result.append(TextDocument(content=chunk, metadata=meta))
45
+ return result
46
+
47
+
48
+ class DocumentReader(ABC):
49
+ """文档读取器抽象"""
50
+
51
+ @abstractmethod
52
+ def read(self) -> List[TextDocument]:
53
+ """读取并返回文档列表"""
54
+
55
+
56
+ class TextReader(DocumentReader):
57
+ """纯文本/Markdown 文件读取器"""
58
+
59
+ def __init__(self, source: str = "", encoding: str = "utf-8"):
60
+ self.source = source
61
+ self.encoding = encoding
62
+
63
+ def read(self) -> List[TextDocument]:
64
+ if not self.source:
65
+ return []
66
+ # 从文件路径读取
67
+ if os.path.isfile(self.source):
68
+ with open(self.source, "r", encoding=self.encoding) as f:
69
+ return [TextDocument(content=f.read(),
70
+ metadata={"source": self.source})]
71
+ # 直接作为文本内容
72
+ return [TextDocument(content=self.source, metadata={"source": "inline"})]
73
+
74
+ def read_text(self, content: str, source: str = "inline") -> TextDocument:
75
+ """直接读取文本字符串"""
76
+ return TextDocument(content=content, metadata={"source": source})
77
+
78
+
79
+ class TextSplitter(ABC):
80
+ """文档切片器抽象"""
81
+
82
+ @abstractmethod
83
+ def split(self, documents: List[TextDocument]) -> List[TextDocument]:
84
+ """将文档切片为更小的块"""
85
+
86
+
87
+ class TokenTextSplitter(TextSplitter):
88
+ """
89
+ 基于 token 近似计数的切片器。
90
+ 生产可替换为 tiktoken 精确计数;此处用字符近似(4 char ≈ 1 token)。
91
+ """
92
+
93
+ def __init__(self, chunk_size: int = 800, chunk_overlap: int = 200,
94
+ min_chunk_size: int = 100):
95
+ self.chunk_size = chunk_size
96
+ self.chunk_overlap = chunk_overlap
97
+ self.min_chunk_size = min_chunk_size
98
+
99
+ def split(self, documents: List[TextDocument]) -> List[TextDocument]:
100
+ # LangChain 优先:递归字符切片(自动按 \n\n/\n/空格/标点逐级切分,语义更佳)
101
+ if _has_langchain_splitters():
102
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
103
+ lc = RecursiveCharacterTextSplitter(
104
+ chunk_size=self.chunk_size * 4, # 保持 4 char ≈ 1 token 语义
105
+ chunk_overlap=min(self.chunk_overlap * 4,
106
+ self.chunk_size * 4 - 1),
107
+ )
108
+ result: List[TextDocument] = []
109
+ for doc in documents:
110
+ if not doc.content:
111
+ continue
112
+ result.extend(_wrap_langchain_chunks(lc.split_text(doc.content), doc))
113
+ return result
114
+
115
+ result: List[TextDocument] = []
116
+ for doc in documents:
117
+ text = doc.content
118
+ if not text:
119
+ continue
120
+ # token 近似:4 字符 ≈ 1 token
121
+ chunk_chars = self.chunk_size * 4
122
+ overlap_chars = self.chunk_overlap * 4
123
+ if len(text) <= chunk_chars:
124
+ result.append(TextDocument(content=text, metadata=dict(doc.metadata)))
125
+ continue
126
+ start = 0
127
+ idx = 0
128
+ while start < len(text):
129
+ end = min(start + chunk_chars, len(text))
130
+ chunk = text[start:end]
131
+ if len(chunk) >= self.min_chunk_size * 4 or start == 0:
132
+ meta = dict(doc.metadata)
133
+ meta["chunk_index"] = idx
134
+ result.append(TextDocument(content=chunk, metadata=meta))
135
+ idx += 1
136
+ if end >= len(text):
137
+ break
138
+ start = end - overlap_chars
139
+ return result
140
+
141
+
142
+ class CharacterTextSplitter(TextSplitter):
143
+ """按分隔符切片"""
144
+
145
+ def __init__(self, separator: str = "\n\n", chunk_size: int = 1000,
146
+ chunk_overlap: int = 200):
147
+ self.separator = separator
148
+ self.chunk_size = chunk_size
149
+ self.chunk_overlap = chunk_overlap
150
+
151
+ def split(self, documents: List[TextDocument]) -> List[TextDocument]:
152
+ # LangChain 优先:字符分隔切片
153
+ if _has_langchain_splitters():
154
+ from langchain_text_splitters import CharacterTextSplitter as LCCharSplit
155
+ lc = LCCharSplit(
156
+ separator=self.separator,
157
+ chunk_size=self.chunk_size,
158
+ # LangChain 要求 overlap < chunk_size;内置降级实现不应用 overlap,
159
+ # 此处夹紧以保证默认 chunk_size=30 等边界场景在安装后同样可用
160
+ chunk_overlap=min(self.chunk_overlap, self.chunk_size - 1),
161
+ )
162
+ result: List[TextDocument] = []
163
+ for doc in documents:
164
+ if not doc.content:
165
+ continue
166
+ result.extend(_wrap_langchain_chunks(lc.split_text(doc.content), doc))
167
+ return result
168
+
169
+ result: List[TextDocument] = []
170
+ for doc in documents:
171
+ parts = doc.content.split(self.separator)
172
+ buffer = ""
173
+ idx = 0
174
+ for part in parts:
175
+ candidate = buffer + self.separator + part if buffer else part
176
+ if len(candidate) > self.chunk_size and buffer:
177
+ meta = dict(doc.metadata)
178
+ meta["chunk_index"] = idx
179
+ result.append(TextDocument(content=buffer, metadata=meta))
180
+ idx += 1
181
+ buffer = part
182
+ else:
183
+ buffer = candidate
184
+ if buffer:
185
+ meta = dict(doc.metadata)
186
+ meta["chunk_index"] = idx
187
+ result.append(TextDocument(content=buffer, metadata=meta))
188
+ return result
spring/ai/memory.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ 会话记忆 - 支持 InMemory 与 Redis 两种存储,为多轮对话提供历史消息管理。
3
+ """
4
+ import json
5
+ from abc import ABC, abstractmethod
6
+ from typing import List, Optional
7
+
8
+ from spring.ai.core import Message
9
+
10
+
11
+ class ChatMemory(ABC):
12
+ """会话记忆抽象"""
13
+
14
+ @abstractmethod
15
+ def add(self, conversation_id: str, message: Message) -> None:
16
+ """追加一条消息到会话"""
17
+
18
+ @abstractmethod
19
+ def get(self, conversation_id: str,
20
+ last_n: int = 20) -> List[Message]:
21
+ """获取会话历史(最近 last_n 条)"""
22
+
23
+ @abstractmethod
24
+ def clear(self, conversation_id: str) -> None:
25
+ """清空指定会话"""
26
+
27
+
28
+ class InMemoryChatMemory(ChatMemory):
29
+ """内存会话记忆 - 开发/测试用"""
30
+
31
+ def __init__(self, max_messages: int = 20):
32
+ self._store: dict = {}
33
+ self._max = max_messages
34
+
35
+ def add(self, conversation_id: str, message: Message) -> None:
36
+ bucket = self._store.setdefault(conversation_id, [])
37
+ bucket.append(message)
38
+ # 滑动窗口:保留最近 max_messages 条
39
+ if len(bucket) > self._max:
40
+ self._store[conversation_id] = bucket[-self._max:]
41
+
42
+ def get(self, conversation_id: str,
43
+ last_n: int = 20) -> List[Message]:
44
+ bucket = self._store.get(conversation_id, [])
45
+ return list(bucket[-last_n:])
46
+
47
+ def clear(self, conversation_id: str) -> None:
48
+ self._store.pop(conversation_id, None)
49
+
50
+
51
+ class RedisChatMemory(ChatMemory):
52
+ """Redis 会话记忆 - 生产用,复用 SpringBootAI RedisClient"""
53
+
54
+ KEY_PREFIX = "springpy:ai:memory:"
55
+
56
+ def __init__(self, redis_client=None, max_messages: int = 20,
57
+ ttl: int = 86400):
58
+ self._client = redis_client
59
+ self._max = max_messages
60
+ self._ttl = ttl
61
+
62
+ def _key(self, conversation_id: str) -> str:
63
+ return f"{self.KEY_PREFIX}{conversation_id}"
64
+
65
+ def add(self, conversation_id: str, message: Message) -> None:
66
+ if self._client is None:
67
+ return
68
+ record = json.dumps(message.to_dict(), ensure_ascii=False)
69
+ self._client.list_push(self._key(conversation_id), record)
70
+ # 维护窗口与 TTL
71
+ key = self._key(conversation_id)
72
+ total = self._client.list_length(key) or 0
73
+ if total > self._max:
74
+ self._client.list_remove_range(
75
+ key, 0, total - self._max - 1
76
+ )
77
+ # 给真正的 list 键刷新 TTL(之前只给 :ttl 标记键设过期,list 键会无限增长)
78
+ # 注意:不能用 set_value(会覆盖 list 键),改用原生 client.expire
79
+ self._refresh_expire(key)
80
+
81
+ def _refresh_expire(self, key: str) -> None:
82
+ """刷新 list 键的 TTL(框架封装无 expire 接口,降级原生 client)"""
83
+ try:
84
+ raw = (self._client.get_client()
85
+ if hasattr(self._client, "get_client") else None)
86
+ if raw is not None and hasattr(raw, "expire"):
87
+ raw.expire(key, self._ttl)
88
+ except Exception:
89
+ pass
90
+
91
+ def get(self, conversation_id: str,
92
+ last_n: int = 20) -> List[Message]:
93
+ if self._client is None:
94
+ return []
95
+ records = self._client.list_range(self._key(conversation_id), -last_n, -1)
96
+ messages: List[Message] = []
97
+ for rec in records or []:
98
+ try:
99
+ d = json.loads(rec) if isinstance(rec, str) else rec
100
+ messages.append(Message(content=d.get("content", ""),
101
+ type=d.get("role", "user")))
102
+ except (json.JSONDecodeError, TypeError):
103
+ continue
104
+ return messages
105
+
106
+ def clear(self, conversation_id: str) -> None:
107
+ if self._client is None:
108
+ return
109
+ self._client.delete_key(self._key(conversation_id))