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.
- spring/__init__.py +66 -0
- spring/ai/__init__.py +78 -0
- spring/ai/advisors.py +139 -0
- spring/ai/annotations.py +74 -0
- spring/ai/autoconfig.py +481 -0
- spring/ai/core.py +391 -0
- spring/ai/etl.py +188 -0
- spring/ai/memory.py +109 -0
- spring/ai/observability.py +129 -0
- spring/ai/providers.py +789 -0
- spring/ai/resilience.py +258 -0
- spring/ai/tools.py +106 -0
- spring/ai/vectorstore.py +303 -0
- spring/annotations/__init__.py +188 -0
- spring/annotations/cache.py +126 -0
- spring/annotations/cloud.py +207 -0
- spring/annotations/conditional.py +272 -0
- spring/annotations/core.py +864 -0
- spring/annotations/messaging.py +107 -0
- spring/aop/__init__.py +4 -0
- spring/aop/cloud_aop.py +404 -0
- spring/aop/comprehensive_aop.py +1015 -0
- spring/aop/method_interceptor.py +19 -0
- spring/aop/proxy_factory.py +55 -0
- spring/cloud/__init__.py +76 -0
- spring/cloud/discovery.py +364 -0
- spring/cloud/feign.py +469 -0
- spring/cloud/gateway.py +452 -0
- spring/cloud/load_balancer.py +149 -0
- spring/cloud/seata.py +557 -0
- spring/cloud/sentinel.py +525 -0
- spring/cloud/tracer.py +337 -0
- spring/config/__init__.py +21 -0
- spring/config/binding.py +206 -0
- spring/config/config_loader.py +405 -0
- spring/context/__init__.py +13 -0
- spring/context/application_context.py +589 -0
- spring/context/bean_definition.py +70 -0
- spring/context/bean_factory.py +1052 -0
- spring/context/registry.py +58 -0
- spring/context/scanner.py +106 -0
- spring/core/__init__.py +3 -0
- spring/core/graceful_shutdown.py +196 -0
- spring/core/typing_utils.py +50 -0
- spring/csv/__init__.py +52 -0
- spring/csv/annotations.py +402 -0
- spring/csv/converters.py +69 -0
- spring/csv/easy_csv.py +95 -0
- spring/csv/exceptions.py +27 -0
- spring/csv/reader.py +195 -0
- spring/csv/writer.py +155 -0
- spring/data/__init__.py +54 -0
- spring/data/page.py +181 -0
- spring/data/repository.py +274 -0
- spring/data/specification.py +228 -0
- spring/datasource/__init__.py +66 -0
- spring/datasource/annotations.py +133 -0
- spring/datasource/context.py +69 -0
- spring/datasource/dynamic.py +148 -0
- spring/event/__init__.py +7 -0
- spring/event/publisher.py +69 -0
- spring/excel/__init__.py +51 -0
- spring/excel/annotations.py +405 -0
- spring/excel/converters.py +231 -0
- spring/excel/easy_excel.py +94 -0
- spring/excel/exceptions.py +31 -0
- spring/excel/reader.py +254 -0
- spring/excel/style.py +95 -0
- spring/excel/writer.py +197 -0
- spring/i18n/__init__.py +97 -0
- spring/i18n/accessor.py +94 -0
- spring/i18n/auto_config.py +177 -0
- spring/i18n/holder.py +106 -0
- spring/i18n/locale.py +152 -0
- spring/i18n/locale_resolver.py +367 -0
- spring/i18n/message_source.py +250 -0
- spring/i18n/middleware.py +79 -0
- spring/i18n/properties.py +168 -0
- spring/i18n/sources.py +255 -0
- spring/logging/__init__.py +1 -0
- spring/logging/loguru_logger.py +228 -0
- spring/main.py +378 -0
- spring/messaging/__init__.py +1 -0
- spring/messaging/rabbitmq.py +302 -0
- spring/monitoring/__init__.py +1 -0
- spring/monitoring/prometheus.py +199 -0
- spring/orm/__init__.py +258 -0
- spring/orm/database.py +222 -0
- spring/orm/ddl_auto.py +1217 -0
- spring/orm/migration.py +419 -0
- spring/orm/mybatis_integration.py +400 -0
- spring/orm/pymybatis/__init__.py +86 -0
- spring/orm/pymybatis/annotations/__init__.py +30 -0
- spring/orm/pymybatis/annotations/annotations.py +332 -0
- spring/orm/pymybatis/cache/__init__.py +47 -0
- spring/orm/pymybatis/cache/cache.py +371 -0
- spring/orm/pymybatis/cache/redis_cache.py +434 -0
- spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
- spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
- spring/orm/pymybatis/configuration.py +525 -0
- spring/orm/pymybatis/core/__init__.py +10 -0
- spring/orm/pymybatis/core/sql_session.py +1382 -0
- spring/orm/pymybatis/core/sql_session_factory.py +76 -0
- spring/orm/pymybatis/dialect/__init__.py +9 -0
- spring/orm/pymybatis/dialect/dialect.py +445 -0
- spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
- spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
- spring/orm/pymybatis/interceptor/__init__.py +31 -0
- spring/orm/pymybatis/interceptor/interceptor.py +427 -0
- spring/orm/pymybatis/mapper/__init__.py +9 -0
- spring/orm/pymybatis/mapper/mapper.py +540 -0
- spring/orm/pymybatis/metrics/__init__.py +41 -0
- spring/orm/pymybatis/metrics/metrics.py +595 -0
- spring/orm/pymybatis/pool/__init__.py +9 -0
- spring/orm/pymybatis/pool/connection_pool.py +711 -0
- spring/orm/pymybatis/security/__init__.py +19 -0
- spring/orm/pymybatis/security/access_control.py +415 -0
- spring/orm/pymybatis/security/password_encoder.py +293 -0
- spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
- spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
- spring/orm/pymybatis/transaction/__init__.py +9 -0
- spring/orm/pymybatis/transaction/transaction.py +288 -0
- spring/orm/pymybatis/type_handler/__init__.py +37 -0
- spring/orm/pymybatis/type_handler/type_handler.py +473 -0
- spring/orm/pymybatis/version.py +9 -0
- spring/orm/pymybatis/xml_parser/__init__.py +9 -0
- spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
- spring/retry/__init__.py +12 -0
- spring/retry/retry_annotations.py +71 -0
- spring/retry/retry_decorator.py +155 -0
- spring/scheduling/__init__.py +3 -0
- spring/scheduling/scheduler.py +389 -0
- spring/security/__init__.py +39 -0
- spring/security/jwt_utils.py +281 -0
- spring/security/replay_protection.py +206 -0
- spring/security/secret_manager.py +226 -0
- spring/security/security_aop.py +248 -0
- spring/security/security_context.py +172 -0
- spring/test/__init__.py +45 -0
- spring/test/slicing.py +341 -0
- spring/tracing/__init__.py +11 -0
- spring/tracing/skywalking.py +229 -0
- spring/tx/__init__.py +52 -0
- spring/tx/events.py +172 -0
- spring/tx/synchronization.py +143 -0
- spring/utils/__init__.py +5 -0
- spring/utils/banner.py +32 -0
- spring/utils/logger.py +73 -0
- spring/utils/redis_client.py +526 -0
- spring/validation/__init__.py +55 -0
- spring/validation/aop.py +141 -0
- spring/validation/constraints.py +357 -0
- spring/validation/exceptions.py +55 -0
- spring/validation/validator.py +139 -0
- spring/web/__init__.py +12 -0
- spring/web/actuator.py +319 -0
- spring/web/exception_handler.py +61 -0
- spring/web/health.py +399 -0
- spring/web/interceptor.py +91 -0
- spring/web/result.py +44 -0
- spring/web/swagger.py +601 -0
- spring/web/web_context.py +755 -0
- spring/websocket/__init__.py +86 -0
- spring/websocket/annotations.py +169 -0
- spring/websocket/broker.py +238 -0
- spring/websocket/exceptions.py +26 -0
- spring/websocket/handler.py +243 -0
- spring/websocket/router.py +526 -0
- spring/websocket/session.py +216 -0
- springbootai-1.8.0.dist-info/METADATA +2796 -0
- springbootai-1.8.0.dist-info/RECORD +175 -0
- springbootai-1.8.0.dist-info/WHEEL +5 -0
- springbootai-1.8.0.dist-info/entry_points.txt +2 -0
- springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
- 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))
|