Conversation
背景:解析完成后、入库前的人工复核目前只能删掉重传再解析。本 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 不共享), 在那里跑脚本会停在登录页。
1. 保存的并发边界需要补齐目前先通过数据库 CAS 更新版本,再覆盖 MinIO 对象,这两步并不是原子发布。 存在这样的交错顺序:
最终会出现“解析产物是新的,分块和向量是旧的”。同一窗口内,另一个编辑者也可能读到“新 revision + 旧内容”,用新 revision 通过检查,再与 A 乱序覆盖对象。因此,当前 CAS 只能排斥使用同一旧版本的请求,不能保证内容发布与状态、版本一致。 建议让编辑与入库共享有效的发布边界。可以考虑先写不可变的新对象,再通过状态和版本条件原子切换数据库中的对象引用;这是一个可选方向,不要求为此引入完整版本管理机制。具体实现尽量保持简单,但不能只靠交换数据库更新与对象写入的顺序解决。 对应位置: 2. 前端交互优先复用现有编辑组件建议先检查项目已有的编辑组件,能复用就尽量复用,避免在 这一阶段的交互可以保持简单:进入编辑、保存、取消,以及必要的未保存提示即可。双栏实时预览和同步滚动不是这条编辑闭环的必要条件,可以先不做,减少弹窗内的状态和维护成本。 另外,当前保存期间 textarea 仍可继续输入,但成功回调会重新读取 这里需要固定提交快照,响应只确认该快照并保留后续修改;或者采用更简单的方式,在保存期间暂时禁止编辑。关闭、重新打开弹窗时,也需要避免旧保存请求的回调污染新的编辑上下文。 对应位置: 3. 样式保持简单尽量复用现有组件样式和主题变量,减少新增的固定颜色、徽标和专用布局。优先保证编辑区清晰、保存与取消操作一致,不必为了这次功能增加较复杂的双栏和同步滚动设计。 希望前端实现聚焦“修正待入库内容”本身,而不是在详情弹窗里维护另一套独立编辑器。 4. 修正测试,并覆盖真实失败窗口
建议重点补充以下验证:
以上问题中,内容发布与入库的并发一致性建议作为合并前需要解决的事项;前端则尽量通过复用和简化收敛实现。 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 通过。
|
感谢评审,四条都处理了,按你的顺序回。 1. 保存的并发边界你描述的交错成立,我按你建议的方向改了:编辑不再覆盖解析产物那条确定性路径,而是先写内容寻址的新对象 你说「不能只靠交换数据库更新与对象写入的顺序解决」,同意,单纯换序只是把窗口挪个位置。现在闭合的方式是不再存在「固定路径被覆盖」这件事:
失败面:条件落空时新对象已落盘但无人引用,刻意不删。内容寻址名是内容的纯函数,两个编辑者提交同一份文本(同一处修正、脚本重放、超时重试)时,输家写的对象与赢家已切换引用的是同一个,删它会让行指向不存在的产物。代价是一次失败保存留一个孤儿,由删除文件时按前缀 删除文件的两个入口(单条与批量)都改成先删行、再清理对象。原顺序(先列举清对象、后删行)除了会漏孤儿,还有一个更糟的窗口:在「列举完成、行删除之前」提交成功的编辑,它的对象在列举里已被删掉、行却指向它,于是预览与下载失败、入库转 清理侧还有个配套改动:产物对象过去按固定路径 2. 前端复用现有编辑组件改成复用
另外自查时发现一个我上一版引入的缺口,一并修了:草稿现在存在 3. 样式双栏、徽标、专用工具栏都删了。现在只剩两条纯尺寸的 4. 测试
另外我新加的 还有一处按仓库「不接受没有当前 consumer 的兼容路径」的规则回退了:上一版我给 本地执行情况
已知限制
本次改动已推到分支 |
冲突只有 .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 逐字节相同)。推空提交重新触发以区分 偶发与稳定失败。
关于 CI 里那 2 条红:外部镜像源事件,与本 PR 无关本轮的 9 条 check-run 中 7 条通过,红的 2 条是 即拉取 三条对照:
本机独立复核过:quay.io 的匿名 token 对 结论:这是仓库级、非本 PR 引入的故障,且不会自行恢复——需要调整 本 PR 的验证证据不依赖这两条 job:它们是 Agent 与拓扑的 E2E,不覆盖知识库路由。本 PR 的证据分布在单测、CI 内执行的 repository 层并发回归( |
|
合并前希望处理以下问题:
另一个非阻塞建议:第一版是否可以只保留“打开详情后点击编辑”,暂不提供行菜单直接进入编辑?这样能移除跨 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,行菜单无编辑按钮)。
|
四点都已处理,commit 1. 正文读取失败时禁止编辑按建议显式传播: 负向测试 一个调用面说明(已记入决策记录):完整信息端点 2. 统一解析产物清理逻辑收敛到唯一实现 新增两条集成用例(真实 HTTP + MinIO,已接入 CI): 3. 同步描述与最终实现
4. 建议项:已采纳移除行菜单「编辑文件」快捷入口与整条自动编辑意图传递链( 验证
|
…down-phase1 # Conflicts: # web/src/components/AgentFilePreview.vue
这是 #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
KnowledgeBase.update_file_markdown;commit point:update_fields_if_status那条 UPDATE(markdown_file、状态条件与期望版本在同一条语句的 WHERE/SET 里)。于是「引用即内容身份」:任何读者(含入库认领)读到的对象由它读到的那一行唯一确定。KnowledgeFileRepository.update_fields_if_status(expected_updated_at=...)。期望版本(文件行的updated_at,随 GET 内容返回、PUT 原样回传)与允许状态构成同一条 UPDATE 的等值条件;任何写入都会推进updated_at,并发保存的输家落空返回 409。parse_file一致)。 反过来(先条件更新)会在条件成功后、对象写入前的窗口里让入库认领通过并读到旧内容,形成「产物新、分块与向量旧」。条件落空时新对象已落盘但无人引用,刻意不删(同名对象可能正是并发赢家已切换引用的那个),由删除文件时的前缀清理收敛。EDITABLE_MARKDOWN_STATUSES = {parsed}与前端canEditParsedContent(同集合)。require_knowledge_base_manage+_ensure_database_supports_documents(只读连接器在此拦下)。ParsedArtifactReadError(_get_file_content_from_meta显式抛出)→ 内容端点映射 502 且不带content_revision;前端把「正文加载成功」作为编辑入口的必要条件。静默降级(返回修订号但没有内容)会让前端开放空白编辑器,一次保存即用空内容替换原产物。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 返回的内容与修订配成一对。test/integration/services/test_knowledge_parsed_edit_publish_window.py:隔离 Schema + 真实 MinIO + 在跑的 Durable Task 租约,在编辑「写产物」前后各设同步点放行一次与生产同形的入库认领,断言入库读到的内容 == 行最终指向的内容;把实现改回旧顺序实测会在内容分叉上失败。markdown_file、内容寻址命名。主张 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(含两个变异校验)。主张 6:正文读取失败时编辑不可用
test_edit_content_unavailable_returns_error_and_preserves_row(真实 HTTP + MinIO:删掉产物对象 → GET 返回 502、响应不含content_revision、行的markdown_file与updated_at均未变)。已接入 CI。主张 7:单删/批删/文件夹删除清掉全部产物对象
test_delete_document_cleans_edited_parsed_objects(单删+批删:编辑后确定性名与内容寻址名并存,删除后前缀列举为空)与test_delete_folder_cleans_child_parsed_objects(文件夹删除后子文件对象清空、行一并消失)。均已接入 CI。其余主张与门禁
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);weblint:check/test:unit380 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、测试与规范:
未验证范围与风险
TEST_USERNAME/TEST_PASSWORD手工执行(本机已执行,编辑相关全部通过)。updated_at作版本的碰撞窗口:同微秒理论窗口存在(每次条件更新一次数据库往返,实际不可达);换计数器列可消除但需新列与回填。get_file_info的影响:该复合端点同样走_get_file_content_from_meta,产物不可读时由其路由的 catch-all 返回 failed 标记(无修订号)——语义一致,但未为它单独立用例。界面变更
待入库文件打开详情后,正文区出现「编辑 Markdown」入口(仅正文加载成功且状态为待入库时);编辑态复用
AgentFilePreview(textarea + 浮动操作条,未保存有标记);保存期间输入与按钮禁用;编辑期间视图切换禁用;有未保存草稿时关闭需确认。截图取自演示数据(临时库、假文件名),画面只包含弹窗本体:
保存后(产物已换新,文件仍在「待入库」,可继续走既有「入库」):
关联事项
上一版为 #1035(同时支持 parsed 与已入库编辑),按维护者意见关闭并拆分:本 PR 只做第一阶段,已入库编辑 + 重新入库发布时点留待第二阶段。无关联 Issue。
补充说明
implemented记录,并列出仍有替代项的取舍(含被否掉的 sha256 方案、孤儿不删的理由、快捷入口移除)。docker/nginx/default.conf的client_max_body_size为 20M(server 级硬墙),且 JSON 转义会膨胀(换行 →\n两字节),故取 5 MiB 留余量;将来调高必须同步改 nginx 配置。_ensure_database_supports_documents在路由层拦下(400)。