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,26 @@
|
|
|
1
|
+
from nonebot import get_driver, require
|
|
2
|
+
from nonebot.plugin import PluginMetadata
|
|
3
|
+
|
|
4
|
+
from .config import Config
|
|
5
|
+
|
|
6
|
+
__plugin_meta__ = PluginMetadata(
|
|
7
|
+
name="群聊记忆",
|
|
8
|
+
description="为群聊提供共享长期记忆、点名对话和按群概率回复",
|
|
9
|
+
usage=(
|
|
10
|
+
"在群聊中 @机器人 或回复机器人的消息即可对话。\n"
|
|
11
|
+
"SUPERUSER:/回复概率 [0~100] 查询或设置当前群随机回复概率;"
|
|
12
|
+
"/清空群记忆 清除当前群聊天记录与长期记忆。"
|
|
13
|
+
),
|
|
14
|
+
type="application",
|
|
15
|
+
homepage="https://github.com/TonyLiangP2010405/nonebot-plugin-group-chat-memory",
|
|
16
|
+
config=Config,
|
|
17
|
+
supported_adapters={"~onebot.v11"},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
get_driver()
|
|
22
|
+
except ValueError:
|
|
23
|
+
pass # 允许在 NoneBot 初始化前单独导入模块
|
|
24
|
+
else:
|
|
25
|
+
require("nonebot_plugin_localstore")
|
|
26
|
+
from . import handlers as handlers
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .context import build_chat_messages
|
|
11
|
+
from .models import ChatContext
|
|
12
|
+
|
|
13
|
+
CHAT_RETRY_DELAYS: tuple[float, ...] = (0.5, 1.0)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelAPIError(RuntimeError):
|
|
17
|
+
"""模型 API 调用失败;异常消息只包含状态或原因,不含密钥与响应正文。"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EmbeddingAPIError(ModelAPIError):
|
|
21
|
+
"""Embedding 请求失败或响应不可用。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChatAPIError(ModelAPIError):
|
|
25
|
+
"""聊天请求失败或响应不可用。"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, *, retryable: bool = False) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.retryable = retryable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _make_client(timeout: float) -> httpx.AsyncClient:
|
|
33
|
+
"""为单次请求创建客户端,使聊天与 Embedding 各自使用独立超时。"""
|
|
34
|
+
return httpx.AsyncClient(timeout=timeout)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _endpoint(base_url: str, path: str) -> str:
|
|
38
|
+
return f"{base_url.rstrip('/')}/{path}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _auth_headers(api_key: str) -> dict[str, str]:
|
|
42
|
+
return {"Authorization": f"Bearer {api_key.strip()}"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EmbeddingClient:
|
|
46
|
+
"""OpenAI 兼容 `/embeddings` 客户端,用于批量向量化。"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: Config) -> None:
|
|
49
|
+
self._config = config
|
|
50
|
+
|
|
51
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
52
|
+
"""批量向量化 texts,返回与输入顺序一致的向量;失败时抛出 EmbeddingAPIError。"""
|
|
53
|
+
if not texts:
|
|
54
|
+
return []
|
|
55
|
+
self._config.require_embedding_config()
|
|
56
|
+
payload = {"model": self._config.group_chat_memory_embedding_model, "input": list(texts)}
|
|
57
|
+
response = await self._post(payload)
|
|
58
|
+
return self._parse(response, len(texts))
|
|
59
|
+
|
|
60
|
+
async def _post(self, payload: dict[str, Any]) -> httpx.Response:
|
|
61
|
+
url = _endpoint(self._config.group_chat_memory_embedding_base_url, "embeddings")
|
|
62
|
+
headers = _auth_headers(self._config.group_chat_memory_embedding_api_key.get_secret_value())
|
|
63
|
+
timeout = self._config.group_chat_memory_embedding_timeout
|
|
64
|
+
try:
|
|
65
|
+
async with _make_client(timeout) as client:
|
|
66
|
+
response = await client.post(url, json=payload, headers=headers)
|
|
67
|
+
except httpx.TimeoutException as exc:
|
|
68
|
+
raise EmbeddingAPIError("Embedding 请求超时。") from exc
|
|
69
|
+
except httpx.HTTPError as exc:
|
|
70
|
+
raise EmbeddingAPIError("Embedding 请求失败,无法连接服务。") from exc
|
|
71
|
+
if not 200 <= response.status_code < 300:
|
|
72
|
+
raise EmbeddingAPIError(f"Embedding 服务返回 HTTP {response.status_code}。")
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
def _parse(self, response: httpx.Response, expected: int) -> list[list[float]]:
|
|
76
|
+
try:
|
|
77
|
+
payload = response.json()
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
raise EmbeddingAPIError("Embedding 响应不是合法 JSON。") from exc
|
|
80
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
81
|
+
if not isinstance(data, list) or not data:
|
|
82
|
+
raise EmbeddingAPIError("Embedding 响应缺少向量数据。")
|
|
83
|
+
if len(data) != expected:
|
|
84
|
+
raise EmbeddingAPIError(
|
|
85
|
+
f"Embedding 响应返回 {len(data)} 条向量,与请求的 {expected} 条不一致。"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
ordered = sorted(
|
|
89
|
+
enumerate(data),
|
|
90
|
+
key=lambda item: _index_of(item[1], item[0]),
|
|
91
|
+
)
|
|
92
|
+
dimensions = self._config.group_chat_memory_embedding_dimensions
|
|
93
|
+
return [_parse_vector(item, dimensions) for _, item in ordered]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ChatClient:
|
|
97
|
+
"""OpenAI 兼容 `/chat/completions` 客户端,带有限重试。"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
config: Config,
|
|
102
|
+
*,
|
|
103
|
+
retry_delays: tuple[float, ...] = CHAT_RETRY_DELAYS,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._config = config
|
|
106
|
+
self._retry_delays = tuple(retry_delays)
|
|
107
|
+
|
|
108
|
+
async def complete(self, context: ChatContext, current_text: str) -> str:
|
|
109
|
+
"""按上下文请求一条回复;瞬时失败按配置次数重试,最终失败抛出 ChatAPIError。"""
|
|
110
|
+
self._config.require_chat_config()
|
|
111
|
+
if not current_text.strip():
|
|
112
|
+
raise ChatAPIError("当前消息为空,无法请求聊天模型。")
|
|
113
|
+
payload = {
|
|
114
|
+
"model": self._config.group_chat_memory_chat_model,
|
|
115
|
+
"messages": build_chat_messages(self._config, context, current_text),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
attempts = len(self._retry_delays) + 1
|
|
119
|
+
for attempt in range(attempts):
|
|
120
|
+
try:
|
|
121
|
+
response = await self._post(payload)
|
|
122
|
+
except ChatAPIError as exc:
|
|
123
|
+
if not exc.retryable or attempt + 1 >= attempts:
|
|
124
|
+
raise
|
|
125
|
+
await asyncio.sleep(self._retry_delays[attempt])
|
|
126
|
+
continue
|
|
127
|
+
return self._parse(response)
|
|
128
|
+
raise ChatAPIError("聊天请求重试次数已用尽。")
|
|
129
|
+
|
|
130
|
+
async def _post(self, payload: dict[str, Any]) -> httpx.Response:
|
|
131
|
+
url = _endpoint(self._config.group_chat_memory_chat_base_url, "chat/completions")
|
|
132
|
+
headers = _auth_headers(self._config.group_chat_memory_chat_api_key.get_secret_value())
|
|
133
|
+
timeout = self._config.group_chat_memory_chat_timeout
|
|
134
|
+
try:
|
|
135
|
+
async with _make_client(timeout) as client:
|
|
136
|
+
response = await client.post(url, json=payload, headers=headers)
|
|
137
|
+
except httpx.TimeoutException as exc:
|
|
138
|
+
raise ChatAPIError("聊天请求超时。", retryable=True) from exc
|
|
139
|
+
except httpx.HTTPError as exc:
|
|
140
|
+
raise ChatAPIError("聊天请求失败,无法连接服务。", retryable=True) from exc
|
|
141
|
+
if not 200 <= response.status_code < 300:
|
|
142
|
+
raise ChatAPIError(
|
|
143
|
+
f"聊天服务返回 HTTP {response.status_code}。",
|
|
144
|
+
retryable=response.status_code == 429 or response.status_code >= 500,
|
|
145
|
+
)
|
|
146
|
+
return response
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _parse(response: httpx.Response) -> str:
|
|
150
|
+
try:
|
|
151
|
+
payload = response.json()
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
raise ChatAPIError("聊天响应不是合法 JSON。") from exc
|
|
154
|
+
choices = payload.get("choices") if isinstance(payload, dict) else None
|
|
155
|
+
if not isinstance(choices, list) or not choices:
|
|
156
|
+
raise ChatAPIError("聊天响应缺少 choices。")
|
|
157
|
+
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
|
158
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
159
|
+
if not isinstance(content, str) or not content.strip():
|
|
160
|
+
raise ChatAPIError("聊天响应缺少回复内容。")
|
|
161
|
+
return content.strip()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _index_of(item: Any, fallback: int) -> int:
|
|
165
|
+
index = item.get("index") if isinstance(item, dict) else None
|
|
166
|
+
if isinstance(index, int) and not isinstance(index, bool):
|
|
167
|
+
return index
|
|
168
|
+
return fallback
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_vector(item: Any, dimensions: int) -> list[float]:
|
|
172
|
+
vector = item.get("embedding") if isinstance(item, dict) else None
|
|
173
|
+
if not isinstance(vector, list):
|
|
174
|
+
raise EmbeddingAPIError("Embedding 响应缺少向量。")
|
|
175
|
+
if len(vector) != dimensions:
|
|
176
|
+
raise EmbeddingAPIError(f"Embedding 向量维度为 {len(vector)},配置要求 {dimensions}。")
|
|
177
|
+
values: list[float] = []
|
|
178
|
+
for value in vector:
|
|
179
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
180
|
+
raise EmbeddingAPIError("Embedding 向量包含非数值。")
|
|
181
|
+
number = float(value)
|
|
182
|
+
if not math.isfinite(number):
|
|
183
|
+
raise EmbeddingAPIError("Embedding 向量包含非有限数值。")
|
|
184
|
+
values.append(number)
|
|
185
|
+
return values
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from nonebot import get_plugin_config
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
7
|
+
|
|
8
|
+
DEFAULT_DB_FILENAME = "group_chat_memory.db"
|
|
9
|
+
|
|
10
|
+
DEFAULT_SYSTEM_PROMPT = (
|
|
11
|
+
"你是一个群聊助手。回答自然、简洁,像群友一样交流。"
|
|
12
|
+
"只使用提供的聊天记录和长期记忆,不要假装记得没有出现过的内容。"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
RUNTIME_CONFIG_DEFAULTS = {
|
|
16
|
+
"chat_base_url": "",
|
|
17
|
+
"chat_api_key": "",
|
|
18
|
+
"chat_model": "deepseek-v4.1-flash",
|
|
19
|
+
"embedding_api_key": "",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(RuntimeError):
|
|
24
|
+
"""功能所需的配置缺失时抛出。"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _localstore_data_dir() -> Path:
|
|
28
|
+
from nonebot_plugin_localstore import get_plugin_data_dir
|
|
29
|
+
|
|
30
|
+
return get_plugin_data_dir()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Config(BaseModel):
|
|
34
|
+
"""群聊记忆插件配置;四个模型运行时字段由私聊命令和数据库覆盖。"""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="ignore")
|
|
37
|
+
|
|
38
|
+
group_chat_memory_chat_base_url: str = ""
|
|
39
|
+
group_chat_memory_chat_api_key: SecretStr = SecretStr("")
|
|
40
|
+
group_chat_memory_chat_model: str = "deepseek-v4.1-flash"
|
|
41
|
+
group_chat_memory_chat_timeout: float = Field(default=60.0, gt=0)
|
|
42
|
+
|
|
43
|
+
group_chat_memory_embedding_base_url: str = "https://api.siliconflow.cn/v1"
|
|
44
|
+
group_chat_memory_embedding_api_key: SecretStr = SecretStr("")
|
|
45
|
+
group_chat_memory_embedding_model: str = "BAAI/bge-m3"
|
|
46
|
+
group_chat_memory_embedding_dimensions: int = Field(default=1024, gt=0)
|
|
47
|
+
group_chat_memory_embedding_timeout: float = Field(default=10.0, gt=0)
|
|
48
|
+
group_chat_memory_query_timeout: float = Field(default=3.0, gt=0)
|
|
49
|
+
|
|
50
|
+
group_chat_memory_db_path_override: str | None = None
|
|
51
|
+
|
|
52
|
+
group_chat_memory_recent_messages: int = Field(default=20, ge=1)
|
|
53
|
+
group_chat_memory_chunk_size: int = Field(default=10, ge=1)
|
|
54
|
+
group_chat_memory_long_term_memories: int = Field(default=5, ge=0)
|
|
55
|
+
group_chat_memory_context_max_chars: int = Field(default=6000, ge=1)
|
|
56
|
+
group_chat_memory_default_probability: float = Field(default=0.0, ge=0.0, le=100.0)
|
|
57
|
+
group_chat_memory_ignored_qq: set[int] = Field(default_factory=set)
|
|
58
|
+
group_chat_memory_system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def database_path(self) -> Path:
|
|
62
|
+
"""数据库文件路径;未配置时使用 localstore 的插件数据目录。"""
|
|
63
|
+
configured = (self.group_chat_memory_db_path_override or "").strip()
|
|
64
|
+
if configured:
|
|
65
|
+
return Path(configured).expanduser()
|
|
66
|
+
return _localstore_data_dir() / DEFAULT_DB_FILENAME
|
|
67
|
+
|
|
68
|
+
def apply_runtime_config(self, values: dict[str, str]) -> None:
|
|
69
|
+
"""应用数据库中的四项全局配置,未保存的字段恢复内置默认值。"""
|
|
70
|
+
for key, default in RUNTIME_CONFIG_DEFAULTS.items():
|
|
71
|
+
value = values.get(key, default)
|
|
72
|
+
if key == "chat_base_url":
|
|
73
|
+
self.group_chat_memory_chat_base_url = value
|
|
74
|
+
elif key == "chat_api_key":
|
|
75
|
+
self.group_chat_memory_chat_api_key = SecretStr(value)
|
|
76
|
+
elif key == "chat_model":
|
|
77
|
+
self.group_chat_memory_chat_model = value
|
|
78
|
+
elif key == "embedding_api_key":
|
|
79
|
+
self.group_chat_memory_embedding_api_key = SecretStr(value)
|
|
80
|
+
|
|
81
|
+
def require_chat_config(self) -> None:
|
|
82
|
+
"""检查聊天模型配置,仅在真正调用聊天功能时使用。"""
|
|
83
|
+
missing = self._missing(
|
|
84
|
+
("GROUP_CHAT_MEMORY_CHAT_BASE_URL", self.group_chat_memory_chat_base_url),
|
|
85
|
+
(
|
|
86
|
+
"GROUP_CHAT_MEMORY_CHAT_API_KEY",
|
|
87
|
+
self.group_chat_memory_chat_api_key.get_secret_value(),
|
|
88
|
+
),
|
|
89
|
+
("GROUP_CHAT_MEMORY_CHAT_MODEL", self.group_chat_memory_chat_model),
|
|
90
|
+
)
|
|
91
|
+
if missing:
|
|
92
|
+
raise ConfigError(f"聊天模型未配置,请设置:{'、'.join(missing)}")
|
|
93
|
+
|
|
94
|
+
def require_embedding_config(self) -> None:
|
|
95
|
+
"""检查 Embedding 配置,仅在真正调用向量化功能时使用。"""
|
|
96
|
+
missing = self._missing(
|
|
97
|
+
(
|
|
98
|
+
"GROUP_CHAT_MEMORY_EMBEDDING_BASE_URL",
|
|
99
|
+
self.group_chat_memory_embedding_base_url,
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
"GROUP_CHAT_MEMORY_EMBEDDING_API_KEY",
|
|
103
|
+
self.group_chat_memory_embedding_api_key.get_secret_value(),
|
|
104
|
+
),
|
|
105
|
+
("GROUP_CHAT_MEMORY_EMBEDDING_MODEL", self.group_chat_memory_embedding_model),
|
|
106
|
+
)
|
|
107
|
+
if missing:
|
|
108
|
+
raise ConfigError(f"Embedding 未配置,请设置:{'、'.join(missing)}")
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _missing(*items: tuple[str, str]) -> list[str]:
|
|
112
|
+
return [name for name, value in items if not value.strip()]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_settings() -> Config:
|
|
116
|
+
"""从 NoneBot 全局配置读取本插件配置。"""
|
|
117
|
+
return get_plugin_config(Config)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .models import ChatContext, Chunk, Message
|
|
10
|
+
from .store import MemoryStore
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .api import EmbeddingClient
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
MEMORY_SECTION_TITLE = "## 长期记忆(较早的相关片段)"
|
|
18
|
+
RECENT_SECTION_TITLE = "## 最近群聊(从旧到新)"
|
|
19
|
+
CURRENT_SECTION_TITLE = "## 当前消息"
|
|
20
|
+
MEMBER_LABEL = "群友"
|
|
21
|
+
BOT_LABEL = "Bot"
|
|
22
|
+
SPEAKER_GUIDE = (
|
|
23
|
+
"聊天记录中的「群友」是群里的其他人,「Bot」或「(bot)」标记的都是你自己之前说过的话。"
|
|
24
|
+
"只回复「当前消息」,不要替其他群友发言,也不要编造记录中没有出现过的内容。"
|
|
25
|
+
)
|
|
26
|
+
TRUNCATION_MARK = "…"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ContextBuilder:
|
|
30
|
+
"""并行读取当前群的最近消息与长期记忆;长期记忆失败时降级为最近消息。"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: Config, store: MemoryStore, embedding: EmbeddingClient) -> None:
|
|
33
|
+
self._config = config
|
|
34
|
+
self._store = store
|
|
35
|
+
self._embedding = embedding
|
|
36
|
+
|
|
37
|
+
async def build(self, group_id: int, query: str) -> ChatContext:
|
|
38
|
+
"""构建一次回复所需的上下文;Embedding 或检索失败不影响最近消息。"""
|
|
39
|
+
recent, chunks = await asyncio.gather(
|
|
40
|
+
self._store.recent_messages(group_id, self._config.group_chat_memory_recent_messages),
|
|
41
|
+
self._long_term_chunks(group_id, query),
|
|
42
|
+
)
|
|
43
|
+
return ChatContext(recent_messages=recent, long_term_chunks=chunks)
|
|
44
|
+
|
|
45
|
+
async def _long_term_chunks(self, group_id: int, query: str) -> list[Chunk]:
|
|
46
|
+
"""在独立短超时内完成查询向量与检索,超时或失败都降级为空列表。"""
|
|
47
|
+
limit = self._config.group_chat_memory_long_term_memories
|
|
48
|
+
if limit < 1 or not query.strip():
|
|
49
|
+
return []
|
|
50
|
+
try:
|
|
51
|
+
return await asyncio.wait_for(
|
|
52
|
+
self._lookup_long_term(group_id, query, limit),
|
|
53
|
+
timeout=self._config.group_chat_memory_query_timeout,
|
|
54
|
+
)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logger.warning("长期记忆检索失败,仅使用最近消息:%s: %s", type(exc).__name__, exc)
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
async def _lookup_long_term(self, group_id: int, query: str, limit: int) -> list[Chunk]:
|
|
60
|
+
vectors = await self._embedding.embed([query])
|
|
61
|
+
if not vectors:
|
|
62
|
+
return []
|
|
63
|
+
return await self._store.search(group_id, vectors[0], limit)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_chat_messages(
|
|
67
|
+
config: Config, context: ChatContext, current_text: str
|
|
68
|
+
) -> list[dict[str, str]]:
|
|
69
|
+
"""组装 OpenAI 兼容 messages:系统提示 + 长期记忆 + 最近消息 + 当前输入。
|
|
70
|
+
|
|
71
|
+
字符预算优先保证当前输入完整,其次保留最近消息,最后才放长期记忆。
|
|
72
|
+
"""
|
|
73
|
+
budget = config.group_chat_memory_context_max_chars
|
|
74
|
+
current_block = f"{CURRENT_SECTION_TITLE}\n{MEMBER_LABEL}: {current_text.strip()}"
|
|
75
|
+
remaining = max(budget - len(current_block), 0)
|
|
76
|
+
|
|
77
|
+
recent_lines = _take_recent_lines(context.recent_messages, remaining)
|
|
78
|
+
remaining -= sum(len(line) for line in recent_lines) + max(len(recent_lines) - 1, 0)
|
|
79
|
+
|
|
80
|
+
memory_blocks = _take_chunk_blocks(
|
|
81
|
+
context.long_term_chunks,
|
|
82
|
+
config.group_chat_memory_long_term_memories,
|
|
83
|
+
max(remaining, 0),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
sections: list[str] = []
|
|
87
|
+
if memory_blocks:
|
|
88
|
+
sections.append(f"{MEMORY_SECTION_TITLE}\n" + "\n\n".join(memory_blocks))
|
|
89
|
+
if recent_lines:
|
|
90
|
+
sections.append(f"{RECENT_SECTION_TITLE}\n" + "\n".join(recent_lines))
|
|
91
|
+
sections.append(current_block)
|
|
92
|
+
|
|
93
|
+
system_prompt = "\n\n".join(
|
|
94
|
+
part for part in (config.group_chat_memory_system_prompt.strip(), SPEAKER_GUIDE) if part
|
|
95
|
+
)
|
|
96
|
+
return [
|
|
97
|
+
{"role": "system", "content": system_prompt},
|
|
98
|
+
{"role": "user", "content": "\n\n".join(sections)},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _take_recent_lines(messages: list[Message], budget: int) -> list[str]:
|
|
103
|
+
"""从最新消息往前取,放不下时截断最后一条,返回从旧到新的行。"""
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
used = 0
|
|
106
|
+
for message in reversed(messages):
|
|
107
|
+
line = _format_message(message)
|
|
108
|
+
separator = 1 if lines else 0
|
|
109
|
+
available = budget - used - separator
|
|
110
|
+
if available <= 0:
|
|
111
|
+
break
|
|
112
|
+
if len(line) > available:
|
|
113
|
+
line = line[: available - 1] + TRUNCATION_MARK if available >= 2 else line[:available]
|
|
114
|
+
lines.append(line)
|
|
115
|
+
used += len(line) + separator
|
|
116
|
+
if used >= budget:
|
|
117
|
+
break
|
|
118
|
+
lines.reverse()
|
|
119
|
+
return lines
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _take_chunk_blocks(chunks: list[Chunk], limit: int, budget: int) -> list[str]:
|
|
123
|
+
"""按相关度顺序取最多 limit 个片段,放不下时停止,保留更高优先级的最近消息。"""
|
|
124
|
+
blocks: list[str] = []
|
|
125
|
+
used = 0
|
|
126
|
+
for chunk in chunks[: max(limit, 0)]:
|
|
127
|
+
text = chunk.text.strip()
|
|
128
|
+
if not text:
|
|
129
|
+
continue
|
|
130
|
+
separator = 2 if blocks else 0
|
|
131
|
+
if used + len(text) + separator > budget:
|
|
132
|
+
break
|
|
133
|
+
blocks.append(text)
|
|
134
|
+
used += len(text) + separator
|
|
135
|
+
return blocks
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_message(message: Message) -> str:
|
|
139
|
+
created_at = _as_utc(message.created_at)
|
|
140
|
+
label = BOT_LABEL if message.is_bot else MEMBER_LABEL
|
|
141
|
+
name = message.display_name.strip() or str(message.user_id)
|
|
142
|
+
return f"[{created_at:%Y-%m-%d %H:%M}] {label} {name}: {message.text.strip()}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _as_utc(value: datetime) -> datetime:
|
|
146
|
+
if value.tzinfo is None:
|
|
147
|
+
return value.replace(tzinfo=timezone.utc)
|
|
148
|
+
return value.astimezone(timezone.utc)
|