Merge branch 'xuhd' into 'master'
feat: 记忆配置改为数据库存储,用户只可配置召回模式 See merge request knowledge-base-bj/deerflow-server!35
This commit is contained in:
commit
a514b2fe79
11 files changed
+329
-106
No files matched your search
@@ -442,6 +442,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# Run Alembic migrations before anything else touches the schema.
|
# Run Alembic migrations before anything else touches the schema.
|
||||||
await _run_db_migrations()
|
await _run_db_migrations()
|
||||||
|
|
||||||
|
# 预热 recall_budget cache(从 DB 批量加载,使 get_effective_memory_config 同步可读)
|
||||||
|
try:
|
||||||
|
from deerflow.config.memory_config import load_recall_budget_cache
|
||||||
|
|
||||||
|
_mc_store = getattr(app.state, "memory_config_store", None)
|
||||||
|
if _mc_store is not None:
|
||||||
|
_recall_budgets = await _mc_store.load_all()
|
||||||
|
load_recall_budget_cache(_recall_budgets)
|
||||||
|
logger.info("Memory config cache warmed: %d user override(s)", len(_recall_budgets))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Memory config cache warm-up failed (non-fatal)")
|
||||||
|
|
||||||
# Ensure admin user exists (auto-create on first boot)
|
# Ensure admin user exists (auto-create on first boot)
|
||||||
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
||||||
await _ensure_admin_user(app)
|
await _ensure_admin_user(app)
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.article_type_store = make_article_type_store(sf)
|
app.state.article_type_store = make_article_type_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.memory_config import make_user_memory_config_store
|
||||||
|
|
||||||
|
app.state.memory_config_store = make_user_memory_config_store(sf)
|
||||||
|
|
||||||
# Run event store (has its own factory with config-driven backend selection)
|
# Run event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
@@ -267,6 +271,14 @@ def get_article_type_store(request: Request):
|
|||||||
return getattr(request.app.state, "article_type_store", None)
|
return getattr(request.app.state, "article_type_store", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_config_store(request: Request):
|
||||||
|
"""Return the per-user memory config store."""
|
||||||
|
val = getattr(request.app.state, "memory_config_store", None)
|
||||||
|
if val is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Memory config store not available")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def get_run_context(request: Request) -> RunContext:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_memory_config_store
|
||||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||||
from deerflow.config.memory_config import (
|
from deerflow.config.memory_config import (
|
||||||
USER_WRITABLE_FIELDS,
|
USER_WRITABLE_FIELDS,
|
||||||
delete_user_memory_override,
|
delete_user_memory_override_db,
|
||||||
get_effective_memory_config,
|
get_effective_memory_config,
|
||||||
save_user_memory_override,
|
save_user_memory_override_db,
|
||||||
)
|
)
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
@@ -295,14 +296,17 @@ async def get_memory_v2_config() -> MemoryV2ConfigResponse:
|
|||||||
"/memory/v2/config",
|
"/memory/v2/config",
|
||||||
response_model=MemoryV2ConfigResponse,
|
response_model=MemoryV2ConfigResponse,
|
||||||
summary="Patch Memory V2 Config",
|
summary="Patch Memory V2 Config",
|
||||||
description="把白名单字段写入当前用户的 per-user 配置覆盖。非白名单字段被忽略。",
|
description="把 hindsight.recall_budget 写入当前用户的 per-user 配置覆盖 (DB 存储)。",
|
||||||
)
|
)
|
||||||
async def patch_memory_v2_config(request: MemoryConfigPatchRequest) -> MemoryV2ConfigResponse:
|
async def patch_memory_v2_config(request: MemoryConfigPatchRequest, req: Request) -> MemoryV2ConfigResponse:
|
||||||
"""更新当前用户的 per-user 记忆配置覆盖。"""
|
"""更新当前用户的 per-user 记忆配置覆盖 (仅 hindsight.recall_budget)。"""
|
||||||
user_id = get_effective_user_id()
|
user_id = get_effective_user_id()
|
||||||
|
store = get_memory_config_store(req)
|
||||||
try:
|
try:
|
||||||
save_user_memory_override(user_id, request.overrides)
|
await save_user_memory_override_db(user_id, request.overrides, store)
|
||||||
except OSError as exc:
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=500, detail="Failed to save memory config override.") from exc
|
raise HTTPException(status_code=500, detail="Failed to save memory config override.") from exc
|
||||||
|
|
||||||
config = get_effective_memory_config(user_id)
|
config = get_effective_memory_config(user_id)
|
||||||
@@ -317,18 +321,20 @@ async def patch_memory_v2_config(request: MemoryConfigPatchRequest) -> MemoryV2C
|
|||||||
response_model=MemoryV2ConfigResponse,
|
response_model=MemoryV2ConfigResponse,
|
||||||
summary="Delete Memory V2 Config Override",
|
summary="Delete Memory V2 Config Override",
|
||||||
description=(
|
description=(
|
||||||
"撤销当前用户的 per-user 配置覆盖。传 ?field=injection.enabled 只撤销该字段;"
|
"撤销当前用户的 per-user 配置覆盖。传 ?field=hindsight.recall_budget 只撤销该字段;"
|
||||||
"不传 field 则清空整个 per-user 覆盖。"
|
"不传 field 则清空全部覆盖。"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def delete_memory_v2_config(
|
async def delete_memory_v2_config(
|
||||||
|
req: Request,
|
||||||
field: str | None = Query(None, description="要撤销的点号路径字段;省略则清空全部覆盖"),
|
field: str | None = Query(None, description="要撤销的点号路径字段;省略则清空全部覆盖"),
|
||||||
) -> MemoryV2ConfigResponse:
|
) -> MemoryV2ConfigResponse:
|
||||||
"""撤销当前用户的 per-user 记忆配置覆盖。"""
|
"""撤销当前用户的 per-user 记忆配置覆盖。"""
|
||||||
user_id = get_effective_user_id()
|
user_id = get_effective_user_id()
|
||||||
|
store = get_memory_config_store(req)
|
||||||
try:
|
try:
|
||||||
delete_user_memory_override(user_id, field)
|
await delete_user_memory_override_db(user_id, field, store)
|
||||||
except OSError as exc:
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete memory config override.") from exc
|
raise HTTPException(status_code=500, detail="Failed to delete memory config override.") from exc
|
||||||
|
|
||||||
config = get_effective_memory_config(user_id)
|
config = get_effective_memory_config(user_id)
|
||||||
|
|||||||
+84
-94
@@ -12,21 +12,21 @@
|
|||||||
injection: {...} # 系统提示词注入
|
injection: {...} # 系统提示词注入
|
||||||
security: {...} # 内容安全扫描
|
security: {...} # 内容安全扫描
|
||||||
|
|
||||||
三层配置覆盖
|
两层配置覆盖
|
||||||
============
|
============
|
||||||
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
|
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
|
||||||
1. per-user 覆盖 ``{base_dir}/users/{user_id}/memory_config.json``
|
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
|
||||||
2. 全局覆盖 ``{base_dir}/memory_user_overrides.json``
|
(有 per-user 记录时自动强制 enabled=True)
|
||||||
3. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
|
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
|
||||||
4. Pydantic 默认值
|
3. Pydantic 默认值
|
||||||
|
|
||||||
第 1、2 层只允许覆盖 :data:`USER_WRITABLE_FIELDS` 白名单内的字段。
|
第 1 层只允许覆盖 :data:`USER_WRITABLE_FIELDS` 白名单内的字段。
|
||||||
|
cache 由 Gateway lifespan 启动时预热,写入时同步更新,保持同步读取路径。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -129,31 +129,26 @@ class MemoryConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# per-user 运行时覆盖
|
# per-user 运行时覆盖 — 白名单
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
# 用户可在运行时覆盖的字段白名单 (点号路径)。
|
# 用户唯一可覆盖的字段:召回深度。
|
||||||
# 不在此表中的字段必须改 config.yaml 并重启 (例如 provider / api_key / 字符配额)。
|
# injection.* / builtin.* / enabled 始终由 config.yaml 控制,用户不可关闭。
|
||||||
USER_WRITABLE_FIELDS: frozenset[str] = frozenset(
|
USER_WRITABLE_FIELDS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
# injection.enabled and injection.include_builtin are always ON;
|
|
||||||
# removing them from the whitelist makes _filter_to_whitelist drop
|
|
||||||
# any per-user override for these fields, so stored "false" values
|
|
||||||
# in existing memory_config.json files are silently ignored.
|
|
||||||
"injection.max_tokens",
|
|
||||||
"injection.include_hindsight",
|
|
||||||
# hindsight.memory_mode is fixed to "hybrid"; not user-writable.
|
|
||||||
# hindsight.bank_mission is fixed; not user-writable.
|
|
||||||
"hindsight.recall_budget",
|
"hindsight.recall_budget",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_VALID_RECALL_BUDGETS: frozenset[str] = frozenset({"low", "mid", "high"})
|
||||||
|
|
||||||
|
|
||||||
def _filter_to_whitelist(overrides: dict[str, Any]) -> dict[str, Any]:
|
def _filter_to_whitelist(overrides: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""把覆盖 dict 过滤为只含白名单字段的嵌套 dict。
|
"""把覆盖 dict 过滤为只含白名单字段的嵌套 dict。
|
||||||
|
|
||||||
输入既支持嵌套形式 ``{"injection": {"enabled": false}}``,也支持
|
输入支持嵌套形式 ``{"hindsight": {"recall_budget": "high"}}`` 或
|
||||||
点号扁平形式 ``{"injection.enabled": false}``。非白名单字段被丢弃并 log。
|
点号扁平形式 ``{"hindsight.recall_budget": "high"}``。
|
||||||
|
非白名单字段被丢弃并 log。
|
||||||
"""
|
"""
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
@@ -184,21 +179,6 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _load_json_file(path: Any) -> dict[str, Any]:
|
|
||||||
"""读取一个 JSON 覆盖文件;不存在或解析失败时返回空 dict。"""
|
|
||||||
try:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
p = Path(path)
|
|
||||||
if not p.exists():
|
|
||||||
return {}
|
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except (OSError, json.JSONDecodeError) as exc:
|
|
||||||
logger.warning("读取记忆配置覆盖文件失败 %s: %s", path, exc)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# 全局单例 + 加载入口
|
# 全局单例 + 加载入口
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -223,82 +203,88 @@ def load_memory_config_from_dict(config_dict: dict) -> None:
|
|||||||
_memory_config = MemoryConfig(**config_dict)
|
_memory_config = MemoryConfig(**config_dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# per-user recall_budget 进程内 cache
|
||||||
|
#
|
||||||
|
# Gateway lifespan 启动时调用 load_recall_budget_cache() 从 DB 批量加载。
|
||||||
|
# API 写入时同步更新 cache,使 get_effective_memory_config() 保持同步读取。
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
_recall_budget_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_recall_budget_cache(data: dict[str, str]) -> None:
|
||||||
|
"""用 DB 批量加载结果替换整个 cache (启动时调用一次)。"""
|
||||||
|
global _recall_budget_cache
|
||||||
|
_recall_budget_cache = dict(data)
|
||||||
|
logger.info("recall_budget cache loaded: %d user(s)", len(_recall_budget_cache))
|
||||||
|
|
||||||
|
|
||||||
|
def set_recall_budget_cache(user_id: str, value: str) -> None:
|
||||||
|
"""写入单个用户的 recall_budget cache。"""
|
||||||
|
_recall_budget_cache[user_id] = value
|
||||||
|
|
||||||
|
|
||||||
|
def delete_recall_budget_cache(user_id: str) -> None:
|
||||||
|
"""从 cache 中移除某用户的 recall_budget。"""
|
||||||
|
_recall_budget_cache.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_recall_budget_from_cache(user_id: str) -> str | None:
|
||||||
|
"""返回 cache 中某用户的 recall_budget,未设置时返回 None。"""
|
||||||
|
return _recall_budget_cache.get(user_id)
|
||||||
|
|
||||||
|
|
||||||
def get_effective_memory_config(user_id: str) -> MemoryConfig:
|
def get_effective_memory_config(user_id: str) -> MemoryConfig:
|
||||||
"""按三层覆盖优先级,返回某用户的生效记忆配置。
|
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
|
||||||
|
|
||||||
优先级 (高 -> 低):per-user 覆盖 > 全局覆盖 > config.yaml > 默认值。
|
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
|
||||||
第 1、2 层只应用 :data:`USER_WRITABLE_FIELDS` 白名单内的字段。
|
有 per-user 记录时自动强制 enabled=True。
|
||||||
|
|
||||||
覆盖文件读取失败不会抛异常 —— 退化为只用 config.yaml 级别配置。
|
|
||||||
"""
|
"""
|
||||||
base = _memory_config.model_dump()
|
base = _memory_config.model_dump()
|
||||||
|
recall_budget = _recall_budget_cache.get(user_id)
|
||||||
|
if recall_budget is not None:
|
||||||
|
base.setdefault("hindsight", {})["recall_budget"] = recall_budget
|
||||||
|
base["enabled"] = True
|
||||||
try:
|
try:
|
||||||
from deerflow.config.paths import get_paths
|
return MemoryConfig(**base)
|
||||||
|
|
||||||
paths = get_paths()
|
|
||||||
global_overrides = _filter_to_whitelist(
|
|
||||||
_load_json_file(paths.memory_global_overrides_file)
|
|
||||||
)
|
|
||||||
user_overrides = _filter_to_whitelist(
|
|
||||||
_load_json_file(paths.user_memory_config_file(user_id))
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 配置覆盖失败必须降级,不能阻断对话
|
|
||||||
logger.warning("加载 per-user 记忆配置覆盖失败,退回 config.yaml: %s", exc)
|
|
||||||
return _memory_config
|
|
||||||
|
|
||||||
merged = _deep_merge(_deep_merge(base, global_overrides), user_overrides)
|
|
||||||
try:
|
|
||||||
return MemoryConfig(**merged)
|
|
||||||
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
|
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
|
||||||
logger.warning("合并后的记忆配置非法,退回 config.yaml: %s", exc)
|
logger.warning("合并后的记忆配置非法,退回 config.yaml: %s", exc)
|
||||||
return _memory_config
|
return _memory_config
|
||||||
|
|
||||||
|
|
||||||
def save_user_memory_override(user_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
# ===========================================================================
|
||||||
"""把白名单字段写入某用户的 per-user 覆盖文件,返回写入后的完整覆盖 dict。
|
# Gateway 调用的异步 DB 对接函数
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def save_user_memory_override_db(user_id: str, patch: dict[str, Any], store: Any) -> dict[str, Any]:
|
||||||
|
"""把白名单字段写入 DB store 并更新 cache,返回生效配置 dict。
|
||||||
|
|
||||||
``patch`` 支持嵌套或点号扁平形式;非白名单字段被忽略。
|
``patch`` 支持嵌套或点号扁平形式;非白名单字段被忽略。
|
||||||
|
``store`` 为 ``UserMemoryConfigStore`` 实例 (避免循环 import,用 Any)。
|
||||||
"""
|
"""
|
||||||
from deerflow.config.paths import get_paths
|
|
||||||
|
|
||||||
path = get_paths().user_memory_config_file(user_id)
|
|
||||||
current = _load_json_file(path)
|
|
||||||
filtered = _filter_to_whitelist(patch)
|
filtered = _filter_to_whitelist(patch)
|
||||||
merged = _deep_merge(current, filtered)
|
recall_budget = (filtered.get("hindsight") or {}).get("recall_budget")
|
||||||
|
if recall_budget is not None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
if recall_budget not in _VALID_RECALL_BUDGETS:
|
||||||
path.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
|
raise ValueError(f"recall_budget 必须为 {sorted(_VALID_RECALL_BUDGETS)} 之一,收到:{recall_budget!r}")
|
||||||
return merged
|
await store.set_recall_budget(user_id, recall_budget)
|
||||||
|
set_recall_budget_cache(user_id, recall_budget)
|
||||||
|
return get_effective_memory_config(user_id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
def delete_user_memory_override(user_id: str, field: str | None = None) -> dict[str, Any]:
|
async def delete_user_memory_override_db(user_id: str, field: str | None, store: Any) -> dict[str, Any]:
|
||||||
"""撤销某用户的 per-user 覆盖。
|
"""撤销某用户的 per-user 覆盖并更新 cache,返回生效配置 dict。
|
||||||
|
|
||||||
``field`` 为点号路径 (如 ``injection.enabled``) 时只删该字段;
|
``field=None`` 清空全部;``field="hindsight.recall_budget"`` 只清该字段。
|
||||||
为 ``None`` 时清空整个 per-user 覆盖文件。返回剩余的覆盖 dict。
|
|
||||||
"""
|
"""
|
||||||
from deerflow.config.paths import get_paths
|
if field is None or field == "hindsight.recall_budget":
|
||||||
|
await store.delete(user_id)
|
||||||
path = get_paths().user_memory_config_file(user_id)
|
delete_recall_budget_cache(user_id)
|
||||||
if field is None:
|
|
||||||
if path.exists():
|
|
||||||
path.unlink()
|
|
||||||
return {}
|
|
||||||
|
|
||||||
current = _load_json_file(path)
|
|
||||||
if "." in field:
|
|
||||||
section, sub_field = field.split(".", 1)
|
|
||||||
if isinstance(current.get(section), dict):
|
|
||||||
current[section].pop(sub_field, None)
|
|
||||||
if not current[section]:
|
|
||||||
current.pop(section, None)
|
|
||||||
else:
|
else:
|
||||||
current.pop(field, None)
|
logger.warning("忽略删除非白名单的记忆配置覆盖字段: %s", field)
|
||||||
|
return get_effective_memory_config(user_id).model_dump()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
||||||
return current
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -312,6 +298,10 @@ __all__ = [
|
|||||||
"set_memory_config",
|
"set_memory_config",
|
||||||
"load_memory_config_from_dict",
|
"load_memory_config_from_dict",
|
||||||
"get_effective_memory_config",
|
"get_effective_memory_config",
|
||||||
"save_user_memory_override",
|
"load_recall_budget_cache",
|
||||||
"delete_user_memory_override",
|
"set_recall_budget_cache",
|
||||||
|
"delete_recall_budget_cache",
|
||||||
|
"get_recall_budget_from_cache",
|
||||||
|
"save_user_memory_override_db",
|
||||||
|
"delete_user_memory_override_db",
|
||||||
]
|
]
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
"""Per-user memory configuration override persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.persistence.memory_config.base import UserMemoryConfigStore
|
||||||
|
from deerflow.persistence.memory_config.memory import MemoryUserMemoryConfigStore
|
||||||
|
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||||
|
from deerflow.persistence.memory_config.sql import SqlUserMemoryConfigRepository
|
||||||
|
|
||||||
|
|
||||||
|
def make_user_memory_config_store(session_factory) -> UserMemoryConfigStore:
|
||||||
|
if session_factory is None:
|
||||||
|
return MemoryUserMemoryConfigStore()
|
||||||
|
return SqlUserMemoryConfigRepository(session_factory)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UserMemoryConfigStore",
|
||||||
|
"UserMemoryConfigRow",
|
||||||
|
"MemoryUserMemoryConfigStore",
|
||||||
|
"SqlUserMemoryConfigRepository",
|
||||||
|
"make_user_memory_config_store",
|
||||||
|
]
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
"""Abstract persistence API for per-user memory config overrides."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class UserMemoryConfigStore(ABC):
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_recall_budget(self, user_id: str) -> str | None:
|
||||||
|
"""Return the user's recall_budget override, or None if not set."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def set_recall_budget(self, user_id: str, value: str) -> None:
|
||||||
|
"""Upsert the user's recall_budget override."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, user_id: str) -> None:
|
||||||
|
"""Remove all per-user overrides for the given user."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def load_all(self) -> dict[str, str]:
|
||||||
|
"""Bulk-load all user recall_budget overrides.
|
||||||
|
|
||||||
|
Returns {user_id: recall_budget} for all rows where recall_budget IS NOT NULL.
|
||||||
|
Called once at startup to warm the in-memory cache.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
"""In-memory user memory config store (used when database.backend=memory)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.persistence.memory_config.base import UserMemoryConfigStore
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryUserMemoryConfigStore(UserMemoryConfigStore):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._data: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def get_recall_budget(self, user_id: str) -> str | None:
|
||||||
|
return self._data.get(user_id)
|
||||||
|
|
||||||
|
async def set_recall_budget(self, user_id: str, value: str) -> None:
|
||||||
|
self._data[user_id] = value
|
||||||
|
|
||||||
|
async def delete(self, user_id: str) -> None:
|
||||||
|
self._data.pop(user_id, None)
|
||||||
|
|
||||||
|
async def load_all(self) -> dict[str, str]:
|
||||||
|
return dict(self._data)
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
"""ORM model for per-user memory configuration overrides."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class UserMemoryConfigRow(Base):
|
||||||
|
__tablename__ = "user_memory_configs"
|
||||||
|
|
||||||
|
# One row per user; upserted on write.
|
||||||
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
|
||||||
|
# The only user-writable field. NULL means "use global default from config.yaml".
|
||||||
|
# Allowed values: "low" | "mid" | "high".
|
||||||
|
recall_budget: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
BeijingDateTime(),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
"""SQLAlchemy-backed user memory config repository."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from deerflow.persistence.memory_config.base import UserMemoryConfigStore
|
||||||
|
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||||
|
|
||||||
|
|
||||||
|
class SqlUserMemoryConfigRepository(UserMemoryConfigStore):
|
||||||
|
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._sf = session_factory
|
||||||
|
|
||||||
|
async def get_recall_budget(self, user_id: str) -> str | None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(UserMemoryConfigRow, user_id)
|
||||||
|
return row.recall_budget if row is not None else None
|
||||||
|
|
||||||
|
async def set_recall_budget(self, user_id: str, value: str) -> None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(UserMemoryConfigRow, user_id)
|
||||||
|
if row is None:
|
||||||
|
row = UserMemoryConfigRow(
|
||||||
|
user_id=user_id,
|
||||||
|
recall_budget=value,
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
else:
|
||||||
|
row.recall_budget = value
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def delete(self, user_id: str) -> None:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(UserMemoryConfigRow, user_id)
|
||||||
|
if row is not None:
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def load_all(self) -> dict[str, str]:
|
||||||
|
stmt = select(UserMemoryConfigRow).where(UserMemoryConfigRow.recall_budget.is_not(None))
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return {row.user_id: row.recall_budget for row in result.scalars() if row.recall_budget is not None}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
"""Create user_memory_configs table.
|
||||||
|
|
||||||
|
Replaces the per-user file-based memory config override
|
||||||
|
({base_dir}/users/{user_id}/memory_config.json) with a DB row.
|
||||||
|
Stores only hindsight.recall_budget — the single user-writable field
|
||||||
|
after the memory config simplification.
|
||||||
|
|
||||||
|
Revision ID: 20260521_01
|
||||||
|
Revises: 20260519_02
|
||||||
|
Create Date: 2026-05-21
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260521_01"
|
||||||
|
down_revision = "20260519_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
inspector = inspect(op.get_bind())
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _has_table("user_memory_configs"):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"user_memory_configs",
|
||||||
|
sa.Column("user_id", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("recall_budget", sa.String(16), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_table("user_memory_configs"):
|
||||||
|
op.drop_table("user_memory_configs")
|
||||||
+2
@@ -16,6 +16,7 @@ there is no matching entity directory.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from deerflow.persistence.agents.model import AgentRow
|
from deerflow.persistence.agents.model import AgentRow
|
||||||
|
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||||
from deerflow.persistence.feedback.model import FeedbackRow
|
from deerflow.persistence.feedback.model import FeedbackRow
|
||||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
@@ -55,5 +56,6 @@ __all__ = [
|
|||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"ThreadShareRow",
|
"ThreadShareRow",
|
||||||
"ToolCallMetricRow",
|
"ToolCallMetricRow",
|
||||||
|
"UserMemoryConfigRow",
|
||||||
"UserRow",
|
"UserRow",
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user