feat:接入mongodb
This commit is contained in:
@@ -59,7 +59,21 @@ class Settings:
|
||||
default_factory=lambda: int(os.getenv("CONVERSATION_HISTORY_TURNS", "6"))
|
||||
)
|
||||
|
||||
# MongoDB 连接串,未配置时会话记录和检查点回退为进程内存实现
|
||||
mongodb_uri: str | None = field(
|
||||
default_factory=lambda: os.getenv("MONGODB_URI") or None, repr=False
|
||||
)
|
||||
mongodb_db: str = field(
|
||||
default_factory=lambda: os.getenv("MONGODB_DB") or "screen_agent"
|
||||
)
|
||||
# 检查点保留时长(秒),超时由 MongoDB TTL 索引清理,0 表示永久保留
|
||||
mongodb_checkpoint_ttl: int = field(
|
||||
default_factory=lambda: int(os.getenv("MONGODB_CHECKPOINT_TTL", "604800"))
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.mongodb_checkpoint_ttl < 0:
|
||||
raise ValueError("MONGODB_CHECKPOINT_TTL 必须是非负整数")
|
||||
if self.conversation_empty_ttl <= 0:
|
||||
raise ValueError("CONVERSATION_EMPTY_TTL 必须是正整数")
|
||||
if self.conversation_history_turns < 0:
|
||||
|
||||
+16
-11
@@ -19,32 +19,37 @@ async def lifespan(application: FastAPI):
|
||||
from app.observability.tracing import flush_tracing
|
||||
from app.processor.graph import build_graph
|
||||
from app.services.agent import AgentService
|
||||
from app.services.persistence import open_persistence
|
||||
|
||||
from app.services.conversation import InMemoryConversationStore
|
||||
|
||||
# TODO 接入 MongoDB 后替换为持久化实现,并同步替换图的 checkpointer
|
||||
store = InMemoryConversationStore()
|
||||
application.state.conversation_store = store
|
||||
application.state.agent_service = AgentService(build_graph(), store)
|
||||
# 按配置选择 MongoDB 或进程内存,会话记录与图检查点使用同一种存储
|
||||
persistence = await open_persistence()
|
||||
application.state.conversation_store = persistence.store
|
||||
application.state.agent_service = AgentService(
|
||||
build_graph(persistence.checkpointer), persistence.store
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 共享客户端只在应用退出时关闭,追踪刷新放到线程避免阻塞事件循环
|
||||
try:
|
||||
await close_decision_engine()
|
||||
await persistence.close()
|
||||
finally:
|
||||
try:
|
||||
await asyncio.to_thread(flush_tracing)
|
||||
await close_decision_engine()
|
||||
finally:
|
||||
await logger.complete()
|
||||
try:
|
||||
await asyncio.to_thread(flush_tracing)
|
||||
finally:
|
||||
await logger.complete()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
application = FastAPI(title="Screen Agent API", lifespan=lifespan)
|
||||
# 统一处理请求校验、HTTP 错误和未捕获异常
|
||||
register_exception_handlers(application)
|
||||
application.include_router(router)
|
||||
application.include_router(conversation_router)
|
||||
# 统一 /api 前缀,方便前端代理和反向代理只配置一条规则
|
||||
application.include_router(router, prefix="/api")
|
||||
application.include_router(conversation_router, prefix="/api")
|
||||
return application
|
||||
|
||||
|
||||
|
||||
+28
-26
@@ -23,6 +23,32 @@ from app.processor.nodes.wait_for_user_node import WaitForUserNode
|
||||
from app.processor.state import QueryState
|
||||
|
||||
|
||||
def build_checkpoint_serde() -> JsonPlusSerializer:
|
||||
# 允许恢复状态中使用的业务模型类型,使检查点读取后仍可按模型对象访问字段
|
||||
# 内存与持久化检查点共用,否则持久化读回的状态无法还原为模型对象
|
||||
return JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[
|
||||
("app.schemas.input_context", "InputContext"),
|
||||
("app.schemas.decision", "DecisionResponse"),
|
||||
("app.schemas.requirement", "Requirement"),
|
||||
("app.schemas.change_scope", "ChangeScope"),
|
||||
("app.schemas.screen_plan", "ScreenPlan"),
|
||||
("app.schemas.component_resource", "ComponentResult"),
|
||||
("app.schemas.actions.add", "AddAction"),
|
||||
("app.schemas.actions.delete", "DeleteAction"),
|
||||
("app.schemas.actions.move", "MoveAction"),
|
||||
("app.schemas.actions.resize", "ResizeAction"),
|
||||
("app.schemas.actions.update", "UpdateAction"),
|
||||
("app.schemas.actions.update_canvas", "UpdateCanvasAction"),
|
||||
("app.schemas.action_validation", "ActionValidationError"),
|
||||
("app.schemas.action_repair", "ActionRepairPlan"),
|
||||
("app.schemas.screen", "Screen"),
|
||||
("app.schemas.clarification", "Clarification"),
|
||||
("app.schemas.clarification_reply", "ClarificationReply"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def route_after_intent(state: QueryState) -> str:
|
||||
# 按最终意图分流:问答进入 QA,意图不明进入澄清,新建或修改进入需求理解
|
||||
return state["intent"]
|
||||
@@ -222,34 +248,10 @@ def build_graph(checkpointer=None):
|
||||
{"input_node": "input_node", END: END},
|
||||
)
|
||||
|
||||
# TODO 本地默认保存在内存,可传入持久化 checkpointer
|
||||
# 仅在调用方未提供检查点存储时创建默认实现,外部传入的存储直接复用
|
||||
# 仅在调用方未提供检查点存储时创建默认实现,外部传入的持久化存储(如 MongoDB)直接复用
|
||||
if checkpointer is None:
|
||||
# 允许恢复状态中使用的业务模型类型,使检查点读取后仍可按模型对象访问字段
|
||||
serde = JsonPlusSerializer(
|
||||
allowed_msgpack_modules=[
|
||||
("app.schemas.input_context", "InputContext"),
|
||||
("app.schemas.decision", "DecisionResponse"),
|
||||
("app.schemas.requirement", "Requirement"),
|
||||
("app.schemas.change_scope", "ChangeScope"),
|
||||
("app.schemas.screen_plan", "ScreenPlan"),
|
||||
("app.schemas.component_resource", "ComponentResult"),
|
||||
("app.schemas.actions.add", "AddAction"),
|
||||
("app.schemas.actions.delete", "DeleteAction"),
|
||||
("app.schemas.actions.move", "MoveAction"),
|
||||
("app.schemas.actions.resize", "ResizeAction"),
|
||||
("app.schemas.actions.update", "UpdateAction"),
|
||||
("app.schemas.actions.update_canvas", "UpdateCanvasAction"),
|
||||
("app.schemas.action_validation", "ActionValidationError"),
|
||||
("app.schemas.action_repair", "ActionRepairPlan"),
|
||||
("app.schemas.screen", "Screen"),
|
||||
("app.schemas.clarification", "Clarification"),
|
||||
("app.schemas.clarification_reply", "ClarificationReply"),
|
||||
]
|
||||
)
|
||||
|
||||
# 将检查点保存在当前进程内存中,支持暂停恢复,但进程退出后不会保留
|
||||
checkpointer = InMemorySaver(serde=serde)
|
||||
checkpointer = InMemorySaver(serde=build_checkpoint_serde())
|
||||
|
||||
# 编译节点和边,并绑定检查点存储,供执行时按 thread_id 保存和恢复状态
|
||||
return workflow.compile(checkpointer=checkpointer).with_config(
|
||||
|
||||
@@ -69,6 +69,13 @@ class PlannerNode(BaseNode):
|
||||
feedback = f"上一次输出:\n{plan.model_dump_json()}\n\n{feedback}"
|
||||
messages = [*messages, HumanMessage(content=feedback)]
|
||||
|
||||
# 组件目录已在进入规划前完整获取,component 类资源需求不可能再有补充来源
|
||||
# 保留会让动作阶段误判为“待获取资源”而失败,能力缺口应由 assumptions 表达
|
||||
# TODO 接入数据源和 RAG 后,仅保留目录之外的资源需求
|
||||
plan.resource_needs = [
|
||||
need for need in plan.resource_needs if need.kind != "component"
|
||||
]
|
||||
|
||||
# 合并需求约束和规划约束,按首次出现顺序去重,保留需求约束在前
|
||||
plan.constraints = list(
|
||||
dict.fromkeys([*requirement.constraints, *plan.constraints])
|
||||
|
||||
@@ -13,11 +13,12 @@ canvas_changes 只填写需求范围内要修改的画布顶层字段及最终
|
||||
remove 仅用于需求明确指定的删除目标,不能因为重新布局而擅自移除组件
|
||||
布局未确定时写清排列关系;提供 layout 时表示最终的画布绝对坐标和尺寸,单位 px
|
||||
提供 component_resources 时,新增组件从目录选择真实 type;默认宽高仅作布局参考
|
||||
组件摘要用于选型,不代表已知完整属性配置或取得业务数据
|
||||
组件摘要用于选型;组件的属性配置和数据绑定由后续动作阶段处理,规划阶段不需要确认
|
||||
|
||||
## 资源与假设
|
||||
结合 change_scope 的资源需求,将仍缺少的资源及用途写入 resource_needs
|
||||
现有物料无法满足的能力也列入资源需求,不编造组件类型、配置能力或数据
|
||||
resource_needs 仅用于组件目录之外的资源,如业务数据、模板、素材;不要为组件的配置、属性或数据绑定方式写 resource_needs
|
||||
用户未指定的数据源、位置、尺寸和标题采用合理默认,写入 assumptions,不作为资源需求
|
||||
不编造目录中不存在的组件类型或数据
|
||||
非阻塞的设计偏好可采用合理假设,写入 assumptions
|
||||
|
||||
## 输出边界
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""会话存储:创建时即占位,用于校验 ID 由后端签发;当前为进程内存实现"""
|
||||
"""会话存储:创建时即占位,用于校验 ID 由后端签发;此处为协议和进程内存实现,MongoDB 实现见 conversation_mongo"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Protocol
|
||||
@@ -9,7 +9,7 @@ from app.schemas.conversation import Turn
|
||||
|
||||
|
||||
class ConversationStore(Protocol):
|
||||
"""后续接入 MongoDB 时实现同一组方法,业务代码不需要改动"""
|
||||
"""内存和 MongoDB 实现同一组方法,业务代码不需要关心具体存储"""
|
||||
|
||||
async def create(self) -> str: ...
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""会话存储的 MongoDB 实现,方法语义与内存实现一致"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from bson.codec_options import CodecOptions
|
||||
from pymongo import ASCENDING, DESCENDING
|
||||
from pymongo.asynchronous.database import AsyncDatabase
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.conversation import Turn
|
||||
|
||||
# 单次清理的空会话上限,避免积压时一次请求扫描过多
|
||||
PURGE_BATCH = 200
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class MongoConversationStore:
|
||||
def __init__(
|
||||
self, db: AsyncDatabase, empty_ttl: int | None = None, prefix: str = ""
|
||||
):
|
||||
ttl = empty_ttl if empty_ttl is not None else settings.conversation_empty_ttl
|
||||
self._empty_ttl = timedelta(seconds=ttl)
|
||||
# 读回的时间统一带 UTC 时区,与 Turn 的时间保持一致;集合名前缀用于测试隔离
|
||||
options = CodecOptions(tz_aware=True, tzinfo=UTC)
|
||||
self._conversations = db.get_collection(
|
||||
f"{prefix}conversations", codec_options=options
|
||||
)
|
||||
self._turns = db.get_collection(f"{prefix}turns", codec_options=options)
|
||||
|
||||
async def ensure_indexes(self) -> None:
|
||||
# 创建索引是幂等的,启动时调用一次即可
|
||||
# 同一任务只有一轮,恢复后的重复写入靠唯一索引落到同一条记录
|
||||
await self._turns.create_index(
|
||||
[("conversation_id", ASCENDING), ("thread_id", ASCENDING)], unique=True
|
||||
)
|
||||
await self._turns.create_index(
|
||||
[("conversation_id", ASCENDING), ("created_at", ASCENDING)]
|
||||
)
|
||||
# 空会话清理按创建时间扫描
|
||||
await self._conversations.create_index("created_at")
|
||||
|
||||
async def _purge_expired(self) -> None:
|
||||
# 只清理没有任何轮次且超过保留时长的空会话,有对话的会话不受影响
|
||||
deadline = _now() - self._empty_ttl
|
||||
expired = await (
|
||||
self._conversations.find({"created_at": {"$lt": deadline}}, {"_id": 1})
|
||||
.limit(PURGE_BATCH)
|
||||
.to_list()
|
||||
)
|
||||
ids = [item["_id"] for item in expired]
|
||||
if not ids:
|
||||
return
|
||||
used = set(
|
||||
await self._turns.distinct(
|
||||
"conversation_id", {"conversation_id": {"$in": ids}}
|
||||
)
|
||||
)
|
||||
empty = [item for item in ids if item not in used]
|
||||
if empty:
|
||||
await self._conversations.delete_many({"_id": {"$in": empty}})
|
||||
|
||||
async def create(self) -> str:
|
||||
await self._purge_expired()
|
||||
conversation_id = str(uuid4())
|
||||
# user_id 为后续长期记忆预留,当前恒为空
|
||||
await self._conversations.insert_one(
|
||||
{"_id": conversation_id, "created_at": _now(), "user_id": None}
|
||||
)
|
||||
return conversation_id
|
||||
|
||||
async def exists(self, conversation_id: str) -> bool:
|
||||
document = await self._conversations.find_one({"_id": conversation_id})
|
||||
if document is None:
|
||||
return False
|
||||
if document["created_at"] >= _now() - self._empty_ttl:
|
||||
return True
|
||||
# 超过空会话保留时长后,只有已产生对话的会话仍视为存在
|
||||
return (
|
||||
await self._turns.find_one({"conversation_id": conversation_id}, {"_id": 1})
|
||||
is not None
|
||||
)
|
||||
|
||||
async def load_turns(self, conversation_id: str, limit: int | None) -> list[Turn]:
|
||||
if limit == 0:
|
||||
return []
|
||||
cursor = self._turns.find({"conversation_id": conversation_id})
|
||||
if limit is None:
|
||||
documents = await cursor.sort(
|
||||
[("created_at", ASCENDING), ("_id", ASCENDING)]
|
||||
).to_list()
|
||||
else:
|
||||
# 倒序取最近 limit 轮,再反转为时间正序
|
||||
documents = await (
|
||||
cursor.sort([("created_at", DESCENDING), ("_id", DESCENDING)])
|
||||
.limit(limit)
|
||||
.to_list()
|
||||
)
|
||||
documents.reverse()
|
||||
return [
|
||||
Turn.model_validate({k: v for k, v in item.items() if k != "_id"})
|
||||
for item in documents
|
||||
]
|
||||
|
||||
async def save_turn(self, turn: Turn) -> None:
|
||||
data = turn.model_dump()
|
||||
created_at = data.pop("created_at")
|
||||
# 同一任务再次写入(澄清恢复后)保留首次创建时间,只更新内容
|
||||
await self._turns.update_one(
|
||||
{"conversation_id": turn.conversation_id, "thread_id": turn.thread_id},
|
||||
{"$set": data, "$setOnInsert": {"created_at": created_at}},
|
||||
upsert=True,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""持久化装配:按配置选择 MongoDB 或进程内存,统一返回会话存储、检查点和关闭函数"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from loguru import logger
|
||||
|
||||
from app.config import settings
|
||||
from app.services.conversation import ConversationStore, InMemoryConversationStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class Persistence:
|
||||
store: ConversationStore
|
||||
# 为空时由图使用默认的内存检查点
|
||||
checkpointer: BaseCheckpointSaver | None
|
||||
close: Callable[[], Awaitable[None]]
|
||||
|
||||
|
||||
async def _close_nothing() -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def open_persistence() -> Persistence:
|
||||
if not settings.mongodb_uri:
|
||||
logger.warning(
|
||||
"未配置 MONGODB_URI,会话记录和检查点仅保存在进程内存,重启后丢失"
|
||||
)
|
||||
return Persistence(InMemoryConversationStore(), None, _close_nothing)
|
||||
|
||||
# 依赖较重且只在启用 MongoDB 时使用,放到函数内导入
|
||||
from langgraph.checkpoint.mongodb import MongoDBSaver
|
||||
from pymongo import AsyncMongoClient, MongoClient
|
||||
|
||||
from app.processor.graph import build_checkpoint_serde
|
||||
from app.services.conversation_mongo import MongoConversationStore
|
||||
|
||||
# 会话记录使用异步客户端;官方检查点基于同步客户端,异步方法由其放入线程池执行
|
||||
async_client = AsyncMongoClient(settings.mongodb_uri, serverSelectionTimeoutMS=5000)
|
||||
sync_client = MongoClient(settings.mongodb_uri, serverSelectionTimeoutMS=5000)
|
||||
|
||||
async def close() -> None:
|
||||
try:
|
||||
await async_client.close()
|
||||
finally:
|
||||
sync_client.close()
|
||||
|
||||
try:
|
||||
# 启动时探测连接,配置错误尽早暴露;异常信息不包含连接串,避免泄露账号密码
|
||||
try:
|
||||
await async_client.admin.command("ping")
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"无法连接 MongoDB:{type(exc).__name__}") from None
|
||||
|
||||
store = MongoConversationStore(async_client[settings.mongodb_db])
|
||||
await store.ensure_indexes()
|
||||
# 与内存检查点共用同一份类型白名单,保证读回后仍是业务模型对象
|
||||
checkpointer = MongoDBSaver(
|
||||
sync_client,
|
||||
settings.mongodb_db,
|
||||
ttl=settings.mongodb_checkpoint_ttl or None,
|
||||
serde=build_checkpoint_serde(),
|
||||
)
|
||||
except BaseException:
|
||||
await close()
|
||||
raise
|
||||
|
||||
logger.info("已启用 MongoDB 持久化,库名 {}", settings.mongodb_db)
|
||||
return Persistence(store, checkpointer, close)
|
||||
Reference in New Issue
Block a user