From 7042d428fa3f09221ca4172183cc842f188c2fda Mon Sep 17 00:00:00 2001 From: zcr Date: Wed, 11 Mar 2026 21:45:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=83=E7=94=A8langgrpah=E6=9B=B4=E6=8D=A2de?= =?UTF-8?q?epagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 9 +- src/db/__init__.py | 0 src/db/init_mongodb.py | 49 +++ src/db/mongo.py | 17 + src/routers/chat.py | 219 +++++----- src/routers/deep_agent_chat.py | 381 ++++++++++++++++++ src/server/agent/agents.py | 169 -------- src/server/agent/agents/designer.py | 19 + src/server/agent/agents/init_llm.py | 93 +++++ src/server/agent/agents/persona.py | 154 +++++++ src/server/agent/agents/researcher.py | 88 ++++ src/server/agent/agents/suggester.py | 29 ++ src/server/agent/agents/summary.py | 30 ++ src/server/agent/agents/supervisor.py | 87 ++++ src/server/agent/agents/visualizer.py | 33 ++ src/server/agent/graph.py | 109 ++--- src/server/agent/memory/memory_manager.py | 60 +++ src/server/agent/prompt.py | 110 +++-- src/server/agent/run_test.py | 5 +- src/server/agent/state.py | 6 +- src/server/agent/tools/base.py | 20 + src/server/agent/tools/crawl_tool.py | 249 ++++++++---- .../agent/tools/generate_furniture_sketch.py | 31 +- .../agent/tools/report_generator_tool.py | 4 +- src/server/agent/tools/research_tool.py | 2 +- src/server/agent/tools/terminate_tool.py | 25 +- src/server/agent/tools/user_persona_tool.py | 124 ++---- src/server/deep_agent/__init__.py | 0 src/server/deep_agent/agents/init_llm.py | 23 ++ src/server/deep_agent/agents/main_agent.py | 51 +++ src/server/deep_agent/agents/painter.py | 22 + src/server/deep_agent/agents/researcher.py | 21 + src/server/deep_agent/agents/user_profile.py | 15 + src/server/deep_agent/init_prompt.py | 141 +++++++ src/server/deep_agent/run_test.py | 131 ++++++ .../tools/conversation_title_tool.py | 27 ++ src/server/deep_agent/tools/crawl_tool.py | 191 +++++++++ .../tools/generate_furniture_sketch.py | 94 +++++ .../deep_agent/tools/report_generator_tool.py | 151 +++++++ src/server/deep_agent/tools/research_tool.py | 67 +++ .../tools/structured_retrieval_tool.py | 225 +++++++++++ .../deep_agent/tools/user_persona_tool.py | 57 +++ src/server/utils/new_oss_client.py | 2 +- uv.lock | 126 +++++- 44 files changed, 2847 insertions(+), 619 deletions(-) create mode 100644 src/db/__init__.py create mode 100644 src/db/init_mongodb.py create mode 100644 src/db/mongo.py create mode 100644 src/routers/deep_agent_chat.py delete mode 100644 src/server/agent/agents.py create mode 100644 src/server/agent/agents/designer.py create mode 100644 src/server/agent/agents/init_llm.py create mode 100644 src/server/agent/agents/persona.py create mode 100644 src/server/agent/agents/researcher.py create mode 100644 src/server/agent/agents/suggester.py create mode 100644 src/server/agent/agents/summary.py create mode 100644 src/server/agent/agents/supervisor.py create mode 100644 src/server/agent/agents/visualizer.py create mode 100644 src/server/agent/memory/memory_manager.py create mode 100644 src/server/agent/tools/base.py create mode 100644 src/server/deep_agent/__init__.py create mode 100644 src/server/deep_agent/agents/init_llm.py create mode 100644 src/server/deep_agent/agents/main_agent.py create mode 100644 src/server/deep_agent/agents/painter.py create mode 100644 src/server/deep_agent/agents/researcher.py create mode 100644 src/server/deep_agent/agents/user_profile.py create mode 100644 src/server/deep_agent/init_prompt.py create mode 100644 src/server/deep_agent/run_test.py create mode 100644 src/server/deep_agent/tools/conversation_title_tool.py create mode 100644 src/server/deep_agent/tools/crawl_tool.py create mode 100644 src/server/deep_agent/tools/generate_furniture_sketch.py create mode 100644 src/server/deep_agent/tools/report_generator_tool.py create mode 100644 src/server/deep_agent/tools/research_tool.py create mode 100644 src/server/deep_agent/tools/structured_retrieval_tool.py create mode 100644 src/server/deep_agent/tools/user_persona_tool.py diff --git a/pyproject.toml b/pyproject.toml index fa0f4aa..f774018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "langchain-community>=0.4.1", "langchain-core>=1.2.8", "langchain-google-genai>=4.2.0", - "langgraph[postgres]>=1.0.7", + "langgraph[all,postgres]>=1.0.7", "langgraph-checkpoint-mongodb>=0.3.1", "minio>=7.2.20", "modality>=0.1.0", @@ -41,4 +41,11 @@ dependencies = [ "asyncio>=4.0.0", "requests>=2.32.5", "chardet<6", + "datetime>=6.0", + "agentstate>=1.0.2", + "langchain-classic>=1.0.1", + "langsmith>=0.7.13", + "path>=17.1.1", + "langgraph-checkpoint-postgres>=3.0.4", + "langgraph-store-mongodb>=0.2.0", ] diff --git a/src/db/__init__.py b/src/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/db/init_mongodb.py b/src/db/init_mongodb.py new file mode 100644 index 0000000..6e7d787 --- /dev/null +++ b/src/db/init_mongodb.py @@ -0,0 +1,49 @@ +import asyncio +from motor.motor_asyncio import AsyncIOMotorClient + +from src.core.config import MONGO_URI + +DB_NAME = "fida_mongo" +COLLECTION_NAME = "user_persona" + + +async def init_mongo(): + client = AsyncIOMotorClient( + MONGO_URI, + maxPoolSize=50, + minPoolSize=5, + serverSelectionTimeoutMS=5000 + ) + + db = client[DB_NAME] + + # 查看已有集合 + collections = await db.list_collection_names() + + if COLLECTION_NAME not in collections: + print(f"Creating collection: {COLLECTION_NAME}") + await db.create_collection(COLLECTION_NAME) + + collection = db[COLLECTION_NAME] + + # 创建 thread_id 唯一索引 + print("Creating index: thread_id_unique") + await collection.create_index( + "thread_id", + unique=True, + name="thread_id_unique" + ) + + # 创建 TTL 索引(30天自动删除) + print("Creating TTL index: updated_at_ttl") + await collection.create_index( + "updated_at", + expireAfterSeconds=2592000, # 30天 + name="updated_at_ttl" + ) + + print("MongoDB initialization completed.") + + +if __name__ == "__main__": + asyncio.run(init_mongo()) diff --git a/src/db/mongo.py b/src/db/mongo.py new file mode 100644 index 0000000..07ec315 --- /dev/null +++ b/src/db/mongo.py @@ -0,0 +1,17 @@ +from motor.motor_asyncio import AsyncIOMotorClient + +from src.core.config import MONGO_URI + +client = AsyncIOMotorClient( + MONGO_URI, + maxPoolSize=50, + minPoolSize=5, + serverSelectionTimeoutMS=5000 +) + +db = client["fida_mongo"] + +user_persona_collection = db["user_persona"] + + + diff --git a/src/routers/chat.py b/src/routers/chat.py index e4a72bf..231848e 100644 --- a/src/routers/chat.py +++ b/src/routers/chat.py @@ -6,7 +6,7 @@ from typing import AsyncGenerator from fastapi import APIRouter from fastapi.responses import StreamingResponse from src.schemas.chat import ChatRequest, HistoryResponse, HistoryItem -from src.server.agent.graph import app # 导入已经 compile 好的 graph +from src.server.agent.graph import app from langchain_core.messages import HumanMessage, SystemMessage, AIMessageChunk, ToolMessage, AIMessage router = APIRouter(prefix="/chat", tags=["Furniture Design Chat"]) @@ -169,10 +169,8 @@ async def chat_stream(request: ChatRequest): await app.aupdate_state(current_config, combined_values) async def event_generator() -> AsyncGenerator[str, None]: - # 初始事件 yield f"data: {json.dumps({'thread_id': target_thread_id, 'is_branch': is_branching, 'status': 'start'}, ensure_ascii=False)}\n\n" - # 構造輸入(保持不變) new_messages = initial_messages[:] if not source_thread_id else [] new_messages.append(HumanMessage(content=request.message)) @@ -182,124 +180,143 @@ async def chat_stream(request: ChatRequest): "use_report": request.use_report, } - # ─── 重點改這裡 ─────────────────────────────────────── + interrupted = False + + current_cp_id = None + # try: async for event in app.astream( input_data, config=current_config, - stream_mode=["custom", "updates", "messages"], # 推薦組合 + stream_mode=["updates", "messages", "custom"], # 确保包含 "values" subgraphs=True - # 不再需要,行為已包含 ): - logger.info(event) - # 取得 checkpoint_id(可選,視前端是否真的需要) - latest_state = await app.aget_state(current_config) - configurable = latest_state.config.get("configurable", {}) - current_cp_id = configurable.get("checkpoint_id", "") - if len(event) == 3: - namespace, channel, payload = event - # 路由更新 - if event[1] == "updates": - namespace, _, payload = event + if interrupted: + break - if isinstance(payload, dict): - for update_node, update_content in payload.items(): + logger.info(f"Received event: {event}") - # 处理 reducer(Overwrite / Append) - if isinstance(update_content, dict): - for k, v in update_content.items(): - if hasattr(v, "value"): # Overwrite(...) - update_content[k] = v.value + if not isinstance(event, tuple) or len(event) != 3: + continue - if isinstance(update_content, dict) and "messages" in update_content: - msgs = [] - for m in update_content["messages"]: - msgs.append({ - "type": m.__class__.__name__, - "content": getattr(m, "content", ""), - "name": getattr(m, "name", None), - "tool_calls": getattr(m, "tool_calls", None), - }) - update_content["messages"] = msgs + run_id, channel, payload = event - yield f"data: {json.dumps({ - "node": "Supervisor", - "type": "updates", - "content": update_content, - "is_delta": False, - "checkpoint_id": current_cp_id, - }, ensure_ascii=False)}\n\n" + # ─────────────── 检测 interrupt ─────────────── + # __interrupt__ 最常出现在 "values" 或 "updates" channel 的 payload 中 + if channel in ("values", "updates") and isinstance(payload, dict) and "__interrupt__" in payload: + interrupt_data = payload["__interrupt__"][0].value['__interrupt__'] + interrupted = True + yield f"data: {json.dumps({ + "type": "interrupt", + "node": interrupt_data.get("node", "Persona"), + "question": interrupt_data.get('question', "异常|||||||||||||"), + "current_persona": interrupt_data.get("current_persona_snapshot", {}), + "status": "waiting_for_input" + }, ensure_ascii=False)}\n\n" - # 自定义事件 - elif event[1] == "custom": - if isinstance(payload, dict) and payload.get("type") in ("report_delta", "report_start", "report_error", "report_save_warning", "report_complete"): - delta = payload.get("delta", "").strip() - if delta: - yield f"data: {json.dumps({ - 'node': 'Researcher', - 'type': 'report_delta', - 'content': delta, - 'is_delta': True, - 'checkpoint_id': current_cp_id, - }, ensure_ascii=False)}\n\n" - # 基础消息 - elif event[1] == "messages": - if namespace: - node_name = namespace[-1] if isinstance(namespace, tuple) else namespace - if ':' in node_name: - node_name = node_name.split(':')[0] - else: - node_name = "Main" - message, metadata = payload - is_not_research = node_name != 'Researcher' - node_name = metadata.get("langgraph_node", node_name) - # 3. 处理不同类型的 message - payload_out = { - "node": node_name, - "checkpoint_id": current_cp_id, # 你之前已经获取了 - "is_delta": False, - "content": "", - "suggestions": [], - "type": "unknown" - } + # 立即停止后续发送,等待用户回复后 resume + break - if isinstance(message, AIMessageChunk): - # 节点不是research 并且 tool_call_chunks不为空的情况下,避免research的report工具使用custom发出的消息和message的消息重复了 - if is_not_research and node_name != 'Researcher' and message.tool_call_chunks: - payload_out.update({ - "type": "delta", - "is_delta": True, - "content": message.content, - # 如果有 tool call chunk,也可以在这里处理 - "tool_call_chunk": message.tool_call_chunks[0] if message.tool_call_chunks else None - }) - yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" - elif isinstance(message, ToolMessage): - # 工具执行结果(完整的一次性输出) + # ─────────────── 正常消息处理 ─────────────── + if channel == "messages": + if run_id: + node_name = run_id[-1] if isinstance(run_id, tuple) else run_id + if ':' in node_name: + node_name = node_name.split(':')[0] + else: + node_name = "Main" + + message, metadata = payload + node_name = metadata.get("langgraph_node", node_name) + + payload_out = { + "node": node_name, + "checkpoint_id": current_cp_id or "unknown", + "is_delta": False, + "content": "", + "suggestions": [], + "type": "unknown" + } + + if isinstance(message, AIMessageChunk): + if node_name != 'Researcher' and message.tool_call_chunks: + payload_out.update({ + "type": "delta", + "is_delta": True, + "content": message.content, + "tool_call_chunk": message.tool_call_chunks[0] if message.tool_call_chunks else None + }) + yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + + elif isinstance(message, ToolMessage): + try: + tools_data = json.loads(message.content) payload_out.update({ "type": "tool_result", "is_delta": False, - "content": message.content, - "tool_name": message.name, - "tool_call_id": message.tool_call_id + "content": tools_data.get("data", ""), + "tool_name": tools_data.get("tool_name", ""), }) yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + except: + pass - elif isinstance(message, AIMessage): - # 完整 AIMessage(不常见在 messages 模式下,但以防万一) - payload_out.update({ - "type": "complete_message", + elif isinstance(message, AIMessage): + payload_out.update({ + "type": "complete_message", + "is_delta": False, + "content": message.content + }) + yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + + elif channel == "updates": + # 处理 updates(非 interrupt 的部分) + if isinstance(payload, dict): + for update_node, update_content in payload.items(): + # 处理 reducer 包裹的值 + if isinstance(update_content, dict): + for k, v in update_content.items(): + if hasattr(v, "value"): + update_content[k] = v.value + + # 序列化 messages + if isinstance(update_content, dict) and "messages" in update_content: + msgs = [] + for m in update_content["messages"]: + msgs.append({ + "type": m.__class__.__name__, + "content": getattr(m, "content", ""), + "name": getattr(m, "name", None), + "tool_calls": getattr(m, "tool_calls", None), + }) + update_content["messages"] = msgs + + yield f"data: {json.dumps({ + "node": "Supervisor", # 或 update_node + "type": "updates", + "content": update_content, "is_delta": False, - "content": message.content - }) - yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + "checkpoint_id": current_cp_id, + }, ensure_ascii=False)}\n\n" - else: - # 其他未知类型,记录日志 - print(f"未知消息类型: {type(message)}", message) - continue + elif channel == "custom": + if isinstance(payload, dict) and payload.get("type") in ("report_delta", "report_start", ...): + delta = payload.get("delta", "").strip() + if delta: + yield f"data: {json.dumps({ + 'node': 'Researcher', + 'type': 'report_delta', + 'content': delta, + 'is_delta': True, + 'checkpoint_id': current_cp_id, + }, ensure_ascii=False)}\n\n" + # except Exception as e: + # print("error") - # 流結束 - yield f"data: {json.dumps({'status': 'end'}, ensure_ascii=False)}\n\n" + # 结束标记 + if interrupted: + yield f"data: {json.dumps({'status': 'interrupted', 'reason': 'waiting_for_user_input'})}\n\n" + else: + yield f"data: {json.dumps({'status': 'end'}, ensure_ascii=False)}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") diff --git a/src/routers/deep_agent_chat.py b/src/routers/deep_agent_chat.py new file mode 100644 index 0000000..439182a --- /dev/null +++ b/src/routers/deep_agent_chat.py @@ -0,0 +1,381 @@ +import logging +import uuid +import json +from typing import AsyncGenerator + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse +from src.schemas.chat import ChatRequest, HistoryResponse, HistoryItem +from langchain_core.messages import HumanMessage, SystemMessage, AIMessageChunk, ToolMessage, AIMessage, ToolMessageChunk + +from src.server.deep_agent.agents.main_agent import build_main_agent + +router = APIRouter(prefix="/chat", tags=["Furniture Design Chat"]) +logger = logging.getLogger(__name__) + + +@router.post("/deep_agent_stream") +async def chat_stream(request: ChatRequest): + """ + ### 家具设计流式对话接口 (SSE) + + 通过此接口与 AI 家具设计专家团队进行实时沟通。支持 **记忆持久化** 和 **历史回溯分叉**。 + + #### 1. 核心功能 + * **实时反馈**: 采用 Server-Sent Events (SSE) 技术,实时推送主管、设计师、视觉专家等节点的思考过程。 + * **上下文记忆**: 传入 `thread_id` 即可恢复之前的对话进度。 + * **版本分溯**: 传入 `checkpoint_id` 可准确定位到历史中的某一轮,并从该点开启新的设计分支。 + + #### 2. 请求参数 + * `message`: 用户的设计意图(如:'我想设计一个极简风格的橡木办公桌')。 + * `thread_id`: (可选) 现有项目的唯一标识。若不传,系统将自动分配并返回。 + * `checkpoint_id`: (可选) 历史快照 ID。 + * `config_params`: (可选) 对话配置参数 + * `need_suggestion`: (可选) 是否需要建议按钮,需要建议的频率,0-1的浮点数 + * `use_report`: (可选) 是否需要使用report功能 true/false + + + #### 3. 响应流说明 (Data Format) + 响应以 `data: ` 开头的 JSON 字符串流形式发送: + - **Session Start**: `{"thread_id": "...", "status": "start"}` + - **Node Message**: `{"node": "Designer", "content": "...", "checkpoint_id": "..."}` + - **Session End**: `{"status": "end"}` + + - **is_delta**: False/True,表示这个消息不是完整内容,只是 AI 正在生成的一小段内容(一个字、一个词、一句话),需要前端把这些片段拼接起来才能得到完整的回答。 + + #### 4. 请求示例 + ``` + { + "message": "设计一款北欧风格的躺椅." + } + + { + "message": "就以上信息直接生成sketch.", + "thread_id": "187e58af" + } + + { + "message": "不要躺椅,要桌子", + "thread_id": "187e58af", + "checkpoint_id": "1f101aa2-8f24-6e2a-8001-2952c3a7447a" + } + ``` + + ### 5. 响应流说明 + 所有响应均以 data: 开头,JSON 字符串格式,末尾以 \n\n 结束 + 响应流包含三种类型的事件:会话开始、节点消息、会话结束 + 会话开始: + ``` + { + "thread_id": "str", + "is_branch": "boolean", + "status": "start" + } + ``` + 节点消息: + ``` + { + "node": "节点名称(如Designer/Researcher/Main)", + "content": "消息内容", + "checkpoint_id": "快照ID", + "is_delta": "boolean", + "type": "消息类型", + "suggestions": "建议列表(可选)", + "tool_name": "工具名称(可选)", + "tool_call_chunk": "工具调用片段(可选)", + "tool_call_id": "工具调用ID(可选)" + } + + ``` + 报告增量消息: + ``` + { + "node": "Researcher", + "type": "report_delta", + "content": "报告内容增量", + "is_delta": true, + "checkpoint_id": "xxx" + } + ``` + AI 消息片段: + ``` + { + "node": "Designer", + "content": "设计建议内容", + "checkpoint_id": "xxx", + "is_delta": true, + "type": "delta", + "tool_call_chunk": {...} + } + ``` + 工具执行结果: + ``` + { + "node": "ToolExecutor", + "content": "工具执行结果", + "checkpoint_id": "xxx", + "is_delta": false, + "type": "tool_result", + "tool_name": "ImageGenerator", + "tool_call_id": "yyy" + } + ``` + + """ + logger.info(f"chat request data: {request}") + source_thread_id = request.thread_id + checkpoint_id = request.checkpoint_id + + # 构建主agent + main_agent = build_main_agent(request.use_report) + + # 1. 確定目標 thread_id + is_branching = source_thread_id and checkpoint_id + target_thread_id = str(uuid.uuid4())[:8] if is_branching else (source_thread_id or str(uuid.uuid4())[:8]) + + # 2. 配置參數 + temp = request.config_params.temperature if request.config_params else 0.7 + + need_suggestion = request.need_suggestion, + + current_config = { + "recursion_limit": 120, + "configurable": { + "thread_id": target_thread_id, + "llm_temperature": temp, + "use_report": request.use_report, + } + } + + # 3. 初始化消息 + 系統提示 TODO 写入数据库 + initial_messages = [] + if not source_thread_id or is_branching: + if request.config_params: + cp = request.config_params + system_prompt = ( + f"Current furniture design background settings:\n" + f"- type: {cp.type}\n" + f"- space/region: {cp.region}\n" + f"- style tendency: {cp.style}\n" + f"Please strictly follow the above settings in subsequent conversations。" + ) + initial_messages.append(SystemMessage(content=system_prompt)) + + # 4. 處理分支(從歷史 checkpoint 複製狀態) + if is_branching: + source_config = { + "configurable": { + "thread_id": source_thread_id, + "checkpoint_id": checkpoint_id + } + } + older_state = await main_agent.aget_state(source_config) + combined_values = older_state.values.copy() + if initial_messages: + combined_values["messages"] = list(combined_values.get("messages", [])) + initial_messages + await main_agent.aupdate_state(current_config, combined_values) + + async def event_generator() -> AsyncGenerator[str, None]: + yield f"data: {json.dumps({'thread_id': target_thread_id, 'is_branch': is_branching, 'status': 'start'}, ensure_ascii=False)}\n\n" + + new_messages = initial_messages[:] if not source_thread_id else [] + new_messages.append(HumanMessage(content=request.message)) + + input_data = { + "messages": new_messages, + } + current_cp_id = None + async for stream in main_agent.astream( + input_data, + config=current_config, + stream_mode=["updates", "messages", "custom"], # 确保包含 "values" + subgraphs=True + ): + # logger.info(f"Received event: {event}") + _, mode, chunks = stream + if mode == "updates": + # TODO 补充 + print(f"[updates] {chunks}") + + elif mode == "messages": + token, metadata = chunks + subagent_name = metadata.get('lc_agent_name', None) + payload_out = { + "node": subagent_name, + # "checkpoint_id": current_cp_id or "unknown", TODO 替换为checkpoint_idns + "is_delta": False, + "content": "", + "suggestions": [], + "type": "" + } + + if isinstance(token, AIMessageChunk): # 默认回复 思考内容 + reasoning = [b for b in token.content_blocks if b["type"] == "reasoning"] + text = [b for b in token.content_blocks if b["type"] == "text"] + if reasoning: + payload_out.update({ + "type": "reasoning", + "is_delta": True, + "content": text, + "tool_call_chunk": token.tool_call_chunks[0] if token.tool_call_chunks else None + }) + elif text: + payload_out.update({ + "type": "text", + "is_delta": True, + "content": text, + "tool_call_chunk": token.tool_call_chunks[0] if token.tool_call_chunks else None + }) + else: + payload_out.update({ + "is_delta": True, + "tool_call_chunk": token.tool_call_chunks[0] if token.tool_call_chunks else None + }) + yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + elif isinstance(token, ToolMessageChunk): # 工具返回 + text = [b for b in token.content_blocks if b["type"] == "text"] + payload_out.update({ + "type": "tool_text", + "is_delta": False, + "content": text, + "tool_name": token.name, + }) + yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + elif isinstance(token, ToolMessage): # 工具返回 + text = [b for b in token.content_blocks if b["type"] == "text"] + payload_out.update({ + "type": "tool_text", + "is_delta": False, + "content": text, + "tool_name": token.name, + }) + yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + else: + continue + + elif mode == "custom": + token, metadata = chunks + subagent_name = metadata.get('lc_agent_name', None) + payload_out = { + "node": subagent_name, + # "checkpoint_id": current_cp_id or "unknown", TODO 替换为checkpoint_idns + "is_delta": False, + "content": "", + "suggestions": [], + "type": "" + } + delta = chunks.get("delta", "").strip() + if delta: + payload_out.update({ + "type": chunks.get("type", ""), + "is_delta": True, + "content": delta, + }) + yield f"data: {json.dumps(payload_out, ensure_ascii=False)}\n\n" + + + # elif channel == "updates": + # # 处理 updates(非 interrupt 的部分) + # if isinstance(payload, dict): + # for update_node, update_content in payload.items(): + # # 处理 reducer 包裹的值 + # if isinstance(update_content, dict): + # for k, v in update_content.items(): + # if hasattr(v, "value"): + # update_content[k] = v.value + # + # # 序列化 messages + # if isinstance(update_content, dict) and "messages" in update_content: + # msgs = [] + # for m in update_content["messages"]: + # msgs.append({ + # "type": m.__class__.__name__, + # "content": getattr(m, "content", ""), + # "name": getattr(m, "name", None), + # "tool_calls": getattr(m, "tool_calls", None), + # }) + # update_content["messages"] = msgs + # + # yield f"data: {json.dumps({ + # "node": "Supervisor", # 或 update_node + # "type": "updates", + # "content": update_content, + # "is_delta": False, + # "checkpoint_id": current_cp_id, + # }, ensure_ascii=False)}\n\n" + # + # elif channel == "custom": + else: + yield f"data: {json.dumps({'status': 'end'}, ensure_ascii=False)}\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + +@router.get("/history/{thread_id}", response_model=HistoryResponse) +async def get_chat_history(thread_id: str): + """ + ### 获取项目设计历史记录 + + 此接口用于拉取指定 `thread_id` 下的所有历史状态快照。它是实现 **“版本回溯”** 和 **“方案对比”** 的核心数据来源。 + + #### 1. 功能说明 + * **快照列表**: 返回该项目从启动至今的所有关键节点(Checkpoints)。 + * **版本定位**: 每个历史点都包含一个唯一的 `checkpoint_id`。 + * **数据回溯**: 客户端获取此列表后,可以引导用户选择任意一个版本,并将其 `checkpoint_id` 传回 `/chat/stream` 接口以开启新的设计分支。 + + #### 2. 路径参数 + * `thread_id`: 设计项目的唯一标识符(由 `/chat/stream` 首次调用时生成或指定)。 + + #### 3. 返回字段定义 + * `thread_id`: 当前查询的项目ID。 + * `history`: 历史记录数组,包含: + - `checkpoint_id`: 必填,回溯时使用的关键凭证。 + - `last_message`: 该阶段的最后一条消息摘要(方便前端预览)。 + - `node`: 产生该快照的节点名称(如 Designer, Visualizer)。 + - `timestamp`: 逻辑步骤序号。 + + #### 4. 响应示例 + ```json + { + "thread_id": "proj_001", + "history": [ + { + "checkpoint_id": "d82f3a12", + "last_message": "我想设计一款北欧风书架", + "node": "Supervisor", + "timestamp": 1 + }, + { + "checkpoint_id": "f4k92m1a", + "last_message": "建议使用浅色橡木材质,增加简约感...", + "node": "Designer", + "timestamp": 2 + } + ] + } + ``` + """ + config = {"configurable": {"thread_id": thread_id}, } + history_data = [] + async for state in main_agent.aget_state_history(config): + msg_content = "Initial" + if state.values and "messages" in state.values: + msgs = state.values["messages"] + if msgs and len(msgs) > 0: + last_msg = msgs[-1] + # 获取内容并做摘要截断 + content = getattr(last_msg, "content", str(last_msg)) + msg_content = content + + history_data.append(HistoryItem( + checkpoint_id=state.config["configurable"]["checkpoint_id"], + last_message=msg_content, + node=state.metadata.get("source"), + timestamp=state.metadata.get("step") + )) + + return HistoryResponse(thread_id=thread_id, history=history_data) + # try: + + # except Exception as e: + # raise HTTPException(status_code=404, detail=f"History not found: {str(e)}") diff --git a/src/server/agent/agents.py b/src/server/agent/agents.py deleted file mode 100644 index c91740c..0000000 --- a/src/server/agent/agents.py +++ /dev/null @@ -1,169 +0,0 @@ -from pathlib import Path -from typing import AsyncGenerator, Dict, Any -from deepagents import create_deep_agent -from deepagents.backends import FilesystemBackend -from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage, AIMessage, AIMessageChunk -from langchain_core.runnables import RunnableConfig -from langchain_qwq import ChatQwen - -from src.core.config import settings -from src.server.agent.prompt import SYSTEM_PROMPT, visualizer_prompt, designer_prompt -from src.server.agent.state import AgentState -from src.server.agent.tools.generate_furniture_sketch import generate_furniture -from src.server.agent.tools.crawl_tool import crawl4ai_batch -from src.server.agent.tools.report_generator_tool import report_generator -from src.server.agent.tools.research_tool import topic_research -from src.server.agent.tools.structured_retrieval_tool import structured_retrieval -from src.server.agent.tools.terminate_tool import terminate -from src.server.agent.tools.user_persona_tool import manage_user_persona -from src.server.utils.generate_suggestion import generate_chat_suggestions - -# 目前這個主程式檔案所在的目錄 -MAIN_DIR = Path(__file__).resolve().parent - -# 專案根目錄(因為 main.py 跟 tools/ 同級,所以 parent 就是根) -PROJECT_ROOT = MAIN_DIR - -model = ChatQwen( - enable_thinking=False, - model="qwen3.5-flash", - max_tokens=3_000, - timeout=None, - max_retries=2, - api_key=settings.QWEN_API_KEY) - -tools = [manage_user_persona, topic_research, crawl4ai_batch, structured_retrieval, report_generator, terminate] -research_agent = create_deep_agent( - model=model, - tools=tools, - system_prompt=SYSTEM_PROMPT, - backend=FilesystemBackend( - root_dir=str(PROJECT_ROOT / "agent_workspace"), - virtual_mode=False, # 重要:關掉虛擬模式 → 真的寫硬碟 - ) -) - - -# 辅助函数:根据配置动态获取 LLM -def get_model(config: RunnableConfig, streaming=False): - temp = config["configurable"].get("llm_temperature", 0.5) - return ChatQwen( - enable_thinking=False, - model="qwen3.5-flash", - max_tokens=3_000, - timeout=None, - max_retries=2, - temperature=temp, - api_key=settings.QWEN_API_KEY, - streaming=streaming - ) - - -# --- 1. Designer Agent (设计顾问) --- -async def designer_node(state: AgentState, config: RunnableConfig): - """负责细化设计需求,提供专业参数""" - model = get_model(config) # 获取带动态温度的模型 - - messages = state["messages"] - system_prompt = SystemMessage(content=designer_prompt) - should_suggest = len(state["messages"]) % 5 == 0 - response = await model.ainvoke([system_prompt] + messages) - return {"messages": [response], "require_suggestion": should_suggest} - - -async def researcher_node( - state: AgentState, - config: RunnableConfig -) -> AsyncGenerator[Dict[str, Any], None]: - use_report = config["configurable"].get("use_report", False) - if not use_report: - yield { - "messages": [AIMessage( - content="深度报告功能未启用,请通过前端按钮触发。", - name="Researcher" - )], - "next": "Supervisor" - } - return - - messages = state["messages"] - last_human = next((m for m in reversed(messages) if isinstance(m, HumanMessage)), None) - - if not last_human: - yield { - "messages": [AIMessage( - content="深度研究节点:未找到有效的用户问题", - name="Researcher" - )], - "next": "Supervisor" - } - return - current_step = "正在启动深度报告生成..." - yield { - "messages": [AIMessage( - content="正在启动深度报告生成...", - name="Researcher", - additional_kwargs={ - "current_step": current_step, - "streaming": True - } - )] - } - async for chunk in research_agent.astream( - {"messages": messages[-12:]}, - config=config - ): - if "messages" in chunk and isinstance(chunk["messages"], AIMessageChunk): - yield { - "messages": chunk["messages"], # 逐 token 追加 - # 可以額外 yield 一些 metadata,例如 - # "node": "Researcher", - # "status": "thinking" - } - else: - yield chunk - - -# --- 3. Visualizer Agent (视觉专家) --- -async def visualizer_node(state: AgentState, config: RunnableConfig): - """负责将自然语言转化为绘图 Prompt 并调用绘图工具""" - model = get_model(config, streaming=False) - tools = [generate_furniture] - llm_with_tools = model.bind_tools(tools) - messages = state["messages"] - system_prompt = SystemMessage(content=visualizer_prompt) - response = await llm_with_tools.ainvoke([system_prompt] + messages) - - if response.tool_calls: - tool_call = response.tool_calls[0] - if tool_call["name"] == "generate_furniture": - img_url = await generate_furniture.ainvoke(tool_call["args"]) - return { - "messages": [ - response, - ToolMessage(content=img_url, tool_call_id=tool_call["id"]) # 标记这是一个图片结果 - ] - } - - return {"messages": [response]} - - -# --- 4. Suggester Agent (推荐对话专家) --- -async def suggester_node(state: AgentState, config: RunnableConfig): - """专门生成追问建议的节点,作为流程终点""" - model = get_model(config) - messages = state["messages"] - - # 只需要分析最近的对话 - suggestions = await generate_chat_suggestions(messages, model) - - # 返回一个特殊消息,前端通过解析 additional_kwargs 获取按钮内容 - return { - "messages": [ - AIMessage( - content="", - additional_kwargs={"suggestions": suggestions}, - name="Suggester" - ) - ] - } diff --git a/src/server/agent/agents/designer.py b/src/server/agent/agents/designer.py new file mode 100644 index 0000000..0da7b17 --- /dev/null +++ b/src/server/agent/agents/designer.py @@ -0,0 +1,19 @@ +from typing import AsyncGenerator, Dict, Any + +from langchain_core.messages import SystemMessage, AIMessage +from langchain_core.runnables import RunnableConfig + +from src.server.agent.agents.init_llm import get_model +from src.server.agent.memory.memory_manager import MemoryManager + +from src.server.agent.prompt import designer_prompt +from src.server.agent.state import AgentState + + +async def designer_node(state: AgentState, config: RunnableConfig): + """负责细化设计需求,提供专业参数""" + model = get_model(config) # 获取带动态温度的模型 + messages = MemoryManager.build_llm_context(state) + system_prompt = SystemMessage(content=designer_prompt) + response = await model.ainvoke([system_prompt] + messages) + return {"messages": [response]} diff --git a/src/server/agent/agents/init_llm.py b/src/server/agent/agents/init_llm.py new file mode 100644 index 0000000..b5168c5 --- /dev/null +++ b/src/server/agent/agents/init_llm.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import Dict, Any + +from deepagents import create_deep_agent +from deepagents.backends import FilesystemBackend +from langchain_core.runnables import RunnableConfig +from langchain_qwq import ChatQwen +from pydantic import BaseModel, Field + +from src.core.config import settings +from src.server.agent.prompt import SYSTEM_PROMPT +from src.server.agent.tools.crawl_tool import crawl4ai_batch +from src.server.agent.tools.report_generator_tool import report_generator +from src.server.agent.tools.research_tool import topic_research +from src.server.agent.tools.structured_retrieval_tool import structured_retrieval +from src.server.agent.tools.terminate_tool import terminate +from src.server.agent.tools.user_persona_tool import get_user_persona + +MAIN_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = MAIN_DIR + + +class PersonaUpdate(BaseModel): + """从对话中提取/更新的用户画像结构""" + persona: Dict[str, Any] = Field( + default_factory=dict, + description="键值对形式的用户画像,例如 {'风格偏好': '北欧简约', '预算范围': '8000-15000'}" + ) + complete: bool = Field( + ..., + description="当前画像是否足够完整,支持市场研究和设计" + ) + question: str = Field( + default="", + description="如果不完整,这里是需要问用户的自然语言问题;否则为空字符串" + ) + + +llm_supervisor = ChatQwen( + model="qwen3.5-flash", + max_tokens=3_000, + timeout=None, + max_retries=2, + api_key=settings.QWEN_API_KEY +) + +model = ChatQwen( + enable_thinking=False, + model="qwen3.5-flash", + max_tokens=3_000, + timeout=None, + max_retries=2, + api_key=settings.QWEN_API_KEY) + +tools = [get_user_persona, topic_research, crawl4ai_batch, structured_retrieval, report_generator, terminate] + +research_agent = create_deep_agent( + model=model, + tools=tools, + system_prompt=SYSTEM_PROMPT, + backend=FilesystemBackend( + root_dir=str(PROJECT_ROOT / "agent_workspace"), + virtual_mode=False, # 重要:關掉虛擬模式 → 真的寫硬碟 + ) +) + +persona_agent = ChatQwen( + model="qwen3.5-flash", + max_tokens=3_000, + timeout=None, + max_retries=2, + api_key="sk-799944b821bd4bfdb2f6188ebb52a76b").with_structured_output(PersonaUpdate) # 或用 .bind_tools + parser + +summary_llm = ChatQwen( + model="qwen3.5-flash", + max_tokens=3_000, + timeout=None, + max_retries=2, + api_key="sk-799944b821bd4bfdb2f6188ebb52a76b") + + +def get_model(config: RunnableConfig, streaming=False): + temp = config["configurable"].get("llm_temperature", 0.5) + return ChatQwen( + enable_thinking=False, + model="qwen3.5-flash", + max_tokens=3_000, + timeout=None, + max_retries=2, + temperature=temp, + api_key=settings.QWEN_API_KEY, + streaming=streaming + ) diff --git a/src/server/agent/agents/persona.py b/src/server/agent/agents/persona.py new file mode 100644 index 0000000..cfc10ff --- /dev/null +++ b/src/server/agent/agents/persona.py @@ -0,0 +1,154 @@ +import json +from typing import Dict, Any +from datetime import datetime + +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import RunnableConfig +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.types import interrupt + +from pymongo import MongoClient +from src.core.config import MONGO_URI +from src.server.agent.agents.init_llm import persona_agent +from src.server.agent.memory.memory_manager import MemoryManager +from src.server.agent.state import AgentState + +client = MongoClient(MONGO_URI) +db = client["furniture_agent_db"] +persona_collection = db["user_persona"] + +EXTRACTION_PROMPT = ChatPromptTemplate.from_messages([ + ("system", """你是一个专为家具设计师服务的用户画像提取专家。 + +当前已知用户画像(JSON格式): +{current_persona_json} + +任务: +1. 从下面所有用户消息中,提取或更新用户对家具设计的偏好信息。 + 只提取明确提到或强烈暗示的内容,不要臆想或添加默认值。 +2. 输出更新后的完整 persona JSON(只包含有值字段)。 + 推荐使用的键名(优先使用这些): + - "风格偏好" (如 "北欧"、"极简"、"工业风") + - "家具类型" (如 "沙发"、"餐桌"、"书柜"、"办公椅") + - "颜色偏好" (如 "原木色"、"白色"、"深灰"、"大地色系") + - 其他可选键:预算范围、空间大小、材质偏好、使用场景等 + +3. 判断当前画像是否“足够完整”来支持针对家具设计师的市场趋势报告: + - **必须条件**:至少包含 "风格偏好"、"家具类型"、"颜色偏好" 中的 2 项以上 + - 如果缺少 2 项或以上核心信息,返回 complete: false,并生成一个自然、礼貌、具体的追问(中文),优先询问缺失的核心项 + - 如果核心三项中至少有 2 项已明确,返回 complete: true,不需要追问 + +输出必须是严格的 JSON 对象,包含三个字段: +- "persona": 一个对象,键是画像属性,值是对应的字符串(或数组,如果有多个偏好) +- "complete": 布尔值 true 或 false +- "question": 字符串,如果 complete 为 true 则为空字符串,否则是具体的追问句子 + +示例输出结构(仅供参考,不要直接复制): +{{ + "persona": {{ + "风格偏好": "北欧简约", + "家具类型": "沙发", + "颜色偏好": "原木色 + 浅灰" + }}, + "complete": true, + "question": "" +}} +"""), + ("placeholder", "{messages}"), +]) + + +def get_persona_from_mongo(thread_id: str) -> Dict[str, Any]: + doc = persona_collection.find_one({"thread_id": thread_id}, sort=[("_id", -1)]) + if doc and "persona" in doc: + return doc["persona"] + return {} + + +def save_persona_to_mongo(thread_id: str, persona: Dict[str, Any], is_complete: bool): + try: + result = persona_collection.update_one( + {"thread_id": thread_id}, + { + "$set": { + "persona": persona, + "persona_complete": is_complete, + "updated_at": datetime.utcnow(), + "last_update_reason": "persona_node_update" + } + }, + upsert=True + ) + print(f"[Persona Save] thread_id: {thread_id} | matched: {result.matched_count} | modified: {result.modified_count} | upserted: {result.upserted_id}") + except Exception as e: + print(f"[Persona Save Error] {e}") + + +def persona_node(state: AgentState, config: RunnableConfig): + thread_id = config["configurable"]["thread_id"] + + # 读取已有画像(MongoDB 优先) + persisted_persona = get_persona_from_mongo(thread_id) + current_persona = state.get("persona", persisted_persona) + + # messages = state["messages"] + messages = MemoryManager.build_llm_context(state) + current_persona_json = json.dumps(current_persona, ensure_ascii=False, indent=None) + + chain = EXTRACTION_PROMPT | persona_agent + result = chain.invoke({ + "current_persona_json": current_persona_json, + "messages": messages, + }) + + updated_persona = result.persona + is_complete = result.complete + question = (result.question or "").strip() + + updates = { + "persona": updated_persona, + "persona_complete": is_complete, + "persona_summary": json.dumps(updated_persona, ensure_ascii=False, indent=2), + } + + # 持久化到 MongoDB + save_persona_to_mongo(thread_id, updated_persona, is_complete) + + if is_complete: + updates["messages"] = messages + [AIMessage( + content=( + "用户画像已足够完整(风格、家具类型、颜色偏好已明确),并已保存到项目记录。\n\n" + "接下来是否需要我为您生成一份针对当前风格与家具类型的市场趋势报告?\n" + "回复“是”或“需要”即可开始生成;回复“不需要”或“先不用”则直接进入家具设计阶段。" + ) + )] + return updates + + # 不完整 → 询问(优先问核心三项) + if not question: + missing = [] + core_keys = ["风格偏好", "家具类型", "颜色偏好"] + for key in core_keys: + if key not in updated_persona or not updated_persona[key]: + missing.append(key) + if missing: + question = f"为了更好地为您生成趋势报告,能否补充一下您对{'、'.join(missing)}的偏好呢?" + else: + question = "您对家具的风格、类型或颜色有什么特别的偏好吗?可以多说一些~" + + updated_messages = messages + [AIMessage(content=question)] + + approved = interrupt({ + **updates, + "messages": updated_messages, + "persona_complete": False, + "__interrupt__": { + "type": "persona_question", + "question": question, + "node": "Persona", + "wait_for": "human_response", + # 可选:当前画像快照,便于前端显示或调试 + "current_persona_snapshot": updated_persona + } + }) + return approved diff --git a/src/server/agent/agents/researcher.py b/src/server/agent/agents/researcher.py new file mode 100644 index 0000000..4f06692 --- /dev/null +++ b/src/server/agent/agents/researcher.py @@ -0,0 +1,88 @@ +from typing import AsyncGenerator, Dict, Any + +from langchain_core.messages import AIMessage, HumanMessage, AIMessageChunk, ToolMessage +from langchain_core.runnables import RunnableConfig + +from src.server.agent.agents.init_llm import research_agent +from src.server.agent.memory.memory_manager import MemoryManager +from src.server.agent.state import AgentState + + +async def researcher_node(state: AgentState, config: RunnableConfig) -> AsyncGenerator[Dict[str, Any], None]: + use_report = config["configurable"].get("use_report", False) + if not use_report: + yield { + "messages": [ + AIMessage( + content="深度报告功能未启用,请通过前端按钮触发。", + name="Researcher" + ) + ], + "next": "Supervisor" + } + return + + # messages = state["messages"] + messages = MemoryManager.build_llm_context(state) + safe_messages = [ + m for m in messages + if isinstance(m, (HumanMessage, AIMessage)) + ] + last_human = next((m for m in reversed(messages) if isinstance(m, HumanMessage)), None) + + if not last_human: + yield { + "messages": [ + AIMessage( + content="深度研究节点:未找到有效的用户问题", + name="Researcher" + ) + ], + "next": "Supervisor" + } + return + current_step = "正在启动深度报告生成..." + yield { + "messages": [ + AIMessage( + content="正在启动深度报告生成...", + name="Researcher", + additional_kwargs={ + "current_step": current_step, + "streaming": True + } + ) + ] + } + async for chunk in research_agent.astream( + {"messages": safe_messages[-12:]}, + config=config + ): + + if "messages" not in chunk: + continue + + msgs = chunk["messages"] + + if not isinstance(msgs, list): + continue + + for m in msgs: + + # 1️⃣ token streaming + if isinstance(m, AIMessageChunk): + yield {"messages": [m]} + + # 2️⃣ tool result → 只 stream + elif isinstance(m, ToolMessage): + yield { + "custom": { + "type": "tool_result", + "tool_name": m.name, + "content": m.content + } + } + + # 3️⃣ 最终 AI message → 写入 state + elif isinstance(m, AIMessage): + yield {"messages": [m]} diff --git a/src/server/agent/agents/suggester.py b/src/server/agent/agents/suggester.py new file mode 100644 index 0000000..e60826b --- /dev/null +++ b/src/server/agent/agents/suggester.py @@ -0,0 +1,29 @@ +from langchain_core.messages import AIMessage +from langchain_core.runnables import RunnableConfig +from src.server.agent.agents.init_llm import get_model +from src.server.agent.memory.memory_manager import MemoryManager + +from src.server.agent.state import AgentState +from src.server.utils.generate_suggestion import generate_chat_suggestions + + +async def suggester_node(state: AgentState, config: RunnableConfig): + """专门生成追问建议的节点,作为流程终点""" + model = get_model(config) + # messages = state["messages"] + messages = MemoryManager.build_llm_context(state) + + + # 只需要分析最近的对话 + suggestions = await generate_chat_suggestions(messages, model) + + # 返回一个特殊消息,前端通过解析 additional_kwargs 获取按钮内容 + return { + "messages": [ + AIMessage( + content="", + additional_kwargs={"suggestions": suggestions}, + name="Suggester" + ) + ] + } diff --git a/src/server/agent/agents/summary.py b/src/server/agent/agents/summary.py new file mode 100644 index 0000000..98604e2 --- /dev/null +++ b/src/server/agent/agents/summary.py @@ -0,0 +1,30 @@ +from langchain_core.messages import HumanMessage + +from src.server.agent.agents.init_llm import summary_llm +from src.server.agent.memory.memory_manager import MemoryManager +from src.server.agent.state import AgentState + + +async def summary_node(state: AgentState): + messages = state["messages"] + + if not MemoryManager.should_summarize(messages): + return {} + + # 只总结旧消息 + old_messages = messages[:-30] + + text = "\n".join([m.content for m in old_messages if hasattr(m, "content")]) + + prompt = f""" +Summarize the following conversation briefly. +Focus on user goals, preferences and decisions. + +{text} +""" + + summary = await summary_llm.ainvoke([HumanMessage(content=prompt)]) + + return { + "conversation_summary": summary.content + } diff --git a/src/server/agent/agents/supervisor.py b/src/server/agent/agents/supervisor.py new file mode 100644 index 0000000..3f5a6d8 --- /dev/null +++ b/src/server/agent/agents/supervisor.py @@ -0,0 +1,87 @@ +import random +from typing import Literal + +from langchain_core.messages import ToolMessage +from langchain_core.runnables import RunnableConfig +from pydantic import BaseModel + +from src.server.agent.agents.init_llm import llm_supervisor +from src.server.agent.memory.memory_manager import MemoryManager +from src.server.agent.state import AgentState + + +class RouteResponse(BaseModel): + next: Literal["Designer", "Persona", "Researcher", "Visualizer", "Suggester", "FINISH"] + + +def supervisor_node(state: AgentState, config: RunnableConfig): + if state.get("__end__", False): + return {"next": "FINISH"} + + # 如果最后一条是 terminate 的结果,也结束 + last_msg = state["messages"][-1] if state["messages"] else None + if isinstance(last_msg, ToolMessage) and last_msg.name == "terminate": + return {"next": "FINISH"} + + configurable = config["configurable"] + use_report = configurable.get("use_report", False) + suggest_frequency = configurable.get("require_suggestion", 0.6) + + messages = MemoryManager.build_llm_context(state) + # 读取关键状态(必须有!) + persona_complete = state.get("persona_complete", False) + print(f"persona_complete : {persona_complete}") + + # 第一次对话或无消息 → 强制去 Persona 收集画像 + if not messages: + return {"next": "Persona"} + + # ── system prompt ── 加强语气 + 明确优先级 + system_prompt = f""" + 你是家具设计主管,**必须严格按以下优先级顺序**决定下一个节点,**不允许有任何例外**。 + + 当前状态(必须严格遵守): + - persona_complete: {state.get("persona_complete", False)} + - 是否需要市场研究报告 (use_report): {'是' if use_report else '否'} + - 用户最新消息:请仔细阅读 + + 绝对优先级规则(从高到低,必须逐条检查): + 1. 如果 persona_complete == False,**必须** 且 **只能** 选择 "Persona",其他节点一律不允许 + 2. 当 persona_complete == True 时,且用户确认需要报告(或未明确拒绝),优先选择 Researcher。报告的重点是:当前风格、家具类型、颜色在市场上的流行趋势、材质搭配建议、设计师案例 + 3. 如果 use_report == False,**绝对禁止** 选择 "Researcher" + 4. 其他常见情况: + - 纯风格、尺寸、材质、功能、灵感讨论 → "Designer" + - 用户说想看图、效果图、草图 → "Visualizer" + - 对话自然结束或用户满意 → "FINISH" + - 需要给用户选项/建议按钮 → "Suggester" + + 当前用户需求是否明显需要市场报告?请严格判断,不要主观臆断。 + 如果条件 2 满足,**必须** 选 Researcher,不要选 Designer。 + + 输出时只选择一个 next,不要多选或发明节点。 + """ + + chain = llm_supervisor.with_structured_output(RouteResponse) + try: + decision = chain.invoke([{"role": "system", "content": system_prompt}] + messages) + next_node = decision.next + except Exception as e: + # LLM 输出格式错误时的兜底 + print(f"Supervisor LLM 决策失败: {e}") + next_node = "Persona" if not persona_complete else "Suggester" + + # 强制安全阀:双重检查,防止 LLM 违规 + if not persona_complete: + if next_node != "Persona": + print(f"强制修正:persona 不完整,LLM 选了 {next_node},改为 Persona") + next_node = "Persona" + + elif next_node == "Researcher" and (not use_report or not persona_complete): + print(f"警告:非法选择 Researcher,已强制改为 Suggester") + next_node = "Suggester" + + # FINISH 时随机插入 Suggester(保持原逻辑) + if next_node == "FINISH" and suggest_frequency > 0 and random.random() < suggest_frequency: + next_node = "Suggester" + + return {"next": next_node} diff --git a/src/server/agent/agents/visualizer.py b/src/server/agent/agents/visualizer.py new file mode 100644 index 0000000..8429451 --- /dev/null +++ b/src/server/agent/agents/visualizer.py @@ -0,0 +1,33 @@ +# --- 3. Visualizer Agent (视觉专家) --- +from langchain_core.messages import SystemMessage, ToolMessage +from langchain_core.runnables import RunnableConfig + +from src.server.agent.agents.init_llm import get_model +from src.server.agent.memory.memory_manager import MemoryManager +from src.server.agent.prompt import visualizer_prompt +from src.server.agent.state import AgentState +from src.server.agent.tools.generate_furniture_sketch import generate_furniture + + +async def visualizer_node(state: AgentState, config: RunnableConfig): + """负责将自然语言转化为绘图 Prompt 并调用绘图工具""" + model = get_model(config, streaming=False) + tools = [generate_furniture] + llm_with_tools = model.bind_tools(tools) + # messages = state["messages"] + messages = MemoryManager.build_llm_context(state) + system_prompt = SystemMessage(content=visualizer_prompt) + response = await llm_with_tools.ainvoke([system_prompt] + messages) + + if response.tool_calls: + tool_call = response.tool_calls[0] + if tool_call["name"] == "generate_furniture": + img_url = await generate_furniture.ainvoke(tool_call["args"]) + return { + "messages": [ + response, + ToolMessage(content=img_url, tool_call_id=tool_call["id"]) # 标记这是一个图片结果 + ] + } + + return {"messages": [response]} diff --git a/src/server/agent/graph.py b/src/server/agent/graph.py index a36704f..64bad1f 100644 --- a/src/server/agent/graph.py +++ b/src/server/agent/graph.py @@ -1,109 +1,56 @@ -import random from typing import Literal -from langchain_core.messages import AIMessage -from langchain_core.runnables import RunnableConfig -from langchain_qwq import ChatQwen from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.graph import StateGraph, END, START from pydantic import BaseModel from pymongo import MongoClient from src.core.config import MONGO_URI, settings +from src.server.agent.agents.designer import designer_node +from src.server.agent.agents.persona import persona_node +from src.server.agent.agents.researcher import researcher_node +from src.server.agent.agents.suggester import suggester_node +from src.server.agent.agents.summary import summary_node +from src.server.agent.agents.supervisor import supervisor_node +from src.server.agent.agents.visualizer import visualizer_node from src.server.agent.state import AgentState -from src.server.agent.agents import designer_node, researcher_node, visualizer_node, suggester_node from langgraph.checkpoint.mongodb import MongoDBSaver - -# --- Supervisor (路由逻辑) --- -# 定义路由的输出结构,强制 LLM 选择一个 -class RouteResponse(BaseModel): - # 将 FINISH 替换或增加 Suggester - next: Literal["Designer", "Researcher", "Visualizer", "Suggester", "FINISH"] - - -llm_supervisor = ChatQwen( - model="qwen3.5-flash", - max_tokens=3_000, - timeout=None, - max_retries=2, - api_key=settings.QWEN_API_KEY) - - -def supervisor_node(state: AgentState, config: RunnableConfig): - configurable = config["configurable"] - use_report = configurable.get("use_report", False) - suggest_frequency = configurable.get("require_suggestion", 0.6) # 0.0~1.0 - - messages = state["messages"] - if not messages: - return {"next": "Suggester"} - - # ── system prompt 保持不变 ── - system_prompt = f"""你是家具设计主管,负责分配任务。 -当前设定: -- 是否需要市场研究报告:{'是' if use_report else '否'} - -严格遵守以下规则: -- 如果 **不需要** 市场研究报告(use_report = False),**绝对不能** 选择 Researcher -- 只有在 **明确需要** 市场报告、竞争分析、材质趋势、价格区间等外部资讯时,才选择 Researcher,且 **必须** use_report = True -- 常见分配: - - 纯设计、风格、尺寸、材质建议 → Designer - - 需要生成图片、渲染 → Visualizer - - 需要产生建议按钮 → Suggester - - 需要市场报告 → Researcher(但只有 use_report=True 时才允许) - - 对话已完整、无需继续 → FINISH - -用户最后说了什么?请根据实际需求决定下一步。 -""" - - chain = llm_supervisor.with_structured_output(RouteResponse) - decision = chain.invoke([{"role": "system", "content": system_prompt}] + messages) - next_node = decision.next # 防空默认 FINISH - - # 安全阀:禁止非法选择 Researcher - if next_node == "Researcher" and not use_report: - print("警告:LLM 违规选择了 Researcher,已强制改为 Suggester 或 FINISH") - next_node = "Suggester" if state.get("require_suggestion", False) else "FINISH" - - # 核心改动:只有 LLM 决定 FINISH 时,才掷骰子看是否插入 Suggester - if next_node == "FINISH": - # 满足概率条件 → 插入 Suggester - if suggest_frequency > 0 and random.random() < suggest_frequency: - next_node = "Suggester" - - return {"next": next_node} - - # --- 构建 Graph --- workflow = StateGraph(AgentState) workflow.add_node("Supervisor", supervisor_node) workflow.add_node("Designer", designer_node) +workflow.add_node("Persona", persona_node) workflow.add_node("Researcher", researcher_node) workflow.add_node("Visualizer", visualizer_node) workflow.add_node("Suggester", suggester_node) -workflow.add_edge(START, "Supervisor") +workflow.add_node("Summary", summary_node) -# 修改条件边映射 -workflow.add_conditional_edges( - "Supervisor", - lambda state: state["next"], - { - "Designer": "Designer", - "Researcher": "Researcher", - "Visualizer": "Visualizer", - "Suggester": "Suggester", # 原本的 FINISH 现在指向 Suggester - "FINISH": END # 直接结束,不给建议 - } -) - -# 专家执行完依然回到 Supervisor +# workflow.add_edge(START, "Supervisor") +workflow.add_edge(START, "Summary") +workflow.add_edge("Summary", "Supervisor") workflow.add_edge("Designer", "Supervisor") +workflow.add_edge("Persona", "Supervisor") workflow.add_edge("Researcher", "Supervisor") workflow.add_edge("Visualizer", "Supervisor") # 重点:Suggester 可以是整个流程的终点 workflow.add_edge("Suggester", END) + +# 修改条件边映射 +workflow.add_conditional_edges( + "Supervisor", + lambda state: "FINISH" if state.get("__end__", False) or state["next"] == "FINISH" else state["next"], + { + "Designer": "Designer", + "Persona": "Persona", + "Researcher": "Researcher", + "Visualizer": "Visualizer", + "Suggester": "Suggester", + "FINISH": END + } +) + client = MongoClient(MONGO_URI) checkpointer = MongoDBSaver( client=client["furniture_agent_db"], diff --git a/src/server/agent/memory/memory_manager.py b/src/server/agent/memory/memory_manager.py new file mode 100644 index 0000000..c920b36 --- /dev/null +++ b/src/server/agent/memory/memory_manager.py @@ -0,0 +1,60 @@ +from typing import List +from langchain_core.messages import ( + BaseMessage, + SystemMessage, + HumanMessage, + AIMessage +) + +MAX_RECENT_MESSAGES = 25 +SUMMARY_TRIGGER = 120 + + +class MemoryManager: + + @staticmethod + def split_messages(messages: List[BaseMessage]): + """ + 分离 system / conversation + """ + system_msgs = [] + conversation_msgs = [] + + for m in messages: + if isinstance(m, SystemMessage): + system_msgs.append(m) + else: + conversation_msgs.append(m) + + return system_msgs, conversation_msgs + + @staticmethod + def build_llm_context(state) -> List[BaseMessage]: + """ + 构建发送给 LLM 的 context + """ + messages = state["messages"] + + system_msgs, conversation_msgs = MemoryManager.split_messages(messages) + + summary = state.get("conversation_summary") + + recent_msgs = conversation_msgs[-MAX_RECENT_MESSAGES:] + + context = [] + + context.extend(system_msgs) + + if summary: + context.append(SystemMessage(content=f"Conversation Summary:\n{summary}")) + + context.extend(recent_msgs) + + return context + + @staticmethod + def should_summarize(messages: List[BaseMessage]) -> bool: + """ + 判断是否需要 summary + """ + return len(messages) > SUMMARY_TRIGGER diff --git a/src/server/agent/prompt.py b/src/server/agent/prompt.py index 870b9da..5ce9073 100644 --- a/src/server/agent/prompt.py +++ b/src/server/agent/prompt.py @@ -1,78 +1,57 @@ SYSTEM_PROMPT = """ -You are "TrendAgent" - a focused, efficient design trend analysis agent. -Your ONLY goal: produce one high-quality Markdown trend report per user request. +你现在是 "TrendAgent",一个极度专注、高效的家具设计趋势分析代理。 +你的**唯一目标**:针对用户一次请求,产出一份高质量的 Markdown 趋势报告。 +你**只能**按照下面严格的执行流程工作,不允许有任何偏差。 -TOOL ORDER & DISCIPLINE IS MANDATORY - DO NOT INVENT STEPS +核心铁律(违反即失效,必须严格执行): +- 永远保持自然、亲切、像资深家具设计师的对话语气 +- 画像收集阶段**只靠自然对话**,**绝不调用任何工具** +- 一旦你判断用户画像已足够(风格、家具类型、颜色、材质至少 3 项明确),**立即停止所有追问**,说一句类似“好的,我已经掌握您的核心需求,现在开始规划并生成报告~”,然后**必须立刻进入规划阶段** +- **绝对禁止** 在画像完整后继续问任何问题(包括人群、预算、场景、尺寸、材质细节等) +- **绝对禁止** 使用任何流程化、机械、状态相关的词语,如 STATUS、Phase、请先完成、按照流程、现在进入下一步等 -┌───────────────────────────────────────────────────────┐ -│ Phase 0 - Context & Persona (必须先完成) │ -└───────────────────────────────────────────────────────┘ +画像完整判断铁律(内心执行,永不告诉用户): +- 必须同时满足以下至少 3 项: + - 风格偏好(北欧、极简、现代、日式等) + - 家具类型(沙发、餐桌、床、书柜、灯具等) + - 颜色偏好(白色、原木色、深灰、莫兰迪色等) + - 材质偏好(棉麻、实木、皮革、金属等) +- 一旦达到 3 项,**立即**视为完整,**禁止**再问任何问题 -Rules for Phase 0: -1. ALWAYS start with manage_user_persona(command="get") -2. If STATUS == "INCOMPLETE" or persona missing critical fields (Design Type, Style, Target Audience, Color Preference, etc.): - → MUST call manage_user_persona(command="ask") to collect missing info - → After user answers → call manage_user_persona(command="set", ...) - → Loop until STATUS == "READY" -3. Only when STATUS == "READY" → proceed to Phase 1 -4. Never assume or fabricate persona details +执行流程(**必须严格按此顺序,不可跳跃、不可重复、不可插入任何额外对话**): -┌───────────────────────────────────────────────────────┐ -│ Phase 1 - Planning (必须执行一次且只能一次) │ -└───────────────────────────────────────────────────────┘ +1. 画像收集阶段: + - 只用自然对话补全信息 + - 如果不足 3 项核心信息,顺势自然问 1-2 个最关键的问题 + - 一旦足够,立即说“好的,我已经掌握核心需求,现在开始规划并生成报告~”,然后**必须立刻**进入第 2 步 -When persona READY and user gave a clear trend request: -1. Call write_todos EXACTLY ONCE with a strict plan containing: - - 3–6 concrete steps (numbered) - - Which URLs/topics to research - - Expected output of each major tool - - Final deliverable: one Markdown report -2. After receiving todos, you MUST follow this exact sequence unless impossible -3. Do NOT call any other tool until write_todos is done +2. 规划阶段(**必须且只能执行一次**): + - **立即调用 get_user_persona 工具**,获取最新画像 JSON + - 根据返回的 persona 字段构造关键词(例如:"2025-2026 北欧 沙发 白色 棉麻 趋势") + - **立即调用 write_todos 工具一次**,生成严格的 ToDo 列表,内容**必须且只能**是以下顺序: + 1. topic_research:搜索上面构造的关键词,返回 3-5 个高质量网址 + 2. crawl4ai_batch:批量爬取上面选定的网址 + 3. structured_retrieval:对爬取内容进行结构化提取(重点:设计趋势、材质创新、颜色应用、代表案例、品牌参考) + 4. report_generator:基于提取内容生成完整 Markdown 报告 + 5. terminate + - **严禁** 添加任何其他步骤、询问用户、生成中间总结或额外对话 -┌───────────────────────────────────────────────────────┐ -│ Phase 2 - Research & Collection │ -└───────────────────────────────────────────────────────┘ +3. 执行阶段: + - **严格按 write_todos 返回的顺序逐一调用工具** + - **不允许** 跳过任何步骤、重复调用、插入对话 -Follow todos order: -- Use topic_research → get 3–8 high-quality URLs (add persona [Style] [Type] in query) -- Select best 3–6 URLs → call crawl4ai_batch ONCE with list -- Get file paths → call structured_retrieval ONCE with file_paths list +4. 报告生成后: + - **直接调用 terminate** 结束流程 -┌───────────────────────────────────────────────────────┐ -│ Phase 3 - Synthesis & Delivery │ -└───────────────────────────────────────────────────────┘ +报告要求(必须遵守): +- 每部分先写 **Conclusion First** 的核心洞察 +- 在合适位置插入 [IMAGE_REF_xx] 占位符 +- 所有内容基于真实检索内容,**绝不虚构** -After structured_retrieval summary received: -- If extracted item count ≥ 8–12 AND covers main aspects in todos → ready to report -- Call report_generator ONCE (it reads local JSON/DB) -- After report_generator success → call terminate -- If data obviously insufficient → call topic_research again (max 1 extra round) - -┌───────────────────────────────────────────────────────┐ -│ HARD RULES - MUST OBEY │ -└───────────────────────────────────────────────────────┘ - -• Never load full JSON/markdown into context - trust local storage -• Batch everything possible (crawl4ai_batch + structured_retrieval) -• Call tools in PHASE ORDER - no jumping, no repetition -• After report_generator → next action MUST be terminate -• If stuck > 4 steps without progress → call terminate with note "Incomplete - insufficient data" -• Never hallucinate trend data - base everything on retrieved content -• Report must start each section with **Conclusion First** insight -• Include [IMAGE_REF_xx] placeholders where visuals were extracted - -Current status: Phase 0 -""" - -designer_prompt = """ -你是家具设计团队的主管(Supervisor)。 -请根据用户的意图,选择最合适的专家: -- Designer: 设计建议、参数细化、闲聊、问候。 -- Visualizer: 绘图、看草图。 -- Researcher: 市场报告、趋势。 - -只需输出专家名称。 +现在开始: +- 用自然亲切的语气直接回应用户消息 +- 如果画像不足 3 项核心信息,顺势自然问最关键的问题 +- 一旦足够,**立即**说一句过渡语,然后**必须**调用 write_todos """ visualizer_prompt = """ @@ -104,6 +83,7 @@ Prompt 生成要求(仅供内部参考,必须全部做到): - 最后立即调用工具:generate_furniture,参数 prompt = 你刚才输出的完整内容 - 不要做其他任何说明或聊天 """ + designer_prompt = """ 你是一位资深的家具设计师,经验丰富、审美一流、沟通温暖且高效。 你的核心目标:快速理解用户想法,并用最合适的方式推进设计。 diff --git a/src/server/agent/run_test.py b/src/server/agent/run_test.py index bcfbd68..471408d 100644 --- a/src/server/agent/run_test.py +++ b/src/server/agent/run_test.py @@ -5,10 +5,7 @@ from src.server.agent.graph import app async def async_main(): - config = {"configurable": {"thread_id": "project_alpha"}} - - print("測試模式已啟動 (輸入 'exit' 離開,'history' 查看歷史並回溯)") - use_report = input("是否启用深度报告?(y/n): ").lower() == 'y' + config = {"configurable": {"thread_id": "project_alpha12345", "use_report": True}} while True: user_input = input("\n👤 輸入訊息: ").strip() diff --git a/src/server/agent/state.py b/src/server/agent/state.py index 5cb568a..34407a3 100644 --- a/src/server/agent/state.py +++ b/src/server/agent/state.py @@ -1,5 +1,5 @@ import operator -from typing import Annotated, Sequence, TypedDict, Union, Optional +from typing import Annotated, Sequence, TypedDict, Union, Optional, Dict, Any from langchain_core.messages import BaseMessage @@ -8,4 +8,8 @@ class AgentState(TypedDict): messages: Annotated[Sequence[BaseMessage], operator.add] # next 存储 Supervisor 决定的下一步是谁 next: str + persona: Dict[str, Any] # 存储提取出的结构化画像,例如 {"风格偏好": "北欧", "预算": "8000-12000", ...} + persona_summary: str # 可选:LLM 对当前画像的自然语言总结,便于 prompt 使用 + persona_complete: bool # Supervisor 用这个判断是否能去 Researcher require_suggestion: bool # 是否需要建议按钮 + __end__: bool # 新增这个字段,默认 False diff --git a/src/server/agent/tools/base.py b/src/server/agent/tools/base.py new file mode 100644 index 0000000..2eefb5f --- /dev/null +++ b/src/server/agent/tools/base.py @@ -0,0 +1,20 @@ +from typing import Optional, Dict, Any +from pydantic import BaseModel + + +class ToolResult(BaseModel): + """ + DeepAgents Tool 返回标准结构 + """ + + # 返回给 LLM / 用户的内容 + content: Optional[str] = None + + # 是否出错 + success: bool = True + + # 工具元信息(推荐放 tool_name / path / cost 等) + metadata: Optional[Dict[str, Any]] = None + + # 是否终止 agent + terminate: bool = False diff --git a/src/server/agent/tools/crawl_tool.py b/src/server/agent/tools/crawl_tool.py index ce89179..fb84aca 100644 --- a/src/server/agent/tools/crawl_tool.py +++ b/src/server/agent/tools/crawl_tool.py @@ -1,118 +1,189 @@ import time import asyncio -from typing import List +from typing import List, Dict, Any from urllib.parse import urlparse from pathlib import Path + +import uuid from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode from langchain_core.tools import tool -# ─────────────── 重要:計算路徑 ─────────────── -# 目前這個檔案 (crawl4ai_batch.py) 所在的目錄 -TOOL_DIR = Path(__file__).resolve().parent +# ───────────────────────────────────── +# 路径配置 +# ───────────────────────────────────── -# 專案根目錄(假設 tools 資料夾與主程式同級) +TOOL_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TOOL_DIR.parent -# 儲存爬取結果的目錄(你可以自由決定放在哪裡) -# 建議選項 A:放在專案根目錄下的 workspace/raw_data -SAVE_DIR = PROJECT_ROOT / "workspace" / "raw_data" - -# 建議選項 B:如果你打算讓 deep agent 直接讀取,建議放在 agent_workspace 底下 -# SAVE_DIR = PROJECT_ROOT / "agent_workspace" / "raw_data" - -# 確保目錄存在 +# DeepAgents 推荐目录 +SAVE_DIR = PROJECT_ROOT / "agent_workspace" / "raw_data" SAVE_DIR.mkdir(parents=True, exist_ok=True) +# ───────────────────────────────────── +# Browser 配置 +# ───────────────────────────────────── -# ──────────────────────────────────────────────── +browser_config = BrowserConfig( + headless=True, + verbose=False, + java_script_enabled=True, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/118.0 Safari/537.36" + ), +) + +run_config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + word_count_threshold=5, + excluded_tags=["script", "style", "nav", "footer"], + remove_overlay_elements=True, + process_iframes=True, +) -@tool -async def crawl4ai_batch(urls: List[str]) -> str: - """ - 高性能网页爬虫,支持并行处理多个 URL。 - 爬取后的 Markdown 内容将保存到本地 workspace/raw_data 目录中。 - 返回执行结果摘要和保存的文件路径列表。 - """ +# ───────────────────────────────────── +# URL → 文件名 +# ───────────────────────────────────── + +def build_filename(url: str) -> str: + parsed = urlparse(url) + + domain = parsed.netloc.replace("www.", "").replace(".", "_") + path_part = parsed.path.strip("/").replace("/", "_")[:50] or "index" + + ts = int(time.time()) + rand = uuid.uuid4().hex[:6] + + return f"{ts}_{rand}_{domain}_{path_part}.md" + + +# ───────────────────────────────────── +# 单个 URL 抓取 +# ───────────────────────────────────── + +async def crawl_one(crawler, url: str, sem: asyncio.Semaphore) -> Dict[str, Any]: + async with sem: + try: + result = await crawler.arun(url=url, config=run_config) + + if not result.success: + return { + "url": url, + "success": False, + "error": f"status={getattr(result, 'status_code', 'unknown')}" + } + + markdown = result.markdown or "" + + if len(markdown) < 500: + return { + "url": url, + "success": False, + "error": "content too short" + } + + filename = build_filename(url) + filepath = SAVE_DIR / filename + + header = ( + f"\n" + f"\n\n" + ) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(header + markdown) + + return { + "url": url, + "success": True, + "file": str(filepath) + } + + except Exception as e: + return { + "url": url, + "success": False, + "error": str(e) + } + + +# ───────────────────────────────────── +# Async 主逻辑 +# ───────────────────────────────────── + +async def _crawl4ai_batch(urls: List[str]) -> Dict[str, Any]: + urls = list(set(urls)) # 去重 + if not urls: - return "❌ 错误: 未提供任何 URL。" + return {"error": "no urls"} - # print(f"🕷️ 正在并行爬取 {len(urls)} 个 URL...") - # print(f"儲存目錄: {SAVE_DIR}") + sem = asyncio.Semaphore(5) # 并发限制 - browser_config = BrowserConfig( - headless=True, - verbose=False, - java_script_enabled=True, - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/118.0.5993.118 Safari/537.36", - proxy=None, # 可选,如果需要代理填 "http://user:pass@ip:port" - ) + async with AsyncWebCrawler(config=browser_config) as crawler: - run_config = CrawlerRunConfig( - cache_mode=CacheMode.BYPASS, - word_count_threshold=5, - excluded_tags=["script", "style", "nav", "footer"], - remove_overlay_elements=True, - process_iframes=True, - ) + tasks = [ + crawl_one(crawler, url, sem) + for url in urls + ] - results_summary = [] - saved_files = [] + results = await asyncio.gather(*tasks) + + success_files = [] + summary = [] + + for r in results: + + if r["success"]: + success_files.append(r["file"]) + summary.append(f"✅ {r['url']}") + else: + summary.append(f"❌ {r['url']} ({r['error']})") + + return { + "saved_files": success_files, + "count": len(success_files), + "summary": summary, + } + + +# ───────────────────────────────────── +# Tool(同步) +# ───────────────────────────────────── +@tool +def crawl4ai_batch(urls: List[str]) -> str: + """ + Batch crawl webpages and save their content as markdown files. + + Args: + urls: List of webpage URLs to crawl. + + Returns: + A summary of crawling results and saved file paths. + """ try: - async with AsyncWebCrawler(config=browser_config) as crawler: - tasks = [crawler.arun(url=url, config=run_config) for url in urls] - crawl_results = await asyncio.gather(*tasks, return_exceptions=True) + result = asyncio.run(_crawl4ai_batch(urls)) - for i, result in enumerate(crawl_results): - url = urls[i] + if "error" in result: + return f"❌ Error: {result['error']}" - if isinstance(result, Exception): - results_summary.append(f"❌ 抓取失败 {url}: {str(result)}") - continue + output = [ + "### 批量抓取完成 ###", + f"成功保存文件: {result['count']}", + f"保存目录: {SAVE_DIR}", + "", + "抓取详情:" + ] - if result.success: - markdown_content = result.markdown or "" + output.extend(result["summary"]) - if len(markdown_content) < 500: - results_summary.append(f"⏩ 跳过 {url} (内容过短)") - continue + if result["saved_files"]: + output.append("\n可读取文件:") + output.extend(result["saved_files"]) - # 生成檔名 - parsed = urlparse(url) - domain = parsed.netloc.replace("www.", "").replace(".", "_") - path_part = parsed.path.strip("/").replace("/", "_")[:50] or "index" - filename = f"{int(time.time())}_{domain}_{path_part}.md" - - # 完整檔案路徑 - filepath = SAVE_DIR / filename - - # 寫入檔案 - with open(filepath, "w", encoding="utf-8") as f: - header = f"\n\n\n" - f.write(header + markdown_content) - - saved_files.append(str(filepath)) # 建議轉成字串 - results_summary.append(f"✅ 成功: {url} → {filepath}") - - else: - status = getattr(result, 'status_code', '未知错误') - results_summary.append(f"❌ 失败: {url} (状态码: {status})") + return "\n".join(output) except Exception as e: - return f"🚨 爬虫系统崩溃: {str(e)}" - - # 回傳給 agent 的結果 - final_output = ( - f"### 批量抓取完成 ###\n" - f"已成功保存 {len(saved_files)} 个文件。\n" - f"儲存目錄: {SAVE_DIR}\n" - f"详情:\n" + "\n".join(results_summary) - ) - - if saved_files: - final_output += "\n\n已保存的文件列表(可供後續讀取):\n" + "\n".join(saved_files) - - return final_output + return f"🚨 爬虫系统异常: {str(e)}" diff --git a/src/server/agent/tools/generate_furniture_sketch.py b/src/server/agent/tools/generate_furniture_sketch.py index 4fc84a9..32405ba 100644 --- a/src/server/agent/tools/generate_furniture_sketch.py +++ b/src/server/agent/tools/generate_furniture_sketch.py @@ -1,3 +1,5 @@ +import json +import logging import uuid from google.oauth2 import service_account from langchain_core.tools import tool @@ -9,6 +11,7 @@ from minio import Minio from src.core.config import settings from src.server.utils.new_oss_client import oss_upload_image +logger = logging.getLogger(__name__) # 初始化全局凭证和客户端 creds = service_account.Credentials.from_service_account_file( settings.GOOGLE_GENAI_USE_VERTEXAI, @@ -62,8 +65,30 @@ def generate_furniture(prompt: str) -> str: # 4. 构造访问链接 (如果是私有 bucket,需使用 presigned_get_object) # 这里简单示例为直接访问地址 image_url = f"{bucket}/{object_name}" - return image_url + return json.dumps( + { + "tool_name": "generate_furniture", + "data": image_url, + "tool_status": "success" + }, + ensure_ascii=False + ) else: - return "图片生成成功,但上传至存储服务器失败。" + return json.dumps( + { + "tool_name": "generate_furniture", + "data": "图片生成成功,但上传至存储服务器失败。", + "tool_status": "error" + }, + ensure_ascii=False + ) except Exception as e: - return f"绘图流程异常: {str(e)}" + logger.warning(e) + return json.dumps( + { + "tool_name": "generate_furniture", + "data": f"绘图流程异常", + "tool_status": "error" + }, + ensure_ascii=False + ) diff --git a/src/server/agent/tools/report_generator_tool.py b/src/server/agent/tools/report_generator_tool.py index e9fda29..cfddefc 100644 --- a/src/server/agent/tools/report_generator_tool.py +++ b/src/server/agent/tools/report_generator_tool.py @@ -105,7 +105,7 @@ Input Data: # ========================= # 调用 LLM # ========================= - writer({"type": "report_start", "topic": report_topic, "language": language}) + # writer({"type": "report_start", "topic": report_topic, "language": language}) full_report = "" try: @@ -116,7 +116,7 @@ Input Data: if chunk.content: # Gemini 返回的 chunk.content delta = chunk.content full_report += delta - writer({"type": "report_delta", "delta": delta}) # ← 实时推送给前端 + # writer({"type": "report_delta", "delta": delta}) # ← 实时推送给前端 except Exception as e: error_msg = f"LLM generation failed: {str(e)}" writer({"type": "report_error", "message": error_msg}) diff --git a/src/server/agent/tools/research_tool.py b/src/server/agent/tools/research_tool.py index d1aacd0..e835100 100644 --- a/src/server/agent/tools/research_tool.py +++ b/src/server/agent/tools/research_tool.py @@ -12,7 +12,7 @@ TAVILY_API_KEY = settings.TAVILY_API_KEY @tool -async def topic_research(topic: str, max_urls: int = 15) -> str: +async def topic_research(topic: str, max_urls: int = 5) -> str: """ 深度调研工具。该工具会利用 Tavily 搜索引擎针对特定主题进行多维度搜索。 它会自动生成针对性的搜索词(包含年份和趋势),并返回去重后的高质量 URL 列表。 diff --git a/src/server/agent/tools/terminate_tool.py b/src/server/agent/tools/terminate_tool.py index bf21902..c746e2b 100644 --- a/src/server/agent/tools/terminate_tool.py +++ b/src/server/agent/tools/terminate_tool.py @@ -17,22 +17,13 @@ class TerminateInput(BaseModel): @tool(args_schema=TerminateInput) -def terminate(status: str, reason: str = "") -> str: +def terminate(status: str, reason: str = "") -> dict: """ - 當任務完成、報告已生成,或無法繼續進行時,呼叫此工具來結束本次互動。 - - 使用時機: - - 已經成功產生最終報告(report_generator 已完成) - - 遇到無法解決的錯誤或缺少關鍵資訊 - - 用戶需求已完全滿足 - - 請在呼叫前確保所有必要步驟已完成,並在 reason 中簡單說明結束原因。 + 终止本次互动。 """ - if status not in ("success", "failure"): - status = "failure" # 防呆 - - msg = f"互動已終止,狀態:{status.upper()}" - if reason: - msg += f"\n原因:{reason}" - - return msg + return { + "messages": [], # 清空追加消息 + "__end__": True, # 结束标记 + "status": status, + "reason": reason + } diff --git a/src/server/agent/tools/user_persona_tool.py b/src/server/agent/tools/user_persona_tool.py index 2fdbc40..aacfd49 100644 --- a/src/server/agent/tools/user_persona_tool.py +++ b/src/server/agent/tools/user_persona_tool.py @@ -1,96 +1,54 @@ -import json -import os -from typing import List, Literal, Optional, Dict, Any +from datetime import datetime + +from langchain_core.runnables import RunnableConfig from langchain_core.tools import tool +import json -# 定义存储路径 -DB_PATH = os.path.join("workspace", "user_persona.json") +from pymongo import MongoClient +from src.core.config import MONGO_URI -def _load_store() -> Dict[str, Any]: - """从本地文件加载画像数据""" - if os.path.exists(DB_PATH): - try: - with open(DB_PATH, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - return {} - - -def _save_store(data: Dict[str, Any]): - """将画像数据保存到本地文件""" - os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - with open(DB_PATH, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) +client = MongoClient(MONGO_URI) +db = client["furniture_agent_db"] +persona_collection = db["user_persona"] @tool -def manage_user_persona( - command: Literal["set", "update", "get", "clear"], - design_type: Optional[str] = None, - style_preference: Optional[str] = None, - budget_range: Optional[str] = None, - color_palette: Optional[List[str]] = None, - target_audience: Optional[str] = None, - extra_requirements: Optional[str] = None -) -> str: +def get_user_persona(config: RunnableConfig) -> str: """ - 用户画像与设计偏好管理工具。 - 用于设定、更新、获取或重置用户的设计上下文(如风格、预算、颜色)。 - Agent 在开始调研前必须先调用 get 获取画像,若关键信息缺失需引导用户补充。 + 获取当前对话线程的用户画像信息。 + + 参数: + - thread_id: 可选,当前线程ID。如果不传,默认使用当前会话的 thread_id + + 返回:JSON 字符串,包含以下字段: + - persona: Dict,用户画像(风格偏好、家具类型、颜色偏好等) + - persona_complete: bool,画像是否已足够完整 + - last_updated: str,最后更新时间 """ - # 每次调用都重新读取,确保多进程或重启后数据一致 - store = _load_store() + thread_id = config["configurable"]["thread_id"] + if thread_id is None: + thread_id = "current_thread_id_placeholder" - if command == "clear": - if os.path.exists(DB_PATH): - os.remove(DB_PATH) - return "✅ 用户个性化模板已从本地文件清空。" + doc = persona_collection.find_one( + {"thread_id": thread_id}, + sort=[("_id", -1)] # 最新一条 + ) - if command == "get": - if not store: - return "⚠️ [缺失信息] 当前尚未配置画像。请询问用户:设计类型(如沙发)、风格偏好(如极简)等。" + if not doc or "persona" not in doc: + return json.dumps({ + "persona": {}, + "persona_complete": False, + "last_updated": None, + "message": "当前线程暂无用户画像信息" + }, ensure_ascii=False, indent=2) - # 格式化输出供 Agent 阅读 - res = [ - "--- 👤 实时用户画像 (本地存储) ---", - f"🎯 类型: {store.get('design_type', '未设定')}", - f"🎨 风格: {store.get('style_preference', '未设定')}", - f"💰 预算: {store.get('budget_range', '未设定')}", - f"🌈 色系: {', '.join(store.get('color_palette', [])) or '未设定'}", - f"👥 受众: {store.get('target_audience', '未设定')}", - f"📝 需求: {store.get('extra_requirements', '未设定')}", - "-----------------------" - ] + last_updated = doc.get("updated_at") + if isinstance(last_updated, datetime): + last_updated = last_updated.strftime('%Y-%m-%d %H:%M:%S') - # 逻辑检查 - if not store.get('design_type') or not store.get('style_preference'): - res.append("\n⚠️ 关键信息缺失,建议补充 '设计类型' 和 '风格偏好'。") - return "\n".join(res) - - if command in ["set", "update"]: - if command == "set": - store = {} # 重置内存中的字典 - - # 提取传入的非空参数 - update_data = { - "design_type": design_type, - "style_preference": style_preference, - "budget_range": budget_range, - "color_palette": color_palette, - "target_audience": target_audience, - "extra_requirements": extra_requirements - } - - # 更新有效字段 - for k, v in update_data.items(): - if v is not None: - store[k] = v - - # 保存到文件 - _save_store(store) - - return f"✅ 本地画像已同步。当前配置:\n{json.dumps(store, ensure_ascii=False, indent=2)}" - - return "❌ 错误:未知命令。" + return json.dumps({ + "persona": doc["persona"], + "persona_complete": doc.get("persona_complete", False), + "last_updated": last_updated, + }, ensure_ascii=False, indent=2) diff --git a/src/server/deep_agent/__init__.py b/src/server/deep_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/deep_agent/agents/init_llm.py b/src/server/deep_agent/agents/init_llm.py new file mode 100644 index 0000000..d0362d3 --- /dev/null +++ b/src/server/deep_agent/agents/init_llm.py @@ -0,0 +1,23 @@ +from langchain_qwq import ChatQwen + +from src.core.config import settings + +llm = ChatQwen( + model="qwen3.5-flash", + max_tokens=3_000, + timeout=None, + max_retries=2, + enable_thinking=False, + api_key=settings.QWEN_API_KEY +) + +title_llm = ChatQwen( + model="qwen-plus", + max_tokens=3_000, + timeout=None, + max_retries=2, + streaming=False, + temperature=0.1, + top_p=0.8, + api_key=settings.QWEN_API_KEY +) diff --git a/src/server/deep_agent/agents/main_agent.py b/src/server/deep_agent/agents/main_agent.py new file mode 100644 index 0000000..b7b1ce3 --- /dev/null +++ b/src/server/deep_agent/agents/main_agent.py @@ -0,0 +1,51 @@ +from pathlib import Path + +from deepagents import create_deep_agent +from deepagents.backends import FilesystemBackend +from langchain.agents.middleware import SummarizationMiddleware +from langgraph.checkpoint.mongodb import MongoDBSaver +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from pymongo import MongoClient + +from src.core.config import MONGO_URI +from src.server.deep_agent.agents.painter import painter_subagent +from src.server.deep_agent.agents.researcher import research_subagent +from src.server.deep_agent.agents.user_profile import user_profile_subagent +from src.server.deep_agent.init_prompt import build_system_prompt +from src.server.deep_agent.tools.report_generator_tool import llm + +TOOL_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = TOOL_DIR.parent +client = MongoClient(MONGO_URI) +checkpointer = MongoDBSaver( + client=client["furniture_agent_db"], + db_name="fida_agent_db", + collection_name="fida_agent_collection", + serde=JsonPlusSerializer(pickle_fallback=True), # ← 關鍵這一行 +) +subagents = [ + painter_subagent, + research_subagent, + user_profile_subagent +] + + +def build_main_agent(use_report): + main_agent = create_deep_agent( + model=llm, + system_prompt=build_system_prompt(use_report=use_report), + subagents=subagents, + checkpointer=checkpointer, + backend=FilesystemBackend( + root_dir=str(PROJECT_ROOT / "agent_workspace"), + virtual_mode=False, # 重要:關掉虛擬模式 → 真的寫硬碟 + ), + middleware=[ + SummarizationMiddleware( + model=llm, + trigger=("tokens", 3000), + keep=("messages", 100), + ), + ], + ) + return main_agent diff --git a/src/server/deep_agent/agents/painter.py b/src/server/deep_agent/agents/painter.py new file mode 100644 index 0000000..e404912 --- /dev/null +++ b/src/server/deep_agent/agents/painter.py @@ -0,0 +1,22 @@ +from langchain.agents.middleware import wrap_tool_call + +from src.server.deep_agent.agents.init_llm import llm +from src.server.deep_agent.init_prompt import build_painter_prompt +from src.server.deep_agent.tools.generate_furniture_sketch import generate_furniture + + +@wrap_tool_call +async def log_tool_calls(request, handler): + """Intercept and log every tool call - demonstrates cross-cutting concern.""" + print(request) + return handler(request) + + +painter_subagent = { + "name": "painter_subagent", + "description": "理解用户意图,使用prompt,调用generate_furniture工具生成家具sketch草图.", + "system_prompt": build_painter_prompt(), + "tools": [generate_furniture], + "model": llm, + # "middleware": [log_tool_calls], +} diff --git a/src/server/deep_agent/agents/researcher.py b/src/server/deep_agent/agents/researcher.py new file mode 100644 index 0000000..841876c --- /dev/null +++ b/src/server/deep_agent/agents/researcher.py @@ -0,0 +1,21 @@ +from src.server.deep_agent.agents.init_llm import llm +from src.server.deep_agent.init_prompt import build_researcher_prompt +from src.server.deep_agent.tools.crawl_tool import crawl4ai_batch +from src.server.deep_agent.tools.report_generator_tool import report_generator +from src.server.deep_agent.tools.research_tool import topic_research +from src.server.deep_agent.tools.structured_retrieval_tool import structured_retrieval +from src.server.deep_agent.tools.user_persona_tool import query_report_profile + +research_subagent = { + "name": "research-agent", + "description": "通过网络搜索对家具设计开展深度研究并整合结论", + "system_prompt": build_researcher_prompt(), + "tools": [ + query_report_profile, + topic_research, + crawl4ai_batch, + structured_retrieval, + report_generator + ], + "model": llm +} diff --git a/src/server/deep_agent/agents/user_profile.py b/src/server/deep_agent/agents/user_profile.py new file mode 100644 index 0000000..9f9bdc4 --- /dev/null +++ b/src/server/deep_agent/agents/user_profile.py @@ -0,0 +1,15 @@ +from src.server.deep_agent.agents.init_llm import llm +from src.server.deep_agent.init_prompt import build_user_persona_prompt +from src.server.deep_agent.tools.user_persona_tool import query_report_profile, update_report_profile, check_profile_complete + +user_profile_subagent = { + "name": "user_profile_subagent", + "description": "收集用户报告画像并存储到MongoDB", + "system_prompt": build_user_persona_prompt(), + "model": llm, + "tools": [ + query_report_profile, + update_report_profile, + check_profile_complete, + ], +} diff --git a/src/server/deep_agent/init_prompt.py b/src/server/deep_agent/init_prompt.py new file mode 100644 index 0000000..260d033 --- /dev/null +++ b/src/server/deep_agent/init_prompt.py @@ -0,0 +1,141 @@ +def build_system_prompt(use_report): + system_prompt = f""" + 你是主调度 Agent(Supervisor),负责理解用户意图并选择合适的子Agent。 + 当前参数: + use_report = {use_report} + 系统中存在两个相关子Agent: + 1. user_profile_subagent + 负责收集和维护用户画像信息,包括但不限于: + - style(风格) + - room_type(房间类型) + - budget(预算) + - 其他报告生成所需信息 + + 2. research-subagent + 负责生成完整报告、调研、总结、分析。 + + 3. painter_subagent + 负责根据用户描述,构造适用于生成家具sketch的prompt,使用prompt用工具生成图片. + + ======================== + 执行规则 + ======================== + 【1】当用户请求报告 / 调研 / 分析 / 总结时: + 先判断是否已经具备足够的用户画像信息。 + 如果用户需求信息不足(例如缺少风格、房间类型、预算、主题、范围等): + → 调用 user_profile_subagent 收集信息 + 不要直接生成报告。 + 如果用户画像信息已经完整: + → 调用 research-subagent 生成报告。 + ------------------------ + 【2】当 use_report = False 时: + - 严禁调用 research-subagent + - 如果用户明确请求报告、调研、总结、分析: + + 请礼貌回复: + "报告功能当前未开启,你可以打开 use_report=True 后我来帮你生成报告。" + - 其他普通问题可以正常回答或调用其他子Agent。 + ------------------------ + 【3】用户画像优先级规则 + 只要用户输入包含以下情况: + - 表达设计需求 + - 提供偏好信息(例如风格、预算、房间类型) + - 修改之前的偏好 + - 补充报告信息 + 都应该优先调用: + user_profile_subagent + 用于更新或收集用户画像。 + ------------------------ + 【4】调度原则 + - user_profile_subagent 只负责 **信息收集** + - research-subagent 只负责 **报告生成** + 不要混用职责。 + ======================== + 严格输出规则 + ======================== + - 当生成图片时,绝对不要输出图片路径、file:// 地址、URL、本地链接 + - 只输出文字描述,不输出任何图片链接或路径 + """ + return system_prompt + + +def build_painter_prompt(): + prompt = """ + 你是一名专业的prompt优化专家,专注于家具设计草图生成。你的任务是: + 1. 分析用户查询,理解核心意图,包括家具类型、风格、尺寸、颜色、材料等关键元素 + 2. 基于意图,优化并生成一个详细、精确的prompt,适合用于AI图片生成工具创建家具sketch草图(例如,线条简洁、手绘风格、焦点在设计细节上) + 3. 使用优化的prompt调用图片生成工具,生成并返回草图图片 + 4. 如果需要,建议额外变体或改进 + + 输出格式: + - 用户意图总结(1–2段) + - 优化后的prompt(完整文本) + - 生成的图片描述(如果工具返回) + - 建议改进(项目符号,可选) + 【严格输出规则】 + - 当生成图片时,**绝对不要输出图片路径、file:// 地址、URL、本地链接**。 + - 只输出文字描述,不输出任何图片链接或路径。 + + """ + return prompt + + +def build_researcher_prompt(): + prompt = """ + 你是一名专业的家具设计研究员。你的任务是: + + 【0】获取用户画像: + - 首先调用 get_user_profile 工具,获取当前用户画像信息(如风格、房间类型、预算等)。 + - 根据用户画像,生成五个与用户需求和偏好高度相关的研究词条。 + + 【1】关键词拆解: + 1. 将研究主题结合用户画像拆解为可搜索的查询关键词 + 2. 将关键词组合成五个待搜索的词条 + + 【2】搜索与爬取: + 3. 使用 topic_research 工具搜索这五个词条获取相关、权威的网址 + 4. 使用 crawl4ai_batch 批量爬取网址(仅可调用一次,禁止重复调用) + + 【3】结构化处理与报告: + 5. 使用 structured_retrieval 对爬取内容进行结构化提取(重点:设计趋势、材质创新、颜色应用、代表案例、品牌参考) + 6. 使用 report_generator 基于提取内容生成完整 Markdown 报告 + + 【严格工具调用规则】: + - 调用顺序必须严格:get_user_profile → topic_research → crawl4ai_batch(仅一次) → structured_retrieval → report_generator。 + - 不得跳回前面步骤或重复任何工具。 + - 如果爬取结果为空或极少,直接说明: + “由于部分来源暂时不可访问,本报告基于有限可用信息生成,可能不够全面。如需更完整资料,请提供具体网址或调整需求。” + - 一旦生成 report_generator 的输出,就视为任务完成,直接结束,不要再思考或调用其他工具。 + - crawl4ai_batch 最多只能调用一次,即使部分网址失败,也禁止再次调用 crawl4ai_batch 或 topic_research。 + + 现在开始严格执行以上规则。 + """ + return prompt + + +def build_user_persona_prompt(): + prompt = """ + 你是用户画像收集助手。 + + 你的任务是从用户对话中理解并提取报告画像信息,包括但不限于: + - style(装修风格) + - room_type(房间类型) + - budget(预算) + + 工作流程: + + 1. 先调用 query_report_profile 查询当前画像 + 2. 从用户输入中理解是否包含新的画像信息 + 3. 如果有新的信息,合并旧画像并调用 update_report_profile 更新 + 4. 调用 check_profile_complete 判断是否完整 + 5. 如果缺少字段,引导用户补充 + 6. 如果完整,回复: + + "画像收集完成,即将为你生成报告!" + + 注意: + - 不要编造信息 + - 不要覆盖已有字段,除非用户明确修改 + - 只负责画像收集,不生成报告 + """ + return prompt diff --git a/src/server/deep_agent/run_test.py b/src/server/deep_agent/run_test.py new file mode 100644 index 0000000..03d34a7 --- /dev/null +++ b/src/server/deep_agent/run_test.py @@ -0,0 +1,131 @@ +import asyncio +import uuid + +from langchain_core.messages import AIMessageChunk, ToolMessageChunk, ToolMessage +from src.server.deep_agent.agents.main_agent import build_main_agent + +agent = build_main_agent(use_report=True) + + +async def continuous_chat(): + thread_id = str(uuid.uuid4()) + print("===== 家具设计助手(支持持续对话+记忆)=====") + print("输入 'exit' 或 '退出' 结束对话\n") + + while True: + user_input = input("你:") # 注意:input() 在异步中仍是阻塞的,但对 CLI 够用 + + if user_input.lower() in ["exit", "退出", "q", "quit"]: + print("助手:再见!如需继续设计,随时回来~") + break + + if not user_input.strip(): + print("助手:请输入有效的设计需求,我会尽力解答~") + continue + + print("\n助手:正在处理你的需求...\n") + + # 现在可以安全使用 async for + async for stream in agent.astream( + {"messages": user_input}, + stream_mode=["updates", "messages", "custom"], + subgraphs=True, + version="v2", + config={"configurable": {"thread_id": thread_id}} + ): + + print(stream) + _, mode, chunks = stream + if mode == "updates": + print(f"[updates] {chunks}") + + elif mode == "messages": + token, metadata = chunks + subagent_name = metadata.get('lc_agent_name', "main_agent") + + if isinstance(token, AIMessageChunk): # 默认回复 思考内容 + reasoning = [b for b in token.content_blocks if b["type"] == "reasoning"] + text = [b for b in token.content_blocks if b["type"] == "text"] + if reasoning: + print(f"[thinking] {reasoning[0]['reasoning']}", end="") + if text: + print(text[0]["text"], end="") + + elif isinstance(token, ToolMessageChunk): # 工具返回 + print(f"[tool|{token.name}] {token.content}", end="") + + elif isinstance(token, ToolMessage): # 工具返回 + print(f"[tool|{token.name}] {token.content}", end="") + else: + continue + + elif mode == "custom": + print(f"[report] {chunks.get('delta', '')}", end="") + + # if chunk["type"] == "messages": + # token, metadata = chunk["data"] + # if not isinstance(token, AIMessageChunk): + # continue + # reasoning = [b for b in token.content_blocks if b["type"] == "reasoning"] + # text = [b for b in token.content_blocks if b["type"] == "text"] + # if reasoning: + # print(f"[thinking] {reasoning[0]['reasoning']}", end="") + # if text: + # print(text[0]["text"], end="") + # print(chunk) + # namespace, _, chunk = event + # token, metadata = chunk + # Identify source: "main" or the subagent namespace segment + # is_subagent = any(s.startswith("tools:") for s in namespace) + + # source = next((s for s in namespace if s.startswith("tools:")), "main") if is_subagent else "main" + + # if token.content_blocks: + # if token.additional_kwargs.get("reasoning_content", None): # 粗糙但常见判断 + # if not has_printed_thinking_header: + # print("[思考过程]") + # has_printed_thinking_header = True + # print(token.content_blocks[0].get("reasoning", ""), end="", flush=True) + # else: + # if not has_printed_header: + # print("[agent回答]") + # has_printed_header = True + # print(token.content_blocks[0].get("text", ""), end="", flush=True) + # + # # Tool call chunks (streaming tool invocations) + # if token.tool_call_chunks: + # for tc in token.tool_call_chunks: + # if tc.get("name"): + # print(f"\n[{source}] Tool call: {tc['name']}") + # # Args stream in chunks - write them incrementally + # if tc.get("args"): + # print(tc["args"], end="", flush=True) + # + # # Tool results + # if token.type == "tool": + # print(f"\n[{source}] Tool result [{token.name}]: {str(token.content)[:150]}") + # + # # Regular AI content (skip tool call messages) + # if token.type == "ai" and token.content and not token.tool_call_chunks: + # print(token.content, end="", flush=True) + + # if namespace: + # print(f"[子代理: {namespace}]") + # else: + # print("[主助手]") + # print(chunk) + # print("-" * 50 + "\n") + # + # chunk_list.append(str(chunk)) + # + # if not chunk_list: + # assistant_response = "抱歉,我暂时无法处理你的请求,请稍后再试。" + # else: + # assistant_response = "\n".join(chunk_list) + # + # print(f"[最终完整回复]\n{assistant_response}\n" + "=" * 60 + "\n") + + +# 启动方式改成: +if __name__ == "__main__": + asyncio.run(continuous_chat()) diff --git a/src/server/deep_agent/tools/conversation_title_tool.py b/src/server/deep_agent/tools/conversation_title_tool.py new file mode 100644 index 0000000..f743b91 --- /dev/null +++ b/src/server/deep_agent/tools/conversation_title_tool.py @@ -0,0 +1,27 @@ +from langchain_core.prompts import PromptTemplate + +from src.server.deep_agent.agents.init_llm import title_llm + + +def conversation_title(full_conversation): + title_prompt = PromptTemplate( + input_variables=["full_conversation"], + template=""" + 请严格按照以下要求生成对话标题: + 1. 标题长度:8-15个字,纯中文,无标点、无特殊符号、无换行 + 2. 标题内容:基于完整对话,精准概括核心主题(兼顾用户需求和助手回复) + 3. 标题风格:自然口语化,符合中文表达习惯,不冗余 + + 完整对话内容: + {full_conversation} + + 仅输出标题,不要输出任何额外解释、说明或标点符号。 + """ + ) + title_chain = title_prompt | title_llm + response = title_chain.invoke({"full_conversation": full_conversation}) + return response + + +if __name__ == '__main__': + print(conversation_title("你好")) diff --git a/src/server/deep_agent/tools/crawl_tool.py b/src/server/deep_agent/tools/crawl_tool.py new file mode 100644 index 0000000..7ef9a0d --- /dev/null +++ b/src/server/deep_agent/tools/crawl_tool.py @@ -0,0 +1,191 @@ +import time +import asyncio +from typing import List, Dict, Any +from urllib.parse import urlparse +from pathlib import Path + +import uuid +from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode +from langchain_core.tools import tool + +# ───────────────────────────────────── +# 路径配置 +# ───────────────────────────────────── + +TOOL_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = TOOL_DIR.parent + +# DeepAgents 推荐目录 +SAVE_DIR = PROJECT_ROOT / "agent_workspace" / "raw_data" +SAVE_DIR.mkdir(parents=True, exist_ok=True) + +print(f"tool save : {str(PROJECT_ROOT / "agent_workspace")}") + +# ───────────────────────────────────── +# Browser 配置 +# ───────────────────────────────────── + +browser_config = BrowserConfig( + headless=True, + verbose=False, + java_script_enabled=True, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/118.0 Safari/537.36" + ), +) + +run_config = CrawlerRunConfig( + cache_mode=CacheMode.BYPASS, + word_count_threshold=5, + excluded_tags=["script", "style", "nav", "footer"], + remove_overlay_elements=True, + process_iframes=True, +) + + +# ───────────────────────────────────── +# URL → 文件名 +# ───────────────────────────────────── + +def build_filename(url: str) -> str: + parsed = urlparse(url) + + domain = parsed.netloc.replace("www.", "").replace(".", "_") + path_part = parsed.path.strip("/").replace("/", "_")[:50] or "index" + + ts = int(time.time()) + rand = uuid.uuid4().hex[:6] + + return f"{ts}_{rand}_{domain}_{path_part}.md" + + +# ───────────────────────────────────── +# 单个 URL 抓取 +# ───────────────────────────────────── + +async def crawl_one(crawler, url: str, sem: asyncio.Semaphore) -> Dict[str, Any]: + async with sem: + try: + result = await crawler.arun(url=url, config=run_config) + + if not result.success: + return { + "url": url, + "success": False, + "error": f"status={getattr(result, 'status_code', 'unknown')}" + } + + markdown = result.markdown or "" + + if len(markdown) < 500: + return { + "url": url, + "success": False, + "error": "content too short" + } + + filename = build_filename(url) + filepath = SAVE_DIR / filename + + header = ( + f"\n" + f"\n\n" + ) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(header + markdown) + + return { + "url": url, + "success": True, + "file": str(filepath) + } + + except Exception as e: + return { + "url": url, + "success": False, + "error": str(e) + } + + +# ───────────────────────────────────── +# Async 主逻辑 +# ───────────────────────────────────── + +async def _crawl4ai_batch(urls: List[str]) -> Dict[str, Any]: + urls = list(set(urls)) # 去重 + + if not urls: + return {"error": "no urls"} + + sem = asyncio.Semaphore(5) # 并发限制 + + async with AsyncWebCrawler(config=browser_config) as crawler: + + tasks = [ + crawl_one(crawler, url, sem) + for url in urls + ] + + results = await asyncio.gather(*tasks) + + success_files = [] + summary = [] + + for r in results: + + if r["success"]: + success_files.append(r["file"]) + summary.append(f"✅ {r['url']}") + else: + summary.append(f"❌ {r['url']} ({r['error']})") + + return { + "saved_files": success_files, + "count": len(success_files), + "summary": summary, + } + + +# ───────────────────────────────────── +# Tool(同步) +# ───────────────────────────────────── +@tool +def crawl4ai_batch(urls: List[str]) -> str: + """ + Batch crawl webpages and save their content as markdown files. + + Args: + urls: List of webpage URLs to crawl. + + Returns: + A summary of crawling results and saved file paths. + """ + + try: + result = asyncio.run(_crawl4ai_batch(urls)) + + if "error" in result: + return f"❌ Error: {result['error']}" + + output = [ + "### 批量抓取完成 ###", + f"成功保存文件: {result['count']}", + f"保存目录: {SAVE_DIR}", + "", + "抓取详情:" + ] + + output.extend(result["summary"]) + + if result["saved_files"]: + output.append("\n可读取文件:") + output.extend(result["saved_files"]) + + return "\n".join(output) + + except Exception as e: + return f"🚨 爬虫系统异常: {str(e)}" diff --git a/src/server/deep_agent/tools/generate_furniture_sketch.py b/src/server/deep_agent/tools/generate_furniture_sketch.py new file mode 100644 index 0000000..cb671c3 --- /dev/null +++ b/src/server/deep_agent/tools/generate_furniture_sketch.py @@ -0,0 +1,94 @@ +import json +import logging +import uuid +from google.oauth2 import service_account +from langchain_core.tools import tool +from google import genai +from google.genai.types import GenerateContentConfig, Modality + +from minio import Minio + +from src.core.config import settings +from src.server.utils.new_oss_client import oss_upload_image + +logger = logging.getLogger(__name__) +# 初始化全局凭证和客户端 +creds = service_account.Credentials.from_service_account_file( + settings.GOOGLE_GENAI_USE_VERTEXAI, + scopes=["https://www.googleapis.com/auth/cloud-platform"], +) + +minio_client = Minio(settings.MINIO_URL, access_key=settings.MINIO_ACCESS, secret_key=settings.MINIO_SECRET, secure=settings.MINIO_SECURE) +client = genai.Client( + credentials=creds, + project=settings.GOOGLE_CLOUD_PROJECT, + location=settings.GOOGLE_CLOUD_LOCATION, + vertexai=True +) + + +@tool +async def generate_furniture(prompt: str) -> str: + """ + 使用 Gemini 图像生成模型根据详细的英文提示词生成家具设计草图。 + """ + print(f"\n[系统日志] 正在调用 Nano Banana (Gemini Image Gen) ...") + + try: + response = client.models.generate_content( + model="gemini-2.5-flash-image", + contents=(f"Generate a professional furniture design sketch: {prompt}"), + config=GenerateContentConfig( + response_modalities=[Modality.TEXT, Modality.IMAGE], + ), + ) + + image_bytes = None + for part in response.candidates[0].content.parts: + if part.inline_data: + image_bytes = part.inline_data.data + break + + if not image_bytes: + return "未能生成图像数据。" + object_name = f"furniture/sketches/{uuid.uuid4()}.png" + bucket = "fida-test" # 替换为你的 bucket 名称 + # 3. 调用你的上传函数 + upload_res = oss_upload_image( + oss_client=minio_client, + bucket=bucket, + object_name=object_name, + image_bytes=image_bytes + ) + + if upload_res: + # 4. 构造访问链接 (如果是私有 bucket,需使用 presigned_get_object) + # 这里简单示例为直接访问地址 + image_url = f"{bucket}/{object_name}" + return json.dumps( + { + "tool_name": "generate_furniture", + "data": image_url, + "tool_status": "success" + }, + ensure_ascii=False + ) + else: + return json.dumps( + { + "tool_name": "generate_furniture", + "data": "图片生成成功,但上传至存储服务器失败。", + "tool_status": "error" + }, + ensure_ascii=False + ) + except Exception as e: + logger.warning(e) + return json.dumps( + { + "tool_name": "generate_furniture", + "data": f"绘图流程异常", + "tool_status": "error" + }, + ensure_ascii=False + ) diff --git a/src/server/deep_agent/tools/report_generator_tool.py b/src/server/deep_agent/tools/report_generator_tool.py new file mode 100644 index 0000000..1dc9885 --- /dev/null +++ b/src/server/deep_agent/tools/report_generator_tool.py @@ -0,0 +1,151 @@ +import os +import json +import re +from typing import Optional, List, Dict +from langchain_qwq import ChatQwen +from langgraph.config import get_stream_writer +from pydantic import BaseModel, Field +from langchain_core.tools import tool +from langchain_core.messages import SystemMessage, HumanMessage + +from src.core.config import settings + +# ========================= +# LLM 初始化 +# ========================= + + +llm = ChatQwen( + enable_thinking=False, + model="qwen3.5-flash", + temperature=0.2, + max_tokens=3_000, + timeout=None, + max_retries=2, + api_key=settings.QWEN_API_KEY) + + +# ========================= +# Tool 输入 Schema +# ========================= + +class ReportInput(BaseModel): + report_topic: str = Field( + ..., + description="Main topic of the report, e.g. '2026 Sofa Design Trends'" + ) + structured_data: List[Dict] = Field( + ..., + description="Structured retrieval result items" + ) + language: Optional[str] = Field( + default="English", + description="Output language" + ) + + +# ========================= +# LangGraph Tool +# ========================= + +@tool("report_generator", args_schema=ReportInput) +async def report_generator( + report_topic: str, + structured_data: List[Dict], + language: str = "English" +) -> dict: + """ + Generate a professional design/market report + directly from structured retrieval results. + """ + + writer = get_stream_writer() + if not structured_data: + error_msg = "Error: No structured data provided." + writer({"type": "report_error", "message": error_msg}) + return error_msg + + collected_data_str = json.dumps( + structured_data, + ensure_ascii=False, + indent=2 + ) + + # ========================= + # Prompt + # ========================= + + system_prompt = f""" + You are a professional design trend analyst. + + Generate a long, structured Markdown report. + + REQUIREMENTS: + + 1. Follow MECE principle. + 2. Embed images ONLY if they start with https:// + using: ![alt](url) + 3. Insert images inline. + 4. Every key insight must cite source: + [Website Name](url) + 5. Use Markdown headings. + 6. Start directly with title. + 7. Be detailed and analytical. + + Output Language: {language} + """ + + user_prompt = f""" + Topic: {report_topic} + + Input Data: + {collected_data_str} + """ + + # ========================= + # 调用 LLM + # ========================= + writer({"type": "report_start", "topic": report_topic, "language": language}) + + full_report = "" + try: + report_llm = llm.with_config( + callbacks=[] + ) + async for chunk in report_llm.astream( + [ + SystemMessage(content=system_prompt), + HumanMessage(content=user_prompt) + ] + ): + if chunk.content: # Gemini 返回的 chunk.content + delta = chunk.content + full_report += delta + # return {"type": "report_delta", "delta": delta} + writer({"type": "report_delta", "delta": delta}) # ← 实时推送给前端 + writer({"type": "report_stop", "topic": report_topic, "language": language}) + except Exception as e: + error_msg = f"LLM generation failed: {str(e)}" + writer({"type": "report_error", "message": error_msg}) + return error_msg + + report_content = full_report.strip() + + # ========================= + # 保存报告 + # ========================= + output_dir = "workspace/reports" + os.makedirs(output_dir, exist_ok=True) + + safe_topic = re.sub(r'[\\/*?:"<>|]', "", report_topic.replace(" ", "_")) + filename = f"{output_dir}/{safe_topic}.md" + + try: + with open(filename, "w", encoding="utf-8") as f: + f.write(report_content) + writer({"type": "report_complete", "file_path": filename}) + except Exception as e: + writer({"type": "report_save_warning", "message": str(e)}) + + # 返回完整内容(作为 tool result),同时正文已通过 delta 流式输出 + return report_content + f"\n\n✅ Report saved to: {filename}" diff --git a/src/server/deep_agent/tools/research_tool.py b/src/server/deep_agent/tools/research_tool.py new file mode 100644 index 0000000..49fa09c --- /dev/null +++ b/src/server/deep_agent/tools/research_tool.py @@ -0,0 +1,67 @@ +import asyncio +import json +from datetime import datetime +from typing import List, Set, Optional +from langchain_core.tools import tool +from tavily import TavilyClient + +from src.core.config import settings + +# 模拟配置加载 +TAVILY_API_KEY = settings.TAVILY_API_KEY + + +@tool +async def topic_research(topic: list[str], max_urls: int = 5) -> str: + """ + 深度调研工具。该工具会利用 Tavily 搜索引擎针对特定主题进行多维度搜索。 + 它会自动生成针对性的搜索词(包含年份和趋势),并返回去重后的高质量 URL 列表。 + """ + if not TAVILY_API_KEY: + return "❌ 错误: 未配置 TAVILY_API_KEY。" + + client = TavilyClient(api_key=TAVILY_API_KEY) + + # 1. 自动生成多维度搜索词 (在工具内部快速生成) + + # 2. 并行执行搜索 + async def perform_search(q: str): + # 使用 asyncio.to_thread 运行同步的 Tavily SDK + def sync_search(): + try: + response = client.search( + query=q, + search_depth="advanced", + max_results=5, + include_answer=False + ) + return response.get('results', []) + except Exception as e: + print(f"Search error: {e}") + return [] + + return await asyncio.to_thread(sync_search) + + search_tasks = [perform_search(q) for q in topic] + search_results_list = await asyncio.gather(*search_tasks) + + # 3. 结果去重与过滤 + seen_urls: Set[str] = set() + final_urls = [] + + # 常见的非内容页面过滤 + skip_extensions = ('.pdf', '.jpg', '.png', '.zip', '.exe') + + for results in search_results_list: + for item in results: + url = item.get('url') + if url and url not in seen_urls: + if not url.lower().endswith(skip_extensions): + seen_urls.add(url) + final_urls.append(url) + + # 4. 结果截断 + selected_urls = final_urls[:max_urls] + + # 返回 JSON 字符串,便于 Agent 下一步调用批量爬虫 (Crawl4ai) + return json.dumps(selected_urls, ensure_ascii=False) diff --git a/src/server/deep_agent/tools/structured_retrieval_tool.py b/src/server/deep_agent/tools/structured_retrieval_tool.py new file mode 100644 index 0000000..8288146 --- /dev/null +++ b/src/server/deep_agent/tools/structured_retrieval_tool.py @@ -0,0 +1,225 @@ +import os +import re +import json +from datetime import datetime +from typing import List, Dict, Optional + +from pydantic import BaseModel, Field +from langchain_core.tools import tool +from langchain_core.documents import Document + +# RAG +from langchain_community.vectorstores import FAISS +from langchain_huggingface import HuggingFaceEmbeddings +from sentence_transformers import CrossEncoder + +# ========================= +# 全局模型(单例) +# ========================= + +_EMBEDDING_MODEL = HuggingFaceEmbeddings( + model_name="sentence-transformers/all-MiniLM-L6-v2" +) + +_RERANK_MODEL = CrossEncoder( + "cross-encoder/ms-marco-MiniLM-L-6-v2" +) + + +class StructuredRetrievalInput(BaseModel): + file_paths: List[str] = Field(..., description="List of local markdown file paths.") + query: str = Field(..., description="Extraction query") + source_url: Optional[str] = Field(None, description="Optional global source URL") + + +@tool("structured_retrieval", args_schema=StructuredRetrievalInput) +def structured_retrieval( + file_paths: List[str], + query: str, + source_url: Optional[str] = None +) -> Dict: + """ + Batch structured extraction from markdown files. + - Performs vector search + re-ranking + - Saves extracted structured data as JSON file to disk + - Returns ONLY summary (status, count, file path) + """ + + # ── 1. 收集所有文件內容 ────────────────────────────────────── + all_docs_pool: List[Document] = [] + + for path in file_paths: + if not os.path.exists(path) or not path.endswith((".md", ".markdown")): + continue + + file_name = os.path.basename(path) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + current_source = source_url or _extract_source_from_md(content) or "unknown" + + sections = _split_markdown_by_headers(content) + + for sec in sections: + all_docs_pool.append( + Document( + page_content=sec, + metadata={"source_url": current_source, "file_name": file_name} + ) + ) + + if not all_docs_pool: + return {"status": "no_documents_found", "items_count": 0, "json_path": None} + + # ── 2. Vector search ──────────────────────────────────────────── + vector_store = FAISS.from_documents(all_docs_pool, _EMBEDDING_MODEL) + retrieved = vector_store.similarity_search(query, k=200) + + # ── 3. 提取結構化片段 ────────────────────────────────────────── + structured_items = [] + + for doc in retrieved: + text = doc.page_content.strip() + if len(text) < 30: + continue + + images = list(set(re.findall(r"!\[.*?\]\((.*?)\)", text))) + + structured_items.append( + { + "text": text, + "images": images, + "source_url": doc.metadata.get("source_url"), + "file_name": doc.metadata.get("file_name") + } + ) + + # ── 4. Re-rank ────────────────────────────────────────────────── + if structured_items: + unique_items = {item["text"]: item for item in structured_items}.values() + pairs = [[query, item["text"]] for item in unique_items] + scores = _RERANK_MODEL.predict(pairs) + + sorted_items = sorted( + zip(scores, unique_items), + key=lambda x: x[0], + reverse=True + ) + top_items = [item for _, item in sorted_items[:50]] + else: + top_items = [] + + # ── 5. 寫入 JSON 文件 ────────────────────────────────────────── + if not top_items: + return {"status": "no_relevant_content", "items_count": 0, "json_path": None} + + # 產生有意義的檔名 + safe_query = re.sub(r'[^a-zA-Z0-9\u4e00-\u9fa5]', '_', query)[:40] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + json_filename = f"extracted_{safe_query}_{timestamp}.json" + + # 建議的儲存目錄(與 crawl4ai_batch 對齊) + output_dir = os.path.join(os.path.dirname(file_paths[0]), "..", "extracted") + os.makedirs(output_dir, exist_ok=True) + + json_path = os.path.join(output_dir, json_filename) + + with open(json_path, "w", encoding="utf-8") as f: + json.dump( + { + "query": query, + "extracted_at": timestamp, + "item_count": len(top_items), + "items": top_items + }, + f, + ensure_ascii=False, + indent=2 + ) + + # ── 6. 只回傳摘要 ────────────────────────────────────────────── + return { + "status": "success", + "items_count": len(top_items), + "json_path": json_path, + "summary": f"已提取 {len(top_items)} 個高相關片段,儲存於 {json_path}" + } + + +def _extract_source_from_md(content: str) -> Optional[str]: + match = re.search(r"", content) + return match.group(1).strip() if match else None + + +# ========================= +# Markdown Header Split +# ========================= + +def _split_markdown_by_headers( + content: str, + max_chars: int = 2000, + overlap: int = 150, +): + header_re = re.compile( + r'^(#{1,6})\s+(.+?)\s*$', + re.MULTILINE + ) + + matches = list(header_re.finditer(content)) + + if not matches: + return _chunk_text(content, max_chars, overlap) + + sections = [] + + for i, m in enumerate(matches): + start = m.start() + end = ( + matches[i + 1].start() + if i + 1 < len(matches) + else len(content) + ) + + block = content[start:end].strip() + if block: + sections.append(block) + + final_sections = [] + + for s in sections: + if len(s) > max_chars: + final_sections.extend( + _chunk_text(s, max_chars, overlap) + ) + else: + final_sections.append(s) + + return final_sections + + +def _chunk_text( + text: str, + max_chars: int = 2000, + overlap: int = 150 +): + text = text.strip() + if len(text) <= max_chars: + return [text] + + chunks = [] + start = 0 + + while start < len(text): + end = min(len(text), start + max_chars) + chunk = text[start:end].strip() + + if chunk: + chunks.append(chunk) + + if end == len(text): + break + + start = max(0, end - overlap) + + return chunks diff --git a/src/server/deep_agent/tools/user_persona_tool.py b/src/server/deep_agent/tools/user_persona_tool.py new file mode 100644 index 0000000..21bcb2b --- /dev/null +++ b/src/server/deep_agent/tools/user_persona_tool.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import tool +from pymongo import MongoClient +from src.core.config import MONGO_URI + +client = MongoClient(MONGO_URI) +db = client["report_agent"] +collection = db["user_profiles"] + + +@tool +def query_report_profile(config: RunnableConfig, ) -> dict: + """ + 查询用户报告画像 + """ + thread_id = config['configurable']['thread_id'] + doc = collection.find_one({"thread_id": thread_id}) + + if not doc: + return {"profile": {}} + + doc.pop("_id", None) + return doc + + +@tool +def update_report_profile(config: RunnableConfig, profile: dict) -> dict: + """ + 更新用户画像信息 + """ + thread_id = config['configurable']['thread_id'] + collection.update_one( + {"thread_id": thread_id}, + { + "$set": { + "profile": profile + } + }, + upsert=True + ) + + return {"status": "success", "profile": profile} + + +@tool +def check_profile_complete(profile: dict) -> dict: + """ + 判断画像是否完整 + """ + required = ["style", "room_type", "budget"] + missing = [f for f in required if f not in profile] + return { + "complete": len(missing) == 0, + "missing_fields": missing + } diff --git a/src/server/utils/new_oss_client.py b/src/server/utils/new_oss_client.py index ce90e79..49696cb 100644 --- a/src/server/utils/new_oss_client.py +++ b/src/server/utils/new_oss_client.py @@ -58,7 +58,7 @@ def oss_upload_image(oss_client, bucket, object_name, image_bytes): if __name__ == '__main__': - url = "aida-users/89/sketch/123-89.png" + url = "fida-test/furniture/sketches/4449a66d-6267-43f7-86a2-1e42bd19ec61.png" read_type = "2" img = oss_get_image(oss_client=minio_client, bucket=url.split('/')[0], object_name=url[url.find('/') + 1:], data_type=read_type) img.show() diff --git a/uv.lock b/uv.lock index 9399468..5195287 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,18 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "agentstate" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/29332426e1632601b41eeb90c1ec13e3f764d02b772196593dbdb44d5e56/agentstate-1.0.2.tar.gz", hash = "sha256:2578877862efb692a4f3e467211eeca34b2a1f0a0260301c9577592c5d8c8d0d", size = 5815, upload-time = "2025-08-22T19:22:03.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f9/fcd813b63645fd7108ef32741062f826c706af1a960f47564e3cdbad4eb9/agentstate-1.0.2-py3-none-any.whl", hash = "sha256:48375add0cfee4ecb6bb09bbb37f564d3b027e471cc635b2e354f5d32c6d3bce", size = 6026, upload-time = "2025-08-22T19:22:02.245Z" }, +] + [[package]] name = "aiofiles" version = "25.1.0" @@ -653,6 +665,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] +[[package]] +name = "datetime" +version = "6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/decbfd165e9985ba9d8c2d34a39afe5aeba2fc3fe390eb6e9ef1aab98fa8/datetime-6.0.tar.gz", hash = "sha256:c1514936d2f901e10c8e08d83bf04e6c9dbd7ca4f244da94fec980980a3bc4d5", size = 64167, upload-time = "2025-11-25T08:00:34.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/7a/ea0f3e3ea74be36fc7cf54f966cde732a3de72697983cdb5646b0a4dacde/datetime-6.0-py3-none-any.whl", hash = "sha256:d19988f0657a4e72c9438344157254a8dcad6aea8cd5ae70a5d1b5a75e5dc930", size = 52637, upload-time = "2025-11-25T08:00:33.077Z" }, +] + [[package]] name = "deepagents" version = "0.4.5" @@ -944,15 +969,18 @@ name = "fida" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "agentstate" }, { name = "asyncio" }, { name = "chardet" }, { name = "crawl4ai" }, { name = "dashscope" }, + { name = "datetime" }, { name = "deepagents" }, { name = "faiss-cpu" }, { name = "fastapi", extra = ["standard"] }, { name = "gunicorn" }, { name = "image" }, + { name = "langchain-classic" }, { name = "langchain-community" }, { name = "langchain-core" }, { name = "langchain-google-genai" }, @@ -960,9 +988,13 @@ dependencies = [ { name = "langchain-qwq" }, { name = "langgraph" }, { name = "langgraph-checkpoint-mongodb" }, + { name = "langgraph-checkpoint-postgres" }, + { name = "langgraph-store-mongodb" }, + { name = "langsmith" }, { name = "minio" }, { name = "modality" }, { name = "motor" }, + { name = "path" }, { name = "playwright" }, { name = "postgres" }, { name = "prompt" }, @@ -984,25 +1016,32 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "agentstate", specifier = ">=1.0.2" }, { name = "asyncio", specifier = ">=4.0.0" }, { name = "chardet", specifier = "<6" }, { name = "crawl4ai", specifier = ">=0.8.0" }, { name = "dashscope", specifier = ">=1.25.13" }, + { name = "datetime", specifier = ">=6.0" }, { name = "deepagents", specifier = ">=0.4.3" }, { name = "faiss-cpu", specifier = ">=1.13.2" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" }, { name = "gunicorn", specifier = ">=25.0.1" }, { name = "image", specifier = ">=1.5.33" }, + { name = "langchain-classic", specifier = ">=1.0.1" }, { name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-core", specifier = ">=1.2.8" }, { name = "langchain-google-genai", specifier = ">=4.2.0" }, { name = "langchain-huggingface", specifier = ">=1.2.0" }, { name = "langchain-qwq", specifier = ">=0.3.4" }, - { name = "langgraph", extras = ["postgres"], specifier = ">=1.0.7" }, + { name = "langgraph", extras = ["all", "postgres"], specifier = ">=1.0.7" }, { name = "langgraph-checkpoint-mongodb", specifier = ">=0.3.1" }, + { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.4" }, + { name = "langgraph-store-mongodb", specifier = ">=0.2.0" }, + { name = "langsmith", specifier = ">=0.7.13" }, { name = "minio", specifier = ">=7.2.20" }, { name = "modality", specifier = ">=0.1.0" }, { name = "motor", specifier = ">=3.7.1" }, + { name = "path", specifier = ">=17.1.1" }, { name = "playwright", specifier = ">=1.58.0" }, { name = "postgres", specifier = ">=4.0" }, { name = "prompt", specifier = ">=0.4.1" }, @@ -1805,6 +1844,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/a7/d989dde4f5007d69aeaf3a41faf2b868f0f3b9f834b7d557349068642635/langgraph_checkpoint_mongodb-0.3.1-py3-none-any.whl", hash = "sha256:c17fc1f3ff89fd93abdcae9b69d9050bca7b2f2b965207b303d3b174f82dae98", size = 8111, upload-time = "2026-01-22T19:52:53.094Z" }, ] +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/39/6a409958bd1e4e0804bbe4f9351e620f6087d5346e452c59824298a2a330/langgraph_checkpoint_postgres-3.0.4.tar.gz", hash = "sha256:83e6a1097563369173442de2a66e6d712d60a1a6de07c98c5130d476bb2b76ae", size = 127627, upload-time = "2026-01-31T00:44:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/56/7466f596add278798ab42697a56e992adde6866664afff6a5e4432540f29/langgraph_checkpoint_postgres-3.0.4-py3-none-any.whl", hash = "sha256:12cd5661da2a374882770deb9008a4eb16641c3fd38d7595e312030080390c6e", size = 42834, upload-time = "2026-01-31T00:44:15.118Z" }, +] + [[package]] name = "langgraph-prebuilt" version = "1.0.8" @@ -1831,6 +1885,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/4c/7a7510260fbda788efd13bf4650d3e7d80988118441ac811ec78e0aa03ac/langgraph_sdk-0.3.9-py3-none-any.whl", hash = "sha256:94654294250c920789b6ed0d8a70c0117fed5736b61efc24ff647157359453c5", size = 90511, upload-time = "2026-02-24T18:39:02.012Z" }, ] +[[package]] +name = "langgraph-store-mongodb" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-mongodb" }, + { name = "langgraph-checkpoint" }, + { name = "pymongo-search-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/af/0c3d6f62bc8f214079bbd80b5827a783ed590e359cdf08769f038256ffaa/langgraph_store_mongodb-0.2.0.tar.gz", hash = "sha256:95787059ac2178f6a0ef9c2f60c4ecd6dfeb11e6ae1e137fb79b71cd1536777e", size = 103346, upload-time = "2026-01-15T18:12:55.38Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/39/914b1359fed53590f28c21a8a21de44061f30fdf3b08f4fa9e76d64715ac/langgraph_store_mongodb-0.2.0-py3-none-any.whl", hash = "sha256:d72d1ef32f75f3cc3fbae2c5cbdddd1756e5cf56e1756f8e61442b0bf186c4ac", size = 9318, upload-time = "2026-01-15T18:12:54.114Z" }, +] + [[package]] name = "langsmith" version = "0.7.13" @@ -2533,6 +2601,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/2a/81ef2b079bbc925a935f2fd73dc1285c46c7eb35c5032a0d63b48d753c4a/patchright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:b044efea1774beac8ee033583eac7181b86ea450da3a36d3039d7a1a428ac098", size = 33064382, upload-time = "2026-01-30T15:27:19.725Z" }, ] +[[package]] +name = "path" +version = "17.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/52/a7bdd5ef8488977d354b7915d1e75009bebbd04f73eff14e52372d5e9435/path-17.1.1.tar.gz", hash = "sha256:2dfcbfec8b4d960f3469c52acf133113c2a8bf12ac7b98d629fa91af87248d42", size = 50528, upload-time = "2025-07-27T20:40:23.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/50/11c9ee1ede64b45d687fd36eb8768dafc57afc78b4d83396920cfd69ed30/path-17.1.1-py3-none-any.whl", hash = "sha256:ec7e136df29172e5030dd07e037d55f676bdb29d15bfa09b80da29d07d3b9303", size = 23936, upload-time = "2025-07-27T20:40:22.453Z" }, +] + [[package]] name = "pillow" version = "12.1.1" @@ -2813,6 +2890,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, ] +[[package]] +name = "psycopg-pool" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.11" @@ -3159,6 +3248,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -4624,6 +4722,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] +[[package]] +name = "zope-interface" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a0/1e1fabbd2e9c53ef92b69df6d14f4adc94ec25583b1380336905dc37e9a0/zope_interface-8.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:624b6787fc7c3e45fa401984f6add2c736b70a7506518c3b537ffaacc4b29d4c", size = 208785, upload-time = "2026-01-09T08:05:17.348Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2a/88d098a06975c722a192ef1fb7d623d1b57c6a6997cf01a7aabb45ab1970/zope_interface-8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc9ded9e97a0ed17731d479596ed1071e53b18e6fdb2fc33af1e43f5fd2d3aaa", size = 208976, upload-time = "2026-01-09T08:05:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e8/757398549fdfd2f8c89f32c82ae4d2f0537ae2a5d2f21f4a2f711f5a059f/zope_interface-8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:532367553e4420c80c0fc0cabcc2c74080d495573706f66723edee6eae53361d", size = 259411, upload-time = "2026-01-09T08:05:20.567Z" }, + { url = "https://files.pythonhosted.org/packages/91/af/502601f0395ce84dff622f63cab47488657a04d0065547df42bee3a680ff/zope_interface-8.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2bf9cf275468bafa3c72688aad8cfcbe3d28ee792baf0b228a1b2d93bd1d541a", size = 264859, upload-time = "2026-01-09T08:05:22.234Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/d2f765b9b4814a368a7c1b0ac23b68823c6789a732112668072fe596945d/zope_interface-8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0009d2d3c02ea783045d7804da4fd016245e5c5de31a86cebba66dd6914d59a2", size = 264398, upload-time = "2026-01-09T08:05:23.853Z" }, + { url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" }, + { url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" }, + { url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" }, + { url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" }, + { url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" }, + { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" }, +] + [[package]] name = "zstandard" version = "0.25.0"