Compare commits
3 Commits
d7cb88c7ba
...
5430379d76
| Author | SHA1 | Date |
|---|---|---|
|
|
5430379d76 | |
|
|
43a1b548aa | |
|
|
fdbaf934b6 |
|
|
@ -5,6 +5,12 @@
|
|||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## 0.62.9 — 2026-08-06
|
||||
|
||||
- 长对话不再把普通历史思考过程重复发送给模型,减少无效上下文占用;工具调用需要的推理状态仍会按模型协议保留。
|
||||
- 对话顶部上下文环改按实际模型输入统计,悬停时可查看剥离的历史思考、当前压缩的旧工具输出和累计整理次数;超出可靠容量时显示 `100%+`。
|
||||
- 新对话自动命名遇到模型服务暂时不可用时,会使用首条消息生成本地兜底标题,不再长期停留为“新对话”。
|
||||
|
||||
## 0.62.8 — 2026-08-06
|
||||
|
||||
- 文件面板把新建文件夹、从其他目录选入和上传文件收进统一的“添加”菜单,减少顶栏按钮数量;隐藏目录、刷新和收起入口保持常驻。
|
||||
|
|
|
|||
|
|
@ -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 够用。
|
||||
|
||||
模型思考参数由 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)。
|
||||
|
||||
**新对话入口(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_dir,name 可选,并可预设 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_dir,name 可选,并可预设 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 事实源语义,不复制文件、不引不可变对象存储。
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `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
|
||||
|
||||
- **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.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。
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ variants:
|
|||
thinking_transport: extra_body
|
||||
reasoning_effort_levels: [low, high, max]
|
||||
default_reasoning_effort: high
|
||||
reasoning_replay: tool_calls # 普通跨轮剥离;工具调用 reasoning 按官方协议完整回传
|
||||
code_quality: good
|
||||
enable_run_python: true
|
||||
max_iterations: 120 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
|
||||
|
|
@ -41,6 +42,7 @@ variants:
|
|||
thinking_transport: extra_body
|
||||
reasoning_effort_levels: [low, medium, high, max]
|
||||
default_reasoning_effort: medium
|
||||
reasoning_replay: tool_calls
|
||||
code_quality: excellent
|
||||
enable_run_python: true
|
||||
max_iterations: 150 # backstop 兜底,非"轮"预算;真正的空转防护是 loop 的无进展熔断 + _RepeatGuard
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.62.8"
|
||||
__version__ = "0.62.9"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import yaml
|
|||
|
||||
from .llm_params import THINKING_TRANSPORTS
|
||||
|
||||
REASONING_REPLAY_POLICIES = {"none", "tool_calls", "all"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelCapabilities:
|
||||
|
|
@ -32,6 +34,9 @@ class ModelCapabilities:
|
|||
thinking_transport: str = "none"
|
||||
reasoning_effort_levels: List[str] = field(default_factory=list)
|
||||
default_reasoning_effort: str = ""
|
||||
# 历史 reasoning 发回模型的策略。持久化原始响应与 provider-bound 输入分离:
|
||||
# none=全部剥离;tool_calls=仅工具调用 assistant 保留;all=完整原样回传。
|
||||
reasoning_replay: str = "none"
|
||||
|
||||
# 代码 / 沙盒
|
||||
code_quality: str = "good"
|
||||
|
|
@ -86,6 +91,11 @@ class ModelCapabilities:
|
|||
f"档案 {path} 的 thinking_transport={caps.thinking_transport!r} 无效;"
|
||||
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":
|
||||
raise ValueError(
|
||||
f"档案 {path} 开启 thinking 时必须声明可验证的 thinking_transport"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,73 @@ COMPACT_CONTEXT_RATIO = 0.5
|
|||
RATIO_MIN = 1.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:
|
||||
"""把 chars/token 比值夹进合理带宽。"""
|
||||
|
|
@ -174,11 +241,13 @@ def prepare_messages_for_llm(
|
|||
keep_recent: int = 12,
|
||||
old_tool_chars: int = 2_000,
|
||||
compact_threshold_chars: int = 0,
|
||||
reasoning_replay: str = "none",
|
||||
) -> List[dict[str, Any]]:
|
||||
"""返回发给 LLM 的 messages 副本。
|
||||
|
||||
- system 和最近 keep_recent 条消息原样保留。
|
||||
- 较旧且过长的 tool content 压缩为头尾摘要。
|
||||
- assistant reasoning 按模型能力档案裁剪,原始 Session/DB 历史不变。
|
||||
- assistant 的 tool_call.arguments 一律原样保留(改写会毒化模型,见模块注释)。
|
||||
- role/tool_call_id/name 等协议字段不变。
|
||||
"""
|
||||
|
|
@ -187,6 +256,7 @@ def prepare_messages_for_llm(
|
|||
keep_recent=keep_recent,
|
||||
old_tool_chars=old_tool_chars,
|
||||
compact_threshold_chars=compact_threshold_chars,
|
||||
reasoning_replay=reasoning_replay,
|
||||
)
|
||||
return prepared
|
||||
|
||||
|
|
@ -197,15 +267,19 @@ def prepare_messages_with_stats(
|
|||
keep_recent: int = 12,
|
||||
old_tool_chars: int = 2_000,
|
||||
compact_threshold_chars: int = 0,
|
||||
reasoning_replay: str = "none",
|
||||
) -> tuple[List[dict[str, Any]], dict[str, int]]:
|
||||
"""返回发给 LLM 的 messages 副本和压缩统计。
|
||||
|
||||
`compact_threshold_chars`:上下文压力门槛。总体量(原始 chars)未超过它时**完全不压缩**
|
||||
`reasoning_replay`:provider 对历史 reasoning 的回传要求;清洗先于体量统计与压缩。
|
||||
|
||||
`compact_threshold_chars`:上下文压力门槛。总体量(清洗后 chars)未超过它时**完全不压缩**
|
||||
—— 短任务不丢旧工具细节,且 prompt 前缀逐轮字节一致、DeepSeek 等前缀缓存全程命中。
|
||||
默认 0 = 永远压缩(向后兼容)。caller(loop)按模型 reliable_context 折算传入。
|
||||
"""
|
||||
if keep_recent < 0:
|
||||
keep_recent = 0
|
||||
messages, reasoning_stats = filter_reasoning_for_replay(messages, reasoning_replay)
|
||||
# 先补齐被中断 run 留下的悬空 tool_calls(否则原样发给模型会被拒,见函数注释)。
|
||||
messages, repaired_tool_calls = _repair_dangling_tool_calls(messages)
|
||||
original_chars = sum(_message_chars(m) for m in messages)
|
||||
|
|
@ -221,6 +295,7 @@ def prepare_messages_with_stats(
|
|||
"compacted_skill_messages": 0,
|
||||
"compaction_skipped": 1,
|
||||
"repaired_tool_calls": repaired_tool_calls,
|
||||
**reasoning_stats,
|
||||
}
|
||||
return unchanged, stats
|
||||
|
||||
|
|
@ -259,5 +334,6 @@ def prepare_messages_with_stats(
|
|||
"compacted_skill_messages": compacted_skill_messages,
|
||||
"compaction_skipped": 0,
|
||||
"repaired_tool_calls": repaired_tool_calls,
|
||||
**reasoning_stats,
|
||||
}
|
||||
return prepared, stats
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from .context import (
|
|||
COMPACT_CONTEXT_RATIO,
|
||||
_message_chars,
|
||||
estimate_window_tokens,
|
||||
filter_reasoning_for_replay,
|
||||
prepare_messages_with_stats,
|
||||
)
|
||||
from .storage import session_scope
|
||||
|
|
@ -142,12 +143,14 @@ def maybe_fold(
|
|||
偏保守,原文全在 DB,可接受,不为此加持久化状态。
|
||||
"""
|
||||
msgs = session.messages
|
||||
before_chars = sum(_message_chars(m) for m in msgs)
|
||||
est_tokens = estimate_window_tokens(msgs, session.last_measured_usage())
|
||||
reasoning_replay = getattr(caps, "reasoning_replay", "none")
|
||||
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:
|
||||
return None
|
||||
head_len = session.context_head_len
|
||||
cutoff = find_cutoff(msgs, head_len)
|
||||
cutoff = find_cutoff(provider_msgs, head_len)
|
||||
if cutoff is None:
|
||||
return None
|
||||
folded_count = cutoff - head_len
|
||||
|
|
@ -157,7 +160,11 @@ def maybe_fold(
|
|||
# 缓存前缀一致,摘要调用吃到 cache hit。切点在两个列表间用「第 k 条 user 消息」对齐:
|
||||
# prepare 只改写 tool 内容 / 补占位 tool 消息,user 消息原样且相对顺序不变。
|
||||
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))
|
||||
if p_cut is None: # 理论不可达,防御
|
||||
return None
|
||||
|
|
@ -192,7 +199,10 @@ def maybe_fold(
|
|||
new_base = session.context_base + folded_count
|
||||
persist_fold(session.task_id, new_base, 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 = {
|
||||
"type": "context_fold",
|
||||
"phase": "done",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from .context import (
|
|||
CHARS_PER_TOKEN,
|
||||
COMPACT_CONTEXT_RATIO,
|
||||
calibrated_chars_per_token,
|
||||
filter_reasoning_for_replay,
|
||||
clamp_ratio,
|
||||
prepare_messages_with_stats,
|
||||
)
|
||||
|
|
@ -458,8 +459,12 @@ class AgentLoop:
|
|||
"""
|
||||
if self._ctx_chars_per_token is None:
|
||||
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.session.messages, self.session.last_measured_usage()
|
||||
provider_messages, self.session.last_measured_usage()
|
||||
)
|
||||
except Exception:
|
||||
self._ctx_chars_per_token = CHARS_PER_TOKEN
|
||||
|
|
@ -500,6 +505,7 @@ class AgentLoop:
|
|||
llm_messages, context_stats = prepare_messages_with_stats(
|
||||
self.session.messages,
|
||||
compact_threshold_chars=compact_threshold,
|
||||
reasoning_replay=getattr(self.caps, "reasoning_replay", "none"),
|
||||
)
|
||||
self._last_sent_chars = context_stats.get("sent_chars", 0)
|
||||
llm_start_event = {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ def generate_task_title(
|
|||
f"{type(e).__name__}: {e}",
|
||||
flush=True,
|
||||
)
|
||||
# 标题是一次性闸:若模型瞬时失败后仍只消费 pending,任务会永久停在
|
||||
# “新对话”。直接用首条消息的第一行作本地兜底,保证有效首发总能完成
|
||||
# 命名;不保留 pending 给后续消息重试,避免拿第二条消息误命名整段对话。
|
||||
title = clean_generated_title("", user_message)
|
||||
|
||||
# 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸:
|
||||
# 用户先改名时 PATCH 已清 false,此处 rowcount=0,不覆盖。
|
||||
|
|
|
|||
|
|
@ -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 analysis:https://www.autodesk.com/products/forma
|
||||
- Autodesk News,Forma 早期设计定位与生成式 AI 功能动态:https://adsknews.autodesk.com/en/news/building-layout-explorer-in-autodesk-forma/
|
||||
|
|
@ -15,6 +15,86 @@ from core.context import (
|
|||
|
||||
|
||||
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:
|
||||
messages = [
|
||||
{"role": "system", "content": "rules"},
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ class _FakeLLM:
|
|||
_FAKE_CAPS = SimpleNamespace(
|
||||
reliable_context=100, # 极小上限 → 体量必然超阈值,不用造几十万字符
|
||||
family="deepseek_v4", variant="flash",
|
||||
reasoning_replay="tool_calls",
|
||||
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.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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ class LLMKwargsTests(unittest.TestCase):
|
|||
self.assertEqual(caps.output_cny_per_mtoken, 2.0)
|
||||
self.assertEqual(caps.cache_hit_cny_per_mtoken, 0.02)
|
||||
self.assertEqual(caps.thinking_transport, "extra_body")
|
||||
self.assertEqual(caps.reasoning_replay, "tool_calls")
|
||||
|
||||
def test_other_controllable_profiles_declare_transport(self) -> None:
|
||||
models_dir = Path(__file__).resolve().parents[1] / "config" / "models"
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ class StaticVendorTests(unittest.TestCase):
|
|||
|
||||
self.assertIn("formatContextStats", 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)
|
||||
|
||||
def test_custom_task_name_is_optional_with_explicit_directory(self) -> None:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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):
|
||||
|
|
@ -32,6 +39,46 @@ class TaskTitleTests(unittest.TestCase):
|
|||
))
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ class TasksCrudTests(unittest.TestCase):
|
|||
# 详情带上下文压力字段
|
||||
d = _client.get(f"/v1/tasks/{tid}", headers=_AUTH).json()
|
||||
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)
|
||||
|
||||
# PATCH:非法 status 400;description 生效
|
||||
|
|
|
|||
|
|
@ -258,15 +258,16 @@ def register_task_routes(app, *, require_user) -> None:
|
|||
def get_task(task_id: str, user_id: UUID = Depends(require_user)):
|
||||
"""单 task meta(不含 messages;走 /messages 拿)。跨 user → 404。
|
||||
|
||||
额外带上下文压力字段(仅详情端点,列表不加 —— 每 task 一条 sum 聚合,列表
|
||||
100 行×聚合不值得):`context_window_chars`(当前窗口体量,idx>=base 的
|
||||
payload 字节和)、`context_limit_chars`(reliable_context×2.5 折算容量)、
|
||||
`context_pressure`(前者/后者,0-1+)、`context_folds`(折叠次数,=
|
||||
usage_events kind='context_fold' 计数)。前端头部压缩指示环用。
|
||||
额外带上下文压力字段(仅详情端点,列表不加):窗口消息先按当前模型的
|
||||
reasoning_replay 生成 provider-bound 视图,再计算 `context_window_chars`、
|
||||
`context_limit_chars`、`context_pressure`;同时返回当前视图会压缩的旧工具
|
||||
消息数、剥离的 reasoning 消息数和累计 `context_folds`。前端头部指示环用。
|
||||
"""
|
||||
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
|
||||
|
||||
try:
|
||||
|
|
@ -283,10 +284,13 @@ def register_task_routes(app, *, require_user) -> None:
|
|||
select(func.count()).select_from(Message).where(Message.task_id == tid)
|
||||
).scalar_one()
|
||||
usage = usage_aggregates(s, [tid])
|
||||
window_chars = s.execute(
|
||||
select(func.coalesce(func.sum(func.length(sa_cast(Message.payload, SAText))), 0))
|
||||
window_messages = [
|
||||
dict(payload) for payload in s.execute(
|
||||
select(Message.payload)
|
||||
.where(Message.task_id == tid, Message.idx >= (row.context_base_idx or 0))
|
||||
).scalar_one()
|
||||
.order_by(Message.idx)
|
||||
).scalars().all()
|
||||
]
|
||||
folds = s.execute(
|
||||
select(func.count()).select_from(UsageEvent)
|
||||
.where(UsageEvent.task_id == tid, UsageEvent.kind == "context_fold")
|
||||
|
|
@ -295,6 +299,14 @@ def register_task_routes(app, *, require_user) -> None:
|
|||
# 容量按 task 当前模型折算;模型档案读不出(profile 已下线等)→ 字段置 None,
|
||||
# 前端画灰环,不 500。
|
||||
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:
|
||||
from core.agent_builder import load_config
|
||||
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"]
|
||||
caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
|
||||
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:
|
||||
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_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_pressure"] = (
|
||||
round(int(window_chars) / limit_chars, 4) if limit_chars else None
|
||||
|
|
|
|||
|
|
@ -2951,6 +2951,14 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
state.taskMeta.context_limit_chars = cs.context_limit_chars;
|
||||
state.taskMeta.context_pressure =
|
||||
(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();
|
||||
}
|
||||
// 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示
|
||||
|
|
|
|||
|
|
@ -79,12 +79,18 @@ export function ctxRingTitle(t) {
|
|||
if (p >= 0.85) lines.push("已达折叠阈值 85%:下次发消息时自动把早期内容整理为摘要");
|
||||
else if (p >= 0.5) 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 {
|
||||
lines.push("窗口占用未知(模型容量档案不可用)");
|
||||
}
|
||||
if (t.context_folds > 0) {
|
||||
lines.push(`已整理 ${t.context_folds} 次(早期消息折叠为摘要,原文在历史里可查)`);
|
||||
}
|
||||
lines.push(`历史已整理 ${t.context_folds || 0} 次(早期消息折叠为摘要,原文在历史里可查)`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +103,7 @@ export function ctxRingHtml(t) {
|
|||
: p >= 0.85 ? "#c0392b"
|
||||
: p >= 0.5 ? "#d99a1b"
|
||||
: "#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 foldBadge = folds > 0
|
||||
? `<span style="font-size:11px;">已整理×${folds}</span>` : "";
|
||||
|
|
|
|||
Loading…
Reference in New Issue