merge: 合并 origin/xuhd,解决 message-list-item 和 WorkspaceRoutes 冲突
- message-list-item: 保留 EmbeddedPageContext + 消息内联编辑两套功能 - WorkspaceRoutes: 保留 IframeChatPage + NotebookDetailPageWithKey Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
712326387f
20 files changed
+859
-231
No files matched your search
+91
-2
@@ -1,8 +1,9 @@
|
||||
"""素材收集专家节点:关键词提取 → 并发搜索 → 相关度排序。"""
|
||||
"""素材收集专家节点:关键词提取 → 并发搜索(含笔记) → 相关度排序。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -21,6 +22,10 @@ from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
|
||||
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
|
||||
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
|
||||
|
||||
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
|
||||
_DEFAULT_MAX_MATERIALS = 50
|
||||
_MIN_MAX_MATERIALS = 1
|
||||
@@ -95,6 +100,67 @@ def _notice(title: str, detail: str, raw: str = "") -> dict:
|
||||
return event
|
||||
|
||||
|
||||
async def _search_notebook(
|
||||
keywords: list[str],
|
||||
search_space_id: int,
|
||||
source_ids: list[int],
|
||||
token: str,
|
||||
) -> list[Material]:
|
||||
"""从笔记 API 按关键词检索内容,返回 source_type='notebook' 的 Material 列表。"""
|
||||
import httpx # 懒导入:仅在笔记模式下才需要
|
||||
|
||||
base = (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
|
||||
if not base:
|
||||
logger.warning("DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search")
|
||||
return []
|
||||
|
||||
results: list[Material] = []
|
||||
mat_idx = 0
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{base}{_NOTEBOOK_SEARCH_PATH}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"query": kw,
|
||||
"source_ids": source_ids,
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": _NOTEBOOK_TOP_K,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# 解析 documents 列表,每个 chunk 作为独立素材
|
||||
documents: list[dict] = data.get("documents") if isinstance(data, dict) else []
|
||||
for doc in documents or []:
|
||||
doc_title: str = doc.get("title") or "笔记文档"
|
||||
doc_url: str = doc.get("url") or ""
|
||||
for chunk in doc.get("chunks") or []:
|
||||
chunk_content: str = (chunk.get("content") or "").strip()
|
||||
if not chunk_content:
|
||||
continue
|
||||
mat_idx += 1
|
||||
raw_score = chunk.get("score")
|
||||
chunk_score = float(raw_score) if raw_score is not None else max(0.5, 0.95 - (mat_idx - 1) * 0.03)
|
||||
results.append(Material(
|
||||
id=f"nb{mat_idx:03d}",
|
||||
title=doc_title,
|
||||
content=chunk_content[:500],
|
||||
source=f"笔记·{doc_title}",
|
||||
source_type="notebook",
|
||||
relevance_score=chunk_score,
|
||||
url=doc_url,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("notebook search kw=%r failed: %s", kw, e)
|
||||
except Exception as e:
|
||||
logger.warning("notebook search client error: %s", e)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
intent = state["user_intent"]
|
||||
article_type = state.get("article_type", "analysis")
|
||||
@@ -181,6 +247,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
if extra_keywords:
|
||||
keywords = list(dict.fromkeys(keywords + extra_keywords))
|
||||
|
||||
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
|
||||
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
|
||||
notebook_materials: list[Material] = []
|
||||
if notebook_search_space_id:
|
||||
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
|
||||
notebook_token: str = cfg.get("notebook_token") or ""
|
||||
_emit("正在检索笔记内容…")
|
||||
notebook_materials = await _search_notebook(
|
||||
keywords[:_NOTEBOOK_MAX_KEYWORDS],
|
||||
notebook_search_space_id,
|
||||
notebook_source_ids,
|
||||
notebook_token,
|
||||
)
|
||||
if notebook_materials:
|
||||
_emit(f"从笔记中获取到 {len(notebook_materials)} 条素材")
|
||||
|
||||
# Step 2: 搜索
|
||||
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
|
||||
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
|
||||
@@ -330,10 +412,17 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
existing_pkg = state.get("material_package")
|
||||
if is_skill_search:
|
||||
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
|
||||
# 笔记素材追加在技能素材之后
|
||||
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
|
||||
raw_materials.extend(notebook_materials)
|
||||
else:
|
||||
seen_urls: set[str] = set()
|
||||
raw_materials = []
|
||||
# 笔记素材优先排在最前面,赋予高相关度
|
||||
raw_materials = list(notebook_materials)
|
||||
for m in notebook_materials:
|
||||
if m.get("url"):
|
||||
seen_urls.add(m["url"])
|
||||
|
||||
preserve_existing = existing_pkg and (is_supplement or is_append_only)
|
||||
if preserve_existing:
|
||||
for m in existing_pkg.get("materials", []):
|
||||
|
||||
@@ -140,6 +140,9 @@ class AIWritingState(TypedDict):
|
||||
# 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原
|
||||
# model_name,存进 state 才能让 resume 后才运行的节点拿到用户模型。
|
||||
model_name: str
|
||||
# 笔记来源(可选):传入后 researcher 阶段会额外从笔记检索素材
|
||||
notebook_search_space_id: Optional[int]
|
||||
notebook_source_ids: Optional[List[int]]
|
||||
|
||||
# Agent A 输出
|
||||
material_package: Optional[MaterialPackage]
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ def notebook_search_tool(query: str, top_k: int = 10) -> str:
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"query": query,
|
||||
"source_ids": [source_ids],
|
||||
"source_ids": source_ids,
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": top_k,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user