mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 05:15:22 +08:00
feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
d099033d79
commit
3a77306c3c
@@ -0,0 +1,583 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import uuid as uuid_mod
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from langbot_plugin.api.definition.components.knowledge_engine.engine import (
|
||||
KnowledgeEngine,
|
||||
KnowledgeEngineCapability,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.rag.context import (
|
||||
RetrievalContext,
|
||||
RetrievalResponse,
|
||||
RetrievalResultEntry,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.rag.models import (
|
||||
IngestionContext,
|
||||
IngestionResult,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.rag.enums import DocumentStatus
|
||||
from store.serialization import serialized_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EMBEDDING_BATCH_SIZE = 32
|
||||
|
||||
|
||||
class LongTermMemoryEngine(KnowledgeEngine):
|
||||
"""Long-term memory KnowledgeEngine.
|
||||
|
||||
Serves as the configuration entry point for the memory plugin
|
||||
(embedding model, isolation mode) and handles L2 episodic memory
|
||||
retrieval and import.
|
||||
"""
|
||||
|
||||
@property
|
||||
def _writes(self):
|
||||
return self.plugin.memory_store._writes
|
||||
|
||||
@classmethod
|
||||
def get_capabilities(cls) -> list[str]:
|
||||
return [KnowledgeEngineCapability.DOC_INGESTION]
|
||||
|
||||
async def on_knowledge_base_create(self, kb_id: str, config: dict) -> None:
|
||||
existing = await self.plugin.memory_store.get_kb_configs()
|
||||
if existing:
|
||||
raise ValueError(
|
||||
"Only one memory knowledge base is supported per plugin instance. "
|
||||
"Please delete the existing one before creating a new one."
|
||||
)
|
||||
logger.info("Memory KB created: %s, config: %s", kb_id, config)
|
||||
await self.plugin.memory_store.save_kb_config(kb_id, config)
|
||||
|
||||
async def on_knowledge_base_delete(self, kb_id: str) -> None:
|
||||
logger.info("Memory KB deleted: %s", kb_id)
|
||||
await self.plugin.memory_store.remove_kb_config(kb_id)
|
||||
|
||||
# ================================================================
|
||||
# time-decay scoring
|
||||
# ================================================================
|
||||
|
||||
@staticmethod
|
||||
def _time_decay_score(
|
||||
similarity: float, timestamp_str: str, half_life_days: float = 30.0,
|
||||
) -> float:
|
||||
"""Blend similarity with exponential time decay.
|
||||
|
||||
final_score = similarity * exp(-ln2 * age_days / half_life_days)
|
||||
|
||||
A memory exactly ``half_life_days`` old retains 50 % of its
|
||||
similarity weight; very recent memories are nearly unaffected.
|
||||
"""
|
||||
if not timestamp_str:
|
||||
return similarity * 0.5
|
||||
try:
|
||||
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
age_days = max(0.0, (now - ts).total_seconds() / 86400.0)
|
||||
decay = math.exp(-math.log(2) * age_days / half_life_days)
|
||||
return similarity * decay
|
||||
except (ValueError, TypeError):
|
||||
return similarity * 0.5
|
||||
|
||||
@staticmethod
|
||||
def _parse_importance(metadata: dict[str, Any]) -> int:
|
||||
raw = metadata.get("importance", "2")
|
||||
try:
|
||||
return max(1, min(5, int(raw)))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
@staticmethod
|
||||
def _importance_weight(importance: int) -> float:
|
||||
# Importance should influence ranking, but not overpower semantic match.
|
||||
# The weights keep importance as a secondary signal.
|
||||
return {
|
||||
1: 0.9,
|
||||
2: 1.0,
|
||||
3: 1.08,
|
||||
4: 1.16,
|
||||
5: 1.24,
|
||||
}.get(importance, 1.0)
|
||||
|
||||
@staticmethod
|
||||
def _speaker_match_weight(
|
||||
metadata: dict[str, Any],
|
||||
current_sender_id: str,
|
||||
) -> float:
|
||||
if not current_sender_id:
|
||||
return 1.0
|
||||
if str(metadata.get("sender_id", "") or "") == current_sender_id:
|
||||
return 1.12
|
||||
return 1.0
|
||||
|
||||
@staticmethod
|
||||
def _has_update_signal(metadata: dict[str, Any]) -> bool:
|
||||
raw_tags = str(metadata.get("tags", "") or "")
|
||||
tags = {
|
||||
item.strip().lower()
|
||||
for item in raw_tags.split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return bool(tags & {
|
||||
"correction",
|
||||
"clarification",
|
||||
"profile-update",
|
||||
"preference-change",
|
||||
"fact-update",
|
||||
"更正",
|
||||
"修正",
|
||||
"纠正",
|
||||
"偏好变化",
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _recency_hint(timestamp_str: str) -> str:
|
||||
if not timestamp_str:
|
||||
return "unknown"
|
||||
try:
|
||||
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
age_days = max(0.0, (now - ts).total_seconds() / 86400.0)
|
||||
except (ValueError, TypeError):
|
||||
return "unknown"
|
||||
|
||||
if age_days < 1:
|
||||
return "fresh"
|
||||
if age_days < 7:
|
||||
return "recent"
|
||||
if age_days < 30:
|
||||
return "still-relevant"
|
||||
if age_days < 90:
|
||||
return "older"
|
||||
return "old"
|
||||
|
||||
# ================================================================
|
||||
# retrieve - called by LocalAgentRunner before LLM invocation
|
||||
# ================================================================
|
||||
|
||||
async def retrieve(self, context: RetrievalContext) -> RetrievalResponse:
|
||||
query = context.query
|
||||
collection_id = context.get_collection_id()
|
||||
settings = context.creation_settings
|
||||
retrieval_settings = context.retrieval_settings
|
||||
store = self.plugin.memory_store
|
||||
|
||||
embedding_model_uuid = settings.get("embedding_model_uuid", "")
|
||||
top_k = retrieval_settings.get("top_k", 5)
|
||||
logger.info(
|
||||
"[LongTermMemory] engine retrieve called: collection_id=%s top_k=%s session_name=%s sender_id=%s bot_uuid=%s query_len=%s",
|
||||
collection_id,
|
||||
top_k,
|
||||
retrieval_settings.get("session_name"),
|
||||
retrieval_settings.get("sender_id", ""),
|
||||
retrieval_settings.get("bot_uuid", ""),
|
||||
len(query),
|
||||
)
|
||||
|
||||
if not query.strip() or not embedding_model_uuid:
|
||||
logger.info(
|
||||
"[LongTermMemory] engine retrieve skipped: collection_id=%s reason=%s",
|
||||
collection_id,
|
||||
"missing_query" if not query.strip() else "missing_embedding_model_uuid",
|
||||
)
|
||||
return RetrievalResponse(results=[], total_found=0)
|
||||
|
||||
# embed the query
|
||||
query_vectors = await self.plugin.invoke_embedding(
|
||||
embedding_model_uuid, [query]
|
||||
)
|
||||
query_vector = query_vectors[0]
|
||||
|
||||
session_name = retrieval_settings.get("session_name")
|
||||
sender_id = str(retrieval_settings.get("sender_id", "") or "")
|
||||
bot_uuid = str(retrieval_settings.get("bot_uuid", "") or "")
|
||||
isolation = settings.get("isolation", "session")
|
||||
# Over-fetch to allow time-decay re-ranking to surface recent
|
||||
# memories that would otherwise be pushed out by pure similarity.
|
||||
fetch_k = top_k * 3
|
||||
retrieval_strategy = store.normalize_retrieval_strategy(
|
||||
settings.get("retrieval_strategy", "auto")
|
||||
)
|
||||
vector_weight = store.normalize_vector_weight(settings.get("vector_weight", 0.7))
|
||||
exact_match_boost = bool(settings.get("exact_match_boost", True))
|
||||
results: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
async def extend_results(filters: dict[str, Any] | None) -> None:
|
||||
nonlocal results
|
||||
logger.info(
|
||||
"[LongTermMemory] engine vector search: collection_id=%s fetch_k=%s strategy=%s filters=%s",
|
||||
collection_id,
|
||||
fetch_k,
|
||||
retrieval_strategy,
|
||||
filters,
|
||||
)
|
||||
search_kwargs = {
|
||||
"collection_id": collection_id,
|
||||
"query_vector": query_vector,
|
||||
"top_k": fetch_k,
|
||||
"filters": filters,
|
||||
}
|
||||
if retrieval_strategy in {"auto", "hybrid"}:
|
||||
try:
|
||||
batch = await self.plugin.vector_search(
|
||||
**search_kwargs,
|
||||
search_type="hybrid",
|
||||
query_text=query,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
except Exception:
|
||||
if retrieval_strategy == "hybrid":
|
||||
logger.warning(
|
||||
"[LongTermMemory] engine hybrid search failed; falling back to vector search",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[LongTermMemory] engine auto hybrid search unavailable; falling back to vector search",
|
||||
exc_info=True,
|
||||
)
|
||||
batch = await self.plugin.vector_search(**search_kwargs)
|
||||
else:
|
||||
batch = await self.plugin.vector_search(**search_kwargs)
|
||||
for item in batch:
|
||||
item_id = item.get("id", "")
|
||||
if item_id and item_id in seen_ids:
|
||||
continue
|
||||
if not store.episode_status_included(item.get("metadata", {})):
|
||||
continue
|
||||
if item_id:
|
||||
seen_ids.add(item_id)
|
||||
results.append(item)
|
||||
if len(results) >= fetch_k:
|
||||
return
|
||||
|
||||
if session_name:
|
||||
scope_key = store.get_scope_key_from_session_name(
|
||||
bot_uuid, session_name, isolation
|
||||
)
|
||||
|
||||
if sender_id:
|
||||
await extend_results({"$and": [{"user_key": scope_key}, {"sender_id": sender_id}]})
|
||||
|
||||
if len(results) < fetch_k:
|
||||
await extend_results({"user_key": scope_key})
|
||||
else:
|
||||
# Refuse to search without scope — returning unfiltered results
|
||||
# would leak memories across sessions / users.
|
||||
logger.warning(
|
||||
"[LongTermMemory] engine retrieve refused: collection_id=%s reason=missing_session_name",
|
||||
collection_id,
|
||||
)
|
||||
return RetrievalResponse(results=[], total_found=0)
|
||||
|
||||
# Time-decay re-ranking: blend similarity with recency so that
|
||||
# recent memories can outrank older ones with marginally higher
|
||||
# vector similarity.
|
||||
half_life = float(settings.get("recency_half_life_days", 30))
|
||||
for r in results:
|
||||
sim = r.get("score") or (1.0 - r.get("distance", 1.0))
|
||||
metadata = r.get("metadata", {})
|
||||
ts = metadata.get("timestamp", "")
|
||||
importance = self._parse_importance(metadata)
|
||||
time_score = self._time_decay_score(float(sim), ts, half_life)
|
||||
importance_weight = self._importance_weight(importance)
|
||||
speaker_weight = self._speaker_match_weight(metadata, sender_id)
|
||||
update_weight = 1.08 if self._has_update_signal(metadata) else 1.0
|
||||
exact_boost = 1.0
|
||||
if exact_match_boost:
|
||||
exact_score = store._metadata_exact_match_score(
|
||||
query,
|
||||
r.get("id", ""),
|
||||
metadata,
|
||||
)
|
||||
exact_boost += min(exact_score, 2.0) * 0.25
|
||||
final_score = (
|
||||
time_score
|
||||
* importance_weight
|
||||
* speaker_weight
|
||||
* update_weight
|
||||
* exact_boost
|
||||
)
|
||||
r["_final_score"] = max(0.0, min(1.0, final_score))
|
||||
r["_recency_hint"] = self._recency_hint(ts)
|
||||
r["_importance"] = importance
|
||||
r["_speaker_match"] = speaker_weight > 1.0
|
||||
r["_has_update_signal"] = update_weight > 1.0
|
||||
r["_exact_match"] = exact_boost > 1.0
|
||||
|
||||
results.sort(key=lambda r: r["_final_score"], reverse=True)
|
||||
results = results[:top_k]
|
||||
logger.info(
|
||||
"[LongTermMemory] engine retrieve completed: collection_id=%s result_count=%s half_life_days=%s",
|
||||
collection_id,
|
||||
len(results),
|
||||
half_life,
|
||||
)
|
||||
|
||||
entries: list[RetrievalResultEntry] = []
|
||||
for r in results:
|
||||
meta = r.get("metadata", {})
|
||||
content = meta.get("content", "")
|
||||
timestamp = meta.get("timestamp", "")
|
||||
importance = str(r.get("_importance", self._parse_importance(meta)))
|
||||
tags = meta.get("tags", "")
|
||||
hints = [f"importance:{importance}"]
|
||||
recency_hint = r.get("_recency_hint", "unknown")
|
||||
if recency_hint != "unknown":
|
||||
hints.append(f"recency:{recency_hint}")
|
||||
if r.get("_speaker_match"):
|
||||
hints.append("speaker:current")
|
||||
if r.get("_has_update_signal"):
|
||||
hints.append("signal:update")
|
||||
if r.get("_exact_match"):
|
||||
hints.append("match:exact")
|
||||
|
||||
display = f"[{timestamp}] ({', '.join(hints)})"
|
||||
if tags:
|
||||
display += f" [{tags}]"
|
||||
# Wrap content in data markers to reduce prompt-injection risk.
|
||||
display += f" <mem>{content}</mem>"
|
||||
|
||||
entries.append(
|
||||
RetrievalResultEntry(
|
||||
id=r.get("id", ""),
|
||||
content=[{"type": "text", "text": display}],
|
||||
metadata=meta,
|
||||
score=r.get("_final_score", r.get("score")),
|
||||
distance=1.0 - r.get("_final_score", r.get("score", 0.0)),
|
||||
)
|
||||
)
|
||||
|
||||
return RetrievalResponse(results=entries, total_found=len(entries))
|
||||
|
||||
# ================================================================
|
||||
# ingest - import memories from JSON file
|
||||
# ================================================================
|
||||
|
||||
async def ingest(self, context: IngestionContext) -> IngestionResult:
|
||||
audit_entries: list[dict[str, Any]] = []
|
||||
result = await self._ingest(context.get_collection_id(), context, audit_entries)
|
||||
# User-scope audit writes run after releasing the collection lock.
|
||||
for entry in audit_entries:
|
||||
await self.plugin.memory_store.append_audit_entry(**entry)
|
||||
return result
|
||||
|
||||
@serialized_write
|
||||
async def _ingest(
|
||||
self,
|
||||
collection_id: str,
|
||||
context: IngestionContext,
|
||||
audit_entries: list[dict[str, Any]],
|
||||
) -> IngestionResult:
|
||||
doc_id = context.file_object.metadata.document_id
|
||||
filename = context.file_object.metadata.filename
|
||||
settings = context.creation_settings
|
||||
embedding_model_uuid = settings.get("embedding_model_uuid", "")
|
||||
|
||||
logger.info("Ingesting memory file: %s (doc=%s)", filename, doc_id)
|
||||
|
||||
if not embedding_model_uuid:
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.FAILED,
|
||||
error_message="No embedding model configured.",
|
||||
)
|
||||
|
||||
# read file content
|
||||
try:
|
||||
content_bytes = await self.plugin.get_knowledge_file_stream(
|
||||
context.file_object.storage_path
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to read file: %s", e)
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.FAILED,
|
||||
error_message=f"Could not read file: {e}",
|
||||
)
|
||||
|
||||
# parse JSON array of memory entries
|
||||
try:
|
||||
text = content_bytes.decode("utf-8")
|
||||
|
||||
# support pre-parsed content from a Parser plugin
|
||||
if context.parsed_content and context.parsed_content.text:
|
||||
text = context.parsed_content.text
|
||||
|
||||
memories = json.loads(text)
|
||||
if not isinstance(memories, list):
|
||||
memories = [memories]
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse memory file: %s", e)
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.FAILED,
|
||||
error_message=f"Invalid JSON format: {e}",
|
||||
)
|
||||
|
||||
if not memories:
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.COMPLETED,
|
||||
chunks_created=0,
|
||||
)
|
||||
|
||||
# batch embed and upsert
|
||||
total_stored = 0
|
||||
batch_texts: list[str] = []
|
||||
batch_ids: list[str] = []
|
||||
batch_metas: list[dict[str, Any]] = []
|
||||
imported_by_user: dict[str, int] = {}
|
||||
|
||||
for mem in memories:
|
||||
content = mem.get("content", "")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
tags = mem.get("tags", [])
|
||||
importance = mem.get("importance", 2)
|
||||
try:
|
||||
raw_timestamp = mem.get("timestamp", "")
|
||||
if raw_timestamp:
|
||||
timestamp = self.plugin.memory_store.normalize_timestamp(
|
||||
str(raw_timestamp)
|
||||
)
|
||||
else:
|
||||
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
except ValueError as exc:
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.FAILED,
|
||||
error_message=str(exc),
|
||||
)
|
||||
user_key = mem.get("user_key", "imported")
|
||||
status = self.plugin.memory_store.normalize_episode_status_value(
|
||||
mem.get("status", "active")
|
||||
)
|
||||
user_key_text = str(user_key or "imported")
|
||||
imported_by_user[user_key_text] = imported_by_user.get(user_key_text, 0) + 1
|
||||
|
||||
mid = uuid_mod.uuid4().hex[:12]
|
||||
batch_texts.append(content)
|
||||
batch_ids.append(mid)
|
||||
batch_metas.append({
|
||||
"content": content,
|
||||
"tags": ",".join(tags) if isinstance(tags, list) else str(tags),
|
||||
"importance": str(importance),
|
||||
"timestamp": timestamp,
|
||||
"user_key": user_key,
|
||||
"source": "import",
|
||||
"document_id": doc_id,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
if len(batch_texts) >= EMBEDDING_BATCH_SIZE:
|
||||
total_stored += await self._embed_and_upsert(
|
||||
collection_id, embedding_model_uuid,
|
||||
batch_texts, batch_ids, batch_metas,
|
||||
)
|
||||
batch_texts, batch_ids, batch_metas = [], [], []
|
||||
|
||||
# flush remaining
|
||||
if batch_texts:
|
||||
total_stored += await self._embed_and_upsert(
|
||||
collection_id, embedding_model_uuid,
|
||||
batch_texts, batch_ids, batch_metas,
|
||||
)
|
||||
|
||||
logger.info("Ingestion complete: %d memories stored", total_stored)
|
||||
for user_key, count in imported_by_user.items():
|
||||
audit_entries.append(dict(
|
||||
scope_key=user_key,
|
||||
user_key=user_key,
|
||||
operation="import_l2",
|
||||
target_type="document",
|
||||
target_id=doc_id,
|
||||
summary=f"Imported {count} L2 episode(s) from {filename}",
|
||||
metadata={
|
||||
"kb_id": collection_id,
|
||||
"filename": filename,
|
||||
"count": count,
|
||||
},
|
||||
))
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.COMPLETED,
|
||||
chunks_created=total_stored,
|
||||
)
|
||||
|
||||
@serialized_write
|
||||
async def _embed_and_upsert(
|
||||
self,
|
||||
collection_id: str,
|
||||
embedding_model_uuid: str,
|
||||
texts: list[str],
|
||||
ids: list[str],
|
||||
metas: list[dict[str, Any]],
|
||||
) -> int:
|
||||
logger.info(
|
||||
"[LongTermMemory] engine embed_and_upsert: collection_id=%s batch_size=%s ids=%s",
|
||||
collection_id,
|
||||
len(texts),
|
||||
ids,
|
||||
)
|
||||
vectors = await self.plugin.invoke_embedding(embedding_model_uuid, texts)
|
||||
await self._writes.mutate(
|
||||
self.plugin,
|
||||
collection_id,
|
||||
lambda: self.plugin.vector_upsert(
|
||||
collection_id=collection_id,
|
||||
vectors=vectors,
|
||||
ids=ids,
|
||||
metadata=metas,
|
||||
documents=texts,
|
||||
),
|
||||
"Host vector_upsert outcome unknown",
|
||||
)
|
||||
return len(texts)
|
||||
|
||||
# ================================================================
|
||||
# delete_document - remove imported memories by document_id
|
||||
# ================================================================
|
||||
|
||||
async def delete_document(self, kb_id: str, document_id: str) -> bool:
|
||||
logger.info(
|
||||
"[LongTermMemory] delete_document called: kb_id=%s document_id=%s",
|
||||
kb_id,
|
||||
document_id,
|
||||
)
|
||||
count = await self._delete_document_vectors(kb_id, document_id)
|
||||
logger.info(
|
||||
"[LongTermMemory] delete_document completed: kb_id=%s document_id=%s deleted_count=%s",
|
||||
kb_id,
|
||||
document_id,
|
||||
count,
|
||||
)
|
||||
await self.plugin.memory_store.append_audit_entry(
|
||||
scope_key=f"document:{document_id}",
|
||||
operation="delete_document",
|
||||
target_type="document",
|
||||
target_id=document_id,
|
||||
summary=f"Deleted imported memory document {document_id}",
|
||||
metadata={"kb_id": kb_id, "deleted": count},
|
||||
)
|
||||
return count > 0
|
||||
|
||||
@serialized_write
|
||||
async def _delete_document_vectors(self, kb_id: str, document_id: str) -> int:
|
||||
return await self._writes.mutate(
|
||||
self.plugin,
|
||||
kb_id,
|
||||
lambda: self.plugin.vector_delete(
|
||||
collection_id=kb_id,
|
||||
filters={"document_id": document_id},
|
||||
),
|
||||
"Host vector_delete outcome unknown",
|
||||
)
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
apiVersion: v1
|
||||
kind: KnowledgeEngine
|
||||
metadata:
|
||||
name: long_term_memory
|
||||
label:
|
||||
en_US: LongTermMemory
|
||||
zh_Hans: 长期记忆
|
||||
description:
|
||||
en_US: 'Long-term memory for LangBot. Configure embedding model and isolation mode here. Supports memory import via JSON files.'
|
||||
zh_Hans: 'LangBot 长期记忆。在此配置嵌入模型和隔离模式,支持通过 JSON 文件导入记忆。'
|
||||
|
||||
spec:
|
||||
creation_schema:
|
||||
- name: embedding_model_uuid
|
||||
label:
|
||||
en_US: Embedding Model
|
||||
zh_Hans: 嵌入模型
|
||||
description:
|
||||
en_US: Embedding model used for semantic search.
|
||||
zh_Hans: 用于语义检索的嵌入模型。
|
||||
type: embedding-model-selector
|
||||
required: true
|
||||
default: ''
|
||||
|
||||
- name: isolation
|
||||
label:
|
||||
en_US: Isolation Mode
|
||||
zh_Hans: 隔离模式
|
||||
description:
|
||||
en_US: 'session: each group/chat has independent memory. bot: all sessions share memory.'
|
||||
zh_Hans: 'session:每个群/会话独立记忆。bot:所有会话共享记忆。'
|
||||
type: select
|
||||
options:
|
||||
- name: session
|
||||
label:
|
||||
en_US: Session
|
||||
zh_Hans: 会话隔离
|
||||
- name: bot
|
||||
label:
|
||||
en_US: Bot
|
||||
zh_Hans: 全局共享
|
||||
default: session
|
||||
|
||||
- name: recency_half_life_days
|
||||
label:
|
||||
en_US: Recency Half-Life Days
|
||||
zh_Hans: 时效半衰期天数
|
||||
description:
|
||||
en_US: Controls how quickly older episodic memories lose ranking weight during automatic recall.
|
||||
zh_Hans: 控制自动召回时旧情景记忆的时效衰减速度。
|
||||
type: integer
|
||||
required: false
|
||||
default: 30
|
||||
|
||||
- name: auto_recall_top_k
|
||||
label:
|
||||
en_US: Auto Recall Top K
|
||||
zh_Hans: 自动召回条数
|
||||
description:
|
||||
en_US: Number of episodic memories injected automatically before model invocation.
|
||||
zh_Hans: 模型调用前自动注入的情景记忆条数。
|
||||
type: integer
|
||||
required: false
|
||||
default: 3
|
||||
|
||||
- name: retrieval_strategy
|
||||
label:
|
||||
en_US: Retrieval Strategy
|
||||
zh_Hans: 检索策略
|
||||
description:
|
||||
en_US: "auto tries hybrid search first and falls back to vector search. Use vector for maximum backend compatibility."
|
||||
zh_Hans: "auto 会优先尝试混合检索,失败时回退向量检索。vector 兼容性最好。"
|
||||
type: select
|
||||
options:
|
||||
- name: auto
|
||||
label:
|
||||
en_US: Auto
|
||||
zh_Hans: 自动
|
||||
- name: vector
|
||||
label:
|
||||
en_US: Vector
|
||||
zh_Hans: 向量
|
||||
- name: hybrid
|
||||
label:
|
||||
en_US: Hybrid
|
||||
zh_Hans: 混合
|
||||
required: false
|
||||
default: auto
|
||||
|
||||
- name: vector_weight
|
||||
label:
|
||||
en_US: Vector Weight
|
||||
zh_Hans: 向量权重
|
||||
description:
|
||||
en_US: Vector similarity weight used by hybrid search.
|
||||
zh_Hans: 混合检索中的向量相似度权重。
|
||||
type: number
|
||||
required: false
|
||||
default: 0.7
|
||||
|
||||
- name: exact_match_boost
|
||||
label:
|
||||
en_US: Exact Match Boost
|
||||
zh_Hans: 精确匹配增强
|
||||
description:
|
||||
en_US: Boost exact matches on episode IDs, sender identity, tags, and content snippets.
|
||||
zh_Hans: 对 episode ID、说话人、标签和内容片段的精确匹配进行增强。
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
- name: consolidation_enabled
|
||||
label:
|
||||
en_US: Enable Consolidation Run
|
||||
zh_Hans: 启用记忆整理执行
|
||||
description:
|
||||
en_US: Allows !memory consolidate run to archive scoped candidates and write a summary. Preview is always available.
|
||||
zh_Hans: 允许 !memory consolidate run 归档当前范围候选并写入摘要。预览始终可用。
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
- name: consolidation_min_age_days
|
||||
label:
|
||||
en_US: Consolidation Min Age Days
|
||||
zh_Hans: 记忆整理最小天数
|
||||
description:
|
||||
en_US: Active low-importance memories must be at least this old before consolidation can propose archiving them.
|
||||
zh_Hans: 低重要度活跃记忆至少达到该天数后,整理流程才会建议归档。
|
||||
type: integer
|
||||
required: false
|
||||
default: 7
|
||||
|
||||
- name: consolidation_max_candidates
|
||||
label:
|
||||
en_US: Consolidation Max Candidates
|
||||
zh_Hans: 记忆整理候选上限
|
||||
description:
|
||||
en_US: Maximum number of scoped episodes considered in one consolidation preview or run.
|
||||
zh_Hans: 单次整理预览或执行最多处理的当前范围情景记忆数量。
|
||||
type: integer
|
||||
required: false
|
||||
default: 20
|
||||
|
||||
- name: consolidation_apply_profile_updates
|
||||
label:
|
||||
en_US: Apply Profile Updates During Consolidation
|
||||
zh_Hans: 整理时更新画像
|
||||
description:
|
||||
en_US: Reserved for future profile-update proposals. Keep disabled unless explicitly supported.
|
||||
zh_Hans: 预留给未来画像更新建议。除非明确支持,否则保持关闭。
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
- name: candidate_extraction_enabled
|
||||
label:
|
||||
en_US: Enable Memory Candidate Extraction
|
||||
zh_Hans: 启用记忆候选提取
|
||||
description:
|
||||
en_US: Extract reviewable memory candidates after normal responses. Disabled by default to avoid memory pollution.
|
||||
zh_Hans: 在普通回复后提取可审核的记忆候选。默认关闭以避免污染记忆。
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
- name: candidate_auto_apply
|
||||
label:
|
||||
en_US: Auto Apply Memory Candidates
|
||||
zh_Hans: 自动应用记忆候选
|
||||
description:
|
||||
en_US: Automatically accept extracted candidates. Keep disabled unless you accept the risk of memory pollution.
|
||||
zh_Hans: 自动接受提取出的候选。除非能接受记忆污染风险,否则保持关闭。
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
- name: candidate_max_per_turn
|
||||
label:
|
||||
en_US: Max Candidates Per Turn
|
||||
zh_Hans: 单轮候选上限
|
||||
description:
|
||||
en_US: Maximum number of memory candidates created from one response turn.
|
||||
zh_Hans: 单次回复最多创建的记忆候选数量。
|
||||
type: integer
|
||||
required: false
|
||||
default: 3
|
||||
|
||||
retrieval_schema:
|
||||
- name: top_k
|
||||
label:
|
||||
en_US: Top K
|
||||
zh_Hans: 返回条数
|
||||
description:
|
||||
en_US: Number of results to return.
|
||||
zh_Hans: 检索返回的结果数量。
|
||||
type: integer
|
||||
default: 5
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: memory_engine.py
|
||||
attr: LongTermMemoryEngine
|
||||
Reference in New Issue
Block a user