nonebot-plugin-group-chat-memory 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.
- nonebot_plugin_group_chat_memory/__init__.py +26 -0
- nonebot_plugin_group_chat_memory/api.py +185 -0
- nonebot_plugin_group_chat_memory/config.py +117 -0
- nonebot_plugin_group_chat_memory/context.py +148 -0
- nonebot_plugin_group_chat_memory/handlers.py +298 -0
- nonebot_plugin_group_chat_memory/memory.py +283 -0
- nonebot_plugin_group_chat_memory/models.py +42 -0
- nonebot_plugin_group_chat_memory/replies.py +193 -0
- nonebot_plugin_group_chat_memory/store.py +425 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/METADATA +173 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/RECORD +13 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_group_chat_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
from .models import Message
|
|
10
|
+
from .store import MemoryStore
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
ReplyCallable = Callable[[int, Message], Awaitable[None]]
|
|
15
|
+
RandomValue = Callable[[], float]
|
|
16
|
+
|
|
17
|
+
WORKER_NAME_PREFIX = "group-chat-memory-reply"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _GroupState:
|
|
21
|
+
"""单群的待回复槽位:点名请求 FIFO,随机请求只保留最新一条。"""
|
|
22
|
+
|
|
23
|
+
__slots__ = ("explicit", "latest_random", "task", "wake")
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self.explicit: deque[Message] = deque()
|
|
27
|
+
self.latest_random: Message | None = None
|
|
28
|
+
self.wake = asyncio.Event()
|
|
29
|
+
self.task: asyncio.Task[None] | None = None
|
|
30
|
+
|
|
31
|
+
def take_next(self) -> Message | None:
|
|
32
|
+
"""取出下一条要回复的消息:先点名队列,再随机槽位。"""
|
|
33
|
+
if self.explicit:
|
|
34
|
+
return self.explicit.popleft()
|
|
35
|
+
message, self.latest_random = self.latest_random, None
|
|
36
|
+
return message
|
|
37
|
+
|
|
38
|
+
def has_pending(self) -> bool:
|
|
39
|
+
return bool(self.explicit) or self.latest_random is not None
|
|
40
|
+
|
|
41
|
+
def discard_random(self) -> bool:
|
|
42
|
+
"""丢弃等待中的随机请求;点名请求保留给 worker 排空。"""
|
|
43
|
+
had_random = self.latest_random is not None
|
|
44
|
+
self.latest_random = None
|
|
45
|
+
return had_random
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ReplyCoordinator:
|
|
49
|
+
"""按群串行、跨群并行的回复调度器。
|
|
50
|
+
|
|
51
|
+
每个群一个 worker:`explicit` 请求进入 FIFO,保证逐条且全部被回复;
|
|
52
|
+
非点名(随机)请求只保留最新一条,等待中的旧请求被直接替换,避免突发消息
|
|
53
|
+
造成回复风暴。不同群各自独立执行,一个群的慢回复不会阻塞其他群。
|
|
54
|
+
|
|
55
|
+
提交时只保存消息本身,不复制上下文;`reply` 回调在实际执行时才构建上下文,
|
|
56
|
+
因此回复使用的永远是执行时刻的群聊状态。单个请求的异常被隔离并记录日志,
|
|
57
|
+
不会中断该群后续请求;`reply` 抛出的 `CancelledError` 会结束 worker,并由
|
|
58
|
+
监督回调为剩余请求重新拉起 worker。`submit` 必须在事件循环中调用;关闭后
|
|
59
|
+
不再接受请求,已接受的点名请求仍会全部执行完毕。
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
store: MemoryStore,
|
|
65
|
+
reply: ReplyCallable,
|
|
66
|
+
*,
|
|
67
|
+
random_value: RandomValue = random.random,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._store = store
|
|
70
|
+
self._reply = reply
|
|
71
|
+
self._random_value = random_value
|
|
72
|
+
self._groups: dict[int, _GroupState] = {}
|
|
73
|
+
self._closing = False
|
|
74
|
+
|
|
75
|
+
async def should_random_reply(self, group_id: int) -> bool:
|
|
76
|
+
"""按当前群概率判断是否随机回复:0% 永不、100% 必定。
|
|
77
|
+
|
|
78
|
+
概率每次从存储读取,管理命令修改后立即生效。0 与 100 的边界不消耗随机值。
|
|
79
|
+
"""
|
|
80
|
+
probability = await self._store.get_probability(group_id)
|
|
81
|
+
if probability <= 0.0:
|
|
82
|
+
return False
|
|
83
|
+
if probability >= 100.0:
|
|
84
|
+
return True
|
|
85
|
+
return self._random_value() < probability / 100.0
|
|
86
|
+
|
|
87
|
+
def submit(self, group_id: int, message: Message, explicit: bool) -> None:
|
|
88
|
+
"""提交一条待回复消息;`explicit` 表示点名或回复触发的必须回复请求。"""
|
|
89
|
+
if self._closing:
|
|
90
|
+
logger.debug("回复协调器已关闭,忽略群 %s 的回复请求。", group_id)
|
|
91
|
+
return
|
|
92
|
+
state = self._groups.get(group_id)
|
|
93
|
+
if state is None:
|
|
94
|
+
state = _GroupState()
|
|
95
|
+
self._groups[group_id] = state
|
|
96
|
+
if explicit:
|
|
97
|
+
state.explicit.append(message)
|
|
98
|
+
else:
|
|
99
|
+
state.latest_random = message
|
|
100
|
+
state.wake.set()
|
|
101
|
+
self._ensure_worker(group_id, state)
|
|
102
|
+
|
|
103
|
+
async def close(self) -> None:
|
|
104
|
+
"""停止接受新请求,并在所有已接受的点名请求回复完毕后关闭 worker。
|
|
105
|
+
|
|
106
|
+
已接受的 explicit(点名/回复)请求全部执行完毕才返回;等待中的随机请求
|
|
107
|
+
被丢弃(只记录群号,不含正文),在途回复也会执行完毕,因此关闭后不会
|
|
108
|
+
遗留任何 worker 或群状态。已结束 worker 的 done callback 可能仍在队列里
|
|
109
|
+
并因排队的点名请求拉起新 worker,所以这里循环等待到完全静默。
|
|
110
|
+
可重复调用;调用方取消 `close()` 不会级联取消 worker,取消异常照常向上
|
|
111
|
+
抛出,可再次调用完成收尾。
|
|
112
|
+
"""
|
|
113
|
+
first = not self._closing
|
|
114
|
+
self._closing = True
|
|
115
|
+
for group_id, state in list(self._groups.items()):
|
|
116
|
+
if first and state.discard_random():
|
|
117
|
+
logger.warning("回复协调器关闭,群 %s 的等待中的随机回复请求被丢弃。", group_id)
|
|
118
|
+
state.wake.set()
|
|
119
|
+
while True:
|
|
120
|
+
# 先让已结束 worker 的 done callback 执行: 它可能为排队的点名请求拉起新 worker.
|
|
121
|
+
await asyncio.sleep(0)
|
|
122
|
+
tasks = [
|
|
123
|
+
state.task
|
|
124
|
+
for state in self._groups.values()
|
|
125
|
+
if state.task is not None and not state.task.done()
|
|
126
|
+
]
|
|
127
|
+
if tasks:
|
|
128
|
+
await asyncio.gather(
|
|
129
|
+
*(asyncio.shield(task) for task in tasks), return_exceptions=True
|
|
130
|
+
)
|
|
131
|
+
continue
|
|
132
|
+
pending = [
|
|
133
|
+
(group_id, state) for group_id, state in self._groups.items() if state.explicit
|
|
134
|
+
]
|
|
135
|
+
if not pending:
|
|
136
|
+
break
|
|
137
|
+
# 兜底: worker 的 done callback 尚未执行时, 由 close 自己续跑点名请求.
|
|
138
|
+
for group_id, state in pending:
|
|
139
|
+
state.wake.set()
|
|
140
|
+
self._ensure_worker(group_id, state)
|
|
141
|
+
self._groups.clear()
|
|
142
|
+
|
|
143
|
+
def _ensure_worker(self, group_id: int, state: _GroupState) -> None:
|
|
144
|
+
task = state.task
|
|
145
|
+
if task is not None and not task.done():
|
|
146
|
+
return
|
|
147
|
+
task = asyncio.create_task(
|
|
148
|
+
self._run_group(group_id, state), name=f"{WORKER_NAME_PREFIX}-{group_id}"
|
|
149
|
+
)
|
|
150
|
+
state.task = task
|
|
151
|
+
task.add_done_callback(
|
|
152
|
+
lambda done, gid=group_id, st=state: self._on_worker_done(gid, st, done)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def _run_group(self, group_id: int, state: _GroupState) -> None:
|
|
156
|
+
while True:
|
|
157
|
+
message = state.take_next()
|
|
158
|
+
if message is None:
|
|
159
|
+
if self._closing:
|
|
160
|
+
return
|
|
161
|
+
state.wake.clear()
|
|
162
|
+
if state.has_pending():
|
|
163
|
+
continue
|
|
164
|
+
await state.wake.wait()
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
await self._reply(group_id, message)
|
|
168
|
+
except Exception:
|
|
169
|
+
logger.exception("群 %s 的回复失败,已跳过该请求。", group_id)
|
|
170
|
+
|
|
171
|
+
def _on_worker_done(self, group_id: int, state: _GroupState, task: asyncio.Task[None]) -> None:
|
|
172
|
+
if task.cancelled():
|
|
173
|
+
if not self._closing:
|
|
174
|
+
logger.warning("群 %s 的回复 worker 被取消。", group_id)
|
|
175
|
+
else:
|
|
176
|
+
error = task.exception()
|
|
177
|
+
if error is not None and not self._closing:
|
|
178
|
+
logger.error("群 %s 的回复 worker 意外退出:%r", group_id, error, exc_info=error)
|
|
179
|
+
if state.task is task:
|
|
180
|
+
state.task = None
|
|
181
|
+
if state.task is not None and not state.task.done():
|
|
182
|
+
return
|
|
183
|
+
if self._needs_recovery(state):
|
|
184
|
+
state.wake.set()
|
|
185
|
+
self._ensure_worker(group_id, state)
|
|
186
|
+
elif self._closing:
|
|
187
|
+
self._groups.pop(group_id, None)
|
|
188
|
+
|
|
189
|
+
def _needs_recovery(self, state: _GroupState) -> bool:
|
|
190
|
+
"""是否还有必须继续处理的请求: 关闭后仍要排空已接受的点名请求。"""
|
|
191
|
+
if state.explicit:
|
|
192
|
+
return True
|
|
193
|
+
return not self._closing and state.latest_random is not None
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import AsyncIterator, Sequence
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
import aiosqlite
|
|
10
|
+
import sqlite_vec
|
|
11
|
+
|
|
12
|
+
from .config import RUNTIME_CONFIG_DEFAULTS, Config
|
|
13
|
+
from .models import Chunk, ChunkStatus, Message
|
|
14
|
+
|
|
15
|
+
DIMENSIONS_META_KEY = "embedding_dimensions"
|
|
16
|
+
|
|
17
|
+
_SCHEMA_STATEMENTS = (
|
|
18
|
+
"""
|
|
19
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
20
|
+
key TEXT PRIMARY KEY,
|
|
21
|
+
value TEXT NOT NULL
|
|
22
|
+
)
|
|
23
|
+
""",
|
|
24
|
+
"""
|
|
25
|
+
CREATE TABLE IF NOT EXISTS runtime_config (
|
|
26
|
+
key TEXT PRIMARY KEY,
|
|
27
|
+
value TEXT NOT NULL
|
|
28
|
+
)
|
|
29
|
+
""",
|
|
30
|
+
"""
|
|
31
|
+
CREATE TABLE IF NOT EXISTS groups (
|
|
32
|
+
group_id INTEGER PRIMARY KEY,
|
|
33
|
+
probability REAL NOT NULL DEFAULT 0
|
|
34
|
+
CHECK (probability >= 0 AND probability <= 100),
|
|
35
|
+
updated_at TEXT NOT NULL
|
|
36
|
+
)
|
|
37
|
+
""",
|
|
38
|
+
"""
|
|
39
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
group_id INTEGER NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
|
|
42
|
+
text TEXT NOT NULL,
|
|
43
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'ready')),
|
|
44
|
+
created_at TEXT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
""",
|
|
47
|
+
"CREATE INDEX IF NOT EXISTS idx_chunks_group ON chunks(group_id)",
|
|
48
|
+
"CREATE INDEX IF NOT EXISTS idx_chunks_status ON chunks(status, id)",
|
|
49
|
+
"""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
group_id INTEGER NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
|
|
53
|
+
user_id INTEGER NOT NULL,
|
|
54
|
+
display_name TEXT NOT NULL,
|
|
55
|
+
text TEXT NOT NULL,
|
|
56
|
+
created_at TEXT NOT NULL,
|
|
57
|
+
is_bot INTEGER NOT NULL DEFAULT 0 CHECK (is_bot IN (0, 1)),
|
|
58
|
+
chunk_id INTEGER REFERENCES chunks(id) ON DELETE SET NULL
|
|
59
|
+
)
|
|
60
|
+
""",
|
|
61
|
+
"""
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_messages_group_time
|
|
63
|
+
ON messages(group_id, created_at, id)
|
|
64
|
+
""",
|
|
65
|
+
"""
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_messages_unchunked
|
|
67
|
+
ON messages(group_id, id) WHERE chunk_id IS NULL
|
|
68
|
+
""",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_VECTOR_TABLE_STATEMENT = """
|
|
72
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_vectors USING vec0(
|
|
73
|
+
chunk_id INTEGER PRIMARY KEY,
|
|
74
|
+
group_id INTEGER PARTITION KEY,
|
|
75
|
+
embedding FLOAT[{dimensions}] distance_metric=cosine
|
|
76
|
+
)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MemoryStore:
|
|
81
|
+
"""按群隔离的 SQLite 与 sqlite-vec 持久化存储。"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, config: Config) -> None:
|
|
84
|
+
self._config = config
|
|
85
|
+
self._dimensions = config.group_chat_memory_embedding_dimensions
|
|
86
|
+
self._default_probability = config.group_chat_memory_default_probability
|
|
87
|
+
self._db: aiosqlite.Connection | None = None
|
|
88
|
+
self._lock = asyncio.Lock()
|
|
89
|
+
|
|
90
|
+
async def open(self) -> None:
|
|
91
|
+
"""打开数据库、加载 sqlite-vec 并创建缺失的表结构。"""
|
|
92
|
+
async with self._lock:
|
|
93
|
+
if self._db is not None:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
path = self._config.database_path
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
db = await aiosqlite.connect(path, isolation_level=None)
|
|
99
|
+
try:
|
|
100
|
+
db.row_factory = aiosqlite.Row
|
|
101
|
+
await db.enable_load_extension(True)
|
|
102
|
+
await db.load_extension(sqlite_vec.loadable_path())
|
|
103
|
+
await db.enable_load_extension(False)
|
|
104
|
+
await db.execute("PRAGMA journal_mode = WAL")
|
|
105
|
+
await db.execute("PRAGMA foreign_keys = ON")
|
|
106
|
+
await db.execute("PRAGMA busy_timeout = 5000")
|
|
107
|
+
await db.execute("PRAGMA synchronous = NORMAL")
|
|
108
|
+
self._db = db
|
|
109
|
+
async with self._transaction_on(db) as tx:
|
|
110
|
+
for statement in _SCHEMA_STATEMENTS:
|
|
111
|
+
await tx.execute(statement)
|
|
112
|
+
await self._ensure_dimensions(tx)
|
|
113
|
+
await tx.execute(_VECTOR_TABLE_STATEMENT.format(dimensions=self._dimensions))
|
|
114
|
+
except BaseException:
|
|
115
|
+
self._db = None
|
|
116
|
+
await db.close()
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
async def close(self) -> None:
|
|
120
|
+
"""关闭数据库连接,可重复调用。"""
|
|
121
|
+
async with self._lock:
|
|
122
|
+
db, self._db = self._db, None
|
|
123
|
+
if db is not None:
|
|
124
|
+
await db.close()
|
|
125
|
+
|
|
126
|
+
async def add_message(self, message: Message) -> int:
|
|
127
|
+
"""持久化一条群消息,返回数据库内的消息 ID。"""
|
|
128
|
+
async with self._transaction() as db:
|
|
129
|
+
await self._ensure_group(db, message.group_id)
|
|
130
|
+
cursor = await db.execute(
|
|
131
|
+
"INSERT INTO messages"
|
|
132
|
+
"(group_id, user_id, display_name, text, created_at, is_bot) "
|
|
133
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
134
|
+
(
|
|
135
|
+
message.group_id,
|
|
136
|
+
message.user_id,
|
|
137
|
+
message.display_name,
|
|
138
|
+
message.text,
|
|
139
|
+
_format_timestamp(message.created_at),
|
|
140
|
+
int(message.is_bot),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
message_id = cursor.lastrowid
|
|
144
|
+
await cursor.close()
|
|
145
|
+
if message_id is None:
|
|
146
|
+
raise RuntimeError("消息写入失败:数据库未返回消息 ID。")
|
|
147
|
+
return int(message_id)
|
|
148
|
+
|
|
149
|
+
async def recent_messages(self, group_id: int, limit: int) -> list[Message]:
|
|
150
|
+
"""返回当前群最近 limit 条消息,按时间从旧到新排列。"""
|
|
151
|
+
if limit < 1:
|
|
152
|
+
return []
|
|
153
|
+
async with self._lock:
|
|
154
|
+
db = self._require_db()
|
|
155
|
+
cursor = await db.execute(
|
|
156
|
+
"SELECT group_id, user_id, display_name, text, created_at, is_bot "
|
|
157
|
+
"FROM messages WHERE group_id = ? "
|
|
158
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
159
|
+
(group_id, limit),
|
|
160
|
+
)
|
|
161
|
+
rows = await cursor.fetchall()
|
|
162
|
+
await cursor.close()
|
|
163
|
+
return [_row_to_message(row) for row in reversed(rows)]
|
|
164
|
+
|
|
165
|
+
async def get_probability(self, group_id: int) -> float:
|
|
166
|
+
"""返回当前群的随机回复概率;未配置的群使用插件默认值。"""
|
|
167
|
+
async with self._lock:
|
|
168
|
+
db = self._require_db()
|
|
169
|
+
cursor = await db.execute(
|
|
170
|
+
"SELECT probability FROM groups WHERE group_id = ?", (group_id,)
|
|
171
|
+
)
|
|
172
|
+
row = await cursor.fetchone()
|
|
173
|
+
await cursor.close()
|
|
174
|
+
if row is None:
|
|
175
|
+
return self._default_probability
|
|
176
|
+
return float(row["probability"])
|
|
177
|
+
|
|
178
|
+
async def set_probability(self, group_id: int, value: float) -> None:
|
|
179
|
+
"""设置当前群的随机回复概率,取值必须在 0 到 100 之间。"""
|
|
180
|
+
probability = float(value)
|
|
181
|
+
if not math.isfinite(probability) or not 0.0 <= probability <= 100.0:
|
|
182
|
+
raise ValueError("随机回复概率必须是 0 到 100 之间的数值。")
|
|
183
|
+
async with self._transaction() as db:
|
|
184
|
+
await db.execute(
|
|
185
|
+
"INSERT INTO groups(group_id, probability, updated_at) VALUES (?, ?, ?) "
|
|
186
|
+
"ON CONFLICT(group_id) DO UPDATE SET "
|
|
187
|
+
"probability = excluded.probability, updated_at = excluded.updated_at",
|
|
188
|
+
(group_id, probability, _format_timestamp(_utc_now())),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def get_runtime_config(self) -> dict[str, str]:
|
|
192
|
+
"""读取全 Bot 共用的模型运行时配置。"""
|
|
193
|
+
async with self._lock:
|
|
194
|
+
db = self._require_db()
|
|
195
|
+
cursor = await db.execute("SELECT key, value FROM runtime_config")
|
|
196
|
+
rows = await cursor.fetchall()
|
|
197
|
+
await cursor.close()
|
|
198
|
+
return {row["key"]: row["value"] for row in rows}
|
|
199
|
+
|
|
200
|
+
async def set_runtime_config(self, key: str, value: str) -> None:
|
|
201
|
+
"""持久化一项白名单内的全局模型配置。"""
|
|
202
|
+
if key not in RUNTIME_CONFIG_DEFAULTS:
|
|
203
|
+
raise ValueError(f"不支持的运行时配置项:{key}")
|
|
204
|
+
async with self._transaction() as db:
|
|
205
|
+
await db.execute(
|
|
206
|
+
"INSERT INTO runtime_config(key, value) VALUES (?, ?) "
|
|
207
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
208
|
+
(key, value),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def create_ready_chunk(self, group_id: int, size: int) -> Chunk | None:
|
|
212
|
+
"""用当前群最早的 size 条未分块消息组成片段,并原子关联这些消息。"""
|
|
213
|
+
if size < 1:
|
|
214
|
+
raise ValueError("分块大小必须至少为 1。")
|
|
215
|
+
async with self._transaction() as db:
|
|
216
|
+
await self._ensure_group(db, group_id)
|
|
217
|
+
cursor = await db.execute(
|
|
218
|
+
"SELECT id, user_id, display_name, text, created_at, is_bot "
|
|
219
|
+
"FROM messages WHERE group_id = ? AND chunk_id IS NULL "
|
|
220
|
+
"ORDER BY created_at, id LIMIT ?",
|
|
221
|
+
(group_id, size),
|
|
222
|
+
)
|
|
223
|
+
rows = await cursor.fetchall()
|
|
224
|
+
await cursor.close()
|
|
225
|
+
if len(rows) < size:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
text = _format_chunk_text(group_id, rows)
|
|
229
|
+
cursor = await db.execute(
|
|
230
|
+
"INSERT INTO chunks(group_id, text, status, created_at) VALUES (?, ?, ?, ?)",
|
|
231
|
+
(
|
|
232
|
+
group_id,
|
|
233
|
+
text,
|
|
234
|
+
ChunkStatus.PENDING.value,
|
|
235
|
+
_format_timestamp(_utc_now()),
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
chunk_id = cursor.lastrowid
|
|
239
|
+
await cursor.close()
|
|
240
|
+
if chunk_id is None:
|
|
241
|
+
raise RuntimeError("片段写入失败:数据库未返回片段 ID。")
|
|
242
|
+
placeholders = ", ".join("?" for _ in rows)
|
|
243
|
+
await db.execute(
|
|
244
|
+
f"UPDATE messages SET chunk_id = ? WHERE id IN ({placeholders})",
|
|
245
|
+
(chunk_id, *(row["id"] for row in rows)),
|
|
246
|
+
)
|
|
247
|
+
return Chunk(id=chunk_id, group_id=group_id, text=text)
|
|
248
|
+
|
|
249
|
+
async def pending_chunks(self) -> list[Chunk]:
|
|
250
|
+
"""返回所有群中尚未写入向量的片段。"""
|
|
251
|
+
async with self._lock:
|
|
252
|
+
db = self._require_db()
|
|
253
|
+
cursor = await db.execute(
|
|
254
|
+
"SELECT id, group_id, text, status FROM chunks WHERE status = ? ORDER BY id",
|
|
255
|
+
(ChunkStatus.PENDING.value,),
|
|
256
|
+
)
|
|
257
|
+
rows = await cursor.fetchall()
|
|
258
|
+
await cursor.close()
|
|
259
|
+
return [_row_to_chunk(row) for row in rows]
|
|
260
|
+
|
|
261
|
+
async def pending_groups(self, size: int) -> list[int]:
|
|
262
|
+
"""返回未分块消息数量至少为 size 的群,按群号升序。"""
|
|
263
|
+
if size < 1:
|
|
264
|
+
raise ValueError("分块大小必须至少为 1。")
|
|
265
|
+
async with self._lock:
|
|
266
|
+
db = self._require_db()
|
|
267
|
+
cursor = await db.execute(
|
|
268
|
+
"SELECT group_id FROM messages WHERE chunk_id IS NULL "
|
|
269
|
+
"GROUP BY group_id HAVING count(*) >= ? ORDER BY group_id",
|
|
270
|
+
(size,),
|
|
271
|
+
)
|
|
272
|
+
rows = await cursor.fetchall()
|
|
273
|
+
await cursor.close()
|
|
274
|
+
return [int(row["group_id"]) for row in rows]
|
|
275
|
+
|
|
276
|
+
async def complete_chunk(self, chunk_id: int, vector: list[float]) -> None:
|
|
277
|
+
"""写入片段向量并标记为 ready;片段已被清空时静默忽略。"""
|
|
278
|
+
embedding = _validated_vector(vector, self._dimensions)
|
|
279
|
+
async with self._transaction() as db:
|
|
280
|
+
cursor = await db.execute("SELECT group_id FROM chunks WHERE id = ?", (chunk_id,))
|
|
281
|
+
row = await cursor.fetchone()
|
|
282
|
+
await cursor.close()
|
|
283
|
+
if row is None:
|
|
284
|
+
return
|
|
285
|
+
await db.execute("DELETE FROM memory_vectors WHERE chunk_id = ?", (chunk_id,))
|
|
286
|
+
await db.execute(
|
|
287
|
+
"INSERT INTO memory_vectors(chunk_id, group_id, embedding) VALUES (?, ?, ?)",
|
|
288
|
+
(chunk_id, row["group_id"], sqlite_vec.serialize_float32(embedding)),
|
|
289
|
+
)
|
|
290
|
+
await db.execute(
|
|
291
|
+
"UPDATE chunks SET status = ? WHERE id = ?",
|
|
292
|
+
(ChunkStatus.READY.value, chunk_id),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
async def search(self, group_id: int, vector: list[float], limit: int) -> list[Chunk]:
|
|
296
|
+
"""在当前群已完成的片段中检索最接近的 limit 个片段。"""
|
|
297
|
+
embedding = _validated_vector(vector, self._dimensions)
|
|
298
|
+
if limit < 1:
|
|
299
|
+
return []
|
|
300
|
+
async with self._lock:
|
|
301
|
+
db = self._require_db()
|
|
302
|
+
cursor = await db.execute(
|
|
303
|
+
"SELECT c.id, c.group_id, c.text, c.status FROM ("
|
|
304
|
+
"SELECT chunk_id, distance FROM memory_vectors "
|
|
305
|
+
"WHERE embedding MATCH ? AND k = ? AND group_id = ?"
|
|
306
|
+
") AS v JOIN chunks AS c ON c.id = v.chunk_id ORDER BY v.distance",
|
|
307
|
+
(sqlite_vec.serialize_float32(embedding), limit, group_id),
|
|
308
|
+
)
|
|
309
|
+
rows = await cursor.fetchall()
|
|
310
|
+
await cursor.close()
|
|
311
|
+
return [_row_to_chunk(row) for row in rows]
|
|
312
|
+
|
|
313
|
+
async def clear_group(self, group_id: int) -> None:
|
|
314
|
+
"""删除当前群的消息、片段与向量,保留概率配置。"""
|
|
315
|
+
async with self._transaction() as db:
|
|
316
|
+
await db.execute("DELETE FROM memory_vectors WHERE group_id = ?", (group_id,))
|
|
317
|
+
await db.execute("DELETE FROM messages WHERE group_id = ?", (group_id,))
|
|
318
|
+
await db.execute("DELETE FROM chunks WHERE group_id = ?", (group_id,))
|
|
319
|
+
|
|
320
|
+
def _require_db(self) -> aiosqlite.Connection:
|
|
321
|
+
if self._db is None:
|
|
322
|
+
raise RuntimeError("数据库尚未打开,请先调用 MemoryStore.open()。")
|
|
323
|
+
return self._db
|
|
324
|
+
|
|
325
|
+
@asynccontextmanager
|
|
326
|
+
async def _transaction(self) -> AsyncIterator[aiosqlite.Connection]:
|
|
327
|
+
db = self._require_db()
|
|
328
|
+
async with self._lock, self._transaction_on(db) as tx:
|
|
329
|
+
yield tx
|
|
330
|
+
|
|
331
|
+
@asynccontextmanager
|
|
332
|
+
async def _transaction_on(
|
|
333
|
+
self, db: aiosqlite.Connection
|
|
334
|
+
) -> AsyncIterator[aiosqlite.Connection]:
|
|
335
|
+
await db.execute("BEGIN IMMEDIATE")
|
|
336
|
+
try:
|
|
337
|
+
yield db
|
|
338
|
+
except BaseException:
|
|
339
|
+
await db.rollback()
|
|
340
|
+
raise
|
|
341
|
+
else:
|
|
342
|
+
await db.commit()
|
|
343
|
+
|
|
344
|
+
async def _ensure_group(self, db: aiosqlite.Connection, group_id: int) -> None:
|
|
345
|
+
await db.execute(
|
|
346
|
+
"INSERT INTO groups(group_id, probability, updated_at) VALUES (?, ?, ?) "
|
|
347
|
+
"ON CONFLICT(group_id) DO NOTHING",
|
|
348
|
+
(group_id, self._default_probability, _format_timestamp(_utc_now())),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
async def _ensure_dimensions(self, db: aiosqlite.Connection) -> None:
|
|
352
|
+
cursor = await db.execute("SELECT value FROM meta WHERE key = ?", (DIMENSIONS_META_KEY,))
|
|
353
|
+
row = await cursor.fetchone()
|
|
354
|
+
await cursor.close()
|
|
355
|
+
if row is None:
|
|
356
|
+
await db.execute(
|
|
357
|
+
"INSERT INTO meta(key, value) VALUES (?, ?)",
|
|
358
|
+
(DIMENSIONS_META_KEY, str(self._dimensions)),
|
|
359
|
+
)
|
|
360
|
+
return
|
|
361
|
+
stored = int(row["value"])
|
|
362
|
+
if stored != self._dimensions:
|
|
363
|
+
raise RuntimeError(
|
|
364
|
+
f"数据库向量维度为 {stored},与配置的 {self._dimensions} 不一致;"
|
|
365
|
+
"请恢复原配置或删除数据库文件后重建。"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _row_to_message(row: aiosqlite.Row) -> Message:
|
|
370
|
+
return Message(
|
|
371
|
+
group_id=row["group_id"],
|
|
372
|
+
user_id=row["user_id"],
|
|
373
|
+
display_name=row["display_name"],
|
|
374
|
+
text=row["text"],
|
|
375
|
+
created_at=_parse_timestamp(row["created_at"]),
|
|
376
|
+
is_bot=bool(row["is_bot"]),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _row_to_chunk(row: aiosqlite.Row) -> Chunk:
|
|
381
|
+
return Chunk(
|
|
382
|
+
id=row["id"],
|
|
383
|
+
group_id=row["group_id"],
|
|
384
|
+
text=row["text"],
|
|
385
|
+
status=row["status"],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _format_chunk_text(group_id: int, rows: Sequence[aiosqlite.Row]) -> str:
|
|
390
|
+
first = _parse_timestamp(rows[0]["created_at"])
|
|
391
|
+
last = _parse_timestamp(rows[-1]["created_at"])
|
|
392
|
+
lines = [f"群 {group_id} 聊天记录({first:%Y-%m-%d %H:%M} ~ {last:%Y-%m-%d %H:%M} UTC)"]
|
|
393
|
+
for row in rows:
|
|
394
|
+
created_at = _parse_timestamp(row["created_at"])
|
|
395
|
+
name = row["display_name"].strip() or str(row["user_id"])
|
|
396
|
+
suffix = "(bot)" if row["is_bot"] else ""
|
|
397
|
+
lines.append(f"[{created_at:%Y-%m-%d %H:%M}] {name}{suffix}: {row['text']}")
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _validated_vector(vector: list[float], dimensions: int) -> list[float]:
|
|
402
|
+
if len(vector) != dimensions:
|
|
403
|
+
raise ValueError(f"向量维度为 {len(vector)},配置要求 {dimensions}。")
|
|
404
|
+
values = [float(item) for item in vector]
|
|
405
|
+
if not all(math.isfinite(item) for item in values):
|
|
406
|
+
raise ValueError("向量包含非有限数值。")
|
|
407
|
+
return values
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _utc_now() -> datetime:
|
|
411
|
+
return datetime.now(timezone.utc)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _format_timestamp(value: datetime) -> str:
|
|
415
|
+
return _as_utc(value).isoformat()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _parse_timestamp(value: str) -> datetime:
|
|
419
|
+
return _as_utc(datetime.fromisoformat(value))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _as_utc(value: datetime) -> datetime:
|
|
423
|
+
if value.tzinfo is None:
|
|
424
|
+
return value.replace(tzinfo=timezone.utc)
|
|
425
|
+
return value.astimezone(timezone.utc)
|