sdpy-kit 0.1.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.
kit/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-08-23
21
+ """Kit 模块——统一数据库访问层 + 工具集
22
+
23
+ 数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
+ """
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ from kit.db import (
29
+ MYSQL,
30
+ POSTGRESQL,
31
+ SQLITE,
32
+ CRUDBase,
33
+ DBError,
34
+ NotFoundError,
35
+ close_all_engines,
36
+ close_engine,
37
+ create_tables,
38
+ get_engine,
39
+ get_session,
40
+ list_tables,
41
+ session_scope,
42
+ table_exists,
43
+ transactional,
44
+ )
45
+
46
+ __all__ = [
47
+ # 引擎管理
48
+ "get_engine",
49
+ "create_tables",
50
+ "list_tables",
51
+ "table_exists",
52
+ "close_all_engines",
53
+ "close_engine",
54
+ # 常量
55
+ "POSTGRESQL",
56
+ "MYSQL",
57
+ "SQLITE",
58
+ # 会话
59
+ "session_scope",
60
+ "get_session",
61
+ # 异常
62
+ "DBError",
63
+ "NotFoundError",
64
+ # CRUD
65
+ "CRUDBase",
66
+ # 事务
67
+ "transactional",
68
+ ]
kit/ai/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-08-21
21
+ """kit/ai - AI 模型服务模块(纯异步,Python 3.13+)
22
+
23
+ 提供开箱即用的各类模型基础服务:
24
+ - ChatService: 对话模型
25
+ - EmbeddingService: 文本嵌入向量
26
+ - RerankService: 重排序
27
+ - ImageService: 文生图
28
+ """
29
+
30
+ from kit.ai.chat_service import ChatRequest, ChatService
31
+ from kit.ai.embedding_service import EmbeddingRequest, EmbeddingService
32
+ from kit.ai.image_service import ImageRequest, ImageService
33
+ from kit.ai.model_builder import ModelBuilder
34
+ from kit.ai.rerank_service import RerankHit, RerankRequest, RerankService
35
+
36
+ __all__ = [
37
+ "ChatRequest",
38
+ "ChatService",
39
+ "EmbeddingRequest",
40
+ "EmbeddingService",
41
+ "ImageRequest",
42
+ "ImageService",
43
+ "ModelBuilder",
44
+ "RerankHit",
45
+ "RerankRequest",
46
+ "RerankService",
47
+ ]
kit/ai/base_service.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ from abc import ABC, abstractmethod
21
+ from typing import Self, override
22
+
23
+ from kit.ai.exceptions import AIResponseError
24
+ from kit.ai.model_client import ModelClient
25
+ from kit.ai.profiles import ModelProfile
26
+ from kit.ai.types import JSONObject, JSONValue
27
+
28
+ __all__ = ["BaseAIService", "safe_get"]
29
+
30
+
31
+ def safe_get(data: JSONValue, *path: str | int) -> JSONValue:
32
+ """按层级路径安全取值,支持 dict 键(str)与 list 索引(int)。
33
+
34
+ Raises:
35
+ AIResponseError: 路径任一环节缺失或类型不符。
36
+ """
37
+ cur: JSONValue = data
38
+ for key in path:
39
+ if isinstance(key, int):
40
+ if not isinstance(cur, list) or key < 0 or key >= len(cur):
41
+ raise AIResponseError(f"响应缺少索引 {path}: {str(data)[:200]}")
42
+ cur = cur[key]
43
+ else:
44
+ if not isinstance(cur, dict) or key not in cur:
45
+ raise AIResponseError(
46
+ f"响应缺少字段 {'.'.join(map(str, path))}: {str(data)[:200]}"
47
+ )
48
+ cur = cur[key]
49
+ return cur
50
+
51
+
52
+ class BaseAIService[RequestT, ResponseT](ABC):
53
+ """AI 服务基类(模板方法 + 泛型)。
54
+
55
+ 子类实现同步的 ``_build_payload``(请求模型→JSON 请求体)与
56
+ ``_parse_response``(JSON 响应体→业务结果),公共的「构造请求、
57
+ 发送、解析」由 ``_call`` 统一编排。请求类型 ``RequestT`` 与结果
58
+ 类型 ``ResponseT`` 由泛型绑定,保证覆写签名一致、类型自洽。
59
+ """
60
+
61
+ def __init__(self, profile: ModelProfile) -> None:
62
+ self._profile: ModelProfile = profile
63
+ self._client: ModelClient = ModelClient(profile)
64
+
65
+ @property
66
+ def profile(self) -> ModelProfile:
67
+ return self._profile
68
+
69
+ @abstractmethod
70
+ def _build_payload(self, request: RequestT) -> JSONObject:
71
+ """将请求模型构造为 JSON 请求体。"""
72
+
73
+ @abstractmethod
74
+ def _parse_response(self, data: JSONObject) -> ResponseT:
75
+ """将 JSON 响应体解析为业务结果。"""
76
+
77
+ async def _call(self, request: RequestT) -> ResponseT:
78
+ """统一编排:构造请求体 → 发送 → 解析响应。"""
79
+ payload = self._build_payload(request)
80
+ data = await self._client.post_json(payload)
81
+ return self._parse_response(data)
82
+
83
+ async def close(self) -> None:
84
+ await self._client.close()
85
+
86
+ async def __aenter__(self) -> Self:
87
+ return self
88
+
89
+ async def __aexit__(self, *exc_info: object) -> None:
90
+ await self.close()
91
+
92
+ @override
93
+ def __str__(self) -> str:
94
+ return f"{type(self).__name__}({self._profile.name})"
kit/ai/chat_service.py ADDED
@@ -0,0 +1,110 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ from collections.abc import AsyncIterator
21
+ from dataclasses import dataclass
22
+ from typing import cast, override
23
+
24
+ from kit.ai.base_service import BaseAIService, safe_get
25
+ from kit.ai.exceptions import AIResponseError
26
+ from kit.ai.types import JSONObject
27
+ from kit.config.log_config import get_default_logger
28
+
29
+ __all__ = ["ChatRequest", "ChatService"]
30
+
31
+ logger = get_default_logger(__name__)
32
+
33
+ Message = dict[str, str]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ChatRequest:
38
+ """对话请求模型。"""
39
+
40
+ messages: list[Message]
41
+ temperature: float | None = None
42
+ max_tokens: int | None = None
43
+ stream: bool = False
44
+
45
+
46
+ class ChatService(BaseAIService[ChatRequest, str]):
47
+ """对话类模型服务(全异步)。"""
48
+
49
+ @override
50
+ def _build_payload(self, request: ChatRequest) -> JSONObject:
51
+ payload: dict[str, object] = {
52
+ "model": self._profile.model,
53
+ "messages": request.messages,
54
+ "stream": request.stream,
55
+ }
56
+ if request.temperature is not None:
57
+ payload["temperature"] = request.temperature
58
+ if request.max_tokens is not None:
59
+ payload["max_tokens"] = request.max_tokens
60
+ return cast(JSONObject, payload)
61
+
62
+ @override
63
+ def _parse_response(self, data: JSONObject) -> str:
64
+ content = safe_get(data, "choices", 0, "message", "content")
65
+ if not isinstance(content, str):
66
+ raise AIResponseError(f"content 类型异常: {type(content).__name__}")
67
+ return content
68
+
69
+ async def ask(
70
+ self,
71
+ messages: list[Message],
72
+ *,
73
+ temperature: float | None = None,
74
+ max_tokens: int | None = None,
75
+ ) -> str:
76
+ request = ChatRequest(messages, temperature=temperature, max_tokens=max_tokens)
77
+ return await self._call(request)
78
+
79
+ async def complete(
80
+ self,
81
+ prompt: str,
82
+ *,
83
+ temperature: float | None = None,
84
+ max_tokens: int | None = None,
85
+ ) -> str:
86
+ messages: list[Message] = [{"role": "user", "content": prompt}]
87
+ return await self.ask(messages, temperature=temperature, max_tokens=max_tokens)
88
+
89
+ async def stream(
90
+ self,
91
+ messages: list[Message],
92
+ *,
93
+ temperature: float | None = None,
94
+ max_tokens: int | None = None,
95
+ ) -> AsyncIterator[str]:
96
+ request = ChatRequest(
97
+ messages, temperature=temperature, max_tokens=max_tokens, stream=True
98
+ )
99
+ payload = self._build_payload(request)
100
+ async for parsed in self._client.stream_json(payload):
101
+ if parsed is None:
102
+ continue
103
+ try:
104
+ delta = safe_get(parsed, "choices", 0, "delta", "content")
105
+ except AIResponseError:
106
+ # 流式结束帧等无 delta 的正常帧,跳过
107
+ logger.debug("流式帧缺少 delta.content,跳过")
108
+ continue
109
+ if isinstance(delta, str) and delta:
110
+ yield delta
@@ -0,0 +1,133 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ import asyncio
21
+ import itertools
22
+ from dataclasses import dataclass
23
+ from typing import cast, override
24
+
25
+ from kit.ai.base_service import BaseAIService, safe_get
26
+ from kit.ai.exceptions import AIError, AIRequestError, AIResponseError
27
+ from kit.ai.profiles import ModelProfile
28
+ from kit.ai.types import JSONObject
29
+
30
+ __all__ = ["EmbeddingRequest", "EmbeddingService"]
31
+
32
+ _DEFAULT_CONCURRENCY = 4
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class EmbeddingRequest:
37
+ """向量化请求模型(单批)。"""
38
+
39
+ texts: list[str]
40
+
41
+
42
+ class EmbeddingService(BaseAIService[EmbeddingRequest, list[list[float]]]):
43
+ """向量化服务(全异步)。
44
+
45
+ 公网 API 有限流限速前提,通过分批 + 可选并发(信号量)控制压力。
46
+ 信号量在运行中的事件循环内惰性创建,避免构造期绑定循环的隐患。
47
+ """
48
+
49
+ def __init__(self, profile: ModelProfile) -> None:
50
+ super().__init__(profile)
51
+ if profile.dimension is None:
52
+ raise ValueError(f"模型 {profile.model} 未配置 dimension,无法用于向量化")
53
+
54
+ @property
55
+ def dimension(self) -> int:
56
+ dim = self._profile.dimension
57
+ if not isinstance(dim, int):
58
+ raise AIResponseError("dimension 在构造期已校验非空,不应缺失")
59
+ return dim
60
+
61
+ @override
62
+ def _build_payload(self, request: EmbeddingRequest) -> JSONObject:
63
+ return cast(
64
+ JSONObject,
65
+ {
66
+ "model": self._profile.model,
67
+ "input": request.texts,
68
+ "encoding_format": "float",
69
+ },
70
+ )
71
+
72
+ @override
73
+ def _parse_response(self, data: JSONObject) -> list[list[float]]:
74
+ items = safe_get(data, "data")
75
+ if not isinstance(items, list):
76
+ raise AIResponseError(f"向量化响应 data 必须是数组: {str(data)[:200]}")
77
+ vectors: list[list[float]] = []
78
+ for item in items:
79
+ if not isinstance(item, dict):
80
+ raise AIResponseError(f"向量化响应条目非法: {str(item)[:200]}")
81
+ embedding = item.get("embedding")
82
+ if not isinstance(embedding, list):
83
+ raise AIResponseError(f"向量化响应 embedding 非法: {str(item)[:200]}")
84
+ floats: list[float] = []
85
+ for value in embedding:
86
+ if not isinstance(value, (int, float)):
87
+ raise AIResponseError(
88
+ f"向量化响应 embedding 元素非法: {str(item)[:200]}"
89
+ )
90
+ floats.append(float(value))
91
+ vectors.append(floats)
92
+ return vectors
93
+
94
+ async def embed_text(self, text: str) -> list[float]:
95
+ vectors = await self._call(EmbeddingRequest([text]))
96
+ return vectors[0]
97
+
98
+ async def embed_texts(
99
+ self,
100
+ texts: list[str],
101
+ *,
102
+ batch_size: int | None = None,
103
+ concurrency: int = _DEFAULT_CONCURRENCY,
104
+ ) -> list[list[float]]:
105
+ if not texts:
106
+ return []
107
+
108
+ batch = batch_size or self._profile.batch_size
109
+ semaphore = asyncio.Semaphore(concurrency)
110
+ results: list[list[float] | None] = [None] * len(texts)
111
+ failures: list[str] = []
112
+
113
+ async def _one(batch_index: int, chunk: tuple[str, ...]) -> None:
114
+ async with semaphore:
115
+ try:
116
+ vectors = await self._call(EmbeddingRequest(list(chunk)))
117
+ except AIError as exc:
118
+ failures.append(f"批次 {batch_index}: {exc}")
119
+ return
120
+ start = batch_index * batch
121
+ for offset, vec in enumerate(vectors):
122
+ results[start + offset] = vec
123
+
124
+ batches = list(itertools.batched(texts, batch, strict=False))
125
+ await asyncio.gather(*(_one(i, chunk) for i, chunk in enumerate(batches)))
126
+
127
+ if failures:
128
+ raise AIRequestError(
129
+ f"有 {len(failures)}/{len(batches)} 批向量化失败: "
130
+ + " | ".join(failures)
131
+ )
132
+
133
+ return [vec for vec in results if vec is not None]
kit/ai/exceptions.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ # date: 2026-08-21
21
+ """kit/ai 统一异常体系
22
+
23
+ 继承链:
24
+ AIError 基类
25
+ ├── AIConfigError 档案文件缺失/非法、档案不存在、密钥未配置、必填字段缺失
26
+ ├── AIRequestError 网络异常、HTTP >= 400
27
+ │ └── AITimeoutError 请求超时(可用于重试策略)
28
+ └── AIResponseError 响应结构解析失败、流式数据无法解析
29
+ """
30
+
31
+
32
+ class AIError(Exception):
33
+ """AI 模型服务基类异常"""
34
+
35
+
36
+ class AIConfigError(AIError):
37
+ """模型档案配置异常(文件缺失/非法、档案不存在、密钥未配置、必填字段缺失)"""
38
+
39
+
40
+ class AIRequestError(AIError):
41
+ """模型服务请求异常(网络异常、HTTP >= 400)。
42
+
43
+ Attributes:
44
+ status_code: HTTP 状态码;非 HTTP 错误(超时、连接失败等)为 None。
45
+ """
46
+
47
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
48
+ super().__init__(message)
49
+ self.status_code: int | None = status_code
50
+
51
+
52
+ class AITimeoutError(AIRequestError):
53
+ """模型服务请求超时异常(可用于重试策略)"""
54
+
55
+
56
+ class AIResponseError(AIError):
57
+ """模型服务响应异常(响应结构解析失败、流式数据无法解析)"""