Skip to content

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

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

zgpnuaa wants to merge 2 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 在既有的单文件操作菜单(下载 / 解析 / 入库 / 重新入库 / 删除)中新增**「编辑文件」**:

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

  • 保存只覆盖解析产物对象,状态保持 parsed,用户继续走既有「入库」流程;

  • 不引入编辑器依赖:编辑区是原生 textarea + 项目既有的 MarkdownPreview 左右分栏,预览与最终展示、被检索引用时是同一套渲染栈。

  • 任务类型: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. parsed 文件的产物可被覆盖,状态不变。 Owner:KnowledgeBase.update_file_markdown;commit point:{kb_id}/parsed/{file_id}.md 的覆盖写;观察边界:knowledge_files.status 仍是 parsed,内容视图读到新产物。该状态没有派生索引,产物对象就是权威内容,覆盖即发布,因此不需要草稿/版本切换。
  2. 两个同时提交的保存只有一个能命中。 Owner:KnowledgeFileRepository.update_fields_if_status(expected_updated_at=...)。期望版本(文件行的 updated_at)与允许状态构成同一条 UPDATE 的等值条件,所以「校验 + 发布」是原子步骤;另一个请求拿到 None → 409。编辑期间解析/入库推进状态或版本同样让条件落空。
  3. 保存顺序是先条件更新、再覆盖产物。 反过来会在条件落空时留下「产物已更新、版本未变」的组合,且接口返回 409 的同时新内容已经持久化。
  4. 已入库内容不能从这个接口改。 Owner:EDITABLE_MARKDOWN_STATUSES = {parsed} 与前端 canEditParsedContent(同集合);已入库文件仍可预览分块,但没有编辑入口。
  5. 权限与类型门控。 Owner:路由依赖 require_knowledge_base_manage + _ensure_database_supports_documents(只读连接器在此拦下,403 先于 404/400)。

验证情况

主张 1:待入库文件保存后产物换新、状态不变

  • 失败面:保存把状态改成别的值,或产物没有真正落盘(用户以为改了、入库时还是旧内容)
  • 语义 Owner:knowledge_files.status / MinIO 解析产物对象
  • 直接证据 / 命令:
    • 真实实例端到端(把本分支部署到一个实例后,用浏览器完成一次「待入库文件 → 编辑文件 → 修正错值 → 保存」):保存前产物含 | 42.0 |,保存后接口回读为 | 4.2 | 且旧值消失、basic 仍为 parsed、响应回传的新版本可用于再次保存
    • 集成测试 test_edit_parsed_document_writes_content_and_keeps_status(真实 HTTP + PostgreSQL + MinIO)
    • 单测 test_待入库文件保存后状态保持待入库
  • 结果:Passed

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

  • 失败面:两个编辑者同时保存时双双通过校验、后写静默覆盖先写(feat(knowledge): 支持编辑解析产物 Markdown(入库前复核 / 已入库修正) #1035 中被指出的场景)
  • 语义 Owner:update_fields_if_statusexpected_updated_at 等值条件
  • 直接证据 / 命令:
    • 真实 PostgreSQL 并发探针:开一次性 scratch 库(非业务库,用完即删),两条连接各跑一次 UPDATE … WHERE status='parsed' AND updated_at=<同一期望版本> → 结果 ['editA: 命中', 'editB: 落空(409)'],落库为 editA
    • SQL 层:编译该 UPDATE,SET 子句含 updated_atonupdate 生效,版本会推进),WHERE 同时含状态与版本
    • CI 内回归test/integration/services/test_durable_task_repository.py::test_update_fields_if_status_requires_matching_expected_version(隔离 schema 的真实 PostgreSQL)——正确版本命中并推进版本、过期版本落空、data 为空时仍逐条校验过滤条件。两个变异(删掉版本等值条件、恢复「data 为空即按 id 取记录」的短路)都会让它以正确原因失败
    • 集成测试 test_concurrent_edits_leave_exactly_one_winnerasyncio.gather 两个同时提交的保存,断言 [200, 409])——见「未验证范围」,本机未执行
  • 负向案例:上述两个变异 + 单测 test_版本落空时返回冲突且不写产物
  • 结果:Passed(单测与 CI 内回归)、Not run(HTTP 并发用例)

主张 3:保存顺序是先条件更新再覆盖产物

  • 失败面:条件落空时产物已被写入 → 接口报错但内容已变,且状态/版本与内容不再对应
  • 直接证据 / 命令:单测 test_保存顺序是先条件更新再覆盖产物(断言调用序列 ["cas","save"])、test_版本落空时返回冲突且不写产物(断言事件只有 cas
  • 负向案例:把两步顺序对调后这两条单测失败
  • 结果:Passed

主张 4:已入库内容不能从这个接口改

  • 失败面:借编辑接口绕开「重新入库」链路,留下「产物已改、索引还是旧的」
  • 语义 Owner:EDITABLE_MARKDOWN_STATUSES
  • 直接证据 / 命令:单测参数化覆盖 indexed / error_indexing / done / error_parsing;集成测试 test_edit_rejects_indexed_document_and_keeps_chunks(断言分块数与文件版本均未变)
  • 负向案例:把集合放宽回多状态后上述断言失败
  • 结果:Passed(单测)、Not run(HTTP 用例)

主张 5:接口的错误码与权限

  • 直接证据 / 命令:单测覆盖空内容 / 缺修订 / 修订非法 / 文件夹 / 无产物 / 超限(并断言无条件更新、无对象写入);集成测试 test_edit_document_rejects_invalid_requeststest_edit_document_requires_manage_permission(403 且版本未推进)
  • 结果:Passed(单测)、Not run(HTTP 用例)

主张 6:前端可编辑集合与后端同集合,保存携带版本

  • 直接证据 / 命令:web/test/unit/knowledge_markdown_edit.test.js(可编辑集合只含 parsed;已入库文件仍可预览分块但无编辑入口;保存请求体带 revision
  • 结果:Passed

主张 7:前端编辑交互不静默丢草稿、放弃不改状态

  • 直接证据 / 命令:web/test/browser/parsedMarkdownEdit.js(仓库既有 run-code 约定):筛选「待入库」后入口可用并能进入编辑态、未保存草稿时 ESC 弹确认而非静默关闭、放弃后该文件仍在「待入库」
  • 结果:Passed

简化 / 删除验收

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

独立语义 Review

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

  1. 第一轮指出:拆分边界、发布时点、执行顺序三处与 feat(knowledge): 支持编辑解析产物 Markdown(入库前复核 / 已入库修正) #1035 的整改要求一致;但并发闸的实现与宣称不符——初版用「读产物算 sha256 比对」,那是非原子读,两个真正并发的 PUT 会双双通过校验(Reviewer 用并发探针复现了「两次都成功、CAS 成功 2 次」)。另指出状态 CAS 隐式依赖 operator_id 非空(data 为空时 repository 会短路成按 id 取记录)、前端存在「有入口但必然保存失败」的组合。
  2. 修复:改为把期望版本放进 UPDATE 的 WHERE(即本 PR 当前实现),并补齐前端入口门控与加载失败分支。
  3. 第二轮复核:H1 已修好,并独立复验了「ISO 往返无损(8 行真实数据)、列精度 6 位微秒、onupdate 推进版本、统计刷新不影响回传的版本」;同时指出该 guard 当时没有任何自动回归(唯一覆盖它的 HTTP 集成用例不在 CI),据此补了 CI 内执行的 repository 层回归(含两个变异校验)。第二轮另指出 GET 侧两次查询之间的窗口、权限用例断言被弱化、朴素时间语义注释缺失——均已修。
  4. 未解决项:无。Reviewer 对「前端组件状态机」只做了代码路径分析(仓库无 jsdom / @vue/test-utils,未引入测试依赖)。

未验证范围与风险

  1. 知识库 HTTP 集成套件 Not run,且它不在 CI 里:本机未配置 TEST_USERNAME / TEST_PASSWORDconftest.py 会 skip),而 .github/workflows/system-tests.yml 是逐条点名 integration 文件、不跑全量,test/integration/api/test_knowledge_router.py 未被点名。因此本 PR 中依赖它的断言([200, 409] 并发用例、403、已入库被拒、MinIO 回读)目前只有「写好的证据」。为避免核心 guard 零回归,并发语义的自动回归已放到 CI 内执行的 test/integration/services/test_durable_task_repository.py(见主张 2)。
  2. 真实并发只做到「同一期望版本两条并发 UPDATE」这一层:探针在一次性 scratch 库上验证了数据库语义,CI 回归验证了 repository 契约;未做端到端并发压测(多用户同时编辑同一文件的 HTTP 层压力)。
  3. 组件级状态机单测未做isEditing / draftChanged 等只在浏览器脚本层覆盖;仓库没有 jsdom / happy-dom / @vue/test-utils,未擅自引入测试依赖。
  4. 期望版本依赖文件行有 updated_at:由应用创建的记录恒有(列默认值);手工插入的历史数据若缺该字段,编辑入口不会出现(fail-closed,不会静默覆盖)。
  5. updated_at 作版本的碰撞窗口:两条写入落在同一微秒的理论窗口存在(每次条件更新都走一次数据库往返,正常时序不可达);换计数器列可彻底消除,但需要新列与历史数据回填。
  6. 条件更新成功后写对象失败的残留:此时状态与内容仍是旧的一对(自洽),但行的版本已推进,用户手里的版本随之过期——下一次保存会 409 并需要重新打开编辑(前端在 409/500 都给了对应提示)。
  7. 已入库编辑不在本 PR:需要先定义新产物的发布时点(草稿 / 权威版本切换)并复用「重新入库」Durable Task。

界面变更

文件行操作菜单新增「编辑文件」(仅待入库文件);进入编辑态后左侧源码、右侧实时预览、两侧按滚动比例同步;有未保存草稿时关闭需确认。

截图取自一个临时演示库(库名与文件名均为演示用假数据),画面只包含行菜单与弹窗本体:

行菜单入口 编辑态(左源码 / 右预览)
行菜单 编辑态

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

保存后

关联事项

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

补充说明

  • 为何不附 proposed 决策记录:拆分方案由维护者在 feat(knowledge): 支持编辑解析产物 Markdown(入库前复核 / 已入库修正) #1035 的评审意见中给出(只做 parsed、已入库走「重新入库」、不要先覆盖再提交),没有待裁决的替代方案;本 PR 直接把结论写成 implemented 记录,并列出并发控制这一处仍有替代项的取舍(含被否掉的 sha256 方案与原因)。
  • 兼容性:无 schema 变更、无数据迁移。
  • 5 MiB 上限的来源:docker/nginx/default.confclient_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 不共享),
在那里跑脚本会停在登录页。
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.

1 participant