Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xhy
This commit is contained in:
commit
dadb0bbe68
782 files changed
+160067
-3424
No files matched your search
+16
-3
@@ -55,6 +55,9 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
|
||||
|
||||
action = user_input.get("action", "confirm")
|
||||
|
||||
# 单输入框文本:可能是技能名 / 来源 / 检索意图,re_search 和 enable_skill_search 都用得到
|
||||
user_query = (user_input.get("userQuery") or user_input.get("user_query") or "").strip()
|
||||
|
||||
if action == "confirm":
|
||||
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
|
||||
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
|
||||
@@ -66,12 +69,22 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
|
||||
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认素材,开始规划大纲"}],
|
||||
}
|
||||
|
||||
# re_search:重新搜索
|
||||
# enable_skill_search:用户选择"启用技能检索",回到 researcher 走 skill 模式
|
||||
if action == "enable_skill_search":
|
||||
msg = f"已启用技能检索(输入:{user_query[:30]})" if user_query else "已启用技能检索,正在筛选可用技能…"
|
||||
return {
|
||||
"last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query},
|
||||
"status": "researching",
|
||||
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
|
||||
}
|
||||
|
||||
# re_search:重新搜索(支持新版 user_query 单输入框 + 旧版 extraKeywords 数组)
|
||||
extra_kws = user_input.get("extraKeywords") or []
|
||||
msg = f"按输入「{user_query[:30]}」重新搜索…" if user_query else "重新搜索中..."
|
||||
return {
|
||||
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws},
|
||||
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query},
|
||||
"status": "researching",
|
||||
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "重新搜索中..."}],
|
||||
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+64
-20
@@ -83,11 +83,16 @@ _SYSTEM_TEMPLATE = """你是一名写作助手。
|
||||
|
||||
_USER_TEMPLATE = """用户写作意图:{user_intent}
|
||||
|
||||
请分析并输出以下 JSON(如果意图中未明确某字段,给出合理默认值):
|
||||
用户在表单中已指定写作配置(以下三项必须原样输出,不要自行修改):
|
||||
- 文章类型(article_type):{preconfigured_article_type}
|
||||
- 目标读者(target_audience):{preconfigured_audience}
|
||||
- 目标字数(word_count_target):{preconfigured_word_count}
|
||||
|
||||
请输出以下 JSON,article_type / target_audience / word_count_target 必须与上述一致,只需根据用户意图精炼 refined_intent:
|
||||
{{{{
|
||||
"article_type": "{type_options} 之一",
|
||||
"target_audience": "目标读者描述",
|
||||
"word_count_target": 800,
|
||||
"article_type": "{preconfigured_article_type}",
|
||||
"target_audience": "{preconfigured_audience}",
|
||||
"word_count_target": {preconfigured_word_count},
|
||||
"refined_intent": "精炼后的写作意图(一句话)"
|
||||
}}}}"""
|
||||
|
||||
@@ -124,6 +129,44 @@ def _coerce_word_count(raw) -> int | None:
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _resolve_intent_fields(
|
||||
state: AIWritingState,
|
||||
result: dict,
|
||||
fallback_result: dict,
|
||||
valid_keys: list[str],
|
||||
default_key: str,
|
||||
) -> tuple[str, str, int, str]:
|
||||
"""合并 LLM 解析结果与用户表单预填字段;预填字段优先(见 AI_WRITING_BACKEND_DEV_ZH §intent_parser)。"""
|
||||
fallback_type = fallback_result["article_type"]
|
||||
if fallback_type not in valid_keys:
|
||||
fallback_type = default_key
|
||||
|
||||
raw_type = result.get("article_type", "")
|
||||
llm_type = raw_type if raw_type in valid_keys else fallback_type
|
||||
|
||||
preconfigured_type = (state.get("article_type") or "").strip()
|
||||
if preconfigured_type in valid_keys:
|
||||
final_article_type = preconfigured_type
|
||||
else:
|
||||
final_article_type = llm_type
|
||||
|
||||
preconfigured_audience = (state.get("target_audience") or "").strip()
|
||||
if preconfigured_audience:
|
||||
final_target_audience = preconfigured_audience
|
||||
else:
|
||||
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
|
||||
|
||||
preconfigured_wc = state.get("word_count_target")
|
||||
if isinstance(preconfigured_wc, int) and preconfigured_wc > 0:
|
||||
final_word_count = preconfigured_wc
|
||||
else:
|
||||
word_count = _coerce_word_count(result.get("word_count_target"))
|
||||
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
|
||||
|
||||
final_refined_intent = result.get("refined_intent") or state["user_intent"]
|
||||
return final_article_type, final_target_audience, final_word_count, final_refined_intent
|
||||
|
||||
|
||||
async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
# 从 configurable 读取动态类型列表,未配置时退回默认值
|
||||
configurable = (config or {}).get("configurable", {})
|
||||
@@ -137,10 +180,6 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
|
||||
type_options = _build_type_options(valid_article_types)
|
||||
system = _SYSTEM_TEMPLATE.format(type_options=type_options)
|
||||
user = _USER_TEMPLATE.format(
|
||||
user_intent=state["user_intent"],
|
||||
type_options=" | ".join(valid_keys),
|
||||
)
|
||||
|
||||
fallback_type = state.get("article_type", default_key)
|
||||
if fallback_type not in valid_keys:
|
||||
@@ -153,6 +192,17 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
"refined_intent": state["user_intent"],
|
||||
}
|
||||
|
||||
preconfigured_wc = fallback_result["word_count_target"]
|
||||
if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0:
|
||||
preconfigured_wc = 800
|
||||
|
||||
user = _USER_TEMPLATE.format(
|
||||
user_intent=state["user_intent"],
|
||||
preconfigured_article_type=fallback_type,
|
||||
preconfigured_audience=fallback_result["target_audience"],
|
||||
preconfigured_word_count=preconfigured_wc,
|
||||
)
|
||||
|
||||
# 流式调用:与 writer_outline / writer_draft 同套机制 —— LLM 逐字吐出 JSON,
|
||||
# 通过 ``intent_chunk`` 推给前端打字机;完成后 ``parse_json`` 解析结构化结果,
|
||||
# 再通过 ``intent_ready`` 推一份结构化字段给前端结果卡片。
|
||||
@@ -193,13 +243,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
"detail": f"意图解析失败,已按默认配置兜底。原因:{llm_error}",
|
||||
})
|
||||
|
||||
# 校验 LLM 返回的 article_type 是否在有效范围内,不合法时回退到默认值
|
||||
raw_type = result.get("article_type", "")
|
||||
final_article_type = raw_type if raw_type in valid_keys else fallback_type
|
||||
final_target_audience = result.get("target_audience") or fallback_result["target_audience"]
|
||||
word_count = _coerce_word_count(result.get("word_count_target"))
|
||||
final_word_count = word_count if word_count is not None else fallback_result["word_count_target"]
|
||||
final_refined_intent = result.get("refined_intent") or state["user_intent"]
|
||||
final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
|
||||
state, result, fallback_result, valid_keys, default_key
|
||||
)
|
||||
|
||||
# 推送结构化结果给前端 —— 结果卡片显示文章类型 / 目标读者 / 字数 / 精炼意图
|
||||
writer({
|
||||
@@ -214,11 +260,9 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
|
||||
|
||||
updates: dict = {"status": "researching", "progress_events": progress_events}
|
||||
updates["article_type"] = final_article_type
|
||||
if result.get("target_audience"):
|
||||
updates["target_audience"] = final_target_audience
|
||||
if word_count is not None:
|
||||
updates["word_count_target"] = final_word_count
|
||||
if result.get("refined_intent"):
|
||||
updates["target_audience"] = final_target_audience
|
||||
updates["word_count_target"] = final_word_count
|
||||
if final_refined_intent != state["user_intent"]:
|
||||
updates["user_intent"] = final_refined_intent
|
||||
|
||||
return updates
|
||||
+197
-73
@@ -10,6 +10,7 @@ from typing import List
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..search import get_search_provider, SearchResult
|
||||
from ..search.skill_search import SkillPicker
|
||||
from ..state import AIWritingState, Material, MaterialPackage
|
||||
from ..prompts.researcher_prompts import (
|
||||
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
|
||||
@@ -96,15 +97,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
|
||||
intervention = state.get("last_user_intervention") or {}
|
||||
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
|
||||
# PAUSE-1 单输入框:用户写的自然语言(技能名 / 来源 / 检索意图),优先级最高
|
||||
user_query: str = (intervention.get("user_query") or "").strip()
|
||||
action = intervention.get("action")
|
||||
|
||||
# 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲
|
||||
is_supplement = action == "supplement_search"
|
||||
# PAUSE-1 素材确认卡用户点「追加检索」并填了新关键词:
|
||||
# PAUSE-1 素材确认卡用户点「按输入重新搜索」并填了 user_query:
|
||||
# - 跳过 LLM 重新生成检索词(节省一次调用 + 尊重用户意图)
|
||||
# - 只检索 extra_keywords 这几个新词,不重检索之前 LLM 出过的词
|
||||
# - 直接用 user_query 作为唯一检索词
|
||||
# - 保留 existing material_package,按 URL 去重合并
|
||||
is_append_only = action == "re_search" and bool(extra_keywords)
|
||||
is_user_query_search = action == "re_search" and bool(user_query)
|
||||
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
|
||||
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
|
||||
# PAUSE-1 素材确认卡用户点「启用技能检索」:
|
||||
# - 不再走 web/intranet 检索,改用 SkillSearchProvider
|
||||
# - 顺序调用候选技能 + 早停(第一个技能够就停,不够再加下一个)
|
||||
# - 旧 material_package 完全丢弃(覆盖式替换,让用户对比 web vs skill 结果)
|
||||
is_skill_search = action == "enable_skill_search"
|
||||
|
||||
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
|
||||
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
|
||||
@@ -126,10 +136,17 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
writer({"type": "research_progress", "message": message})
|
||||
|
||||
# Step 1: 提取关键词
|
||||
kw_result: dict = {} # 走追加分支时跳过 LLM 调用,但下方 notice 拼接需要兜底值
|
||||
if is_append_only:
|
||||
# 追加模式:直接用用户填的关键词,不再调 LLM
|
||||
keywords: list[str] = _clean_keywords(extra_keywords, intent)
|
||||
kw_result: dict = {} # 走"用户输入 / 追加 / 技能"分支时跳过 LLM 调用,notice 拼接需要兜底值
|
||||
if is_skill_search:
|
||||
# 技能模式:每个 skill 会自己拼 query(user_query + skill.description),这里不用 LLM 生成关键词
|
||||
keywords: list[str] = [user_query or intent]
|
||||
elif is_user_query_search:
|
||||
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
|
||||
keywords = [user_query]
|
||||
_emit(f"按你的输入「{user_query[:30]}」检索…")
|
||||
elif is_append_only:
|
||||
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
|
||||
keywords = _clean_keywords(extra_keywords, intent)
|
||||
_emit(f"追加检索 {len(keywords)} 个关键词…")
|
||||
else:
|
||||
_emit("正在生成检索词…")
|
||||
@@ -150,78 +167,172 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
if extra_keywords:
|
||||
keywords = list(dict.fromkeys(keywords + extra_keywords))
|
||||
|
||||
# Step 2: 并发搜索
|
||||
provider = get_search_provider(ai_writing_config)
|
||||
# Step 2: 搜索
|
||||
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
|
||||
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
|
||||
# b. 按顺序遍历 skills:每个 skill 用其 description 让 LLM 生成针对性关键词
|
||||
# c. 调真实 web/intranet provider 执行检索,推 invoke_skill + skill_result 事件
|
||||
# d. 累积素材到 max_materials 即 break,推 stop_early
|
||||
skill_steps: list[dict] = []
|
||||
search_errors: list[str] = []
|
||||
|
||||
# 按用户指定的关键词数量截断。
|
||||
# 追加模式:keywords 本身就是用户填的,全部检索不截断(用户填 N 个就搜 N 个)。
|
||||
# 其他模式:受 keyword_count 限制,但 extra_keywords 在内时上限放宽到 keywords 总数。
|
||||
if is_append_only:
|
||||
search_keywords = keywords
|
||||
def _emit_skill_step(step: dict) -> None:
|
||||
skill_steps.append(step)
|
||||
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
|
||||
|
||||
if is_skill_search:
|
||||
# ── 技能编排检索 ──
|
||||
# 技能选择走流式 LLM(推 skill_select_chunk)+ <think> 分离,前端打字机展示
|
||||
picker = SkillPicker(
|
||||
config=config,
|
||||
model_name=keyword_model,
|
||||
emit_step=_emit_skill_step,
|
||||
max_select=5,
|
||||
user_query=user_query,
|
||||
)
|
||||
selected = await picker.pick(user_intent=intent)
|
||||
search_errors.extend(picker.errors)
|
||||
|
||||
# 真实 web/intranet provider(按 config.yaml 配置)
|
||||
web_provider = get_search_provider(ai_writing_config)
|
||||
|
||||
# 每个 skill 尽量多取(不分摊),第一个就达到 max_materials 即可早停;
|
||||
# 这才贴近"第一个技能够了就停,否则下一个补充"的语义。
|
||||
per_skill = max(1, max_materials)
|
||||
raw_materials_pre: list[Material] = []
|
||||
seen_urls_pre: set[str] = set()
|
||||
stopped_early = False
|
||||
|
||||
for entry in selected:
|
||||
sk = entry["skill"]
|
||||
# 用 LLM 把 user_query + skill.description 转成针对性关键词
|
||||
_emit_skill_step({
|
||||
"type": "invoke_skill",
|
||||
"message": f"使用技能「{sk.name}」开始检索…",
|
||||
"skill_name": sk.name,
|
||||
})
|
||||
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
|
||||
try:
|
||||
res = await web_provider.search(skill_query, max_results=per_skill)
|
||||
except Exception as e:
|
||||
logger.warning("skill[%s] search failed: %s", sk.name, e)
|
||||
search_errors.append(f"skill[{sk.name}]: {e}")
|
||||
res = []
|
||||
|
||||
# 去重累积
|
||||
new_count = 0
|
||||
for r in res or []:
|
||||
if r.url and r.url in seen_urls_pre:
|
||||
continue
|
||||
if r.url:
|
||||
seen_urls_pre.add(r.url)
|
||||
raw_materials_pre.append(Material(
|
||||
id=f"m{len(raw_materials_pre)+1:03d}",
|
||||
title=r.title,
|
||||
content=r.content[:500],
|
||||
source=f"{r.source}(via 技能 {sk.name})",
|
||||
published_at=r.published_at,
|
||||
relevance_score=r.relevance_score,
|
||||
source_type=r.source_type,
|
||||
url=r.url,
|
||||
recUuid=r.recUuid,
|
||||
))
|
||||
new_count += 1
|
||||
|
||||
_emit_skill_step({
|
||||
"type": "skill_result",
|
||||
"message": f"技能「{sk.name}」检索到 {new_count} 条新素材",
|
||||
"skill_name": sk.name,
|
||||
"matched_count": new_count,
|
||||
})
|
||||
|
||||
# 早停:累积素材已达上限
|
||||
if len(raw_materials_pre) >= max_materials:
|
||||
stopped_early = True
|
||||
_emit_skill_step({
|
||||
"type": "stop_early",
|
||||
"message": f"已获取 {len(raw_materials_pre)} 条素材,达到上限,停止调用后续技能",
|
||||
"matched_count": len(raw_materials_pre),
|
||||
})
|
||||
break
|
||||
|
||||
# 技能模式不再走下面的并发/合并逻辑,all_results_nested 留空、跳过 web 通用搜索
|
||||
all_results_nested: list = []
|
||||
# 把已收集到的素材挂在 raw_materials_pre,下方合并阶段直接挪过去
|
||||
else:
|
||||
search_limit = keyword_count
|
||||
if extra_keywords:
|
||||
search_limit = max(search_limit, len(keywords))
|
||||
search_keywords = keywords[:search_limit]
|
||||
total = len(search_keywords)
|
||||
# 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整)
|
||||
per_keyword = _per_keyword_limit(max_materials, total)
|
||||
_emit(f"检索词已生成,开始检索 {total} 个方向…")
|
||||
# ── 常规 web/intranet 模式 ──
|
||||
provider = get_search_provider(ai_writing_config)
|
||||
|
||||
# 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
|
||||
completed = 0
|
||||
# 按用户指定的关键词数量截断。
|
||||
# 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。
|
||||
if is_user_query_search or is_append_only:
|
||||
search_keywords = keywords
|
||||
else:
|
||||
search_limit = keyword_count
|
||||
if extra_keywords:
|
||||
search_limit = max(search_limit, len(keywords))
|
||||
search_keywords = keywords[:search_limit]
|
||||
total = len(search_keywords)
|
||||
per_keyword = _per_keyword_limit(max_materials, total)
|
||||
_emit(f"检索词已生成,开始检索 {total} 个方向…")
|
||||
|
||||
async def _search_one(kw: str) -> list[SearchResult]:
|
||||
nonlocal completed
|
||||
try:
|
||||
return await provider.search(kw, max_results=per_keyword)
|
||||
finally:
|
||||
completed += 1
|
||||
if completed < total:
|
||||
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
|
||||
else:
|
||||
_emit(f"已完成全部 {total} 次检索")
|
||||
completed = 0
|
||||
|
||||
search_tasks = [_search_one(kw) for kw in search_keywords]
|
||||
all_results_nested: list[list[SearchResult]] = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
async def _search_one(kw: str) -> list[SearchResult]:
|
||||
nonlocal completed
|
||||
try:
|
||||
return await provider.search(kw, max_results=per_keyword)
|
||||
finally:
|
||||
completed += 1
|
||||
if completed < total:
|
||||
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
|
||||
else:
|
||||
_emit(f"已完成全部 {total} 次检索")
|
||||
|
||||
search_tasks = [_search_one(kw) for kw in search_keywords]
|
||||
all_results_nested = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
|
||||
search_errors.extend(getattr(provider, "errors", []))
|
||||
|
||||
# 合并去重
|
||||
# - 首次检索 / 普通 re_search(不带 extra_keywords,用户只想 LLM 重出):旧素材丢弃
|
||||
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
|
||||
# - PAUSE-1 追加检索 is_append_only:保留旧素材 + 仅检索新关键词
|
||||
seen_urls: set[str] = set()
|
||||
raw_materials: list[Material] = []
|
||||
# - 技能检索 is_skill_search:覆盖式替换,旧素材完全丢弃(编排循环已经把素材累积到 raw_materials_pre)
|
||||
existing_pkg = state.get("material_package")
|
||||
preserve_existing = existing_pkg and (is_supplement or is_append_only)
|
||||
if preserve_existing:
|
||||
for m in existing_pkg.get("materials", []):
|
||||
seen_urls.add(m.get("url", ""))
|
||||
raw_materials.append(m)
|
||||
if is_skill_search:
|
||||
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
|
||||
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
|
||||
else:
|
||||
seen_urls: set[str] = set()
|
||||
raw_materials = []
|
||||
preserve_existing = existing_pkg and (is_supplement or is_append_only)
|
||||
if preserve_existing:
|
||||
for m in existing_pkg.get("materials", []):
|
||||
seen_urls.add(m.get("url", ""))
|
||||
raw_materials.append(m)
|
||||
|
||||
search_errors: list[str] = []
|
||||
for results in all_results_nested:
|
||||
if isinstance(results, Exception):
|
||||
logger.warning("search failed: %s", results)
|
||||
search_errors.append(f"{type(results).__name__}: {results}")
|
||||
continue
|
||||
for r in results:
|
||||
if r.url and r.url in seen_urls:
|
||||
for results in all_results_nested:
|
||||
if isinstance(results, Exception):
|
||||
logger.warning("search failed: %s", results)
|
||||
search_errors.append(f"{type(results).__name__}: {results}")
|
||||
continue
|
||||
seen_urls.add(r.url)
|
||||
raw_materials.append(Material(
|
||||
id=f"m{len(raw_materials)+1:03d}",
|
||||
title=r.title,
|
||||
content=r.content[:500],
|
||||
source=r.source,
|
||||
published_at=r.published_at,
|
||||
relevance_score=r.relevance_score,
|
||||
source_type=r.source_type,
|
||||
url=r.url,
|
||||
recUuid=r.recUuid,
|
||||
))
|
||||
|
||||
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
|
||||
search_errors.extend(getattr(provider, "errors", []))
|
||||
for r in results:
|
||||
if r.url and r.url in seen_urls:
|
||||
continue
|
||||
if r.url:
|
||||
seen_urls.add(r.url)
|
||||
raw_materials.append(Material(
|
||||
id=f"m{len(raw_materials)+1:03d}",
|
||||
title=r.title,
|
||||
content=r.content[:500],
|
||||
source=r.source,
|
||||
published_at=r.published_at,
|
||||
relevance_score=r.relevance_score,
|
||||
source_type=r.source_type,
|
||||
url=r.url,
|
||||
recUuid=r.recUuid,
|
||||
))
|
||||
|
||||
# Step 3: 相关度排序(如素材较多,让 LLM 排序)
|
||||
_emit("检索完成,正在筛选排序素材…")
|
||||
@@ -264,6 +375,14 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
old_kws = list(existing_pkg.get("keywords") or [])
|
||||
pkg_keywords = list(dict.fromkeys(old_kws + keywords))
|
||||
pkg = MaterialPackage(keywords=pkg_keywords, materials=materials, summary=summary)
|
||||
# 技能检索时把过程步骤一并放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
|
||||
if is_skill_search:
|
||||
pkg["skill_search_used"] = True
|
||||
pkg["skill_search_steps"] = skill_steps
|
||||
elif existing_pkg and existing_pkg.get("skill_search_used") and (is_supplement or is_append_only):
|
||||
# 保留之前一次技能检索的痕迹(不污染纯 web 检索)
|
||||
pkg["skill_search_used"] = existing_pkg.get("skill_search_used", False)
|
||||
pkg["skill_search_steps"] = existing_pkg.get("skill_search_steps", [])
|
||||
|
||||
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
|
||||
notices: list[dict] = []
|
||||
@@ -274,19 +393,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
if search_errors:
|
||||
uniq = list(dict.fromkeys(search_errors))
|
||||
# 解析失败时收集到的接口原始响应体,前端折叠展示便于定位
|
||||
raw_dumps = list(dict.fromkeys(getattr(provider, "raw_responses", []) or []))
|
||||
# (技能模式下 provider 局部变量没暴露,所以 raw_dumps 兜底为空列表)
|
||||
provider_obj = locals().get("provider") or locals().get("web_provider")
|
||||
raw_dumps = list(dict.fromkeys(getattr(provider_obj, "raw_responses", []) or []))
|
||||
notices.append(_notice(
|
||||
"ES 波动导致检索异常",
|
||||
";".join(uniq[:3]),
|
||||
raw="\n\n----------\n\n".join(raw_dumps[:3]),
|
||||
))
|
||||
|
||||
# 进度事件文案:追加模式下让用户看到只搜了哪几个新词
|
||||
step_start_msg = (
|
||||
f"追加检索关键词:{', '.join(keywords[:3])}"
|
||||
if is_append_only
|
||||
else f"搜索关键词:{', '.join(keywords[:3])}"
|
||||
)
|
||||
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
|
||||
if is_skill_search:
|
||||
step_start_msg = f"启用技能检索({len(skill_steps)} 步)"
|
||||
elif is_user_query_search:
|
||||
step_start_msg = f"按你的输入检索:{user_query[:40]}"
|
||||
elif is_append_only:
|
||||
step_start_msg = f"追加检索关键词:{', '.join(keywords[:3])}"
|
||||
else:
|
||||
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
|
||||
return {
|
||||
"material_package": pkg,
|
||||
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ from .internet_search import InternetSearchProvider
|
||||
from .intranet_stub import IntranetSearchProvider
|
||||
from .demo_search import DemoSearchProvider
|
||||
from .duckduckgo_search import DuckDuckGoSearchProvider
|
||||
from .skill_search import SkillSearchProvider
|
||||
|
||||
|
||||
def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
|
||||
@@ -60,5 +61,6 @@ __all__ = [
|
||||
"BaseSearchProvider", "SearchResult",
|
||||
"InternetSearchProvider", "IntranetSearchProvider",
|
||||
"DemoSearchProvider", "DuckDuckGoSearchProvider",
|
||||
"SkillSearchProvider",
|
||||
"get_search_provider",
|
||||
]
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"""技能选择器:根据用户意图 + 输入,从启用的 Skills 里挑出按相关度排序的若干个。
|
||||
|
||||
注意:这个模块**只负责选择技能,不直接产出素材**。
|
||||
真正的素材产出由 researcher 节点用每个 skill 的描述去编排真实 web/intranet 检索。
|
||||
|
||||
流程(对应素材确认卡上的"技能检索"按钮):
|
||||
1) list_skills 事件 —— 列出当前启用的全部 skill
|
||||
2) skill_select_chunk 事件 —— LLM 流式打字 + <think></think> 分离(前端打字机展示)
|
||||
3) select_skills 事件 —— LLM 给出的排序结果(结构化字段,写到 SkillStepsCard 里)
|
||||
|
||||
下游 researcher 节点拿到 selected_skills 后,**按顺序**:
|
||||
- 用 skill.description + user_query 生成针对性 query
|
||||
- 调真实 web search provider 执行
|
||||
- 累积素材达到阈值就停(推 stop_early)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..nodes._utils import (
|
||||
ThinkTagParser,
|
||||
_content_to_text,
|
||||
get_model,
|
||||
parse_json,
|
||||
safe_get_stream_writer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SELECT_SKILLS_SYSTEM = """你是一名情报研究员,需要从可用「技能」中挑出对当前检索任务最有帮助的若干个,并按相关度从高到低排序。
|
||||
技能是一段封装好的指令 / 知识 / 检索策略,每个技能有 name 和 description。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个 JSON 对象,不要 markdown 代码块。
|
||||
- 必须使用英文双引号。
|
||||
- selected_skills 中的 name 必须严格来自下方"可用技能"列表,不要编造,按相关度从高到低排序。
|
||||
- 如果用户输入里明确点名了某个技能(按 name 完全匹配),把它排在第一位。
|
||||
- 至少选 1 个,最多选 {max_select} 个。
|
||||
|
||||
【完整示例】
|
||||
{{"selected_skills": ["platform-sales-data", "industry-report"], "reason": "用户想查各平台销量,平台销量技能最相关"}}"""
|
||||
|
||||
_SELECT_SKILLS_USER = """写作意图:{user_intent}
|
||||
用户补充输入:{user_query}
|
||||
|
||||
可用技能(共 {skill_count} 个):
|
||||
{skill_list}
|
||||
|
||||
请从中按相关度从高到低选出最多 {max_select} 个技能。
|
||||
只输出 JSON:
|
||||
{{"selected_skills": ["name1", "name2"], "reason": "为什么选这些 + 排序依据"}}"""
|
||||
|
||||
|
||||
EmitStepFn = Callable[[dict], None]
|
||||
|
||||
|
||||
class SkillPicker:
|
||||
"""从启用的 Skills 中流式选择按相关度排序的若干个供下游编排使用。
|
||||
|
||||
构造参数:
|
||||
config: LangGraph runnable config(用于创建 model)
|
||||
model_name: 指定使用的模型,None 则用 configurable.model_name
|
||||
emit_step: 推送时间线步骤事件(list_skills / select_skills)
|
||||
max_select: LLM 单次最多挑几个技能
|
||||
user_query: 用户在素材确认卡输入框写的自然语言
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
model_name: Optional[str] = None,
|
||||
emit_step: Optional[EmitStepFn] = None,
|
||||
max_select: int = 5,
|
||||
user_query: str = "",
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._model_name = model_name
|
||||
self._emit_step = emit_step or (lambda _step: None)
|
||||
self._max_select = max_select
|
||||
self._user_query = (user_query or "").strip()
|
||||
self._errors: list[str] = []
|
||||
|
||||
@property
|
||||
def errors(self) -> list[str]:
|
||||
return list(self._errors)
|
||||
|
||||
def _load_enabled_skills(self) -> list:
|
||||
try:
|
||||
from deerflow.skills import get_or_new_skill_storage
|
||||
storage = get_or_new_skill_storage()
|
||||
skills = storage.load_skills(enabled_only=True)
|
||||
except Exception as e:
|
||||
logger.exception("ai_writing.skill_search: 加载启用技能失败")
|
||||
self._errors.append(f"skill list error: {e}")
|
||||
skills = []
|
||||
self._emit_step({
|
||||
"type": "list_skills",
|
||||
"message": f"发现 {len(skills)} 个可用技能",
|
||||
"skill_names": [s.name for s in skills],
|
||||
})
|
||||
return skills
|
||||
|
||||
async def pick(self, user_intent: str) -> List[dict]:
|
||||
"""流式选择并排序候选技能,返回 [{skill, reason}, ...],按相关度降序。
|
||||
|
||||
过程中通过 custom 通道推送 ``skill_select_chunk`` 事件给前端打字机;
|
||||
结束后再推一条 ``select_skills`` 结构化事件。
|
||||
"""
|
||||
skills = self._load_enabled_skills()
|
||||
if not skills:
|
||||
self._emit_step({
|
||||
"type": "select_skills",
|
||||
"message": "当前没有启用的技能可用",
|
||||
"skill_names": [],
|
||||
})
|
||||
return []
|
||||
|
||||
skill_list_text = "\n".join(
|
||||
f"- {s.name}: {s.description or '(无描述)'}" for s in skills
|
||||
)
|
||||
|
||||
fallback = {"selected_skills": [s.name for s in skills[: self._max_select]], "reason": ""}
|
||||
|
||||
# 流式 LLM 调用:与 intent_parser / writer_outline 同套机制
|
||||
writer = safe_get_stream_writer()
|
||||
parser = ThinkTagParser()
|
||||
full_text = ""
|
||||
|
||||
def _emit_chunk(text: str, is_thinking: bool) -> None:
|
||||
writer({
|
||||
"type": "skill_select_chunk",
|
||||
"text": text,
|
||||
"is_thinking": is_thinking,
|
||||
"agent_name": "素材收集专家",
|
||||
})
|
||||
|
||||
try:
|
||||
model = get_model(self._config, model_name=self._model_name)
|
||||
async for chunk in model.astream([
|
||||
SystemMessage(content=_SELECT_SKILLS_SYSTEM.format(max_select=self._max_select)),
|
||||
HumanMessage(content=_SELECT_SKILLS_USER.format(
|
||||
user_intent=user_intent or "(无明确写作意图)",
|
||||
user_query=self._user_query or "(无)",
|
||||
skill_count=len(skills),
|
||||
skill_list=skill_list_text,
|
||||
max_select=self._max_select,
|
||||
)),
|
||||
]):
|
||||
delta = _content_to_text(chunk.content)
|
||||
if not delta:
|
||||
continue
|
||||
for is_thinking, text in parser.feed(delta):
|
||||
if not is_thinking:
|
||||
full_text += text
|
||||
_emit_chunk(text, is_thinking)
|
||||
except Exception as e:
|
||||
logger.exception("ai_writing.skill_search: 流式选择技能失败")
|
||||
self._errors.append(f"skill select error: {e}")
|
||||
|
||||
result = parse_json(full_text, fallback) if full_text else fallback
|
||||
|
||||
wanted_names = result.get("selected_skills") or []
|
||||
if not isinstance(wanted_names, list):
|
||||
wanted_names = []
|
||||
by_name = {s.name: s for s in skills}
|
||||
reason = result.get("reason", "") or ""
|
||||
chosen: list[dict] = []
|
||||
for name in wanted_names:
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
sk = by_name.get(name.strip())
|
||||
if sk and sk.name not in {c["skill"].name for c in chosen}:
|
||||
chosen.append({"skill": sk, "reason": reason})
|
||||
if len(chosen) >= self._max_select:
|
||||
break
|
||||
|
||||
self._emit_step({
|
||||
"type": "select_skills",
|
||||
"message": f"按相关度排序,候选 {len(chosen)} 个技能(将按顺序使用)",
|
||||
"skill_names": [c["skill"].name for c in chosen],
|
||||
"reason": reason,
|
||||
})
|
||||
return chosen
|
||||
|
||||
|
||||
# 保留旧名字让 search/__init__.py 的导出不报错;研究节点不再用它
|
||||
SkillSearchProvider = SkillPicker
|
||||
+29
-4
@@ -3,21 +3,43 @@ import operator
|
||||
|
||||
# ── 素材 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Material(TypedDict):
|
||||
class Material(TypedDict, total=False):
|
||||
id: str
|
||||
title: str
|
||||
content: str # 截断到 500 字
|
||||
source: str
|
||||
published_at: str
|
||||
relevance_score: float
|
||||
source_type: str # "internet" | "intranet"
|
||||
source_type: str # "internet" | "intranet" | "skill"
|
||||
url: str
|
||||
recUuid: str
|
||||
# skill 类素材附带的元信息(skill_name / container_path / category),其他类型为空
|
||||
extra_metadata: dict
|
||||
|
||||
class MaterialPackage(TypedDict):
|
||||
class SkillStep(TypedDict, total=False):
|
||||
"""技能检索过程的单步记录,用于前端时间线展示。
|
||||
|
||||
type=match_skills:根据用户输入流式分析有没有匹配技能(无 skill_names)
|
||||
type=list_skills:列出可用技能
|
||||
type=select_skills:LLM 从中选出的相关技能
|
||||
type=invoke_skill:实际调用某一个技能检索
|
||||
type=stop_early:达到素材阈值,提前停止后续技能调用
|
||||
"""
|
||||
type: str
|
||||
message: str
|
||||
skill_names: List[str]
|
||||
skill_name: str
|
||||
matched_count: int
|
||||
reason: str
|
||||
|
||||
class MaterialPackage(TypedDict, total=False):
|
||||
keywords: List[str]
|
||||
materials: List[Material]
|
||||
summary: str
|
||||
# 是否本次素材来自技能检索(覆盖式替换 web 检索)
|
||||
skill_search_used: bool
|
||||
# 技能检索的步骤记录(前端展示"使用了 X 技能"卡片)
|
||||
skill_search_steps: List[SkillStep]
|
||||
|
||||
# ── 大纲 ──────────────────────────────────────────────────────────────────────
|
||||
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
|
||||
@@ -72,10 +94,13 @@ class ReviewResult(TypedDict):
|
||||
|
||||
# ── 用户干预 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserInterventionPayload(TypedDict):
|
||||
class UserInterventionPayload(TypedDict, total=False):
|
||||
action: str
|
||||
approved_material_ids: List[str]
|
||||
extra_keywords: List[str]
|
||||
# PAUSE-1 素材确认 / enable_skill_search 共用:用户在单输入框里填的自然语言
|
||||
# (可能是要使用的技能名、想检索的来源、或者一段补充检索意图)。
|
||||
user_query: str
|
||||
edited_outline: Optional[Outline]
|
||||
outline_feedback: str
|
||||
user_revision_notes: str
|
||||
|
||||
+24
-11
@@ -9,6 +9,7 @@ from deerflow.agents.middlewares.clarification_middleware import ClarificationMi
|
||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||
@@ -254,11 +255,14 @@ def _build_middlewares(
|
||||
List of middleware instances.
|
||||
"""
|
||||
resolved_app_config = app_config or get_app_config()
|
||||
cfg = _get_runtime_config(config)
|
||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
||||
|
||||
# Inject the admin prompt prefix into the model request (kept out of the
|
||||
# persisted/displayed message so the user never sees it).
|
||||
middlewares.append(PromptPrefixMiddleware())
|
||||
if cfg.get("rag_mode_enabled", False):
|
||||
middlewares.append(RagCitationMiddleware())
|
||||
|
||||
# Add summarization middleware if enabled
|
||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||
@@ -266,7 +270,6 @@ def _build_middlewares(
|
||||
middlewares.append(summarization_middleware)
|
||||
|
||||
# Add TodoList middleware if plan mode is enabled
|
||||
cfg = _get_runtime_config(config)
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||
@@ -370,6 +373,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
||||
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
|
||||
writing_mode = bool(cfg.get("writing_mode", False))
|
||||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||
@@ -497,24 +501,30 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
is_bootstrap=True,
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
# Default lead agent (unchanged behavior)
|
||||
# Use subagent tool groups if in subagent mode, otherwise use parent agent config
|
||||
effective_tool_groups = subagent_tool_groups if subagent_mode else (agent_config.tool_groups if agent_config else None)
|
||||
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
|
||||
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
|
||||
default_tools = get_available_tools(
|
||||
model_name=model_name,
|
||||
groups=agent_config.tool_groups if agent_config else None,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
)
|
||||
if is_notebook_mode:
|
||||
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
|
||||
|
||||
default_tools = [notebook_search_tool] + default_tools
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||
tools=get_available_tools(
|
||||
model_name=model_name,
|
||||
groups=effective_tool_groups,
|
||||
subagent_enabled=subagent_enabled,
|
||||
is_scheduled_run=cfg.get("is_scheduled_run", False),
|
||||
excluded_tools=requested_excluded_tools,
|
||||
app_config=resolved_app_config,
|
||||
),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
@@ -526,6 +536,9 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
is_notebook_mode=is_notebook_mode,
|
||||
notebook_source_ids=notebook_source_ids,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
+120
-8
@@ -667,17 +667,25 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
|
||||
@lru_cache(maxsize=32)
|
||||
def _get_cached_skills_prompt_section(
|
||||
skill_signature: tuple[tuple[str, str, str, str], ...],
|
||||
display_signature: tuple[tuple[str, str, str], ...],
|
||||
available_skills_key: tuple[str, ...] | None,
|
||||
container_base_path: str,
|
||||
skill_evolution_section: str,
|
||||
) -> str:
|
||||
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
|
||||
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
|
||||
skills_list = ""
|
||||
if filtered:
|
||||
skill_items = "\n".join(
|
||||
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>\n </skill>"
|
||||
for name, description, category, location in filtered
|
||||
)
|
||||
items = []
|
||||
for name, description, category, location in filtered:
|
||||
display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
|
||||
display_hint = ""
|
||||
if display_mode:
|
||||
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
|
||||
items.append(
|
||||
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
|
||||
)
|
||||
skill_items = "\n".join(items)
|
||||
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
|
||||
|
||||
# Restricted mode: agent has an explicit skill allowlist.
|
||||
@@ -693,6 +701,21 @@ def _get_cached_skills_prompt_section(
|
||||
else:
|
||||
scope_note = ""
|
||||
|
||||
display_pattern = (
|
||||
"""
|
||||
**Skill Result Display Pattern:**
|
||||
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
|
||||
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
|
||||
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
|
||||
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
|
||||
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
|
||||
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
|
||||
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
|
||||
"""
|
||||
if display_signature
|
||||
else ""
|
||||
)
|
||||
|
||||
return f"""<skill_system>
|
||||
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
|
||||
{scope_note}
|
||||
@@ -702,13 +725,19 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
||||
3. The skill file contains references to external resources under the same folder
|
||||
4. Load referenced resources only when needed during execution
|
||||
5. Follow the skill's instructions precisely
|
||||
{display_pattern}
|
||||
{location_hint}{evolution_block}
|
||||
{skills_list}
|
||||
|
||||
</skill_system>"""
|
||||
|
||||
|
||||
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None) -> str:
|
||||
def get_skills_prompt_section(
|
||||
available_skills: set[str] | None = None,
|
||||
*,
|
||||
app_config: AppConfig | None = None,
|
||||
display_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Generate the skills prompt section with available skills list."""
|
||||
skills = _get_enabled_skills_for_config(app_config)
|
||||
|
||||
@@ -739,11 +768,30 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c
|
||||
return ""
|
||||
|
||||
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
|
||||
display_signature: tuple[tuple[str, str, str], ...] = ()
|
||||
if display_enabled:
|
||||
try:
|
||||
from deerflow.config.extensions_config import get_extensions_config
|
||||
|
||||
extensions = get_extensions_config()
|
||||
display_signature = tuple(
|
||||
(
|
||||
name,
|
||||
skill_config.display.mode,
|
||||
skill_config.display.custom_instruction,
|
||||
)
|
||||
for name, skill_config in sorted(extensions.skills.items())
|
||||
if skill_config.display is not None
|
||||
and skill_config.display.enabled
|
||||
and skill_config.display.mode != "none"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
|
||||
available_key = tuple(sorted(available_skills)) if available_skills is not None else None
|
||||
if not skill_signature and available_key is not None:
|
||||
return ""
|
||||
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
|
||||
return _get_cached_skills_prompt_section(skill_signature, available_key, container_base_path, skill_evolution_section)
|
||||
return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
|
||||
|
||||
|
||||
def get_agent_soul(agent_id: str | None) -> str:
|
||||
@@ -869,6 +917,9 @@ def apply_prompt_template(
|
||||
app_config: AppConfig | None = None,
|
||||
soul_override: str | None = None,
|
||||
memory_injection_enabled: bool = True,
|
||||
rag_mode_enabled: bool = False,
|
||||
is_notebook_mode: bool = False,
|
||||
notebook_source_ids: list[int] | None = None,
|
||||
) -> str:
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
|
||||
@@ -901,7 +952,11 @@ def apply_prompt_template(
|
||||
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
||||
|
||||
# Get skills section
|
||||
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
||||
skills_section = get_skills_prompt_section(
|
||||
available_skills,
|
||||
app_config=app_config,
|
||||
display_enabled=rag_mode_enabled,
|
||||
)
|
||||
|
||||
# Get deferred tools section (tool_search)
|
||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||
@@ -939,6 +994,21 @@ Writing mode is enabled for this run.
|
||||
|
||||
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
|
||||
network_policy_section = _build_network_policy_section()
|
||||
rag_mode_section = (
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
RAG/reference display mode is enabled.
|
||||
When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
|
||||
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
|
||||
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
|
||||
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
|
||||
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
|
||||
</rag_citation_mode>
|
||||
"""
|
||||
if rag_mode_enabled
|
||||
else ""
|
||||
)
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
@@ -956,4 +1026,46 @@ Writing mode is enabled for this run.
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
|
||||
return prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
|
||||
if is_notebook_mode:
|
||||
source_ids = notebook_source_ids or []
|
||||
if source_ids:
|
||||
source_line = f"当前检索范围:{len(source_ids)} 个来源(IDs: {', '.join(str(i) for i in source_ids)})"
|
||||
else:
|
||||
source_line = "当前检索范围:全部来源"
|
||||
|
||||
prompt += f"""
|
||||
|
||||
<notebook_mode>
|
||||
## 角色
|
||||
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
|
||||
|
||||
## 可用知识来源
|
||||
{source_line}
|
||||
|
||||
## 强制工作流程(每次用户提问都必须严格执行,无例外)
|
||||
1. **第一步(必须)**:立即调用 `search_notebook_sources` 工具,`query` 填写用户问题或精炼的检索关键词。
|
||||
- 无论问题多简单,都必须先检索,不得跳过。
|
||||
- 若检索结果不足,可再次调用不同 query 补充检索。
|
||||
2. **第二步**:仅基于检索到的内容组织回答,**不得使用训练知识或推断**。
|
||||
3. **第三步**:若检索结果中确实无相关内容,明确告知用户"暂未在来源中找到相关信息"。
|
||||
|
||||
## 引用格式(强制)
|
||||
- 每个事实/句子后立即插入 `[[cite:<chunk_id>]]` 标记,chunk_id 来自检索结果中每个文本块的 id 字段。
|
||||
- 多个来源支持同一句话时连续插入:`[[cite:101]][[cite:102]]`
|
||||
- 引用标记紧跟被引用内容末尾,不加空格,不单独成行。
|
||||
|
||||
## 示例
|
||||
用户:合同中违约金是多少?
|
||||
(调用 search_notebook_sources query="违约金",返回 chunk_id=101 的结果)
|
||||
回答:根据合同第五条,违约金为合同总额的 0.5%[[cite:101]],乙方拒绝收货视为违约[[cite:101]]。
|
||||
|
||||
## 绝对禁止
|
||||
- 未调用 `search_notebook_sources` 就直接回答
|
||||
- 引用不在检索结果中的 chunk_id
|
||||
- 省略引用标记
|
||||
- 使用笔记本来源之外的知识
|
||||
</notebook_mode>"""
|
||||
|
||||
return prompt
|
||||
+41
-7
@@ -61,6 +61,21 @@ _AUTH_PATTERNS = (
|
||||
"无权",
|
||||
"未授权",
|
||||
)
|
||||
# Upstream model gateway returned a malformed payload (typically a JSON decode
|
||||
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
|
||||
# response). These are almost always transient inference-side issues and safe
|
||||
# to retry.
|
||||
_UPSTREAM_GARBAGE_PATTERNS = (
|
||||
"bad_response_status_code",
|
||||
"bad response status",
|
||||
"expecting ',' delimiter",
|
||||
"expecting value",
|
||||
"expecting property name",
|
||||
"unexpected character",
|
||||
"unterminated string",
|
||||
"extra data",
|
||||
"jsondecodeerror",
|
||||
)
|
||||
|
||||
|
||||
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
@@ -139,9 +154,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
detail = _extract_error_detail(exc)
|
||||
lowered = detail.lower()
|
||||
error_code = _extract_error_code(exc)
|
||||
error_code_lower = str(error_code).lower() if error_code is not None else ""
|
||||
status_code = _extract_status_code(exc)
|
||||
|
||||
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(str(error_code).lower(), _QUOTA_PATTERNS):
|
||||
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(error_code_lower, _QUOTA_PATTERNS):
|
||||
return False, "quota"
|
||||
if _matches_any(lowered, _AUTH_PATTERNS):
|
||||
return False, "auth"
|
||||
@@ -159,6 +175,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
return True, "transient"
|
||||
if _matches_any(lowered, _BUSY_PATTERNS):
|
||||
return True, "busy"
|
||||
# Treat upstream gateway garbage (malformed JSON, truncated responses)
|
||||
# as a retriable transient — the deterministic char-position errors we
|
||||
# see from vLLM/proxy layers usually clear on a re-roll.
|
||||
if _matches_any(lowered, _UPSTREAM_GARBAGE_PATTERNS) or _matches_any(error_code_lower, _UPSTREAM_GARBAGE_PATTERNS):
|
||||
return True, "upstream"
|
||||
|
||||
return False, "generic"
|
||||
|
||||
@@ -171,21 +192,34 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||
seconds = max(1, round(wait_ms / 1000))
|
||||
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
|
||||
if reason == "busy":
|
||||
reason_text = "服务繁忙"
|
||||
elif reason == "upstream":
|
||||
reason_text = "上游响应异常"
|
||||
else:
|
||||
reason_text = "网络/连接异常"
|
||||
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
|
||||
|
||||
def _build_circuit_breaker_message(self) -> str:
|
||||
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
||||
|
||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||
detail = _extract_error_detail(exc)
|
||||
def _build_user_message(self, reason: str) -> str:
|
||||
if reason == "quota":
|
||||
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
||||
if reason == "auth":
|
||||
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
||||
if reason in {"busy", "transient"}:
|
||||
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
|
||||
return f"大模型因「{detail}」原因调用失败。"
|
||||
if reason == "upstream":
|
||||
return "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。"
|
||||
# NOTE: We intentionally do NOT embed the raw exception detail into the
|
||||
# user-facing message. Raw `Error code: 400 - {...}` strings — especially
|
||||
# Python repr-style dict literals — get persisted as AI messages and then
|
||||
# re-fed to the model on the next turn, where their unescaped quotes /
|
||||
# braces can corrupt the upstream JSON payload and cause deterministic
|
||||
# JSONDecodeError loops. Keep the user message clean; full detail is
|
||||
# already in the backend log via `logger.warning(..., exc_info=exc)`.
|
||||
return "大模型本次调用未能成功完成,请稍后再试或更换模型。"
|
||||
|
||||
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||
try:
|
||||
@@ -249,7 +283,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return AIMessage(content=self._build_user_message(reason))
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
@@ -295,7 +329,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return AIMessage(content=self._build_user_message(reason))
|
||||
|
||||
|
||||
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
|
||||
|
||||
+10
@@ -137,7 +137,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
|
||||
召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是
|
||||
背景信息而非用户的新输入。
|
||||
|
||||
**跳过开关**:若 ``runtime.context.memory_recall_disabled`` 为真,直接
|
||||
返回 None。圆桌规划等临时性任务(intent / recommend / leader / special)
|
||||
不需要从用户个人记忆里召回,Hindsight HTTP 同步阻塞会显著增加 LLM 首
|
||||
字节延迟(前端"调度中"卡顿);由路由层通过 ``_run_payload`` 的 context
|
||||
字段统一关闭。
|
||||
"""
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is not None and getattr(runtime, "context", None):
|
||||
if runtime.context.get("memory_recall_disabled"):
|
||||
return None
|
||||
if not self._injection_enabled:
|
||||
return None
|
||||
config = self._memory_config or get_memory_config()
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
"""Inject stable citation numbering before RAG final answers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from deerflow.runtime.references import (
|
||||
build_latest_reference_prompt,
|
||||
build_reference_debug_payload,
|
||||
build_reference_debug_summary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
|
||||
|
||||
|
||||
def _get_rag_debug_logger() -> logging.Logger:
|
||||
debug_logger = logging.getLogger(_RAG_DEBUG_LOGGER_NAME)
|
||||
if getattr(debug_logger, "_rag_file_configured", False):
|
||||
return debug_logger
|
||||
log_path = Path(os.environ.get("RAG_CITATION_DEBUG_LOG", "logs/rag_citation_debug.log"))
|
||||
if not log_path.is_absolute():
|
||||
log_path = Path.cwd() / log_path
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
debug_logger.addHandler(handler)
|
||||
debug_logger.setLevel(logging.INFO)
|
||||
debug_logger.propagate = False
|
||||
setattr(debug_logger, "_rag_file_configured", True)
|
||||
setattr(debug_logger, "_rag_file_path", str(log_path))
|
||||
return debug_logger
|
||||
|
||||
|
||||
def _log_rag_debug(message: str, *args: Any) -> None:
|
||||
logger.info(message, *args)
|
||||
_get_rag_debug_logger().info(message, *args)
|
||||
|
||||
|
||||
class RagCitationMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Give the model the same numbered references the frontend will render."""
|
||||
|
||||
@staticmethod
|
||||
def _enabled(request: ModelRequest) -> bool:
|
||||
runtime = getattr(request, "runtime", None)
|
||||
context = getattr(runtime, "context", None) if runtime is not None else None
|
||||
if isinstance(context, dict) and context.get("rag_mode_enabled"):
|
||||
return True
|
||||
config = getattr(runtime, "config", None) if runtime is not None else None
|
||||
if isinstance(config, dict):
|
||||
configurable = config.get("configurable")
|
||||
if isinstance(configurable, dict) and configurable.get("rag_mode_enabled"):
|
||||
return True
|
||||
runtime_context = config.get("context")
|
||||
if isinstance(runtime_context, dict) and runtime_context.get("rag_mode_enabled"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _patch(request: ModelRequest) -> ModelRequest | None:
|
||||
if not RagCitationMiddleware._enabled(request):
|
||||
return None
|
||||
messages = list(request.messages)
|
||||
prompt, batch = build_latest_reference_prompt(messages)
|
||||
if not prompt:
|
||||
_log_rag_debug(
|
||||
"middleware reference-prompt enabled=true prompt=false messages=%s",
|
||||
len(messages),
|
||||
)
|
||||
return None
|
||||
summary = build_reference_debug_summary(messages, [batch] if batch else None)
|
||||
_log_rag_debug(
|
||||
"middleware reference-prompt enabled=true prompt=true single_batch=true messages=%s "
|
||||
"tool_messages=%s raw_items=%s normalized_items=%s kept_items=%s latest_batch_id=%s "
|
||||
"latest_batch_sources=%s",
|
||||
summary["messages"],
|
||||
summary["tool_messages"],
|
||||
summary["raw_items"],
|
||||
summary["normalized_items"],
|
||||
summary["kept_items"],
|
||||
(batch or {}).get("id"),
|
||||
len((batch or {}).get("sources") or []),
|
||||
)
|
||||
_log_rag_debug(
|
||||
"middleware reference-prompt-payload %s",
|
||||
json.dumps(
|
||||
build_reference_debug_payload(
|
||||
messages=messages,
|
||||
batch=batch,
|
||||
prompt=prompt,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
)
|
||||
patched_messages = [
|
||||
*messages,
|
||||
SystemMessage(content=prompt),
|
||||
]
|
||||
return request.override(messages=patched_messages)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
patched = self._patch(request)
|
||||
return handler(patched or request)
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
patched = self._patch(request)
|
||||
return await handler(patched or request)
|
||||
@@ -840,10 +840,13 @@ class DeerFlowClient:
|
||||
|
||||
current_config = get_extensions_config()
|
||||
|
||||
config_data = {
|
||||
"mcpServers": mcp_servers,
|
||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
||||
}
|
||||
config_data = {
|
||||
"mcpServers": mcp_servers,
|
||||
"skills": {
|
||||
name: skill.model_dump(by_alias=True, exclude_none=True)
|
||||
for name, skill in current_config.skills.items()
|
||||
},
|
||||
}
|
||||
|
||||
self._atomic_write_json(config_path, config_data)
|
||||
|
||||
@@ -903,13 +906,20 @@ class DeerFlowClient:
|
||||
if config_path is None:
|
||||
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
|
||||
|
||||
extensions_config = get_extensions_config()
|
||||
extensions_config.skills[name] = SkillStateConfig(enabled=enabled)
|
||||
|
||||
config_data = {
|
||||
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()},
|
||||
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()},
|
||||
}
|
||||
extensions_config = get_extensions_config()
|
||||
current_skill_config = extensions_config.skills.get(name)
|
||||
extensions_config.skills[name] = SkillStateConfig(
|
||||
enabled=enabled,
|
||||
display=current_skill_config.display if current_skill_config else None,
|
||||
)
|
||||
|
||||
config_data = {
|
||||
"mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
|
||||
"skills": {
|
||||
n: sc.model_dump(by_alias=True, exclude_none=True)
|
||||
for n, sc in extensions_config.skills.items()
|
||||
},
|
||||
}
|
||||
|
||||
self._atomic_write_json(config_path, config_data)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Configuration and loaders for custom agents."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
@@ -13,6 +15,25 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SOUL_FILENAME = "SOUL.md"
|
||||
AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
# Per-file mtime caches. These are read on every /api/agents list/detail request
|
||||
# (one read per agent) — caching keyed by (path, mtime_ns, size) is correct
|
||||
# because any edit to the file updates mtime, and an external tool re-writing
|
||||
# the same bytes doesn't actually change the result we'd return. Cache size is
|
||||
# unbounded but lookup is O(1) and entries are tiny (one parsed dict / string
|
||||
# per agent file); for the order of N≤10⁴ agents the memory cost is negligible.
|
||||
_config_cache: dict[str, tuple[tuple[int, int], "AgentConfig | None"]] = {}
|
||||
_soul_cache: dict[str, tuple[tuple[int, int], str | None]] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _file_stat_key(path) -> tuple[int, int] | None:
|
||||
"""Return (mtime_ns, size) for cache invalidation, or None when missing."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return (st.st_mtime_ns, st.st_size)
|
||||
# Backward-compatible alias. Historically this validated the display name;
|
||||
# after the DB-backed agent table, display names can be Chinese and duplicated.
|
||||
# The pattern now applies only to storage/runtime ids.
|
||||
@@ -96,6 +117,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
)
|
||||
return None
|
||||
|
||||
# mtime-based cache. The config file is rewritten every time the agent is
|
||||
# edited via PUT /api/agents/{id}, which bumps mtime — so the next read
|
||||
# picks up the change automatically. Missing config_file = key None below,
|
||||
# bypassing the cache (cheap path: just return AgentConfig with id/name).
|
||||
cache_path_key = str(config_file)
|
||||
stat_key = _file_stat_key(config_file) if config_file.exists() else None
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
cached = _config_cache.get(cache_path_key)
|
||||
if cached is not None and cached[0] == stat_key:
|
||||
return cached[1]
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
@@ -114,7 +147,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
known_fields = set(AgentConfig.model_fields.keys())
|
||||
data = {k: v for k, v in data.items() if k in known_fields}
|
||||
|
||||
return AgentConfig(**data)
|
||||
result = AgentConfig(**data)
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
_config_cache[cache_path_key] = (stat_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def load_agent_soul(agent_id: str | None) -> str | None:
|
||||
@@ -133,8 +170,21 @@ def load_agent_soul(agent_id: str | None) -> str | None:
|
||||
soul_path = agent_dir / SOUL_FILENAME
|
||||
if not soul_path.exists():
|
||||
return None
|
||||
# mtime-based cache, same rationale as load_agent_config: SOUL.md is
|
||||
# rewritten on edit (PUT /api/agents/{id}), which bumps mtime.
|
||||
cache_path_key = str(soul_path)
|
||||
stat_key = _file_stat_key(soul_path)
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
cached = _soul_cache.get(cache_path_key)
|
||||
if cached is not None and cached[0] == stat_key:
|
||||
return cached[1]
|
||||
content = soul_path.read_text(encoding="utf-8").strip()
|
||||
return content or None
|
||||
result = content or None
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
_soul_cache[cache_path_key] = (stat_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def list_custom_agents() -> list[AgentConfig]:
|
||||
|
||||
+59
-5
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -53,10 +53,64 @@ class McpServerConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillStateConfig(BaseModel):
|
||||
"""Configuration for a single skill's state."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||
class SkillDisplayTableColumn(BaseModel):
|
||||
"""A configured table column for structured skill result display."""
|
||||
|
||||
label: str = Field(description="Column label shown in the frontend table")
|
||||
field: str = Field(description="Field name or dot path read from each result item")
|
||||
width: str | None = Field(default=None, description="Optional CSS width for this column")
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillDisplayConfig(BaseModel):
|
||||
"""Frontend display adapter for structured skill results."""
|
||||
|
||||
enabled: bool = Field(default=False, description="Whether result display adaptation is enabled")
|
||||
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
|
||||
default="none",
|
||||
description="How the frontend should render this skill's structured results",
|
||||
)
|
||||
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
|
||||
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
|
||||
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
|
||||
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
|
||||
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
|
||||
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
|
||||
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
|
||||
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
|
||||
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
|
||||
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
|
||||
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
|
||||
link_strategy: Literal["auto", "url", "id"] = Field(
|
||||
default="auto",
|
||||
description="Whether links use a URL field, an ID template, or automatic fallback",
|
||||
alias="linkStrategy",
|
||||
)
|
||||
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
|
||||
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
|
||||
default="doubleBracket",
|
||||
description="Citation token style expected in model output",
|
||||
alias="citationToken",
|
||||
)
|
||||
max_items: int = Field(default=8, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
|
||||
custom_instruction: str = Field(
|
||||
default="",
|
||||
description="User-authored instruction for custom rendering schema generation",
|
||||
alias="customInstruction",
|
||||
)
|
||||
table_columns: list[SkillDisplayTableColumn] = Field(
|
||||
default_factory=list,
|
||||
description="Columns used when mode is table",
|
||||
alias="tableColumns",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillStateConfig(BaseModel):
|
||||
"""Configuration for a single skill's state."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
|
||||
|
||||
|
||||
class ExtensionsConfig(BaseModel):
|
||||
|
||||
@@ -17,6 +17,7 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -37,10 +38,53 @@ class PromptPrefixSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class AppearanceSettings(BaseModel):
|
||||
"""System-wide appearance defaults that any *new* user inherits.
|
||||
|
||||
Once a user explicitly toggles their theme in the UI, that choice is
|
||||
persisted in the browser and supersedes this default — so changing
|
||||
``default_theme`` here only affects users who have never touched the
|
||||
toggle, and any future first-time loads.
|
||||
"""
|
||||
|
||||
default_theme: Literal["light", "dark"] = Field(
|
||||
default="light",
|
||||
description="Theme applied for users who haven't picked their own (light / dark)",
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class CitationDisplaySettings(BaseModel):
|
||||
"""System-wide cleanup rules for citation cards and detail dialogs."""
|
||||
|
||||
cleanup_words: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.",
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class PrivateSquareSettings(BaseModel):
|
||||
"""Admin-configurable settings for the agent private-square (私密广场).
|
||||
|
||||
The password gates read access to all agents flagged ``is_private=True``.
|
||||
The hash is stored opaquely — the router layer is responsible for hashing
|
||||
and verifying so the harness package keeps zero dependencies on
|
||||
``app.gateway.auth``.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(default=False, description="Whether the private square is offered to users at all")
|
||||
password_hash: str = Field(default="", description="Opaque hash of the admin-configured password. Empty = no password set yet.")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""Top-level system settings document."""
|
||||
|
||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.agents.base import AgentStore
|
||||
from deerflow.persistence.agents.memory import MemoryAgentStore
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||
from deerflow.persistence.agents.sql import AgentRepository
|
||||
|
||||
__all__ = [
|
||||
"AgentFavoriteRow",
|
||||
"AgentRepository",
|
||||
"AgentRow",
|
||||
"AgentStore",
|
||||
|
||||
@@ -81,3 +81,61 @@ class AgentStore(ABC):
|
||||
async def list_by_ids(self, agent_ids: list[str], user_id: str) -> list[dict[str, Any]]:
|
||||
"""Return rows for ``agent_ids`` visible to ``user_id``, preserving the input order."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_private(self) -> list[dict[str, Any]]:
|
||||
"""Return every agent flagged ``is_private=True``.
|
||||
|
||||
Caller is responsible for authorization — typically the caller has
|
||||
already verified the admin-configured private-square password.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
"""Return every agent regardless of ownership/visibility.
|
||||
|
||||
Caller is responsible for authorization — typically the caller has
|
||||
already verified ``system_role == "admin"``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update any agent row by id, regardless of ownership.
|
||||
|
||||
Caller is responsible for authorization (admin only). Returns ``None``
|
||||
if the agent does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
"""Delete any agent row by id, regardless of ownership.
|
||||
|
||||
Caller is responsible for authorization (admin only). Returns
|
||||
``False`` if the agent does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# --- Favorites ("收藏") ---
|
||||
|
||||
@abstractmethod
|
||||
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
||||
"""Bookmark ``agent_id`` for ``user_id``. Idempotent."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
|
||||
"""Remove a bookmark. Returns ``False`` when none existed."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
||||
"""Return the set of agent ids ``user_id`` has favorited."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
||||
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
|
||||
raise NotImplementedError
|
||||
+44
@@ -21,6 +21,7 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
|
||||
class MemoryAgentStore(AgentStore):
|
||||
def __init__(self) -> None:
|
||||
self._agents: dict[str, dict[str, Any]] = {}
|
||||
self._favorites: set[tuple[str, str]] = set()
|
||||
|
||||
@staticmethod
|
||||
def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
|
||||
@@ -138,3 +139,46 @@ class MemoryAgentStore(AgentStore):
|
||||
rows = [self._agents[aid] for aid in agent_ids if aid in self._agents and self._is_visible(self._agents[aid], user_id)]
|
||||
rows.sort(key=lambda a: order_map.get(a["id"], 0))
|
||||
return [_copy(a) for a in rows]
|
||||
|
||||
async def list_private(self) -> list[dict[str, Any]]:
|
||||
rows = [_copy(a) for a in self._agents.values() if bool(a.get("is_private"))]
|
||||
rows.sort(key=lambda a: a.get("created_at") or "")
|
||||
return rows
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
rows = [_copy(a) for a in self._agents.values()]
|
||||
rows.sort(key=lambda a: a.get("created_at") or "")
|
||||
return rows
|
||||
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
return _copy(row)
|
||||
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
if agent_id not in self._agents:
|
||||
return False
|
||||
self._agents.pop(agent_id, None)
|
||||
return True
|
||||
|
||||
# --- Favorites ("收藏") ---
|
||||
|
||||
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
||||
self._favorites.add((user_id, agent_id))
|
||||
|
||||
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
|
||||
if (user_id, agent_id) in self._favorites:
|
||||
self._favorites.discard((user_id, agent_id))
|
||||
return True
|
||||
return False
|
||||
|
||||
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
||||
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
|
||||
|
||||
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
||||
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
|
||||
+24
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, String
|
||||
from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -21,6 +21,12 @@ class AgentRow(Base):
|
||||
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
# When True, the agent is moved into the password-gated "private square"
|
||||
# (私密广场) and is hidden from the regular public square / built-in list.
|
||||
# The flag is independent of ``published``: a private agent is still
|
||||
# visible to its owner in the "我的" tab, and visible to anyone in the
|
||||
# private-square listing once the password has been verified.
|
||||
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
|
||||
# featured rank (lower sorts first). Only ``published=True`` agents are
|
||||
# eligible; the router enforces that invariant on writes. BIGINT so the
|
||||
@@ -29,3 +35,20 @@ class AgentRow(Base):
|
||||
featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AgentFavoriteRow(Base):
|
||||
"""A user's bookmark of a (typically public-square) agent.
|
||||
|
||||
Lets a user collect agents from the 广场 (square) into their "我的" tab
|
||||
without owning them. One row per (user, agent); the unique constraint makes
|
||||
favoriting idempotent.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_favorites"
|
||||
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
+109
-7
@@ -4,12 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import and_, delete, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.agents.base import AgentStore
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||
|
||||
|
||||
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
|
||||
@@ -28,10 +30,31 @@ class AgentRepository(AgentStore):
|
||||
|
||||
@staticmethod
|
||||
def _visible_filter(user_id: str):
|
||||
"""Visibility for single-record fetches (``get_visible``).
|
||||
|
||||
Lenient: a caller who already knows the agent id can fetch it even
|
||||
when it's flagged ``is_private``. The private-square password is the
|
||||
*discovery* gate (listings); individual reads are not re-gated, so a
|
||||
bookmarked / linked private agent stays openable.
|
||||
"""
|
||||
return or_(AgentRow.user_id.is_(None), AgentRow.user_id == user_id, AgentRow.published.is_(True))
|
||||
|
||||
@staticmethod
|
||||
def _listing_filter(user_id: str):
|
||||
"""Visibility for *listings* — hides private-square agents from
|
||||
non-owners so they only surface inside the password-gated endpoint.
|
||||
Owners keep seeing their own private agents in '我的'.
|
||||
"""
|
||||
return or_(
|
||||
AgentRow.user_id == user_id,
|
||||
and_(
|
||||
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
|
||||
AgentRow.is_private.is_(False),
|
||||
),
|
||||
)
|
||||
|
||||
async def list_visible(self, user_id: str) -> list[dict[str, Any]]:
|
||||
stmt = select(AgentRow).where(self._visible_filter(user_id)).order_by(AgentRow.created_at.asc())
|
||||
stmt = select(AgentRow).where(self._listing_filter(user_id)).order_by(AgentRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
@@ -61,6 +84,7 @@ class AgentRepository(AgentStore):
|
||||
description=data.get("description") or "",
|
||||
user_id=data.get("user_id"),
|
||||
published=bool(data.get("published", False)),
|
||||
is_private=bool(data.get("is_private", False)),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -99,7 +123,7 @@ class AgentRepository(AgentStore):
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
@@ -112,7 +136,7 @@ class AgentRepository(AgentStore):
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None or row.user_id is not None:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
@@ -157,7 +181,7 @@ class AgentRepository(AgentStore):
|
||||
async def list_featured(self, user_id: str) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(AgentRow)
|
||||
.where(AgentRow.featured_order.is_not(None), self._visible_filter(user_id))
|
||||
.where(AgentRow.featured_order.is_not(None), self._listing_filter(user_id))
|
||||
.order_by(AgentRow.featured_order.asc(), AgentRow.created_at.asc())
|
||||
)
|
||||
async with self._sf() as session:
|
||||
@@ -168,9 +192,87 @@ class AgentRepository(AgentStore):
|
||||
if not agent_ids:
|
||||
return []
|
||||
order_map = {aid: idx for idx, aid in enumerate(agent_ids)}
|
||||
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._visible_filter(user_id))
|
||||
stmt = select(AgentRow).where(AgentRow.id.in_(agent_ids), self._listing_filter(user_id))
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
rows = list(result.scalars())
|
||||
rows.sort(key=lambda r: order_map.get(r.id, len(order_map)))
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
async def list_private(self) -> list[dict[str, Any]]:
|
||||
stmt = select(AgentRow).where(AgentRow.is_private.is_(True)).order_by(AgentRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
stmt = select(AgentRow).order_by(AgentRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
# --- Favorites ("收藏") ---
|
||||
|
||||
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
||||
async with self._sf() as session:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(AgentFavoriteRow.id)
|
||||
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return
|
||||
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
# Lost a race against a concurrent favorite of the same agent —
|
||||
# the unique constraint already guarantees the desired state.
|
||||
await session.rollback()
|
||||
|
||||
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(AgentFavoriteRow).where(
|
||||
AgentFavoriteRow.user_id == user_id,
|
||||
AgentFavoriteRow.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
||||
async with self._sf() as session:
|
||||
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
|
||||
await session.commit()
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Add ``agents.is_private`` for the private-square (私密广场) feature.
|
||||
|
||||
A nullable-default bool column flagging agents that should live in the
|
||||
password-gated private square instead of the regular public square.
|
||||
|
||||
Revision ID: 20260526_01
|
||||
Revises: 20260522_01
|
||||
Create Date: 2026-05-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260526_01"
|
||||
down_revision = "20260522_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _has_index(table_name: str, index_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("agents") and not _has_column("agents", "is_private"):
|
||||
op.add_column(
|
||||
"agents",
|
||||
sa.Column(
|
||||
"is_private",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
if _has_table("agents") and not _has_index("agents", "ix_agents_is_private"):
|
||||
op.create_index("ix_agents_is_private", "agents", ["is_private"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_index("agents", "ix_agents_is_private"):
|
||||
op.drop_index("ix_agents_is_private", table_name="agents")
|
||||
if _has_column("agents", "is_private"):
|
||||
op.drop_column("agents", "is_private")
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Add ``agent_favorites`` for the agent collection (收藏) feature.
|
||||
|
||||
Lets a user bookmark agents from the public square (广场) so they surface in
|
||||
their "我的" tab without owning them. One row per (user, agent).
|
||||
|
||||
Revision ID: 20260528_01
|
||||
Revises: 20260526_01
|
||||
Create Date: 2026-05-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260528_01"
|
||||
down_revision = "20260526_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table("agent_favorites"):
|
||||
op.create_table(
|
||||
"agent_favorites",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=False),
|
||||
sa.Column("agent_id", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),
|
||||
)
|
||||
op.create_index("ix_agent_favorites_user_id", "agent_favorites", ["user_id"])
|
||||
op.create_index("ix_agent_favorites_agent_id", "agent_favorites", ["agent_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("agent_favorites"):
|
||||
op.drop_table("agent_favorites")
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Add ``user_custom_prompts`` for per-user saved prompts (常用提示词).
|
||||
|
||||
Bound to the user (not the browser) so prompts follow the account.
|
||||
|
||||
Revision ID: 20260528_02
|
||||
Revises: 20260528_01
|
||||
Create Date: 2026-05-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260528_02"
|
||||
down_revision = "20260528_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table("user_custom_prompts"):
|
||||
op.create_table(
|
||||
"user_custom_prompts",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_user_custom_prompts_user_id", "user_custom_prompts", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("user_custom_prompts"):
|
||||
op.drop_table("user_custom_prompts")
|
||||
+6
-2
@@ -15,7 +15,7 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
||||
there is no matching entity directory.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
@@ -30,15 +30,17 @@ from deerflow.persistence.scheduled_tasks.model import (
|
||||
ScheduledTaskSubscriptionRow,
|
||||
SchedulerThreadRow,
|
||||
)
|
||||
from deerflow.persistence.skills.model import SkillRow
|
||||
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
|
||||
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
||||
|
||||
__all__ = [
|
||||
"FeedbackRow",
|
||||
"AgentFavoriteRow",
|
||||
"AgentRow",
|
||||
"LlmCallMetricRow",
|
||||
"NotificationRow",
|
||||
@@ -51,11 +53,13 @@ __all__ = [
|
||||
"ScheduledTaskSubscriptionRow",
|
||||
"SchedulerThreadRow",
|
||||
"SkillRow",
|
||||
"SkillDisplayAdapterRow",
|
||||
"TagAssignmentRow",
|
||||
"TagRow",
|
||||
"ThreadMetaRow",
|
||||
"ThreadShareRow",
|
||||
"ToolCallMetricRow",
|
||||
"UserCustomPromptRow",
|
||||
"UserMemoryConfigRow",
|
||||
"UserRow",
|
||||
]
|
||||
@@ -68,6 +68,14 @@ class SkillStore(ABC):
|
||||
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
"""Return every skill record regardless of ownership/visibility.
|
||||
|
||||
Caller is responsible for authorization (admin only).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_published_names(self) -> set[str]:
|
||||
"""Return the set of skill names with ``published = True`` (any owner).
|
||||
|
||||
+6
@@ -134,5 +134,11 @@ class MemorySkillStore(SkillStore):
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
_with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
for record in self._skills.values()
|
||||
]
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
return {name for name, record in self._skills.items() if record.get("published")}
|
||||
+13
@@ -31,3 +31,16 @@ class SkillRow(Base):
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class SkillDisplayAdapterRow(Base):
|
||||
__tablename__ = "skill_display_adapters"
|
||||
|
||||
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# Store JSON as text for maximum MySQL compatibility. Some deployments still
|
||||
# run MySQL variants where native JSON columns or unquoted names such as
|
||||
# ``display`` are not accepted consistently.
|
||||
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
@@ -149,6 +149,12 @@ class SkillRepository(SkillStore):
|
||||
async with self._sf() as session:
|
||||
return await session.scalar(stmt) or 0
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
stmt = select(SkillRow).order_by(SkillRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
||||
async with self._sf() as session:
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Per-user custom prompt persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
||||
from deerflow.persistence.user_prompts.sql import UserCustomPromptRepository
|
||||
|
||||
__all__ = [
|
||||
"UserCustomPromptRepository",
|
||||
"UserCustomPromptRow",
|
||||
"make_user_prompt_store",
|
||||
]
|
||||
|
||||
|
||||
def make_user_prompt_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return UserCustomPromptRepository(session_factory)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"""ORM model for per-user custom prompts (常用提示词)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class UserCustomPromptRow(Base):
|
||||
__tablename__ = "user_custom_prompts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
"""SQLAlchemy-backed per-user custom prompt repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
||||
|
||||
|
||||
def _row_to_dict(row: UserCustomPromptRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class UserCustomPromptRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(UserCustomPromptRow)
|
||||
.where(UserCustomPromptRow.user_id == user_id)
|
||||
.order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def create(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = UserCustomPromptRow(
|
||||
id=data["id"],
|
||||
user_id=user_id,
|
||||
title=data.get("title") or "",
|
||||
content=data["content"],
|
||||
sort_order=data.get("sort_order", 0),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def update(self, prompt_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(UserCustomPromptRow, prompt_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
for key in ("title", "content", "sort_order"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def delete(self, prompt_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(UserCustomPromptRow, prompt_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,865 @@
|
||||
"""Build stable per-turn citation batches from LangGraph messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
|
||||
|
||||
|
||||
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
|
||||
|
||||
|
||||
def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
batches: list[dict[str, Any]] = []
|
||||
for index, message in enumerate(messages):
|
||||
if _message_type(message) != "human":
|
||||
continue
|
||||
end = len(messages)
|
||||
for cursor in range(index + 1, len(messages)):
|
||||
if _message_type(messages[cursor]) == "human":
|
||||
end = cursor
|
||||
break
|
||||
sources = _collect_sources(messages[index + 1 : end])
|
||||
if sources:
|
||||
anchor = _find_reference_batch_anchor(messages, index + 1, end)
|
||||
message_id = _message_id(anchor) or _message_id(message) or str(index)
|
||||
batches.append(
|
||||
{
|
||||
"id": message_id,
|
||||
"assistant_message_id": message_id,
|
||||
"sources": sources,
|
||||
}
|
||||
)
|
||||
return batches
|
||||
|
||||
|
||||
def build_latest_reference_batch(messages: list[Any]) -> dict[str, Any] | None:
|
||||
"""Return the single combined citation batch for the latest human turn."""
|
||||
human_index = _find_latest_human_index(messages)
|
||||
if human_index is None:
|
||||
return None
|
||||
sources = _collect_sources(messages[human_index + 1 :])
|
||||
if not sources:
|
||||
return None
|
||||
assistant = _find_reference_batch_anchor(messages, human_index + 1, len(messages))
|
||||
message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index)
|
||||
return {
|
||||
"id": message_id,
|
||||
"assistant_message_id": message_id,
|
||||
"source_count": len(sources),
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
|
||||
def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, Any] | None]:
|
||||
batch = build_latest_reference_batch(messages)
|
||||
if batch is None:
|
||||
return "", None
|
||||
return build_reference_prompt_for_batch(batch), batch
|
||||
|
||||
|
||||
def build_reference_prompt_for_batch(batch: dict[str, Any]) -> str:
|
||||
"""Build the authoritative model prompt from one already-combined batch."""
|
||||
sources = [
|
||||
source
|
||||
for source in batch.get("sources", [])
|
||||
if isinstance(source, dict)
|
||||
]
|
||||
source_count = len(sources)
|
||||
lines = [
|
||||
f"本轮所有检索结果已合并为同一个 referenceBatch,共 {source_count} 条,编号从 [1] 到 [{source_count}] 唯一连续。",
|
||||
"下面这一批就是当前回答唯一可用的参考文献批次;不要按检索词、工具调用次数或工具原始编号重新分批编号。",
|
||||
"如果你仍需要补充检索,请只继续调用工具,不要输出正文;最终回答只能引用补充检索后系统重新合并出的最新 referenceBatch。",
|
||||
"",
|
||||
f"<reference_batch source_count=\"{source_count}\">",
|
||||
]
|
||||
for source in sources:
|
||||
title = source.get("title") or f"参考文献 {source.get('index')}"
|
||||
meta = " · ".join(
|
||||
str(item)
|
||||
for item in (source.get("source"), source.get("author"), source.get("time"))
|
||||
if item
|
||||
)
|
||||
content = str(source.get("content") or source.get("snippet") or "").strip()
|
||||
if len(content) > 520:
|
||||
content = content[:517] + "..."
|
||||
head = f"[{source.get('index')}] {title}"
|
||||
if meta:
|
||||
head += f"({meta})"
|
||||
lines.append(head)
|
||||
if content:
|
||||
lines.append(content)
|
||||
lines.append("")
|
||||
lines.append("</reference_batch>")
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
[
|
||||
"引用规则:",
|
||||
"1. 回答正文中的关键结论必须标注对应编号,例如 [1]、[2]。",
|
||||
"2. 多个来源可写为 [1][3][5]。",
|
||||
"3. 只能引用上方存在的编号,不要编造编号。",
|
||||
"4. 不要使用工具原始编号,也不要重新排序、重新分批或重新从 [1] 开始编号。",
|
||||
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
|
||||
"6. 参考文献模式下不要输出 <skill-display .../> 占位符,也不要在正文里额外渲染卡片、表格或列表;前端会在右侧参考文献区域展示。",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def _find_latest_human_index(messages: list[Any]) -> int | None:
|
||||
for index in range(len(messages) - 1, -1, -1):
|
||||
if _message_type(messages[index]) == "human":
|
||||
return index
|
||||
return None
|
||||
|
||||
|
||||
def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
"""Return compact counts for citation extraction logs."""
|
||||
turn_summaries: list[dict[str, Any]] = []
|
||||
total_tool_messages = 0
|
||||
total_raw_items = 0
|
||||
total_normalized_items = 0
|
||||
total_kept_items = 0
|
||||
for index, message in enumerate(messages):
|
||||
if _message_type(message) != "human":
|
||||
continue
|
||||
end = len(messages)
|
||||
for cursor in range(index + 1, len(messages)):
|
||||
if _message_type(messages[cursor]) == "human":
|
||||
end = cursor
|
||||
break
|
||||
tool_messages = 0
|
||||
raw_items = 0
|
||||
normalized_items = 0
|
||||
kept_items = 0
|
||||
for tool_message in messages[index + 1 : end]:
|
||||
if _message_type(tool_message) != "tool":
|
||||
continue
|
||||
tool_messages += 1
|
||||
items = _first_list(_extract_tool_payload(tool_message))
|
||||
raw_items += len(items)
|
||||
for raw_item in items:
|
||||
normalized = _normalize_reference_item(raw_item, normalized_items + 1)
|
||||
if not normalized:
|
||||
continue
|
||||
normalized_items += 1
|
||||
kept_items += 1
|
||||
total_tool_messages += tool_messages
|
||||
total_raw_items += raw_items
|
||||
total_normalized_items += normalized_items
|
||||
total_kept_items += kept_items
|
||||
turn_summaries.append(
|
||||
{
|
||||
"human_index": index,
|
||||
"tool_messages": tool_messages,
|
||||
"raw_items": raw_items,
|
||||
"normalized_items": normalized_items,
|
||||
"kept_items": kept_items,
|
||||
}
|
||||
)
|
||||
batch_summaries = [
|
||||
{
|
||||
"id": batch.get("id"),
|
||||
"assistant_message_id": batch.get("assistant_message_id"),
|
||||
"source_count": len(batch.get("sources") or []),
|
||||
}
|
||||
for batch in (batches or build_reference_batches(messages))
|
||||
]
|
||||
return {
|
||||
"messages": len(messages),
|
||||
"turns": len(turn_summaries),
|
||||
"tool_messages": total_tool_messages,
|
||||
"raw_items": total_raw_items,
|
||||
"normalized_items": total_normalized_items,
|
||||
"kept_items": total_kept_items,
|
||||
"turns_detail": turn_summaries,
|
||||
"batches": batch_summaries,
|
||||
}
|
||||
|
||||
|
||||
def build_reference_debug_payload(
|
||||
*,
|
||||
messages: list[Any] | None = None,
|
||||
batch: dict[str, Any] | None = None,
|
||||
batches: list[dict[str, Any]] | None = None,
|
||||
prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a JSON-safe payload for comparing model prompts and UI batches."""
|
||||
payload: dict[str, Any] = {}
|
||||
if messages is not None:
|
||||
payload["message_trace"] = [_message_debug_item(index, message) for index, message in enumerate(messages)]
|
||||
if prompt is not None:
|
||||
payload["model_reference_prompt"] = prompt
|
||||
if batch is not None:
|
||||
payload["batch"] = _batch_debug_item(batch)
|
||||
if batches is not None:
|
||||
payload["batches"] = [_batch_debug_item(item) for item in batches]
|
||||
return payload
|
||||
|
||||
|
||||
def adapt_reference_batches_with_displays(
|
||||
batches: list[dict[str, Any]],
|
||||
displays: dict[str, SkillDisplayConfig | dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Apply persisted skill display configs to already-built reference batches.
|
||||
|
||||
The reference builder can run before the gateway has a fresh extensions
|
||||
cache, while the skill adapter editor persists the user's latest mapping in
|
||||
the database. This pass lets the API response use that DB-backed mapping
|
||||
without changing citation indexes.
|
||||
"""
|
||||
normalized_displays: dict[str, SkillDisplayConfig] = {}
|
||||
for skill_name, display in displays.items():
|
||||
try:
|
||||
config = (
|
||||
display
|
||||
if isinstance(display, SkillDisplayConfig)
|
||||
else SkillDisplayConfig.model_validate(display)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if not config.enabled or config.mode == "none":
|
||||
continue
|
||||
normalized_displays[skill_name] = config
|
||||
|
||||
if not normalized_displays:
|
||||
return batches
|
||||
|
||||
displays_by_normalized_name = {
|
||||
_normalize_name(name): (name, display)
|
||||
for name, display in normalized_displays.items()
|
||||
}
|
||||
adapted_batches: list[dict[str, Any]] = []
|
||||
for batch in batches:
|
||||
sources: list[dict[str, Any]] = []
|
||||
for source in batch.get("sources", []):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
skill_name = str(source.get("skillName") or "")
|
||||
display = normalized_displays.get(skill_name)
|
||||
if display is None:
|
||||
normalized_source = _normalize_name(skill_name)
|
||||
normalized_match = displays_by_normalized_name.get(normalized_source)
|
||||
if normalized_match is None:
|
||||
for normalized_skill, candidate in displays_by_normalized_name.items():
|
||||
if (
|
||||
normalized_source.endswith(normalized_skill)
|
||||
or normalized_skill in normalized_source
|
||||
):
|
||||
normalized_match = candidate
|
||||
break
|
||||
if normalized_match is not None:
|
||||
skill_name, display = normalized_match
|
||||
raw = source.get("raw")
|
||||
if display is not None and isinstance(raw, dict):
|
||||
index_value = int(source.get("index") or len(sources) + 1)
|
||||
configured = _normalize_with_display_config(
|
||||
raw,
|
||||
index_value,
|
||||
skill_name,
|
||||
display,
|
||||
)
|
||||
if configured is not None:
|
||||
sources.append(
|
||||
{
|
||||
**source,
|
||||
**configured,
|
||||
"index": index_value,
|
||||
"skillName": skill_name,
|
||||
"raw": raw,
|
||||
}
|
||||
)
|
||||
continue
|
||||
sources.append(source)
|
||||
adapted_batches.append({**batch, "sources": sources})
|
||||
return adapted_batches
|
||||
|
||||
|
||||
def _message_debug_item(index: int, message: Any) -> dict[str, Any]:
|
||||
content = _message_content(message)
|
||||
tool_calls = _extract_tool_calls(message)
|
||||
return {
|
||||
"position": index,
|
||||
"type": _message_type(message),
|
||||
"id": _message_id(message),
|
||||
"name": _message_name(message) if _message_type(message) == "tool" else None,
|
||||
"tool_call_id": _message_tool_call_id(message) if _message_type(message) == "tool" else None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call.get("id"),
|
||||
"name": call.get("name"),
|
||||
"args": call.get("args"),
|
||||
}
|
||||
for call in tool_calls
|
||||
],
|
||||
"content_length": len(content) if isinstance(content, str) else None,
|
||||
}
|
||||
|
||||
|
||||
def _batch_debug_item(batch: dict[str, Any]) -> dict[str, Any]:
|
||||
sources = [source for source in batch.get("sources", []) if isinstance(source, dict)]
|
||||
return {
|
||||
"id": batch.get("id"),
|
||||
"assistant_message_id": batch.get("assistant_message_id"),
|
||||
"source_count": len(sources),
|
||||
"sources": [_source_debug_item(source) for source in sources],
|
||||
}
|
||||
|
||||
|
||||
def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = source.get("raw")
|
||||
raw_record = raw if isinstance(raw, dict) else {}
|
||||
return {
|
||||
"index": source.get("index"),
|
||||
"id": source.get("id"),
|
||||
"skillName": source.get("skillName"),
|
||||
"mode": source.get("mode"),
|
||||
"title": source.get("title"),
|
||||
"source": source.get("source"),
|
||||
"author": source.get("author"),
|
||||
"time": source.get("time"),
|
||||
"url": source.get("url"),
|
||||
"snippet": source.get("snippet"),
|
||||
"content": source.get("content"),
|
||||
"raw_id_candidates": {
|
||||
"recUuid": raw_record.get("recUuid"),
|
||||
"rec_uuid": raw_record.get("rec_uuid"),
|
||||
"uuid": raw_record.get("uuid"),
|
||||
"docId": raw_record.get("docId"),
|
||||
"doc_id": raw_record.get("doc_id"),
|
||||
"id": raw_record.get("id"),
|
||||
"url": raw_record.get("url"),
|
||||
},
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
sources: list[dict[str, Any]] = []
|
||||
pending_tool_contexts: list[dict[str, Any]] = []
|
||||
tool_contexts_by_id: dict[str, dict[str, Any]] = {}
|
||||
for message in messages:
|
||||
message_type = _message_type(message)
|
||||
if message_type == "ai":
|
||||
for tool_call in _extract_tool_calls(message):
|
||||
context = _build_tool_context(tool_call)
|
||||
pending_tool_contexts.append(context)
|
||||
if context.get("id"):
|
||||
tool_contexts_by_id[str(context["id"])] = context
|
||||
continue
|
||||
if message_type != "tool":
|
||||
continue
|
||||
tool_context = _resolve_tool_context(
|
||||
message,
|
||||
pending_tool_contexts,
|
||||
tool_contexts_by_id,
|
||||
)
|
||||
payload = _extract_tool_payload(message)
|
||||
for raw_item in _first_list(payload):
|
||||
normalized = _normalize_reference_item(
|
||||
raw_item,
|
||||
len(sources) + 1,
|
||||
payload=payload,
|
||||
tool_context=tool_context,
|
||||
)
|
||||
if not normalized:
|
||||
continue
|
||||
normalized["index"] = len(sources) + 1
|
||||
sources.append(normalized)
|
||||
return sources
|
||||
|
||||
|
||||
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
|
||||
answer = _find_last_assistant_answer(messages, start, end)
|
||||
if answer is not None:
|
||||
return answer
|
||||
for index in range(end - 1, start - 1, -1):
|
||||
candidate = messages[index]
|
||||
if _message_type(candidate) == "ai" and _message_id(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None:
|
||||
for index in range(end - 1, start - 1, -1):
|
||||
candidate = messages[index]
|
||||
if (
|
||||
_message_type(candidate) == "ai"
|
||||
and _message_id(candidate)
|
||||
and _has_answer_content(candidate)
|
||||
):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _message_type(message: Any) -> str:
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("type") or message.get("role") or "")
|
||||
return str(getattr(message, "type", "") or "")
|
||||
|
||||
|
||||
def _message_id(message: Any) -> str | None:
|
||||
value = message.get("id") if isinstance(message, dict) else getattr(message, "id", None)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _message_content(message: Any) -> Any:
|
||||
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
|
||||
|
||||
|
||||
def _message_name(message: Any) -> str:
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
|
||||
return str(getattr(message, "name", None) or "tool")
|
||||
|
||||
|
||||
def _message_tool_call_id(message: Any) -> str | None:
|
||||
if isinstance(message, dict):
|
||||
value = message.get("tool_call_id") or message.get("toolCallId")
|
||||
else:
|
||||
value = getattr(message, "tool_call_id", None) or getattr(message, "toolCallId", None)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _has_tool_calls(message: Any) -> bool:
|
||||
if isinstance(message, dict):
|
||||
direct = message.get("tool_calls")
|
||||
chunks = message.get("tool_call_chunks")
|
||||
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
|
||||
else:
|
||||
direct = getattr(message, "tool_calls", None)
|
||||
chunks = getattr(message, "tool_call_chunks", None)
|
||||
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
|
||||
return bool(direct or chunks or extra)
|
||||
|
||||
|
||||
def _has_answer_content(message: Any) -> bool:
|
||||
content = _message_content(message)
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
return any(
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "text"
|
||||
and str(part.get("text") or "").strip()
|
||||
for part in content
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _extract_tool_calls(message: Any) -> list[dict[str, Any]]:
|
||||
candidates: list[Any] = []
|
||||
if isinstance(message, dict):
|
||||
for key in ("tool_calls", "tool_call_chunks"):
|
||||
value = message.get(key)
|
||||
if isinstance(value, list):
|
||||
candidates.extend(value)
|
||||
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
|
||||
if isinstance(extra, list):
|
||||
candidates.extend(extra)
|
||||
else:
|
||||
for key in ("tool_calls", "tool_call_chunks"):
|
||||
value = getattr(message, key, None)
|
||||
if isinstance(value, list):
|
||||
candidates.extend(value)
|
||||
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
|
||||
if isinstance(extra, list):
|
||||
candidates.extend(extra)
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
for raw in candidates:
|
||||
call = _normalize_tool_call(raw)
|
||||
if not call:
|
||||
continue
|
||||
call_id = str(call.get("id") or "")
|
||||
if call_id and call_id in seen_ids:
|
||||
continue
|
||||
if call_id:
|
||||
seen_ids.add(call_id)
|
||||
calls.append(call)
|
||||
return calls
|
||||
|
||||
|
||||
def _normalize_tool_call(raw: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
fn = raw.get("function") if isinstance(raw.get("function"), dict) else {}
|
||||
name = raw.get("name") or fn.get("name") or raw.get("tool_name")
|
||||
if not name:
|
||||
return None
|
||||
return {
|
||||
"id": raw.get("id") or raw.get("tool_call_id") or raw.get("call_id"),
|
||||
"name": str(name),
|
||||
"args": _normalize_tool_args(raw.get("args") or raw.get("arguments") or fn.get("arguments")),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_tool_args(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return {"command": value}
|
||||
return {}
|
||||
|
||||
|
||||
def _build_tool_context(tool_call: dict[str, Any]) -> dict[str, Any]:
|
||||
tool_name = str(tool_call.get("name") or "tool")
|
||||
args = tool_call.get("args") if isinstance(tool_call.get("args"), dict) else {}
|
||||
return {
|
||||
"id": tool_call.get("id"),
|
||||
"tool_name": tool_name,
|
||||
"args": args,
|
||||
"skill_name": _infer_skill_name(tool_name, args),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_tool_context(
|
||||
message: Any,
|
||||
pending_tool_contexts: list[dict[str, Any]],
|
||||
tool_contexts_by_id: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
tool_call_id = _message_tool_call_id(message)
|
||||
if tool_call_id and tool_call_id in tool_contexts_by_id:
|
||||
context = tool_contexts_by_id[tool_call_id]
|
||||
pending_tool_contexts[:] = [
|
||||
item for item in pending_tool_contexts if str(item.get("id") or "") != tool_call_id
|
||||
]
|
||||
return context
|
||||
if pending_tool_contexts:
|
||||
return pending_tool_contexts.pop(0)
|
||||
tool_name = _message_name(message)
|
||||
return {
|
||||
"id": tool_call_id,
|
||||
"tool_name": tool_name,
|
||||
"args": {},
|
||||
"skill_name": _infer_skill_name(tool_name, {}),
|
||||
}
|
||||
|
||||
|
||||
def _extract_tool_payload(message: Any) -> Any:
|
||||
content = _message_content(message)
|
||||
if isinstance(content, dict):
|
||||
return _unwrap_tool_payload(content)
|
||||
if isinstance(content, list):
|
||||
texts: list[str] = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if isinstance(part.get("json"), dict):
|
||||
return _unwrap_tool_payload(part["json"])
|
||||
if part.get("type") == "json" and isinstance(part.get("json"), dict):
|
||||
return _unwrap_tool_payload(part["json"])
|
||||
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||
texts.append(part["text"])
|
||||
unwrapped = _unwrap_tool_payload(part)
|
||||
if unwrapped is not None:
|
||||
return unwrapped
|
||||
content = "\n".join(texts)
|
||||
if isinstance(content, str):
|
||||
return _parse_json_like(content)
|
||||
return None
|
||||
|
||||
|
||||
def _unwrap_tool_payload(value: dict[str, Any]) -> Any:
|
||||
list_keys = ("results", "data", "items", "hits", "documents", "records", "sources")
|
||||
if any(isinstance(value.get(key), list) for key in list_keys):
|
||||
return value
|
||||
for key in ("result", "output", "stdout", "content", "text", "payload", "response"):
|
||||
nested = value.get(key)
|
||||
if isinstance(nested, str):
|
||||
parsed = _parse_json_like(nested)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
if isinstance(nested, dict):
|
||||
unwrapped = _unwrap_tool_payload(nested)
|
||||
if unwrapped is not None:
|
||||
return unwrapped
|
||||
return value
|
||||
|
||||
|
||||
def _parse_json_like(text: str) -> Any:
|
||||
trimmed = text.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
try:
|
||||
return json.loads(trimmed)
|
||||
except Exception:
|
||||
pass
|
||||
first = trimmed.find("{")
|
||||
last = trimmed.rfind("}")
|
||||
if 0 <= first < last:
|
||||
try:
|
||||
return json.loads(trimmed[first : last + 1])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _first_list(payload: Any) -> list[Any]:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
for key in ("results", "data", "items", "hits", "documents", "records", "entries", "matches", "chunks", "sources"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
for key in ("result", "payload", "response", "output", "data"):
|
||||
found = _first_list(payload.get(key))
|
||||
if found:
|
||||
return found
|
||||
return []
|
||||
|
||||
|
||||
def _nested_records(raw: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return []
|
||||
layers = [raw]
|
||||
for key in ("metadata", "meta", "attrs", "attributes", "properties", "extra"):
|
||||
nested = raw.get(key)
|
||||
if isinstance(nested, dict):
|
||||
layers.append(nested)
|
||||
return layers
|
||||
|
||||
|
||||
def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
|
||||
for layer in layers:
|
||||
for key in keys:
|
||||
value = layer.get(key)
|
||||
if isinstance(value, (str, int, float)) and str(value).strip():
|
||||
return str(value).strip()
|
||||
if isinstance(value, list):
|
||||
text = _stringify(value, "")
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
|
||||
try:
|
||||
return {
|
||||
name: state.display
|
||||
for name, state in get_extensions_config().skills.items()
|
||||
if state.display is not None
|
||||
and state.enabled
|
||||
and state.display.enabled
|
||||
and state.display.mode != "none"
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _infer_skill_name(tool_name: str, args: dict[str, Any]) -> str | None:
|
||||
displays = _enabled_skill_displays()
|
||||
if tool_name in displays:
|
||||
return tool_name
|
||||
normalized_tool = _normalize_name(tool_name)
|
||||
for skill_name in displays:
|
||||
normalized_skill = _normalize_name(skill_name)
|
||||
if (
|
||||
normalized_tool == normalized_skill
|
||||
or normalized_tool.endswith(normalized_skill)
|
||||
or normalized_skill in normalized_tool
|
||||
):
|
||||
return skill_name
|
||||
for value in args.values():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
match = _SKILL_PATH_RE.search(value)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_display_match(
|
||||
raw: dict[str, Any],
|
||||
payload: Any,
|
||||
tool_context: dict[str, Any] | None,
|
||||
) -> tuple[str, SkillDisplayConfig] | None:
|
||||
displays = _enabled_skill_displays()
|
||||
if not displays:
|
||||
return None
|
||||
skill_name = str((tool_context or {}).get("skill_name") or "")
|
||||
display = displays.get(skill_name)
|
||||
if display is not None:
|
||||
return skill_name, display
|
||||
tool_name = str((tool_context or {}).get("tool_name") or "")
|
||||
inferred = _infer_skill_name(tool_name, (tool_context or {}).get("args") or {})
|
||||
if inferred and inferred in displays:
|
||||
return inferred, displays[inferred]
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_with_display_config(
|
||||
raw: dict[str, Any],
|
||||
fallback_index: int,
|
||||
skill_name: str,
|
||||
display: SkillDisplayConfig,
|
||||
) -> dict[str, Any] | None:
|
||||
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
|
||||
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
|
||||
content = _stringify(_get_by_path(raw, display.content_field), snippet)
|
||||
source = _stringify(_get_by_path(raw, display.source_field), "") or None
|
||||
author = _stringify(_get_by_path(raw, display.author_field), "") or None
|
||||
item_type = _stringify(_get_by_path(raw, display.type_field), "result") or "result"
|
||||
time = _stringify(_get_by_path(raw, display.time_field), "") or None
|
||||
item_id = _stringify(_get_by_path(raw, display.id_field), "") or None
|
||||
score = _number_or_none(_get_by_path(raw, display.score_field))
|
||||
url = _resolve_display_link(raw, display)
|
||||
if not title and not content and not url:
|
||||
return None
|
||||
return {
|
||||
"id": item_id or url,
|
||||
"skillName": skill_name,
|
||||
"mode": display.mode,
|
||||
"title": title,
|
||||
"type": item_type,
|
||||
"snippet": snippet,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"author": author,
|
||||
"time": time,
|
||||
"url": url,
|
||||
"score": score,
|
||||
"tableColumns": [column.model_dump(by_alias=True) for column in display.table_columns],
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
|
||||
url = _stringify(_get_by_path(raw, display.url_field), "")
|
||||
item_id = _stringify(_get_by_path(raw, display.id_field), "")
|
||||
if display.link_strategy == "url":
|
||||
return url or None
|
||||
if display.link_strategy == "id":
|
||||
return _render_template(display.link_template, raw) if item_id and display.link_template else None
|
||||
return url or (_render_template(display.link_template, raw) if item_id and display.link_template else None) or None
|
||||
|
||||
|
||||
def _render_template(template: str | None, raw: dict[str, Any]) -> str | None:
|
||||
if not template:
|
||||
return None
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
value = _get_by_path(raw, match.group(1).strip())
|
||||
return quote(_stringify(value, ""), safe="")
|
||||
|
||||
return re.sub(r"\{([^}]+)\}", replace, template)
|
||||
|
||||
|
||||
def _get_by_path(value: Any, path: str | None) -> Any:
|
||||
if not path:
|
||||
return None
|
||||
cursor = value
|
||||
for part in path.split("."):
|
||||
if isinstance(cursor, dict):
|
||||
cursor = cursor.get(part)
|
||||
else:
|
||||
return None
|
||||
return cursor
|
||||
|
||||
|
||||
def _stringify(value: Any, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, (str, int, float)):
|
||||
text = str(value).strip()
|
||||
return text if text else fallback
|
||||
if isinstance(value, list):
|
||||
parts = [
|
||||
str(item).strip()
|
||||
for item in value
|
||||
if isinstance(item, (str, int, float)) and str(item).strip()
|
||||
]
|
||||
return "、".join(parts) if parts else fallback
|
||||
return fallback
|
||||
|
||||
|
||||
def _number_or_none(value: Any) -> float | None:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", name.lower())
|
||||
|
||||
|
||||
def _normalize_reference_item(
|
||||
raw: Any,
|
||||
fallback_index: int,
|
||||
*,
|
||||
payload: Any = None,
|
||||
tool_context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
display_match = _resolve_display_match(raw, payload, tool_context)
|
||||
if display_match is not None:
|
||||
skill_name, display = display_match
|
||||
configured = _normalize_with_display_config(raw, fallback_index, skill_name, display)
|
||||
if configured is not None:
|
||||
return configured
|
||||
layers = _nested_records(raw)
|
||||
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
|
||||
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
|
||||
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
|
||||
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
|
||||
author = _pick(layers, ("author", "authors", "creator", "writer", "byline", "publisher", "owner"))
|
||||
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
|
||||
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
|
||||
score_raw = _pick(layers, ("score", "relevance", "similarity"))
|
||||
score = None
|
||||
if score_raw is not None:
|
||||
try:
|
||||
score = float(score_raw)
|
||||
except Exception:
|
||||
score = None
|
||||
if not title:
|
||||
title = source or f"结果 {fallback_index}"
|
||||
if not content:
|
||||
content = ""
|
||||
if not title and not content and not url:
|
||||
return None
|
||||
return {
|
||||
"id": item_id or url,
|
||||
"skillName": str((tool_context or {}).get("skill_name") or (tool_context or {}).get("tool_name") or "backend-reference"),
|
||||
"mode": "citation",
|
||||
"title": title,
|
||||
"type": "result",
|
||||
"snippet": content,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"author": author,
|
||||
"time": time,
|
||||
"url": url,
|
||||
"score": score,
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _reference_key(source: dict[str, Any]) -> str:
|
||||
raw = source.get("raw") if isinstance(source.get("raw"), dict) else {}
|
||||
item_id = source.get("id") or source.get("url") or raw.get("recUuid") or raw.get("uuid") or raw.get("docId") or raw.get("id")
|
||||
if item_id:
|
||||
return str(item_id).casefold().strip()
|
||||
return "|".join(
|
||||
str(source.get(key) or "").casefold().strip()
|
||||
for key in ("title", "source", "time", "content", "snippet")
|
||||
)
|
||||
+8
-2
@@ -1,4 +1,10 @@
|
||||
from .registry import get_scheduled_task_service, set_scheduled_task_service
|
||||
from .service import DuplicateTaskNameError, ScheduledTaskService
|
||||
from .service import DuplicateTaskNameError, MarkdownRequirementError, ScheduledTaskService
|
||||
|
||||
__all__ = ["DuplicateTaskNameError", "ScheduledTaskService", "get_scheduled_task_service", "set_scheduled_task_service"]
|
||||
__all__ = [
|
||||
"DuplicateTaskNameError",
|
||||
"MarkdownRequirementError",
|
||||
"ScheduledTaskService",
|
||||
"get_scheduled_task_service",
|
||||
"set_scheduled_task_service",
|
||||
]
|
||||
+77
@@ -36,6 +36,27 @@ _RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
|
||||
# Retries allowed after the first attempt before the run is marked "failed".
|
||||
_MAX_RUN_RETRIES = 3
|
||||
|
||||
# Minimum length (in characters) the generated Markdown file's content must
|
||||
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
|
||||
# this threshold — it is enforced silently by the service.
|
||||
_MARKDOWN_MIN_CHARS = 100
|
||||
|
||||
# Instruction appended to the prompt when ``require_markdown`` is on. It only
|
||||
# asks the model to produce a ``.md`` file; the length requirement is enforced
|
||||
# in code (not surfaced to the model).
|
||||
_MARKDOWN_INSTRUCTION = (
|
||||
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
|
||||
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
|
||||
)
|
||||
|
||||
|
||||
class MarkdownRequirementError(Exception):
|
||||
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
|
||||
|
||||
The run is marked failed (no retry) so the user can see the output did not
|
||||
meet the requirement (a Markdown file longer than the minimum length).
|
||||
"""
|
||||
|
||||
|
||||
class DuplicateTaskNameError(Exception):
|
||||
"""A user already owns a scheduled task with the requested name.
|
||||
@@ -166,6 +187,42 @@ def _looks_like_markdown(content: str) -> bool:
|
||||
return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns)
|
||||
|
||||
|
||||
def _task_requires_markdown(task: dict[str, Any]) -> bool:
|
||||
"""Whether a task has the ``require_markdown`` flag set.
|
||||
|
||||
The flag lives inside ``execution_context`` (no dedicated column) so the
|
||||
feature works without a schema migration on existing databases.
|
||||
"""
|
||||
ctx = task.get("execution_context")
|
||||
return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
|
||||
|
||||
|
||||
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
|
||||
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
|
||||
|
||||
For tasks with ``require_markdown`` enabled the run only succeeds if at least
|
||||
one generated file is Markdown (``.md``/``.markdown``) and its content is
|
||||
longer than :data:`_MARKDOWN_MIN_CHARS` characters. The file is read from its
|
||||
archived path; binary/undecodable files never qualify.
|
||||
"""
|
||||
for item in result_files or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "")
|
||||
if not name.lower().endswith((".md", ".markdown")):
|
||||
continue
|
||||
path = item.get("path")
|
||||
if not isinstance(path, str) or not path:
|
||||
continue
|
||||
try:
|
||||
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
if len(text.strip()) > _MARKDOWN_MIN_CHARS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_result(
|
||||
task: dict[str, Any],
|
||||
*,
|
||||
@@ -451,6 +508,7 @@ class ScheduledTaskService:
|
||||
source_agent_name: str | None = None,
|
||||
execution_agent_name: str | None = None,
|
||||
execution_context: dict[str, Any] | None = None,
|
||||
require_markdown: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
tz = _normalize_timezone(timezone)
|
||||
cron_expr = _schedule_to_cron(schedule)
|
||||
@@ -461,6 +519,7 @@ class ScheduledTaskService:
|
||||
context = dict(execution_context or {})
|
||||
if execution_agent_name:
|
||||
context["agent_name"] = execution_agent_name
|
||||
context["require_markdown"] = bool(require_markdown)
|
||||
normalized_description = description.strip() if isinstance(description, str) else None
|
||||
task = await self.store.create_task(
|
||||
{
|
||||
@@ -519,6 +578,18 @@ class ScheduledTaskService:
|
||||
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
|
||||
if data.get("execution_context") is not None:
|
||||
update_data["execution_context"] = data["execution_context"]
|
||||
# ``require_markdown`` is persisted inside execution_context (no dedicated
|
||||
# column), so merge it into the context being written — preferring a
|
||||
# context supplied in the same request, else the task's existing one.
|
||||
if data.get("require_markdown") is not None:
|
||||
base_ctx = (
|
||||
update_data["execution_context"]
|
||||
if "execution_context" in update_data
|
||||
else (existing or {}).get("execution_context")
|
||||
)
|
||||
ctx = dict(base_ctx or {})
|
||||
ctx["require_markdown"] = bool(data["require_markdown"])
|
||||
update_data["execution_context"] = ctx
|
||||
# Re-enabling a task must also schedule it forward from now, otherwise a
|
||||
# paused task with a stale/empty next_run_at would never fire again.
|
||||
if update_data.get("enabled") is True and "next_run_at" not in update_data:
|
||||
@@ -831,6 +902,10 @@ class ScheduledTaskService:
|
||||
run_id=run_id,
|
||||
output_snapshot=output_snapshot,
|
||||
)
|
||||
if _task_requires_markdown(task) and not _has_valid_markdown_file(result_files):
|
||||
raise MarkdownRequirementError(
|
||||
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
|
||||
)
|
||||
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
|
||||
|
||||
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
|
||||
@@ -895,6 +970,8 @@ class ScheduledTaskService:
|
||||
f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n"
|
||||
f"{task['prompt']}"
|
||||
)
|
||||
if execution_context.get("require_markdown"):
|
||||
content += _MARKDOWN_INSTRUCTION
|
||||
body = RunCreateRequest(
|
||||
assistant_id="lead_agent",
|
||||
input={"messages": [{"type": "human", "content": content}]},
|
||||
|
||||
+48
-27
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
|
||||
"""Return whether the selected shell is a PowerShell executable."""
|
||||
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
|
||||
|
||||
@staticmethod
|
||||
def _is_cmd_shell(shell: str) -> bool:
|
||||
"""Return whether the selected shell is cmd.exe."""
|
||||
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
||||
|
||||
@staticmethod
|
||||
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
||||
"""Return the first executable shell path or command found from candidates."""
|
||||
@staticmethod
|
||||
def _is_cmd_shell(shell: str) -> bool:
|
||||
"""Return whether the selected shell is cmd.exe."""
|
||||
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_powershell_command(command: str) -> str:
|
||||
"""Make bash-style command glue usable in Windows PowerShell 5.
|
||||
|
||||
The public tool is still named ``bash`` for model compatibility, but on
|
||||
Windows local sandbox it may fall back to PowerShell. PowerShell 5 does
|
||||
not support ``&&``, while the sandbox layer prepends commands with
|
||||
``cd <workspace> &&``. Rewriting the separator keeps skill scripts
|
||||
executable in that fallback shell.
|
||||
"""
|
||||
return command.replace("&&", ";")
|
||||
|
||||
@staticmethod
|
||||
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
|
||||
"""Return the first executable shell path or command found from candidates."""
|
||||
for shell in candidates:
|
||||
if os.path.isabs(shell):
|
||||
if os.path.isfile(shell) and os.access(shell, os.X_OK):
|
||||
@@ -302,30 +314,39 @@ class LocalSandbox(Sandbox):
|
||||
resolved_command = self._resolve_paths_in_command(command)
|
||||
shell = self._get_shell()
|
||||
|
||||
if os.name == "nt":
|
||||
if self._is_powershell(shell):
|
||||
args = [shell, "-NoProfile", "-Command", resolved_command]
|
||||
if os.name == "nt":
|
||||
if self._is_powershell(shell):
|
||||
args = [
|
||||
shell,
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
self._normalize_powershell_command(resolved_command),
|
||||
]
|
||||
elif self._is_cmd_shell(shell):
|
||||
args = [shell, "/c", resolved_command]
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=600,
|
||||
)
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
result = subprocess.run(
|
||||
args,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=600,
|
||||
)
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
|
||||
|
||||
@@ -149,3 +149,58 @@ async def cascade_skill_unpublished_or_deleted(
|
||||
counters["notified"] += 1
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
def sync_agent_skills_to_available(available_skill_names: set[str]) -> dict[str, Any]:
|
||||
"""Strip skill references that no longer exist on disk from every agent.
|
||||
|
||||
Walks every agent dir and rewrites its ``config.yaml`` ``skills`` list to
|
||||
contain only names that appear in ``available_skill_names``. Used by the
|
||||
admin "agent-skills sync" endpoint to repair historical drift — skills
|
||||
deleted before the cascade existed, deleted via a path that skipped the
|
||||
cascade, or removed from the filesystem manually.
|
||||
|
||||
Synchronous because it only touches local files and the admin endpoint
|
||||
runs it in a worker thread.
|
||||
|
||||
Returns ``{checked, modified, removals: [{agent_id, removed_skills}]}``.
|
||||
"""
|
||||
counters: dict[str, Any] = {"checked": 0, "modified": 0, "removals": []}
|
||||
|
||||
for agent_dir in _list_agent_dirs():
|
||||
config_file = agent_dir / "config.yaml"
|
||||
if not config_file.exists():
|
||||
continue
|
||||
counters["checked"] += 1
|
||||
|
||||
try:
|
||||
with config_file.open("r", encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError:
|
||||
logger.warning("Sync: failed to parse %s, skipping", config_file)
|
||||
continue
|
||||
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or not skills:
|
||||
continue
|
||||
|
||||
kept: list[str] = []
|
||||
removed: list[str] = []
|
||||
for name in skills:
|
||||
if isinstance(name, str) and name in available_skill_names:
|
||||
kept.append(name)
|
||||
else:
|
||||
removed.append(name if isinstance(name, str) else str(name))
|
||||
|
||||
if not removed:
|
||||
continue
|
||||
|
||||
data["skills"] = kept
|
||||
with config_file.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
counters["modified"] += 1
|
||||
counters["removals"].append({"agent_id": agent_dir.name, "removed_skills": removed})
|
||||
logger.info("Sync: removed orphan skills %s from agent '%s'", removed, agent_dir.name)
|
||||
|
||||
return counters
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""笔记本 RAG 检索工具。
|
||||
|
||||
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
|
||||
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
|
||||
|
||||
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from langchain.tools import tool
|
||||
from langgraph.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
|
||||
|
||||
|
||||
def _notebook_api_base() -> str:
|
||||
return (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
|
||||
|
||||
|
||||
@tool("search_notebook_sources", parse_docstring=True)
|
||||
def notebook_search_tool(query: str, top_k: int = 10) -> str:
|
||||
"""Search the notebook's knowledge sources to find relevant content.
|
||||
|
||||
Call this tool for EVERY user question. After retrieving results, cite
|
||||
sources inline using [[cite:<document_id>]] markers immediately after
|
||||
each claim, e.g. "违约金为0.5%[[cite:101]]".
|
||||
|
||||
Args:
|
||||
query: The user's question or a refined search query.
|
||||
top_k: Number of top chunks to retrieve (default 10).
|
||||
"""
|
||||
base = _notebook_api_base()
|
||||
if not base:
|
||||
return "Notebook API base URL not configured (DEER_FLOW_NOTEBOOK_API_BASE)."
|
||||
|
||||
cfg = get_config().get("configurable", {})
|
||||
search_space_id: int | None = cfg.get("notebook_search_space_id")
|
||||
source_ids: list[int] = cfg.get("notebook_source_ids") or []
|
||||
token: str = cfg.get("notebook_token") or ""
|
||||
|
||||
if not search_space_id:
|
||||
return "No notebook search space configured."
|
||||
|
||||
logger.info("notebook_search_tool query=%r source_ids=%s search_space_id=%s", query, source_ids, search_space_id)
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{base}{_SEARCH_PATH}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"query": query,
|
||||
"source_ids": [source_ids],
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": top_k,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data.get("content") if isinstance(data, dict) else None
|
||||
return content if content else "No relevant content found."
|
||||
except Exception as e:
|
||||
logger.warning("notebook_search_tool failed: %s", e)
|
||||
return f"Search failed: {e}"
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for RAG citation batch construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from deerflow.runtime.references import (
|
||||
build_latest_reference_batch,
|
||||
build_latest_reference_prompt,
|
||||
build_reference_batches,
|
||||
)
|
||||
|
||||
|
||||
def _tool_message(call_id: str, query: str, start: int) -> dict:
|
||||
return {
|
||||
"type": "tool",
|
||||
"name": "web_search",
|
||||
"tool_call_id": call_id,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"query": query,
|
||||
"results": [
|
||||
{
|
||||
"title": f"{query} result {start}",
|
||||
"content": f"{query} content {start}",
|
||||
"url": f"https://example.test/{start}",
|
||||
},
|
||||
{
|
||||
"title": f"{query} result {start + 1}",
|
||||
"content": f"{query} content {start + 1}",
|
||||
"url": f"https://example.test/{start + 1}",
|
||||
},
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_latest_reference_prompt_uses_one_combined_batch_for_multi_search_turn() -> None:
|
||||
messages = [
|
||||
{"type": "human", "id": "human-1", "content": "search two terms"},
|
||||
{
|
||||
"type": "ai",
|
||||
"id": "ai-search-1",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call-1", "name": "web_search", "args": {"query": "Trump"}}],
|
||||
},
|
||||
_tool_message("call-1", "Trump", 1),
|
||||
{
|
||||
"type": "ai",
|
||||
"id": "ai-search-2",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call-2", "name": "web_search", "args": {"query": "White House"}}],
|
||||
},
|
||||
_tool_message("call-2", "White House", 3),
|
||||
]
|
||||
|
||||
batch = build_latest_reference_batch(messages)
|
||||
assert batch is not None
|
||||
assert batch["source_count"] == 4
|
||||
assert [source["index"] for source in batch["sources"]] == [1, 2, 3, 4]
|
||||
assert [source["title"] for source in batch["sources"]] == [
|
||||
"Trump result 1",
|
||||
"Trump result 2",
|
||||
"White House result 3",
|
||||
"White House result 4",
|
||||
]
|
||||
|
||||
prompt, prompt_batch = build_latest_reference_prompt(messages)
|
||||
assert prompt_batch == batch
|
||||
assert '<reference_batch source_count="4">' in prompt
|
||||
assert "[1] Trump result 1" in prompt
|
||||
assert "[4] White House result 4" in prompt
|
||||
assert "重新分批" in prompt
|
||||
|
||||
|
||||
def test_reference_batches_keep_separate_user_turns_but_merge_searches_inside_turn() -> None:
|
||||
messages = [
|
||||
{"type": "human", "id": "human-1", "content": "first"},
|
||||
{"type": "ai", "id": "ai-1", "content": "", "tool_calls": [{"id": "c1", "name": "web_search"}]},
|
||||
_tool_message("c1", "first", 1),
|
||||
{"type": "ai", "id": "answer-1", "content": "answer [1]"},
|
||||
{"type": "human", "id": "human-2", "content": "second"},
|
||||
{"type": "ai", "id": "ai-2", "content": "", "tool_calls": [{"id": "c2", "name": "web_search"}]},
|
||||
_tool_message("c2", "second-a", 3),
|
||||
{"type": "ai", "id": "ai-3", "content": "", "tool_calls": [{"id": "c3", "name": "web_search"}]},
|
||||
_tool_message("c3", "second-b", 5),
|
||||
{"type": "ai", "id": "answer-2", "content": "answer [1][4]"},
|
||||
]
|
||||
|
||||
batches = build_reference_batches(messages)
|
||||
assert [len(batch["sources"]) for batch in batches] == [2, 4]
|
||||
assert [source["index"] for source in batches[1]["sources"]] == [1, 2, 3, 4]
|
||||
assert batches[0]["assistant_message_id"] == "answer-1"
|
||||
assert batches[1]["assistant_message_id"] == "answer-2"
|
||||
Reference in new issue
Block a user