Skip to content

feat(knowledge): 支持编辑待入库的解析产物 - #1041

Open
zgpnuaa wants to merge 10 commits into
xerrors:mainfrom
zgpnuaa:feat/edit-parsed-markdown-phase1
Open

zgpnuaa wants to merge 10 commits into
xerrors:mainfrom
zgpnuaa:feat/edit-parsed-markdown-phase1

Conversation

@zgpnuaa

@zgpnuaa zgpnuaa commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

这是 #1035 的按建议拆分版:只做「待入库(parsed)解析产物的编辑」这一条闭环,#1035 中的已入库编辑与索引清理已全部移除。

变更说明

背景:解析完成后、入库前的人工复核目前只能看、不能改,发现解析问题只能删掉重传重跑解析。本 PR 让待入库文件的详情弹窗内可以进入编辑(复用既有的 AgentFilePreview 编辑态),保存人工修正后的解析产物:

  • 复用既有 parsed 状态(前端该状态文案就是「待入库」),不新增状态;

  • 保存写内容寻址的新产物对象并原子切换行的引用,状态保持 parsed,用户继续走既有「入库」流程;

  • 不引入编辑器依赖:编辑区复用 AgentFilePreview(详情弹窗本来就用它渲染源文件视图);

  • 编辑入口只存在于详情弹窗(正文加载成功后出现),不提供行菜单直达编辑的快捷入口(评审建议,已采纳:省去跨 store、弹窗与内容加载传递自动编辑意图的状态链)。

  • 任务类型:feature

  • 目标:待入库的解析产物可人工修正。

  • 非目标:不做已入库文件的编辑(那必须复用既有「重新入库」Durable Task,由任务取得 ownership 后再切换权威内容,属后续独立 PR);不做版本历史、审计流水、协同编辑;不对外部 API 或 Agent 工具暴露该写入口(保持「Agent 只读知识库」)。

  • substantial / trivial 判断:非 trivial(新增 HTTP 端点、新增持久内容发布点与并发控制、新增前端交互)。完整设计(发布时点、并发、失败面、清理)集中在决策记录:docs/develop-guides/decisions/implemented/2026-09-18-parsed-only-markdown-edit.md。

工程主张与 Owner

  1. 保存写内容寻址的新对象,再用一条条件更新切换行的引用。 Owner:KnowledgeBase.update_file_markdown;commit point:update_fields_if_status 那条 UPDATE(markdown_file、状态条件与期望版本在同一条语句的 WHERE/SET 里)。于是「引用即内容身份」:任何读者(含入库认领)读到的对象由它读到的那一行唯一确定。
  2. 两个同时提交的保存只有一个能命中。 Owner:KnowledgeFileRepository.update_fields_if_status(expected_updated_at=...)。期望版本(文件行的 updated_at,随 GET 内容返回、PUT 原样回传)与允许状态构成同一条 UPDATE 的等值条件;任何写入都会推进 updated_at,并发保存的输家落空返回 409。
  3. 顺序是先写新对象、再条件更新(与 parse_file 一致)。 反过来(先条件更新)会在条件成功后、对象写入前的窗口里让入库认领通过并读到旧内容,形成「产物新、分块与向量旧」。条件落空时新对象已落盘但无人引用,刻意不删(同名对象可能正是并发赢家已切换引用的那个),由删除文件时的前缀清理收敛。
  4. 已入库内容不能从这个接口改。 Owner:EDITABLE_MARKDOWN_STATUSES = {parsed} 与前端 canEditParsedContent(同集合)。
  5. 权限与类型门控。 Owner:路由依赖 require_knowledge_base_manage + _ensure_database_supports_documents(只读连接器在此拦下)。
  6. 正文读取失败时编辑不可用。 Owner:ParsedArtifactReadError(_get_file_content_from_meta 显式抛出)→ 内容端点映射 502 且不带 content_revision;前端把「正文加载成功」作为编辑入口的必要条件。静默降级(返回修订号但没有内容)会让前端开放空白编辑器,一次保存即用空内容替换原产物。
  7. 解析产物清理只有一个实现。 Owner:KnowledgeBase.delete_parsed_objects(静态方法,点号前缀清理)。单文件删除、批量删除(HTTP 层)与文件夹删除(delete_folder)都调用它;顺序都是先删行、再清理对象。

验证情况

主张 1/3:保存后产物换新、状态不变;发布顺序正确

  • 失败面:保存没真正落盘(用户以为改了、入库还是旧内容);或顺序反了留下「产物新、向量旧」的窗口。
  • 直接证据 / 命令:
    • 真实实例端到端(浏览器:待入库文件 → 详情 → 编辑 → 修正错值 → 保存):保存后接口回读为修正值且旧值消失、basic 仍为 parsed、响应回传的新版本可用于再次保存(连续两次保存均 200)。
    • 集成测试 test_edit_parsed_document_writes_content_and_keeps_status(真实 HTTP + PostgreSQL + MinIO):同一次 GET 返回的内容与修订配成一对。
    • CI 内窗口用例 test/integration/services/test_knowledge_parsed_edit_publish_window.py:隔离 Schema + 真实 MinIO + 在跑的 Durable Task 租约,在编辑「写产物」前后各设同步点放行一次与生产同形的入库认领,断言入库读到的内容 == 行最终指向的内容;把实现改回旧顺序实测会在内容分叉上失败。
    • 单测:保存顺序(先落盘再条件更新)、CAS 的 data 携带切换后的 markdown_file、内容寻址命名。
  • 结果:Passed

主张 2:两个同时提交的保存只有一个能命中

  • 失败面:两个编辑者同时保存时双双通过校验、后写静默覆盖先写。
  • 直接证据 / 命令:集成测试 test_concurrent_edits_leave_exactly_one_winner(真实 HTTP,asyncio.gather 两个同时保存,断言 [200, 409])与 test_edit_rejects_stale_revision_without_overwriting;CI 内 repository 回归 test_update_fields_if_status_requires_matching_expected_version(含两个变异校验)。
  • 结果:Passed

主张 6:正文读取失败时编辑不可用

  • 失败面:产物对象不可读时仍返回 200+修订号,前端开放空白编辑器,保存即替换原产物。
  • 直接证据 / 命令:集成测试 test_edit_content_unavailable_returns_error_and_preserves_row(真实 HTTP + MinIO:删掉产物对象 → GET 返回 502、响应不含 content_revision、行的 markdown_file 与 updated_at 均未变)。已接入 CI。
  • 负向案例:若恢复吞异常实现,该测试会以「200 且含修订号」失败。
  • 结果:Passed

主张 7:单删/批删/文件夹删除清掉全部产物对象

  • 失败面:清理逻辑分叉后某条路径漏删(或误删互为前缀的文档),编辑留下的内容寻址对象成为永久孤儿。
  • 直接证据 / 命令:集成测试 test_delete_document_cleans_edited_parsed_objects(单删+批删:编辑后确定性名与内容寻址名并存,删除后前缀列举为空)与 test_delete_folder_cleans_child_parsed_objects(文件夹删除后子文件对象清空、行一并消失)。均已接入 CI。
  • 结果:Passed

其余主张与门禁

  • 主张 4/5:单测参数化覆盖 indexed / error_indexing / done / error_parsing;集成测试断言分块数与版本未变、非管理员 403 且产物与版本未动、空内容 400 / 缺修订 422 / 修订非法 400。Passed(本机以真实凭据执行)。
  • 前端:web/test/unit/knowledge_markdown_edit.test.js;web/test/browser/parsedMarkdownEdit.js(筛选「待入库」→ 打开详情 → 弹窗内出现编辑入口 → 进入编辑 → ESC 确认 → 保存挂起窗口草稿不丢 → 放弃不改状态 → 编辑期间视图切换被禁用)。
  • 门禁:pytest test/unit -m "not slow" 2382 passed;编辑/清理相关集成 9 passed(真实 HTTP + PG + MinIO);web lint:check / test:unit 380 passed / build 通过;verify_engineering_contracts.py 通过;git diff --check 干净。

简化 / 删除验收

本 PR 相对 #1035 是做减法:删除 purge_indexed_chunks 钩子与其 Milvus 覆写、was_indexed 分支、前后端「保存会清索引」的交互与状态集合、以及为此收窄的 CAS 语义。负向搜索确认全仓已无 purge_indexed_chunks / was_indexed / willPurgeIndexOnSave 引用。重新引入的条件是第二阶段的已入库编辑,届时走「重新入库」任务而不是本地清理。

本轮再删两块:行菜单直达编辑的快捷入口(含 openFileDetailForEdit / fileDetailStartEdit / startInEdit 整条意图传递链,负向搜索确认无残留);路由层自维护的前缀清理(收敛到 KnowledgeBase.delete_parsed_objects,负向搜索确认路由与 base 只有这一处实现)。

独立语义 Review

三轮全新上下文的独立 Reviewer 覆盖需求、完整 diff、测试与规范:

  1. 第一轮(feat(knowledge): 支持编辑解析产物 Markdown(入库前复核 / 已入库修正) #1035 时代)指出并发闸初版(读产物算 sha256)是非原子读,并发探针复现了双双通过;改为把期望版本放进 UPDATE 的 WHERE。
  2. 第二轮确认修复并指出该 guard 当时零自动回归,据此补了 CI 内执行的 repository 回归与 publish window 用例;另指出 GET 侧窗口、权限断言弱化——均已修。
  3. 第三轮(本轮)覆盖读取失败传播、清理收敛、描述同步与快捷入口移除;结论见后续评论。

未验证范围与风险

  1. 知识库 HTTP 集成整套不在 CI:CI 按 node id 点名 9 条编辑/清理用例(Durable Task worker path 步骤,有凭据且 Milvus 已起);整套仍需 TEST_USERNAME / TEST_PASSWORD 手工执行(本机已执行,编辑相关全部通过)。
  2. 真实并发只做到 HTTP 层双并发:未做多用户压力;数据库语义由 CI 内 repository 回归钉死。
  3. 组件级状态机单测未做:仓库无 jsdom / @vue/test-utils,未引入测试依赖;前端行为靠浏览器脚本与真实页面验证。
  4. updated_at 作版本的碰撞窗口:同微秒理论窗口存在(每次条件更新一次数据库往返,实际不可达);换计数器列可消除但需新列与回填。
  5. 读取失败对 get_file_info 的影响:该复合端点同样走 _get_file_content_from_meta,产物不可读时由其路由的 catch-all 返回 failed 标记(无修订号)——语义一致,但未为它单独立用例。
  6. 已入库编辑不在本 PR:需先定义发布时点并复用「重新入库」Durable Task。

界面变更

待入库文件打开详情后,正文区出现「编辑 Markdown」入口(仅正文加载成功且状态为待入库时);编辑态复用 AgentFilePreview(textarea + 浮动操作条,未保存有标记);保存期间输入与按钮禁用;编辑期间视图切换禁用;有未保存草稿时关闭需确认。

截图取自演示数据(临时库、假文件名),画面只包含弹窗本体:

详情内编辑入口 编辑态(textarea + 浮动操作条)
详情入口 编辑态

保存后(产物已换新,文件仍在「待入库」,可继续走既有「入库」):

保存后

关联事项

上一版为 #1035(同时支持 parsed 与已入库编辑),按维护者意见关闭并拆分:本 PR 只做第一阶段,已入库编辑 + 重新入库发布时点留待第二阶段。无关联 Issue。

补充说明

  • 为何不附 proposed 决策记录:拆分方案由维护者在 feat(knowledge): 支持编辑解析产物 Markdown(入库前复核 / 已入库修正) #1035 的评审意见中给出,没有待裁决的替代方案;本 PR 直接把结论写成 implemented 记录,并列出仍有替代项的取舍(含被否掉的 sha256 方案、孤儿不删的理由、快捷入口移除)。
  • 兼容性:无 schema 变更、无数据迁移。
  • 5 MiB 上限的来源:docker/nginx/default.conf 的 client_max_body_size 为 20M(server 级硬墙),且 JSON 转义会膨胀(换行 → \n 两字节),故取 5 MiB 留余量;将来调高必须同步改 nginx 配置。
  • 只读连接器(Dify / Notion)由 _ensure_database_supports_documents 在路由层拦下(400)。

背景:解析完成后、入库前的人工复核目前只能删掉重传再解析。本 PR 在既有的单文件操作
菜单(下载 / 解析 / 入库 / 重新入库 / 删除)里新增「编辑文件」,复用既有 parsed 状态
(前端该状态文案就是「待入库」),不新增状态、不引入编辑器依赖:编辑区是原生 textarea
+项目既有的 MarkdownPreview 左右分栏,预览与最终展示同一套渲染栈。

范围只放开 parsed:该状态没有派生索引,产物对象就是权威内容,覆盖写回确定性路径
{kb_id}/parsed/{file_id}.md 即发布,状态保持 parsed,用户继续走既有「入库」。已入库
内容的编辑不在本阶段——那必须复用「重新入库」Durable Task,由任务取得 ownership 后
再切换权威内容;在同步接口里另实现一套清理会留下「接口返回 409/500,但新产物已持久化、
文件仍是 indexed + 旧向量」的组合。

并发把期望版本做成落库条件:revision 是文件行的 updated_at(随内容读取一起返回、保存
原样回传),与允许状态一起构成同一条 UPDATE 的等值条件(update_fields_if_status 新增
expected_updated_at)。任何对文件的写入都会推进 updated_at,因此两个并发保存只有一条
能命中、另一条 409;编辑期间解析/入库推进状态或版本同样让条件落空。校验与发布因此是
原子步骤。顺序为先条件更新、再覆盖产物:条件落空时产物尚未写入。

验证:后端单测 18 条;test/unit/knowledge 193 条通过(另 1 条失败是临时验证目录缺
uv.lock 的环境问题);新增一条在 CI 内执行的真实 PostgreSQL 回归
(test/integration/services/test_durable_task_repository.py,隔离 schema,无需凭据),
覆盖「正确版本命中并推进 updated_at / 过期版本落空 / data 为空仍校验过滤条件」,
删掉版本条件或恢复短路这两个变异都会让它以正确原因失败;该文件全量 20 条通过。
真实 HTTP 集成用例(含 asyncio.gather 两个同时提交的保存只允许一个 200)已写好,
但知识库 HTTP 集成套件不在 CI 且本机缺 TEST_USERNAME/TEST_PASSWORD,标记为未执行。
前端单测与浏览器脚本相应更新;新增决策记录(含被否掉的「读产物算哈希」方案)。
OIDC 只把会话写回 172.25.104.79:5173,localhost:5173 是另一个来源(localStorage 不共享),
在那里跑脚本会停在登录页。
@xerrors xerrors self-assigned this Sep 21, 2026
@xerrors

xerrors commented Sep 23, 2026

Copy link
Copy Markdown
Owner

1. 保存的并发边界需要补齐

目前先通过数据库 CAS 更新版本,再覆盖 MinIO 对象,这两步并不是原子发布。update_fields_if_status 返回时数据库事务已经提交,而实际内容要等后续 _save_markdown_to_minio 才发布。

存在这样的交错顺序:

  1. 编辑请求 A 通过 CAS,更新文件版本,状态仍是 parsed;
  2. A 尚未覆盖对象,入库任务将状态改为 indexing,并读取旧 Markdown;
  3. A 覆盖对象并返回保存成功;
  4. 入库任务根据旧内容生成分块和向量。

最终会出现“解析产物是新的,分块和向量是旧的”。同一窗口内,另一个编辑者也可能读到“新 revision + 旧内容”,用新 revision 通过检查,再与 A 乱序覆盖对象。因此,当前 CAS 只能排斥使用同一旧版本的请求,不能保证内容发布与状态、版本一致。

建议让编辑与入库共享有效的发布边界。可以考虑先写不可变的新对象,再通过状态和版本条件原子切换数据库中的对象引用;这是一个可选方向,不要求为此引入完整版本管理机制。具体实现尽量保持简单,但不能只靠交换数据库更新与对象写入的顺序解决。

对应位置:backend/package/yuxi/knowledge/base.py 的 update_file_markdown,尤其是条件更新到对象覆盖之间的步骤。

2. 前端交互优先复用现有编辑组件

建议先检查项目已有的编辑组件,能复用就尽量复用,避免在 FileDetailModal 中新增一套编辑、预览、同步滚动和草稿管理逻辑。

这一阶段的交互可以保持简单:进入编辑、保存、取消,以及必要的未保存提示即可。双栏实时预览和同步滚动不是这条编辑闭环的必要条件,可以先不做,减少弹窗内的状态和维护成本。

另外,当前保存期间 textarea 仍可继续输入,但成功回调会重新读取 draftContent.value:点击保存时提交的是文本 A,等待响应期间改成 B,回调就会把 B 当作已保存内容展示并清空草稿,服务端实际只保存了 A。重新打开后,后续输入会丢失。

这里需要固定提交快照,响应只确认该快照并保留后续修改;或者采用更简单的方式,在保存期间暂时禁止编辑。关闭、重新打开弹窗时,也需要避免旧保存请求的回调污染新的编辑上下文。

对应位置:web/src/components/FileDetailModal.vue 的 textarea 和 saveMarkdown。

3. 样式保持简单

尽量复用现有组件样式和主题变量,减少新增的固定颜色、徽标和专用布局。优先保证编辑区清晰、保存与取消操作一致,不必为了这次功能增加较复杂的双栏和同步滚动设计。

希望前端实现聚焦“修正待入库内容”本身,而不是在详情弹窗里维护另一套独立编辑器。

4. 修正测试,并覆盖真实失败窗口

test_dify_query_params_and_documents_readonly 新增的 PUT 请求只传了 content,没有传请求模型要求的 revision。FastAPI 会先返回 422,无法到达测试期望的 400 只读连接器业务拒绝。建议传入格式合法的 revision;缺 revision 的 422 用例单独保留。

建议重点补充以下验证:

  • 在编辑 CAS 提交后、对象写入前设置确定性同步点,再启动入库或另一位编辑者的读取与保存,核对最终对象、版本与索引一致,而不只验证两个相同旧 revision 的请求返回 [200, 409]。
  • 延迟保存响应,在等待期间继续输入,确认草稿不会丢失,也不会被错误标记为已保存。
  • 保存后重新读取,确认内容与返回的 revision 对应。
  • 将关键 HTTP / 对象存储验证接入实际执行的测试入口,避免只有 repository 层测试覆盖到 CAS。

以上问题中,内容发布与入库的并发一致性建议作为合并前需要解决的事项;前端则尽量通过复用和简化收敛实现。


AI 辅助 Review,供参考。本次为静态代码审查,未运行本地服务、脚本或测试;上述交错场景来自代码路径分析,尚未实际复现。

按评审收紧发布与删除边界:

- 编辑先写内容寻址的新对象,再用一条 update_fields_if_status 把 markdown_file
  与状态、期望版本一起下发,校验与引用切换落在同一条 UPDATE 上;入库认领与它
  由行锁串行,入库读的仍是它认领那一刻返回的引用
- 版本条件落空时新对象刻意不删:内容寻址名是内容的纯函数,同名对象可能正是
  并发赢家已切换引用的那个;孤儿由删除时的前缀清理收敛
- 删除文件改为先删行再按前缀清对象,单条与批量一致,避免两步之间提交的编辑
  让行指向已被删掉的对象
- update_fields_if_status 去掉没有生产调用方的条件校验分支;期望版本脱离可写
  字段单独下发时显式失败,不再静默返回
- 前端复用 AgentFilePreview 编辑态,编辑期间禁用视图切换,保存用请求序号作废
  过期响应与自身上下文
- 新增 publish window 用例(真实 PostgreSQL + MinIO + Durable Task 租约),
  按 node id 接入 CI;浏览器脚本补保存挂起窗口与视图切换断言

验证:unit 2174 passed/53 skipped;publish window + repository + stats 28 passed;
知识库 HTTP 集成 46 passed;web lint/unit 339/build 通过;真实页面 Playwright 通过。
@zgpnuaa

zgpnuaa commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

感谢评审,四条都处理了,按你的顺序回。

1. 保存的并发边界

你描述的交错成立,我按你建议的方向改了:编辑不再覆盖解析产物那条确定性路径,而是先写内容寻址的新对象 {kb_id}/parsed/{file_id}.{hash16}.md,再用一条 update_fields_if_status 把 markdown_file、状态和期望版本一起下发——引用切换与版本校验落在同一条 UPDATE 的 WHERE 里。

你说「不能只靠交换数据库更新与对象写入的顺序解决」,同意,单纯换序只是把窗口挪个位置。现在闭合的方式是不再存在「固定路径被覆盖」这件事:

  • 入库认领(milvus.py 里那条只校验状态与租约的 UPDATE)与编辑的引用切换都是单条原子语句,由行锁串行。编辑先命中则引用已切换,入库拿到的 claimed_record 指向新对象;认领先命中则状态变 indexing,编辑的条件落空返回 409。你列的第三步「A 覆盖对象」没有了——A 写的是没人引用的新对象。
  • 入库读的仍是它认领那一刻返回的引用(_file_record_to_meta(claimed_record) → _read_markdown_from_minio),所以「引用即内容身份」成立,并发读不会读到覆盖一半的内容。
  • 顺序与 parse_file 对齐(先写对象、后条件更新),于是 CAS 成功即意味着对象必然已存在。

失败面:条件落空时新对象已落盘但无人引用,刻意不删。内容寻址名是内容的纯函数,两个编辑者提交同一份文本(同一处修正、脚本重放、超时重试)时,输家写的对象与赢家已切换引用的是同一个,删它会让行指向不存在的产物。代价是一次失败保存留一个孤儿,由删除文件时按前缀 {kb_id}/parsed/{file_id}.(带点号,避免 abc 误删 abcdef.md)收敛;delete_folder 也走同一条清理,因为文件夹删除不经过 HTTP 层的清理。

删除文件的两个入口(单条与批量)都改成先删行、再清理对象。原顺序(先列举清对象、后删行)除了会漏孤儿,还有一个更糟的窗口:在「列举完成、行删除之前」提交成功的编辑,它的对象在列举里已被删掉、行却指向它,于是预览与下载失败、入库转 error_parsing。改成先删行后,任何 CAS 成功的编辑其对象都写在删行之前,最坏只是多删一个对象。孤儿窗口没有消失(编辑在删行前读到版本、对象写在列举之后仍会留一个),但更窄且不再是悬空引用。

清理侧还有个配套改动:产物对象过去按固定路径 {file_id}.md 精确删除,现在按前缀 {kb_id}/parsed/{file_id}.(带点号锚点,避免 abc 误删 abcdef.md)清理——否则历次编辑留下的内容寻址对象会全部遗留成永久孤儿。文件夹删除路径也收口到 _delete_parsed_objects,它不经过 HTTP 层。

2. 前端复用现有编辑组件

改成复用 AgentFilePreview 的编辑态(:editable + :saving="savingMarkdown" + @save,startEditing 经 defineExpose 调用)。顺带核实:FileDetailModal 自己原本就在用它渲染 source 视图,所以这不是新依赖。

  • 双栏实时预览、同步滚动、弹窗内那套草稿管理整体删掉了(相对你评审的那一版,FileDetailModal.vue 删 195 行、新增 76 行),交互就剩进入编辑、保存、取消,加上原有的未保存提示。
  • 你说的「保存期间 textarea 仍可输入、回调重读 draftContent」:采用你给的更简单的方式,AgentFilePreview 的 :disabled="saving" 锁住输入与保存/取消按钮,没有引入快照机制。
  • 关闭或切换文档时的响应污染:加了保存专用的请求序号(与本文件 basicRequestSeq / contentRequestSeq 同一套写法)。序号在关闭、换文档时自增,在飞的响应发现序号不符就丢弃,不写 meta、修订和提示,并解除「保存中」,否则那次保存之后编辑框会一直是禁用态。成功与失败两条分支都做了判断。
  • 保存中不再弹「放弃未保存的修改」——服务端已经在写,那句话与事实相反,改成提示正在保存。

另外自查时发现一个我上一版引入的缺口,一并修了:草稿现在存在 AgentFilePreview 的局部状态里,而 Markdown 分支是 v-if 渲染的,编辑期间切到「源文件 / Chunks」会把编辑态连同草稿一起卸载,而且切换本身没有任何确认步骤——正好绕过你要的未保存提示。修法是编辑期间禁用视图切换(与「保存期间禁用输入」同一种收敛)。这条在真实页面上做了负向复现:去掉 :disabled 后点「源文件」,编辑框消失且草稿为空、无确认框;加回后同一操作被挡住、草稿保留。浏览器脚本里也补了这条断言。

3. 样式

双栏、徽标、专用工具栏都删了。现在只剩两条纯尺寸的 :deep 覆盖(parsed-preview-container / parsed-preview-content 的 height、max-height),与紧邻的既有 .source-preview-container 同款式;FileDetailModal.vue 里硬编码颜色数量与改动前一致(1 处,是既有的)。

4. 测试

  • test_dify_query_params_and_documents_readonly 的 PUT 补了格式合法的 revision;缺 revision 的 422 用例在 test_edit_document_rejects_invalid_requests 里保留。
  • 新增真实失败窗口用例 backend/test/integration/services/test_knowledge_parsed_edit_publish_window.py:隔离 Schema 的真实 PostgreSQL + 真实 MinIO 对象 + 一个在跑的 Durable Task 租约。在编辑「写产物」这一步的前后各设一个确定性同步点,放行一次与生产同形的入库认领(带 processing_task_id / processing_owner,走仓库的租约分支,不是简化版 UPDATE),断言 入库读到的内容 == 行最终指向的内容,以及「认领推进状态后编辑不应同时成功」。这个同步点在旧顺序里正好落在 CAS 之后、覆盖之前,也就是你描述的那个窗口;把实现改回旧顺序实测,写之前那个同步点的用例在内容分叉上失败:AssertionError: assert '# 人工修正\n\n新内容。' == '# 原始产物\n\n旧内容。'。认领确实走租约分支也验证过:把 Task 行的 worker_id 改成与认领携带的不一致,认领会返回 None、用例失败;改回即通过。
  • 接入实际执行的入口:这条用例加进 system-tests.yml;另外按 node id 把 6 条编辑 HTTP 用例加进 Verify Durable Task worker path 步骤(该步骤已有凭据、且此时 Milvus 已起——套件的知识库清理依赖它)。CAS 的 repository 契约在 test_durable_task_repository.py,本来就在 CI 里点名。
  • 延迟保存响应期间继续输入:web/test/browser/parsedMarkdownEdit.js 用路由拦截扣住 PUT,断言 textarea 与保存按钮都禁用、草稿仍标「未保存」;随后 abort(请求不到服务端,不写库),断言输入框恢复可编辑且草稿仍在、没有被误标为已保存。
  • 内容与 revision 对应:HTTP 集成里补了同一条件——after["content"] == edited 与 after["content_revision"] == payload["content_revision"] 取自同一次 GET;真实页面上也跑了连续两次保存,都是 200(修订回填生效,不会因为自己上一次的保存判冲突)。

另外我新加的 test_edit_document_requires_manage_permission 前提是错的:require_knowledge_base_read 与 require_knowledge_base_manage 都挂在 get_admin_user 上,HTTP 层构造不出「能读不能管」的用户。已改成断言非管理员被拒、且产物内容与版本都未被改动(前置与回读用管理员凭据,否则读接口本身就会 403)。

还有一处按仓库「不接受没有当前 consumer 的兼容路径」的规则回退了:上一版我给 update_fields_if_status 加的「无可写字段但带过滤条件也逐条校验」那条分支,9 个生产调用点的 data 都含可写字段(status / markdown_file / updated_by),实际打不到,已删除并恢复原来的短路。回退后 expected_updated_at 单独下发会被静默忽略(静默返回一行,调用方会当成 CAS 命中),所以又补了一句显式失败:没有可写字段却带了期望版本时直接 ValueError,仓库用例也从「断言它不生效」改成断言抛错。

本地执行情况

  • pytest test/unit -m "not slow":2174 passed, 53 skipped
  • test_knowledge_parsed_edit_publish_window.py + test_durable_task_repository.py + test_knowledge_stats_refresh.py:28 passed
  • test_knowledge_router.py(真实 HTTP + PG + MinIO,需 TEST_USERNAME/TEST_PASSWORD):46 passed,其中 6 条按 CI 的 node id 选择器单独复跑通过
  • web:lint:check 干净、test:unit 339 passed、build 通过
  • 真实页面(Playwright,已登录开发环境):筛选「待入库」后编辑入口可用 → 编辑态 → 编辑期间视图切换被挡 → 未保存草稿 ESC 不被静默丢弃 → 保存挂起窗口 → 放弃编辑不改动状态

已知限制

  • 编辑与入库并发时「入库真的建出来的分块」内容没有端到端断言(要真实 embedding 与 Milvus 写入)。上面的不变量「入库读到的内容 == 行最终指向的内容」是同一件事在不引入嵌入链路前提下可核对的形式。
  • 期望版本用 updated_at 时间戳,同微秒碰撞的理论窗口仍在(每次条件更新都要走一次数据库往返,实际不可达);换计数器列可以彻底消除,但需要新列与历史回填。
  • 知识库 HTTP 集成套件只有编辑相关的 6 条进了 CI,整套仍需凭据手工执行。
  • 组件级状态机单测缺失:仓库没有 jsdom / @vue/test-utils,没有为这个功能引入测试依赖,前端行为靠浏览器脚本与真实页面验证。

本次改动已推到分支 feat/edit-parsed-markdown-phase1(d8b6048c)。

冲突只有 .github/workflows/system-tests.yml:main 把 durable task worker path
拆成了独立 job,因此采用 main 的两 job 结构,并把 6 条解析产物编辑 HTTP 用例
放进该 job 的 Verify Durable Task worker path 步骤——那里已拉起 Milvus 且注入了
E2E 凭据,正是这批用例需要的环境;无凭据的 publish window 用例留在 system-tests
job 的统计步骤之后。

合并后复验:unit 2310 passed/58 skipped;发布窗口 + repository + stats 28 passed;
知识库 HTTP 集成 46 passed;web lint/unit 360/build 通过;真实页面 Playwright 通过。
上一轮两个 job 都在 step 8「Start runtime topology」失败,任何测试尚未执行,
疑似该次运行的环境问题(同一步骤与 main 逐字节相同)。推空提交重新触发以区分
偶发与稳定失败。
@zgpnuaa

zgpnuaa commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

关于 CI 里那 2 条红:外部镜像源事件,与本 PR 无关

本轮的 9 条 check-run 中 7 条通过,红的 2 条是 PostgreSQL, readiness and deterministic Agent path 与 Durable Task worker path。两者都停在 step 8 Start … runtime topology,日志原文:

docker compose up -d postgres redis minio etcd milvus sandbox-provisioner api worker
 minio Error unauthorized: access to the requested resource is not authorized
Error response from daemon: unauthorized: access to the requested resource is not authorized

即拉取 quay.io/minio/minio:RELEASE.2023-03-20T20-16-18Z 被 quay.io 拒绝;随后的 32 个 E2E 步骤全部 skipped——不是测试失败,是拓扑根本没起来。

三条对照:

  • 上游 main 自己同样红:run 36102326268(2026-09-25T06:19Z 的 push)日志里是同一行错误。
  • 时间线:本仓库 system-tests 最后一次全绿为 2026-09-24T07:52Z,此后全部为红。
  • 外部来源:FLINK-40802 记载 quay.io/minio/minio 自 2026-09-24 起不再允许匿名拉取(最后成功 12:07 UTC、首次失败 21:41 UTC);Docker Hub 的 minio/minio、minio/mc 更早在 2026-09-11~14 已被移除。

本机独立复核过:quay.io 的匿名 token 对 minio/minio 授予的 access[].actions 为空数组(同一流程下 prometheus/prometheus 为 ["pull"]),带该 token 请求 manifest 返回 401。

结论:这是仓库级、非本 PR 引入的故障,且不会自行恢复——需要调整 docker-compose.yml 的 MinIO 镜像来源后,这两个 job 才会重新可跑。截至 2026-09-26 未见针对此的改动。

本 PR 的验证证据不依赖这两条 job:它们是 Agent 与拓扑的 E2E,不覆盖知识库路由。本 PR 的证据分布在单测、CI 内执行的 repository 层并发回归(test/integration/services/test_durable_task_repository.py),以及一次真实实例上的浏览器端到端;各条用例的执行状态与未验证范围已在正文「验证情况」逐项标注。另外本 PR 当前 mergeable=true,那 2 条红不影响可合并状态。

@xerrors
xerrors self-requested a review September 28, 2026 07:45
@xerrors

xerrors commented Sep 28, 2026

Copy link
Copy Markdown
Owner

合并前希望处理以下问题:

  1. 正文读取失败时禁止编辑。 当前 MinIO 读取异常被捕获后仍返回修订号,前端可能开放空白编辑器,用户保存后会替换原产物。请明确传播读取失败,并在正文成功加载前禁用编辑;补充读取失败的负向测试,同时验证原对象引用与内容不变。
  2. 统一解析产物清理逻辑。 当前路由与 KnowledgeBase 重复维护前缀清理,文件夹删除又单独补调用。请收敛到一个实现,由删除用例调用,覆盖单文件、批量和文件夹删除,保留先删除记录再清理对象的顺序。
  3. 同步描述与最终实现。 PR 描述仍有“先条件更新再覆盖产物”的旧方案,部分注释也已不符合当前代码。请更新为最终行为,把完整设计解释集中到决策记录,代码只保留必要的约束说明。

另一个非阻塞建议:第一版是否可以只保留“打开详情后点击编辑”,暂不提供行菜单直接进入编辑?这样能移除跨 store、弹窗和内容加载的自动编辑意图传递。如果保留快捷入口,请说明其必要性。

按第三轮 review 处理三条必改与一条建议:

1. 正文读取失败显式传播:产物对象读取失败时 _get_file_content_from_meta
   抛 ParsedArtifactReadError,内容端点映射 502 且不带 content_revision。
   原实现吞异常后照常返回修订号,前端会开放空白编辑器,一次保存即用
   空内容替换原产物。前端同时把「正文加载成功」作为编辑入口的必要
   条件。新增集成用例:删掉产物对象 → GET 502、无修订号、行的引用与
   版本均未变。

2. 解析产物清理收敛到唯一实现 KnowledgeBase.delete_parsed_objects
   (静态方法):单文件删除、批量删除(路由层)与文件夹删除
   (delete_folder)都调用它,路由不再自行维护前缀清理;先删行再清
   对象的顺序不变。新增集成用例覆盖单删、批删与文件夹删除的产物
   对象清理(编辑留下的内容寻址名一并清掉)。

3. 描述与实现对齐:决策记录同步读取失败传播、清理单实现与入口
   收敛三处;PR 描述重写为最终行为(内容寻址发布、详情内编辑)。

4. 采纳非阻塞建议:移除文件行菜单「编辑文件」快捷入口与整条自动
   编辑意图传递链(openFileDetailForEdit / fileDetailStartEdit /
   startInEdit),编辑入口只存在于详情弹窗。

CI 的编辑用例 node 列表补 3 条新用例。

验证:单测 test_knowledge_update_file_markdown 21 passed(引用改名后
的方法);编辑/清理集成 9 passed(真实 HTTP + PostgreSQL + MinIO,
含 3 条新用例);pytest test/unit -m "not slow" 2382 passed;web
lint:check / test:unit 380 passed / build 通过;契约检查与 docs
build 通过;真实页面(详情内入口 → 编辑 → 保存 → 内容更正、状态
保持 parsed,行菜单无编辑按钮)。
@zgpnuaa

zgpnuaa commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

四点都已处理,commit 8d84e5a0(含决策记录与 PR 描述同步)。

1. 正文读取失败时禁止编辑

按建议显式传播:_get_file_content_from_meta 在产物对象读取失败时抛 ParsedArtifactReadError,内容端点映射 502 且不带 content_revision;前端同时把「正文加载成功」作为编辑入口的必要条件(loaded && !error && revision 三者齐备才出现入口)。

负向测试 test_edit_content_unavailable_returns_error_and_preserves_row(真实 HTTP + MinIO,已接入 CI):删掉产物对象后 GET 返回 502、响应不含 content_revision、行的 markdown_file 与 updated_at 均未变。恢复吞异常实现时它以「200 且含修订号」失败。

一个调用面说明(已记入决策记录):完整信息端点 GET /documents/{doc_id} 复用同一内容装配,产物不可读时走其既有 catch-all 返回 failed 标记(同样无修订号)——它的前端消费者只取入库参数,拿不到值时回退默认参数,且入库链路读同一产物也必然失败,因此没有为它单独映射 502。

2. 统一解析产物清理逻辑

收敛到唯一实现 KnowledgeBase.delete_parsed_objects(静态方法,点号前缀清理):单文件删除、批量删除(路由层)与文件夹删除(delete_folder)都调用它,路由不再自行维护前缀清理;先删行、再清理对象的顺序不变。

新增两条集成用例(真实 HTTP + MinIO,已接入 CI):test_delete_document_cleans_edited_parsed_objects(单删+批删:编辑后确定性名与内容寻址名并存,删除后前缀列举为空)、test_delete_folder_cleans_child_parsed_objects(文件夹删除后子文件对象清空、行一并消失)。

3. 同步描述与最终实现

  • PR 描述整体重写为最终行为:主张 3 已从「先条件更新再覆盖产物」改为先写内容寻址新对象、再单条条件更新切换引用;「未验证范围」里“集成套件不在 CI”的旧断言更正(编辑/清理共 9 条已按 node id 接入 Durable Task worker path 步骤);界面变更与截图全部换新(旧截图展示的是第一版已删除的行菜单入口与双栏编辑器)。
  • 完整设计集中在决策记录(发布时点、并发、失败面、孤儿不删的理由、读取失败传播的调用面、入口收敛),代码注释只保留约束说明。

4. 建议项:已采纳

移除行菜单「编辑文件」快捷入口与整条自动编辑意图传递链(openFileDetailForEdit / fileDetailStartEdit / startInEdit prop / pendingStartEdit 消费),编辑入口只存在于详情弹窗。同意你的判断——为省一次点击维护一条跨 store、弹窗与内容加载的状态传递链不值得。负向搜索确认无残留;浏览器脚本同步改为「点行 → 详情弹窗 → 编辑 Markdown」路径。

验证

  • 单测 test_knowledge_update_file_markdown.py 21 passed;pytest test/unit -m "not slow" 2382 passed
  • 编辑/清理集成 9 passed(真实 HTTP + PostgreSQL + MinIO,含 3 条新用例);本机以真实凭据执行
  • web:lint:check / test:unit 380 passed / build 通过
  • 契约检查、docs build、git diff --check 通过
  • 真实页面(本分支部署的实例):详情内入口 → 编辑 → 保存 → 内容更正、状态保持 parsed;行菜单无编辑按钮

…down-phase1

# Conflicts:
#	web/src/components/AgentFilePreview.vue

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants