Merge branch 'master-nj' into 'master'
增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。 See merge request knowledge-base-bj/deerflow-code!25
This commit is contained in:
commit
33b0af333c
81 files changed
+8436
-1076
No files matched your search
+609
@@ -0,0 +1,609 @@
|
||||
# Offline backend template for DeerFlow
|
||||
config_version: 8
|
||||
log_level: info
|
||||
|
||||
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
|
||||
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
|
||||
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
|
||||
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
|
||||
gateway:
|
||||
port: 8001
|
||||
|
||||
# Optional Redis-backed global concurrency coordination.
|
||||
# Leave disabled to keep the existing in-process behavior.
|
||||
# When enabled, Redis operations are short-timeout and fail-open by default:
|
||||
# if Redis is slow or unavailable, user Q&A continues with local-only limits.
|
||||
concurrency:
|
||||
redis:
|
||||
enabled: false
|
||||
url: redis://127.0.0.1:6379/0
|
||||
password: null
|
||||
# Prefix with the deployment/system id because Redis may be shared by
|
||||
# multiple projects. Keep this unique per system to avoid cross-project
|
||||
# leases affecting each other.
|
||||
namespace: cmzs_deerflow:concurrency
|
||||
socket_connect_timeout_ms: 80
|
||||
socket_timeout_ms: 80
|
||||
acquire_timeout_ms: 120
|
||||
operation_timeout_ms: 80
|
||||
circuit_breaker_failures: 3
|
||||
circuit_breaker_cooldown_seconds: 30
|
||||
lease_ttl_seconds: 90
|
||||
heartbeat_interval_seconds: 20
|
||||
fail_open: true
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: $DEEPSEEK_CHAT_API_KEY
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: $GLM5_FP8_API_KEY
|
||||
base_url: https://api.us-west-2.modal.direct/v2
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
supports_reasoning_effort: false
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
when_thinking_disabled:
|
||||
extra_body:
|
||||
enable_thinking: false
|
||||
- name: inclusionAI/Ling-flash-2.0
|
||||
display_name: inclusionAI/Ling-flash-2.0
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: inclusionAI/Ling-flash-2.0
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Pro
|
||||
display_name: deepseek-ai/DeepSeek-V4-Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Pro
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: qwen3.6-flash
|
||||
display_name: qwen3.6-flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: qwen3.6-flash
|
||||
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg
|
||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
|
||||
request_timeout: 30.0
|
||||
max_retries: 2
|
||||
stream_usage: true
|
||||
supports_thinking: true
|
||||
supports_reasoning_effort: false
|
||||
supports_vision: false
|
||||
- name: qwen3.6
|
||||
display_name: qwen3.6
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: qwen3.6
|
||||
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
||||
base_url: http://119.254.86.251:8001/v1
|
||||
request_timeout: 30.0
|
||||
max_retries: 2
|
||||
stream_usage: true
|
||||
supports_thinking: false
|
||||
supports_reasoning_effort: false
|
||||
supports_vision: false
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-flash
|
||||
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
# - name: deepseek-v4-pro
|
||||
# display_name: DeepSeek V4 Pro
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-pro
|
||||
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
# - name: MiniMax-M2.7
|
||||
# display_name: MiniMax-M2.7
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: MiniMax-M2.7
|
||||
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
# base_url: https://api.minimaxi.com/v1
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: glm-5.1
|
||||
# display_name: glm-5.1
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: glm-5.1
|
||||
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
|
||||
# base_url: https://open.bigmodel.cn/api/paas/v4/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: deepseek-ai/DeepSeek-OCR
|
||||
# display_name: deepseek-ai/DeepSeek-OCR
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-ai/DeepSeek-OCR
|
||||
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
|
||||
# base_url: https://api.siliconflow.cn/v1/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: zai-org/GLM-5-FP8
|
||||
# display_name: zai-org/GLM-5-FP8
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: zai-org/GLM-5-FP8
|
||||
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
# base_url: https://api.us-west-2.modal.direct/v1
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: Qwen/Qwen3.6-27B
|
||||
# display_name: Qwen/Qwen3.6-27B
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: Qwen/Qwen3.6-27B
|
||||
# api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||
# base_url: https://api.siliconflow.cn/v1/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
|
||||
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
|
||||
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
|
||||
# 识图(图片识别)配置。
|
||||
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
|
||||
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
|
||||
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
|
||||
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
|
||||
# - prompt:不带具体问题时使用的默认识图指令(可选)。
|
||||
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
|
||||
vision:
|
||||
model_name: deepseek-ai/DeepSeek-OCR
|
||||
# prompt: 请详细描述这张图片的内容。
|
||||
# max_tokens: 2048
|
||||
|
||||
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
|
||||
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
|
||||
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
|
||||
deep_research:
|
||||
image_generation:
|
||||
enabled: true
|
||||
provider: openai_compatible
|
||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
|
||||
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
|
||||
model: wan2.7-image # 例如 gpt-image-1
|
||||
size: 1024x1024
|
||||
# quality: standard
|
||||
timeout_seconds: 120
|
||||
|
||||
# browser_act 相关配置
|
||||
browser_context:
|
||||
enabled: true
|
||||
actions_enabled: true
|
||||
|
||||
# 上传文件识别配置。
|
||||
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
|
||||
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
|
||||
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
|
||||
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
|
||||
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
|
||||
uploads:
|
||||
auto_convert_documents: true
|
||||
pdf_converter: auto
|
||||
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
max_chars: 60
|
||||
prompt_template: |
|
||||
请为下面这段对话生成一个简洁的标题,概括对话的核心主题。
|
||||
|
||||
要求:
|
||||
- 标题必须使用简体中文
|
||||
- 尽量精炼,不超过 {max_words} 个词
|
||||
- 只返回标题本身,不要加引号、不要解释、结尾不要标点
|
||||
|
||||
User: {user_msg}
|
||||
Assistant: {assistant_msg}
|
||||
|
||||
# 上下文压缩(对话摘要):长对话接近上下文上限时,把较早的消息总结成一段摘要,
|
||||
# 只保留最近若干条原始消息,避免撑爆模型上下文窗口。
|
||||
# - trigger:触发条件,可配多个,任意一个先满足就压缩(OR 关系)。真正决定何时
|
||||
# 压缩的是 trigger(不是 keep)。type=tokens 按 token 总数;type=messages 按条数。
|
||||
# - keep:压缩后保留多少最近的上下文(tokens / messages)。
|
||||
# - trim_tokens_to_summarize:喂给摘要模型的消息最多裁到多少 token。
|
||||
# 当前为「测试值(易触发)」:发到服务器后稍微聊几句就会触发,可在 Gateway 日志里
|
||||
# grep「上下文压缩」/「context compaction」确认生效;确认后再调回生产值
|
||||
# (trigger tokens 60000 / messages 80,keep tokens 16000)。
|
||||
summarization:
|
||||
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
|
||||
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep
|
||||
# 等仅是调参,Web 端是否压缩由前端按钮(summarization_enabled)决定。
|
||||
enabled: false
|
||||
# model_name: deepseek-chat # 留空则用主对话模型
|
||||
trigger:
|
||||
- type: tokens
|
||||
value: 120000
|
||||
- type: messages
|
||||
value: 160
|
||||
keep:
|
||||
type: tokens
|
||||
value: 40000
|
||||
trim_tokens_to_summarize: 8000
|
||||
|
||||
tool_groups:
|
||||
- name: web
|
||||
- name: file:read
|
||||
- name: file:write
|
||||
- name: bash
|
||||
|
||||
tools:
|
||||
- name: web_search
|
||||
group: web
|
||||
# DuckDuckGo search — no API key required. To switch to the deployment's
|
||||
# internal search service later, restore the configurable_search block
|
||||
# preserved in the comments below (fill in the real values) and point
|
||||
# `use` back to deerflow.community.configurable_search.tools:web_search_tool.
|
||||
use: deerflow.community.ddg_search.tools:web_search_tool
|
||||
enabled: true
|
||||
max_results: 5
|
||||
# ── configurable_search alternative (deployment-internal service) ──
|
||||
# endpoint: "https://xxx.xxx.xxx/searchName"
|
||||
# verify_ssl: false
|
||||
# timeout: 30
|
||||
# result_url_template: "https://xxx.xxx.xxx/mindId/xxx?recUuid={recUuid}"
|
||||
# The runtime query always overwrites payload.query; all other fields are
|
||||
# sent unchanged so deployment-specific request parameters stay in config.
|
||||
# payload:
|
||||
# query: ""
|
||||
# xxx: "xxxx"
|
||||
|
||||
- name: web_fetch
|
||||
group: web
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
timeout: 15
|
||||
|
||||
- name: ls
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:ls_tool
|
||||
|
||||
- name: read_file
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:read_file_tool
|
||||
|
||||
- name: glob
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:glob_tool
|
||||
max_results: 200
|
||||
|
||||
- name: grep
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:grep_tool
|
||||
max_results: 100
|
||||
|
||||
- name: write_file
|
||||
group: file:write
|
||||
use: deerflow.sandbox.tools:write_file_tool
|
||||
|
||||
- name: str_replace
|
||||
group: file:write
|
||||
use: deerflow.sandbox.tools:str_replace_tool
|
||||
|
||||
- name: bash
|
||||
group: bash
|
||||
use: deerflow.sandbox.tools:bash_tool
|
||||
|
||||
# browser_act 相关配置
|
||||
- name: browser_fetch_page
|
||||
group: web
|
||||
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
|
||||
|
||||
# browser_act 相关配置
|
||||
- name: browser_act
|
||||
group: web
|
||||
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
|
||||
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
allow_host_bash: true
|
||||
mounts:
|
||||
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
|
||||
container_path: /mnt/knowledge
|
||||
read_only: true
|
||||
|
||||
skills:
|
||||
path: skills
|
||||
security_scan:
|
||||
enabled: false
|
||||
|
||||
skill_evolution:
|
||||
enabled: true
|
||||
curator:
|
||||
enabled: true # 每 24 小时自动清理 + 合并 skill
|
||||
interval_hours: 24
|
||||
review_worker:
|
||||
enabled: true # 被动复盘开关
|
||||
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
|
||||
max_messages: 60 # 传给 LLM 的最大消息条数
|
||||
timeout_seconds: 120 # 单次 review 超时(秒)
|
||||
model_name: # 留空则使用主模型
|
||||
|
||||
agents_api:
|
||||
enabled: true
|
||||
|
||||
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
|
||||
auth_login:
|
||||
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
|
||||
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
|
||||
username_login:
|
||||
require_password: false
|
||||
password: ''
|
||||
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
|
||||
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
|
||||
# 用户名不在库里时自动注册。
|
||||
token_login:
|
||||
enabled: true
|
||||
token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo'
|
||||
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
|
||||
service_authorization: 'Bearer admin'
|
||||
timeout_seconds: 10
|
||||
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
|
||||
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
|
||||
verify_ssl: false
|
||||
ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
|
||||
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
|
||||
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
|
||||
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录。
|
||||
mock_tokens:
|
||||
'123ewq': 'admin'
|
||||
|
||||
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
|
||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||
|
||||
database:
|
||||
backend: sqlite
|
||||
sqlite_dir: .deer-flow/data
|
||||
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
|
||||
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
|
||||
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
|
||||
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
|
||||
echo_sql: false
|
||||
|
||||
checkpointer:
|
||||
type: sqlite
|
||||
connection_string: .deer-flow/data/deerflow.db
|
||||
|
||||
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||
# 三层覆盖优先级(高->低):
|
||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||
memory:
|
||||
enabled: true
|
||||
version:
|
||||
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
|
||||
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
|
||||
provider:
|
||||
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||
# builtin 始终启用,这里只决定 external 选哪个
|
||||
# (version=v1 时此字段忽略)
|
||||
|
||||
# V1 专属配置 —— version=v1 时生效
|
||||
v1:
|
||||
max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实
|
||||
fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存
|
||||
debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取
|
||||
model_name: '' # 用于事实提取的 LLM 模型名;空则继承全局默认模型
|
||||
token_budget: 2000 # 注入系统提示词的 token 预算
|
||||
builtin:
|
||||
enabled: true
|
||||
memory_char_limit: 2200
|
||||
user_char_limit: 1375
|
||||
deduplicate_on_load: true
|
||||
|
||||
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
|
||||
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
|
||||
api_url: http://47.88.25.99:18888
|
||||
api_key:
|
||||
'' # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
|
||||
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
|
||||
bank_id_template: 'deerflow-user-{user_id}'
|
||||
work_bank_id_template: 'deerflow-work-{user_id}-{agent_id}'
|
||||
memory_mode: context # 'context' | 'tools' | 'hybrid'
|
||||
bank_mission: '你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。'
|
||||
bank_retain_mission: '请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。'
|
||||
recall_budget: mid # 'low' | 'mid' | 'high'
|
||||
recall_max_tokens: 4096
|
||||
recall_max_input_chars: 800
|
||||
auto_recall: true
|
||||
auto_retain: true
|
||||
retain_async: true
|
||||
retain_every_n_turns: 1
|
||||
retain_user_prefix: 'User'
|
||||
retain_assistant_prefix: 'Assistant'
|
||||
|
||||
# background_extraction:预留功能,代码尚未实现 —— 仅设计文档存在。
|
||||
# 待对应版本支持后,取消下面整段注释即可启用(漏记兜底)。
|
||||
# background_extraction:
|
||||
# enabled: false # 默认关闭,作为漏记兜底
|
||||
# debounce_seconds: 30
|
||||
# model_name: ~ # null = 用默认模型
|
||||
# fact_confidence_threshold: 0.7
|
||||
# max_facts_per_extraction: 10
|
||||
# correction_detection: true
|
||||
# reinforcement_detection: true
|
||||
|
||||
injection:
|
||||
enabled: true
|
||||
max_tokens: 2000
|
||||
include_builtin: true
|
||||
include_hindsight: true
|
||||
context_tag: 'memory-context' # 包裹 Hindsight recall 的标签名
|
||||
|
||||
security:
|
||||
scan_content: true # 注入/外渗模式扫描
|
||||
block_invisible_unicode: true
|
||||
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
|
||||
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
|
||||
|
||||
# AI 写作子系统 —— 素材收集专家专用配置。
|
||||
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
|
||||
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
|
||||
ai_writing:
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||
# 用「真实智能体运行时」跑配置技能(read SKILL.md + 全套工具 + 沙箱),让技能的真实
|
||||
# 检索逻辑(调内网接口/MCP/读文件)真正执行,而不是塞进通用 web/ES 检索 provider。
|
||||
# 内网技能必须开 true,否则技能会走通用 provider、连不上内网而报 ConnectError。
|
||||
# 仅对非「知识库检索(knowledge-base-search)」的配置技能生效;失败回退直连检索。
|
||||
researcher_skill_agent: true
|
||||
# 最终兜底:当所有技能都没检索到素材时,直连内网知识库 ES 接口兜底检索(避免素材全空)。
|
||||
# 配上你内网 ES 接口地址即启用;留空则不启用该兜底。
|
||||
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
|
||||
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: "samples" # 兜底检索用的知识库名
|
||||
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
|
||||
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
|
||||
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
|
||||
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
|
||||
intranet_search_debug: false
|
||||
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
|
||||
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
|
||||
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
|
||||
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
|
||||
use_builtin_agents:
|
||||
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
|
||||
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
|
||||
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
|
||||
editor: false # 编辑(事实/逻辑/语言三维度审查)
|
||||
|
||||
# Optional independently deployed WeKnora integration. DeerFlow owns only
|
||||
# these connection values; database/Redis/storage/parser/model settings stay
|
||||
# in WeKnora's own docker-compose/.env/config files. A non-empty api_base_url
|
||||
# enables WeKnora mode; leaving it empty preserves the existing LLMWiki iframe.
|
||||
# admin_email/admin_password are used only by DeerFlow's server-side WeKnora
|
||||
# detail-page iframe proxy. Prefer environment variables in production, e.g.
|
||||
# admin_email: "$WEKNORA_ADMIN_EMAIL"
|
||||
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
|
||||
llmwiki:
|
||||
weknora:
|
||||
api_base_url: "http://119.254.86.251:8088/deerflow-api"
|
||||
web_base_url: "http://119.254.86.251:8088"
|
||||
admin_email: "lqq@qq.com"
|
||||
admin_password: "1qaz@WSX"
|
||||
|
||||
log_system:
|
||||
enabled: true
|
||||
url: 'https://your-log-system.example.com'
|
||||
label: '日志系统'
|
||||
|
||||
task_deeplink:
|
||||
# 线上接口未通时用假数据;三个开关互相独立。
|
||||
test: true # 目的树查询走 mock.purpose_detail 假数据
|
||||
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
|
||||
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
|
||||
# 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。
|
||||
cop_task_detail_test: true
|
||||
cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail"
|
||||
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
|
||||
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
|
||||
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
|
||||
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
|
||||
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
|
||||
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
|
||||
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
|
||||
# xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。
|
||||
action_history_url: ""
|
||||
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
|
||||
detail_3q_url: ""
|
||||
mock:
|
||||
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
|
||||
purpose_detail:
|
||||
state: "200"
|
||||
msg: "操作成功!"
|
||||
data:
|
||||
- purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
|
||||
childrenList:
|
||||
- actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
|
||||
childrenList:
|
||||
- drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
|
||||
childrenList: []
|
||||
- actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
|
||||
childrenList:
|
||||
- drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
|
||||
childrenList:
|
||||
- narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
|
||||
childrenList: []
|
||||
# delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
|
||||
delete:
|
||||
message: "行动已经开始策划,不可覆盖"
|
||||
success: true
|
||||
# action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。
|
||||
# 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。
|
||||
action_detail:
|
||||
state: "200"
|
||||
msg: "操作成功!"
|
||||
data:
|
||||
id: 27354
|
||||
purposeId: 2850
|
||||
taskId: 2055
|
||||
actionName: ""
|
||||
actionStatus: 25
|
||||
narration: ""
|
||||
# cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。
|
||||
cop_task_detail:
|
||||
data:
|
||||
id: 2055
|
||||
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务"
|
||||
taskDirection: "正向引导 + 风险预警"
|
||||
taskName: "任务01:暑期文旅舆情综合研判"
|
||||
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
|
||||
@@ -1147,6 +1147,10 @@ Automatic conversation summarization when approaching token limits:
|
||||
the config flag. `config.summarization.enabled` now defaults to **false**.
|
||||
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
|
||||
(tokens, messages, or fraction of max input) and the keep policy.
|
||||
- A compressed request must retain the latest non-empty `HumanMessage`, even
|
||||
when it has already been folded into the hidden summary. This keeps strict
|
||||
OpenAI-compatible gateways from rejecting a tool-continuation call with
|
||||
`No user query found in message`.
|
||||
- Keeps recent messages while summarizing older ones.
|
||||
|
||||
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
|
||||
@@ -1265,8 +1269,13 @@ the terminal `lastJobId` in `usage_snapshot` so a refreshed conversation page
|
||||
can replay its trace from the event log. Detailed mode uses stable targets (`introduction`,
|
||||
`section:N`, `conclusion`) because section writers are concurrent;
|
||||
multi-agent revisions must emit `report_reset` before their next report
|
||||
version. Do not emit raw model reasoning or one persisted event per provider
|
||||
token. The Vite page owns Word export through
|
||||
version. Do not persist raw model reasoning or one persisted event per provider
|
||||
token. Models that expose thinking inside ordinary `<think>...</think>` text
|
||||
must be split statefully before a report delta, checkpoint, or Markdown
|
||||
projection is created; an optional live-only `report_thinking` frame can drive
|
||||
the message-list thought trace, while the sandbox receives answer Markdown
|
||||
only. The Vite
|
||||
page owns Word export through
|
||||
`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`; do not add Python
|
||||
document-conversion dependencies merely to export text.
|
||||
All non-streaming Deep Research control-plane calls (`complete()` for query /
|
||||
@@ -1284,6 +1293,31 @@ The per-user Deep Research concurrency guard counts only session statuses
|
||||
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
|
||||
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
|
||||
|
||||
`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL
|
||||
read/write splitting. Deep Research session/job/event/source/message writes
|
||||
must serialise their changed row while the write transaction is still open
|
||||
(`flush` for inserts, select-after-update **before** commit), never through a
|
||||
post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise
|
||||
turns a committed job into a misleading 500, can make dispatcher `claim_job()`
|
||||
lose its snapshot, or can fail report/follow-up persistence after the model has
|
||||
already completed. Always nudge the dispatcher for both a newly created job and
|
||||
an idempotently reused queued job; otherwise a retry after such an interrupted
|
||||
response returns the old job but leaves it waiting for the periodic scan. The
|
||||
SSE replay generator must catch temporary event/job-store reads, emit a
|
||||
recoverable warning, and keep the connection open so the next poll can repair
|
||||
the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions:
|
||||
`test_deep_research_writes_never_depend_on_post_commit_refresh`,
|
||||
`test_job_create_never_reads_back_after_commit`,
|
||||
`test_retry_of_existing_queued_job_nudges_dispatcher`, and
|
||||
`test_stream_stays_open_when_durable_replay_has_a_transient_failure`.
|
||||
|
||||
At ordinary report completion, persist and confirm the session's
|
||||
`report_markdown` projection **before** marking the Job `completed`. Retry a
|
||||
short transient projection failure; if it cannot be confirmed, let the job
|
||||
become failed rather than reporting a report that vanishes on refresh.
|
||||
`test_report_is_persisted_before_job_is_marked_completed` and
|
||||
`test_report_projection_failure_never_marks_job_completed` cover the ordering.
|
||||
|
||||
The chat-collection frontend must keep report-writing progress in the existing
|
||||
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
|
||||
virtual `deep_research_progress` tool calls for the durable phases, keep the
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
install:
|
||||
uv sync
|
||||
|
||||
dev:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
|
||||
dev:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-exclude ".deer-flow/*" --reload-exclude ".runtime/*"
|
||||
|
||||
gateway:
|
||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
||||
|
||||
@@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
||||
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
||||
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
|
||||
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
|
||||
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
|
||||
@@ -180,6 +180,11 @@ the right-side `report.md` sandbox can write smoothly, while bounded,
|
||||
persisted `report_chunk` events remain reconnect checkpoints. The durable log
|
||||
is still authoritative: a browser reconnect, slow consumer, or another worker
|
||||
falls back to `?after=<seq>` replay rather than starting a second model call.
|
||||
Reasoning exposed by providers either through structured reasoning fields or
|
||||
inline `<think>...</think>` text is separated from report deltas, checkpoints,
|
||||
and the persisted Markdown projection. It may be forwarded as an ephemeral
|
||||
live `report_thinking` frame for the message-list thought trace, but is never
|
||||
persisted or written to the sandbox; only answer Markdown reaches the sandbox.
|
||||
The short non-streaming control-plane calls used to plan queries, sections, and
|
||||
reviews are independently capped at 45 seconds (maximum 120 seconds). If query
|
||||
or section planning times out, the runner emits a visible recoverable warning
|
||||
@@ -231,12 +236,16 @@ only applies to sessions with a `running` or `awaiting_input` durable job.
|
||||
DeerFlow's stock configuration includes image recognition but no image-generation
|
||||
provider. Deep Research therefore keeps illustrations disabled until an operator
|
||||
fills `config.yaml -> deep_research.image_generation` with an
|
||||
OpenAI-compatible image endpoint, API key, and model. When configured, the
|
||||
capabilities endpoint enables the page's “生成报告配图” switch. A selected job
|
||||
requests at most four base64 image results, writes them as protected
|
||||
`research-image-*.png|jpg|webp` artifacts, injects `deep-research://` links into
|
||||
the Markdown report, and emits image SSE events. A failed/unconfigured image
|
||||
provider only emits a recoverable warning; the text report still completes.
|
||||
image endpoint, API key, and model. `provider: openai_compatible` uses the
|
||||
standard `/images/generations` protocol and requires `b64_json`; `provider:
|
||||
dashscope_native` supports Qwen Image 3.0 and Wan 2.7 on DashScope / Token Plan
|
||||
with the native multimodal-generation protocol. Native provider image URLs are
|
||||
downloaded immediately, so both providers produce protected local artifacts.
|
||||
When configured, the capabilities endpoint enables the page's “生成报告配图” switch.
|
||||
A selected job writes at most four `research-image-*.png|jpg|webp` artifacts,
|
||||
injects `deep-research://` links into the Markdown report, and emits image SSE
|
||||
events. A failed/unconfigured image provider only emits a recoverable warning;
|
||||
the text report still completes.
|
||||
|
||||
Completed reports also expose `GET /api/deep-research/sessions/{id}/messages`
|
||||
and `POST /api/deep-research/sessions/{id}/chat`. Follow-up answers are grounded
|
||||
|
||||
@@ -44,6 +44,7 @@ from deerflow.agents.deep_research.types import (
|
||||
DeepResearchResult,
|
||||
)
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
@@ -61,6 +62,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
|
||||
_LEASE_TTL_SECONDS = 60
|
||||
_HEARTBEAT_INTERVAL = 20.0
|
||||
_CANCEL_POLL_INTERVAL = 5.0
|
||||
_REPORT_PROJECTION_RETRY_ATTEMPTS = 3
|
||||
_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5
|
||||
|
||||
# Phase → progress% mapping for the job timeline.
|
||||
_PHASE_PROGRESS: dict[str, int] = {
|
||||
@@ -155,9 +158,7 @@ class RecordingEventSink:
|
||||
elif type == "sources_curated" and self._source_store is not None:
|
||||
for sid in (payload or {}).get("selectedIds", []):
|
||||
try:
|
||||
await self._source_store.set_selected(
|
||||
sid, selected=True, reason="curated", user_id=self._user_id
|
||||
)
|
||||
await self._source_store.set_selected(sid, selected=True, reason="curated", user_id=self._user_id)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
@@ -263,14 +264,8 @@ class DeepResearchJobExecutor:
|
||||
cancel_token = CancellationToken()
|
||||
|
||||
main_task = asyncio.current_task()
|
||||
heartbeat = (
|
||||
asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task))
|
||||
if lease_owner
|
||||
else None
|
||||
)
|
||||
watcher = asyncio.create_task(
|
||||
self._watch_cancellation(job_id, main_task, lease_owner, cancel_token)
|
||||
)
|
||||
heartbeat = asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task)) if lease_owner else None
|
||||
watcher = asyncio.create_task(self._watch_cancellation(job_id, main_task, lease_owner, cancel_token))
|
||||
|
||||
try:
|
||||
# Normal sandbox Markdown rewrites share the same durable lease,
|
||||
@@ -388,9 +383,7 @@ class DeepResearchJobExecutor:
|
||||
session_for_model.setdefault("config_snapshot", snapshot.get("config") or {})
|
||||
|
||||
if not report.strip() or not instruction:
|
||||
await self._mark_document_rewrite_failed(
|
||||
job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。"
|
||||
)
|
||||
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。")
|
||||
return
|
||||
|
||||
events = PersistingEventSink(
|
||||
@@ -419,9 +412,7 @@ class DeepResearchJobExecutor:
|
||||
phase="planning",
|
||||
payload={"type": "requirements_started"},
|
||||
)
|
||||
await self._job_store.update_progress(
|
||||
job_id, lease_owner=lease_owner, phase="planning", progress=20
|
||||
)
|
||||
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="planning", progress=20)
|
||||
requirement_parts: list[str] = []
|
||||
# Persist the first fragment too: the HTTP client opens its SSE stream
|
||||
# just after queuing the job, so this closes the tiny subscribe race
|
||||
@@ -447,6 +438,14 @@ class DeepResearchJobExecutor:
|
||||
)
|
||||
last_requirements_checkpoint = time.monotonic()
|
||||
|
||||
async def on_requirement_thinking(delta: str) -> None:
|
||||
if delta:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="planning",
|
||||
payload={"type": "thinking", "chunk": delta},
|
||||
)
|
||||
|
||||
requirements_fallback = False
|
||||
try:
|
||||
requirements_plan = await stream_full_report_rewrite_requirements(
|
||||
@@ -456,6 +455,7 @@ class DeepResearchJobExecutor:
|
||||
style=style_instruction,
|
||||
model_name=model_name,
|
||||
on_delta=on_requirement_delta,
|
||||
on_reasoning=on_requirement_thinking,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
@@ -490,9 +490,7 @@ class DeepResearchJobExecutor:
|
||||
"plan": requirements_plan,
|
||||
},
|
||||
)
|
||||
await self._job_store.update_progress(
|
||||
job_id, lease_owner=lease_owner, phase="writing", progress=50
|
||||
)
|
||||
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="writing", progress=50)
|
||||
|
||||
draft_parts: list[str] = []
|
||||
last_checkpoint = time.monotonic()
|
||||
@@ -527,6 +525,14 @@ class DeepResearchJobExecutor:
|
||||
)
|
||||
last_checkpoint = time.monotonic()
|
||||
|
||||
async def on_thinking(delta: str) -> None:
|
||||
if delta:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": delta},
|
||||
)
|
||||
|
||||
try:
|
||||
rewritten, citation_ids, _usage = await stream_full_report_rewrite(
|
||||
session=session_for_model,
|
||||
@@ -536,6 +542,7 @@ class DeepResearchJobExecutor:
|
||||
style=style,
|
||||
model_name=model_name,
|
||||
on_delta=on_delta,
|
||||
on_reasoning=on_thinking,
|
||||
requirements_plan=requirements_plan,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
@@ -558,9 +565,7 @@ class DeepResearchJobExecutor:
|
||||
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
|
||||
validation_error = validate_markdown(rewritten)
|
||||
if validation_error:
|
||||
await self._mark_document_rewrite_failed(
|
||||
job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation"
|
||||
)
|
||||
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation")
|
||||
return
|
||||
|
||||
before_headings = heading_count(report)
|
||||
@@ -580,13 +585,9 @@ class DeepResearchJobExecutor:
|
||||
validation_warnings=warnings,
|
||||
)
|
||||
summary["citationCount"] = len(citation_ids)
|
||||
await events.emit(
|
||||
"document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary}
|
||||
)
|
||||
await events.emit("document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary})
|
||||
await events.emit("document_rewrite", phase="exporting", payload={"type": "commit_started"})
|
||||
await self._job_store.update_progress(
|
||||
job_id, lease_owner=lease_owner, phase="exporting", progress=90
|
||||
)
|
||||
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="exporting", progress=90)
|
||||
|
||||
committed_hash = __import__("hashlib").sha256(rewritten.encode("utf-8")).hexdigest()
|
||||
updated = await self._session_store.replace_report_if_unchanged(
|
||||
@@ -641,9 +642,7 @@ class DeepResearchJobExecutor:
|
||||
expected_report=rewritten,
|
||||
report_markdown=report,
|
||||
)
|
||||
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else (
|
||||
"保存改写版本失败,且报告随后被修改,未再覆盖。"
|
||||
)
|
||||
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else ("保存改写版本失败,且报告随后被修改,未再覆盖。")
|
||||
await self._mark_document_rewrite_failed(
|
||||
job_id,
|
||||
session_id,
|
||||
@@ -747,6 +746,11 @@ class DeepResearchJobExecutor:
|
||||
instruction = str(snapshot.get("instruction") or "").strip()
|
||||
style = str(snapshot.get("style") or "professional").strip()
|
||||
model_name = str(snapshot.get("model_name") or "").strip() or None
|
||||
generate_images = bool(snapshot.get("generate_images"))
|
||||
try:
|
||||
max_generated_images = min(max(int(snapshot.get("max_generated_images") or 2), 1), 4)
|
||||
except (TypeError, ValueError):
|
||||
max_generated_images = 2
|
||||
|
||||
async def fail(code: str, message: str, *, stage: str = "generation") -> None:
|
||||
await events.emit(
|
||||
@@ -806,13 +810,20 @@ class DeepResearchJobExecutor:
|
||||
)
|
||||
return
|
||||
|
||||
rewrite_source = original
|
||||
if generate_images:
|
||||
# Do not ask the writer to preserve a previous run's managed image
|
||||
# blocks. A new illustration pass will create a fresh, local set;
|
||||
# user-authored image links remain untouched.
|
||||
from app.gateway.document_rewrite_illustrations import strip_managed_document_illustrations
|
||||
|
||||
rewrite_source = strip_managed_document_illustrations(original)
|
||||
|
||||
style_instruction = DOCUMENT_STYLE_INSTRUCTIONS.get(
|
||||
style,
|
||||
DOCUMENT_STYLE_INSTRUCTIONS["professional"],
|
||||
)
|
||||
resolved_name = model_name or (
|
||||
self._app_config.models[0].name if self._app_config.models else None
|
||||
)
|
||||
resolved_name = model_name or (self._app_config.models[0].name if self._app_config.models else None)
|
||||
model_cfg = self._app_config.get_model_config(resolved_name) if resolved_name else None
|
||||
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
|
||||
try:
|
||||
@@ -847,12 +858,21 @@ class DeepResearchJobExecutor:
|
||||
last_requirements_checkpoint = 0.0
|
||||
requirements_fallback = False
|
||||
try:
|
||||
async def on_requirement_thinking(delta: str) -> None:
|
||||
if delta:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="planning",
|
||||
payload={"type": "thinking", "chunk": delta},
|
||||
)
|
||||
|
||||
async for delta in astream_rewrite_requirements(
|
||||
model,
|
||||
file_name=actual_path.name,
|
||||
instruction=instruction,
|
||||
style_instruction=style_instruction,
|
||||
original=original,
|
||||
original=rewrite_source,
|
||||
on_thinking=on_requirement_thinking,
|
||||
):
|
||||
requirement_parts.append(delta)
|
||||
requirement_draft = "".join(requirement_parts)
|
||||
@@ -907,7 +927,7 @@ class DeepResearchJobExecutor:
|
||||
f"{requirements_plan}\n"
|
||||
"【已确认的改写执行计划结束】\n\n"
|
||||
"【原始 Markdown 开始】\n"
|
||||
f"{original}\n"
|
||||
f"{rewrite_source}\n"
|
||||
"【原始 Markdown 结束】"
|
||||
)
|
||||
),
|
||||
@@ -932,6 +952,7 @@ class DeepResearchJobExecutor:
|
||||
|
||||
draft_parts: list[str] = []
|
||||
last_checkpoint = time.monotonic()
|
||||
inline_think_filter = InlineThinkTagFilter()
|
||||
try:
|
||||
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
|
||||
thinking_delta, text_delta = extract_stream_chunk_parts(chunk)
|
||||
@@ -941,6 +962,13 @@ class DeepResearchJobExecutor:
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": thinking_delta},
|
||||
)
|
||||
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
|
||||
if inline_thinking:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": inline_thinking},
|
||||
)
|
||||
if not text_delta:
|
||||
continue
|
||||
draft_parts.append(text_delta)
|
||||
@@ -972,6 +1000,26 @@ class DeepResearchJobExecutor:
|
||||
await fail(type(exc).__name__, str(exc))
|
||||
return
|
||||
|
||||
tail, inline_thinking_tail = inline_think_filter.finish_parts()
|
||||
if inline_thinking_tail:
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={"type": "thinking", "chunk": inline_thinking_tail},
|
||||
)
|
||||
if tail:
|
||||
draft_parts.append(tail)
|
||||
draft = "".join(draft_parts)
|
||||
await events.publish_live(
|
||||
"document_rewrite",
|
||||
phase="writing",
|
||||
payload={
|
||||
"type": "rewrite_delta",
|
||||
"delta": tail,
|
||||
"charCount": visible_document_char_count(draft),
|
||||
},
|
||||
)
|
||||
|
||||
rewritten = "".join(draft_parts).strip()
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
@@ -982,6 +1030,148 @@ class DeepResearchJobExecutor:
|
||||
"charCount": visible_document_char_count(rewritten),
|
||||
},
|
||||
)
|
||||
|
||||
# Reject an invalid text draft before spending image-generation quota.
|
||||
validation_error = validate_markdown_document(rewritten)
|
||||
if validation_error:
|
||||
await fail("VALIDATION_FAILED", validation_error, stage="validation")
|
||||
return
|
||||
|
||||
generated_illustrations = []
|
||||
persisted_illustrations = []
|
||||
illustration_warnings: list[str] = []
|
||||
if generate_images:
|
||||
from app.gateway.document_rewrite_illustrations import generate_document_illustrations
|
||||
from deerflow.agents.deep_research.adapters.image import ImageGenerationSettings, build_image_provider
|
||||
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
phase="illustrating",
|
||||
progress=88,
|
||||
)
|
||||
# Make the phase visible even when the configured provider is
|
||||
# unavailable or every provider request fails before a result.
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_started",
|
||||
"section": "准备生成文档配图",
|
||||
"position": 0,
|
||||
"total": max_generated_images,
|
||||
},
|
||||
)
|
||||
image_settings = ImageGenerationSettings.from_app_config()
|
||||
if not image_settings.is_configured:
|
||||
illustration_warnings.append("当前部署未配置图片生成服务,已跳过文档配图。")
|
||||
else:
|
||||
provider = build_image_provider()
|
||||
|
||||
async def on_image_started(section: str, position: int, total: int) -> None:
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_started",
|
||||
"section": section,
|
||||
"position": position,
|
||||
"total": total,
|
||||
},
|
||||
)
|
||||
|
||||
async def on_image_generated(image, total: int, generated_count: int) -> None:
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_generated",
|
||||
"section": image.section,
|
||||
"position": image.position,
|
||||
"total": total,
|
||||
"generatedCount": generated_count,
|
||||
},
|
||||
)
|
||||
|
||||
generated_illustrations, generated_warnings = await generate_document_illustrations(
|
||||
provider,
|
||||
markdown=rewritten,
|
||||
document_name=actual_path.name,
|
||||
maximum=max_generated_images,
|
||||
on_started=on_image_started,
|
||||
on_generated=on_image_generated,
|
||||
)
|
||||
illustration_warnings.extend(generated_warnings)
|
||||
|
||||
# Do not save images if a manual edit has invalidated the frozen
|
||||
# source version while image calls were in flight.
|
||||
current_before_images = await asyncio.to_thread(actual_path.read_text, encoding="utf-8")
|
||||
if content_hash(current_before_images) != base_hash:
|
||||
message = "文件在生成配图期间已被修改,已保留原文件。"
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="done",
|
||||
payload={"type": "conflict", "message": message},
|
||||
)
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
status="completed",
|
||||
phase="done",
|
||||
progress=100,
|
||||
result_snapshot={
|
||||
"kind": "artifact_document_rewrite",
|
||||
"status": "conflict",
|
||||
"content": rewritten,
|
||||
"originalContent": original,
|
||||
"requirementsPlan": requirements_plan,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if generated_illustrations:
|
||||
from app.gateway.document_rewrite_illustrations import (
|
||||
insert_document_illustrations,
|
||||
persist_document_illustrations,
|
||||
)
|
||||
|
||||
try:
|
||||
persisted_illustrations, persistence_warnings = await asyncio.to_thread(
|
||||
persist_document_illustrations,
|
||||
generated_illustrations,
|
||||
markdown_path=actual_path,
|
||||
virtual_markdown_path=virtual_path,
|
||||
job_id=job_id,
|
||||
)
|
||||
illustration_warnings.extend(persistence_warnings)
|
||||
rewritten = insert_document_illustrations(rewritten, persisted_illustrations)
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "rewrite_checkpoint",
|
||||
"content": rewritten,
|
||||
"charCount": visible_document_char_count(rewritten),
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("could not save artifact rewrite illustrations for %s", job_id)
|
||||
illustration_warnings.append("配图保存失败,已完成文字改写但未插入图片。")
|
||||
persisted_illustrations = []
|
||||
else:
|
||||
persisted_illustrations = []
|
||||
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="illustrating",
|
||||
payload={
|
||||
"type": "illustration_completed",
|
||||
"generatedCount": len(persisted_illustrations),
|
||||
"requestedCount": max_generated_images,
|
||||
"warnings": illustration_warnings,
|
||||
},
|
||||
)
|
||||
|
||||
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
|
||||
validation_error = validate_markdown_document(rewritten)
|
||||
if validation_error:
|
||||
@@ -991,6 +1181,7 @@ class DeepResearchJobExecutor:
|
||||
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
|
||||
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
|
||||
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
|
||||
warnings.extend(illustration_warnings)
|
||||
summary = build_document_rewrite_summary(
|
||||
source_display_name=actual_path.name,
|
||||
instruction=instruction,
|
||||
@@ -999,6 +1190,8 @@ class DeepResearchJobExecutor:
|
||||
rewritten=rewritten,
|
||||
validation_warnings=warnings,
|
||||
)
|
||||
summary["generatedImageCount"] = len(persisted_illustrations)
|
||||
summary["requestedImageCount"] = max_generated_images if generate_images else 0
|
||||
await events.emit(
|
||||
"document_rewrite",
|
||||
phase="reviewing",
|
||||
@@ -1124,9 +1317,7 @@ class DeepResearchJobExecutor:
|
||||
job_id=job_id,
|
||||
on_event=self._live_publisher,
|
||||
)
|
||||
await events.emit(
|
||||
"document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage}
|
||||
)
|
||||
await events.emit("document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage})
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
@@ -1332,11 +1523,7 @@ class DeepResearchJobExecutor:
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"请为以下研究报告写一段 150-250 字的摘要,使用"
|
||||
f"{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n"
|
||||
f"{report_markdown[:24_000]}"
|
||||
),
|
||||
"content": (f"请为以下研究报告写一段 150-250 字的摘要,使用{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n{report_markdown[:24_000]}"),
|
||||
},
|
||||
],
|
||||
on_delta=on_delta,
|
||||
@@ -1369,10 +1556,18 @@ class DeepResearchJobExecutor:
|
||||
|
||||
# ── terminal-state writers ──────────────────────────────────────────────
|
||||
|
||||
async def _mark_completed(
|
||||
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
|
||||
) -> None:
|
||||
# Job row → completed.
|
||||
async def _mark_completed(self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult) -> None:
|
||||
# Persist the report projection before exposing a terminal completed
|
||||
# job. The session row is the durable report itself; marking the job
|
||||
# complete first and suppressing a session-write failure can otherwise
|
||||
# make a successfully generated report disappear after refresh.
|
||||
await self._persist_completed_report_projection(
|
||||
job_id=job_id,
|
||||
session_id=session_id,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Job row → completed only after the report is durable.
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
@@ -1385,29 +1580,61 @@ class DeepResearchJobExecutor:
|
||||
"plan": result.plan,
|
||||
},
|
||||
)
|
||||
# Session row → completed + report.
|
||||
source_count = len(result.source_ids)
|
||||
with suppress(Exception): # noqa: BLE001
|
||||
await self._session_store.update(
|
||||
session_id,
|
||||
status="completed",
|
||||
report_markdown=result.report_markdown,
|
||||
report_html=result.report_html,
|
||||
plan_snapshot=result.plan,
|
||||
# Keep the terminal job id alongside model usage. The
|
||||
# frontend uses it only to replay the persisted research trace
|
||||
# after a refresh; it does not affect billing or job state.
|
||||
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
|
||||
source_count=source_count,
|
||||
active_job_id=None,
|
||||
)
|
||||
# Emit a terminal event for SSE consumers.
|
||||
with suppress(Exception): # noqa: BLE001
|
||||
await self._emit_terminal(job_id, session_id, "job_completed")
|
||||
|
||||
async def _mark_awaiting(
|
||||
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
|
||||
async def _persist_completed_report_projection(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
session_id: str,
|
||||
result: DeepResearchResult,
|
||||
) -> None:
|
||||
"""Durably save the report before publishing job completion.
|
||||
|
||||
A write can succeed while a short-lived MySQL proxy/readback hiccup
|
||||
makes its caller fail. Retry the full owner-scoped update a few times;
|
||||
if it still cannot be confirmed, propagate the error so ``_run`` marks
|
||||
the job failed instead of advertising a report that cannot be reopened.
|
||||
"""
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1):
|
||||
try:
|
||||
updated = await self._session_store.update(
|
||||
session_id,
|
||||
status="completed",
|
||||
report_markdown=result.report_markdown,
|
||||
report_html=result.report_html,
|
||||
plan_snapshot=result.plan,
|
||||
# Keep the terminal job id alongside model usage. The
|
||||
# frontend uses it only to replay the persisted research
|
||||
# trace after a refresh; it does not affect billing.
|
||||
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
|
||||
source_count=len(result.source_ids),
|
||||
active_job_id=None,
|
||||
)
|
||||
if updated is not None:
|
||||
return
|
||||
last_error = RuntimeError(f"Deep Research session {session_id} no longer exists")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
|
||||
if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS:
|
||||
logger.warning(
|
||||
"deep-research report projection failed for %s (attempt %s/%s); retrying",
|
||||
job_id,
|
||||
attempt,
|
||||
_REPORT_PROJECTION_RETRY_ATTEMPTS,
|
||||
exc_info=True,
|
||||
)
|
||||
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
|
||||
|
||||
raise RuntimeError(f"研究报告未能保存到会话 {session_id},任务不会标记为完成") from last_error
|
||||
|
||||
async def _mark_awaiting(self, job_id: str, session_id: str, pause: ResearchAwaitingInput) -> None:
|
||||
"""Project a persisted plan-review interrupt onto the owning session."""
|
||||
with suppress(Exception): # noqa: BLE001
|
||||
await self._session_store.update(
|
||||
@@ -1417,9 +1644,7 @@ class DeepResearchJobExecutor:
|
||||
active_job_id=job_id,
|
||||
)
|
||||
|
||||
async def _mark_failed(
|
||||
self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str
|
||||
) -> None:
|
||||
async def _mark_failed(self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str) -> None:
|
||||
await self._job_store.update_progress(
|
||||
job_id,
|
||||
lease_owner=lease_owner,
|
||||
|
||||
@@ -85,6 +85,7 @@ async def stream_full_report_rewrite(
|
||||
style: str,
|
||||
model_name: str | None,
|
||||
on_delta: Callable[[str], Awaitable[None]],
|
||||
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
|
||||
requirements_plan: str | None = None,
|
||||
) -> tuple[str, list[str], dict[str, Any]]:
|
||||
"""Generate a complete Markdown replacement without starting new research."""
|
||||
@@ -126,6 +127,7 @@ async def stream_full_report_rewrite(
|
||||
messages=messages,
|
||||
operation="full_report_rewrite",
|
||||
on_delta=on_delta,
|
||||
on_reasoning=on_reasoning,
|
||||
)
|
||||
answer = result.text.strip() or report
|
||||
answer, cited = _normalise_citations(answer, source_ids)
|
||||
@@ -140,6 +142,7 @@ async def stream_full_report_rewrite_requirements(
|
||||
style: str,
|
||||
model_name: str | None,
|
||||
on_delta: Callable[[str], Awaitable[None]],
|
||||
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> str:
|
||||
"""Stream a compact plan before rewriting a research report.
|
||||
|
||||
@@ -169,6 +172,7 @@ async def stream_full_report_rewrite_requirements(
|
||||
],
|
||||
operation="full_report_rewrite_requirements",
|
||||
on_delta=on_delta,
|
||||
on_reasoning=on_reasoning,
|
||||
)
|
||||
return result.text.strip()
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Safe, optional illustrations for a sandbox Markdown full rewrite.
|
||||
|
||||
The writer streams prose first. When requested, this module derives a small
|
||||
set of heading-scoped illustration prompts, persists the returned bytes inside
|
||||
the same user-owned artifact tree, then adds only local ``/mnt`` references to
|
||||
the Markdown. Provider URLs never reach the browser or the saved document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from deerflow.agents.deep_research.adapters.image import ImageGenerationError, ImageProvider
|
||||
|
||||
_HEADING_RE = re.compile(r"^##[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
|
||||
_TITLE_RE = re.compile(r"^#[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
|
||||
_START_MARKER = "<!-- deerflow:ai-rewrite-image:start -->"
|
||||
_END_MARKER = "<!-- deerflow:ai-rewrite-image:end -->"
|
||||
_MANAGED_IMAGE_BLOCK_RE = re.compile(
|
||||
rf"\n?{re.escape(_START_MARKER)}.*?{re.escape(_END_MARKER)}\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
_MIME_EXTENSIONS = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocumentIllustration:
|
||||
"""A provider result that has not been exposed or persisted yet."""
|
||||
|
||||
section: str
|
||||
position: int
|
||||
data: bytes
|
||||
mime_type: str
|
||||
alt: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersistedDocumentIllustration:
|
||||
"""A generated image with a virtual artifact path safe for Markdown."""
|
||||
|
||||
section: str
|
||||
position: int
|
||||
virtual_path: str
|
||||
alt: str
|
||||
|
||||
|
||||
def strip_managed_document_illustrations(markdown: str) -> str:
|
||||
"""Remove only DeerFlow-generated image blocks before a fresh image pass."""
|
||||
|
||||
return _MANAGED_IMAGE_BLOCK_RE.sub("\n", markdown).strip()
|
||||
|
||||
|
||||
def select_illustration_sections(markdown: str, maximum: int) -> list[str]:
|
||||
"""Pick distinct H2 sections; fall back to the title for short documents."""
|
||||
|
||||
limit = max(0, min(maximum, 4))
|
||||
if not limit:
|
||||
return []
|
||||
sections: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for match in _HEADING_RE.finditer(markdown):
|
||||
section = _clean_heading(match.group(1))
|
||||
if section and section not in seen:
|
||||
seen.add(section)
|
||||
sections.append(section)
|
||||
if len(sections) >= limit:
|
||||
return sections
|
||||
if sections:
|
||||
return sections
|
||||
title_match = _TITLE_RE.search(markdown)
|
||||
title = _clean_heading(title_match.group(1)) if title_match else "文档主题"
|
||||
return [title or "文档主题"]
|
||||
|
||||
|
||||
async def generate_document_illustrations(
|
||||
provider: ImageProvider,
|
||||
*,
|
||||
markdown: str,
|
||||
document_name: str,
|
||||
maximum: int,
|
||||
on_started=None,
|
||||
on_generated=None,
|
||||
) -> tuple[list[DocumentIllustration], list[str]]:
|
||||
"""Generate a bounded, sequential image set and keep failures recoverable."""
|
||||
|
||||
sections = select_illustration_sections(markdown, maximum)
|
||||
title_match = _TITLE_RE.search(markdown)
|
||||
document_title = _clean_heading(title_match.group(1)) if title_match else Path(document_name).stem
|
||||
generated: list[DocumentIllustration] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for position, section in enumerate(sections, 1):
|
||||
if on_started is not None:
|
||||
await on_started(section, position, len(sections))
|
||||
try:
|
||||
result = await provider.generate(
|
||||
_illustration_prompt(document_title, section),
|
||||
section_id=section,
|
||||
)
|
||||
data = result.get("data") if isinstance(result, dict) else None
|
||||
if not isinstance(data, bytes) or not data:
|
||||
raise ImageGenerationError("图片生成服务未返回可保存的图片数据")
|
||||
mime_type = str(result.get("mime_type") or "image/png").lower()
|
||||
if mime_type not in _MIME_EXTENSIONS:
|
||||
mime_type = "image/png"
|
||||
illustration = DocumentIllustration(
|
||||
section=section,
|
||||
position=position,
|
||||
data=data,
|
||||
mime_type=mime_type,
|
||||
alt=_clean_alt(str(result.get("alt") or f"{section}配图")),
|
||||
)
|
||||
generated.append(illustration)
|
||||
if on_generated is not None:
|
||||
await on_generated(illustration, len(sections), len(generated))
|
||||
except ImageGenerationError as exc:
|
||||
warnings.append(f"“{section}”配图未生成:{exc}")
|
||||
except Exception: # noqa: BLE001
|
||||
# Generation remains best effort: an unavailable image provider
|
||||
# must never discard an otherwise complete document rewrite.
|
||||
warnings.append(f"“{section}”配图生成失败,已保留文字改写结果。")
|
||||
return generated, warnings
|
||||
|
||||
|
||||
def persist_document_illustrations(
|
||||
illustrations: list[DocumentIllustration],
|
||||
*,
|
||||
markdown_path: Path,
|
||||
virtual_markdown_path: str,
|
||||
job_id: str,
|
||||
) -> tuple[list[PersistedDocumentIllustration], list[str]]:
|
||||
"""Best-effort persist each asset beside the rewritten Markdown file.
|
||||
|
||||
A single failed local write must not discard earlier successful images. In
|
||||
particular, Windows development reloaders can briefly recreate an output
|
||||
directory while a report is being written. The caller receives warnings
|
||||
for the timeline, while any persisted image remains usable in the final
|
||||
Markdown.
|
||||
"""
|
||||
|
||||
if not illustrations:
|
||||
return [], []
|
||||
# Keep this name intentionally short. On Windows the local sandbox path
|
||||
# already contains user/thread UUIDs; including a long report title plus a
|
||||
# UUID-based temporary filename exceeded MAX_PATH (and surfaced as a
|
||||
# misleading FileNotFoundError). Job ids make names unique across files,
|
||||
# so a single short hidden directory per outputs folder is sufficient.
|
||||
asset_directory_name = ".ai-images"
|
||||
asset_directory = markdown_path.parent / asset_directory_name
|
||||
virtual_parent = PurePosixPath(virtual_markdown_path).parent.as_posix().rstrip("/")
|
||||
persisted: list[PersistedDocumentIllustration] = []
|
||||
warnings: list[str] = []
|
||||
for image in illustrations:
|
||||
try:
|
||||
extension = _MIME_EXTENSIONS[image.mime_type]
|
||||
file_name = f"{job_id}-{image.position:02d}.{extension}"
|
||||
_atomic_write_bytes(asset_directory / file_name, image.data)
|
||||
persisted.append(
|
||||
PersistedDocumentIllustration(
|
||||
section=image.section,
|
||||
position=image.position,
|
||||
virtual_path=f"{virtual_parent}/{asset_directory_name}/{file_name}",
|
||||
alt=image.alt,
|
||||
)
|
||||
)
|
||||
except OSError:
|
||||
warnings.append(f"“{image.section}”配图保存失败,已继续保留其他成功配图。")
|
||||
return persisted, warnings
|
||||
|
||||
|
||||
def insert_document_illustrations(
|
||||
markdown: str,
|
||||
illustrations: list[PersistedDocumentIllustration],
|
||||
) -> str:
|
||||
"""Insert fenced local-image blocks directly below their selected sections."""
|
||||
|
||||
amended = strip_managed_document_illustrations(markdown)
|
||||
for image in illustrations:
|
||||
marker = f"## {image.section}"
|
||||
block = (
|
||||
f"\n\n{_START_MARKER}\n"
|
||||
f"\n"
|
||||
f"{_END_MARKER}\n"
|
||||
)
|
||||
if marker in amended:
|
||||
amended = amended.replace(marker, f"{marker}{block}", 1)
|
||||
else:
|
||||
amended = f"{amended.rstrip()}{block}"
|
||||
return amended.strip()
|
||||
|
||||
|
||||
def _atomic_write_bytes(path: Path, content: bytes) -> None:
|
||||
"""Write one image atomically with Windows-safe temporary path lengths."""
|
||||
|
||||
last_error: FileNotFoundError | None = None
|
||||
for _attempt in range(3):
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
# A runtime cleanup can remove a freshly-created asset folder
|
||||
# between image calls. Recreate it immediately before writing and
|
||||
# retry this small local operation. NamedTemporaryFile deliberately
|
||||
# keeps the temporary basename short: appending a report title,
|
||||
# job id, and UUID can exceed Windows MAX_PATH before os.replace.
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=path.parent,
|
||||
prefix=".w-",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as file:
|
||||
temporary = Path(file.name)
|
||||
file.write(content)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
os.replace(temporary, path)
|
||||
return
|
||||
except FileNotFoundError as exc:
|
||||
last_error = exc
|
||||
finally:
|
||||
try:
|
||||
if temporary is not None and temporary.exists():
|
||||
temporary.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
|
||||
def _illustration_prompt(document_title: str, section: str) -> str:
|
||||
return (
|
||||
"为一份专业 Markdown 文档生成一张可嵌入的章节配图。"
|
||||
f"文档主题:{_clean_heading(document_title)}。章节:{section}。"
|
||||
"画面应信息性、简洁、与正文内容一致,可使用抽象示意、数据可视化风格或场景插画。"
|
||||
"不要在图片中放置可读文字、品牌标志、水印或人物肖像;不得虚构具体数据或事实。"
|
||||
)
|
||||
|
||||
|
||||
def _clean_heading(value: str) -> str:
|
||||
return " ".join(value.replace("\r", " ").replace("\n", " ").split())[:160]
|
||||
|
||||
|
||||
def _clean_alt(value: str) -> str:
|
||||
return _clean_heading(value).replace("[", "").replace("]", "") or "文档配图"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DocumentIllustration",
|
||||
"PersistedDocumentIllustration",
|
||||
"generate_document_illustrations",
|
||||
"insert_document_illustrations",
|
||||
"persist_document_illustrations",
|
||||
"select_illustration_sections",
|
||||
"strip_managed_document_illustrations",
|
||||
]
|
||||
@@ -14,10 +14,12 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_DOCUMENT_REWRITE_CHARS = 120_000
|
||||
@@ -89,9 +91,7 @@ def build_rewrite_requirements_context(content: str) -> str:
|
||||
if len(headings) >= 30:
|
||||
break
|
||||
|
||||
heading_count = sum(
|
||||
1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line)
|
||||
)
|
||||
heading_count = sum(1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line))
|
||||
link_count = len(re.findall(r"!?(?:\[[^\]]*\]\([^)]*\))", content))
|
||||
table_rows = sum(1 for line in content.splitlines() if line.lstrip().startswith("|"))
|
||||
return "\n".join(
|
||||
@@ -136,16 +136,7 @@ def build_rewrite_requirements_messages(
|
||||
|
||||
return [
|
||||
SystemMessage(content=DOCUMENT_REWRITE_REQUIREMENTS_SYSTEM_PROMPT),
|
||||
HumanMessage(
|
||||
content=(
|
||||
f"文件名:{file_name}\n"
|
||||
f"用户改写要求:{instruction}\n"
|
||||
f"选定风格:{style_instruction}\n\n"
|
||||
"【文档结构信息开始】\n"
|
||||
f"{build_rewrite_requirements_context(original)}\n"
|
||||
"【文档结构信息结束】"
|
||||
)
|
||||
),
|
||||
HumanMessage(content=(f"文件名:{file_name}\n用户改写要求:{instruction}\n选定风格:{style_instruction}\n\n【文档结构信息开始】\n{build_rewrite_requirements_context(original)}\n【文档结构信息结束】")),
|
||||
]
|
||||
|
||||
|
||||
@@ -156,18 +147,30 @@ async def astream_rewrite_requirements(
|
||||
instruction: str,
|
||||
style_instruction: str,
|
||||
original: str,
|
||||
on_thinking: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield only visible plan text from the short requirements-planning call."""
|
||||
"""Yield visible plan text and optionally forward live model reasoning."""
|
||||
messages = build_rewrite_requirements_messages(
|
||||
file_name=file_name,
|
||||
instruction=instruction,
|
||||
style_instruction=style_instruction,
|
||||
original=original,
|
||||
)
|
||||
inline_think_filter = InlineThinkTagFilter()
|
||||
async for chunk in model.astream(messages, config={"run_name": "document_rewrite_requirements"}):
|
||||
_, text = extract_stream_chunk_parts(chunk)
|
||||
if text:
|
||||
yield text
|
||||
structured_thinking, text = extract_stream_chunk_parts(chunk)
|
||||
if structured_thinking and on_thinking is not None:
|
||||
await on_thinking(structured_thinking)
|
||||
visible_text, inline_thinking = inline_think_filter.push_parts(text)
|
||||
if inline_thinking and on_thinking is not None:
|
||||
await on_thinking(inline_thinking)
|
||||
if visible_text:
|
||||
yield visible_text
|
||||
tail, inline_thinking_tail = inline_think_filter.finish_parts()
|
||||
if inline_thinking_tail and on_thinking is not None:
|
||||
await on_thinking(inline_thinking_tail)
|
||||
if tail:
|
||||
yield tail
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, content: str) -> None:
|
||||
|
||||
@@ -21,6 +21,7 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_agent_store
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -125,6 +126,37 @@ async def _validate_public_chain_mapping(
|
||||
status_code=400,
|
||||
detail="Global business mappings require a public business chain",
|
||||
)
|
||||
|
||||
# 公共业务映射会被任意账号启动,链条中的席位也必须对任意账号可见。
|
||||
# 仅把链条本身发布、却把其中某个自定义智能体保留为未发布/私有,会导致
|
||||
# 前端只能解析到不足两个席位,最终被误判为“没有可用业务链”。
|
||||
seats = chain.get("seats")
|
||||
if isinstance(seats, list):
|
||||
agent_store = get_agent_store(request)
|
||||
unavailable_names: list[str] = []
|
||||
for raw_seat in seats:
|
||||
if not isinstance(raw_seat, dict):
|
||||
continue
|
||||
agent_id = str(raw_seat.get("agent_id") or "").strip()
|
||||
if not agent_id:
|
||||
continue
|
||||
agent = await agent_store.get_any(agent_id)
|
||||
is_builtin = agent is not None and agent.get("user_id") is None
|
||||
is_public_agent = bool(agent and agent.get("published")) and not bool(
|
||||
agent and agent.get("is_private")
|
||||
)
|
||||
if is_builtin or is_public_agent:
|
||||
continue
|
||||
name = str(raw_seat.get("name") or agent_id).strip()
|
||||
unavailable_names.append(name)
|
||||
if unavailable_names:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Global business mappings require every seat to be published and public; "
|
||||
f"unavailable agents: {', '.join(unavailable_names)}"
|
||||
),
|
||||
)
|
||||
fields["chain_id"] = chain_id
|
||||
fields["chain_title"] = str(chain.get("title") or "") or None
|
||||
|
||||
|
||||
@@ -639,17 +639,30 @@ async def start_job(session_id: str, body: StartJobRequest, request: Request) ->
|
||||
input_snapshot=input_snapshot,
|
||||
)
|
||||
|
||||
# Update session status.
|
||||
await session_store.update(
|
||||
session_id,
|
||||
user_id=user_id,
|
||||
status="running",
|
||||
active_job_id=job["id"],
|
||||
)
|
||||
# The durable job is the source of truth once it has been committed. A
|
||||
# transient failure updating the display projection must not turn that
|
||||
# successful write into a misleading 500 response or leave it undispatched.
|
||||
# The executor re-projects the terminal status when it completes.
|
||||
try:
|
||||
await session_store.update(
|
||||
session_id,
|
||||
user_id=user_id,
|
||||
status="running",
|
||||
active_job_id=job["id"],
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"deep-research job %s was queued but its session running projection could not be updated",
|
||||
job["id"],
|
||||
)
|
||||
|
||||
# Nudge the dispatcher to claim immediately.
|
||||
# Nudge the dispatcher even for an idempotent retry. If an earlier request
|
||||
# committed the job but failed before this point (for example a transient
|
||||
# MySQL read-after-write failure), the retry receives the existing queued
|
||||
# job. Restricting the nudge to ``created`` would leave that durable job
|
||||
# queued until the next periodic scan and make the UI appear stuck.
|
||||
dispatcher = _get_dispatcher(request)
|
||||
if dispatcher is not None and created:
|
||||
if dispatcher is not None:
|
||||
dispatcher.nudge()
|
||||
|
||||
return {
|
||||
@@ -810,36 +823,68 @@ async def stream_job(
|
||||
if loop.time() >= next_durable_poll:
|
||||
# Durable replay repairs a missed live delta after
|
||||
# reconnect or when another worker owned the job.
|
||||
events = await event_store.list_after(job_id, after=cursor, limit=200)
|
||||
for ev in events:
|
||||
payload = {
|
||||
"seq": ev["seq"],
|
||||
"sessionId": session_id,
|
||||
"jobId": job_id,
|
||||
"type": ev["event_type"],
|
||||
"phase": ev.get("phase", "initializing"),
|
||||
"timestamp": _iso(ev.get("created_at")),
|
||||
"payload": ev.get("payload") or {},
|
||||
}
|
||||
yield format_sse("progress", payload, event_id=str(ev["seq"]))
|
||||
cursor = max(cursor, ev["seq"])
|
||||
current = await job_store.get_unscoped(job_id)
|
||||
if current and current.get("status") in TERMINAL_STATUSES:
|
||||
try:
|
||||
events = await event_store.list_after(job_id, after=cursor, limit=200)
|
||||
for ev in events:
|
||||
payload = {
|
||||
"seq": ev["seq"],
|
||||
"sessionId": session_id,
|
||||
"jobId": job_id,
|
||||
"type": ev["event_type"],
|
||||
"phase": ev.get("phase", "initializing"),
|
||||
"timestamp": _iso(ev.get("created_at")),
|
||||
"payload": ev.get("payload") or {},
|
||||
}
|
||||
yield format_sse("progress", payload, event_id=str(ev["seq"]))
|
||||
cursor = max(cursor, ev["seq"])
|
||||
current = await job_store.get_unscoped(job_id)
|
||||
if current and current.get("status") in TERMINAL_STATUSES:
|
||||
yield format_sse(
|
||||
"progress",
|
||||
{
|
||||
"seq": cursor + 1,
|
||||
"sessionId": session_id,
|
||||
"jobId": job_id,
|
||||
"type": "job_status",
|
||||
"phase": current.get("phase", "done"),
|
||||
"timestamp": _iso(datetime.now(UTC)),
|
||||
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
|
||||
},
|
||||
event_id=str(cursor + 1),
|
||||
)
|
||||
return
|
||||
next_durable_poll = loop.time() + _POLL_INTERVAL_S
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
# Do not let one read-replica / transient DB failure
|
||||
# abort the ASGI generator (which surfaces as an
|
||||
# ExceptionGroup and leaves the page stuck). Live
|
||||
# frames can still reach an attached browser, and the
|
||||
# next durable poll repairs the cursor on recovery.
|
||||
logger.warning(
|
||||
"deep-research SSE durable replay temporarily failed for job %s; keeping stream open",
|
||||
job_id,
|
||||
exc_info=True,
|
||||
)
|
||||
yield format_sse(
|
||||
"progress",
|
||||
{
|
||||
"seq": cursor + 1,
|
||||
"seq": 0,
|
||||
"liveId": f"durable-replay-retry:{job_id}:{int(loop.time() * 1000)}",
|
||||
"sessionId": session_id,
|
||||
"jobId": job_id,
|
||||
"type": "job_status",
|
||||
"phase": current.get("phase", "done"),
|
||||
"type": "warning",
|
||||
"phase": "initializing",
|
||||
"timestamp": _iso(datetime.now(UTC)),
|
||||
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
|
||||
"payload": {
|
||||
"code": "durable_replay_temporarily_unavailable",
|
||||
"message": "进度同步暂时不可用,任务仍在继续;正在自动重试。",
|
||||
"recoverable": True,
|
||||
},
|
||||
},
|
||||
event_id=str(cursor + 1),
|
||||
)
|
||||
return
|
||||
next_durable_poll = loop.time() + _POLL_INTERVAL_S
|
||||
next_durable_poll = loop.time() + max(_POLL_INTERVAL_S, 3.0)
|
||||
if live_queue is None:
|
||||
await asyncio.sleep(max(0.01, next_durable_poll - loop.time()))
|
||||
continue
|
||||
|
||||
@@ -158,20 +158,19 @@ def _clarification_blocks_ready(
|
||||
*,
|
||||
is_fresh: bool,
|
||||
) -> bool:
|
||||
"""Position intent must wait for a fresh clarification answer.
|
||||
"""A fresh clarification always pauses intent resolution.
|
||||
|
||||
The generic Step-1 route intentionally preserves READY-first behavior for
|
||||
stale tool calls in historical snapshots. Position roundtable instead has
|
||||
a four-part hand-off whose missing context is requested interactively, so
|
||||
a newly dispatched clarification is a strict terminal pause. A historical
|
||||
tool call from the preceding user-assistance turn must not block the next
|
||||
user answer from reaching READY.
|
||||
A tool call and a premature ``[INTENT_READY]`` block may coexist in the
|
||||
same upstream run. The tool call is an explicit request for user input,
|
||||
so it wins for every Step-1 workspace (including 8BF): do not consume the
|
||||
assistance card before the user can answer it. Stale calls from earlier
|
||||
turns remain harmless because only a fresh call blocks READY.
|
||||
|
||||
``intent_agent_id`` stays in the signature for route-call compatibility
|
||||
and to keep the policy extensible per agent if a future flow needs one.
|
||||
"""
|
||||
return (
|
||||
intent_agent_id == POSITION_INTENT_AGENT_ID
|
||||
and bool(clarification_calls)
|
||||
and is_fresh
|
||||
)
|
||||
del intent_agent_id
|
||||
return bool(clarification_calls) and is_fresh
|
||||
|
||||
|
||||
def _intent_thread_metadata(
|
||||
@@ -195,17 +194,22 @@ def _intent_thread_metadata(
|
||||
if not task_id:
|
||||
return metadata
|
||||
|
||||
metadata.update(
|
||||
{
|
||||
"taskId": task_id,
|
||||
"position_roundtable_task_id": task_id,
|
||||
"position_task_id": f"{task_id}:intelligence",
|
||||
"position_id": "intelligence",
|
||||
"position_roundtable_phase": "intent",
|
||||
}
|
||||
)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID:
|
||||
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
|
||||
metadata["taskId"] = task_id
|
||||
# 8BF 使用通用意图接口但有明确的上下文 kind;它不能被岗位会商的
|
||||
# 会话索引误认为岗位任务,否则两个页面的历史记录会串在一起。
|
||||
# 其它历史调用保持原有 metadata 形状,避免影响既有会商恢复逻辑。
|
||||
is_eightbf_context = task_context.get("kind") == "eightbf_roundtable_task"
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID or not is_eightbf_context:
|
||||
metadata.update(
|
||||
{
|
||||
"position_roundtable_task_id": task_id,
|
||||
"position_task_id": f"{task_id}:intelligence",
|
||||
"position_id": "intelligence",
|
||||
"position_roundtable_phase": "intent",
|
||||
}
|
||||
)
|
||||
if intent_agent_id == POSITION_INTENT_AGENT_ID:
|
||||
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -691,11 +695,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
)
|
||||
clarification_is_fresh = current_rounds > past_rounds
|
||||
|
||||
# A position-roundtable clarification is an explicit pause.
|
||||
# Some models emit a premature [INTENT_READY] text after the
|
||||
# tool call in the same upstream run. For this dedicated flow
|
||||
# the fresh clarification must win so the user can answer it;
|
||||
# generic roundtable keeps its historical READY-first order.
|
||||
# A fresh clarification is an explicit pause. Some models emit
|
||||
# a premature [INTENT_READY] block after its tool call in the
|
||||
# same upstream run; the user-assistance card must win so the
|
||||
# user can answer it before any summary is accepted.
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
clarification_calls = [
|
||||
@@ -705,10 +708,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
and (tc.get("name") or "").lower() == "ask_clarification"
|
||||
]
|
||||
|
||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
||||
# --- 终态分支 1:结构化意图已就绪(无新追问时)---
|
||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
|
||||
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
|
||||
# 仍残留 ask_clarification 的 tool_calls;若它是本轮新产生的追问,
|
||||
# 必须先等待用户回答,不能让 READY 覆盖可交互的协助卡。
|
||||
summary: dict[str, Any] | None = None
|
||||
ready_text: str = ""
|
||||
for msg in reversed(messages):
|
||||
|
||||
@@ -59,6 +59,7 @@ from app.gateway.document_rewrite_summary import build_document_rewrite_summary
|
||||
from app.gateway.path_utils import aresolve_thread_virtual_path
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -143,6 +144,8 @@ class DocumentRewriteRequest(BaseModel):
|
||||
instruction: str = Field(..., min_length=1, max_length=4_000)
|
||||
style: str | None = Field(default=None)
|
||||
model_name: str | None = Field(default=None, alias="modelName")
|
||||
generate_images: bool = Field(default=False, alias="generateImages")
|
||||
max_generated_images: int = Field(default=2, alias="maxGeneratedImages", ge=1, le=4)
|
||||
expected_hash: str | None = Field(default=None, alias="expectedHash")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
@@ -193,12 +196,7 @@ def _artifact_rewrite_scope(thread_id: str, virtual_path: str) -> str:
|
||||
|
||||
def _artifact_job_matches(job: dict, *, thread_id: str, virtual_path: str) -> bool:
|
||||
snapshot = job.get("input_snapshot")
|
||||
return bool(
|
||||
isinstance(snapshot, dict)
|
||||
and snapshot.get("kind") == "artifact_document_rewrite"
|
||||
and snapshot.get("thread_id") == thread_id
|
||||
and snapshot.get("path") == virtual_path
|
||||
)
|
||||
return bool(isinstance(snapshot, dict) and snapshot.get("kind") == "artifact_document_rewrite" and snapshot.get("thread_id") == thread_id and snapshot.get("path") == virtual_path)
|
||||
|
||||
|
||||
def _artifact_job_response(job: dict) -> dict:
|
||||
@@ -215,6 +213,8 @@ def _artifact_job_response(job: dict) -> dict:
|
||||
"instruction": str(snapshot.get("instruction") or ""),
|
||||
"style": str(snapshot.get("style") or "professional"),
|
||||
"modelName": snapshot.get("model_name"),
|
||||
"generateImages": bool(snapshot.get("generate_images")),
|
||||
"maxGeneratedImages": int(snapshot.get("max_generated_images") or 2),
|
||||
"originalContent": snapshot.get("original_content") or "",
|
||||
},
|
||||
}
|
||||
@@ -266,10 +266,7 @@ async def _stream_durable_artifact_rewrite_job(
|
||||
yield _sse(
|
||||
"error",
|
||||
{
|
||||
"message": str(
|
||||
current.get("error_message")
|
||||
or "全文改写任务未完成,原文件未被修改。"
|
||||
),
|
||||
"message": str(current.get("error_message") or "全文改写任务未完成,原文件未被修改。"),
|
||||
"stage": str(current.get("phase") or "generation"),
|
||||
},
|
||||
)
|
||||
@@ -397,6 +394,8 @@ async def start_durable_document_rewrite_job(
|
||||
"instruction": body.instruction.strip(),
|
||||
"style": body.style or "professional",
|
||||
"model_name": body.model_name,
|
||||
"generate_images": body.generate_images,
|
||||
"max_generated_images": body.max_generated_images,
|
||||
}
|
||||
request_hash = hashlib.sha256(
|
||||
json.dumps(
|
||||
@@ -405,6 +404,8 @@ async def start_durable_document_rewrite_job(
|
||||
"instruction": body.instruction.strip(),
|
||||
"style": body.style or "professional",
|
||||
"model_name": body.model_name,
|
||||
"generate_images": body.generate_images,
|
||||
"max_generated_images": body.max_generated_images,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
@@ -468,13 +469,7 @@ async def list_durable_document_rewrite_jobs(
|
||||
virtual_path = path.strip()
|
||||
scope = _artifact_rewrite_scope(thread_id, virtual_path)
|
||||
rows = await job_store.list_by_session(scope, user_id=user_id, limit=limit)
|
||||
return {
|
||||
"items": [
|
||||
_artifact_job_response(row)
|
||||
for row in rows
|
||||
if _artifact_job_matches(row, thread_id=thread_id, virtual_path=virtual_path)
|
||||
]
|
||||
}
|
||||
return {"items": [_artifact_job_response(row) for row in rows if _artifact_job_matches(row, thread_id=thread_id, virtual_path=virtual_path)]}
|
||||
|
||||
|
||||
@router.get("/document-rewrite/jobs/{job_id}/stream", response_class=StreamingResponse)
|
||||
@@ -665,10 +660,14 @@ async def rewrite_document(
|
||||
"plan": requirements_plan,
|
||||
},
|
||||
)
|
||||
inline_think_filter = InlineThinkTagFilter()
|
||||
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
|
||||
thinking_delta, text_delta = _extract_chunk_parts(chunk)
|
||||
if thinking_delta:
|
||||
yield _sse("thinking", {"chunk": thinking_delta})
|
||||
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
|
||||
if inline_thinking:
|
||||
yield _sse("thinking", {"chunk": inline_thinking})
|
||||
if text_delta:
|
||||
draft_parts.append(text_delta)
|
||||
yield _sse(
|
||||
@@ -679,6 +678,19 @@ async def rewrite_document(
|
||||
},
|
||||
)
|
||||
|
||||
tail, inline_thinking_tail = inline_think_filter.finish_parts()
|
||||
if inline_thinking_tail:
|
||||
yield _sse("thinking", {"chunk": inline_thinking_tail})
|
||||
if tail:
|
||||
draft_parts.append(tail)
|
||||
yield _sse(
|
||||
"rewrite_delta",
|
||||
{
|
||||
"delta": tail,
|
||||
"charCount": _count_document_chars("".join(draft_parts)),
|
||||
},
|
||||
)
|
||||
|
||||
rewritten = "".join(draft_parts).strip()
|
||||
yield _sse("validation_started", {})
|
||||
validation_error = _markdown_validation_error(rewritten)
|
||||
@@ -688,11 +700,7 @@ async def rewrite_document(
|
||||
|
||||
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
|
||||
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
|
||||
warnings = (
|
||||
["标题数量发生变化,请在前后对比中确认结构。"]
|
||||
if heading_count_before != heading_count_after
|
||||
else []
|
||||
)
|
||||
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
|
||||
yield _sse(
|
||||
"validation_completed",
|
||||
{
|
||||
@@ -891,12 +899,21 @@ async def rewrite_selection(
|
||||
async def generate():
|
||||
try:
|
||||
model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
|
||||
inline_think_filter = InlineThinkTagFilter()
|
||||
async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
|
||||
thinking_delta, text_delta = _extract_chunk_parts(chunk)
|
||||
if thinking_delta:
|
||||
yield _sse("thinking", {"chunk": thinking_delta})
|
||||
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
|
||||
if inline_thinking:
|
||||
yield _sse("thinking", {"chunk": inline_thinking})
|
||||
if text_delta:
|
||||
yield _sse("text", {"chunk": text_delta})
|
||||
tail, inline_thinking_tail = inline_think_filter.finish_parts()
|
||||
if inline_thinking_tail:
|
||||
yield _sse("thinking", {"chunk": inline_thinking_tail})
|
||||
if tail:
|
||||
yield _sse("text", {"chunk": tail})
|
||||
yield _sse("done", {})
|
||||
except Exception as e:
|
||||
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
|
||||
|
||||
@@ -101,7 +101,7 @@ models:
|
||||
max_retries: 2
|
||||
stream_usage: true
|
||||
supports_thinking: true
|
||||
supports_reasoning_effort: false
|
||||
supports_reasoning_effort: true
|
||||
supports_vision: false
|
||||
- name: qwen3.6
|
||||
display_name: qwen3.6
|
||||
@@ -207,17 +207,18 @@ vision:
|
||||
# max_tokens: 2048
|
||||
|
||||
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
|
||||
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
|
||||
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
|
||||
# provider: openai_compatible 使用 POST {base_url}/images/generations 并要求 b64_json。
|
||||
# provider: dashscope_native 用于千问 Token Plan 的 qwen-image-3.0-pro / wan2.7-image:
|
||||
# 使用原生多模态 generation 接口,服务返回的临时图片 URL 会立即下载并保存为受鉴权产物。
|
||||
deep_research:
|
||||
image_generation:
|
||||
enabled: true
|
||||
provider: openai_compatible
|
||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
|
||||
provider: dashscope_native
|
||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
|
||||
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
|
||||
model: wan2.7-image # 例如 gpt-image-1
|
||||
model: wan2.7-image # 也可设为 qwen-image-3.0-pro
|
||||
size: 1024x1024
|
||||
# quality: standard
|
||||
watermark: false
|
||||
timeout_seconds: 120
|
||||
|
||||
# browser_act 相关配置
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ path and commit below, and keep the Apache-2.0 notice in the distribution.
|
||||
| `runners/deep.py` | `gpt_researcher/skills/deep_research.py` | Breadth/depth branching, learnings and follow-up questions are preserved with an explicit global budget, semaphores and cooperative cancellation. |
|
||||
| `runners/multi_agent.py` | `multi_agents/agents/{orchestrator,editor,writer,fact_checker}.py` | Editor/researcher/writer/reviewer roles become a LangGraph 1.x `StateGraph`, with the review pause persisted as a durable job state. |
|
||||
| `adapters/context.py`, `adapters/material.py` | `gpt_researcher/context/compression.py`, `gpt_researcher/skills/researcher.py` | Retains context/de-duplication intent while preserving DeerFlow provenance (`rec_uuid`, source id, source type) and enforcing the existing material-provider boundary. |
|
||||
| `runners/images.py`, `adapters/image.py` | `gpt_researcher/skills/image_generator.py` | Retains report-image placement intent, but image generation uses a configured DeerFlow OpenAI-compatible adapter and protected hidden-thread artifacts. |
|
||||
| `runners/images.py`, `adapters/image.py` | `gpt_researcher/skills/image_generator.py` | Retains report-image placement intent, while configured OpenAI-compatible and DashScope-native adapters both persist protected hidden-thread artifacts. |
|
||||
|
||||
## Required adaptation invariants
|
||||
|
||||
|
||||
+81
-28
@@ -48,6 +48,11 @@ class PersistingEventSink:
|
||||
self._session_id = session_id
|
||||
self._job_id = job_id
|
||||
self._on_event = on_event
|
||||
# A durable event row normally owns the sequence. When the event
|
||||
# database is temporarily unavailable, use distinct negative sequence
|
||||
# numbers for the live-only path so clients can render every milestone
|
||||
# without advancing their durable ``after`` cursor.
|
||||
self._fallback_seq = 0
|
||||
|
||||
async def emit(
|
||||
self,
|
||||
@@ -56,26 +61,66 @@ class PersistingEventSink:
|
||||
phase: ResearchPhase = "initializing",
|
||||
payload: dict | None = None,
|
||||
) -> DeepResearchEvent:
|
||||
row = await self._repo.append(
|
||||
session_id=self._session_id,
|
||||
job_id=self._job_id,
|
||||
event_type=type,
|
||||
phase=phase,
|
||||
payload=payload or {},
|
||||
)
|
||||
# Reconstruct the envelope from the persisted row (seq is now assigned).
|
||||
event = DeepResearchEvent(
|
||||
seq=int(row.get("seq", 0)),
|
||||
session_id=self._session_id,
|
||||
job_id=self._job_id,
|
||||
type=row.get("event_type", type),
|
||||
phase=row.get("phase", phase),
|
||||
payload=row.get("payload") or payload or {},
|
||||
)
|
||||
if self._on_event is not None:
|
||||
await self._on_event(event.to_sse_dict())
|
||||
event_payload = payload or {}
|
||||
try:
|
||||
row = await self._repo.append(
|
||||
session_id=self._session_id,
|
||||
job_id=self._job_id,
|
||||
event_type=type,
|
||||
phase=phase,
|
||||
payload=event_payload,
|
||||
)
|
||||
# Reconstruct the envelope from the persisted row (seq is now assigned).
|
||||
event = DeepResearchEvent(
|
||||
seq=int(row.get("seq", 0)),
|
||||
session_id=self._session_id,
|
||||
job_id=self._job_id,
|
||||
type=row.get("event_type", type),
|
||||
phase=row.get("phase", phase),
|
||||
payload=row.get("payload") or event_payload,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# Progress persistence must not turn a recoverable database blip
|
||||
# into a failed report. The job/status stores still record terminal
|
||||
# state; this live-only frame lets an attached UI stay responsive
|
||||
# until durable replay becomes available again after reconnect.
|
||||
self._fallback_seq -= 1
|
||||
logger.exception(
|
||||
"deep-research event persistence failed; continuing job with a live-only event (job=%s, type=%s, phase=%s)",
|
||||
self._job_id,
|
||||
type,
|
||||
phase,
|
||||
)
|
||||
event = DeepResearchEvent(
|
||||
seq=self._fallback_seq,
|
||||
session_id=self._session_id,
|
||||
job_id=self._job_id,
|
||||
type=type,
|
||||
phase=phase,
|
||||
payload={**event_payload, "eventPersistenceDegraded": True},
|
||||
)
|
||||
await self._publish_event(event)
|
||||
return event
|
||||
|
||||
async def _publish_event(self, event: DeepResearchEvent) -> None:
|
||||
"""Push a persisted or live-only event without risking the worker."""
|
||||
if self._on_event is None:
|
||||
return
|
||||
wire = event.to_sse_dict()
|
||||
if event.seq < 0:
|
||||
wire["liveId"] = f"event-persistence-fallback:{self._job_id}:{abs(event.seq)}"
|
||||
try:
|
||||
await self._on_event(wire)
|
||||
except Exception: # noqa: BLE001
|
||||
# A browser disconnect or a live-hub issue must not abort report
|
||||
# generation; persisted rows will still be replayable on reconnect.
|
||||
logger.warning(
|
||||
"deep-research live event publish failed; job continues (job=%s, seq=%s)",
|
||||
self._job_id,
|
||||
event.seq,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def publish_live(
|
||||
self,
|
||||
type: str, # noqa: A002
|
||||
@@ -85,16 +130,24 @@ class PersistingEventSink:
|
||||
) -> None:
|
||||
if self._on_event is None:
|
||||
return
|
||||
await self._on_event(
|
||||
{
|
||||
"seq": 0,
|
||||
"sessionId": self._session_id,
|
||||
"jobId": self._job_id,
|
||||
"type": type,
|
||||
"phase": phase,
|
||||
"payload": payload or {},
|
||||
}
|
||||
)
|
||||
try:
|
||||
await self._on_event(
|
||||
{
|
||||
"seq": 0,
|
||||
"sessionId": self._session_id,
|
||||
"jobId": self._job_id,
|
||||
"type": type,
|
||||
"phase": phase,
|
||||
"payload": payload or {},
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(
|
||||
"deep-research live frame publish failed; job continues (job=%s, type=%s)",
|
||||
self._job_id,
|
||||
type,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class NullEventSink:
|
||||
|
||||
+211
-12
@@ -1,14 +1,17 @@
|
||||
"""Optional, DeerFlow-configured report image generation.
|
||||
|
||||
The upstream GPT Researcher image skill owns its provider configuration, so it
|
||||
cannot be safely reused in DeerFlow. This adapter instead talks to a deployment
|
||||
configured OpenAI-compatible ``/images/generations`` endpoint. It is never
|
||||
configured from a browser request: model credentials stay in ``config.yaml``
|
||||
and the per-session snapshot contains only the boolean ``generate_images``.
|
||||
cannot be safely reused in DeerFlow. This adapter supports both a deployment
|
||||
configured OpenAI-compatible ``/images/generations`` endpoint and the native
|
||||
DashScope multimodal-generation endpoint used by Qwen Image 3.0 and Wan 2.7.
|
||||
It is never configured from a browser request: model credentials stay in
|
||||
``config.yaml`` and the per-session snapshot contains only the boolean
|
||||
``generate_images``.
|
||||
|
||||
Images are requested as base64 rather than provider-hosted URLs. The runner can
|
||||
therefore put the exact bytes into the session's protected artifact directory;
|
||||
reports never depend on an expiring public image URL.
|
||||
OpenAI-compatible images are requested as base64; DashScope image URLs are
|
||||
downloaded immediately. The runner therefore always puts exact bytes into the
|
||||
session's protected artifact directory, and reports never depend on an expiring
|
||||
public image URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,11 +19,13 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
_DASHSCOPE_GENERATION_PATH = "/api/v1/services/aigc/multimodal-generation/generation"
|
||||
|
||||
|
||||
class ImageGenerationError(RuntimeError):
|
||||
@@ -28,17 +33,17 @@ class ImageGenerationError(RuntimeError):
|
||||
|
||||
|
||||
class ImageGenerationSettings(BaseModel):
|
||||
"""Server-only configuration for an OpenAI-compatible image endpoint.
|
||||
"""Server-only configuration for a report image endpoint.
|
||||
|
||||
Example ``config.yaml`` section::
|
||||
|
||||
deep_research:
|
||||
image_generation:
|
||||
enabled: true
|
||||
provider: openai_compatible
|
||||
base_url: https://api.openai.com/v1
|
||||
provider: dashscope_native
|
||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
|
||||
api_key: $DEEP_RESEARCH_IMAGE_API_KEY
|
||||
model: gpt-image-1
|
||||
model: qwen-image-3.0-pro
|
||||
|
||||
``api_key`` is resolved by :class:`AppConfig` while loading YAML, but this
|
||||
object is deliberately not placed in ``DeepResearchConfig`` and is never
|
||||
@@ -46,12 +51,13 @@ class ImageGenerationSettings(BaseModel):
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
provider: Literal["openai_compatible"] = "openai_compatible"
|
||||
provider: Literal["openai_compatible", "dashscope_native"] = "openai_compatible"
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
model: str = ""
|
||||
size: str = "1024x1024"
|
||||
quality: str | None = None
|
||||
watermark: bool = False
|
||||
timeout_seconds: float = Field(default=120, ge=1, le=300)
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
@@ -215,11 +221,203 @@ class OpenAICompatibleImageProvider:
|
||||
return {"data": data, "mime_type": mime_type, "alt": alt, "prompt": prompt}
|
||||
|
||||
|
||||
class DashScopeNativeImageProvider:
|
||||
"""Generate report illustrations through DashScope's native image API.
|
||||
|
||||
Qwen Image 3.0 and Wan 2.7 do not implement the OpenAI
|
||||
``/images/generations`` request/response contract. They receive a
|
||||
multimodal ``input.messages`` payload and normally return a short-lived
|
||||
image URL. The URL is downloaded immediately so the research report keeps
|
||||
a protected local artifact, just like the OpenAI-compatible provider.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: ImageGenerationSettings,
|
||||
*,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
if not settings.is_configured:
|
||||
raise ValueError("Image generation provider is not configured")
|
||||
self._settings = settings
|
||||
self._transport = transport
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
section_id: str = "",
|
||||
user_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
del user_id # Credentials are deployment-scoped; users never supply them.
|
||||
body = _dashscope_request_body(self._settings, prompt)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._settings.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self._settings.timeout_seconds,
|
||||
transport=self._transport,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.post(
|
||||
_dashscope_generation_endpoint(self._settings.base_url),
|
||||
json=body,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
encoded, image_url, mime_type = _dashscope_image_result(payload)
|
||||
if encoded:
|
||||
data = _decode_image_base64(encoded)
|
||||
elif image_url:
|
||||
data, mime_type = await _download_dashscope_image(client, image_url, mime_type)
|
||||
else:
|
||||
raise ImageGenerationError("千问图片生成服务未返回图片地址或图片数据")
|
||||
except ImageGenerationError:
|
||||
raise
|
||||
except httpx.TimeoutException as exc:
|
||||
raise ImageGenerationError("图片生成服务响应超时") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ImageGenerationError(_http_error_message(exc.response)) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ImageGenerationError("图片生成服务暂时不可用") from exc
|
||||
except ValueError as exc:
|
||||
raise ImageGenerationError("图片生成服务返回了无效响应") from exc
|
||||
|
||||
if not data or len(data) > _MAX_IMAGE_BYTES:
|
||||
raise ImageGenerationError("生成图片为空或超过大小限制")
|
||||
alt = f"{section_id or '研究报告'}配图"
|
||||
return {"data": data, "mime_type": _normalise_mime_type(mime_type), "alt": alt, "prompt": prompt}
|
||||
|
||||
|
||||
def _dashscope_generation_endpoint(base_url: str) -> str:
|
||||
"""Resolve a native DashScope endpoint without relying on a workspace id.
|
||||
|
||||
Token Plan deployments commonly expose the full native endpoint, while
|
||||
regular DashScope configuration often uses just an API origin. Supporting
|
||||
both avoids an accidental ``.../generation/images/generations`` path.
|
||||
"""
|
||||
|
||||
base = base_url.rstrip("/")
|
||||
if base.endswith(_DASHSCOPE_GENERATION_PATH):
|
||||
return base
|
||||
parsed = urlparse(base)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return base
|
||||
return f"{parsed.scheme}://{parsed.netloc}{_DASHSCOPE_GENERATION_PATH}"
|
||||
|
||||
|
||||
def _dashscope_request_body(settings: ImageGenerationSettings, prompt: str) -> dict[str, Any]:
|
||||
parameters: dict[str, Any] = {
|
||||
"n": 1,
|
||||
"size": settings.size.replace("x", "*").replace("X", "*"),
|
||||
"watermark": settings.watermark,
|
||||
}
|
||||
# ``prompt_extend`` is a Qwen Image-only parameter. Wan 2.7 rejects
|
||||
# unrelated generation options, so do not send it there.
|
||||
if settings.model.strip().lower().startswith("qwen-image-"):
|
||||
parameters["prompt_extend"] = True
|
||||
return {
|
||||
"model": settings.model,
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"text": prompt}],
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
|
||||
def _dashscope_image_result(payload: object) -> tuple[str, str, str]:
|
||||
"""Return optional base64, image URL and MIME type from native output."""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return "", "", ""
|
||||
output = payload.get("output")
|
||||
choices = output.get("choices") if isinstance(output, dict) else None
|
||||
if not isinstance(choices, list):
|
||||
return "", "", ""
|
||||
for choice in choices:
|
||||
message = choice.get("message") if isinstance(choice, dict) else None
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
encoded = str(part.get("b64_json") or "")
|
||||
raw_image_url = part.get("image") or part.get("image_url")
|
||||
if isinstance(raw_image_url, dict):
|
||||
raw_image_url = raw_image_url.get("url")
|
||||
image_url = str(raw_image_url or "")
|
||||
if encoded or image_url:
|
||||
return encoded, image_url, str(part.get("mime_type") or "")
|
||||
return "", "", ""
|
||||
|
||||
|
||||
async def _download_dashscope_image(
|
||||
client: httpx.AsyncClient,
|
||||
image_url: str,
|
||||
mime_type: str,
|
||||
) -> tuple[bytes, str]:
|
||||
"""Download only an HTTPS model result, never forwarding the API key."""
|
||||
|
||||
parsed = urlparse(image_url)
|
||||
if parsed.scheme != "https" or not parsed.netloc:
|
||||
raise ImageGenerationError("千问图片生成服务返回了不安全的图片地址")
|
||||
response = await client.get(image_url, headers={"Accept": "image/png,image/jpeg,image/webp"})
|
||||
response.raise_for_status()
|
||||
final_url = urlparse(str(response.url))
|
||||
if final_url.scheme != "https" or not final_url.netloc:
|
||||
raise ImageGenerationError("千问图片下载地址不安全")
|
||||
response_mime_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||||
return response.content, response_mime_type or mime_type
|
||||
|
||||
|
||||
def _decode_image_base64(encoded: str) -> bytes:
|
||||
if encoded.startswith("data:") and "," in encoded:
|
||||
encoded = encoded.split(",", 1)[1]
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise ImageGenerationError("图片生成服务返回的图片编码无效") from exc
|
||||
|
||||
|
||||
def _normalise_mime_type(value: str) -> str:
|
||||
mime_type = value.lower()
|
||||
return mime_type if mime_type in {"image/png", "image/jpeg", "image/webp"} else "image/png"
|
||||
|
||||
|
||||
def _http_error_message(response: httpx.Response) -> str:
|
||||
"""Expose a short provider message without leaking credentials or payloads."""
|
||||
|
||||
detail = ""
|
||||
try:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
source = error if isinstance(error, dict) else payload
|
||||
message = source.get("message") if isinstance(source, dict) else None
|
||||
if isinstance(message, str) and message.strip():
|
||||
detail = f":{message.strip()[:300]}"
|
||||
except ValueError:
|
||||
pass
|
||||
return f"图片生成服务返回 HTTP {response.status_code}{detail}"
|
||||
|
||||
|
||||
def build_image_provider() -> ImageProvider:
|
||||
"""Build the optional provider from server config, or a safe no-op."""
|
||||
settings = ImageGenerationSettings.from_app_config()
|
||||
if not settings.is_configured:
|
||||
return NullImageProvider()
|
||||
if settings.provider == "dashscope_native":
|
||||
return DashScopeNativeImageProvider(settings)
|
||||
return OpenAICompatibleImageProvider(settings)
|
||||
|
||||
|
||||
@@ -229,5 +427,6 @@ __all__ = [
|
||||
"ImageProvider",
|
||||
"NullImageProvider",
|
||||
"OpenAICompatibleImageProvider",
|
||||
"DashScopeNativeImageProvider",
|
||||
"build_image_provider",
|
||||
]
|
||||
+46
-22
@@ -33,6 +33,7 @@ from deerflow.agents.deep_research.cancellation import CancellationToken
|
||||
from deerflow.agents.deep_research.config import DeepResearchConfig, ModelRole
|
||||
from deerflow.agents.deep_research.events import EventSink
|
||||
from deerflow.agents.deep_research.types import CompletionResult
|
||||
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain.chat_models import BaseChatModel
|
||||
@@ -83,6 +84,16 @@ def _content_to_text(resp: Any) -> str:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict):
|
||||
# Never fall through to ``content`` for structured reasoning
|
||||
# blocks. Providers differ here, but those blocks are not
|
||||
# report prose and must not reach ReportChunkEmitter.
|
||||
if block.get("type") in {
|
||||
"thinking",
|
||||
"thinking_delta",
|
||||
"reasoning",
|
||||
"reasoning_delta",
|
||||
}:
|
||||
continue
|
||||
parts.append(str(block.get("text") or block.get("content") or ""))
|
||||
return "".join(parts)
|
||||
return str(content or "")
|
||||
@@ -99,7 +110,20 @@ def _reasoning_delta(chunk: Any) -> str:
|
||||
"""
|
||||
ak = getattr(chunk, "additional_kwargs", None) or {}
|
||||
val = ak.get("reasoning_content") or ak.get("reasoning") or ""
|
||||
return val if isinstance(val, str) else ""
|
||||
if isinstance(val, str) and val:
|
||||
return val
|
||||
# Anthropic/OpenAI-compatible adapters may put the same increment in an
|
||||
# explicitly typed content block instead of ``additional_kwargs``.
|
||||
content = getattr(chunk, "content", None)
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
str(block.get("text") or block.get("content") or "")
|
||||
for block in content
|
||||
if isinstance(block, dict)
|
||||
and block.get("type") in {"thinking", "thinking_delta", "reasoning", "reasoning_delta"}
|
||||
]
|
||||
return "".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_usage(resp: Any) -> dict[str, Any]:
|
||||
@@ -234,9 +258,7 @@ class DeerFlowCompletionBackend:
|
||||
parsed = _try_parse_structured(text, response_schema)
|
||||
if parsed is not None:
|
||||
await self._record_usage(model_name, operation, usage)
|
||||
return CompletionResult(
|
||||
text=text, model=model_name, usage=usage, structured=parsed
|
||||
)
|
||||
return CompletionResult(text=text, model=model_name, usage=usage, structured=parsed)
|
||||
if attempt == 0:
|
||||
# One repair re-prompt (§9.5).
|
||||
logger.warning("deep_research llm: JSON parse failed for %s, retrying", operation)
|
||||
@@ -306,13 +328,20 @@ class DeerFlowCompletionBackend:
|
||||
|
||||
text_parts: list[str] = []
|
||||
usage: dict[str, Any] = {}
|
||||
inline_think_filter = InlineThinkTagFilter()
|
||||
async for chunk in model.astream(to_langchain_messages(messages), **kwargs):
|
||||
if self._cancel is not None:
|
||||
self._cancel.raise_if_cancelled()
|
||||
reasoning = _reasoning_delta(chunk)
|
||||
if reasoning and on_reasoning is not None:
|
||||
await on_reasoning(reasoning)
|
||||
delta = _content_to_text(chunk)
|
||||
# A few OpenAI-compatible models put their reasoning in the
|
||||
# ordinary content channel instead of ``reasoning_content``.
|
||||
# Split it from Markdown even when tags span tokens: callers may
|
||||
# display a live thought trace, but it never joins ``text``.
|
||||
delta, inline_reasoning = inline_think_filter.push_parts(_content_to_text(chunk))
|
||||
if inline_reasoning and on_reasoning is not None:
|
||||
await on_reasoning(inline_reasoning)
|
||||
if delta:
|
||||
text_parts.append(delta)
|
||||
if on_delta is not None:
|
||||
@@ -321,6 +350,14 @@ class DeerFlowCompletionBackend:
|
||||
if any(chunk_usage.values()):
|
||||
usage = chunk_usage
|
||||
|
||||
tail, inline_reasoning_tail = inline_think_filter.finish_parts()
|
||||
if inline_reasoning_tail and on_reasoning is not None:
|
||||
await on_reasoning(inline_reasoning_tail)
|
||||
if tail:
|
||||
text_parts.append(tail)
|
||||
if on_delta is not None:
|
||||
await on_delta(tail)
|
||||
|
||||
await self._record_usage(model_name, operation, usage)
|
||||
return CompletionResult(text="".join(text_parts), model=model_name, usage=usage)
|
||||
|
||||
@@ -328,9 +365,7 @@ class DeerFlowCompletionBackend:
|
||||
|
||||
async def _record_usage(self, model_name: str, operation: str, usage: dict[str, Any]) -> None:
|
||||
"""Accumulate per-model totals + emit a ``model_usage`` event."""
|
||||
bucket = self._usage_totals.setdefault(
|
||||
model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
)
|
||||
bucket = self._usage_totals.setdefault(model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
||||
for k in bucket:
|
||||
bucket[k] += int(usage.get(k, 0) or 0)
|
||||
|
||||
@@ -390,10 +425,7 @@ def _schema_instructions(schema: type[BaseModel]) -> str:
|
||||
"""Build a compact "respond as JSON matching this schema" instruction."""
|
||||
props = schema.model_json_schema().get("properties", {})
|
||||
fields = ", ".join(f'"{k}"' for k in props)
|
||||
return (
|
||||
"Respond with ONLY a valid JSON object (no markdown, no prose) "
|
||||
f"containing these keys: {fields}."
|
||||
)
|
||||
return f"Respond with ONLY a valid JSON object (no markdown, no prose) containing these keys: {fields}."
|
||||
|
||||
|
||||
def _inject_schema_hint(messages: list[BaseMessage], hint: str) -> list[BaseMessage]:
|
||||
@@ -403,21 +435,13 @@ def _inject_schema_hint(messages: list[BaseMessage], hint: str) -> list[BaseMess
|
||||
return [*messages, SystemMessage(content=hint)]
|
||||
|
||||
|
||||
def _inject_repair_hint(
|
||||
messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]
|
||||
) -> list[BaseMessage]:
|
||||
def _inject_repair_hint(messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]) -> list[BaseMessage]:
|
||||
"""Re-prompt: 'your last reply was invalid JSON, fix it'."""
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
return [
|
||||
*messages,
|
||||
HumanMessage(
|
||||
content=(
|
||||
"Your previous reply was not valid JSON and could not be parsed:\n"
|
||||
f"{bad_text[:500]}\n\n"
|
||||
"Please respond again with ONLY a valid JSON object."
|
||||
)
|
||||
),
|
||||
HumanMessage(content=(f"Your previous reply was not valid JSON and could not be parsed:\n{bad_text[:500]}\n\nPlease respond again with ONLY a valid JSON object.")),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+5
@@ -63,6 +63,11 @@ ALL_PHASES: tuple[ResearchPhase, ...] = (
|
||||
|
||||
EventType = Literal[
|
||||
"job_status",
|
||||
# Terminal events are kept separate from job_status for the historical
|
||||
# event timeline. The executor persists these after the DB state has
|
||||
# been committed, so a reconnecting browser receives an explicit end.
|
||||
"job_completed",
|
||||
"job_failed",
|
||||
"phase_changed",
|
||||
"plan_created",
|
||||
"queries_planned",
|
||||
|
||||
+1
@@ -364,6 +364,7 @@ class BasicRunner:
|
||||
messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
|
||||
operation="write_report",
|
||||
on_delta=emitter.add,
|
||||
on_reasoning=emitter.add_thinking,
|
||||
)
|
||||
await emitter.flush()
|
||||
return result.text
|
||||
|
||||
+3
@@ -338,6 +338,7 @@ class DetailedRunner(BasicRunner):
|
||||
],
|
||||
operation="write_detailed_introduction",
|
||||
on_delta=emitter.add,
|
||||
on_reasoning=emitter.add_thinking,
|
||||
)
|
||||
await emitter.flush()
|
||||
return result.text
|
||||
@@ -373,6 +374,7 @@ class DetailedRunner(BasicRunner):
|
||||
],
|
||||
operation="write_detailed_section",
|
||||
on_delta=emitter.add,
|
||||
on_reasoning=emitter.add_thinking,
|
||||
)
|
||||
await emitter.flush()
|
||||
return result.text
|
||||
@@ -389,6 +391,7 @@ class DetailedRunner(BasicRunner):
|
||||
],
|
||||
operation="write_detailed_conclusion",
|
||||
on_delta=emitter.add,
|
||||
on_reasoning=emitter.add_thinking,
|
||||
)
|
||||
await emitter.flush()
|
||||
return result.text
|
||||
|
||||
+20
-3
@@ -64,6 +64,7 @@ async def generate_report_images(
|
||||
)
|
||||
image_paths: dict[str, str] = {}
|
||||
amended_report = report_markdown
|
||||
total = len(sections)
|
||||
|
||||
for position, section in enumerate(sections, 1):
|
||||
if cancellation is not None:
|
||||
@@ -72,7 +73,7 @@ async def generate_report_images(
|
||||
await adapters.events.emit(
|
||||
"image_generation_started",
|
||||
phase="illustrating",
|
||||
payload={"section": section, "position": position},
|
||||
payload={"section": section, "position": position, "total": total},
|
||||
)
|
||||
try:
|
||||
generated = await adapters.images.generate(
|
||||
@@ -95,13 +96,26 @@ async def generate_report_images(
|
||||
await adapters.events.emit(
|
||||
"image_generated",
|
||||
phase="illustrating",
|
||||
payload={"section": section, "position": position, "name": name, "alt": alt},
|
||||
payload={
|
||||
"section": section,
|
||||
"position": position,
|
||||
"total": total,
|
||||
"name": name,
|
||||
"alt": alt,
|
||||
},
|
||||
)
|
||||
except ImageGenerationError as exc:
|
||||
await adapters.events.emit(
|
||||
"warning",
|
||||
phase="illustrating",
|
||||
payload={"code": "image_generation_failed", "message": str(exc), "recoverable": True},
|
||||
payload={
|
||||
"code": "image_generation_failed",
|
||||
"message": str(exc),
|
||||
"recoverable": True,
|
||||
"section": section,
|
||||
"position": position,
|
||||
"total": total,
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
# Third-party image APIs and artifact storage must never turn a
|
||||
@@ -114,6 +128,9 @@ async def generate_report_images(
|
||||
"code": "image_generation_failed",
|
||||
"message": "报告配图生成失败,已继续保留文字报告",
|
||||
"recoverable": True,
|
||||
"section": section,
|
||||
"position": position,
|
||||
"total": total,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+1
@@ -321,6 +321,7 @@ class MultiAgentRunner(BasicRunner):
|
||||
],
|
||||
operation="revise_multi_agent_report",
|
||||
on_delta=emitter.add,
|
||||
on_reasoning=emitter.add_thinking,
|
||||
)
|
||||
await emitter.flush()
|
||||
return {"report": result.text, "revision_count": int(state.get("revision_count") or 0) + 1}
|
||||
|
||||
+14
@@ -64,6 +64,20 @@ class ReportChunkEmitter:
|
||||
await self._emit(self._buffer[: self._chunk_size])
|
||||
self._buffer = self._buffer[self._chunk_size :]
|
||||
|
||||
async def add_thinking(self, delta: str) -> None:
|
||||
"""Forward reasoning to the live UI without persisting it as report text."""
|
||||
if not delta:
|
||||
return
|
||||
await self._events.publish_live(
|
||||
"report_thinking",
|
||||
phase=self._phase,
|
||||
payload={
|
||||
"delta": delta,
|
||||
"target": self._target,
|
||||
"reportVersion": self._report_version,
|
||||
},
|
||||
)
|
||||
|
||||
async def flush(self) -> None:
|
||||
"""Persist the final tail once the model stream has finished."""
|
||||
if self._buffer:
|
||||
|
||||
+162
-112
@@ -27,14 +27,15 @@ from typing import Any, Protocol, override, runtime_checkable
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import SummarizationMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
AnyMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
message_to_dict,
|
||||
messages_from_dict,
|
||||
)
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
AnyMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
message_to_dict,
|
||||
messages_from_dict,
|
||||
)
|
||||
from langgraph.config import get_config
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
@@ -145,8 +146,8 @@ class _SummaryCacheEntry:
|
||||
|
||||
summarized_ids: frozenset[str]
|
||||
summary_text: str
|
||||
preserved_ids: frozenset[str] = frozenset()
|
||||
preserved_messages: tuple[AnyMessage, ...] = ()
|
||||
preserved_ids: frozenset[str] = frozenset()
|
||||
preserved_messages: tuple[AnyMessage, ...] = ()
|
||||
|
||||
|
||||
# Process-wide summary cache shared across every middleware instance.
|
||||
@@ -160,51 +161,99 @@ class _SummaryCacheEntry:
|
||||
# so only the first threshold crossing (or a genuine re-compaction) pays for an
|
||||
# LLM call. A lock guards the non-atomic LRU eviction against concurrent
|
||||
# threads (subagents / parallel conversations share this process).
|
||||
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
|
||||
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
|
||||
_STATE_CACHE_KEY = "context_summary_cache"
|
||||
|
||||
|
||||
def _clear_global_summary_cache() -> None:
|
||||
"""Drop every cached summary. Intended for tests/diagnostics only."""
|
||||
with _GLOBAL_SUMMARY_CACHE_LOCK:
|
||||
_GLOBAL_SUMMARY_CACHE.clear()
|
||||
|
||||
|
||||
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
|
||||
"""Serialize a summary cache entry so it can live in LangGraph state."""
|
||||
return {
|
||||
"version": 1,
|
||||
"summarized_ids": sorted(entry.summarized_ids),
|
||||
"summary_text": entry.summary_text,
|
||||
"preserved_ids": sorted(entry.preserved_ids),
|
||||
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
|
||||
"""Best-effort restore of a state-backed summary cache entry."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
summary_text = raw.get("summary_text")
|
||||
if not isinstance(summary_text, str) or not summary_text:
|
||||
return None
|
||||
summarized_ids = raw.get("summarized_ids") or []
|
||||
preserved_ids = raw.get("preserved_ids") or []
|
||||
preserved_messages_raw = raw.get("preserved_messages") or []
|
||||
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
|
||||
return None
|
||||
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
|
||||
return _SummaryCacheEntry(
|
||||
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
|
||||
summary_text=summary_text,
|
||||
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
|
||||
preserved_messages=preserved_messages,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
|
||||
return None
|
||||
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
|
||||
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
|
||||
_STATE_CACHE_KEY = "context_summary_cache"
|
||||
|
||||
|
||||
def _clear_global_summary_cache() -> None:
|
||||
"""Drop every cached summary. Intended for tests/diagnostics only."""
|
||||
with _GLOBAL_SUMMARY_CACHE_LOCK:
|
||||
_GLOBAL_SUMMARY_CACHE.clear()
|
||||
|
||||
|
||||
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
|
||||
"""Serialize a summary cache entry so it can live in LangGraph state."""
|
||||
return {
|
||||
"version": 1,
|
||||
"summarized_ids": sorted(entry.summarized_ids),
|
||||
"summary_text": entry.summary_text,
|
||||
"preserved_ids": sorted(entry.preserved_ids),
|
||||
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
|
||||
}
|
||||
|
||||
|
||||
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
|
||||
"""Best-effort restore of a state-backed summary cache entry."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
summary_text = raw.get("summary_text")
|
||||
if not isinstance(summary_text, str) or not summary_text:
|
||||
return None
|
||||
summarized_ids = raw.get("summarized_ids") or []
|
||||
preserved_ids = raw.get("preserved_ids") or []
|
||||
preserved_messages_raw = raw.get("preserved_messages") or []
|
||||
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
|
||||
return None
|
||||
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
|
||||
return _SummaryCacheEntry(
|
||||
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
|
||||
summary_text=summary_text,
|
||||
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
|
||||
preserved_messages=preserved_messages,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _has_nonempty_human_message(message: AnyMessage) -> bool:
|
||||
"""Whether a message is a real, non-empty user turn.
|
||||
|
||||
Some OpenAI-compatible gateways reject a completion request that contains
|
||||
only ``system`` / ``assistant`` / ``tool`` roles. That can legitimately
|
||||
happen after compaction: the active model call is continuing a tool turn,
|
||||
while the user request that started the turn has already moved into the
|
||||
internal summary.
|
||||
"""
|
||||
if not isinstance(message, HumanMessage):
|
||||
return False
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
return any(bool(block.strip()) if isinstance(block, str) else bool((block.get("text") or block.get("content") or "").strip()) if isinstance(block, dict) else False for block in content)
|
||||
return bool(str(content).strip())
|
||||
|
||||
|
||||
def _ensure_latest_human_message(
|
||||
source_messages: list[AnyMessage],
|
||||
compressed_messages: list[AnyMessage],
|
||||
) -> list[AnyMessage]:
|
||||
"""Keep the latest real user request visible to strict chat gateways.
|
||||
|
||||
The message is inserted after leading system messages, so it neither
|
||||
exposes the internal summary to the UI nor splits an AI tool-call from its
|
||||
following ToolMessages. When there was no user turn in the source at all,
|
||||
do not fabricate one: that is an upstream caller bug and should remain
|
||||
diagnosable rather than silently changing the task.
|
||||
"""
|
||||
if any(_has_nonempty_human_message(message) for message in compressed_messages):
|
||||
return compressed_messages
|
||||
|
||||
latest_human = next(
|
||||
(message for message in reversed(source_messages) if _has_nonempty_human_message(message)),
|
||||
None,
|
||||
)
|
||||
if latest_human is None:
|
||||
logger.warning("summarization: compressed model request has no user message and source history contains none; passing it through unchanged")
|
||||
return compressed_messages
|
||||
|
||||
insert_at = 0
|
||||
while insert_at < len(compressed_messages) and isinstance(compressed_messages[insert_at], SystemMessage):
|
||||
insert_at += 1
|
||||
return [*compressed_messages[:insert_at], latest_human, *compressed_messages[insert_at:]]
|
||||
|
||||
|
||||
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
@@ -231,8 +280,8 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
|
||||
# Points at the *process-wide* cache (not a fresh per-instance dict) so a
|
||||
# summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``.
|
||||
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
|
||||
self._pending_state_cache_update: dict[str, Any] | None = None
|
||||
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
|
||||
self._pending_state_cache_update: dict[str, Any] | None = None
|
||||
|
||||
@override
|
||||
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
@@ -248,21 +297,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
"""Async counterpart of :meth:`before_model` — also a no-op."""
|
||||
return None
|
||||
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
"""Persist the latest reusable summary cache into thread state."""
|
||||
if self._pending_state_cache_update is None:
|
||||
return None
|
||||
update = self._pending_state_cache_update
|
||||
self._pending_state_cache_update = None
|
||||
return {_STATE_CACHE_KEY: update}
|
||||
|
||||
@override
|
||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
"""Async counterpart of :meth:`after_model`."""
|
||||
return self.after_model(state, runtime)
|
||||
|
||||
# ---- wrap_model_call: transient, display-preserving compression ----------
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
"""Persist the latest reusable summary cache into thread state."""
|
||||
if self._pending_state_cache_update is None:
|
||||
return None
|
||||
update = self._pending_state_cache_update
|
||||
self._pending_state_cache_update = None
|
||||
return {_STATE_CACHE_KEY: update}
|
||||
|
||||
@override
|
||||
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
"""Async counterpart of :meth:`after_model`."""
|
||||
return self.after_model(state, runtime)
|
||||
|
||||
# ---- wrap_model_call: transient, display-preserving compression ----------
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
@@ -376,20 +425,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
return None
|
||||
|
||||
thread_id = self._resolve_request_thread_id(request)
|
||||
if thread_id is None:
|
||||
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
|
||||
cached = self._get_cache_entry(thread_id) if thread_id else None
|
||||
if cached is None:
|
||||
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
|
||||
if cached is not None and thread_id:
|
||||
self._store_cache_entry(thread_id, cached)
|
||||
present_ids = {m.id for m in messages}
|
||||
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
|
||||
if cache_usable:
|
||||
preserved = list(cached.preserved_messages)
|
||||
excluded_ids = cached.summarized_ids | cached.preserved_ids
|
||||
preserved.extend(m for m in messages if m.id not in excluded_ids)
|
||||
effective = [self._summary_message(cached.summary_text), *preserved]
|
||||
if thread_id is None:
|
||||
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
|
||||
cached = self._get_cache_entry(thread_id) if thread_id else None
|
||||
if cached is None:
|
||||
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
|
||||
if cached is not None and thread_id:
|
||||
self._store_cache_entry(thread_id, cached)
|
||||
present_ids = {m.id for m in messages}
|
||||
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
|
||||
if cache_usable:
|
||||
preserved = list(cached.preserved_messages)
|
||||
excluded_ids = cached.summarized_ids | cached.preserved_ids
|
||||
preserved.extend(m for m in messages if m.id not in excluded_ids)
|
||||
effective = [self._summary_message(cached.summary_text), *preserved]
|
||||
effective = _ensure_latest_human_message(messages, effective)
|
||||
# Decide re-summarization on the *counted* size of the compressed
|
||||
# context only — NOT ``_should_summarize`` — because that folds in the
|
||||
# last AIMessage's reported ``usage_metadata`` (system prompt + tool
|
||||
@@ -400,8 +450,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
# Refresh LRU recency and reuse without an LLM call.
|
||||
self._touch_cache_entry(thread_id)
|
||||
logger.info(
|
||||
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary "
|
||||
"thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
|
||||
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
|
||||
thread_id,
|
||||
self.token_counter(messages),
|
||||
self.token_counter(effective),
|
||||
@@ -415,7 +464,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
# an "it compacts every turn" report is diagnosable straight from the
|
||||
# backend log: cache=miss → thread_id/key problem; unusable → message-id
|
||||
# instability; stale → preserved genuinely grew back over the trigger.
|
||||
cache_state = "miss" if cached is None else "stale"
|
||||
cache_state = "miss" if cached is None else "stale"
|
||||
logger.info(
|
||||
"summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d",
|
||||
thread_id,
|
||||
@@ -452,22 +501,23 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
) -> list[AnyMessage]:
|
||||
"""Assemble the compressed message list and update the per-thread cache."""
|
||||
compressed = [self._summary_message(summary), *preserved]
|
||||
compressed = _ensure_latest_human_message([*messages_to_summarize, *preserved], compressed)
|
||||
|
||||
if thread_id:
|
||||
summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None)
|
||||
preserved_ids = {m.id for m in preserved if m.id is not None}
|
||||
self._store_cache_entry(
|
||||
thread_id,
|
||||
entry := _SummaryCacheEntry(
|
||||
summarized_ids=summarized_ids,
|
||||
summary_text=summary,
|
||||
preserved_ids=frozenset(preserved_ids),
|
||||
preserved_messages=tuple(preserved),
|
||||
),
|
||||
)
|
||||
self._pending_state_cache_update = _serialize_cache_entry(entry)
|
||||
|
||||
return compressed
|
||||
self._store_cache_entry(
|
||||
thread_id,
|
||||
entry := _SummaryCacheEntry(
|
||||
summarized_ids=summarized_ids,
|
||||
summary_text=summary,
|
||||
preserved_ids=frozenset(preserved_ids),
|
||||
preserved_messages=tuple(preserved),
|
||||
),
|
||||
)
|
||||
self._pending_state_cache_update = _serialize_cache_entry(entry)
|
||||
|
||||
return compressed
|
||||
|
||||
def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None:
|
||||
"""Return the cached entry for ``thread_id`` (thread-safe read)."""
|
||||
@@ -571,7 +621,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
return None
|
||||
return _resolve_thread_id(runtime)
|
||||
|
||||
def _summary_message(self, summary: str) -> SystemMessage:
|
||||
def _summary_message(self, summary: str) -> SystemMessage:
|
||||
"""Build the summary message handed to the model.
|
||||
|
||||
It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because
|
||||
@@ -579,21 +629,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
state), the frontend never receives it — the user keeps seeing the
|
||||
original conversation.
|
||||
"""
|
||||
return SystemMessage(
|
||||
content=(
|
||||
"Internal conversation summary for continuity only. Use it silently "
|
||||
"to answer the user's latest request. Never reveal, quote, mention, "
|
||||
"or describe this summary or the fact that context was compacted.\n\n"
|
||||
"<internal_context_summary>\n"
|
||||
f"{summary}\n"
|
||||
"</internal_context_summary>"
|
||||
),
|
||||
return SystemMessage(
|
||||
content=(
|
||||
"Internal conversation summary for continuity only. Use it silently "
|
||||
"to answer the user's latest request. Never reveal, quote, mention, "
|
||||
"or describe this summary or the fact that context was compacted.\n\n"
|
||||
"<internal_context_summary>\n"
|
||||
f"{summary}\n"
|
||||
"</internal_context_summary>"
|
||||
),
|
||||
name="summary",
|
||||
additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"},
|
||||
)
|
||||
|
||||
@override
|
||||
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
|
||||
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
|
||||
"""Kept for backwards compatibility; delegates to :meth:`_summary_message`."""
|
||||
return [self._summary_message(summary)]
|
||||
|
||||
|
||||
+16
-35
@@ -68,13 +68,7 @@ class DeepResearchEventRepository:
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
async with self._sf() as session:
|
||||
current_max = (
|
||||
await session.execute(
|
||||
select(func.max(DeepResearchEventRow.seq)).where(
|
||||
DeepResearchEventRow.job_id == job_id
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
current_max = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar() or 0
|
||||
next_seq = int(current_max) + 1
|
||||
row = DeepResearchEventRow(
|
||||
job_id=job_id,
|
||||
@@ -86,49 +80,38 @@ class DeepResearchEventRepository:
|
||||
)
|
||||
session.add(row)
|
||||
try:
|
||||
# Do not issue ``session.refresh(row)`` after commit. On a
|
||||
# MySQL read/write-splitting deployment that read can land
|
||||
# on a lagging replica, which cannot see the just-committed
|
||||
# event and raises ``Could not refresh instance``. Flush
|
||||
# assigns generated primary-key/default values on the writer
|
||||
# connection, so the plain dict is complete before commit.
|
||||
await session.flush()
|
||||
result = self._row_to_dict(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
return result
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
continue # retry with recomputed max
|
||||
raise RuntimeError(
|
||||
f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})"
|
||||
)
|
||||
raise RuntimeError(f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})")
|
||||
|
||||
async def list_after(
|
||||
self, job_id: str, *, after: int = 0, limit: int = 200
|
||||
) -> list[dict[str, Any]]:
|
||||
async def list_after(self, job_id: str, *, after: int = 0, limit: int = 200) -> list[dict[str, Any]]:
|
||||
"""Events with ``seq > after`` in ascending order (SSE replay, §15.3)."""
|
||||
async with self._sf() as session:
|
||||
stmt = (
|
||||
select(DeepResearchEventRow)
|
||||
.where(DeepResearchEventRow.job_id == job_id)
|
||||
.where(DeepResearchEventRow.seq > after)
|
||||
.order_by(DeepResearchEventRow.seq.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
stmt = select(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id).where(DeepResearchEventRow.seq > after).order_by(DeepResearchEventRow.seq.asc()).limit(limit)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return [self._row_to_dict(r) for r in rows]
|
||||
|
||||
async def last_seq(self, job_id: str) -> int:
|
||||
async with self._sf() as session:
|
||||
mx = (
|
||||
await session.execute(
|
||||
select(func.max(DeepResearchEventRow.seq)).where(
|
||||
DeepResearchEventRow.job_id == job_id
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
mx = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar()
|
||||
return int(mx or 0)
|
||||
|
||||
async def delete_by_job(self, job_id: str) -> int:
|
||||
from sqlalchemy import delete
|
||||
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id)
|
||||
)
|
||||
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id))
|
||||
await session.commit()
|
||||
return result.rowcount
|
||||
|
||||
@@ -136,8 +119,6 @@ class DeepResearchEventRepository:
|
||||
from sqlalchemy import delete
|
||||
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id)
|
||||
)
|
||||
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id))
|
||||
await session.commit()
|
||||
return result.rowcount
|
||||
+26
-12
@@ -113,9 +113,18 @@ class DeepResearchJobRepository:
|
||||
phase=phase,
|
||||
)
|
||||
session.add(row)
|
||||
# Do not issue ``session.refresh(row)`` after commit. A
|
||||
# MySQL read/write-splitting proxy can route that read to
|
||||
# a lagging replica, even though the INSERT has already
|
||||
# committed on the writer. The caller would receive 500
|
||||
# while an invisible queued job remains in the database.
|
||||
# ``flush`` materializes ORM defaults on the writer, so we
|
||||
# can return a complete, durable snapshot without a
|
||||
# replica-dependent readback.
|
||||
await session.flush()
|
||||
result = self._row_to_dict(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row), True
|
||||
return result, True
|
||||
except IntegrityError:
|
||||
# Unique-index conflict → an active job already exists.
|
||||
pass
|
||||
@@ -230,13 +239,14 @@ class DeepResearchJobRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
# ── heartbeat ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -351,13 +361,14 @@ class DeepResearchJobRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
# ── two-phase cancel ────────────────────────────────────────────────────
|
||||
|
||||
@@ -385,13 +396,14 @@ class DeepResearchJobRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
async def finalize_cancel(
|
||||
self, job_id: str, *, lease_owner: str | None = None
|
||||
@@ -419,13 +431,14 @@ class DeepResearchJobRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
# ── resume after human-review interrupt ─────────────────────────────────
|
||||
|
||||
@@ -460,13 +473,14 @@ class DeepResearchJobRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
# ── cleanup ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+9
-4
@@ -74,9 +74,13 @@ class DeepResearchMessageRepository:
|
||||
seq=int(current_max) + 1,
|
||||
)
|
||||
session.add(row)
|
||||
# Keep the successful append independent of a later replica read.
|
||||
# The explicit id/seq and Python-side defaults are available after
|
||||
# flush, before the transaction releases its writer connection.
|
||||
await session.flush()
|
||||
snapshot = self._row_to_dict(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
return snapshot
|
||||
|
||||
async def list_by_session(
|
||||
self, session_id: str, *, user_id: str | _AutoSentinel = AUTO, limit: int = 100
|
||||
@@ -139,10 +143,11 @@ class DeepResearchMessageRepository:
|
||||
if not result.rowcount:
|
||||
await session.rollback()
|
||||
return None
|
||||
await session.commit()
|
||||
stmt = select(DeepResearchMessageRow).where(*where)
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row is not None else None
|
||||
snapshot = self._row_to_dict(row) if row is not None else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
async def delete_by_session(self, session_id: str) -> int:
|
||||
from sqlalchemy import delete
|
||||
|
||||
+12
-6
@@ -85,9 +85,13 @@ class DeepResearchSessionRepository:
|
||||
runtime_thread_id=runtime_thread_id,
|
||||
)
|
||||
session.add(row)
|
||||
# New sessions are a control-plane write. Avoid a post-commit
|
||||
# refresh because MySQL read/write-splitting can make a just
|
||||
# created session look absent on a lagging replica.
|
||||
await session.flush()
|
||||
snapshot = self._row_to_dict(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
return snapshot
|
||||
|
||||
# ── read ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -187,13 +191,14 @@ class DeepResearchSessionRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
async def replace_report_if_unchanged(
|
||||
self,
|
||||
@@ -231,13 +236,14 @@ class DeepResearchSessionRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
# ── delete ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+8
-1
@@ -23,6 +23,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
class DeepResearchSourceRow(Base):
|
||||
__tablename__ = "deep_research_sources"
|
||||
__table_args__ = (
|
||||
# MySQL requires a prefix length when indexing TEXT columns. The
|
||||
# column remains unbounded so long URLs are preserved; the prefix is
|
||||
# only an index implementation detail and equality filters still
|
||||
# compare the complete URL value.
|
||||
sa.Index("ix_deep_research_sources_url", "url", mysql_length=512),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
@@ -32,7 +39,7 @@ class DeepResearchSourceRow(Base):
|
||||
# The query that first surfaced this material.
|
||||
first_seen_query: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
url: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_content: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="")
|
||||
snippet: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(256), nullable=False, default="")
|
||||
|
||||
+10
-4
@@ -37,9 +37,14 @@ class DeepResearchSourceRepository:
|
||||
row = DeepResearchSourceRow(session_id=session_id, job_id=job_id, **fields)
|
||||
session.add(row)
|
||||
try:
|
||||
# The source was produced on the worker's write path. Build
|
||||
# its response on that same connection so a replica-lag read
|
||||
# cannot turn a successfully collected source into a failed
|
||||
# research run.
|
||||
await session.flush()
|
||||
snapshot = self._row_to_dict(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._row_to_dict(row)
|
||||
return snapshot
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# Fallback: find existing by rec_uuid or url.
|
||||
@@ -147,13 +152,14 @@ class DeepResearchSourceRepository:
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
return None
|
||||
await session.commit()
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DeepResearchSourceRow).where(DeepResearchSourceRow.id == source_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self._row_to_dict(row) if row else None
|
||||
snapshot = self._row_to_dict(row) if row else None
|
||||
await session.commit()
|
||||
return snapshot
|
||||
|
||||
async def delete_by_session(self, session_id: str) -> int:
|
||||
from sqlalchemy import delete
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Safe helpers for provider text streams.
|
||||
|
||||
Some OpenAI-compatible providers place reasoning in the normal ``content``
|
||||
stream as ``<think>...</think>`` instead of using LangChain's dedicated
|
||||
reasoning fields. Those fragments must be separated from report/file content
|
||||
while callers can still surface them in an explicitly marked live UI trace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class InlineThinkTagFilter:
|
||||
"""Separate inline ``<think>`` blocks while preserving token streaming.
|
||||
|
||||
Tags can be split across arbitrary provider chunks, so regex-per-chunk is
|
||||
not safe: it can leak a partial tag or the model's reasoning into the
|
||||
visible draft. This small state machine holds only the suffix that might
|
||||
still form a tag and emits all confirmed answer text immediately.
|
||||
"""
|
||||
|
||||
_OPEN_TAG = "<think>"
|
||||
_CLOSE_TAG = "</think>"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._inside_think = False
|
||||
self._pending = ""
|
||||
|
||||
@staticmethod
|
||||
def _possible_tag_prefix_size(content: str, tag: str) -> int:
|
||||
"""Return the longest suffix which may become the next tag."""
|
||||
lowered = content.lower()
|
||||
for size in range(min(len(content), len(tag) - 1), 0, -1):
|
||||
if lowered[-size:] == tag[:size]:
|
||||
return size
|
||||
return 0
|
||||
|
||||
def push_parts(self, chunk: str) -> tuple[str, str]:
|
||||
"""Return ``(visible_text, thinking_text)`` for one provider chunk."""
|
||||
if not chunk:
|
||||
return "", ""
|
||||
|
||||
remaining = self._pending + chunk
|
||||
self._pending = ""
|
||||
visible: list[str] = []
|
||||
thinking: list[str] = []
|
||||
|
||||
while remaining:
|
||||
tag = self._CLOSE_TAG if self._inside_think else self._OPEN_TAG
|
||||
marker_at = remaining.lower().find(tag)
|
||||
if marker_at >= 0:
|
||||
(thinking if self._inside_think else visible).append(remaining[:marker_at])
|
||||
remaining = remaining[marker_at + len(tag) :]
|
||||
self._inside_think = not self._inside_think
|
||||
continue
|
||||
|
||||
suffix_size = self._possible_tag_prefix_size(remaining, tag)
|
||||
confirmed = remaining[:-suffix_size] if suffix_size else remaining
|
||||
(thinking if self._inside_think else visible).append(confirmed)
|
||||
self._pending = remaining[-suffix_size:] if suffix_size else ""
|
||||
break
|
||||
|
||||
return "".join(visible), "".join(thinking)
|
||||
|
||||
def push(self, chunk: str) -> str:
|
||||
"""Compatibility helper returning only confirmed visible text."""
|
||||
return self.push_parts(chunk)[0]
|
||||
|
||||
def finish_parts(self) -> tuple[str, str]:
|
||||
"""Flush the remaining visible or thinking text at end of stream."""
|
||||
if self._inside_think:
|
||||
thinking = self._pending
|
||||
self._pending = ""
|
||||
self._inside_think = False
|
||||
return "", thinking
|
||||
tail = self._pending
|
||||
self._pending = ""
|
||||
return tail, ""
|
||||
|
||||
def finish(self) -> str:
|
||||
"""Compatibility helper returning only the visible stream tail."""
|
||||
return self.finish_parts()[0]
|
||||
|
||||
|
||||
def strip_inline_think_tags(content: str) -> str:
|
||||
"""Return complete text with inline model-thinking blocks removed."""
|
||||
text_filter = InlineThinkTagFilter()
|
||||
return text_filter.push(content) + text_filter.finish()
|
||||
|
||||
|
||||
__all__ = ["InlineThinkTagFilter", "strip_inline_think_tags"]
|
||||
@@ -38,17 +38,27 @@ class _ChainStore:
|
||||
return self.chain
|
||||
|
||||
|
||||
class _AgentStore:
|
||||
def __init__(self, agents: dict[str, dict[str, object]] | None = None) -> None:
|
||||
self.agents = agents or {}
|
||||
|
||||
async def get_any(self, agent_id: str) -> dict[str, object] | None:
|
||||
return self.agents.get(agent_id)
|
||||
|
||||
|
||||
def _request(
|
||||
role: str,
|
||||
chain: dict[str, object] | None = None,
|
||||
*,
|
||||
email: str = "lqq@example.com",
|
||||
agents: dict[str, dict[str, object]] | None = None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
business_mapping_store=_MappingStore(),
|
||||
roundtable_chain_store=_ChainStore(chain),
|
||||
agent_store=_AgentStore(agents),
|
||||
)
|
||||
),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", email=email, system_role=role)),
|
||||
@@ -130,3 +140,34 @@ async def test_global_mapping_rejects_a_private_chain() -> None:
|
||||
|
||||
assert error.value.status_code == 400
|
||||
assert request.app.state.business_mapping_store.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_mapping_rejects_a_chain_with_an_unpublished_agent_seat() -> None:
|
||||
request = _request(
|
||||
"admin",
|
||||
{
|
||||
"id": "chain-8bf",
|
||||
"title": "岗位协同链",
|
||||
"is_public": True,
|
||||
"seats": [
|
||||
{"agent_id": "published", "name": "已发布智能体"},
|
||||
{"agent_id": "private", "name": "未发布智能体"},
|
||||
],
|
||||
},
|
||||
agents={
|
||||
"published": {"id": "published", "user_id": "owner", "published": True, "is_private": False},
|
||||
"private": {"id": "private", "user_id": "owner", "published": False, "is_private": False},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await business_mapping_router.update_mapping(
|
||||
request,
|
||||
"8BF",
|
||||
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
|
||||
)
|
||||
|
||||
assert error.value.status_code == 400
|
||||
assert "未发布智能体" in error.value.detail
|
||||
assert request.app.state.business_mapping_store.calls == []
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Fault-tolerance coverage for Deep Research progress events."""
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.agents.deep_research.adapters.event_sink import PersistingEventSink
|
||||
|
||||
|
||||
class _UnavailableEventRepository:
|
||||
async def append(self, **kwargs): # noqa: ANN003
|
||||
raise RuntimeError("database temporarily unavailable")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_persistence_failure_keeps_job_live_and_emits_unique_fallback_event():
|
||||
published: list[dict] = []
|
||||
|
||||
async def publish(event: dict) -> None:
|
||||
published.append(event)
|
||||
|
||||
sink = PersistingEventSink(
|
||||
_UnavailableEventRepository(),
|
||||
session_id="drs_1",
|
||||
job_id="drj_1",
|
||||
on_event=publish,
|
||||
)
|
||||
|
||||
first = await sink.emit("phase_changed", phase="planning", payload={"to": "planning"})
|
||||
second = await sink.emit("queries_planned", phase="collecting", payload={"queries": ["AI"]})
|
||||
|
||||
assert (first.seq, second.seq) == (-1, -2)
|
||||
assert first.payload["eventPersistenceDegraded"] is True
|
||||
assert published[0]["liveId"] == "event-persistence-fallback:drj_1:1"
|
||||
assert published[1]["liveId"] == "event-persistence-fallback:drj_1:2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_publish_failure_does_not_fail_event_delivery():
|
||||
class _EventRepository:
|
||||
async def append(self, **kwargs): # noqa: ANN003
|
||||
return {
|
||||
"seq": 1,
|
||||
"event_type": kwargs["event_type"],
|
||||
"phase": kwargs["phase"],
|
||||
"payload": kwargs["payload"],
|
||||
}
|
||||
|
||||
async def broken_publisher(event: dict) -> None: # noqa: ARG001
|
||||
raise RuntimeError("browser disconnected")
|
||||
|
||||
sink = PersistingEventSink(
|
||||
_EventRepository(),
|
||||
session_id="drs_1",
|
||||
job_id="drj_1",
|
||||
on_event=broken_publisher,
|
||||
)
|
||||
|
||||
event = await sink.emit("phase_changed", phase="planning")
|
||||
|
||||
assert event.seq == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_rewrite_milestone_is_a_supported_durable_event():
|
||||
"""Guard the artifact-rewrite job path against EventType regressions."""
|
||||
|
||||
class _EventRepository:
|
||||
async def append(self, **kwargs): # noqa: ANN003
|
||||
return {
|
||||
"seq": 1,
|
||||
"event_type": kwargs["event_type"],
|
||||
"phase": kwargs["phase"],
|
||||
"payload": kwargs["payload"],
|
||||
}
|
||||
|
||||
sink = PersistingEventSink(_EventRepository(), session_id="drs_1", job_id="drj_1")
|
||||
|
||||
event = await sink.emit(
|
||||
"document_rewrite",
|
||||
phase="planning",
|
||||
payload={"type": "requirements_started"},
|
||||
)
|
||||
|
||||
assert event.type == "document_rewrite"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Failure-ordering regression coverage for final Deep Research report writes."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway import deep_research_job_executor as executor_module
|
||||
from app.gateway.deep_research_job_executor import DeepResearchJobExecutor
|
||||
from deerflow.agents.deep_research.events import DeepResearchEvent
|
||||
from deerflow.agents.deep_research.types import DeepResearchResult
|
||||
|
||||
|
||||
class _JobStore:
|
||||
def __init__(self, session_store) -> None:
|
||||
self._session_store = session_store
|
||||
self.progress_calls: list[dict] = []
|
||||
|
||||
async def update_progress(self, job_id: str, **fields): # noqa: ARG002
|
||||
assert self._session_store.report_updates, "report must be persisted before job completion"
|
||||
self.progress_calls.append(fields)
|
||||
return {"id": job_id, **fields}
|
||||
|
||||
|
||||
class _SessionStore:
|
||||
def __init__(self, *, available: bool = True) -> None:
|
||||
self.available = available
|
||||
self.report_updates: list[dict] = []
|
||||
|
||||
async def update(self, session_id: str, **fields): # noqa: ARG002
|
||||
if not self.available:
|
||||
raise RuntimeError("temporary session database failure")
|
||||
self.report_updates.append(fields)
|
||||
return {"id": session_id, **fields}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_is_persisted_before_job_is_marked_completed():
|
||||
sessions = _SessionStore()
|
||||
jobs = _JobStore(sessions)
|
||||
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
|
||||
|
||||
await runner._mark_completed(
|
||||
"drj_1",
|
||||
"drs_1",
|
||||
"worker_1",
|
||||
DeepResearchResult(report_markdown="# 报告", source_ids=["src_1"]),
|
||||
)
|
||||
|
||||
assert sessions.report_updates[0]["report_markdown"] == "# 报告"
|
||||
assert jobs.progress_calls[0]["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_projection_failure_never_marks_job_completed(monkeypatch):
|
||||
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_ATTEMPTS", 2)
|
||||
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_DELAY_SECONDS", 0)
|
||||
sessions = _SessionStore(available=False)
|
||||
jobs = _JobStore(sessions)
|
||||
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="研究报告未能保存"):
|
||||
await runner._mark_completed(
|
||||
"drj_1",
|
||||
"drs_1",
|
||||
"worker_1",
|
||||
DeepResearchResult(report_markdown="# 报告"),
|
||||
)
|
||||
|
||||
assert jobs.progress_calls == []
|
||||
|
||||
|
||||
def test_terminal_timeline_events_are_valid_durable_event_types():
|
||||
assert DeepResearchEvent(type="job_completed", phase="done").type == "job_completed"
|
||||
assert DeepResearchEvent(type="job_failed", phase="done").type == "job_failed"
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Persistence tests for deep-research sessions, jobs, events, sources, messages.
|
||||
|
||||
Uses in-memory SQLite + aiosqlite, mirroring ``test_ai_writing_session_repo``.
|
||||
Focuses on the durable-job mechanics that are load-bearing for correctness:
|
||||
|
||||
- Session CRUD + user isolation.
|
||||
- Job ``try_create_or_get_active`` idempotency (unique dedupe key).
|
||||
- Job lease claim / renew / release (atomic CAS).
|
||||
- Two-phase cancel (``request_cancel`` → ``finalize_cancel``).
|
||||
- Event seq monotonicity + ``list_after`` replay (SSE reconnect correctness).
|
||||
|
||||
Pattern: a plain ``async def _make_repos()`` helper (not a fixture) — matches
|
||||
the project convention for async DB setup in pytest-asyncio strict mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
|
||||
async def _make_repos():
|
||||
"""Create an in-memory SQLite repo set with all deep-research tables."""
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.deep_research_events.model import DeepResearchEventRow # noqa: F401
|
||||
from deerflow.persistence.deep_research_events.sql import DeepResearchEventRepository
|
||||
from deerflow.persistence.deep_research_jobs.model import DeepResearchJobRow # noqa: F401
|
||||
from deerflow.persistence.deep_research_jobs.sql import DeepResearchJobRepository
|
||||
from deerflow.persistence.deep_research_messages.model import DeepResearchMessageRow # noqa: F401
|
||||
from deerflow.persistence.deep_research_messages.sql import DeepResearchMessageRepository
|
||||
from deerflow.persistence.deep_research_sessions.model import DeepResearchSessionRow # noqa: F401
|
||||
from deerflow.persistence.deep_research_sessions.sql import DeepResearchSessionRepository
|
||||
from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow # noqa: F401
|
||||
from deerflow.persistence.deep_research_sources.sql import DeepResearchSourceRepository
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||
return {
|
||||
"sessions": DeepResearchSessionRepository(sf),
|
||||
"jobs": DeepResearchJobRepository(sf),
|
||||
"events": DeepResearchEventRepository(sf),
|
||||
"sources": DeepResearchSourceRepository(sf),
|
||||
"messages": DeepResearchMessageRepository(sf),
|
||||
"_engine": engine,
|
||||
}
|
||||
|
||||
|
||||
# ── sessions ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_create_and_get():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
created = await repos["sessions"].create(id="drs_1", user_id="u1", title="Test", query="AI research", mode="deep")
|
||||
assert created["id"] == "drs_1"
|
||||
assert created["status"] == "draft"
|
||||
|
||||
got = await repos["sessions"].get("drs_1", user_id="u1")
|
||||
assert got is not None
|
||||
assert got["query"] == "AI research"
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deep_research_writes_never_depend_on_post_commit_refresh(monkeypatch):
|
||||
"""Control-plane writes must survive a MySQL replica-lag readback."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
|
||||
raise AssertionError("Deep Research writes must not call session.refresh")
|
||||
|
||||
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
session = await repos["sessions"].create(
|
||||
id="drs_1", user_id="u1", title="Test", query="AI research"
|
||||
)
|
||||
assert session["id"] == "drs_1"
|
||||
assert await repos["sessions"].update("drs_1", user_id="u1", status="running")
|
||||
|
||||
job, created = await repos["jobs"].try_create_or_get_active(
|
||||
id="drj_1", session_id="drs_1", user_id="u1"
|
||||
)
|
||||
assert created is True
|
||||
assert job["id"] == "drj_1"
|
||||
|
||||
source = await repos["sources"].upsert(
|
||||
session_id="drs_1",
|
||||
id="src_1",
|
||||
user_id="u1",
|
||||
title="A",
|
||||
raw_content="source content",
|
||||
source="web",
|
||||
content_hash="hash_1",
|
||||
)
|
||||
assert source["id"] == "src_1"
|
||||
|
||||
message = await repos["messages"].append(
|
||||
id="msg_1",
|
||||
session_id="drs_1",
|
||||
user_id="u1",
|
||||
role="assistant",
|
||||
content="已开始撰写",
|
||||
)
|
||||
assert message["id"] == "msg_1"
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_user_isolation():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["sessions"].create(id="drs_1", user_id="u1", query="q1")
|
||||
assert await repos["sessions"].get("drs_1", user_id="u2") is None
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_list_strips_heavy_columns():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", config_snapshot={"mode": "deep"})
|
||||
items = await repos["sessions"].list_by_user(user_id="u1")
|
||||
assert len(items) == 1
|
||||
assert "report_markdown" not in items[0]
|
||||
assert "config_snapshot" not in items[0]
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_update_and_delete():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["sessions"].create(id="drs_1", user_id="u1", query="q")
|
||||
updated = await repos["sessions"].update("drs_1", user_id="u1", title="New Title")
|
||||
assert updated["title"] == "New Title"
|
||||
assert await repos["sessions"].delete("drs_1", user_id="u1") is True
|
||||
assert await repos["sessions"].get("drs_1", user_id="u1") is None
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_active_by_user():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", status="running")
|
||||
await repos["sessions"].create(id="drs_2", user_id="u1", query="q", status="completed")
|
||||
count = await repos["sessions"].count_active_by_user(user_id="u1")
|
||||
assert count == 1
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
# ── jobs: idempotent create ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_try_create_returns_created_true():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
job, created = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
|
||||
assert created is True
|
||||
assert job["status"] == "queued"
|
||||
assert job["active_dedupe_key"] is not None
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_create_never_reads_back_after_commit(monkeypatch):
|
||||
"""A committed job must not fail when a MySQL replica briefly lags."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
|
||||
raise AssertionError("DeepResearchJobRepository must not call session.refresh")
|
||||
|
||||
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
job, created = await repos["jobs"].try_create_or_get_active(
|
||||
id="drj_1",
|
||||
session_id="drs_1",
|
||||
user_id="u1",
|
||||
request_id="req_1",
|
||||
)
|
||||
assert created is True
|
||||
assert job["id"] == "drj_1"
|
||||
assert job["status"] == "queued"
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_try_create_idempotent_returns_existing():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
job1, created1 = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
|
||||
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1", request_id="req_2")
|
||||
assert created1 is True
|
||||
assert created2 is False
|
||||
assert job2["id"] == job1["id"]
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_dedupe_released_on_terminal():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
job, _ = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
assert job["active_dedupe_key"] is not None
|
||||
await repos["jobs"].update_progress("drj_1", lease_owner=None, user_id="u1", status="completed")
|
||||
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1")
|
||||
assert created2 is True
|
||||
assert job2["id"] == "drj_2"
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
# ── jobs: lease mechanics ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_claim_succeeds_for_queued():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||
claimed = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||
assert claimed is not None
|
||||
assert claimed["status"] == "running"
|
||||
assert claimed["lease_owner"] == "w1"
|
||||
assert claimed["attempt"] == 1
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_claims_queued_job_and_starts_report_worker():
|
||||
"""The claimed snapshot must reach the executor before the job can run."""
|
||||
from app.gateway.deep_research_job_dispatcher import DeepResearchJobDispatcher
|
||||
|
||||
class _Executor:
|
||||
def __init__(self) -> None:
|
||||
self.started: list[tuple[dict, str, str | None]] = []
|
||||
|
||||
def start_job(self, snapshot: dict, *, job_id: str, lease_owner: str | None) -> bool:
|
||||
self.started.append((snapshot, job_id, lease_owner))
|
||||
return True
|
||||
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(
|
||||
id="drj_1",
|
||||
session_id="drs_1",
|
||||
user_id="u1",
|
||||
input_snapshot={"query": "测试报告"},
|
||||
)
|
||||
runner = _Executor()
|
||||
dispatcher = DeepResearchJobDispatcher(repos["jobs"], runner, worker_id="worker_1")
|
||||
|
||||
started = await dispatcher.dispatch_once()
|
||||
|
||||
assert started == 1
|
||||
assert runner.started == [({"query": "测试报告"}, "drj_1", "worker_1")]
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_claim_fails_if_already_claimed():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||
first = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||
assert first is not None
|
||||
second = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=lease_until)
|
||||
assert second is None
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_renew_lease_only_by_owner():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w1", lease_until=lease_until) is True
|
||||
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w2", lease_until=lease_until) is False
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_expired_lease_reclaimable():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
expired = datetime.now(UTC) - timedelta(seconds=10)
|
||||
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=expired)
|
||||
now = datetime.now(UTC)
|
||||
claimable = await repos["jobs"].list_claimable(now=now)
|
||||
assert "drj_1" in claimable
|
||||
reclaimed = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=now + timedelta(seconds=60))
|
||||
assert reclaimed is not None
|
||||
assert reclaimed["lease_owner"] == "w2"
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
# ── jobs: two-phase cancel ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_cancel_two_phase():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||
|
||||
requested = await repos["jobs"].request_cancel("drj_1", user_id="u1")
|
||||
assert requested is not None
|
||||
assert requested["cancel_requested"] is True
|
||||
|
||||
finalized = await repos["jobs"].finalize_cancel("drj_1")
|
||||
assert finalized is not None
|
||||
assert finalized["status"] == "cancelled"
|
||||
assert finalized["active_dedupe_key"] is None
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_cancel_idempotent():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
first = await repos["jobs"].request_cancel("drj_1", user_id="u1")
|
||||
assert first is not None
|
||||
await repos["jobs"].finalize_cancel("drj_1")
|
||||
second = await repos["jobs"].request_cancel("drj_1", user_id="u1")
|
||||
assert second is None
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
# ── jobs: progress update with lease gate ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_update_progress_lease_gated():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||
|
||||
updated = await repos["jobs"].update_progress("drj_1", lease_owner="w1", phase="collecting", progress=50)
|
||||
assert updated is not None
|
||||
assert updated["phase"] == "collecting"
|
||||
assert updated["progress"] == 50
|
||||
|
||||
rejected = await repos["jobs"].update_progress("drj_1", lease_owner="w2", phase="writing")
|
||||
assert rejected is None
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
# ── events: monotonic seq + replay ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_seq_monotonic():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
repo = repos["events"]
|
||||
e1 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="phase_changed", payload={"to": "planning"})
|
||||
e2 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s1"})
|
||||
e3 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s2"})
|
||||
assert e1["seq"] == 1
|
||||
assert e2["seq"] == 2
|
||||
assert e3["seq"] == 3
|
||||
assert await repo.last_seq("drj_1") == 3
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_append_never_reads_back_after_commit(monkeypatch):
|
||||
"""Event persistence must work when a post-commit read hits a lagging replica."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
|
||||
raise AssertionError("DeepResearchEventRepository must not call session.refresh")
|
||||
|
||||
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
event = await repos["events"].append(
|
||||
session_id="drs_1",
|
||||
job_id="drj_1",
|
||||
event_type="phase_changed",
|
||||
payload={"to": "planning"},
|
||||
)
|
||||
assert event["id"] is not None
|
||||
assert event["seq"] == 1
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_list_after_replay():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
repo = repos["events"]
|
||||
for i in range(5):
|
||||
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat", payload={"i": i})
|
||||
replayed = await repo.list_after("drj_1", after=2)
|
||||
assert len(replayed) == 3
|
||||
assert [e["seq"] for e in replayed] == [3, 4, 5]
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_payload_round_trip():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
repo = repos["events"]
|
||||
await repo.append(
|
||||
session_id="drs_1",
|
||||
job_id="drj_1",
|
||||
event_type="plan_created",
|
||||
payload={"subtopics": ["AI", "ML"], "nested": {"a": 1}},
|
||||
)
|
||||
events = await repo.list_after("drj_1", after=0)
|
||||
assert events[0]["payload"]["subtopics"] == ["AI", "ML"]
|
||||
assert events[0]["payload"]["nested"]["a"] == 1
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_seq_independent_per_job():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
repo = repos["events"]
|
||||
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
|
||||
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
|
||||
await repo.append(session_id="drs_1", job_id="drj_2", event_type="heartbeat")
|
||||
assert await repo.last_seq("drj_1") == 2
|
||||
assert await repo.last_seq("drj_2") == 1
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
# ── sources ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_upsert_and_list():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["sources"].upsert(
|
||||
session_id="drs_1",
|
||||
id="src_1",
|
||||
user_id="u1",
|
||||
title="Source A",
|
||||
url="https://example.com/a",
|
||||
raw_content="content A",
|
||||
source="web",
|
||||
source_type="web",
|
||||
content_hash="hash_a",
|
||||
)
|
||||
items = await repos["sources"].list_by_session("drs_1", user_id="u1")
|
||||
assert len(items) == 1
|
||||
assert items[0]["title"] == "Source A"
|
||||
assert "raw_content" not in items[0]
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_set_selected():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["sources"].upsert(
|
||||
session_id="drs_1",
|
||||
id="src_1",
|
||||
user_id="u1",
|
||||
title="A",
|
||||
raw_content="c",
|
||||
source="web",
|
||||
content_hash="h",
|
||||
)
|
||||
updated = await repos["sources"].set_selected("src_1", selected=True, reason="relevant", citation_key="1", user_id="u1")
|
||||
assert updated["selected"] is True
|
||||
assert updated["citation_key"] == "1"
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
|
||||
|
||||
# ── messages ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_append_and_list():
|
||||
repos = await _make_repos()
|
||||
try:
|
||||
await repos["messages"].append(id="msg_1", session_id="drs_1", user_id="u1", role="user", content="question?")
|
||||
await repos["messages"].append(
|
||||
id="msg_2",
|
||||
session_id="drs_1",
|
||||
user_id="u1",
|
||||
role="assistant",
|
||||
content="answer",
|
||||
citation_source_ids=["src_1"],
|
||||
)
|
||||
msgs = await repos["messages"].list_by_session("drs_1", user_id="u1")
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "user"
|
||||
assert msgs[1]["citation_source_ids"] == ["src_1"]
|
||||
finally:
|
||||
await repos["_engine"].dispose()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Regression coverage for idempotent Deep Research job submission."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.routers import deep_research
|
||||
|
||||
|
||||
class _SessionStore:
|
||||
def __init__(self) -> None:
|
||||
self.updates: list[dict] = []
|
||||
|
||||
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
|
||||
return {
|
||||
"id": session_id,
|
||||
"query": "测试主题",
|
||||
"title": "测试主题",
|
||||
"config_snapshot": {"mode": "basic"},
|
||||
}
|
||||
|
||||
async def count_active_by_user(self, *, user_id: str) -> int: # noqa: ARG002
|
||||
return 0
|
||||
|
||||
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
|
||||
self.updates.append(fields)
|
||||
return {"id": session_id, **fields}
|
||||
|
||||
|
||||
class _JobStore:
|
||||
async def get_active_for_session(self, session_id: str, *, user_id: str): # noqa: ARG002
|
||||
return None
|
||||
|
||||
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
|
||||
# Simulate a retry after an earlier request committed the job but did
|
||||
# not finish responding to the browser.
|
||||
return {"id": "drj_existing", "status": "queued"}, False
|
||||
|
||||
|
||||
class _Dispatcher:
|
||||
def __init__(self) -> None:
|
||||
self.nudges = 0
|
||||
|
||||
def nudge(self) -> None:
|
||||
self.nudges += 1
|
||||
|
||||
|
||||
def _request(*, sessions, jobs, dispatcher):
|
||||
return SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
deep_research_session_store=sessions,
|
||||
deep_research_job_store=jobs,
|
||||
deep_research_dispatcher=dispatcher,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_of_existing_queued_job_nudges_dispatcher(monkeypatch):
|
||||
"""An idempotent retry must recover a queued-but-not-yet-dispatched job."""
|
||||
|
||||
async def fake_require_user(request): # noqa: ANN001
|
||||
return "u1"
|
||||
|
||||
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
|
||||
sessions = _SessionStore()
|
||||
dispatcher = _Dispatcher()
|
||||
request = _request(sessions=sessions, jobs=_JobStore(), dispatcher=dispatcher)
|
||||
|
||||
response = await deep_research.start_job(
|
||||
"drs_1",
|
||||
deep_research.StartJobRequest(entry="legacy"),
|
||||
request,
|
||||
)
|
||||
|
||||
assert response == {
|
||||
"jobId": "drj_existing",
|
||||
"sessionId": "drs_1",
|
||||
"status": "queued",
|
||||
"reused": True,
|
||||
}
|
||||
assert dispatcher.nudges == 1
|
||||
assert sessions.updates == [{"status": "running", "active_job_id": "drj_existing"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_job_still_starts_when_session_projection_temporarily_fails(monkeypatch):
|
||||
"""A committed job is more important than its non-authoritative UI projection."""
|
||||
|
||||
async def fake_require_user(request): # noqa: ANN001
|
||||
return "u1"
|
||||
|
||||
class _ProjectionFailureSessionStore(_SessionStore):
|
||||
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
|
||||
raise RuntimeError("temporary database readback failure")
|
||||
|
||||
class _NewJobStore(_JobStore):
|
||||
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
|
||||
return {"id": "drj_new", "status": "queued"}, True
|
||||
|
||||
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
|
||||
dispatcher = _Dispatcher()
|
||||
response = await deep_research.start_job(
|
||||
"drs_1",
|
||||
deep_research.StartJobRequest(entry="legacy"),
|
||||
_request(
|
||||
sessions=_ProjectionFailureSessionStore(),
|
||||
jobs=_NewJobStore(),
|
||||
dispatcher=dispatcher,
|
||||
),
|
||||
)
|
||||
|
||||
assert response["jobId"] == "drj_new"
|
||||
assert response["reused"] is False
|
||||
assert dispatcher.nudges == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_report_card_can_enable_images_after_collection(monkeypatch):
|
||||
"""The write-time card override must win over the collection-time default."""
|
||||
|
||||
async def fake_require_user(request): # noqa: ANN001
|
||||
return "u1"
|
||||
|
||||
class _ChatSessionStore(_SessionStore):
|
||||
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
|
||||
return {
|
||||
"id": session_id,
|
||||
"query": "测试主题",
|
||||
"title": "测试主题",
|
||||
"runtime_thread_id": "collector-thread",
|
||||
"config_snapshot": {
|
||||
"mode": "basic",
|
||||
"collection_mode": "chat",
|
||||
"generate_images": False,
|
||||
"max_generated_images": 2,
|
||||
},
|
||||
}
|
||||
|
||||
class _CapturingJobStore(_JobStore):
|
||||
def __init__(self) -> None:
|
||||
self.created: dict | None = None
|
||||
|
||||
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
|
||||
self.created = kwargs
|
||||
return {"id": "drj_images", "status": "queued"}, True
|
||||
|
||||
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
|
||||
sessions = _ChatSessionStore()
|
||||
jobs = _CapturingJobStore()
|
||||
response = await deep_research.start_job(
|
||||
"drs_1",
|
||||
deep_research.StartJobRequest(
|
||||
entry="chat",
|
||||
config={"generate_images": True, "max_generated_images": 3},
|
||||
),
|
||||
_request(sessions=sessions, jobs=jobs, dispatcher=_Dispatcher()),
|
||||
)
|
||||
|
||||
assert response["jobId"] == "drj_images"
|
||||
assert jobs.created is not None
|
||||
snapshot = jobs.created["input_snapshot"]["config"]
|
||||
assert snapshot["generate_images"] is True
|
||||
assert snapshot["max_generated_images"] == 3
|
||||
# The session snapshot is persisted too, so a reload/replay keeps the
|
||||
# write-time choice instead of reverting to the creation-time false.
|
||||
assert any(
|
||||
update.get("config_snapshot", {}).get("generate_images") is True
|
||||
for update in sessions.updates
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch):
|
||||
"""A stream DB blip must yield a recoverable warning, not an ASGI error group."""
|
||||
|
||||
async def fake_require_user(request): # noqa: ANN001
|
||||
return "u1"
|
||||
|
||||
class _StreamJobStore:
|
||||
async def get(self, job_id: str, *, user_id: str): # noqa: ARG002
|
||||
return {"id": job_id, "session_id": "drs_1"}
|
||||
|
||||
async def get_unscoped(self, job_id: str): # noqa: ARG002
|
||||
return None
|
||||
|
||||
class _UnavailableEventStore:
|
||||
async def list_after(self, *args, **kwargs): # noqa: ANN002, ANN003
|
||||
raise RuntimeError("temporary replica failure")
|
||||
|
||||
async def is_connected() -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
deep_research_job_store=_StreamJobStore(),
|
||||
deep_research_event_store=_UnavailableEventStore(),
|
||||
)
|
||||
),
|
||||
is_disconnected=is_connected,
|
||||
)
|
||||
|
||||
response = await deep_research.stream_job("drj_1", request)
|
||||
chunk = await anext(response.body_iterator)
|
||||
await response.body_iterator.aclose()
|
||||
|
||||
assert "durable_replay_temporarily_unavailable" in chunk
|
||||
@@ -206,14 +206,14 @@ def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
|
||||
assert phrase in prompt
|
||||
|
||||
|
||||
def test_position_intent_clarification_waits_for_the_user_before_ready():
|
||||
"""A same-run READY block must not consume the user-assistance pause."""
|
||||
def test_fresh_intent_clarification_waits_for_the_user_before_ready():
|
||||
"""A same-run READY block must not consume a fresh assistance pause."""
|
||||
assert intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not intent_router._clarification_blocks_ready(
|
||||
assert intent_router._clarification_blocks_ready(
|
||||
intent_router.INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=True,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Regression coverage for provider-safe transient context compression."""
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
|
||||
from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message
|
||||
|
||||
|
||||
def test_compression_restores_latest_human_turn_when_only_tool_tail_remains():
|
||||
user = HumanMessage(content="请分析这份材料", id="user-1")
|
||||
source = [
|
||||
user,
|
||||
AIMessage(content="", id="ai-1"),
|
||||
ToolMessage(content="检索结果", tool_call_id="call-1", id="tool-1"),
|
||||
]
|
||||
compressed = [SystemMessage(content="internal summary"), *source[1:]]
|
||||
|
||||
result = _ensure_latest_human_message(source, compressed)
|
||||
|
||||
assert result[0].type == "system"
|
||||
assert result[1] is user
|
||||
assert [message.type for message in result[2:]] == ["ai", "tool"]
|
||||
|
||||
|
||||
def test_compression_does_not_duplicate_human_turn_already_in_tail():
|
||||
user = HumanMessage(content="继续", id="user-1")
|
||||
compressed = [SystemMessage(content="internal summary"), user]
|
||||
|
||||
result = _ensure_latest_human_message([user], compressed)
|
||||
|
||||
assert result == compressed
|
||||
Reference in new issue
Block a user