Compare commits

...

3 Commits

20 changed files with 771 additions and 28 deletions

View File

@ -5,6 +5,12 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.62.9 — 2026-08-06
- 长对话不再把普通历史思考过程重复发送给模型,减少无效上下文占用;工具调用需要的推理状态仍会按模型协议保留。
- 对话顶部上下文环改按实际模型输入统计,悬停时可查看剥离的历史思考、当前压缩的旧工具输出和累计整理次数;超出可靠容量时显示 `100%+`
- 新对话自动命名遇到模型服务暂时不可用时,会使用首条消息生成本地兜底标题,不再长期停留为“新对话”。
## 0.62.8 — 2026-08-06 ## 0.62.8 — 2026-08-06
- 文件面板把新建文件夹、从其他目录选入和上传文件收进统一的“添加”菜单,减少顶栏按钮数量;隐藏目录、刷新和收起入口保持常驻。 - 文件面板把新建文件夹、从其他目录选入和上传文件收进统一的“添加”菜单,减少顶栏按钮数量;隐藏目录、刷新和收起入口保持常驻。

View File

@ -127,7 +127,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听
默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。 默认 `deepseek_v4.flash`;复杂 bug / 终稿升 pro + reasoning_effort=max;fallback 手动切 Claude。成本量级:修 bug flash ~$0.01 / 完整申报书 flash ~$0.30(pro-max ~$1.5,Opus ~$10+)。99% 任务 flash 够用。
模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度;`core/llm_params.py` 是请求构造唯一入口。DeepSeek、GLM、方舟当前共享 `extra_body` 协议,未验证网关明确用 `none`、不猜参数协议,主循环不再按 family 分支。`/v1/models` 只返回语义明确的 `thinking_enabled` 模型思考参数由 profile 统一表达:`thinking_enabled` 只表示开关,`thinking_transport` 只表示已验证的传输协议,`reasoning_effort` 只表示开启后的推理强度`reasoning_replay` 只表示历史 reasoning 的 provider 回传策略(`none` / `tool_calls` / `all``core/llm_params.py` 是请求参数构造唯一入口`core/context.py` 是历史消息清洗唯一入口。原始 assistant 响应仍完整落库供展示与导出,发模型前才按 profile 裁剪,且上下文统计、压缩与折叠都使用裁剪后的请求视图。DeepSeek、GLM、方舟当前共享 `extra_body` 协议DeepSeek V4 仅为带工具调用的 assistant 消息保留 reasoning,未验证网关明确用 `none`、不猜参数协议,主循环不再按 family 分支。`/v1/models` 只返回语义明确的 `thinking_enabled`
--- ---
@ -171,7 +171,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听
两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。 两个并列入口,正交不嵌套:**Task list**(主,"我的对话历史")+ **Dir tree**(辅,"我的文件资产")。类比 Finder + 最近使用。dir **不是 task 的父容器**、无 DB 实体、path 即标识。同 working_dir 多 task 共享 = "同一项目多对话",无需"项目"实体;前缀嵌套拒(no-subtask)。skill 产物全落 working_dir,不引 artifacts 表。空 dir 正常展示(上传本身是有效行为)。多 task 并发写由软警告兜底(§7.9)。
**新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir后端据此判定自动命名以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题working_dir 仍可省略并 fallback 到 name`auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir人工 PATCH name 同时清 pending条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口UI 同样要求明确选择 working_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`,失败只保留占位名、不阻塞主 run **新对话入口(0.60)**:登录未选 task 与左栏「+ 新对话」共用同一前端草稿页,先选择已有 working_dir 或输入新目录名,再直接写消息;草稿不落 DB首发时才 `POST /v1/tasks`,避免空 task 堆积。创建请求省略/留空 name 时必须显式给 working_dir后端据此判定自动命名以「新对话」占位并置一次性 `auto_title_pending`;显式 name 的旧调用继续视为人工标题working_dir 仍可省略并 fallback 到 name`auto_title` 字段只作兼容保留。首条消息并行触发短标题调用,结果只改 `tasks.name`、绝不改 working_dir人工 PATCH name 同时清 pending条件 UPDATE 保证在途标题也不能覆盖用户命名。原完整创建表单保留为「自定义」入口UI 同样要求明确选择 working_dirname 可选,并可预设 description/skill/model。标题是 UI 元数据辅助调用,记 `usage_events.kind="task_title"`;模型调用失败时以首条消息第一行生成本地兜底标题,不阻塞主 run也不把 pending 留给后续消息误命名
**对话产物引用(0025)**:真实文件仍是事实源,不建 artifacts 表;`messages.artifact_refs` 只保存可重建的轻量 UI 元数据,规范路径以该 task 的**当前 working_dir 为根**,形如 `{version:1, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}`。预览/下载走 task-scoped 文件 API服务端用 task 当前 `working_dir` 解析因此顶层工作目录改名后历史卡片仍有效。普通源码树、中间文件和配套资源只留文件面板agent 仅用 `publish_artifacts` 显式提升少量最终文件,单条消息最多 10 个,图像/视频/Office 转 PDF 等成品工具可自动提升。`NULL` 表示迁移前旧消息,前端继续使用正文路径抽取,并在 task-scoped API 上启用只读兼容链(旧 user-root 含义→原样 task-relative→去掉旧目录前缀新消息写 `[]` 或结构化列表,停止启发式抽取,避免重复卡片与误识别。文件在 working_dir 内再次移动或删除后引用可失效,这是 FS 事实源语义,不复制文件、不引不可变对象存储。 **对话产物引用(0025)**:真实文件仍是事实源,不建 artifacts 表;`messages.artifact_refs` 只保存可重建的轻量 UI 元数据,规范路径以该 task 的**当前 working_dir 为根**,形如 `{version:1, scope:"working_dir", path:"reports/a.pdf", label?:"最终报告"}`。预览/下载走 task-scoped 文件 API服务端用 task 当前 `working_dir` 解析因此顶层工作目录改名后历史卡片仍有效。普通源码树、中间文件和配套资源只留文件面板agent 仅用 `publish_artifacts` 显式提升少量最终文件,单条消息最多 10 个,图像/视频/Office 转 PDF 等成品工具可自动提升。`NULL` 表示迁移前旧消息,前端继续使用正文路径抽取,并在 task-scoped API 上启用只读兼容链(旧 user-root 含义→原样 task-relative→去掉旧目录前缀新消息写 `[]` 或结构化列表,停止启发式抽取,避免重复卡片与误识别。文件在 working_dir 内再次移动或删除后引用可失效,这是 FS 事实源语义,不复制文件、不引不可变对象存储。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-06(文件顶栏添加操作收拢,bump 0.62.8) 最后更新:2026-08-06(上下文 reasoning 回传治理与任务标题兜底,bump 0.62.9)
--- ---
@ -23,6 +23,7 @@
### 2026-08-06 ### 2026-08-06
- **08-06 / 0.62.9 / reasoning 回传治理 + 上下文环口径统一**:原始 assistant reasoning 继续完整落库供展示/导出provider-bound 请求按模型档案选择性剥离DeepSeek V4 仅保留工具调用 reasoning普通跨轮与模型切换不再携带私有状态。任务详情、SSE、压缩和折叠统一使用清洗后视图顶部环悬停补充 reasoning 剥离数、当前压缩工具消息数与累计整理次数,超可靠容量封顶显示 `100%+` 并保留真实百分比。自动标题模型调用失败时改用首条消息首行本地兜底并一次性消费 pending新增设计院工程图纸知识库与智能设计辅助系统调研文档。相关 85 项上下文/前端/循环 unittest 全绿、1 项测试库门控安全跳过标题专项、Python 编译、JavaScript 语法及 diff 检查通过;无 schema、migration、依赖或运行方式变化未连接生产 DB。
- **08-06 / 0.62.8 / 文件顶栏添加操作收拢**:把新建文件夹、从其他目录选入和上传文件三个同类入口合并为“+ 添加”菜单,`.*` 隐藏目录开关、刷新与收起继续常驻,顶栏操作由 6 个减至 4 个;拖拽上传和各操作原有行为不变。相关静态前端与无 DB 路由共 33 项 unittest、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。 - **08-06 / 0.62.8 / 文件顶栏添加操作收拢**:把新建文件夹、从其他目录选入和上传文件三个同类入口合并为“+ 添加”菜单,`.*` 隐藏目录开关、刷新与收起继续常驻,顶栏操作由 6 个减至 4 个;拖拽上传和各操作原有行为不变。相关静态前端与无 DB 路由共 33 项 unittest、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。
- **08-06 / 0.62.7 / 文件面板先备料 + 隐藏开关常驻**:文件栏 `.*` 开关不再随浏览路径消失,但后端继续保证 user_root 平台点目录永不展示;新增“+”入口和 `POST /v1/files/mkdir`,可在根目录或当前目录创建直接子目录,创建成功后自动进入并刷新新对话目录候选,形成“建目录→上传/选入→开新对话”流程。目录名复用 working_dir 校验,点目录、路径式名称与重名分别拒绝;相关静态前端及无 DB 路由共 33 项 unittest、JavaScript 语法、Ruff 致命规则和 diff 检查通过。无 schema、migration 或依赖变化,未连接生产 DB。 - **08-06 / 0.62.7 / 文件面板先备料 + 隐藏开关常驻**:文件栏 `.*` 开关不再随浏览路径消失,但后端继续保证 user_root 平台点目录永不展示;新增“+”入口和 `POST /v1/files/mkdir`,可在根目录或当前目录创建直接子目录,创建成功后自动进入并刷新新对话目录候选,形成“建目录→上传/选入→开新对话”流程。目录名复用 working_dir 校验,点目录、路径式名称与重名分别拒绝;相关静态前端及无 DB 路由共 33 项 unittest、JavaScript 语法、Ruff 致命规则和 diff 检查通过。无 schema、migration 或依赖变化,未连接生产 DB。
- **08-06 / 0.62.6 / 模型标签禁止断行**:对话顶部模型控件增加不可断行约束,中栏宽度收紧时优先压缩下拉框,不再把“模型”两个汉字拆成两行;静态前端与更新日志共 12 项 unittest、diff 检查通过。纯样式修复,无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。 - **08-06 / 0.62.6 / 模型标签禁止断行**:对话顶部模型控件增加不可断行约束,中栏宽度收紧时优先压缩下拉框,不再把“模型”两个汉字拆成两行;静态前端与更新日志共 12 项 unittest、diff 检查通过。纯样式修复,无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。

View File

@ -16,6 +16,7 @@ variants:
thinking_transport: extra_body thinking_transport: extra_body
reasoning_effort_levels: [low, high, max] reasoning_effort_levels: [low, high, max]
default_reasoning_effort: high default_reasoning_effort: high
reasoning_replay: tool_calls # 普通跨轮剥离;工具调用 reasoning 按官方协议完整回传
code_quality: good code_quality: good
enable_run_python: true enable_run_python: true
max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
@ -41,6 +42,7 @@ variants:
thinking_transport: extra_body thinking_transport: extra_body
reasoning_effort_levels: [low, medium, high, max] reasoning_effort_levels: [low, medium, high, max]
default_reasoning_effort: medium default_reasoning_effort: medium
reasoning_replay: tool_calls
code_quality: excellent code_quality: excellent
enable_run_python: true enable_run_python: true
max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.62.8" __version__ = "0.62.9"

View File

@ -9,6 +9,8 @@ import yaml
from .llm_params import THINKING_TRANSPORTS from .llm_params import THINKING_TRANSPORTS
REASONING_REPLAY_POLICIES = {"none", "tool_calls", "all"}
@dataclass @dataclass
class ModelCapabilities: class ModelCapabilities:
@ -32,6 +34,9 @@ class ModelCapabilities:
thinking_transport: str = "none" thinking_transport: str = "none"
reasoning_effort_levels: List[str] = field(default_factory=list) reasoning_effort_levels: List[str] = field(default_factory=list)
default_reasoning_effort: str = "" default_reasoning_effort: str = ""
# 历史 reasoning 发回模型的策略。持久化原始响应与 provider-bound 输入分离:
# none=全部剥离tool_calls=仅工具调用 assistant 保留all=完整原样回传。
reasoning_replay: str = "none"
# 代码 / 沙盒 # 代码 / 沙盒
code_quality: str = "good" code_quality: str = "good"
@ -86,6 +91,11 @@ class ModelCapabilities:
f"档案 {path} 的 thinking_transport={caps.thinking_transport!r} 无效;" f"档案 {path} 的 thinking_transport={caps.thinking_transport!r} 无效;"
f"可选: {sorted(THINKING_TRANSPORTS)}" f"可选: {sorted(THINKING_TRANSPORTS)}"
) )
if caps.reasoning_replay not in REASONING_REPLAY_POLICIES:
raise ValueError(
f"档案 {path} 的 reasoning_replay={caps.reasoning_replay!r} 无效;"
f"可选: {sorted(REASONING_REPLAY_POLICIES)}"
)
if caps.thinking_enabled and caps.thinking_transport == "none": if caps.thinking_enabled and caps.thinking_transport == "none":
raise ValueError( raise ValueError(
f"档案 {path} 开启 thinking 时必须声明可验证的 thinking_transport" f"档案 {path} 开启 thinking 时必须声明可验证的 thinking_transport"

View File

@ -29,6 +29,73 @@ COMPACT_CONTEXT_RATIO = 0.5
RATIO_MIN = 1.0 RATIO_MIN = 1.0
RATIO_MAX = 4.0 RATIO_MAX = 4.0
_REASONING_FIELDS = {
"reasoning_content",
"thinking",
"thinking_blocks",
"redacted_thinking",
}
def filter_reasoning_for_replay(
messages: List[dict[str, Any]],
policy: str = "none",
) -> tuple[List[dict[str, Any]], dict[str, int]]:
"""构造 provider-bound 消息副本,按能力档案裁剪历史 reasoning。
原始 Session/DB payload 不动继续供 UI导出和排障使用工具调用需要 reasoning
连续性的 provider 可用 ``tool_calls``未声明能力的 provider 默认 ``none``避免
跨模型发送私有状态保留时把 LiteLLM 可能放在 provider_specific_fields 里的
reasoning_content 提升到 DeepSeek/OpenAI-compatible 所需的顶层字段
"""
if policy not in {"none", "tool_calls", "all"}:
raise ValueError(f"unsupported reasoning replay policy: {policy!r}")
filtered: List[dict[str, Any]] = []
stripped_messages = 0
stripped_chars = 0
for message in messages:
new_msg = deepcopy(message)
if policy == "all":
filtered.append(new_msg)
continue
preserve = (
policy == "tool_calls"
and new_msg.get("role") == "assistant"
and bool(new_msg.get("tool_calls"))
)
if preserve:
provider_fields = new_msg.get("provider_specific_fields")
if (
"reasoning_content" not in new_msg
and isinstance(provider_fields, dict)
and provider_fields.get("reasoning_content") is not None
):
new_msg["reasoning_content"] = provider_fields["reasoning_content"]
filtered.append(new_msg)
continue
before = _message_chars(new_msg)
removed = False
for field in _REASONING_FIELDS:
if field in new_msg:
new_msg.pop(field)
removed = True
# LiteLLM 用该容器承载不同供应商私有响应字段;它不是通用聊天协议,模型切换
# 或普通跨轮时不应原样透传。需要连续性的工具消息已在 preserve 分支返回。
if "provider_specific_fields" in new_msg:
new_msg.pop("provider_specific_fields")
removed = True
after = _message_chars(new_msg)
if removed:
stripped_messages += 1
stripped_chars += max(0, before - after)
filtered.append(new_msg)
return filtered, {
"stripped_reasoning_messages": stripped_messages,
"stripped_reasoning_chars": stripped_chars,
}
def clamp_ratio(ratio: float) -> float: def clamp_ratio(ratio: float) -> float:
"""把 chars/token 比值夹进合理带宽。""" """把 chars/token 比值夹进合理带宽。"""
@ -174,11 +241,13 @@ def prepare_messages_for_llm(
keep_recent: int = 12, keep_recent: int = 12,
old_tool_chars: int = 2_000, old_tool_chars: int = 2_000,
compact_threshold_chars: int = 0, compact_threshold_chars: int = 0,
reasoning_replay: str = "none",
) -> List[dict[str, Any]]: ) -> List[dict[str, Any]]:
"""返回发给 LLM 的 messages 副本。 """返回发给 LLM 的 messages 副本。
- system 和最近 keep_recent 条消息原样保留 - system 和最近 keep_recent 条消息原样保留
- 较旧且过长的 tool content 压缩为头尾摘要 - 较旧且过长的 tool content 压缩为头尾摘要
- assistant reasoning 按模型能力档案裁剪原始 Session/DB 历史不变
- assistant tool_call.arguments 一律原样保留(改写会毒化模型,见模块注释) - assistant tool_call.arguments 一律原样保留(改写会毒化模型,见模块注释)
- role/tool_call_id/name 等协议字段不变 - role/tool_call_id/name 等协议字段不变
""" """
@ -187,6 +256,7 @@ def prepare_messages_for_llm(
keep_recent=keep_recent, keep_recent=keep_recent,
old_tool_chars=old_tool_chars, old_tool_chars=old_tool_chars,
compact_threshold_chars=compact_threshold_chars, compact_threshold_chars=compact_threshold_chars,
reasoning_replay=reasoning_replay,
) )
return prepared return prepared
@ -197,15 +267,19 @@ def prepare_messages_with_stats(
keep_recent: int = 12, keep_recent: int = 12,
old_tool_chars: int = 2_000, old_tool_chars: int = 2_000,
compact_threshold_chars: int = 0, compact_threshold_chars: int = 0,
reasoning_replay: str = "none",
) -> tuple[List[dict[str, Any]], dict[str, int]]: ) -> tuple[List[dict[str, Any]], dict[str, int]]:
"""返回发给 LLM 的 messages 副本和压缩统计。 """返回发给 LLM 的 messages 副本和压缩统计。
`compact_threshold_chars`:上下文压力门槛总体量(原始 chars)未超过它时**完全不压缩** `reasoning_replay`:provider 对历史 reasoning 的回传要求清洗先于体量统计与压缩
`compact_threshold_chars`:上下文压力门槛总体量(清洗后 chars)未超过它时**完全不压缩**
短任务不丢旧工具细节, prompt 前缀逐轮字节一致DeepSeek 等前缀缓存全程命中 短任务不丢旧工具细节, prompt 前缀逐轮字节一致DeepSeek 等前缀缓存全程命中
默认 0 = 永远压缩(向后兼容)caller(loop)按模型 reliable_context 折算传入 默认 0 = 永远压缩(向后兼容)caller(loop)按模型 reliable_context 折算传入
""" """
if keep_recent < 0: if keep_recent < 0:
keep_recent = 0 keep_recent = 0
messages, reasoning_stats = filter_reasoning_for_replay(messages, reasoning_replay)
# 先补齐被中断 run 留下的悬空 tool_calls(否则原样发给模型会被拒,见函数注释)。 # 先补齐被中断 run 留下的悬空 tool_calls(否则原样发给模型会被拒,见函数注释)。
messages, repaired_tool_calls = _repair_dangling_tool_calls(messages) messages, repaired_tool_calls = _repair_dangling_tool_calls(messages)
original_chars = sum(_message_chars(m) for m in messages) original_chars = sum(_message_chars(m) for m in messages)
@ -221,6 +295,7 @@ def prepare_messages_with_stats(
"compacted_skill_messages": 0, "compacted_skill_messages": 0,
"compaction_skipped": 1, "compaction_skipped": 1,
"repaired_tool_calls": repaired_tool_calls, "repaired_tool_calls": repaired_tool_calls,
**reasoning_stats,
} }
return unchanged, stats return unchanged, stats
@ -259,5 +334,6 @@ def prepare_messages_with_stats(
"compacted_skill_messages": compacted_skill_messages, "compacted_skill_messages": compacted_skill_messages,
"compaction_skipped": 0, "compaction_skipped": 0,
"repaired_tool_calls": repaired_tool_calls, "repaired_tool_calls": repaired_tool_calls,
**reasoning_stats,
} }
return prepared, stats return prepared, stats

View File

@ -30,6 +30,7 @@ from .context import (
COMPACT_CONTEXT_RATIO, COMPACT_CONTEXT_RATIO,
_message_chars, _message_chars,
estimate_window_tokens, estimate_window_tokens,
filter_reasoning_for_replay,
prepare_messages_with_stats, prepare_messages_with_stats,
) )
from .storage import session_scope from .storage import session_scope
@ -142,12 +143,14 @@ def maybe_fold(
偏保守,原文全在 DB,可接受,不为此加持久化状态 偏保守,原文全在 DB,可接受,不为此加持久化状态
""" """
msgs = session.messages msgs = session.messages
before_chars = sum(_message_chars(m) for m in msgs) reasoning_replay = getattr(caps, "reasoning_replay", "none")
est_tokens = estimate_window_tokens(msgs, session.last_measured_usage()) provider_msgs, _ = filter_reasoning_for_replay(msgs, reasoning_replay)
before_chars = sum(_message_chars(m) for m in provider_msgs)
est_tokens = estimate_window_tokens(provider_msgs, session.last_measured_usage())
if est_tokens < caps.reliable_context * FOLD_TRIGGER_RATIO: if est_tokens < caps.reliable_context * FOLD_TRIGGER_RATIO:
return None return None
head_len = session.context_head_len head_len = session.context_head_len
cutoff = find_cutoff(msgs, head_len) cutoff = find_cutoff(provider_msgs, head_len)
if cutoff is None: if cutoff is None:
return None return None
folded_count = cutoff - head_len folded_count = cutoff - head_len
@ -157,7 +160,11 @@ def maybe_fold(
# 缓存前缀一致,摘要调用吃到 cache hit。切点在两个列表间用「第 k 条 user 消息」对齐: # 缓存前缀一致,摘要调用吃到 cache hit。切点在两个列表间用「第 k 条 user 消息」对齐:
# prepare 只改写 tool 内容 / 补占位 tool 消息,user 消息原样且相对顺序不变。 # prepare 只改写 tool 内容 / 补占位 tool 消息,user 消息原样且相对顺序不变。
compact_threshold = int(caps.reliable_context * COMPACT_CONTEXT_RATIO * CHARS_PER_TOKEN) compact_threshold = int(caps.reliable_context * COMPACT_CONTEXT_RATIO * CHARS_PER_TOKEN)
prepared, _ = prepare_messages_with_stats(msgs, compact_threshold_chars=compact_threshold) prepared, _ = prepare_messages_with_stats(
msgs,
compact_threshold_chars=compact_threshold,
reasoning_replay=reasoning_replay,
)
p_cut = _kth_user_index(prepared, _user_ordinal(msgs, cutoff)) p_cut = _kth_user_index(prepared, _user_ordinal(msgs, cutoff))
if p_cut is None: # 理论不可达,防御 if p_cut is None: # 理论不可达,防御
return None return None
@ -192,7 +199,10 @@ def maybe_fold(
new_base = session.context_base + folded_count new_base = session.context_base + folded_count
persist_fold(session.task_id, new_base, summary) persist_fold(session.task_id, new_base, summary)
session.apply_fold(cutoff, summary) session.apply_fold(cutoff, summary)
after_chars = sum(_message_chars(m) for m in session.messages) after_provider_msgs, _ = filter_reasoning_for_replay(
session.messages, reasoning_replay
)
after_chars = sum(_message_chars(m) for m in after_provider_msgs)
stats = { stats = {
"type": "context_fold", "type": "context_fold",
"phase": "done", "phase": "done",

View File

@ -26,6 +26,7 @@ from .context import (
CHARS_PER_TOKEN, CHARS_PER_TOKEN,
COMPACT_CONTEXT_RATIO, COMPACT_CONTEXT_RATIO,
calibrated_chars_per_token, calibrated_chars_per_token,
filter_reasoning_for_replay,
clamp_ratio, clamp_ratio,
prepare_messages_with_stats, prepare_messages_with_stats,
) )
@ -458,8 +459,12 @@ class AgentLoop:
""" """
if self._ctx_chars_per_token is None: if self._ctx_chars_per_token is None:
try: try:
provider_messages, _ = filter_reasoning_for_replay(
self.session.messages,
getattr(self.caps, "reasoning_replay", "none"),
)
self._ctx_chars_per_token = calibrated_chars_per_token( self._ctx_chars_per_token = calibrated_chars_per_token(
self.session.messages, self.session.last_measured_usage() provider_messages, self.session.last_measured_usage()
) )
except Exception: except Exception:
self._ctx_chars_per_token = CHARS_PER_TOKEN self._ctx_chars_per_token = CHARS_PER_TOKEN
@ -500,6 +505,7 @@ class AgentLoop:
llm_messages, context_stats = prepare_messages_with_stats( llm_messages, context_stats = prepare_messages_with_stats(
self.session.messages, self.session.messages,
compact_threshold_chars=compact_threshold, compact_threshold_chars=compact_threshold,
reasoning_replay=getattr(self.caps, "reasoning_replay", "none"),
) )
self._last_sent_chars = context_stats.get("sent_chars", 0) self._last_sent_chars = context_stats.get("sent_chars", 0)
llm_start_event = { llm_start_event = {

View File

@ -101,6 +101,10 @@ def generate_task_title(
f"{type(e).__name__}: {e}", f"{type(e).__name__}: {e}",
flush=True, flush=True,
) )
# 标题是一次性闸:若模型瞬时失败后仍只消费 pending任务会永久停在
# “新对话”。直接用首条消息的第一行作本地兜底,保证有效首发总能完成
# 命名;不保留 pending 给后续消息重试,避免拿第二条消息误命名整段对话。
title = clean_generated_title("", user_message)
# 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸: # 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸:
# 用户先改名时 PATCH 已清 false此处 rowcount=0不覆盖。 # 用户先改名时 PATCH 已清 false此处 rowcount=0不覆盖。

View File

@ -0,0 +1,416 @@
# 设计院工程图纸知识库与智能设计辅助系统调研
## 1. 调研目的
某设计院长期从事工厂产线及设备设计工作,积累了大量工程图纸、项目资料、设备参数、工艺数据和设计经验。随着新业务增加,设计院希望评估是否可以基于历史资料和 AI 智能体,辅助新项目完成资料检索、类似项目推荐、方案思路生成、设备清单初拟,甚至生成部分图纸草案。
本调研重点回答以下问题:
1. 这件事具体想做什么,边界在哪里;
2. 技术难度如何,哪些近期可做,哪些需要长期建设;
3. 市面上是否已有成熟产品可以直接使用;
4. 如果自建,大致需要哪些步骤;
5. 成本、周期、人力和风险大致是什么量级;
6. 是否值得推进,以及建议采用什么路线。
本调研不把目标定义为“AI 自动完成完整施工图设计”。更合理的定位是:
> 构建基于历史工程资料、图纸知识库和大模型智能体的设计辅助能力,优先服务方案阶段和资料复用,逐步扩展到清单、流程图、布置草图和 CAD/BIM 半自动化。
---
## 2. 想做的事情
设计院想做的不是单一聊天机器人,也不是单纯 CAD 自动画图,而是一个分层的工程设计辅助系统。
### 2.1 工程资料知识库
将历史项目资料整理成可检索、可引用、可复用的知识库,资料范围包括:
- 历史项目设计说明;
- 技术协议、招标文件、可研报告;
- 设备清单、参数表、报价清单;
- 工艺路线、方案汇报材料;
- 内部规范、标准图集、设计经验;
- 图纸文件、图签、图纸目录和图纸版本信息。
该层的价值是解决“资料在哪里、哪个项目能参考、依据是什么”的问题。
### 2.2 类似项目推荐
新项目启动时,根据产能、原料、工艺、场地、设备、行业类型等条件,自动推荐相似历史项目和可复用资料。例如:
> 该项目与 A 项目、B 项目较相似,建议参考 A 项目的工艺路线、B 项目的设备清单和 C 项目的布置原则。
这部分依赖项目元数据和图纸索引质量,不完全依赖大模型本身。
### 2.3 方案辅助生成
在资料检索基础上,自动生成:
- 需求理解;
- 待确认问题;
- 初步工艺路线;
- 设备配置建议;
- 设备清单初稿;
- 设计说明草稿;
- 风险点和注意事项;
- 可参考图纸清单。
该层适合优先建设,因为它价值明显、技术门槛相对可控,也便于人工审核。
### 2.4 图纸辅助生成
图纸生成应分层看待:
| 图纸类型 | 自动化可行性 | 说明 |
|---|---:|---|
| 工艺流程图 / PFD | 高 | 适合基于模板和规则生成 |
| 设备关系图 / 流程框图 | 高 | 可由设备清单和工艺关系生成 |
| 设备清单 / BOM | 高 | 可由参数、模板和规则生成 |
| 产线布置草图 | 中 | 需要规则、场地约束和人工调整 |
| DXF/DWG 初稿 | 中 | 可通过 CAD 模板和脚本生成草图 |
| 非标设备详图 | 中低 | 需要专业模型、经验规则和校核 |
| 多专业施工图 | 低 | 涉及规范、安全、专业协同和设计责任 |
短期目标应是“辅助生成初稿”,不是“自动生成可盖章施工图”。
---
## 3. 难度与可行性判断
### 3.1 总体判断
该方向具备较强可行性,但难点不在“让 AI 回答问题”而在资料治理、工程规则结构化、CAD/BIM 接口和责任边界。
按难度排序:
1. 文档资料问答最容易;
2. 类似项目推荐和方案草稿生成较可行;
3. 设备清单生成需要设备库和选型规则;
4. 流程图、关系图、布置草图需要模板和参数化规则;
5. 完整施工图自动生成难度最高,不适合作为短期目标。
### 3.2 分项难度评估
| 建设内容 | 难度 | 近期可行性 | 主要依赖 |
|---|---:|---:|---|
| 历史资料整理与入库 | 中 | 高 | 资料收集、命名规则、元数据 |
| 设计说明 / 技术协议问答 | 低-中 | 高 | 文档解析、知识库、权限控制 |
| 扫描件资料读取 | 中 | 中高 | OCR / 文档理解能力 |
| 类似项目推荐 | 中 | 中高 | 项目标签、图纸索引、案例库 |
| 方案提纲生成 | 中 | 中高 | 历史方案、专业提示词、人工审核 |
| 设备清单初稿 | 中 | 中 | 设备参数库、选型规则、模板 |
| 工艺流程图生成 | 中高 | 中 | 流程模板、设备关系、图形输出 |
| DXF/DWG 草图生成 | 中高 | 中 | CAD 二次开发、模板、图块库 |
| 规范校核 | 高 | 中低 | 规则库、规范条文结构化 |
| 完整施工图生成 | 很高 | 低 | 多专业规则、CAD/BIM 深度集成、校审体系 |
### 3.3 关键制约因素
影响项目成败的关键因素包括:
- 历史资料是否集中、完整、可授权使用;
- 图纸命名、图签、版本和项目编号是否规范;
- 是否能整理出项目元数据和图纸索引;
- 资深工程师是否愿意参与规则沉淀和结果审核;
- 现有 CAD/BIM 软件是否允许二次开发或接口调用;
- 数据安全、权限隔离和日志审计是否满足院内要求。
---
## 4. 市面已有方案调研
目前市场上有 AI 工业设计、建筑方案设计、智能图纸识别和设计院自研系统等方向,但尚未看到一款可以直接覆盖“墙材产线设计全流程”的现成产品。现有方案更适合作为参考或局部能力补充。
### 4.1 设序科技 DesignOrder
- **定位**工业“AI 生成式设计与方案”产品公司,官网表述为“基于 AI 的新一代工业 CAD”。
- **相关能力**:工业设计、仿真、工艺和制造环节的效率提升,强调工程数据结构和 AI 生成式设计。
- **适配判断**:在工业 AI 设计方向最接近本需求,但是否适配墙材产线、非标工艺和设计院历史资料体系,需要商务沟通和 POC 验证。
- **成本判断**:商业产品或定制合作,预计成本较高,但可减少自研工业设计核心能力的时间。
### 4.2 广联达 CONCETTO
- **定位**:广联达官方 AI 建筑设计软件,面向建筑方案阶段。
- **相关能力**任务书解读、场地分析、AI 渲染、数字建模、AI 分析、AI 估算、汇报材料生成等。
- **适配判断**:适合建筑前期策划和方案设计,技术思路可参考,但与工厂产线、设备配置和工业流程设计差异较大。
- **成本判断**:商业 SaaS / 软件产品成本中高,直接适配度有限。
### 4.3 小库科技 XKool
- **定位**:建筑行业 AI 应用和建筑数字化平台,提供建筑设计、建造、管理等方向的数智化产品。
- **相关能力**:建筑 AIGC、设计云、AI-driven BIM on Cloud、规划和建筑设计辅助。
- **适配判断**:适合建筑规划和建筑设计领域,对产线工艺、设备和工程图纸复用的直接帮助有限。
- **成本判断**:商业产品成本中等,主要作为建筑 AI 平台参考。
### 4.4 Autodesk Forma
- **定位**Autodesk 面向早期建筑和场地设计的 AI 云端工具。
- **相关能力**:场地规划、体量方案、日照/风环境/微气候等分析、方案比选Autodesk 也在 Forma 中探索生成式 AI 平面布局能力。
- **适配判断**:适合建筑和场地早期规划,不是产线设备设计工具,但其“早期方案 + 多方案比选 + 分析”的产品形态值得参考。
- **成本判断**:标准商业订阅成本中等,深度定制和生态集成成本另计。
### 4.5 设计总院“智云出图”等自研案例
- **定位**:大型设计院基于自身业务建设知识库、协同系统和专业出图系统。
- **相关能力**:专业知识库、协同业务系统、桥梁结构出图、造价和外业系统等。
- **适配判断**:说明“设计院自研知识库 + 专业出图系统”路线可行,但这类系统通常依赖长期资料治理、专业规则积累和较高研发投入。
- **成本判断**:长期自研成本高,适合有持续投入和明确专业突破口的单位。
### 4.6 zcbot 院内智能体底座
zcbot 不是市面上的商业 CAD 出图软件,而是当前已有的院内智能体工作台,可作为自建路线的基础。
当前可承接的能力包括:
- 多用户 Web 控制台、任务会话、文件上传和移动端使用;
- 个人知识库 `.kb`,适合先做项目组级资料库、规范库和设备资料库;
- 院级文档检索服务客户端,可对接共享大库;
- Word、PDF、Excel、CSV 等资料读取,扫描件 PDF 可走文档理解 OCR
- 文件处理、脚本执行、表格处理、报告生成和 PPT 生成;
- 企业微信 / 个人微信入口,可用于移动端查询、推送和追问;
- 后续可扩展 CAD/DXF 解析、图签抽取、图块库、设备库和规则校核工具。
zcbot 的价值在于降低第一阶段建设成本,先把“资料复用、问答、方案草稿、报告/PPT 产物”跑起来;后续 CAD/BIM 自动化能力可以作为工具层逐步接入。
### 4.7 方案对比
| 方案 | 适用方向 | 可直接适配墙材产线 | 建设/采购成本 | 周期 | 综合判断 |
|---|---|:---:|---:|---:|---|
| 设序科技 DesignOrder | 工业 AI 设计 | 中 | 高 | 1-3 个月 POC | 最接近工业设计方向,但需验证领域适配 |
| 广联达 CONCETTO | 建筑方案设计 | 低 | 中高 | 1-2 个月试用评估 | 可参考方案生成形态,直接适配度低 |
| 小库 XKool | 建筑 AI / BIM 云 | 低 | 中 | 1-2 个月试用评估 | 建筑方向较强,产线场景需另行开发 |
| Autodesk Forma | 场地和建筑早期设计 | 低 | 中 | 1-2 个月试用评估 | 可参考多方案比选和分析能力 |
| 大型设计院自研系统 | 专业知识库和出图 | 中高 | 高 | 6-18 个月以上 | 路线可借鉴,投入和组织要求高 |
| zcbot 自建底座 | 院内智能体和资料复用 | 中高 | 低-中 | 1-2 个月初版 | 最适合作为低成本起步路线 |
---
## 5. 自建建设路线
如果采用自建路线,建议分阶段推进,不宜直接进入完整 CAD 出图。
### 5.1 阶段一:资料知识库与类似项目检索
- **目标**:能查资料、找类似项目、列参考依据、生成方案提纲。
- **周期**1-2 个月。
- **主要工作**
- 整理历史项目资料;
- 建立项目元数据表;
- 建立图纸目录和图纸索引;
- 导入设计说明、设备清单、技术协议和规范;
- 建立自然语言查询和类似项目推荐能力。
- **产出**
- 工程资料知识库;
- 项目案例库;
- 图纸索引库;
- 类似项目推荐结果;
- 方案提纲和参考资料清单。
- **可用底座**zcbot + 个人知识库 + 院级文档检索服务。
### 5.2 阶段二:方案生成与设备清单初拟
- **目标**:根据新项目输入生成初步方案、设备配置建议、设备清单初稿和风险点。
- **周期**2-4 个月。
- **主要工作**
- 建立设备参数库;
- 梳理设备选型规则;
- 建立方案模板和设计说明模板;
- 引入人工审核和修订机制;
- 将输出格式标准化为 DOCX、PDF、Excel 或 PPT。
- **产出**
- 初步技术方案;
- 设备清单初稿;
- 设计说明草稿;
- 风险点和待确认问题;
- 汇报材料草稿。
### 5.3 阶段三:流程图和布置草图生成
- **目标**:生成 PFD、设备关系图、简单布置草图或 DXF/DWG 初稿。
- **周期**3-6 个月。
- **主要工作**
- 建立标准流程图模板;
- 建立标准图块库;
- 开发参数化绘图脚本;
- 对接 CAD/DXF 生成工具;
- 让工程师在 CAD 中继续深化。
- **产出**
- 工艺流程图;
- 设备关系图;
- 初步布置草图;
- DXF/DWG 草稿;
- CAD 模板和图块库。
### 5.4 阶段四:专业图纸半自动生成与校核
- **目标**:在特定专业和标准化图纸上实现半自动生成和辅助校核。
- **周期**6-18 个月。
- **主要工作**
- 选择标准化程度高的图纸类型;
- 建立专业规则库;
- 接入 CAD/BIM 二次开发接口;
- 建立规范校核程序;
- 对接校审、版本和权限流程。
- **产出**
- 专业图纸半自动生成模块;
- 规则校核模块;
- 图纸版本和审批流程;
- 多专业协同接口。
---
## 6. 成本与投入估算
以下为调研阶段估算,具体费用取决于数据规模、部署方式、模型选择、是否采购商业产品、是否接入 CAD/BIM。
### 6.1 三类路线成本比较
| 路线 | 周期 | 成本级别 | 适用情况 | 主要成本 |
|---|---:|---:|---|---|
| 商业产品试用 / POC | 1-3 个月 | 中高-高 | 想快速验证外部工业 AI 能力 | 软件采购、定制服务、接口适配 |
| 基于 zcbot 自建轻量系统 | 1-2 个月初版 | 低-中 | 先做资料复用、问答、方案草稿 | 资料治理、人力、模型 API、少量集成 |
| 完整自研设计平台 | 6-18 个月以上 | 高 | 要深度接入 CAD/BIM 和校审流程 | 研发团队、规则库、CAD 二开、长期维护 |
### 6.2 人力投入估算
| 角色 | 阶段一 | 阶段二 | 阶段三及以后 |
|---|---:|---:|---:|
| 业务负责人 | 0.2-0.5 人月 | 0.5 人月 | 持续参与 |
| 资深设计工程师 | 0.5-1 人月 | 1-2 人月 | 持续参与规则和校核 |
| 资料整理人员 | 1-2 人月 | 1-2 人月 | 持续补充 |
| AI / 后端工程师 | 1-2 人月 | 2-4 人月 | 3-8 人月 |
| CAD/BIM 二开工程师 | 可暂不投入 | 0.5-1 人月调研 | 2-6 人月 |
| 运维 / 安全 | 0.2-0.5 人月 | 0.5 人月 | 持续维护 |
### 6.3 费用构成
主要费用包括:
- 大模型 API 或私有模型部署费用;
- 文档解析、OCR、向量检索或知识库服务费用
- 服务器、存储、备份和安全审计成本;
- 商业软件试用、采购或定制费用;
- CAD/BIM 二次开发费用;
- 资料治理和专业工程师审核的人力成本;
- 后续运维、模型升级和系统迭代成本。
阶段一成本通常不应过高,核心目标是验证资料复用和方案草稿是否真的节省工程师时间。若阶段一无法产生明确价值,不建议直接进入高成本 CAD 自动出图。
---
## 7. 主要风险
### 7.1 资料治理风险
历史资料如果分散、命名混乱、版本不清、缺少图签和项目编号AI 很难准确检索和复用。资料治理是该项目的基础工作,不是附属工作。
### 7.2 图纸理解风险
DWG 图纸中的图层、图块、尺寸、标注、设备编号、管线关系等信息不能完全依赖大模型理解,需要专门解析工具、图层规范和结构化抽取流程。
### 7.3 专业规则风险
设备间距、检修空间、安全距离、输送角度、管道坡度、电气容量等规则必须由专业工程师定义,并固化为规则库或校核程序。不能让大模型凭经验自由生成关键工程判断。
### 7.4 设计责任风险
AI 输出应定位为辅助草稿,不应直接作为正式交付物。关键技术方案、施工图和专业图纸仍需工程师审核、校核和签字确认。
### 7.5 数据安全风险
设计院资料涉及客户信息、商业秘密和知识产权。系统应优先考虑私有化部署、权限隔离、日志审计、数据备份和模型调用边界。
### 7.6 期望管理风险
如果一开始宣传“自动出施工图”,很容易造成过高预期。建议统一口径为“资料复用、方案辅助、图纸草稿和专业校核辅助”,逐步建设。
---
## 8. 调研结论
### 8.1 是否值得做
值得做,但应分层推进。
短期价值不在自动出图,而在:
- 快速查找历史资料;
- 推荐类似项目;
- 总结历史经验;
- 生成方案提纲;
- 初拟设备清单;
- 输出设计说明和汇报材料草稿。
这些工作在方案阶段耗时较多、复用价值高、人工审核成本可控,适合作为第一阶段目标。
### 8.2 是否有现成软件
目前没有一款现成软件可以直接覆盖“墙材产线设计”的完整流程。
现有商业产品可提供参考或局部能力:
- 设序科技更接近工业 AI 设计方向;
- 广联达 CONCETTO、小库、Autodesk Forma 更偏建筑方案和场地规划;
- 大型设计院自研案例证明路线可行,但投入较高;
- zcbot 可作为现有院内智能体底座,先承担资料知识库、方案草稿、文档产物和后续工具接入。
### 8.3 推荐路线
建议采用:
> zcbot 自建底座 + 历史资料知识库 + 工程规则逐步结构化 + 后续 CAD/BIM 工具接入。
不建议一开始采购或自研完整智能出图平台。更稳妥的路径是:
1. 先做资料知识库和类似项目检索;
2. 再做方案草稿和设备清单初拟;
3. 再做流程图、设备关系图和 DXF/DWG 草图;
4. 最后选择标准化程度高的图纸类型推进半自动出图和校核。
---
## 9. 建议下一步调研内容
正式立项前,建议补充调研以下内容:
1. **资料现状**
- 历史项目数量;
- 文件格式和存放位置;
- 图纸命名、版本、图签和项目编号规范;
- 是否已有电子化设备清单和参数表。
2. **业务优先级**
- 哪类项目复用率最高;
- 方案阶段最耗时的工作是什么;
- 哪些内容可接受 AI 生成草稿;
- 哪些内容必须人工严格绘制和校核。
3. **软件环境**
- 当前使用 AutoCAD、Revit、SolidWorks、Plant 3D 或其他软件的情况;
- 是否已有 PDM、PLM、ERP、图档系统或设备数据库
- 是否允许系统接入 CAD/BIM 软件接口。
4. **外部产品验证**
- 联系设序科技等工业 AI 厂商,确认是否支持产线类场景;
- 评估是否可做小范围 POC
- 获取报价、交付周期、私有化能力和数据安全条款。
5. **自建条件评估**
- zcbot 部署环境;
- 院级文档检索服务接入条件;
- 模型调用方式和数据出域要求;
- 资料治理负责人和专业审核机制。
---
## 10. 参考资料
检索日期2026-07-23。
- 设序科技 DesignOrder 官网,公司介绍与“基于 AI 的新一代工业 CAD”定位https://www.designorder.cn/zh/about
- 广联达 CONCETTO 官网AI 建筑设计软件与全流程方案设计能力https://www.concetto.cn/
- 小库科技 XKool 官网,建筑 AIGC、设计云与 AI-driven BIM on Cloud 能力https://www.xkool.ai/zh
- Autodesk Forma 官网AI-powered cloud software for site planning and analysishttps://www.autodesk.com/products/forma
- Autodesk NewsForma 早期设计定位与生成式 AI 功能动态https://adsknews.autodesk.com/en/news/building-layout-explorer-in-autodesk-forma/

View File

@ -15,6 +15,86 @@ from core.context import (
class ContextCompactionTests(unittest.TestCase): class ContextCompactionTests(unittest.TestCase):
def test_default_policy_strips_reasoning_without_mutating_history(self) -> None:
messages = [
{"role": "user", "content": "question"},
{
"role": "assistant",
"content": "answer",
"reasoning_content": "private reasoning",
"provider_specific_fields": {"reasoning_content": "duplicate"},
},
]
prepared, stats = prepare_messages_with_stats(
messages, compact_threshold_chars=10_000,
)
self.assertNotIn("reasoning_content", prepared[1])
self.assertNotIn("provider_specific_fields", prepared[1])
self.assertEqual(prepared[1]["content"], "answer")
self.assertEqual(stats["stripped_reasoning_messages"], 1)
self.assertGreater(stats["stripped_reasoning_chars"], 0)
self.assertEqual(messages[1]["reasoning_content"], "private reasoning")
def test_tool_call_policy_keeps_only_tool_reasoning(self) -> None:
messages = [
{
"role": "assistant",
"content": None,
"reasoning_content": "need a tool",
"tool_calls": [{
"id": "tc1", "type": "function",
"function": {"name": "shell", "arguments": "{}"},
}],
},
{"role": "tool", "tool_call_id": "tc1", "content": "ok"},
{
"role": "assistant",
"content": "done",
"reasoning_content": "final reasoning",
},
]
prepared, stats = prepare_messages_with_stats(
messages,
reasoning_replay="tool_calls",
compact_threshold_chars=10_000,
)
self.assertEqual(prepared[0]["reasoning_content"], "need a tool")
self.assertNotIn("reasoning_content", prepared[2])
self.assertEqual(stats["stripped_reasoning_messages"], 1)
def test_tool_call_policy_promotes_nested_reasoning_content(self) -> None:
messages = [{
"role": "assistant",
"content": None,
"provider_specific_fields": {"reasoning_content": "nested reasoning"},
"tool_calls": [{
"id": "tc1", "type": "function",
"function": {"name": "shell", "arguments": "{}"},
}],
}, {"role": "tool", "tool_call_id": "tc1", "content": "ok"}]
prepared = prepare_messages_for_llm(
messages, reasoning_replay="tool_calls",
)
self.assertEqual(prepared[0]["reasoning_content"], "nested reasoning")
def test_all_policy_preserves_reasoning_verbatim(self) -> None:
message = {
"role": "assistant",
"content": "answer",
"reasoning_content": "reasoning",
"provider_specific_fields": {"signature": "opaque"},
}
prepared = prepare_messages_for_llm([message], reasoning_replay="all")
self.assertEqual(prepared[0], message)
def test_preserves_system_and_recent_messages(self) -> None: def test_preserves_system_and_recent_messages(self) -> None:
messages = [ messages = [
{"role": "system", "content": "rules"}, {"role": "system", "content": "rules"},

View File

@ -146,6 +146,7 @@ class _FakeLLM:
_FAKE_CAPS = SimpleNamespace( _FAKE_CAPS = SimpleNamespace(
reliable_context=100, # 极小上限 → 体量必然超阈值,不用造几十万字符 reliable_context=100, # 极小上限 → 体量必然超阈值,不用造几十万字符
family="deepseek_v4", variant="flash", family="deepseek_v4", variant="flash",
reasoning_replay="tool_calls",
input_cny_per_mtoken=1.0, output_cny_per_mtoken=2.0, input_cny_per_mtoken=1.0, output_cny_per_mtoken=2.0,
) )
@ -226,6 +227,35 @@ class MaybeFoldTests(unittest.TestCase):
self.assertEqual(sess.messages, before) # 内存零污染 self.assertEqual(sess.messages, before) # 内存零污染
self.assertTrue(any(e.get("type") == "warn" for e in events)) self.assertTrue(any(e.get("type") == "warn" for e in events))
def test_fold_input_strips_final_reasoning_but_keeps_tool_reasoning(self) -> None:
sess = self._session()
sess.messages[2]["reasoning_content"] = "final reasoning"
sess.messages[4] = {
"role": "assistant",
"content": None,
"reasoning_content": "tool reasoning",
"tool_calls": [{
"id": "tc1", "type": "function",
"function": {"name": "shell", "arguments": "{}"},
}],
}
sess.messages.insert(5, {
"role": "tool", "tool_call_id": "tc1", "name": "shell", "content": "ok",
})
llm = _FakeLLM("## 目标\n...")
with patch.object(cf, "persist_fold"), patch.object(cf, "record_chat_usage"):
stats = cf.maybe_fold(
sess, llm, _FAKE_CAPS, user_id=uuid4(), emit=lambda e: None,
)
self.assertIsNotNone(stats)
sent = llm.last_messages[:-1]
final_msg = next(m for m in sent if m.get("content", "").startswith("a000"))
tool_msg = next(m for m in sent if m.get("tool_calls"))
self.assertNotIn("reasoning_content", final_msg)
self.assertEqual(tool_msg["reasoning_content"], "tool reasoning")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -97,6 +97,7 @@ class LLMKwargsTests(unittest.TestCase):
self.assertEqual(caps.output_cny_per_mtoken, 2.0) self.assertEqual(caps.output_cny_per_mtoken, 2.0)
self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.02) self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.02)
self.assertEqual(caps.thinking_transport, "extra_body") self.assertEqual(caps.thinking_transport, "extra_body")
self.assertEqual(caps.reasoning_replay, "tool_calls")
def test_other_controllable_profiles_declare_transport(self) -> None: def test_other_controllable_profiles_declare_transport(self) -> None:
models_dir = Path(__file__).resolve().parents[1] / "config" / "models" models_dir = Path(__file__).resolve().parents[1] / "config" / "models"

View File

@ -111,6 +111,10 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("formatContextStats", src) self.assertIn("formatContextStats", src)
self.assertIn("context_original_chars", src) self.assertIn("context_original_chars", src)
self.assertIn("context_stripped_reasoning_messages", src)
self.assertIn("当前请求视图压缩", src)
self.assertIn("历史已整理", src)
self.assertIn('p > 1 ? "100%+"', src)
self.assertIn("cache_hit_tokens", src) self.assertIn("cache_hit_tokens", src)
def test_custom_task_name_is_optional_with_explicit_directory(self) -> None: def test_custom_task_name_is_optional_with_explicit_directory(self) -> None:

View File

@ -2,8 +2,15 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
from core.task_title import clean_generated_title, is_attachment_only_message from core.task_title import (
clean_generated_title,
generate_task_title,
is_attachment_only_message,
)
class TaskTitleTests(unittest.TestCase): class TaskTitleTests(unittest.TestCase):
@ -32,6 +39,46 @@ class TaskTitleTests(unittest.TestCase):
)) ))
self.assertFalse(is_attachment_only_message("")) self.assertFalse(is_attachment_only_message(""))
def test_model_failure_applies_local_fallback_title(self):
task_id = uuid4()
user_id = uuid4()
read_session = MagicMock()
read_session.execute.return_value.first.return_value = SimpleNamespace(
auto_title_pending=True,
auto_title_version=3,
)
write_session = MagicMock()
write_session.execute.return_value.rowcount = 1
read_ctx = MagicMock()
read_ctx.__enter__.return_value = read_session
write_ctx = MagicMock()
write_ctx.__enter__.return_value = write_session
with (
patch("core.task_title.session_scope", side_effect=[read_ctx, write_ctx]),
patch(
"core.task_title.load_config",
return_value={
"default_model": "deepseek_v4.flash",
"models_dir": "config/models",
},
),
patch("core.task_title.ModelCapabilities.load", return_value=MagicMock()),
patch("core.task_title.LLM", side_effect=RuntimeError("provider unavailable")),
):
title = generate_task_title(
task_id=task_id,
user_id=user_id,
user_message="分析手机端自动命名失败\n补充说明",
model_profile="deepseek_v4.flash",
)
self.assertEqual(title, "分析手机端自动命名失败")
values = write_session.execute.call_args.args[0].compile().params
self.assertEqual(values["name"], "分析手机端自动命名失败")
self.assertFalse(values["auto_title_pending"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -126,6 +126,8 @@ class TasksCrudTests(unittest.TestCase):
# 详情带上下文压力字段 # 详情带上下文压力字段
d = _client.get(f"/v1/tasks/{tid}", headers=_AUTH).json() d = _client.get(f"/v1/tasks/{tid}", headers=_AUTH).json()
self.assertIn("context_window_chars", d) self.assertIn("context_window_chars", d)
self.assertIn("context_compacted_tool_messages", d)
self.assertIn("context_stripped_reasoning_messages", d)
self.assertIn("context_folds", d) self.assertIn("context_folds", d)
# PATCH:非法 status 400;description 生效 # PATCH:非法 status 400;description 生效

View File

@ -258,15 +258,16 @@ def register_task_routes(app, *, require_user) -> None:
def get_task(task_id: str, user_id: UUID = Depends(require_user)): def get_task(task_id: str, user_id: UUID = Depends(require_user)):
"""单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。 """单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。
额外带上下文压力字段(仅详情端点,列表不加 task 一条 sum 聚合,列表 额外带上下文压力字段(仅详情端点,列表不加):窗口消息先按当前模型的
100 ×聚合不值得):`context_window_chars`(当前窗口体量,idx>=base reasoning_replay 生成 provider-bound 视图再计算 `context_window_chars`
payload 字节和)`context_limit_chars`(reliable_context×2.5 折算容量) `context_limit_chars``context_pressure`同时返回当前视图会压缩的旧工具
`context_pressure`(前者/后者,0-1+)`context_folds`(折叠次数,= 消息数剥离的 reasoning 消息数和累计 `context_folds`前端头部指示环用
usage_events kind='context_fold' 计数)前端头部压缩指示环用
""" """
from sqlalchemy import Text as SAText, cast as sa_cast from core.context import (
CHARS_PER_TOKEN,
from core.context import CHARS_PER_TOKEN COMPACT_CONTEXT_RATIO,
prepare_messages_with_stats,
)
from core.storage.models import UsageEvent from core.storage.models import UsageEvent
try: try:
@ -283,10 +284,13 @@ def register_task_routes(app, *, require_user) -> None:
select(func.count()).select_from(Message).where(Message.task_id == tid) select(func.count()).select_from(Message).where(Message.task_id == tid)
).scalar_one() ).scalar_one()
usage = usage_aggregates(s, [tid]) usage = usage_aggregates(s, [tid])
window_chars = s.execute( window_messages = [
select(func.coalesce(func.sum(func.length(sa_cast(Message.payload, SAText))), 0)) dict(payload) for payload in s.execute(
select(Message.payload)
.where(Message.task_id == tid, Message.idx >= (row.context_base_idx or 0)) .where(Message.task_id == tid, Message.idx >= (row.context_base_idx or 0))
).scalar_one() .order_by(Message.idx)
).scalars().all()
]
folds = s.execute( folds = s.execute(
select(func.count()).select_from(UsageEvent) select(func.count()).select_from(UsageEvent)
.where(UsageEvent.task_id == tid, UsageEvent.kind == "context_fold") .where(UsageEvent.task_id == tid, UsageEvent.kind == "context_fold")
@ -295,6 +299,14 @@ def register_task_routes(app, *, require_user) -> None:
# 容量按 task 当前模型折算;模型档案读不出(profile 已下线等)→ 字段置 None, # 容量按 task 当前模型折算;模型档案读不出(profile 已下线等)→ 字段置 None,
# 前端画灰环,不 500。 # 前端画灰环,不 500。
limit_chars = None limit_chars = None
window_chars = 0
context_stats: dict[str, int] = {
"sent_chars": 0,
"saved_chars": 0,
"compacted_tool_messages": 0,
"stripped_reasoning_messages": 0,
"stripped_reasoning_chars": 0,
}
try: try:
from core.agent_builder import load_config from core.agent_builder import load_config
from core.capabilities import ModelCapabilities from core.capabilities import ModelCapabilities
@ -303,9 +315,31 @@ def register_task_routes(app, *, require_user) -> None:
profile = d.get("model_profile") or cfg["default_model"] profile = d.get("model_profile") or cfg["default_model"]
caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"]) caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
limit_chars = int(caps.reliable_context * CHARS_PER_TOKEN) limit_chars = int(caps.reliable_context * CHARS_PER_TOKEN)
compact_threshold = int(
caps.reliable_context * COMPACT_CONTEXT_RATIO * CHARS_PER_TOKEN
)
_, context_stats = prepare_messages_with_stats(
window_messages,
compact_threshold_chars=compact_threshold,
reasoning_replay=caps.reasoning_replay,
)
window_chars = context_stats["original_chars"]
except Exception: except Exception:
pass # 模型档案不可用时仍返回原始 payload 的近似体量;容量保持 None前端灰环。
from core.context import _message_chars
window_chars = sum(_message_chars(message) for message in window_messages)
d["context_window_chars"] = int(window_chars) d["context_window_chars"] = int(window_chars)
d["context_sent_chars"] = int(context_stats.get("sent_chars", window_chars))
d["context_saved_chars"] = int(context_stats.get("saved_chars", 0))
d["context_compacted_tool_messages"] = int(
context_stats.get("compacted_tool_messages", 0)
)
d["context_stripped_reasoning_messages"] = int(
context_stats.get("stripped_reasoning_messages", 0)
)
d["context_stripped_reasoning_chars"] = int(
context_stats.get("stripped_reasoning_chars", 0)
)
d["context_limit_chars"] = limit_chars d["context_limit_chars"] = limit_chars
d["context_pressure"] = ( d["context_pressure"] = (
round(int(window_chars) / limit_chars, 4) if limit_chars else None round(int(window_chars) / limit_chars, 4) if limit_chars else None

View File

@ -2951,6 +2951,14 @@ function handleSseEvent(ev, asstCard, ctx) {
state.taskMeta.context_limit_chars = cs.context_limit_chars; state.taskMeta.context_limit_chars = cs.context_limit_chars;
state.taskMeta.context_pressure = state.taskMeta.context_pressure =
(cs.context_original_chars || 0) / cs.context_limit_chars; (cs.context_original_chars || 0) / cs.context_limit_chars;
state.taskMeta.context_sent_chars = cs.context_sent_chars || 0;
state.taskMeta.context_saved_chars = cs.context_saved_chars || 0;
state.taskMeta.context_compacted_tool_messages =
cs.context_compacted_tool_messages || 0;
state.taskMeta.context_stripped_reasoning_messages =
cs.context_stripped_reasoning_messages || 0;
state.taskMeta.context_stripped_reasoning_chars =
cs.context_stripped_reasoning_chars || 0;
refreshCtxRing(); refreshCtxRing();
} }
// 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示 // 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示

View File

@ -79,12 +79,18 @@ export function ctxRingTitle(t) {
if (p >= 0.85) lines.push("已达折叠阈值 85%:下次发消息时自动把早期内容整理为摘要"); if (p >= 0.85) lines.push("已达折叠阈值 85%:下次发消息时自动把早期内容整理为摘要");
else if (p >= 0.5) lines.push("已过 50% 压缩门槛:发送时旧工具输出会被压缩"); else if (p >= 0.5) lines.push("已过 50% 压缩门槛:发送时旧工具输出会被压缩");
else lines.push("未压缩:体量未到 50% 门槛,上下文原样发送"); else lines.push("未压缩:体量未到 50% 门槛,上下文原样发送");
const stripped = t.context_stripped_reasoning_messages || 0;
const compacted = t.context_compacted_tool_messages || 0;
if (stripped > 0) {
lines.push(`模型输入已剥离 ${stripped} 条历史思考(页面历史仍保留)`);
}
if (compacted > 0) {
lines.push(`当前请求视图压缩 ${compacted} 条旧工具输出`);
}
} else { } else {
lines.push("窗口占用未知(模型容量档案不可用)"); lines.push("窗口占用未知(模型容量档案不可用)");
} }
if (t.context_folds > 0) { lines.push(`历史已整理 ${t.context_folds || 0} 次(早期消息折叠为摘要,原文在历史里可查)`);
lines.push(`已整理 ${t.context_folds} 次(早期消息折叠为摘要,原文在历史里可查)`);
}
return lines.join("\n"); return lines.join("\n");
} }
@ -97,7 +103,7 @@ export function ctxRingHtml(t) {
: p >= 0.85 ? "#c0392b" : p >= 0.85 ? "#c0392b"
: p >= 0.5 ? "#d99a1b" : p >= 0.5 ? "#d99a1b"
: "#3a9d5d"; : "#3a9d5d";
const label = known ? `${Math.round(p * 100)}%` : ""; const label = known ? (p > 1 ? "100%+" : `${Math.round(p * 100)}%`) : "";
const folds = t.context_folds || 0; const folds = t.context_folds || 0;
const foldBadge = folds > 0 const foldBadge = folds > 0
? `<span style="font-size:11px;">已整理×${folds}</span>` : ""; ? `<span style="font-size:11px;">已整理×${folds}</span>` : "";