diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 2a1666cebf..1f72f0bb37 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -31,7 +31,9 @@ metadata: - 用户要**查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +permission-get-setting`;它只读取目标自身设置,不递归审计文件夹子文档权限。裸 token 必须显式传 `--type`。 - 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。 - 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。 -- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。 +- 用户要**给已存在的知识库各节点写维护要求 / 维护标准 / 收录范围 / 命名规范**,或要“建立标准知识库维护方法,后续同事按标准补资料”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_base_bootstrap`](references/lark-drive-workflow-knowledge-base-bootstrap.md) workflow。该 workflow 读取现有节点结构和草稿,生成规范后写前确认,把通用规范写入根节点、专属维护要求写入各子节点;不移动 / 不删除 / 不重命名节点。 +- 用户要**把本地文件 / 文件夹的资料整理入库到已有知识库**,或要“盘点这批本地资料、去重后转成知识页归位”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_ingest`](references/lark-drive-workflow-knowledge-ingest.md) workflow。该 workflow 盘点本地资料(去重 / 敏感初筛)、据维护规范映射归位、转成飞书 docx 知识页写入、写后验证;只上传原文件不算完成(`drive +upload` 仅作来源附件);目标库不存在时请用户先自建知识库或提供已有库链接再来(不从零建知识空间)。 +- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;对已存在知识库各节点撰写并写入维护标准,进入 `knowledge_base_bootstrap`;把本地资料转成知识页入库,进入 `knowledge_ingest`;只移动一个已明确资源时仍使用原子移动命令。 - 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`;按标题定位和处理重复候选时遵循 [`references/lark-drive-search.md`](references/lark-drive-search.md)。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。 - 用户要对**文档评论**做任何操作(添加评论、列表 / 批量查询、回复、获取 / 更新 / 删除回复、解决 / 恢复、reaction),按下方 Shortcuts 表选择对应的 `drive +` 评论命令,执行前先阅读该命令的 ref。按评论定位文档正文位置见 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md)。 - 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。 diff --git a/skills/lark-drive/references/lark-drive-workflow-knowledge-base-bootstrap-outputs.md b/skills/lark-drive/references/lark-drive-workflow-knowledge-base-bootstrap-outputs.md new file mode 100644 index 0000000000..786b6867d4 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-workflow-knowledge-base-bootstrap-outputs.md @@ -0,0 +1,158 @@ +# 知识库维护标准初始化 — 规范模板与输出模板 + +本文件是 [`lark-drive-workflow-knowledge-base-bootstrap.md`](lark-drive-workflow-knowledge-base-bootstrap.md) 的配套引用文档,在 workflow 进入 `OUTLINE_PROPOSE`、`GEN_STANDARD` 和 `WRITE_CONFIRM` 状态时加载。 + +本文件承载两类内容:`GEN_STANDARD` 生成维护规范时的内容模板,以及各状态用户可见输出的表格样式。所有模板是结构骨架,实际文本由 agent 结合知识库主题、节点名称和现有草稿填充。不要将本文件的模板原样照抄为死板套话。 + +## 维护规范内容模型 + +维护规范分两层写入:通用规范写入根节点,专属维护要求写入各子节点。通用规范只在根节点写一次,子节点不重复通用规范全文。 + +每个节点的维护规范文档统一由两部分组成:**顶部一张 6 行治理表**(结构化元数据,供门禁校验和后续维护)+ **正文**(该节点的维护规范内容)。治理表字段名称和顺序保持一致,不因节点类型删减;字段未知时填“待确认”,并把 `page_status` 保持为“进行中”。 + +### 6 行治理表(所有节点统一表头) + +| 字段 | 填写规则 | 门禁相关 | +|------|----------|----------| +| 来源 | 这份维护规范的制定依据:用户提供的标准、上位制度,或“据业务常识制定”。多来源逐条列出 | 不得为空 | +| 负责人 | 本节点维护规范的负责人,优先“姓名|团队”。未知填“待确认” | 待确认时 `page_status` 须为进行中 | +| 版本与状态 | 写成 `v1.0|已完成`;状态只能是 `进行中 / 已完成 / 已废弃` | 状态须合规;有“待确认”时不得为已完成 | +| 适用与可见范围 | 适用的组织、岗位、人群和可见范围;全员适用也显式写明 | 不得为空 | +| 生效与更新 | 写成 `生效:YYYY-MM-DD|更新:YYYY-MM-DD|原因:首次创建 + 说明`;无独立生效日期写“不适用(原因)” | 允许“待确认” | +| 复核策略 | 写成 `类型:<知识类型>|周期:<天>|下次复核:YYYY-MM-DD` | 允许“待确认” | + +字段值未确定时统一填“待确认”,不得编造。含“待确认”的节点 `page_status` 保持“进行中”;只有 6 行齐备、无“待确认”的节点才可标为“已完成”。 + +### 根节点:通用维护规范 + +根节点文档 = 6 行治理表 + 通用规范正文。正文包含以下六个部分,缺乏来源依据的部分据知识库业务领域的通用文档管理常识补全,并保持与知识库主题一致。 + +| 部分 | 内容要点 | 来源 | +|------|----------|------| +| 维护总则 | 维护的基本原则,例如及时更新时限、内容真实完整、分类存放、可追溯 | 据业务常识生成 | +| 文件命名规范 | 统一命名格式,例如 `[资料类别]-[项目/部位]-[编制单位]-[日期]`,并约定日期格式 | 据业务常识生成,格式贴合知识库主题 | +| 文件格式要求 | 各类资料的文件格式约定,例如图纸、报告、照片分别用什么格式 | 据业务常识生成 | +| 维护职责分工 | 各子节点分别由谁维护、谁审核 | 结构来自节点树;具体人员留占位待用户补充 | +| 更新与检查机制 | 日常维护、定期检查、归档锁定等机制 | 据业务常识生成 | +| 节点导航 | 指向各子节点的导航,便于快速进入 | 结构来自节点树 | + +### 子节点:专属维护要求 + +子节点文档 = 6 行治理表 + 专属维护要求正文。正文聚焦“这个节点收什么、怎么收”,不复述根节点通用规范。 + +| 部分 | 内容要点 | 来源 | +|------|----------|------| +| 收录范围 | 本节点应收录的资料类别清单 | 优先从该节点现有草稿归纳;草稿缺失再据节点名称和业务常识补全 | +| 分类细则 | 节点内资料的进一步分类或编号规则(如有必要) | 从草稿归纳或据常识补全 | +| 命名与格式适用 | 本节点资料适用的命名与格式约定,引用根节点通用规范并给出本节点示例 | 据根节点通用规范派生 | +| 维护与审核人 | 本节点的维护人和审核人 | 留占位待用户补充 | + +## 用户可见输出模板 + +以下表格是各状态用户可见输出的样式骨架。列名使用中文;内部枚举值转为自然语言中文标签展示。 + +### READ_STRUCTURE:结构概览 + +```text +知识库:<知识库名称> +根节点:<根节点标题> +节点总数:(docx / 非 docx / 快捷方式 ) + +| 层级 | 节点标题 | 类型 | 现有内容 | +|------|----------|------|----------| +| 根 | 建材经贸大厦改造项目验收 | 文档 | 默认占位 | +| 子 | 消防验收 | 文档 | 已有草稿 | +| 子 | 质量验收 | 文档 | 已有草稿 | +``` + +类型列:文档 / 表格 / 多维表格 / 思维笔记 / 幻灯片 / 快捷方式。 +现有内容列:默认占位 / 已有草稿 / 空。 + +### OUTLINE_PROPOSE:大纲提议表 + +仅在结构过简(如只有根节点)时展示。基于知识库主题和根节点标题提议子节点大纲,请用户确认后新建。确认表除节点名和收录范围外,必须给出每个拟建节点的精确 `--title`、目标位置(`--parent-node-token` 指向 `root_node`,或 `--space-id` 建在空间顶层)和对象类型,以便用户逐项核对。 + +```text +当前知识库仅有根节点 <根节点标题>(root_node token: wikcn_ROOT),缺少承载分类的子节点。 +基于主题提议以下子节点大纲,确认后新建(新建为文档节点,可再调整): + +| 拟建 --title | 目标位置(parent) | 对象类型 | 收录范围(简述) | +|--------------|--------------------|----------|------------------| +| 消防验收 | wikcn_ROOT | docx | 消防设计图、验收报告、现场照片 | +| 质量验收 | wikcn_ROOT | docx | 质量检验记录、整改单、验收结论 | +| 集团项目验收 | wikcn_ROOT | docx | 批复、招标、合同、竣工验收总结 | + +确认后对每个节点执行: + wiki +node-create --as --parent-node-token wikcn_ROOT --title "<拟建标题>" --obj-type docx + → 记录返回的 node_token 与 obj_token,回读并入 node_inventory;obj_token 即后续 docs +update 的写入目标 + +确认新建这些节点吗?也可增删或改名后再建;如只想为现有根节点写规范,可跳过新建。 +``` + +新建后回读并入结构,再继续分诊与规范撰写。 + +### TYPE_TRIAGE:分诊表 + +```text +| 节点标题 | 类型 | 分诊 | 处理方式 | +|----------|------|------|----------| +| 消防验收 | 文档 | 可写正文 | 写入维护规范 | +| 验收台账 | 表格 | 非文档节点 | 默认跳过(可选:新建文档规范页) | +| 外部资料 | 快捷方式 | 快捷方式 | 跳过(无自有正文) | +``` + +分诊列:可写正文 / 非文档节点 / 快捷方式。 + +### WRITE_CONFIRM:写入计划 + +R2 高风险写入。确认前必须展示每个目标节点的稳定标识(`node_token`,重名子节点靠它区分)、写入命令族、写法、`kb_gate.py` 门禁结果,以及将写入的精确内容或相对现状的 diff——不能只给标题和摘要。内容较长时,逐节点展示其完整拟写正文(可折叠为分节,但必须可供用户逐字核对)。 + +```text +即将写入 个节点,门禁拦截 个,状态收紧 个,跳过 个。写入为高风险操作(docs +update),确认后执行。 + +| 节点标题 | node_token | 写法 | 命令族 | 门禁 | 写入内容 | +|----------|------------|------|--------|------|----------| +| 建材经贸大厦改造项目验收(根) | wikcn_ROOT | 覆盖 | docs +update overwrite | 通过 | 通用维护规范(六部分,全文见下) | +| 消防验收 | wikcn_A | 追加 | docs +update append | 收紧为进行中:负责人待确认 | 收录范围 + 分类细则(全文见下) | +| 质量验收 | wikcn_B | 覆盖 | docs +update overwrite | 通过 | 收录范围 + 分类细则(全文见下) | +| 验收台账·规范页(新建) | 建后回填 | 新建文档 | wiki +node-create → docs +update | 通过 | 该节点专属维护要求(全文见下) | + +<逐节点展开将写入的精确正文;append 场景标明追加位置,overwrite 场景标明将替换的占位内容> + +新建文档(new_docx)项,须展示完整命令序列(原对象不改动): + 1) wiki +node-create --as --parent-node-token <目标父节点,如非docx原节点同级 wikcn_T 的父> --title "<精确标题,如:验收台账·维护规范>" --obj-type docx + 2) 记录返回的 node_token 与 obj_token + 3) docs +update --as --doc <上一步 obj_token> --command overwrite --content <规范正文> +说明:new_docx 目标位置必须显式(--parent-node-token 或 --space-id),标题必须精确给出;node-create 返回的 obj_token 才是 docs +update 的写入目标,不得写向原非 docx 节点。 + +门禁拦截(不写入,记入 unsupported_checks): +| 节点标题 | node_token | 门禁原因 | +|----------|------------|----------| +| 验收台账 | wikcn_T | 载体不是文档节点:obj_type=sheet | + +跳过节点: +| 节点标题 | node_token | 类型 | 原因 | +|----------|------------|------|------| +| 外部资料 | wikcn_C | 快捷方式 | 无自有正文 | +``` + +门禁列:通过 / 收紧为进行中(原因) / 拦截(原因)。写法列:覆盖 / 追加 / 新建文档 / 跳过。 + +### VERIFY:验证与汇总 + +```text +| 节点标题 | 写入 | 校验 | +|----------|------|------| +| 建材经贸大厦改造项目验收(根) | 成功 | 已确认通用规范落地 | +| 消防验收 | 成功 | 已确认收录范围落地 | + +汇总:已更新 个节点,跳过 个节点。 +未写入节点(unsupported_checks): +| 节点标题 | 类型 | 原因 | +|----------|------|------| +| 验收台账 | 表格 | 非文档节点 | + +知识库链接: +``` + +校验列:已确认落地 / 未落地(需重试)/ 失败。 diff --git a/skills/lark-drive/references/lark-drive-workflow-knowledge-base-bootstrap.md b/skills/lark-drive/references/lark-drive-workflow-knowledge-base-bootstrap.md new file mode 100644 index 0000000000..241eef5f25 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-workflow-knowledge-base-bootstrap.md @@ -0,0 +1,223 @@ +# 知识库维护标准初始化 Workflow + +Workflow id: `knowledge_base_bootstrap` + +Risk / Structure: `R2` / `S2` + +本文实现已注册的知识库维护标准初始化 workflow。执行前必须先读取 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。 + +本文定义 workflow 专属的状态机、类型分诊规则、写法选择规则和 command family 允许范围。规范文本模板和用户可见输出模板放在配套 outputs 文档,仅在进入需要它的状态时按需加载。 + +配套 outputs 文档只是本 workflow 的引用文件,不是独立 skill。不要把用户请求直接路由到 outputs 文档。 + +## 必读上下文 + +执行本 workflow 前,必须先读取 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),用于处理身份、认证、权限和写操作确认规则。 + +按阶段渐进加载其他 skill / 引用文档: + +- 读取知识库结构与节点解析:[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md) 和 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) +- 读取节点文档现有内容:[`../../lark-doc/SKILL.md`](../../lark-doc/SKILL.md) 和 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) +- 写入节点文档正文:[`../../lark-doc/references/lark-doc-update.md`](../../lark-doc/references/lark-doc-update.md);按 `--doc-format` 读取 [`../../lark-doc/references/lark-doc-md.md`](../../lark-doc/references/lark-doc-md.md) 或 [`../../lark-doc/references/lark-doc-xml.md`](../../lark-doc/references/lark-doc-xml.md) +- 为非 docx 节点补建规范页:[`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) +- 规范文本模板和输出模板:[`lark-drive-workflow-knowledge-base-bootstrap-outputs.md`](lark-drive-workflow-knowledge-base-bootstrap-outputs.md) + +## 适用范围 + +本 workflow 用于对一个已存在的飞书 Wiki 知识库,基于其现有节点结构和草稿内容,生成并写入标准维护要求:通用维护规范写入根节点,节点专属维护要求写入各子节点,供后续维护者按标准补充资料。 + +适用触发语包括: + +- "给这个知识库 / 各节点写清楚维护要求 / 维护标准" +- "建立标准知识库维护方法,后续同事按标准往里补资料" +- "帮我把维护规范更新到知识库的各个节点里" +- "为知识库各节点补上收录范围、命名规范和维护职责" + +目标必须是一个已存在的 Wiki 知识库链接、知识空间或知识库节点。当目标结构过简(例如只有根节点、缺少承载分类的子节点)时,本 workflow 先基于知识库主题提议一套子节点大纲,经用户确认后新建节点,再进入维护标准撰写;不在未确认前擅自新建节点树。 + +## 非目标 + +本 workflow 不处理: + +- 移动、复制、删除或重命名节点;这类结构调整应使用 [`knowledge_organize`](lark-drive-workflow-knowledge-organize.md) 或 [`topic_move_collector`](lark-drive-workflow-topic-move-collector.md)。本 workflow 仅在 `OUTLINE_PROPOSE` 经用户确认后新建大纲子节点,不改动已有节点的位置、归属或标题。 +- 把外部文件归档 / 上传到节点下;单文件智能归档是独立需求,不属于本 workflow。 +- 知识库内容问答或检索。 +- 知识空间成员、权限或密级治理;这类需求使用 [`permission_governance`](lark-drive-workflow-permission-governance.md)。 +- 覆盖用户已有的实质草稿内容;除非用户在写前确认阶段明确点名要求重写该节点。 +- 改写 sheet / bitable / mindnote / slides 等非 docx 节点的内部数据结构。 + +## Agent 执行约束 + +触发本 workflow 后,agent 必须: + +1. 按 `Execution State Machine` 的顺序执行,并维护 `Runtime State` 字段。 +2. 执行某个状态前,先加载 `Progressive Load Map` 中该状态要求的引用文档;不要预加载全部文档。 +3. 在 `WRITE_CONFIRM` 之前,绝不执行文档正文写入(`docs +update`)。唯一例外是 `OUTLINE_PROPOSE`:仅在用户单独确认大纲后,才可执行 `wiki +node-create` 新建大纲节点;除此之外任何状态都不得新建、修改节点。 +4. 只执行 `Command Map` 允许的 command family;命令语法、scope 要求、参数规则以被引用 skill / reference 为准。 +5. 用户可见说明、字段说明和表格文案使用中文;状态名、字段名、枚举值、命令名保留英文稳定标识。 +6. 内部枚举值在用户可见输出中转为自然语言中文标签。 +7. 不声明 CLI / API 不支持的能力;无法执行的写入必须记入 `unsupported_checks`,不得静默省略。 +8. 写入后必须用 fresh read 验证内容落地,不以写入命令的返回值直接判定成功。 + +## Runtime State + +本 workflow 在共享 `Artifact Contract` 基础上维护以下字段: + +| Field | Meaning | +|-------|---------| +| `current_state` | `Execution State Machine` 中的当前状态 | +| `target_space` | 解析出的 `space_id`、`root_node`(承载通用规范的唯一根节点:token + obj_type)、`top_level_nodes`(空间顶层节点列表)和用户原始输入 URL。通用规范只写入单一 `root_node`;解析规则见 `Root Node Resolution` | +| `identity` | 执行身份,默认 `user`;节点解析与写入必须使用同一身份 | +| `node_inventory` | 全部节点的归一化列表:`node_token`、`obj_token`、`title`、`obj_type`、`node_type`、父子层级 | +| `node_class` | 每个节点的分诊结果:`writable_docx` / `non_docx_entity` / `shortcut` | +| `permissions_observed` | 各写动作实际观测到的权限:`read` / `edit_existing_docx`(`docs +update`)/ `create_node`(`wiki +node-create`),取值 `true` / `false` / `unknown`;只记录已验证或实际报错得到的结果,不由身份或读取成功推断写权限 | +| `outline_proposal` | 结构过简时提议的子节点大纲:拟建节点标题、层级、收录范围摘要;经用户确认后用于新建节点 | +| `draft_map` | 每个 docx 节点的现有内容判定:`empty_placeholder` / `has_draft` 及内容摘要 | +| `standard_plan` | 生成的维护规范:根节点通用规范 + 各子节点专属维护要求 | +| `write_mode_map` | 每个节点的写法决策:`overwrite` / `append` / `new_docx` / `skip` | +| `unsupported_checks` | 因节点类型或权限无法写入的节点及原因 | +| `write_results` | 每个节点的写入结果 | +| `verification_results` | 每个已写节点的 fresh read 校验结果 | +| `partial` | 结果是否不完整,以及不完整原因(权限、类型、API / 分页失败) | + +## Execution State Machine + +| State | Protocol Step | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State | +|-------|---------------|-----------------|---------------|--------------------|---------------|------------| +| `PARSE_TARGET` | `route` / `scope` | Workflow 触发 | 加载 wiki skill;把目标解析为 `space_id`:给定 wiki 节点 / 文档 URL 时用 `wiki +node-get` 取 `space_id` 且该节点即候选根,给定普通知识空间时用 `wiki +space-list` 取 `space_id` 并用 `wiki +node-list --page-all`(省略 parent)列出顶层节点;**目标是个人知识库(`my_library` 或个人库 URL)时,`wiki +space-list` 不返回个人库,须改用 `wiki spaces get --params '{"space_id":"my_library"}'` 解析出真实 `space_id`,再列顶层节点**;按 `Root Node Resolution` 确定唯一 `root_node`,多个顶层节点无法自动定根时停下请用户选定;确认目标就是该知识库 | 目标知识库与根节点确认,或(多顶层时)请用户选定根节点 | `true` | `READ_STRUCTURE` | +| `READ_STRUCTURE` | `read` | 目标已确认 | 递归读取整棵节点树填充 `node_inventory`:每次 `wiki +node-list` 必须用 `--page-all`(或按 `page_token` 翻页到 `has_more=false`),并对 `has_child=true` 的节点逐层下钻,不得只取首页;对 docx 节点读取现有内容填充 `draft_map`;判定结构是否过简(无子节点或子节点不足以承载分类)。注意 `--page-all` 仍有默认翻页上限(`--page-limit` 默认 10 页、每页至多 50 节点):某层超过该上限或子节点读取失败时,置 `partial` 并记原因。**`partial` 时 fail closed:不进入 `OUTLINE_PROPOSE` / `TYPE_TRIAGE` / `WRITE`**,因为截断的树可能被误判为"结构过简"而重复建大纲、或写规范时漏掉未读到的节点;须先读全(提高 `--page-limit` / 续 `page_token` / 缩小目标)或由用户缩小范围后再继续 | 结构概览:节点数、层级、草稿 / 占位分布;不完整时明确标注并停下 | 读取不全时为 `true`(停下待用户),否则为 `false` | `OUTLINE_PROPOSE` or `TYPE_TRIAGE` | +| `OUTLINE_PROPOSE` | `assess` / `plan` / `confirm` | 结构过简 | 加载 outputs 文档;基于知识库主题、根节点标题和已有草稿提议子节点大纲填充 `outline_proposal`;请用户确认后用 `wiki +node-create --obj-type docx` 新建拟定子节点,并回读并入 `node_inventory` | 大纲提议表 + 新建确认请求;确认后报告新建结果 | `true` | `TYPE_TRIAGE` | +| `TYPE_TRIAGE` | `assess` / `plan` | 结构已读(含新建节点) | 按 `obj_type` / `node_type` 将每个节点分诊为 `writable_docx` / `non_docx_entity` / `shortcut`,填充 `node_class` | 分诊表:可写正文节点、需特殊处理节点及原因 | 存在 `non_docx_entity` / `shortcut` 时为 `true`,否则为 `false` | `GEN_STANDARD` | +| `GEN_STANDARD` | `assess` / `plan` | 分诊完成 | 加载 outputs 文档;为可写范围生成 `standard_plan`:根节点通用规范 + 各子节点专属维护要求;子节点收录范围优先从草稿归纳,缺失再据业务常识补全 | 维护规范草案预览 | 除非用户直接进入确认,否则为 `false` | `WRITE_CONFIRM` | +| `WRITE_CONFIRM` | `confirm` | 规范草案就绪 | 生成逐节点写入计划(含 `node_token`、`obj_type`、`write_mode`、6 行治理字段);运行 `kb_gate.py` 门禁校验,仅 `ready=true` 的节点可进入 `WRITE`,被拦节点记入 `unsupported_checks`,门禁将 `narrowed` 节点的页面状态收紧为进行中;向用户展示计划、门禁结果、精确正文 / diff 与跳过原因 | 写入计划表(含 node_token + 命令族 + 门禁结果)+ 逐节点精确内容 / diff + 被拦 / 收紧 / 跳过清单及原因 | `true` | `WRITE` or `DONE` | +| `WRITE` | `execute` | 用户已确认写入范围 | 加载 doc-update reference;按 `write_mode_map` 逐节点写入(根节点与子节点可并行);**每个 `overwrite` 节点落笔前先 `docs +fetch` 重读并按 `Write Mode Selection` 的基线校验(占位覆盖须仍为 `empty_placeholder`;已确认草稿覆盖须与确认时基线一致),携带读到的 `revision` 再写,与基线不符则停下重新确认**;非 docx 节点仅在用户选择 `new_docx` 时新建 docx 规范页 | 写入进度报告 | 除非被阻断,否则为 `false` | `VERIFY` | +| `VERIFY` | `verify` | 写入完成 | 对每个已写节点执行 fresh read 校验内容落地;汇总 `unsupported_checks` | 验证表和最终汇总 | `false` | `DONE` | +| `DONE` | `done` | 无更多动作 | 停止 | 最终回复:已更新节点数、跳过节点及原因、知识库链接 | `false` | End | + +## Progressive Load Map + +Agent 必须在执行某状态前,读取该状态要求的引用文档。 + +| State | Required Reference | +|-------|---------------------| +| `PARSE_TARGET` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)、[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md) | +| `READ_STRUCTURE` | [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md)、[`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) | +| `OUTLINE_PROPOSE` | [`lark-drive-workflow-knowledge-base-bootstrap-outputs.md`](lark-drive-workflow-knowledge-base-bootstrap-outputs.md)、[`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) | +| `TYPE_TRIAGE` | 本文件的 `Node Type Triage` | +| `GEN_STANDARD` | [`lark-drive-workflow-knowledge-base-bootstrap-outputs.md`](lark-drive-workflow-knowledge-base-bootstrap-outputs.md) | +| `WRITE_CONFIRM` | [`lark-drive-workflow-knowledge-base-bootstrap-outputs.md`](lark-drive-workflow-knowledge-base-bootstrap-outputs.md);门禁脚本 `scripts/kb_gate.py` | +| `WRITE` | [`../../lark-doc/references/lark-doc-update.md`](../../lark-doc/references/lark-doc-update.md)、[`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md)(overwrite 前重读);`new_docx` 时读取 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) | +| `VERIFY` | 复用 `READ_STRUCTURE` 阶段的读取上下文 | + +## Root Node Resolution + +通用维护规范只写入**唯一**根节点 `root_node`,不得写入多个顶层节点。按以下顺序确定: + +1. 目标是 wiki 节点 / 文档 URL(`wiki +node-get` 可解析出该节点)→ 该节点即 `root_node`,其子树为处理范围。 +2. 目标是知识空间、且顶层只有 1 个节点 → 该顶层节点即 `root_node`。 +3. 目标是知识空间、顶层有多个节点 → 不自动选根,停在 `PARSE_TARGET`,列出候选顶层节点(标题 + token)请用户选定一个作为 `root_node`;用户也可指定“为每个顶层节点分别建规范”,但仍需逐个确认,不默认批量。 +4. 选定的 `root_node` 若不是 docx(`obj_type != docx`)→ 不向其 `docs +update` 写通用规范;按 `Node Type Triage` 处理:经用户确认走 `new_docx` 在其下新建 docx 规范页,或跳过并记入 `unsupported_checks`。 + +`root_node` 确定前不生成写入计划,也不进入 `WRITE`。 + +## Node Type Triage + +`TYPE_TRIAGE` 依据 `wiki +node-list` 返回的 `obj_type` 和 `node_type` 对每个节点分诊。禁止假设所有节点都是 docx。 + +| 分类 | 判定条件 | 处理方式 | +|------|----------|----------| +| `writable_docx` | `obj_type=docx` 且 `node_type=origin` | 主路径:用 `docs +update` 写入维护规范正文 | +| `non_docx_entity` | `node_type=origin` 且 `obj_type != docx`(含 `sheet`、`bitable`、`mindnote`、`slides`、`file` 及任何其他非 docx 类型) | 默认 `skip` 并记入 `unsupported_checks`。仅当用户在 `WRITE_CONFIRM` 明确要求时,走 `new_docx`:在该节点同级或子级新建 docx 节点承载其维护规范,原对象不改动 | +| `shortcut` | `node_type=shortcut`(任意 `obj_type`) | 一律 `skip` 并记入 `unsupported_checks`;快捷方式无自有正文,写入无意义 | + +默认策略:`non_docx_entity` 默认 `skip` + 报告;`new_docx` 是用户显式选择后的可选动作。任何 `skip` 都必须在最终汇总列出节点类型和原因。 + +完备性要求:`node_inventory` 中每个节点都必须落入以上三类之一。三类判定按 `shortcut`(`node_type=shortcut`)→ `writable_docx`(`node_type=origin` 且 `obj_type=docx`)→ `non_docx_entity`(其余 `node_type=origin`)的顺序穷尽划分;未识别的新 `obj_type` 归入 `non_docx_entity`,不得让任何节点无分类地漏过。 + +## Write Mode Selection + +对 `writable_docx` 节点,依据 `draft_map` 决定写法,避免误删用户已有内容。 + +| docx 节点现状 | 写法 | 理由 | +|---------------|------|------| +| 飞书默认模板占位 / 无实质内容(`empty_placeholder`) | `overwrite` | 占位内容可整篇重写 | +| 已有用户实质草稿(`has_draft`) | `append`(默认) | 追加规范,保留用户原文 | +| 用户在 `WRITE_CONFIRM` 明确点名要求重写的有草稿节点 | `overwrite` | 需用户对该节点显式确认 | + +`draft_state` 必须来自对目标节点的**新鲜读取**(`docs +fetch`),不得复用 `READ_STRUCTURE` 早期缓存或过期计划里的 `draft_state`;门禁只能校验传入 JSON、无法验证其新鲜度。 + +**关键:新鲜读取的时机在用户确认之后、每次 `overwrite` 落笔之前**,而不仅是 `WRITE_CONFIRM` 生成计划时。因为 `WRITE_CONFIRM` 会停下等用户确认,等待期间协作者可能改动目标节点;若沿用确认前的判定、且 `docs +update` 用默认 `revision-id=-1` 写最新版,`overwrite` 会覆盖确认后新增的内容。落笔前的校验按写法基线区分: + +- **占位覆盖(确认时 `draft_state=empty_placeholder`)**:fresh read 必须仍为 `empty_placeholder`(内容未变)才写;读回已变成 `has_draft` 说明等待期间被补入草稿,停下重新确认或改 `append`。 +- **已确认的草稿覆盖(确认时 `draft_state=has_draft` 且 `overwrite_confirmed=true`)**:记录确认时展示给用户的 `revision` / 内容基线,fresh read 必须与该基线**一致**(草稿未再变动)才写;一致即按确认执行,不因读回仍是 `has_draft` 而反复追问;若与基线不一致(草稿在确认后又被改),停下重新确认。 + +两种情况都携带读到的 `revision` 写入;有疑问时优先 `append`,不静默覆盖。 + +## Write Gate + +`WRITE_CONFIRM` 生成写入计划后,必须先经 `scripts/kb_gate.py` 门禁校验,再进入 `WRITE`。门禁是确定性代码校验,agent 的判断只能收紧结果、不能绕过门禁。 + +先从当前 `SKILL.md` 位置解析 skill 根目录绝对路径 ``,再运行;不要假设当前工作目录,也不要用相对路径直接执行。 + +```bash +python3 "/references/scripts/kb_gate.py" --plan "<写入计划 JSON 路径>" +# 或经 stdin:cat plan.json | python3 "<...>/kb_gate.py" --plan - +``` + +写入计划 JSON 每个节点提供:`node_token`、`title`、`obj_type`、`write_mode`(`overwrite`/`append`/`new_docx`/`skip`)、`draft_state`(`empty_placeholder`/`has_draft`)、`overwrite_confirmed`,`new_docx` 另需 `parent_node_token` 或 `space_id`(确认的建节点位置),以及 6 行治理字段 `governance`(`source`、`owner`、`version_status`、`scope_visibility`、`effective_update`、`review_policy`、`page_status`)。 + +门禁判定分两级: + +- **硬拦(`ready=false`,不得写入)**:载体不是 docx 且写法不是 `new_docx`、缺少 6 行治理表、必填字段(来源、适用与可见范围)为空、页面状态非法、覆盖有草稿节点但缺 `overwrite_confirmed`、覆盖写入但 `draft_state` 缺失或未知(fail-closed,避免误清空草稿)、`new_docx` 缺确认的建节点位置(`parent_node_token` / `space_id` 都为空,避免 user 身份回退个人库 my_library)、未知写法。 +- **一致性收紧(`narrowed=true`,可写但降级)**:治理字段含“待确认”却把 `page_status` 标为“已完成”时,强制收紧为“进行中”,以保留“先建框架、后续补全”的场景,同时不让残缺内容冒充已完成。 + +`ready=false` 的节点记入 `unsupported_checks` 并在写入计划中标出原因,不进入 `WRITE`;`narrowed=true` 的节点按收紧后的状态写入。 + +## Command Map + +只能使用当前状态允许的 command family。命令详细语法属于被引用 skill / reference。 + +| State | Allowed Command Families | Purpose | +|-------|--------------------------|---------| +| `PARSE_TARGET` | `wiki +node-get`、`wiki +space-list`、`wiki spaces get`(解析个人库 my_library)、`wiki +node-list --page-all` | 把 URL / 空间解析为 `space_id` 并确认根层节点 | +| `READ_STRUCTURE` | `wiki +node-list --page-all`(对 `has_child=true` 逐层下钻)、`docs +fetch` | 完整递归读取节点树和节点草稿内容 | +| `OUTLINE_PROPOSE` | `wiki +node-create --obj-type docx`(仅用户确认大纲后)、`wiki +node-list --page-all` | 新建确认后的大纲子节点并分页回读(`--page-all`,避免漏掉新建节点) | +| `TYPE_TRIAGE` | 无写命令 | 仅对已读结构做分类 | +| `GEN_STANDARD` | 无写命令 | 模型生成维护规范 | +| `WRITE_CONFIRM` | 无飞书写命令;`python3 /references/scripts/kb_gate.py`(本地只读门禁校验) | 生成写入计划、门禁校验并请用户确认 | +| `WRITE` | `docs +fetch`(overwrite 前重读 draft_state 与 revision)、`docs +update`(`overwrite` / `append` / `block_*`);仅 `new_docx` 时 `wiki +node-create --obj-type docx` | 执行已确认的受控写入 | +| `VERIFY` | `docs +fetch`、`wiki +node-list` | fresh read 校验写入结果 | + +## Transition Rules + +1. `PARSE_TARGET` 无法解析出唯一知识库时,只问目标澄清问题并停止。 +2. `PARSE_TARGET` 按 `Root Node Resolution` 确定唯一 `root_node`;知识空间存在多个顶层节点且无法自动定根时,停下列出候选请用户选定,不擅自选一个或默认全建。 +3. 认证或 API scope 缺失时,按 `lark-shared` 权限处理并停止。 +4. 权限按动作分别判断,填充 `permissions_observed`,一个动作受阻不连累其余动作: + - 读权限缺失 → 停止,无法盘点结构(前提不满足); + - `docs +update` 可用、`wiki +node-create` 不可用 → 照常为可编辑的 docx 节点写规范;`OUTLINE_PROPOSE` 或 `new_docx` 需新建的节点列入“待创建节点(缺创建权限)”并记入 `unsupported_checks`,不阻塞其余写入; + - 两者都可用 → 正常执行建节点与写规范; + - 仅可读 → 只输出规范草案与写入计划,不执行任何写入; + - 某动作实际返回 `permission_denied` → 只停该动作、记入 `unsupported_checks`,不用同参数重试、不静默切 bot、不自动申请权限。 +5. 权限硬规则:读取成功不等于具备写权限;写权限只以实际写入返回为准,不由身份、角色或读取成功推断。遇权限不足不自动申请,但必须精确告知受阻的动作和具体节点,不静默跳过。 +6. `READ_STRUCTURE` 判定结构过简(无子节点或子节点不足以承载分类)时进入 `OUTLINE_PROPOSE`;结构已足够时直接进入 `TYPE_TRIAGE`。节点树读取不全(`partial`,含 `--page-all` 触及默认页数上限)时 fail closed:不进入 `OUTLINE_PROPOSE` / `TYPE_TRIAGE` / `WRITE`,先读全或由用户缩小范围,避免把截断的树误判为过简而重复建大纲或漏写节点。 +7. `OUTLINE_PROPOSE` 中用户拒绝新建大纲、或只想为现有根节点写规范时,跳过新建,直接以现有节点进入 `TYPE_TRIAGE`;不擅自新建任何节点。 +8. `TYPE_TRIAGE` 发现全部节点均为 `non_docx_entity` / `shortcut` 时,说明本知识库没有可写入正文的 docx 节点,询问是否对相关节点走 `new_docx`,不静默结束。 +9. 用户在 `WRITE_CONFIRM` 拒绝写入时,输出已保存的规范草案并转入 `DONE`。 +10. `WRITE` 中单个节点写入失败时,记录失败并继续其余相互独立的节点;写入结束后在 `VERIFY` 统一报告失败项。 +11. 身份在 `PARSE_TARGET` 确定后保持不变;节点解析、读取、写入、验证使用同一身份。 + +## References + +- [lark-drive workflow 总框架](lark-drive-workflow.md) +- [Outputs:规范模板与输出模板](lark-drive-workflow-knowledge-base-bootstrap-outputs.md) +- 门禁脚本:`scripts/kb_gate.py`(及测试 `scripts/kb_gate_test.py`) +- [lark-shared](../../lark-shared/SKILL.md) +- [lark-wiki](../../lark-wiki/SKILL.md) +- [lark-wiki-node-list](../../lark-wiki/references/lark-wiki-node-list.md) +- [lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md) +- [lark-doc](../../lark-doc/SKILL.md) +- [lark-doc-fetch](../../lark-doc/references/lark-doc-fetch.md) +- [lark-doc-update](../../lark-doc/references/lark-doc-update.md) +- [knowledge_organize](lark-drive-workflow-knowledge-organize.md) +- [topic_move_collector](lark-drive-workflow-topic-move-collector.md) diff --git a/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-analyze.md b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-analyze.md new file mode 100644 index 0000000000..c6f3d8b118 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-analyze.md @@ -0,0 +1,118 @@ +# 本地资料入库 — 盘点、对齐、分诊与映射 + +本文件是 [`lark-drive-workflow-knowledge-ingest.md`](lark-drive-workflow-knowledge-ingest.md) 的配套 phase 文档,在 workflow 进入 `INVENTORY`、`TARGET_ALIGN`、`NODE_PROPOSE` 和 `ANALYZE_TRIAGE` 状态时加载。 + +本文承载资料摄取前的**读与分析**细节:本地盘点、与目标库对齐(含降级)、承载节点提议、逐份资料分诊与映射。本阶段全程零飞书写入,唯一例外是 `NODE_PROPOSE` 经用户确认后的节点新建。发布计划、转换写入和验证见 [`lark-drive-workflow-knowledge-ingest-publish.md`](lark-drive-workflow-knowledge-ingest-publish.md)。 + +## INVENTORY:本地资料盘点 + +`inventory.py` 是确定性盘点脚本,只算元数据、不读正文、不修改原件。先从当前 `SKILL.md` 位置解析 skill 根目录绝对路径 ``,再运行;不要假设当前工作目录,也不要用相对路径直接执行。 + +```bash +python3 "/references/scripts/inventory.py" \ + --root "<用户授权的本地路径>" \ + --output-dir "<本次任务目录>/inventory" +``` + +脚本行为要点: + +- **SHA-256 精确去重**:内容哈希相同归为同一 `duplicate_group`,`proposed_action` 标 `deduplicate_review`;只选一个主来源入库,其余保留溯源,不删文件。 +- **敏感初筛**:按文件名命中敏感词(身份证 / 薪资明细 / 合同 / password 等)标 `risk_hint=possible_sensitive:*`;这是初筛线索,最终敏感等级在 `ANALYZE_TRIAGE` 据正文判定。 +- **可解析性判断**:`parse_readiness` = `text_extractable`(可提取文本)/ `ocr_or_visual_review`(图片需 OCR / 视觉审阅)/ `manual_review`(需人工)/ `failed`(读取失败)。 +- **符号链接安全**:拒绝符号链接作为授权根,跳过目录内符号链接(记入 `skipped_symlinks`),不通过快捷方式越界读取。 +- **扫描完整性**:不可读子目录(权限不足等)不会被静默跳过——脚本置 `scan_complete=false`、记入 `unreadable_dirs`,主输出 `ok=false` 且退出码非零。此时**盘点不完整**,须向用户如实报告未覆盖的目录与原因,不得当作完整成功;用户补授权或缩小范围后重跑,不基于残缺盘点直接规划入库。 + +产物:`inventory/inventory.csv`(Excel 友好)+ `inventory/inventory.json`(含 `summary` 与逐条 `items`),填入 `Runtime State` 的 `inventory`。 + +### 增量识别(第二次及以后触发) + +台账地基分层: + +- **每页级持久状态**(复核日期 / 负责人 / 版本)写进知识页的 6 行治理表,跟随知识库、换人不丢。 +- **本次运行的增量去重台账**为本地 `inventory.json` + `execution_ledger`,用于跳过与断点续跑。 + +增量分工:`inventory.py` 每次做**全量盘点**、不读旧台账;**增量对比由 agent 完成**——拿本次 `inventory.json` 与上次的按 `source_id`(SHA-256) 比对,未变资料标记跳过,只对新增 / 变化资料继续后续状态。本地台账缺失时,从目标库现状(`node_inventory`)重建最小基线继续,不直接写入。 + +用户可见输出:资料盘点概览(文件数、重复组数、可能敏感数、无法解析数;增量时标出跳过数)。样式见 [`lark-drive-workflow-knowledge-ingest-outputs.md`](lark-drive-workflow-knowledge-ingest-outputs.md)。 + +## TARGET_ALIGN:与目标库对齐 + +递归读取目标库节点树填充 `node_inventory`:每次 `wiki +node-list` 用 `--page-all`(或按 `page_token` 翻到 `has_more=false`),对 `has_child=true` 的节点逐层下钻,不得只取首页;任何一层未读全时置 `partial` 并记原因。 + +**`partial` 时 fail closed**:节点树未读全(分页上限、权限或 API 失败)时,不得基于残缺 `node_inventory` 做资料映射或进入 `NODE_PROPOSE` 新建节点——否则可能把资料映射错节点,或因没看见已存在的节点而重复新建。此时停下向用户报告读取不全的原因与范围,先补齐读取或由用户缩小目标范围,再继续;确实无法读全时只输出「读取不完整」结论,不写入。 + +### 探测维护规范与对齐模式 + +逐节点探测维护规范,但**只对 origin docx 节点执行 `docs +fetch`**(`node_type=origin` 且 `obj_type=docx`):读取其正文,判断是否存在 `knowledge_base_bootstrap` 写入的维护规范(顶部 6 行治理表 + 收录范围 / 命名规范段落),填充 `standard_map`。sheet、bitable、file、shortcut 等非 docx 节点没有可读正文,直接记为「无可读规范」,不对其 `docs +fetch`(否则会读失败中断混合知识库的对齐)。据规范覆盖情况设 `alignment_mode`: + +| alignment_mode | 判定 | 处理 | +|----------------|------|------| +| `standard` | 目标节点均有维护规范 | 按规范收录范围与命名做映射(主路径,情况 4) | +| `degraded` | 目标节点均无维护规范 | 据资料内容 + 节点标题推断映射与命名;提示可先跑 `knowledge_base_bootstrap`(情况 3) | +| `mixed` | 部分节点有、部分没有 | 逐节点分流:有规范走 `standard`,无规范走 `degraded`(情况 6) | + +### 降级映射的具体做法(无规范时) + +无规范不阻塞入库,只是缺少归类 / 命名标尺。降级四件事: + +1. **归到哪个节点**:读资料内容,与现有各节点**标题**语义匹配,归入最贴近者;匹配不出则归根节点或列「待人工归位」,不硬塞。 +2. **起什么名**:无命名规范,按「资料主题 + 类型」生成通用标题(如 `退货政策说明`),不套格式模板。 +3. **打标记**:`alignment_mode=degraded`;每份资料映射标 `mapping_confidence`(`low` / `medium`)。 +4. **告知**:`TARGET_ALIGN` 提示无规范、映射据推断;发布计划逐项展示推断的节点与名字供用户核对可改;`DONE` 汇总再次声明本批为降级映射。 + +### 判定节点是否足以承载 + +节点为空(仅根节点)、或现有节点无法承载本批资料的主题分布时,判定「节点不足」,进入 `NODE_PROPOSE`(情况 1)。否则直接进入 `ANALYZE_TRIAGE`。 + +## NODE_PROPOSE:据真实资料提议承载节点 + +仅在节点不足以承载资料(情况 1 / 5)时进入。与 `knowledge_base_bootstrap` 的 `OUTLINE_PROPOSE` 区别:后者据**知识库主题**(尚无资料)提大纲,本状态据**已盘点的真实资料内容**提承载节点,因见过真实资料而更贴合。两者输入不同,不重复、不互调。 + +步骤: + +1. 据 `inventory` 与已读资料内容,按主题聚类提议一组承载节点(拟建标题 + 目标位置 + 收录范围摘要),填充 `outline_proposal`。 +2. 展示提议表(含每个拟建节点的精确 `--title`、`--parent-node-token` 或 `--space-id`、`--obj-type docx`),请用户确认。 +3. **建节点是外部写入,必须用户确认后**才执行 `wiki +node-create --obj-type docx`;记录返回的 `node_token` / `obj_token`,并用 `wiki +node-list --page-all` 分页回读并入 `node_inventory`(分页回读避免漏掉刚新建的节点)。 +4. 用户拒绝新建时,只把资料映射到现有节点或列「待人工归位」,不擅自新建。 + +提议表样式见 [`lark-drive-workflow-knowledge-ingest-outputs.md`](lark-drive-workflow-knowledge-ingest-outputs.md)。 + +## ANALYZE_TRIAGE:逐份资料分诊与映射 + +本状态 agent 真正**读取资料正文**(Word / PDF / 图片内容),逐份分析填充 `material_map`。至少完成: + +### 判类与内容归纳 + +- 判定资料类别(制度 / 操作手册 / FAQ / 案例 / 台账等)与承担的知识职责。 +- 一份资料对应一个主问题;据内容归纳,缺失信息记「待确认」不补造。 + +### 版本冲突识别 + +- 优先核对发布主体、版本号、生效 / 失效日期、适用范围、Owner;文件修改时间只是线索,不足以判新旧。 +- 与目标节点已有生产页讲同一件事但对不上时,标 `conflict_status`:`suspected`(疑似)/ `confirmed`(确认冲突)/ `resolved`(已裁决)/ `none`。 +- **无法自动裁决的冲突**:不覆盖现有生产页,保持现状,列入「需业务确认」;该资料由 `publish_gate.py` 阻塞发布,直到 `resolved`。 + +### 敏感判定 + +- 结合 `inventory` 的 `risk_hint` 与正文,定 `sensitivity`:`public` / `internal` / `restricted` / `prohibited`。 +- `prohibited` 不入库;`restricted` 需 `sensitive_review_status=approved` 且用户对具体资料 + 目标范围明确确认,否则由门禁阻塞。 + +### 映射与命名 + +- 据 `standard_map` 把资料映射到 `target_node` 并生成拟定标题(`standard` 模式按规范收录范围与命名;`degraded` 模式据内容推断,标 `mapping_confidence`)。 +- 给 `proposed_action`:`add`(新增)/ `update`(更新现有页)/ `merge`(合并到主页)/ `reference`(仅引用)/ `review`(待确认)/ `skip`(不入库)。 + +### Node Type Triage(对映射目标节点) + +对每份资料的 `target_node` 按 entry 文件 `Node Type Triage` 分诊:`writable_docx` 走主路径;`non_docx_entity` 默认 `skip`(用户要才 `new_docx`);`shortcut` 一律 `skip`。被 skip 的记入 `unsupported_checks` 并在汇总说明。 + +用户可见输出:资料分析表(判类、目标节点、拟定名、冲突 / 敏感标记、映射置信度、处置建议)。样式见 [`lark-drive-workflow-knowledge-ingest-outputs.md`](lark-drive-workflow-knowledge-ingest-outputs.md)。 + +## References + +- [entry:knowledge_ingest 主文档](lark-drive-workflow-knowledge-ingest.md) +- [publish:发布计划、转换写入与验证](lark-drive-workflow-knowledge-ingest-publish.md) +- [outputs:模板](lark-drive-workflow-knowledge-ingest-outputs.md) +- [lark-wiki-node-list](../../lark-wiki/references/lark-wiki-node-list.md)、[lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md) +- [lark-doc-fetch](../../lark-doc/references/lark-doc-fetch.md) +- [knowledge_base_bootstrap](lark-drive-workflow-knowledge-base-bootstrap.md) diff --git a/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-outputs.md b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-outputs.md new file mode 100644 index 0000000000..15e0ea0c18 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-outputs.md @@ -0,0 +1,191 @@ +# 本地资料入库 — 发布计划模板与输出模板 + +本文件是 [`lark-drive-workflow-knowledge-ingest.md`](lark-drive-workflow-knowledge-ingest.md) 的配套引用文档,在 workflow 进入 `NODE_PROPOSE`、`ANALYZE_TRIAGE`、`PUBLISH_PLAN`、`VERIFY` 等需要展示结构化输出的状态时加载。 + +本文承载两类内容:`publish_gate.py` 的发布计划 JSON schema 与 6 行治理表模板,以及各状态用户可见输出的表格样式。所有模板是结构骨架,实际文本由 agent 结合资料内容、目标节点和维护规范填充。列名使用中文;内部枚举值在用户可见输出中转为自然语言中文标签。 + +## 6 行治理表(每个知识页统一表头) + +每个新建或更新的知识页顶部套一张 6 行治理表,字段名称与顺序保持一致,不因资料类型删减。 + +| 字段 | 填写规则 | 门禁相关 | +|------|----------|----------| +| 来源 | 资料的原始出处:原文件名 + 版本 / 日期,多来源逐条列出;转换页标注原 PDF 页码或原文件 | 不得为空 | +| 负责人 | 该知识页内容负责人,优先「姓名|团队」。未知填「待确认」 | 待确认时 `page_status` 须为进行中 | +| 版本与状态 | 写成 `v1.0|已完成`;状态只能是 `进行中 / 已完成 / 已废弃` | 状态须合规;有「待确认」时不得为已完成 | +| 适用与可见范围 | 适用的组织、岗位、人群和可见范围;全员适用也显式写明 | 不得为空 | +| 生效与更新 | 写成 `生效:YYYY-MM-DD|更新:YYYY-MM-DD|原因:首次入库 + 说明`;无独立生效日期写「不适用(原因)」 | 允许「待确认」 | +| 复核策略 | 写成 `类型:<知识类型>|周期:<天>|下次复核:YYYY-MM-DD` | 允许「待确认」 | + +字段值未确定时统一填「待确认」,不得编造。含「待确认」或仅部分解析(partial)的页面 `page_status` 保持「进行中」;只有 6 行齐备、无「待确认」、完整解析的页面才可标「已完成」。门禁把**字段整个缺失(key 省略)视同「待确认」**:缺 负责人 / 版本与状态 / 生效与更新 / 复核策略 任一行时,标「已完成」会被收紧为「进行中」(来源、适用与可见范围为空则直接硬拦)。 + +## 发布计划 JSON schema(publish_gate.py 输入) + +`PUBLISH_PLAN` 生成的发布计划为 JSON 对象,顶层 `items` 数组,每项字段: + +```json +{ + "items": [ + { + "source_id": "", + "title": "退货政策说明", + "publish_role": "knowledge_page", + "write_via": "import_docx", + "proposed_action": "add", + "target_obj_type": "docx", + "target_token": "wikcn_NODE", + "target_obj_token": "doxcn_OBJ", + "parent_token": "", + "space_id": "", + "sensitivity": "internal", + "sensitive_review_status": "", + "conflict_status": "none", + "parse_status": "parsed", + "attachment_confirmed": false, + "governance": { + "source": "退货政策原始 Word|2026-08 版", + "owner": "李四|客服团队", + "version_status": "v1.0|已完成", + "scope_visibility": "全员", + "effective_update": "生效:2026-09-01|更新:2026-09-01|原因:首次入库", + "review_policy": "类型:政策|周期:180|下次复核:2027-03-01", + "page_status": "已完成" + } + } + ] +} +``` + +字段取值: + +- `publish_role`:`knowledge_page`(默认,需 governance)/ `source_attachment`(原文件附件,需 `attachment_confirmed=true`、`write_via=drive_upload`、`target_token` 指向确认的 Wiki 节点,无 governance)。 +- `write_via`:`import_docx` / `docs_update` / `node_create_docx`(知识页三种写法)/ `drive_upload`(仅附件)。 +- `proposed_action`:`add` / `update` / `merge` / `reference` / `review` / `skip`。**闭集,缺失或非法值一律硬拦**;`update` / `merge` 面向既有页必须走 `docs_update`,用 `import_docx` 会被硬拦(避免新增重复子页)。 +- `target_obj_type`:目标节点对象类型;知识页必须为 `docx`。 +- `target_token`:既有目标节点 token(Wiki `wikcn_*`),用于 Wiki 操作;`node_create_docx` 可空,但须提供 `parent_token` 或 `space_id`。 +- `target_obj_token`:目标页的 docx 对象 token(`doxcn_*`)或规范 Wiki URL,供图片类 `docs +update` 绑定本地资源用——裸 `wikcn_*` node token 不触发资源解析;无图片资源时可省略。 +- `parent_token` / `space_id`:`node_create_docx` 的确认建节点位置(二选一必填),防止 user 身份下静默回退到个人库 my_library。 +- `sensitivity`:`public` / `internal` / `restricted` / `prohibited`。**闭集,缺失或非法值一律硬拦**(不当安全放行)。 +- `conflict_status`:`none` / `suspected` / `confirmed` / `resolved`。**闭集,缺失或非法值一律硬拦**。 +- `parse_status`:`parsed` / `partial` / `unsupported` / `failed`。**闭集,缺失或非法值一律硬拦**。 +- `governance.page_status`:`进行中` / `已完成` / `已废弃`。**缺失即硬拦**(不得省略)。 + +门禁输出为 `{ok, summary{total,writable,blocked,narrowed,attachments}, items[]}`,每项含 `ready`、`blocked_reasons`、`narrowed`、`narrow_reasons`、`counts_as_page`、`effective_page_status`。 + +## 用户可见输出模板 + +### INVENTORY:资料盘点概览 + +```text +本地来源:<授权路径> +资料总数:(可提取文本 / 需 OCR 视觉 / 需人工 / 读取失败 ) +精确重复组:(组内仅保留一个主来源入库) +可能敏感(按文件名): +跳过的符号链接: +增量:跳过未变资料 ,本次处理 (首次运行省略此行) +``` + +### TARGET_ALIGN:结构与规范对齐 + +```text +目标知识库:<名称> +节点总数:;对齐模式:按规范 / 降级推断 / 混合 + +| 节点标题 | 类型 | 维护规范 | 收录范围(摘要) | +|----------|------|----------|------------------| +| 退货售后 | 文档 | 有 | 退货政策、售后流程、FAQ | +| 物流配送 | 文档 | 无(降级) | (据资料内容推断) | + +降级提示(无规范时):目标节点缺少维护规范,本次映射将据资料内容与节点标题推断, +建议先运行 knowledge_base_bootstrap 立规范以获得统一收录范围与命名。 +``` + +维护规范列:有 / 无(降级)。类型列:文档 / 表格 / 多维表格 / 思维笔记 / 幻灯片 / 快捷方式。 + +### NODE_PROPOSE:承载节点提议表 + +仅在节点不足以承载资料时展示。据真实资料内容提议,确认后新建。 + +```text +现有节点不足以承载本批资料。基于已盘点资料内容,提议以下承载节点(新建为文档节点,可增删改名): + +| 拟建 --title | 目标位置(parent) | 对象类型 | 收录范围(据资料摘要) | 对应资料数 | +|--------------|--------------------|----------|------------------------|-----------| +| 退货售后 | wikcn_ROOT | docx | 退货政策、售后流程 | 5 | +| 物流配送 | wikcn_ROOT | docx | 配送时效、区域政策 | 3 | + +确认后对每个节点执行: + wiki +node-create --as --parent-node-token wikcn_ROOT --title "<拟建标题>" --obj-type docx + → 记录返回 node_token / obj_token,回读并入 node_inventory + +确认新建这些节点吗?也可增删 / 改名后再建;如只想归入现有节点,可跳过新建。 +``` + +### ANALYZE_TRIAGE:资料分析表 + +```text +| 资料 | 类别 | 目标节点 | 拟定标题 | 处置 | 冲突 | 敏感 | 映射置信度 | +|------|------|----------|----------|------|------|------|-----------| +| 退货政策.docx | 政策 | 退货售后 | 退货政策说明 | 新增 | 无 | 内部 | 高 | +| 退货政策_旧.pdf | 政策 | 退货售后 | — | 待确认 | 疑似冲突 | 内部 | 高 | +| 员工薪资表.xlsx | 台账 | — | — | 不入库 | 无 | 受限 | — | +``` + +处置列:新增 / 更新 / 合并 / 仅引用 / 待确认 / 不入库。冲突列:无 / 疑似冲突 / 确认冲突 / 已裁决。敏感列:公开 / 内部 / 受限 / 禁止。映射置信度列:高 / 中 / 低(降级映射时给出)。 + +### PUBLISH_PLAN:发布计划 + 三清单 + +R2-R3 高风险写入。确认前必须展示每份资料的目标节点稳定标识、`write_via`、门禁结果,以及将写入的精确内容或相对现状的 diff——不能只给标题和摘要。 + +```text +即将入库 份资料,门禁拦截 份,状态收紧 份,跳过 份。写入为高风险操作,确认后执行。 + +| 资料 | 目标节点 (token) | 角色 | 写法 (write_via) | 门禁 | 写入内容 | +|------|------------------|------|------------------|------|----------| +| 退货政策.docx | 退货售后 (wikcn_A) | 知识页 | import_docx | 通过 | 退货政策知识页(全文见下) | +| 配送时效.pdf | 物流配送 (wikcn_B) | 知识页 | docs_update | 收紧为进行中:仅部分解析 | 配送时效正文(标页码,全文见下) | +| 退货政策原件.pdf | 退货售后 (wikcn_A) | 来源附件 | drive_upload | 通过(附件,不计页面) | 原文件留证 | + +<逐份展开将写入的精确正文与治理表;import_docx 标明整理要点,docs_update 标明来源页码> + +门禁拦截(不写入,记入 unsupported_checks): +| 资料 | 原因 | +|------|------| +| 退货政策_旧.pdf | 存在未裁决冲突(conflict_status=confirmed) | +| 员工薪资表.xlsx | 受限敏感内容未通过审核 | + +冲突清单:<列出疑似 / 确认冲突资料及差异,待业务裁决> +敏感清单:<列出 restricted / prohibited 资料及处置> +无法解析清单:<列出 unsupported / failed 资料及原因> +``` + +门禁列:通过 / 通过(附件,不计页面)/ 收紧为进行中(原因)/ 拦截(原因)。角色列:知识页 / 来源附件。 + +### VERIFY:验证与汇总 + +```text +| 资料 | 目标节点 | 写入 | 校验 | +|------|----------|------|------| +| 退货政策.docx | 退货售后 | 成功 | 已确认 docx 正文落地 | +| 配送时效.pdf | 物流配送 | 成功 | 已确认正文落地(进行中) | + +汇总:已入库 份知识页,附件 个,跳过 份,失败 份。 +未入库(unsupported_checks): +| 资料 | 原因 | +|------|------| +| 退货政策_旧.pdf | 未裁决冲突,待业务确认 | +| 员工薪资表.xlsx | 受限敏感未审 | + +台账位置:<本次任务目录>/inventory + execution_ledger +知识库链接: +对齐模式:<按规范 / 降级推断 / 混合>(降级时提示本批映射未依据维护规范) +``` + +校验列:已确认落地 / 已确认落地(进行中)/ 未落地(需重试)/ 失败。 + +## References + +- [entry:knowledge_ingest 主文档](lark-drive-workflow-knowledge-ingest.md) +- [analyze:盘点、对齐、分诊与映射](lark-drive-workflow-knowledge-ingest-analyze.md) +- [publish:发布计划、转换写入与验证](lark-drive-workflow-knowledge-ingest-publish.md) +- 门禁脚本:`scripts/publish_gate.py`(及测试 `scripts/publish_gate_test.py`) diff --git a/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-publish.md b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-publish.md new file mode 100644 index 0000000000..aac716ed54 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest-publish.md @@ -0,0 +1,130 @@ +# 本地资料入库 — 发布计划、转换写入与验证 + +本文件是 [`lark-drive-workflow-knowledge-ingest.md`](lark-drive-workflow-knowledge-ingest.md) 的配套 phase 文档,在 workflow 进入 `PUBLISH_PLAN`、`CONVERT_WRITE` 和 `VERIFY` 状态时加载。 + +本文承载资料入库的**写与验证**细节:发布计划与门禁、异构资料转换铁律、执行台账、写后验证。发布计划 JSON schema 与输出模板见 [`lark-drive-workflow-knowledge-ingest-outputs.md`](lark-drive-workflow-knowledge-ingest-outputs.md)。 + +## 核心铁律:最终载体必须是可检索 docx 正文 + +本 workflow 的完成标准是「目标 Wiki 节点下存在 `obj_type=docx` 的可检索正文」,不是「上传了原文件」。据此: + +- 每个知识页最终都是目标节点下的飞书 docx;原始文件只作来源附件、来源链接,不替代正文。 +- `drive +upload` 只上传原文件(结果类型 `file`),不是文档转换命令,**禁止用于完成知识页**,也禁止把上传成功记为页面 `created` / `verified`。 +- 用户说「补充到知识库」默认目标是知识页;只有明确说「只上传原文件 / 作为附件」时才允许只建文件节点。 + +这些由 `publish_gate.py` 硬门禁强制。 + +## PUBLISH_PLAN:发布计划与门禁 + +### 生成发布计划 + +为每份 `ready` 分析结果生成一个计划项(字段与 schema 见 outputs 文档),至少含: + +- `source_id`、`title`、`target_node`; +- `publish_role`:`knowledge_page`(默认)/ `source_attachment`(仅用户明确要求保留原件时); +- `write_via`:`import_docx` / `docs_update` / `node_create_docx` / `drive_upload`(见 entry 的 `Write Via Selection`); +- `target_obj_type`、`target_token`; +- `sensitivity`、`sensitive_review_status`、`conflict_status`、`parse_status`; +- 知识页的 6 行治理字段 `governance`(见 outputs 文档)。 + +### 运行门禁 + +```bash +python3 "/references/scripts/publish_gate.py" --plan "<发布计划 JSON 路径>" +``` + +门禁两级判定(详见 entry 的 `Publish Gate`):硬拦项 `ready=false` 记入 `unsupported_checks` 不写入;`narrowed=true` 项按收紧后的页面状态写入。 + +### 产出计划 + 三清单 + +向用户展示:发布计划表(目标节点 + `write_via` + 门禁结果 + 逐份精确处置),以及三张清单——**冲突清单**(`conflict_status=suspected/confirmed`)、**敏感清单**(`prohibited` / `restricted` 未审)、**无法解析清单**(`parse_status=unsupported/failed`)。用户确认后仅 `ready=true` 项进入 `CONVERT_WRITE`。全部被拦时(情况 9)报告「本批 0 入库」+ 逐项原因,不静默结束。 + +## CONVERT_WRITE:异构资料转换与写入 + +收到确认后逐份执行。批量写入 / 导入同一位置时**串行**,避免并发冲突。 + +**身份一致性**:本阶段所有写命令(`drive +import`、`docs +update`、`wiki +move`、`wiki +node-create`、`drive +upload`)都用 `PARSE_SOURCES` 确定的同一身份执行——命令模板中的 `--as ` 是占位符,须替换为该身份(用户选 bot 路径就是 `bot`),不得硬编码 `user`。discovery、目标解析、写入、验证若用不同身份,Drive 资源和权限不同,后续 move / 验证会失败或操作到错误资源。 + +### Word / Markdown / TXT / HTML(write_via=import_docx,仅用于新建页) + +`import_docx` 只用于 `proposed_action=add`(**新建页**)。`update` / `merge` 面向既有目标页,必须走 `docs_update` 定向更新,不得用 import_docx——否则会新增一个子页面而不是更新既有页(见「更新既有页」小节)。 + +用 `drive +import --type docx` 转飞书云文档,读回整理后落到目标节点;不要把原始分页、页眉页脚当知识结构。 + +```bash +lark-cli drive +import --as --type docx --file "<本地文件>" --folder-token "<暂存/目标文件夹>" --name "<发布计划中的标题>" +``` + +- 导入结果必须返回 `docx` 类型和在线文档 token,否则转换失败。 +- **异步续跑**:`drive +import` 内置轮询窗口内未完成时会返回 `ready=false` / `timed_out=true` 和 `ticket`,用 `drive +task_result --scenario import --ticket ` 续查,拿到最终在线文档 token 后再继续,不把未完成当完成。 +- **迁入目标节点**:一份对应一页且保真结构适用时,用 `wiki +move --obj-type docx --obj-token <导入文档 token> --target-space-id --target-parent-token <目标父节点>` 迁入目标位置。`docs_to_wiki` 迁入可能返回 `task_id` 异步执行;返回 `ready=false` / `timed_out=true` 时,用 `drive +task_result --scenario wiki_move --task-id ` 续查至完成,不把超时当失败。迁入完成后必须 fresh read 确认目标 Wiki 节点 `obj_type=docx` 且 `docs +fetch` 可读(ready-state 验证),再套 6 行治理表。 +- 多份合并 / 一份拆页 / 需统一重写时,创建目标 docx 节点后 `docs +update` 写整理内容,导入件仅作暂存来源。 +- PDF **不可**用 `drive +import`(不在支持扩展名内),走下节。 + +### PDF(write_via=docs_update) + +PDF 不假设可直接导入。先解析文本层;扫描件借 agent 多模态能力 OCR,记录解析方式、页码与置信度。随后 `docs +update` 把有效内容重建为 docx 正文;原 PDF 仅在需保留证据时作附件。 + +- 文字、表格、结论标注原 PDF 页码; +- OCR 低置信度、表格错位或关键页不可解析时标 `parse_status=partial/unsupported`,不猜测补齐(门禁会拦 unsupported、收紧 partial 的已完成状态); +- 不复制封面、页眉、水印和纯装饰图。 + +### 图片(write_via=docs_update) + +结合上下文判断媒体作用再决定是否入页:只保留能解释规则 / 步骤 / 入口 / 证据的图片,放在其解释的段落附近,加图注(说明 + 来源 + 必要时间),并把图中关键文字转成可检索正文——不让答案只存在于截图里。 + +图片类 `docs +update` 绑定本地资源时,必须用目标页的 **docx 对象 token(`doxcn_*`,即计划里的 `target_obj_token`)或规范 Wiki URL** 定位,裸 Wiki node token(`wikcn_*`)不触发资源解析。计划须同时保留 `target_token`(Wiki 操作用)和 `target_obj_token`(写正文 / 绑图用)。 + +### 更新既有页(write_via=docs_update,proposed_action=update/merge) + +资料映射到一个**既有目标页**(`proposed_action=update` 或 `merge`)时,不论原始资料是不是 Word,都走 `docs_update` 对既有页定向更新,**不走 import_docx**(import 会新增子页面,而非更新目标页): + +- 先用稳定 token(`target_token`)定位既有页并读取现状,不按标题匹配。落笔前 `docs +fetch` 重读并记录 `revision`,携带该 `revision` 再写;若确认后、写入前内容已变(协作者改动),停下重新确认,不用默认 `revision-id=-1` 静默覆盖最新版。 +- 优先定向替换或 block 级编辑受影响部分;仅整页失效且用户确认整页重建时才 overwrite。 +- Word/PDF 等来源仍先解析为整理内容,再写入既有页;导入件(如用到)仅作暂存来源,不作为最终页。 +- 更新后保持 6 行治理表结构,刷新版本、生效 / 更新时间、更新原因与复核策略。 + +### 6 行治理表 + +每个新建或更新的知识页顶部套统一的 6 行治理表(字段与顺序见 outputs 文档),再写正文。字段未知填「待确认」并保持 `page_status=进行中`;只有 6 行齐备、无「待确认」、完整解析的页面才可标「已完成」。 + +### 原文件附件(write_via=drive_upload,默认关闭) + +仅当用户明确要求保留原件时,才把原文件挂为 `source_attachment`。附件分两类,执行路径不同: + +**A. 知识页的伴随附件**(同一份资料既转知识页、又保留原件作证据):上传必须**在其对应知识页 fresh-read 验证通过之后**,即只消费 `execution_ledger` 中状态为 `verified` 的知识页条目: + +- `CONVERT_WRITE` 阶段只写知识页,不上传伴随附件; +- 对应知识页经 `VERIFY` 通过、记为 `verified` 后,才上传其 `source_attachment`(`drive +upload --wiki-token`); +- 对应知识页验证失败(`failed` / `blocked`)的资料**不上传**其原件,避免留下没有可检索知识页的孤儿附件。 + +**B. 纯附件**(用户明确说“只上传原文件 / 只作附件、不要知识页”,该资料没有对应知识页):这类项 `publish_role=source_attachment` 且没有伴随的 `knowledge_page` 台账条目,独立执行——在 `CONVERT_WRITE` 直接 `drive +upload --wiki-token` 挂到用户确认的目标节点,`VERIFY` 校验附件本身已挂载成功。它不依赖任何知识页 `verified`(否则永远执行不了)。 + +两类附件上传成功都只记为附件结果,绝不推进知识页状态。绝不删除、修改或移动原始文件。 + +### 执行台账 + +`execution_ledger` 是唯一进度源,逐份记状态:`planned` → `created`(写入完成待验证)→ `verified`(读验通过)/ `failed`(失败记因,有界重试不无限重试)/ `blocked`(等权限或业务确认)/ `skipped`(用户决定不处理)。重跑时从 `created` 做验证、从 `failed` 做有界重试,不重建 `verified` 项。 + +## VERIFY:写后验证 + +对每个已写页面 fresh read,逐项确认: + +- Wiki 节点 `obj_type=docx`,不是 `file` / `sheet` / 其他类型; +- `docs +fetch` 能读取目标 `obj_token`; +- 6 行治理表完整; +- 正文可检索、非空、不依赖附件才能理解; +- 图片位置 / 图注正确,来源页码 / 时间码可追溯。 + +出现以下任一情况判**转换失败**,记 `failed`,不得标 `verified`:只得到原文件下载卡片、节点类型为 `file`、正文为空、正文只有附件链接、无法 `docs +fetch` 读取。 + +用户可见输出:验证表 + 最终汇总(已入库页数、跳过 / 被拦资料及原因、失败项、台账位置、知识库链接)。样式见 [`lark-drive-workflow-knowledge-ingest-outputs.md`](lark-drive-workflow-knowledge-ingest-outputs.md)。 + +## References + +- [entry:knowledge_ingest 主文档](lark-drive-workflow-knowledge-ingest.md) +- [analyze:盘点、对齐、分诊与映射](lark-drive-workflow-knowledge-ingest-analyze.md) +- [outputs:模板](lark-drive-workflow-knowledge-ingest-outputs.md) +- [lark-drive-import](lark-drive-import.md)、[lark-drive-upload](lark-drive-upload.md) +- [lark-doc-update](../../lark-doc/references/lark-doc-update.md)、[lark-doc-fetch](../../lark-doc/references/lark-doc-fetch.md) +- [lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md)、[lark-wiki-move](../../lark-wiki/references/lark-wiki-move.md) diff --git a/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest.md b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest.md new file mode 100644 index 0000000000..ce9baf3621 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-workflow-knowledge-ingest.md @@ -0,0 +1,228 @@ +# 本地资料入库 Workflow + +Workflow id: `knowledge_ingest` + +Risk / Structure: `R2-R3` / `S3` + +本文实现已注册的本地资料入库 workflow。执行前必须先读取 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。 + +本文定义 workflow 专属的状态机、来源盘点规则、资料分诊与映射规则、转换写入铁律和 command family 允许范围。分析细节、转换/写入细节和输出模板拆到配套 phase / outputs 文档,仅在进入需要它的状态时按需加载。 + +配套 phase / outputs 文档只是本 workflow 的引用文件,不是独立 skill。不要把用户请求直接路由到这些文档。 + +## 必读上下文 + +执行本 workflow 前,必须先读取 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),用于处理身份、认证、权限和写操作确认规则。 + +按阶段渐进加载其他 skill / 引用文档,见 `Progressive Load Map`。不要因为命中本 workflow 就预加载全部文档。 + +## 适用范围 + +本 workflow 用于把用户明确授权的**本地文件**,经盘点、去重、敏感初筛和内容分析后,转换成飞书 **docx 知识页**写入一个**已存在**的 Wiki 知识库,供搜索和知识问答使用。对应「把散落的本地资料整理入库」的场景。 + +适用触发语包括: + +- "把这个文件夹 / 这些本地资料整理进知识库" +- "帮我把这批文档归类后放到知识库对应节点里" +- "盘点这些本地文件,去重后转成知识页入库" +- "这批资料入库,按知识库的收录规范归位和命名" + +目标必须是一个**已存在**的 Wiki 知识库链接、知识空间或节点。来源必须是**本地文件或目录路径**。 + +## 核心立场:知识页而非原文件归档 + +本 workflow 默认交付**可检索的 docx 知识页**,不是原文件归档。原因:上传的原文件(`obj_type=file`)无法被 `docs +fetch` 读取正文,也难以进入飞书知识问答语料;只有 `obj_type=docx` 的可检索正文才是标准知识源。 + +因此: + +- 用户说「把资料补充到知识库」而未明确说「只上传原文件 / 作为附件」时,一律按知识页处理。 +- `drive +upload` 只用于**来源附件**(`source_attachment`),永远不算知识页完成;默认关闭,仅用户明确要求保留原件时才开启。 +- 这两条由 `publish_gate.py` 硬门禁强制,agent 声明只能收紧、不能绕过。 + +## 非目标 + +本 workflow 不处理: + +- **从零创建知识空间**:目标 Wiki 不存在时,本 workflow 停下,请用户先自建一个知识库(或提供已有库链接)再来;本 workflow 不建知识空间,也不代跑其他 workflow(见 `Situation Routing` 情况 2)。 +- **撰写维护规范**:立规范是 [`knowledge_base_bootstrap`](lark-drive-workflow-knowledge-base-bootstrap.md) 的职责;本 workflow 只**读**其产物做映射依据。 +- **云盘 / Wiki / 会议纪要等非本地来源**:本版仅摄取本地文件。 +- **移动、复制、删除或重命名已有节点**:结构调整用 [`knowledge_organize`](lark-drive-workflow-knowledge-organize.md) 或 [`topic_move_collector`](lark-drive-workflow-topic-move-collector.md)。本 workflow 仅在 `NODE_PROPOSE` 经用户确认后新建承载节点。 +- **删除、修改或移动原始资料**:原始文件只读,绝不改动。 +- **知识内容问答或检索**、**AI 质量评分**、**建飞书复核任务**:均不在本版范围。 +- **知识空间成员、权限或密级治理**:用 [`permission_governance`](lark-drive-workflow-permission-governance.md)。 + +## 与 knowledge_base_bootstrap 的关系(松耦合、只读、不互调) + +两个 workflow 数据松耦合,不流程串联,不互相调用: + +- 本 workflow 在 `TARGET_ALIGN` **读** `knowledge_base_bootstrap` 写入各节点的维护规范(收录范围、命名规范),据其决定「资料归到哪个节点、怎么命名」。 +- 读不到规范时**降级继续**(据资料内容 + 节点标题推断映射与命名),并提示用户可先跑 `knowledge_base_bootstrap` 立规范;不代跑、不擅自立规范。 +- 目标库不存在或节点不足时的处理见 `Situation Routing`。 + +## Agent 执行约束 + +触发本 workflow 后,agent 必须: + +1. 按 `Execution State Machine` 的顺序执行,并维护 `Runtime State` 字段。 +2. 执行某个状态前,先加载 `Progressive Load Map` 中该状态要求的引用文档;不要预加载全部文档。 +3. 在 `PUBLISH_PLAN` 用户确认之前,绝不执行任何飞书写入(`docs +update` / `drive +import` 写入目标库 / `drive +upload`)。唯一例外是 `NODE_PROPOSE`:仅在用户单独确认承载节点大纲后,才可执行 `wiki +node-create` 新建节点。 +4. `inventory.py` 只算元数据、不读正文;资料正文分析在 `ANALYZE_TRIAGE` 由 agent 完成。原始文件全程只读,绝不修改 / 移动 / 删除。 +5. 只执行 `Command Map` 允许的 command family;命令语法、scope 要求、参数规则以被引用 skill / reference 为准。 +6. 用户可见说明、字段说明和表格文案使用中文;状态名、字段名、枚举值、命令名保留英文稳定标识;内部枚举值在用户可见输出中转为自然语言中文标签。 +7. 不声明 CLI / API 不支持的能力;无法执行的写入必须记入 `unsupported_checks`,不得静默省略。 +8. 转换写入后必须用 fresh read 验证载体为 docx、正文落地,不以写入命令返回值直接判定成功。 +9. 一份资料写入失败或被门禁拦截,只影响该资料,不连累其余相互独立的资料。 + +## Runtime State + +本 workflow 在共享 `Artifact Contract` 基础上维护以下字段: + +| Field | Meaning | +|-------|---------| +| `current_state` | `Execution State Machine` 中的当前状态 | +| `source_scope` | 用户授权的本地路径(文件或目录)和原始输入;仅本地来源 | +| `target_space` | 解析出的 `space_id`、目标节点范围和用户原始输入 URL;目标为已有 Wiki | +| `identity` | 执行身份,默认 `user`;解析、读取、写入、验证使用同一身份 | +| `inventory` | `inventory.py` 产出的资料台账:每份资料的 `source_id`(SHA-256)、类型、大小、`parse_readiness`、`risk_hint`、重复组 | +| `node_inventory` | 目标库节点的归一化列表:`node_token`、`obj_token`、`title`、`obj_type`、`node_type`、层级 | +| `standard_map` | 各节点从 `knowledge_base_bootstrap` 规范读到的收录范围 / 命名规范;无规范的节点标记缺失 | +| `alignment_mode` | `standard`(有规范可依)/ `degraded`(无规范,据内容推断映射)/ `mixed`(逐节点不同) | +| `outline_proposal` | 节点不足时据真实资料提议的承载节点:拟建标题、位置、收录范围;经用户确认后新建 | +| `material_map` | 每份资料的分析结果:判类、`target_node`、拟定标题、版本冲突状态、敏感等级、`proposed_action`(add/update/merge/reference/review/skip)、`mapping_confidence` | +| `publish_plan` | 逐资料写入计划:`publish_role`、`write_via`、目标节点、`parse_status`、6 行治理字段 | +| `gate_result` | `publish_gate.py` 门禁结果:每项 ready / blocked / narrowed 及原因 | +| `execution_ledger` | 执行台账:每份资料的 `planned` / `created` / `verified` / `failed` / `blocked` / `skipped` 状态,用于断点续跑 | +| `unsupported_checks` | 因权限、类型、解析或门禁无法写入的资料及原因 | +| `verification_results` | 每个已写页面的 fresh read 校验结果 | +| `partial` | 结果是否不完整,以及不完整原因(权限、解析、分页或 API 失败) | + +## Execution State Machine + +| State | Protocol Step | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State | +|-------|---------------|-----------------|---------------|--------------------|---------------|------------| +| `PARSE_SOURCES` | `route` / `scope` | Workflow 触发 | 解析本地授权路径为 `source_scope`;解析目标 Wiki 为 `space_id` 和节点范围。按 `Situation Routing` 判定:目标库不存在 → 停下请用户先自建知识库或提供已有库链接(情况 2);目标不唯一 → 停下请用户选定(情况 7)。确认来源路径与目标库 | 来源路径 + 目标知识库确认;或无库请用户先建库 / 多目标选定请求 | `true` | `INVENTORY` | +| `INVENTORY` | `read` | 来源与目标已确认 | 运行 `inventory.py` 盘点本地资料填充 `inventory`(SHA-256 去重、敏感初筛、可解析性判断)。若存在上次运行的旧台账,做 SHA-256 diff,未变资料标记跳过(增量)。仅本地读,零写入、零修改原件 | 资料盘点概览:文件数、重复组、可能敏感数、无法解析数;增量时标出跳过数 | 除非报错否则 `false` | `TARGET_ALIGN` | +| `TARGET_ALIGN` | `read` / `assess` | 盘点完成 | 递归读取目标库节点树填充 `node_inventory`(`wiki +node-list --page-all`,对 `has_child=true` 逐层下钻);逐节点探测 `knowledge_base_bootstrap` 维护规范填充 `standard_map`,据覆盖情况设 `alignment_mode`(`standard`/`degraded`/`mixed`)。判定节点是否足以承载本批资料 | 目标结构概览 + 规范探测结果(哪些节点有规范)+ 对齐模式;无规范时明确降级提示 | 除非读取被阻断否则 `false` | `NODE_PROPOSE` or `ANALYZE_TRIAGE` | +| `NODE_PROPOSE` | `assess` / `plan` / `confirm` | 节点不足以承载资料(情况 1 / 5) | 加载 analyze phase;据已盘点的真实资料内容提议承载节点填充 `outline_proposal`;请用户确认后用 `wiki +node-create --obj-type docx` 新建,回读并入 `node_inventory` | 承载节点提议表 + 新建确认请求;确认后报告新建结果 | `true` | `ANALYZE_TRIAGE` | +| `ANALYZE_TRIAGE` | `assess` / `plan` | 结构就位(含新建节点) | 加载 analyze phase;逐份读取资料正文,判类、识别版本冲突、映射到目标节点(据 `standard_map`;无规范则降级据内容推断)、按规范或主题生成拟定标题、给 `proposed_action`;对目标节点做 Node Type Triage(非 docx / shortcut 处理)填充 `material_map` | 资料分析表:判类、目标节点、拟定名、冲突/敏感标记、映射置信度 | 除非用户直接进入确认否则 `false` | `PUBLISH_PLAN` | +| `PUBLISH_PLAN` | `plan` / `confirm` | 分析完成 | 加载 publish phase + outputs;生成逐资料 `publish_plan`(含 `publish_role`、`write_via`、目标节点、`parse_status`、6 行治理字段);运行 `publish_gate.py` 门禁;产出发布计划 + 冲突/敏感/无法解析三清单。仅 `ready=true` 的资料进入 `CONVERT_WRITE`,被拦记入 `unsupported_checks`,`narrowed` 项按收紧后状态写入 | 发布计划表(含目标节点 + write_via + 门禁结果)+ 逐资料精确处置 + 三清单 + 被拦/收紧/跳过原因 | `true` | `CONVERT_WRITE` or `DONE` | +| `CONVERT_WRITE` | `execute` | 用户已确认发布计划 | 加载 publish phase;按计划逐份转换写入知识页:Word/.md/.txt/.html 走 `drive +import --type docx`,PDF/图片解析后 `docs +update`,每页套 6 行治理表;`new_docx` 目标先 `wiki +node-create`。**纯附件项**(用户只要原件、无对应知识页)在此直接 `drive +upload` 挂到确认节点;**知识页的伴随附件不在此上传**(留到 VERIFY)。逐项更新 `execution_ledger` | 转换写入进度报告 | 除非被阻断否则 `false` | `VERIFY` | +| `VERIFY` | `verify` | 写入完成 | 对每个已写页面 fresh read:确认 `obj_type=docx`、`docs +fetch` 可读、6 行治理表存在、正文非空且不依赖附件;任一不满足记 `failed`。**仅对 `verified` 的知识页、且用户开启伴随附件时,才 `drive +upload` 挂其 `source_attachment`(页面验证失败的不上传,避免孤儿附件)**;纯附件在 `CONVERT_WRITE` 已上传,此处校验其挂载成功。汇总 `unsupported_checks` | 验证表 + 最终汇总 | `false` | `DONE` | +| `DONE` | `done` | 无更多动作 | 停止 | 最终回复:已入库页数、跳过 / 被拦资料及原因、失败项、台账位置、知识库链接 | `false` | End | + +## Progressive Load Map + +Agent 必须在执行某状态前,读取该状态要求的引用文档。 + +| State | Required Reference | +|-------|---------------------| +| `PARSE_SOURCES` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)、[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md) | +| `INVENTORY` | 本文件的 `Command Map`;脚本 `scripts/inventory.py` | +| `TARGET_ALIGN` | [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md)、[`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) | +| `NODE_PROPOSE` | [`lark-drive-workflow-knowledge-ingest-analyze.md`](lark-drive-workflow-knowledge-ingest-analyze.md)、[`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) | +| `ANALYZE_TRIAGE` | [`lark-drive-workflow-knowledge-ingest-analyze.md`](lark-drive-workflow-knowledge-ingest-analyze.md) | +| `PUBLISH_PLAN` | [`lark-drive-workflow-knowledge-ingest-publish.md`](lark-drive-workflow-knowledge-ingest-publish.md)、[`lark-drive-workflow-knowledge-ingest-outputs.md`](lark-drive-workflow-knowledge-ingest-outputs.md);门禁脚本 `scripts/publish_gate.py` | +| `CONVERT_WRITE` | [`lark-drive-workflow-knowledge-ingest-publish.md`](lark-drive-workflow-knowledge-ingest-publish.md)、[`../../lark-drive/references/lark-drive-import.md`](lark-drive-import.md)、[`../../lark-doc/references/lark-doc-update.md`](../../lark-doc/references/lark-doc-update.md);`import_docx` 迁入时 [`../../lark-wiki/references/lark-wiki-move.md`](../../lark-wiki/references/lark-wiki-move.md) 与异步续跑 [`lark-drive-task-result.md`](lark-drive-task-result.md);`new_docx` 时 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md);纯附件时 [`lark-drive-upload.md`](lark-drive-upload.md) | +| `VERIFY` | 复用 `TARGET_ALIGN` 阶段的读取上下文;用户开启伴随附件时 [`lark-drive-upload.md`](lark-drive-upload.md)(仅对 `verified` 知识页) | + +## Situation Routing + +`PARSE_SOURCES` 与 `TARGET_ALIGN` 按目标库现状分流。九种情况的完整处理: + +| 情况 | 判定 | 处理 | +|------|------|------| +| 1. 有库、节点不足以承载资料 | `TARGET_ALIGN` | 进入 `NODE_PROPOSE`:据真实资料提议承载节点,用户确认后新建(写入需确认),回主流程 | +| 2. 目标库不存在 | `PARSE_SOURCES` | 停下,请用户先自建一个知识库或提供已有库链接再来;**不**自行建知识空间、**不**代跑其他 workflow(建库能力不在本 workflow) | +| 3. 有库、无维护规范 | `TARGET_ALIGN` | `alignment_mode=degraded`,据资料内容 + 节点标题推断映射与命名;提示可先跑 `knowledge_base_bootstrap`;不强制、不代跑 | +| 4. 有库、有规范 | `TARGET_ALIGN` | 正常主路径:按规范收录范围与命名做映射 | +| 5. 有库有规范、但无节点可承载这批资料 | `ANALYZE_TRIAGE` | 退化为情况 1,进入 `NODE_PROPOSE`;或用户选择归入最近节点 | +| 6. 部分节点有规范、部分没有 | `TARGET_ALIGN` 逐节点 | `alignment_mode=mixed`:映射到有规范节点走情况 4,映射到无规范节点走情况 3 降级 | +| 7. 目标不唯一(解析出多个库) | `PARSE_SOURCES` | 停下列候选,请用户选定唯一目标 | +| 8. 目标节点非 docx(sheet/bitable/mindnote/shortcut) | `ANALYZE_TRIAGE`(Node Type Triage) | non_docx_entity 默认跳过并记录,用户要才 `new_docx` 另建 docx 承载;shortcut 一律跳过 | +| 9. 资料全被门禁拦下(全敏感/全冲突/全不可解析) | `PUBLISH_PLAN` | 不静默结束:报告「本批 0 入库」+ 逐份原因 + 补救建议 | + +## Node Type Triage + +`ANALYZE_TRIAGE` 对每个**映射目标节点**依据 `obj_type` / `node_type` 分诊。禁止假设目标节点都是 docx。 + +| 分类 | 判定条件 | 处理方式 | +|------|----------|----------| +| `writable_docx` | `obj_type=docx` 且 `node_type=origin` | 主路径:`docs +update` 或 `import_docx` 写入知识页 | +| `non_docx_entity` | `node_type=origin` 且 `obj_type != docx` | 默认 `skip` 并记入 `unsupported_checks`。仅用户在 `PUBLISH_PLAN` 明确要求时走 `new_docx`:在其同级 / 子级新建 docx 节点承载,原对象不改动 | +| `shortcut` | `node_type=shortcut`(任意 `obj_type`) | 一律 `skip` 并记入 `unsupported_checks`;快捷方式无自有正文 | + +完备性要求:每个映射目标节点都必须落入三类之一,按 `shortcut` → `writable_docx` → `non_docx_entity` 顺序穷尽划分;未识别的新 `obj_type` 归入 `non_docx_entity`,不得漏过。 + +## Write Via Selection + +对 `writable_docx` 目标,依据 `proposed_action` 与资料类型选择 `write_via`(详见 publish phase)。**先看 `proposed_action`**:`update` / `merge` 面向既有页,一律走 `docs_update`;`add`(新建页)才按资料类型选 import 或 docs_update。 + +| proposed_action / 资料类型 | write_via | 说明 | +|----------------------------|-----------|------| +| update / merge(既有页,任意来源) | `docs_update` | 定向更新既有页;不用 import_docx(会新增子页面而非更新目标页) | +| add + Word / .doc / .md / .txt / .html | `import_docx` | `drive +import --type docx` 转飞书文档后整理,`wiki +move` 迁入目标节点 | +| add + PDF / 图片 / 需重写的内容 | `docs_update` | 解析 / OCR 后 `docs +update` 重建可检索正文 | +| add + 目标节点缺失、需新建承载页 | `node_create_docx` | 先 `wiki +node-create --obj-type docx` 再写正文 | +| 原始文件(仅用户开启附件时) | `drive_upload` | 只作 `source_attachment`,永不算知识页完成 | + +## Publish Gate + +`PUBLISH_PLAN` 生成发布计划后,必须先经 `scripts/publish_gate.py` 门禁校验,再进入 `CONVERT_WRITE`。门禁是确定性代码校验,agent 判断只能收紧、不能绕过。 + +先从当前 `SKILL.md` 位置解析 skill 根目录绝对路径 ``,再运行;不要假设当前工作目录,也不要用相对路径直接执行。 + +```bash +python3 "/references/scripts/publish_gate.py" --plan "<发布计划 JSON 路径>" +# 或经 stdin:cat plan.json | python3 "<...>/publish_gate.py" --plan - +``` + +发布计划 JSON 每项字段与门禁判定规则见 [`lark-drive-workflow-knowledge-ingest-publish.md`](lark-drive-workflow-knowledge-ingest-publish.md) 与 [`lark-drive-workflow-knowledge-ingest-outputs.md`](lark-drive-workflow-knowledge-ingest-outputs.md)。门禁判定分两级: + +- **硬拦(`ready=false`,不得写入)**:知识页载体非 docx、`write_via=drive_upload` 冒充知识页、缺 6 行治理表、必填字段(来源、适用可见范围)空、页面状态非法、敏感项(prohibited 或 restricted 未审)进生产、未裁决冲突(suspected/confirmed)进生产、资料不可解析(unsupported/failed)、缺目标 token、未知 write_via / publish_role、附件缺显式确认。 +- **一致性收紧(`narrowed=true`,可写但降级)**:治理字段含「待确认」却标「已完成」,或资料仅部分解析(partial)却标「已完成」→ 强制收紧为「进行中」。 + +`ready=false` 的资料记入 `unsupported_checks` 并在计划中标出原因,不进入 `CONVERT_WRITE`;`narrowed=true` 按收紧后状态写入。 + +## Command Map + +只能使用当前状态允许的 command family。命令详细语法属于被引用 skill / reference。 + +| State | Allowed Command Families | Purpose | +|-------|--------------------------|---------| +| `PARSE_SOURCES` | `wiki +node-get`、`wiki +space-list`、`wiki +node-list --page-all`、`drive +search`(定位/消歧目标库) | 解析本地路径与目标库,判定 `Situation Routing` | +| `INVENTORY` | `python3 /references/scripts/inventory.py`(本地只读盘点) | 盘点本地资料、去重、敏感初筛 | +| `TARGET_ALIGN` | `wiki +node-list --page-all`(逐层下钻)、`docs +fetch` | 读节点树与各节点维护规范 | +| `NODE_PROPOSE` | `wiki +node-create --obj-type docx`(仅用户确认后)、`wiki +node-list --page-all` | 新建确认后的承载节点并分页回读(`--page-all`,避免漏掉新建节点) | +| `ANALYZE_TRIAGE` | 无飞书写命令(agent 读本地资料正文分析) | 判类、冲突识别、映射、命名、分诊 | +| `PUBLISH_PLAN` | 无飞书写命令;`python3 /references/scripts/publish_gate.py`(本地只读门禁) | 生成发布计划、门禁校验、请用户确认 | +| `CONVERT_WRITE` | `docs +fetch`(update/merge 落笔前重读 revision)、`drive +import --type docx`、`drive +task_result --scenario import`(import 异步续跑)、`docs +update`、`wiki +move --obj-type docx`(import 件迁入目标节点)、`drive +task_result --scenario wiki_move`(迁入异步续跑)、`wiki +node-create --obj-type docx`(new_docx)、`drive +upload`(仅纯附件项) | 执行已确认的受控转换写入(纯附件在此上传,伴随附件留到 VERIFY) | +| `VERIFY` | `docs +fetch`、`wiki +node-list`;`drive +upload`(仅对 `verified` 知识页挂伴随 source_attachment) | fresh read 校验写入结果,并对已验证页面按需挂伴随附件 | + +## Transition Rules + +1. `PARSE_SOURCES` 无法解析出本地来源路径或唯一目标库时,只问澄清问题并停止。 +2. `PARSE_SOURCES` 检测目标库不存在(情况 2)时,停下请用户先自建知识库或提供已有库链接再来,不自行建知识空间、不代跑其他 workflow;目标不唯一(情况 7)时列候选请用户选定。 +3. 认证或 API scope 缺失时,按 `lark-shared` 权限处理并停止。 +4. 权限按动作分别判断,一个动作受阻不连累其余:读权限缺失 → 停止(无法盘点结构);`docs +update` 可用而 `wiki +node-create` 不可用 → 照常写可编辑 docx 节点,`NODE_PROPOSE` / `new_docx` 需新建的列入「待创建节点」并记 `unsupported_checks`;仅可读 → 只输出发布计划不写入;某动作实际返回 `permission_denied` → 只停该动作、记入 `unsupported_checks`,不同参重试、不静默切 bot、不自动申请权限。 +5. 权限硬规则:读取成功不等于具备写权限;写权限只以实际写入返回为准。 +6. `TARGET_ALIGN` 判定节点足以承载资料时直接进入 `ANALYZE_TRIAGE`;不足时进入 `NODE_PROPOSE`。用户拒绝新建节点时,只把资料映射到现有节点或列为待人工归位,不擅自新建。节点树读取不全(`partial`)时 fail closed:不基于残缺清单做映射或新建节点,停下报告并先补齐读取。 +7. 无维护规范时(情况 3/6)降级继续并提示,不强制路由到 `knowledge_base_bootstrap`。 +8. `ANALYZE_TRIAGE` 发现版本冲突且无法自动裁决时,该资料标 `conflict_status=suspected/confirmed`,不覆盖现有生产页,列入「需业务确认」;相关资料由门禁阻塞发布。 +9. `PUBLISH_PLAN` 门禁拦截的资料不进入写入;用户拒绝发布时输出已保存的发布计划并转入 `DONE`。全部资料被拦时(情况 9)报告 0 入库及逐项原因,不静默结束。 +10. `CONVERT_WRITE` 中单份资料失败或被拦,记录并继续其余相互独立的资料;`drive +upload` 成功只记为附件结果,绝不推进页面状态。原始文件全程只读。 +11. 身份在 `PARSE_SOURCES` 确定后保持不变;盘点、读取、写入、验证使用同一身份。 + +## References + +- [lark-drive workflow 总框架](lark-drive-workflow.md) +- [Analyze:盘点、对齐、分诊与映射](lark-drive-workflow-knowledge-ingest-analyze.md) +- [Publish:发布计划、转换写入与验证](lark-drive-workflow-knowledge-ingest-publish.md) +- [Outputs:发布计划模板与输出模板](lark-drive-workflow-knowledge-ingest-outputs.md) +- 脚本:`scripts/inventory.py`(及测试 `scripts/inventory_test.py`)、`scripts/publish_gate.py`(及测试 `scripts/publish_gate_test.py`) +- [lark-shared](../../lark-shared/SKILL.md) +- [lark-wiki](../../lark-wiki/SKILL.md)、[lark-wiki-node-list](../../lark-wiki/references/lark-wiki-node-list.md)、[lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md) +- [lark-doc](../../lark-doc/SKILL.md)、[lark-doc-fetch](../../lark-doc/references/lark-doc-fetch.md)、[lark-doc-update](../../lark-doc/references/lark-doc-update.md) +- [lark-drive-import](lark-drive-import.md)、[lark-drive-upload](lark-drive-upload.md) +- [knowledge_base_bootstrap](lark-drive-workflow-knowledge-base-bootstrap.md) +- [knowledge_organize](lark-drive-workflow-knowledge-organize.md) +- [topic_move_collector](lark-drive-workflow-topic-move-collector.md) diff --git a/skills/lark-drive/references/lark-drive-workflow.md b/skills/lark-drive/references/lark-drive-workflow.md index 30707769a9..9c3c9ce87c 100644 --- a/skills/lark-drive/references/lark-drive-workflow.md +++ b/skills/lark-drive/references/lark-drive-workflow.md @@ -97,7 +97,7 @@ Structure Level: 2. Entry file 超过约 300 行时,优先拆 `commands`、`outputs` 或 `artifacts` reference。 3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。 4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template,不默认新增独立 workflow。 -5. 已有样板:`permission_governance` 是 `R2/S2`;`knowledge_organize` 和 `topic_move_collector` 是 `R2-R3/S3`。 +5. 已有样板:`permission_governance` 是 `R2/S2`;`knowledge_organize` 和 `topic_move_collector` 是 `R2-R3/S3`;`knowledge_ingest` 是 `R2-R3/S3`。 ## 加载与拆分边界 @@ -113,6 +113,8 @@ Structure Level: | `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 | | `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 | | `topic_move_collector` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-topic-move-collector.md`](lark-drive-workflow-topic-move-collector.md) | 按主题、关键词或内容线索跨容器搜索资料,验证相关性和移动资格,并在用户确认后归档到 Drive 文件夹或 Wiki 节点 | +| `knowledge_base_bootstrap` | Registered | `R2` | `S2` | [`lark-drive-workflow-knowledge-base-bootstrap.md`](lark-drive-workflow-knowledge-base-bootstrap.md) | 对已存在的 Wiki 知识库,基于现有节点结构和草稿生成标准维护要求,并在用户确认后将通用规范写入根节点、专属维护要求写入各子节点 | +| `knowledge_ingest` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-ingest.md`](lark-drive-workflow-knowledge-ingest.md) | 把授权的本地文件盘点、去重、敏感初筛后,据知识库维护规范映射归位,转成飞书 docx 知识页写入已有 Wiki 并写后验证;只上传原文件不算完成,目标库不存在则指路 knowledge_base_bootstrap | ## Workflow Loading diff --git a/skills/lark-drive/references/scripts/inventory.py b/skills/lark-drive/references/scripts/inventory.py new file mode 100644 index 0000000000..4a32d37a79 --- /dev/null +++ b/skills/lark-drive/references/scripts/inventory.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Build a read-only inventory for an explicitly authorized local source path. + +This script is the deterministic ingest step for the `knowledge_ingest` +workflow's INVENTORY state. It scans an authorized local file or directory and +produces a source ledger (inventory.csv / inventory.json) without modifying, +moving, or deleting any source file. + +Design principle: the inventory is metadata only. It computes SHA-256 for exact +deduplication, flags possibly-sensitive files by filename, and classifies parse +readiness by extension. It never reads file *content* to make governance +decisions -- that is the agent's job in a later state. Incremental diffing (skip +files whose SHA-256 is unchanged since a prior run) is likewise done by the +agent comparing a fresh inventory.json against the prior one; this script always +performs a full scan and carries no baseline state. + +Symlink safety: the authorized root must not itself be a symbolic link, and any +symlink encountered inside the tree is skipped (never resolved or read), so a +shortcut cannot redirect the scan outside the authorized scope. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path + + +DEFAULT_EXTENSIONS = { + ".csv", ".doc", ".docx", ".htm", ".html", ".jpeg", ".jpg", ".json", + ".md", ".ods", ".odt", ".pdf", ".png", ".ppt", ".pptx", ".rtf", + ".svg", ".tif", ".tiff", ".tsv", ".txt", ".xls", ".xlsx", ".yaml", ".yml", +} + +SKIP_DIRS = { + ".git", ".svn", "__pycache__", "node_modules", "dist", "build", + ".cache", ".idea", ".vscode", +} + +SENSITIVE_NAME_HINTS = { + "身份证", "手机号", "银行卡", "花名册", "客户名单", "员工名单", "工资明细", + "薪资明细", "绩效结果", "病历", "体检结果", "合同", "仲裁", "申诉", "奖惩", + "password", "secret", "access_token", "api_key", "credential", "private_key", +} + +TEXT_EXTRACTABLE = { + ".csv", ".doc", ".docx", ".htm", ".html", ".json", ".md", ".ods", ".odt", + ".pdf", ".ppt", ".pptx", ".rtf", ".tsv", ".txt", ".xls", ".xlsx", ".yaml", ".yml", +} + +IMAGE_EXTENSIONS = {".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Inventory authorized source files without modifying them." + ) + parser.add_argument("--root", required=True, help="Authorized file or directory to scan") + parser.add_argument("--output-dir", required=True, help="Directory for inventory.csv/json") + parser.add_argument( + "--extensions", + help="Comma-separated extensions; defaults to common knowledge-source formats", + ) + parser.add_argument("--include-hidden", action="store_true", help="Include hidden files") + return parser.parse_args() + + +def normalize_extensions(raw: str | None) -> set[str]: + if not raw: + return set(DEFAULT_EXTENSIONS) + extensions = set() + for value in raw.split(","): + value = value.strip().lower() + if value: + extensions.add(value if value.startswith(".") else f".{value}") + if not extensions: + raise ValueError("--extensions did not contain a usable extension") + return extensions + + +def hash_file(path: Path) -> str: + digest = hashlib.sha256() + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def iter_files(root: Path, include_hidden: bool, skipped_symlinks: list[str], + skipped_nonregular: list[str], walk_errors: list[str], + exclude_dir: Path | None = None): + if root.is_file(): + yield root + return + + def _on_error(exc: OSError) -> None: + # os.walk silently skips a directory it cannot list unless we capture the + # error here; record it so the scan is reported incomplete rather than a + # false success that omits authorized material. + target = getattr(exc, "filename", None) or str(root) + try: + rel = str(Path(target).relative_to(root)) + except (ValueError, TypeError): + rel = str(target) + walk_errors.append(f"{rel}: {exc.strerror or exc}") + + for current, dirs, files in os.walk(root, onerror=_on_error): + dirs[:] = sorted( + item for item in dirs + if item not in SKIP_DIRS and (include_hidden or not item.startswith(".")) + and not _record_symlink(Path(current) / item, root, skipped_symlinks) + and not _is_excluded(Path(current) / item, exclude_dir) + ) + for name in sorted(files): + if name.startswith("~$") or (not include_hidden and name.startswith(".")): + continue + path = Path(current) / name + if _record_symlink(path, root, skipped_symlinks): + continue + # Skip non-regular files (FIFOs, devices, sockets): opening a FIFO + # for reading blocks indefinitely in hash_file. is_file() follows no + # symlink here because symlinks are already filtered above. + if not path.is_file(): + skipped_nonregular.append(_relative_name(path, root)) + continue + yield path + + +def _relative_name(path: Path, root: Path) -> str: + """Best-effort path relative to root, falling back to the bare name.""" + try: + return str(path.relative_to(root)) + except ValueError: + return path.name + + +def _is_excluded(path: Path, exclude_dir: Path | None) -> bool: + """Return True if path is the workflow output dir nested under the root. + + Keeps a re-run from ingesting its own inventory.csv/json ledger. + """ + if exclude_dir is None: + return False + try: + return path.resolve() == exclude_dir + except OSError: + return False + + +def _record_symlink(path: Path, root: Path, skipped_symlinks: list[str]) -> bool: + """Return True for symlinks without resolving or reading their targets.""" + if not path.is_symlink(): + return False + skipped_symlinks.append(_relative_name(path, root)) + return True + + +def risk_hint(name: str) -> str: + lowered = name.lower() + hits = sorted(hint for hint in SENSITIVE_NAME_HINTS if hint.lower() in lowered) + return "possible_sensitive:" + "|".join(hits) if hits else "needs_content_review" + + +def parse_readiness(extension: str) -> str: + if extension in TEXT_EXTRACTABLE: + return "text_extractable" + if extension in IMAGE_EXTENSIONS: + return "ocr_or_visual_review" + return "manual_review" + + +def empty_row(relative: str, title: str, extension: str, error: str) -> dict: + return { + "source_id": "", "source_type": "local_file", "source_location": relative, + "title": title, "extension": extension, "size_bytes": "", "modified_at": "", + "sha256": "", "duplicate_group": "", "duplicate_count": "", + "parse_readiness": "failed", "risk_hint": "unknown", "version": "待确认", + "publisher": "待确认", "business_owner": "待指定", "topic": "待分类", + "scope": "待确认", "audience": "待确认", "sensitivity": "待人工审核", + "conflict_status": "unknown", "target_node": "待映射", + "proposed_action": "manual_review", "review_status": "blocked", "error": error, + } + + +def build_inventory( + root: Path, + extensions: set[str], + include_hidden: bool, + skipped_symlinks: list[str], + skipped_nonregular: list[str], + walk_errors: list[str], + exclude_dir: Path | None = None, +) -> list[dict]: + rows = [] + for path in iter_files(root, include_hidden, skipped_symlinks, + skipped_nonregular, walk_errors, exclude_dir): + extension = path.suffix.lower() + if extension not in extensions: + continue + relative = path.name if root.is_file() else str(path.relative_to(root)) + try: + stat = path.stat() + digest = hash_file(path) + rows.append({ + "source_id": digest, + "source_type": "local_file", + "source_location": relative, + "title": path.name, + "extension": extension, + "size_bytes": stat.st_size, + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "sha256": digest, + "duplicate_group": "", + "duplicate_count": 1, + "parse_readiness": parse_readiness(extension), + "risk_hint": risk_hint(path.name), + "version": "待确认", + "publisher": "待确认", + "business_owner": "待指定", + "topic": "待分类", + "scope": "待确认", + "audience": "待确认", + "sensitivity": "待人工审核", + "conflict_status": "none", + "target_node": "待映射", + "proposed_action": "review", + "review_status": "pending", + "error": "", + }) + except (OSError, PermissionError) as exc: + rows.append(empty_row(relative, path.name, extension, str(exc))) + + counts = Counter(row["sha256"] for row in rows if row["sha256"]) + groups = { + digest: f"exact-{index:04d}" + for index, (digest, count) in enumerate( + ((digest, count) for digest, count in sorted(counts.items()) if count > 1), + start=1, + ) + } + for row in rows: + digest = row["sha256"] + if digest: + row["duplicate_count"] = counts[digest] + row["duplicate_group"] = groups.get(digest, "") + if counts[digest] > 1: + row["proposed_action"] = "deduplicate_review" + return rows + + +def write_outputs( + rows: list[dict], + root: Path, + output_dir: Path, + skipped_symlinks: list[str], + skipped_nonregular: list[str], + walk_errors: list[str], +) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + fields = list(rows[0]) if rows else list(empty_row("", "", "", "")) + with (output_dir / "inventory.csv").open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + payload = { + "schema_version": "1.0", + "generated_at": datetime.now(tz=timezone.utc).isoformat(), + "authorized_root": str(root.resolve()), + "source_files_modified": False, + # A scan is complete only if every directory under the root was listable. + # An unreadable subdirectory makes the inventory incomplete, so downstream + # planning must not treat it as a full picture of authorized material. + "scan_complete": not walk_errors, + "skipped_symlinks": sorted(skipped_symlinks), + "skipped_nonregular": sorted(skipped_nonregular), + "unreadable_dirs": sorted(walk_errors), + "summary": { + "files": len(rows), + "skipped_symlinks": len(skipped_symlinks), + "skipped_nonregular": len(skipped_nonregular), + "unreadable_dirs": len(walk_errors), + "failed": sum(row["parse_readiness"] == "failed" for row in rows), + "exact_duplicate_groups": len({row["duplicate_group"] for row in rows if row["duplicate_group"]}), + "possible_sensitive_by_filename": sum( + row["risk_hint"].startswith("possible_sensitive:") for row in rows + ), + }, + "items": rows, + } + (output_dir / "inventory.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + +def main() -> int: + args = parse_args() + root = Path(args.root).expanduser() + if root.is_symlink(): + print( + f"error: authorized root must not be a symbolic link: {root}", + file=sys.stderr, + ) + return 2 + if not root.exists() or not (root.is_file() or root.is_dir()): + print(f"error: authorized root is not a readable file or directory: {root}", file=sys.stderr) + return 2 + try: + output_dir = Path(args.output_dir).expanduser() + # Reject output-dir equal to the scan root: the ledger would be written + # into the very directory being scanned and ingested on the next run. + # (A nested output-dir is instead pruned during traversal, below.) + try: + resolved_output = output_dir.resolve() + except OSError: + resolved_output = None + if root.is_dir() and resolved_output is not None and resolved_output == root.resolve(): + print( + "error: --output-dir must not be the scan root; choose a path " + "outside the scanned directory to avoid ingesting the ledger", + file=sys.stderr, + ) + return 2 + # If the output dir is nested under the scanned root, exclude it so a + # re-run does not ingest its own inventory ledger (and the JSON's + # changing timestamp does not read as a new file every run). + exclude_dir = resolved_output + skipped_symlinks: list[str] = [] + skipped_nonregular: list[str] = [] + walk_errors: list[str] = [] + rows = build_inventory( + root, + normalize_extensions(args.extensions), + args.include_hidden, + skipped_symlinks, + skipped_nonregular, + walk_errors, + exclude_dir, + ) + write_outputs(rows, root, output_dir, skipped_symlinks, skipped_nonregular, walk_errors) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + scan_complete = not walk_errors + print(json.dumps({ + "ok": scan_complete, + "scan_complete": scan_complete, + "files": len(rows), + "exact_duplicate_groups": len({row["duplicate_group"] for row in rows if row["duplicate_group"]}), + "skipped_symlinks": len(skipped_symlinks), + "skipped_nonregular": len(skipped_nonregular), + "unreadable_dirs": len(walk_errors), + "output_dir": str(output_dir.resolve()), + "source_files_modified": False, + }, ensure_ascii=False)) + # An unreadable directory means authorized material may be missing from the + # ledger; exit non-zero so the caller does not treat it as a complete scan. + return 0 if scan_complete else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/lark-drive/references/scripts/inventory_test.py b/skills/lark-drive/references/scripts/inventory_test.py new file mode 100644 index 0000000000..c67f137104 --- /dev/null +++ b/skills/lark-drive/references/scripts/inventory_test.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Tests for inventory.py. Run: python3 inventory_test.py""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +import inventory + + +class InventoryHelpersTest(unittest.TestCase): + def test_normalize_extensions_defaults(self): + self.assertEqual(inventory.normalize_extensions(None), set(inventory.DEFAULT_EXTENSIONS)) + self.assertEqual(inventory.normalize_extensions(""), set(inventory.DEFAULT_EXTENSIONS)) + + def test_normalize_extensions_adds_leading_dot(self): + self.assertEqual(inventory.normalize_extensions("pdf, docx"), {".pdf", ".docx"}) + + def test_normalize_extensions_rejects_empty_list(self): + with self.assertRaises(ValueError): + inventory.normalize_extensions(", ,") + + def test_risk_hint_flags_sensitive_filename(self): + self.assertTrue(inventory.risk_hint("员工薪资明细.xlsx").startswith("possible_sensitive:")) + self.assertTrue(inventory.risk_hint("api_key_backup.txt").startswith("possible_sensitive:")) + + def test_risk_hint_default_needs_review(self): + self.assertEqual(inventory.risk_hint("产品说明.docx"), "needs_content_review") + + def test_parse_readiness_classes(self): + self.assertEqual(inventory.parse_readiness(".pdf"), "text_extractable") + self.assertEqual(inventory.parse_readiness(".png"), "ocr_or_visual_review") + self.assertEqual(inventory.parse_readiness(".mov"), "manual_review") + + +class InventoryScanTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def _write(self, name: str, content: bytes) -> Path: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return path + + def _build(self, **kwargs): + skipped: list[str] = [] + skipped_nonregular: list[str] = [] + walk_errors: list[str] = [] + rows = inventory.build_inventory( + self.root, + inventory.normalize_extensions(kwargs.get("extensions")), + kwargs.get("include_hidden", False), + skipped, + skipped_nonregular, + walk_errors, + kwargs.get("exclude_dir"), + ) + return rows, skipped, skipped_nonregular + + def test_exact_duplicates_grouped(self): + self._write("a.txt", b"same content") + self._write("sub/b.txt", b"same content") + self._write("c.txt", b"different") + rows, _, _ = self._build() + dup_rows = [r for r in rows if r["duplicate_group"]] + self.assertEqual(len(dup_rows), 2) + self.assertEqual({r["duplicate_group"] for r in dup_rows}, {"exact-0001"}) + for r in dup_rows: + self.assertEqual(r["proposed_action"], "deduplicate_review") + self.assertEqual(r["duplicate_count"], 2) + unique = [r for r in rows if not r["duplicate_group"]] + self.assertEqual(len(unique), 1) + self.assertEqual(unique[0]["proposed_action"], "review") + + def test_sensitive_filename_flagged(self): + self._write("薪资明细.xlsx", b"x") + rows, _, _ = self._build() + self.assertTrue(rows[0]["risk_hint"].startswith("possible_sensitive:")) + + def test_extension_filter_excludes_others(self): + self._write("keep.pdf", b"x") + self._write("drop.exe", b"x") + rows, _, _ = self._build(extensions="pdf") + self.assertEqual([r["title"] for r in rows], ["keep.pdf"]) + + def test_skip_dirs_ignored(self): + self._write("node_modules/pkg.json", b"{}") + self._write("real.json", b"{}") + rows, _, _ = self._build() + self.assertEqual([r["title"] for r in rows], ["real.json"]) + + def test_hidden_files_excluded_by_default(self): + self._write(".secret.txt", b"x") + self._write("visible.txt", b"x") + rows, _, _ = self._build() + self.assertEqual([r["title"] for r in rows], ["visible.txt"]) + + def test_office_lock_files_excluded(self): + self._write("~$draft.docx", b"x") + self._write("draft.docx", b"x") + rows, _, _ = self._build() + self.assertEqual([r["title"] for r in rows], ["draft.docx"]) + + @unittest.skipUnless(hasattr(os, "symlink"), "symlink not supported") + def test_symlink_file_skipped(self): + target = self._write("target.txt", b"x") + try: + os.symlink(target, self.root / "link.txt") + except (OSError, NotImplementedError): + self.skipTest("symlink creation not permitted") + rows, skipped, _ = self._build() + self.assertEqual([r["title"] for r in rows], ["target.txt"]) + self.assertIn("link.txt", skipped) + + def test_empty_dir_produces_no_rows(self): + rows, skipped, _ = self._build() + self.assertEqual(rows, []) + self.assertEqual(skipped, []) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "mkfifo not supported") + def test_fifo_skipped_not_hashed(self): + # A FIFO with an allowed suffix must be skipped, not opened (which would + # block hash_file indefinitely). + try: + os.mkfifo(self.root / "pipe.txt") + except (OSError, NotImplementedError, AttributeError): + self.skipTest("mkfifo not permitted") + self._write("real.txt", b"x") + rows, _, nonregular = self._build() + self.assertEqual([r["title"] for r in rows], ["real.txt"]) + self.assertIn("pipe.txt", nonregular) + + def test_nested_output_dir_excluded(self): + # The workflow ledger under the scanned root must not ingest itself. + self._write("doc.txt", b"x") + self._write("inventory/inventory.csv", b"source_id,\n") + self._write("inventory/inventory.json", b"{}") + exclude = (self.root / "inventory").resolve() + rows, _, _ = self._build(exclude_dir=exclude) + self.assertEqual([r["title"] for r in rows], ["doc.txt"]) + + +class InventoryOutputTest(unittest.TestCase): + def test_write_outputs_empty_dir_has_header(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "src" + root.mkdir() + out = Path(tmp) / "out" + inventory.write_outputs([], root, out, [], [], []) + csv_text = (out / "inventory.csv").read_text(encoding="utf-8-sig") + self.assertTrue(csv_text.startswith("source_id,")) + payload = json.loads((out / "inventory.json").read_text(encoding="utf-8")) + self.assertEqual(payload["summary"]["files"], 0) + self.assertFalse(payload["source_files_modified"]) + self.assertTrue(payload["scan_complete"]) + + def test_write_outputs_records_summary(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "src" + root.mkdir() + (root / "a.txt").write_bytes(b"dup") + (root / "b.txt").write_bytes(b"dup") + (root / "薪资明细.xlsx").write_bytes(b"x") + out = Path(tmp) / "out" + skipped: list[str] = [] + skipped_nonregular: list[str] = [] + walk_errors: list[str] = [] + rows = inventory.build_inventory( + root, set(inventory.DEFAULT_EXTENSIONS), False, skipped, + skipped_nonregular, walk_errors + ) + inventory.write_outputs(rows, root, out, skipped, skipped_nonregular, walk_errors) + payload = json.loads((out / "inventory.json").read_text(encoding="utf-8")) + self.assertEqual(payload["summary"]["files"], 3) + self.assertEqual(payload["summary"]["exact_duplicate_groups"], 1) + self.assertEqual(payload["summary"]["possible_sensitive_by_filename"], 1) + + def test_write_outputs_incomplete_when_walk_errors(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "src" + root.mkdir() + out = Path(tmp) / "out" + inventory.write_outputs([], root, out, [], [], ["locked_dir: Permission denied"]) + payload = json.loads((out / "inventory.json").read_text(encoding="utf-8")) + self.assertFalse(payload["scan_complete"]) + self.assertEqual(payload["summary"]["unreadable_dirs"], 1) + + +class InventoryMainTest(unittest.TestCase): + def _run_main(self, argv): + old = sys.argv + sys.argv = ["inventory.py"] + argv + try: + return inventory.main() + finally: + sys.argv = old + + def test_output_dir_equal_to_root_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "doc.txt").write_bytes(b"x") + code = self._run_main(["--root", tmp, "--output-dir", tmp]) + self.assertEqual(code, 2) + # No ledger should have been written into the scanned root. + self.assertFalse((Path(tmp) / "inventory.json").exists()) + + def test_output_dir_outside_root_ok(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "src" + root.mkdir() + (root / "doc.txt").write_bytes(b"x") + out = Path(tmp) / "out" + code = self._run_main(["--root", str(root), "--output-dir", str(out)]) + self.assertEqual(code, 0) + self.assertTrue((out / "inventory.json").exists()) + + def test_nested_output_dir_not_self_ingested(self): + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "doc.txt").write_bytes(b"x") + out = Path(tmp) / "inventory" + # First run writes the ledger nested under root. + self.assertEqual(self._run_main(["--root", tmp, "--output-dir", str(out)]), 0) + # Second run must not ingest the ledger it just wrote. + self.assertEqual(self._run_main(["--root", tmp, "--output-dir", str(out)]), 0) + payload = json.loads((out / "inventory.json").read_text(encoding="utf-8")) + titles = [item["title"] for item in payload["items"]] + self.assertEqual(titles, ["doc.txt"]) + + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, "needs POSIX perms, non-root") + def test_unreadable_subdir_reports_incomplete(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "src" + root.mkdir() + (root / "doc.txt").write_bytes(b"x") + locked = root / "locked" + locked.mkdir() + (locked / "hidden.txt").write_bytes(b"y") + os.chmod(locked, 0o000) + out = Path(tmp) / "out" + try: + code = self._run_main(["--root", str(root), "--output-dir", str(out)]) + finally: + os.chmod(locked, 0o755) # restore so tempdir cleanup succeeds + self.assertEqual(code, 1) + payload = json.loads((out / "inventory.json").read_text(encoding="utf-8")) + self.assertFalse(payload["scan_complete"]) + self.assertGreaterEqual(payload["summary"]["unreadable_dirs"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/lark-drive/references/scripts/kb_gate.py b/skills/lark-drive/references/scripts/kb_gate.py new file mode 100644 index 0000000000..15b82fa1fe --- /dev/null +++ b/skills/lark-drive/references/scripts/kb_gate.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Gate the knowledge_base_bootstrap write plan before any node is written. + +This script is the deterministic guard for the `knowledge_base_bootstrap` +workflow's WRITE_CONFIRM state. It reads the write plan (one entry per target +node) and decides, per node, whether the node may be written. + +Design principle (mirrors enterprise-kb-ops build_graph.py): an agent's own +readiness claim can only narrow the outcome, never bypass a gate. Hard gates +block the write outright; consistency issues do not block a "先框架后补" draft +but force the page status back to 进行中 so nothing incomplete is passed off +as 已完成. + +Input: a JSON object (via --plan or stdin) shaped as: + + { + "nodes": [ + { + "node_token": "wikcn_ROOT", + "title": "建材经贸大厦改造项目验收", + "obj_type": "docx", # wiki node object type + "write_mode": "overwrite", # overwrite | append | new_docx | skip + "draft_state": "empty_placeholder", # empty_placeholder | has_draft + "parent_node_token": "", # new_docx: confirmed parent node (alt to space_id) + "space_id": "", # new_docx: confirmed target space (alt to parent) + "overwrite_confirmed": false, # user explicitly confirmed rewriting a draft + "governance": { # the 6-row governance table fields + "source": "据业务常识制定", + "owner": "待确认", + "version_status": "v1.0|进行中", + "scope_visibility": "全员", + "effective_update": "生效:2026-09-01|更新:2026-09-01|原因:首次创建", + "review_policy": "类型:流程指引|周期:90|下次复核:2026-12-01", + "page_status": "进行中" + } + } + ] + } + +Output: a JSON object on stdout: + + { + "ok": true, + "summary": {"total": N, "writable": X, "blocked": Y, "narrowed": Z}, + "nodes": [ + {"node_token": "...", "title": "...", "ready": true/false, + "blocked_reasons": [...], "narrowed": true/false, + "effective_page_status": "进行中|已完成|已废弃"} + ] + } + +Exit code: 0 when the plan parsed and was evaluated (even if some nodes are +blocked); 2 on malformed input. A non-empty `blocked` count is a normal +result, not a script error. +""" + +from __future__ import annotations + +import argparse +import json +import sys + + +GOVERNANCE_FIELDS = { + "source": "来源", + "owner": "负责人", + "version_status": "版本与状态", + "scope_visibility": "适用与可见范围", + "effective_update": "生效与更新", + "review_policy": "复核策略", + "page_status": "页面状态", +} + +# Fields that must carry a real value; "待确认" here does not block a draft but +# does prevent the page from being marked 已完成 (handled as a narrowing). +REQUIRED_NON_EMPTY = ("source", "scope_visibility") + +VALID_PAGE_STATUSES = {"进行中", "已完成", "已废弃"} +UNRESOLVED_MARKERS = ("待确认", "待补充", "待指定", "未确认", "未解决", "tbd", "unknown") +VALID_WRITE_MODES = {"overwrite", "append", "new_docx", "skip"} +VALID_DRAFT_STATES = {"empty_placeholder", "has_draft"} + + +def parse_args() -> argparse.Namespace: + """Parse CLI arguments (--plan path or stdin).""" + parser = argparse.ArgumentParser( + description="Gate the knowledge_base_bootstrap write plan." + ) + parser.add_argument( + "--plan", + help="Path to the write-plan JSON; omit or use '-' to read from stdin", + ) + return parser.parse_args() + + +def load_plan(path: str | None) -> dict: + """Load and shape-check the write-plan JSON from a file or stdin.""" + if not path or path == "-": + raw = sys.stdin.read() + else: + with open(path, encoding="utf-8") as handle: + raw = handle.read() + data = json.loads(raw) + if not isinstance(data, dict) or not isinstance(data.get("nodes"), list): + raise ValueError("plan must be a JSON object with a 'nodes' array") + return data + + +def has_unresolved(value) -> bool: + """Return True if the value contains an unresolved marker (待确认, TBD, ...).""" + normalized = str(value or "").strip().lower() + if not normalized: + return False + return any(marker in normalized for marker in UNRESOLVED_MARKERS) + + +def is_empty(value) -> bool: + """Return True if the value is empty or whitespace-only.""" + return not str(value or "").strip() + + +def evaluate_node(node: dict) -> dict: + """Evaluate one write-plan node and return its gate verdict. + + Applies hard gates (block the write) and a consistency narrowing (allow the + write but force page status back to 进行中). A node's own claims can only + narrow the outcome; they can never bypass a hard gate. + """ + title = str(node.get("title") or node.get("node_token") or "<未命名节点>") + token = str(node.get("node_token") or "").strip() + write_mode = str(node.get("write_mode") or "") + # Normalize a non-dict / missing governance to {} so every downstream + # access is safe (a truthy non-dict such as a string would otherwise crash + # governance.get(...), including on the skip branch). + governance = node.get("governance") + if not isinstance(governance, dict): + governance = {} + + hard_reasons: list[str] = [] + narrow_reasons: list[str] = [] + + # skip mode is not a write; report it as not-writable without blocking noise. + if write_mode == "skip": + return { + "node_token": token, + "title": title, + "ready": False, + "blocked_reasons": ["计划为 skip,不写入"], + "narrowed": False, + "narrow_reasons": [], + "effective_page_status": str(governance.get("page_status") or ""), + } + + if write_mode not in VALID_WRITE_MODES: + hard_reasons.append(f"未知写法:{write_mode or '(空)'}") + + # --- Hard gate 0: a real write needs a stable node target --- + if not token and write_mode != "new_docx": + hard_reasons.append("缺少 node_token,无法定位写入目标") + # new_docx must carry a confirmed destination; otherwise a user-mode create + # silently falls back to my_library instead of the target space. + if write_mode == "new_docx": + if is_empty(node.get("parent_node_token")) and is_empty(node.get("space_id")): + hard_reasons.append("new_docx 缺少确认的建节点位置(parent_node_token 或 space_id)") + + # --- Hard gate 1: carrier must be a document node --- + obj_type = str(node.get("obj_type") or "").strip().lower() + if write_mode == "new_docx": + # new_docx creates a fresh docx page; it must not target a node that is + # already a docx (that should be overwrite/append instead). + if obj_type == "docx": + hard_reasons.append("new_docx 不能用于已是 docx 的节点,应改用 overwrite/append") + elif not obj_type: + hard_reasons.append("未提供节点 obj_type,无法确认载体") + elif obj_type != "docx": + hard_reasons.append(f"载体不是文档节点:obj_type={node.get('obj_type')}") + + # --- Hard gate 2: overwrite must know the draft state, and overwriting a + # real draft needs explicit confirmation. Fails closed: a missing or unknown + # draft_state blocks overwrite rather than risking a silent draft wipe. --- + if write_mode == "overwrite": + draft_state = str(node.get("draft_state") or "").strip() + if draft_state not in VALID_DRAFT_STATES: + hard_reasons.append( + f"覆盖写入的草稿状态未知(draft_state={node.get('draft_state')}),拒绝覆盖" + ) + elif draft_state == "has_draft" and node.get("overwrite_confirmed") is not True: + hard_reasons.append("覆盖有草稿的节点但缺少用户显式确认") + + # --- Governance completeness --- + if not governance: + hard_reasons.append("缺少 6 行治理表") + else: + for field in REQUIRED_NON_EMPTY: + if is_empty(governance.get(field)): + hard_reasons.append(f"治理字段缺失:{GOVERNANCE_FIELDS[field]}") + + page_status = str(governance.get("page_status") or "").strip() + if not page_status: + hard_reasons.append(f"治理字段缺失:{GOVERNANCE_FIELDS['page_status']}") + elif page_status not in VALID_PAGE_STATUSES: + hard_reasons.append(f"页面状态非法:{page_status}") + + # Consistency narrowing: a field that is unresolved (待确认/TBD) OR + # entirely missing/empty cannot be sold as 已完成. A missing key is + # treated the same as 待确认 so an omitted row cannot pass as complete. + # (source/scope_visibility are already hard-blocked when empty above.) + unresolved_fields = [ + GOVERNANCE_FIELDS[key] + for key in GOVERNANCE_FIELDS + if key != "page_status" + and (has_unresolved(governance.get(key)) or is_empty(governance.get(key))) + ] + if unresolved_fields and page_status == "已完成": + narrow_reasons.append( + "存在待确认或缺失字段(" + "、".join(unresolved_fields) + "),状态收紧为进行中" + ) + + ready = not hard_reasons + effective_status = str(governance.get("page_status") or "") + narrowed = bool(narrow_reasons) + if narrowed: + effective_status = "进行中" + + return { + "node_token": token, + "title": title, + "ready": ready, + "blocked_reasons": hard_reasons, + "narrowed": narrowed, + "narrow_reasons": narrow_reasons, + "effective_page_status": effective_status, + } + + +def main() -> int: + """Load the plan, evaluate every node, and print the gate result JSON.""" + args = parse_args() + try: + plan = load_plan(args.plan) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + return 2 + + results = [evaluate_node(node if isinstance(node, dict) else {}) for node in plan["nodes"]] + writable = sum(1 for item in results if item["ready"]) + blocked = sum(1 for item in results if not item["ready"]) + narrowed = sum(1 for item in results if item["narrowed"]) + + print(json.dumps({ + "ok": True, + "summary": { + "total": len(results), + "writable": writable, + "blocked": blocked, + "narrowed": narrowed, + }, + "nodes": results, + }, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/lark-drive/references/scripts/kb_gate_test.py b/skills/lark-drive/references/scripts/kb_gate_test.py new file mode 100644 index 0000000000..aa6b44495b --- /dev/null +++ b/skills/lark-drive/references/scripts/kb_gate_test.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Tests for kb_gate.py. Run: python3 kb_gate_test.py""" + +from __future__ import annotations + +import unittest + +import kb_gate + + +def _governance(**overrides) -> dict: + base = { + "source": "据业务常识制定", + "owner": "张三|业务团队", + "version_status": "v1.0|已完成", + "scope_visibility": "全员", + "effective_update": "生效:2026-09-01|更新:2026-09-01|原因:首次创建", + "review_policy": "类型:流程指引|周期:90|下次复核:2026-12-01", + "page_status": "已完成", + } + base.update(overrides) + return base + + +def _node(**overrides) -> dict: + base = { + "node_token": "wikcn_A", + "title": "消防验收", + "obj_type": "docx", + "write_mode": "overwrite", + "draft_state": "empty_placeholder", + "overwrite_confirmed": False, + "governance": _governance(), + } + base.update(overrides) + return base + + +class KbGateTest(unittest.TestCase): + def test_complete_docx_is_writable(self): + result = kb_gate.evaluate_node(_node()) + self.assertTrue(result["ready"]) + self.assertEqual(result["blocked_reasons"], []) + self.assertFalse(result["narrowed"]) + + def test_missing_obj_type_blocks(self): + result = kb_gate.evaluate_node(_node(obj_type="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("载体" in r for r in result["blocked_reasons"])) + + def test_non_docx_carrier_blocks(self): + result = kb_gate.evaluate_node(_node(obj_type="sheet")) + self.assertFalse(result["ready"]) + self.assertTrue(any("不是文档节点" in r for r in result["blocked_reasons"])) + + def test_new_docx_allows_non_docx_source(self): + # new_docx creates a fresh docx page beside a non-docx node; not blocked + # when a confirmed destination is given. + result = kb_gate.evaluate_node( + _node(obj_type="sheet", write_mode="new_docx", draft_state="empty_placeholder", + parent_node_token="wikcn_PARENT") + ) + self.assertTrue(result["ready"]) + + def test_overwrite_draft_without_confirm_blocks(self): + result = kb_gate.evaluate_node( + _node(write_mode="overwrite", draft_state="has_draft", overwrite_confirmed=False) + ) + self.assertFalse(result["ready"]) + self.assertTrue(any("覆盖有草稿" in r for r in result["blocked_reasons"])) + + def test_overwrite_unknown_draft_state_fails_closed(self): + # Missing / unknown draft_state must block overwrite, not silently wipe. + result = kb_gate.evaluate_node(_node(write_mode="overwrite", draft_state="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("草稿状态未知" in r for r in result["blocked_reasons"])) + + def test_overwrite_bogus_draft_state_fails_closed(self): + result = kb_gate.evaluate_node(_node(write_mode="overwrite", draft_state="maybe")) + self.assertFalse(result["ready"]) + self.assertTrue(any("草稿状态未知" in r for r in result["blocked_reasons"])) + + def test_overwrite_draft_with_confirm_ok(self): + result = kb_gate.evaluate_node( + _node(write_mode="overwrite", draft_state="has_draft", overwrite_confirmed=True) + ) + self.assertTrue(result["ready"]) + + def test_append_draft_is_writable(self): + result = kb_gate.evaluate_node(_node(write_mode="append", draft_state="has_draft")) + self.assertTrue(result["ready"]) + + def test_missing_governance_blocks(self): + result = kb_gate.evaluate_node(_node(governance={})) + self.assertFalse(result["ready"]) + self.assertTrue(any("6 行治理表" in r for r in result["blocked_reasons"])) + + def test_empty_required_field_blocks(self): + result = kb_gate.evaluate_node(_node(governance=_governance(source=""))) + self.assertFalse(result["ready"]) + self.assertTrue(any("来源" in r for r in result["blocked_reasons"])) + + def test_invalid_page_status_blocks(self): + result = kb_gate.evaluate_node(_node(governance=_governance(page_status="草稿"))) + self.assertFalse(result["ready"]) + self.assertTrue(any("页面状态非法" in r for r in result["blocked_reasons"])) + + def test_missing_page_status_blocks(self): + # An empty page_status must hard-block, not fall through as writable. + result = kb_gate.evaluate_node(_node(governance=_governance(page_status=""))) + self.assertFalse(result["ready"]) + self.assertTrue(any("页面状态" in r for r in result["blocked_reasons"])) + + def test_unresolved_owner_with_done_status_narrows(self): + # owner 待确认 but marked 已完成 -> writable draft, status narrowed to 进行中. + result = kb_gate.evaluate_node( + _node(governance=_governance(owner="待确认", page_status="已完成")) + ) + self.assertTrue(result["ready"]) + self.assertTrue(result["narrowed"]) + self.assertEqual(result["effective_page_status"], "进行中") + + def test_missing_field_with_done_status_narrows(self): + # A governance object omitting non-required rows entirely must not pass as + # 已完成: an absent key is treated like 待确认 and narrows to 进行中. + gov = {"source": "x", "scope_visibility": "全员", "page_status": "已完成"} + result = kb_gate.evaluate_node(_node(governance=gov)) + self.assertTrue(result["ready"]) + self.assertTrue(result["narrowed"]) + self.assertEqual(result["effective_page_status"], "进行中") + + def test_unresolved_owner_with_inprogress_not_narrowed(self): + # owner 待确认 and already 进行中 -> writable, no narrowing needed. + result = kb_gate.evaluate_node( + _node(governance=_governance(owner="待确认", page_status="进行中")) + ) + self.assertTrue(result["ready"]) + self.assertFalse(result["narrowed"]) + self.assertEqual(result["effective_page_status"], "进行中") + + def test_skip_mode_is_not_writable_without_hard_block(self): + result = kb_gate.evaluate_node(_node(write_mode="skip")) + self.assertFalse(result["ready"]) + self.assertEqual(result["blocked_reasons"], ["计划为 skip,不写入"]) + + def test_unknown_write_mode_blocks(self): + result = kb_gate.evaluate_node(_node(write_mode="frobnicate")) + self.assertFalse(result["ready"]) + self.assertTrue(any("未知写法" in r for r in result["blocked_reasons"])) + + def test_skip_with_nondict_governance_does_not_crash(self): + # Regression: a truthy non-dict governance on a skip entry must not raise. + result = kb_gate.evaluate_node(_node(write_mode="skip", governance="待确认")) + self.assertFalse(result["ready"]) + self.assertEqual(result["blocked_reasons"], ["计划为 skip,不写入"]) + self.assertEqual(result["effective_page_status"], "") + + def test_nondict_governance_normalized_blocks(self): + # A non-dict governance on a real write is treated as missing table. + result = kb_gate.evaluate_node(_node(governance="oops")) + self.assertFalse(result["ready"]) + self.assertTrue(any("6 行治理表" in r for r in result["blocked_reasons"])) + + def test_empty_node_token_blocks(self): + result = kb_gate.evaluate_node(_node(node_token="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("node_token" in r for r in result["blocked_reasons"])) + + def test_new_docx_without_token_ok(self): + # new_docx creates a fresh node, so it does not require an existing token, + # but it does require a confirmed destination (parent or space). + result = kb_gate.evaluate_node( + _node(node_token="", obj_type="", write_mode="new_docx", parent_node_token="wikcn_PARENT") + ) + self.assertTrue(result["ready"]) + + def test_new_docx_with_space_ok(self): + result = kb_gate.evaluate_node( + _node(node_token="", obj_type="", write_mode="new_docx", space_id="spc_X") + ) + self.assertTrue(result["ready"]) + + def test_new_docx_without_destination_blocks(self): + # No parent and no space: a user-mode create would fall back to my_library. + result = kb_gate.evaluate_node( + _node(node_token="", obj_type="", write_mode="new_docx") + ) + self.assertFalse(result["ready"]) + self.assertTrue(any("建节点位置" in r for r in result["blocked_reasons"])) + + def test_new_docx_on_existing_docx_blocks(self): + # new_docx must not target a node that is already docx. + result = kb_gate.evaluate_node( + _node(obj_type="docx", write_mode="new_docx", parent_node_token="wikcn_PARENT") + ) + self.assertFalse(result["ready"]) + self.assertTrue(any("new_docx" in r for r in result["blocked_reasons"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/lark-drive/references/scripts/publish_gate.py b/skills/lark-drive/references/scripts/publish_gate.py new file mode 100644 index 0000000000..a9215d9f77 --- /dev/null +++ b/skills/lark-drive/references/scripts/publish_gate.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Gate the knowledge_ingest publish plan before any material is written. + +This script is the deterministic guard for the `knowledge_ingest` workflow's +PUBLISH_PLAN state. It reads the publish plan (one entry per material -> target +node mapping) and decides, per item, whether that material may be converted and +written into the knowledge base. + +Design principle (mirrors kb_gate.py and enterprise-kb-ops build_graph.py): an +agent's own readiness claim can only narrow the outcome, never bypass a gate. +Hard gates block the write outright; consistency issues do not block a +"先框架后补" draft but force the page status back to 进行中 so nothing +incomplete or partially-parsed is passed off as 已完成. + +The core iron rules this gate enforces: + - a `knowledge_page` must land on an `obj_type=docx` node with a searchable + body; a non-docx carrier is a hard block; + - `drive +upload` (write_via=drive_upload) only produces a `file` node, so it + can never complete a knowledge_page -- it is allowed ONLY for a + `source_attachment`, and even then only when the user has explicitly opted + in (attachment_confirmed), and it never advances page status; + - sensitive (prohibited, or restricted without an approved review) and + unresolved-conflict materials are blocked from production. + +Input: a JSON object (via --plan or stdin) shaped as: + + { + "items": [ + { + "source_id": "", + "title": "退货政策说明", + "publish_role": "knowledge_page", # knowledge_page | source_attachment + "write_via": "docs_update", # docs_update | import_docx | node_create_docx | drive_upload + "proposed_action": "add", # add | update | merge | reference | review | skip (fail-closed) + "target_obj_type": "docx", # target wiki node object type + "target_token": "wikcn_NODE", # node/obj token (blank ok for node_create_docx) + "parent_token": "", # node_create_docx: confirmed parent node token + "space_id": "", # node_create_docx: confirmed target space (alt to parent) + "sensitivity": "internal", # public | internal | restricted | prohibited (fail-closed) + "sensitive_review_status": "", # for restricted: approved | pending | ... + "conflict_status": "none", # none | suspected | confirmed | resolved (fail-closed) + "parse_status": "parsed", # parsed | partial | unsupported | failed (fail-closed) + "attachment_confirmed": false, # user opted in to uploading the original file + "governance": { # 6-row governance table (knowledge_page only) + "source": "退货政策原始 Word|2026-08 版", + "owner": "李四|客服团队", + "version_status": "v1.0|已完成", + "scope_visibility": "全员", + "effective_update": "生效:2026-09-01|更新:2026-09-01|原因:首次入库", + "review_policy": "类型:政策|周期:180|下次复核:2027-03-01", + "page_status": "已完成" + } + } + ] + } + +Output: a JSON object on stdout: + + { + "ok": true, + "summary": {"total": N, "writable": X, "blocked": Y, "narrowed": Z, "attachments": A}, + "items": [ + {"source_id": "...", "title": "...", "publish_role": "...", + "ready": true/false, "blocked_reasons": [...], "narrowed": true/false, + "narrow_reasons": [...], "counts_as_page": true/false, + "effective_page_status": "进行中|已完成|已废弃|"} + ] + } + +Exit code: 0 when the plan parsed and was evaluated (even if some items are +blocked); 2 on malformed input. A non-empty `blocked` count is a normal result, +not a script error. +""" + +from __future__ import annotations + +import argparse +import json +import sys + + +GOVERNANCE_FIELDS = { + "source": "来源", + "owner": "负责人", + "version_status": "版本与状态", + "scope_visibility": "适用与可见范围", + "effective_update": "生效与更新", + "review_policy": "复核策略", + "page_status": "页面状态", +} + +# Fields that must carry a real value on a knowledge_page; "待确认" here does not +# block a draft but does prevent the page from being marked 已完成 (a narrowing). +REQUIRED_NON_EMPTY = ("source", "scope_visibility") + +VALID_PAGE_STATUSES = {"进行中", "已完成", "已废弃"} +UNRESOLVED_MARKERS = ("待确认", "待补充", "待指定", "未确认", "未解决", "tbd", "unknown") + +# write_via paths that produce a searchable docx knowledge page. +PAGE_WRITE_VIA = {"docs_update", "import_docx", "node_create_docx"} +# write_via that only uploads the original file as an attachment node. +ATTACHMENT_WRITE_VIA = "drive_upload" +VALID_WRITE_VIA = PAGE_WRITE_VIA | {ATTACHMENT_WRITE_VIA} + +# Closed triage enums. Values outside these sets are rejected (fail closed) +# rather than treated as safe, so a typo or an unclassified item cannot bypass +# the sensitivity / conflict / parse gates. +VALID_SENSITIVITY = {"public", "internal", "restricted", "prohibited"} +VALID_CONFLICT = {"none", "suspected", "confirmed", "resolved"} +VALID_PARSE = {"parsed", "partial", "unsupported", "failed"} +# Closed proposed_action enum. Actions split into those that publish a page and +# those that do not; a non-publishing action must never reach a write. +VALID_ACTIONS = {"add", "update", "merge", "reference", "review", "skip"} +# Actions that update an existing page: they must use docs_update (import_docx or +# node_create_docx would create a duplicate child instead of updating). +UPDATE_ACTIONS = {"update", "merge"} +# Non-publishing actions: they carry no write and must not be marked ready. +NONPUBLISH_ACTIONS = {"reference", "review", "skip"} +# Conflict states that block production until a human resolves them. +BLOCKING_CONFLICTS = {"suspected", "confirmed"} +# Parse states that cannot yield a searchable body at all. +UNUSABLE_PARSE = {"unsupported", "failed"} + + +def parse_args() -> argparse.Namespace: + """Parse CLI arguments (--plan path or stdin).""" + parser = argparse.ArgumentParser( + description="Gate the knowledge_ingest publish plan." + ) + parser.add_argument( + "--plan", + help="Path to the publish-plan JSON; omit or use '-' to read from stdin", + ) + return parser.parse_args() + + +def load_plan(path: str | None) -> dict: + """Load and shape-check the publish-plan JSON from a file or stdin.""" + if not path or path == "-": + raw = sys.stdin.read() + else: + with open(path, encoding="utf-8") as handle: + raw = handle.read() + data = json.loads(raw) + if not isinstance(data, dict) or not isinstance(data.get("items"), list): + raise ValueError("plan must be a JSON object with an 'items' array") + return data + + +def has_unresolved(value) -> bool: + """Return True if the value contains an unresolved marker (待确认, TBD, ...).""" + normalized = str(value or "").strip().lower() + if not normalized: + return False + return any(marker in normalized for marker in UNRESOLVED_MARKERS) + + +def is_empty(value) -> bool: + """Return True if the value is empty or whitespace-only.""" + return not str(value or "").strip() + + +def _check_sensitivity_conflict(item: dict, hard_reasons: list[str]) -> None: + """Shared gates: sensitive and unresolved-conflict material never ships. + + Both enums fail closed: a missing or unrecognized value is blocked rather + than treated as safe, so an unclassified or misspelled triage state cannot + slip past the gate. + """ + sensitivity = str(item.get("sensitivity") or "").strip().lower() + if sensitivity not in VALID_SENSITIVITY: + hard_reasons.append(f"敏感等级未分类或非法(sensitivity={item.get('sensitivity')})") + elif sensitivity == "prohibited": + hard_reasons.append("敏感等级 prohibited,禁止入库") + elif sensitivity == "restricted": + review = str(item.get("sensitive_review_status") or "").strip().lower() + if review != "approved": + hard_reasons.append("受限敏感内容未通过审核(sensitive_review_status 非 approved)") + + conflict = str(item.get("conflict_status") or "").strip().lower() + if conflict not in VALID_CONFLICT: + hard_reasons.append(f"冲突状态未分类或非法(conflict_status={item.get('conflict_status')})") + elif conflict in BLOCKING_CONFLICTS: + hard_reasons.append(f"存在未裁决冲突(conflict_status={conflict})") + + +def _evaluate_attachment(item: dict, title: str, source_id: str) -> dict: + """Evaluate a source_attachment item. + + An attachment is the original file uploaded via drive +upload. It never + carries a governance table and never counts as a completed knowledge page; + it requires an explicit user opt-in (attachment_confirmed). + """ + hard_reasons: list[str] = [] + write_via = str(item.get("write_via") or "") + + if write_via != ATTACHMENT_WRITE_VIA: + hard_reasons.append( + f"source_attachment 只能用 drive_upload,当前 write_via={write_via or '(空)'}" + ) + if item.get("attachment_confirmed") is not True: + hard_reasons.append("上传原文件需用户显式确认(attachment_confirmed)") + # The upload must be anchored to a confirmed Wiki node; without it the file + # lands in the Drive root instead of the knowledge base the user approved. + if is_empty(item.get("target_token")): + hard_reasons.append("附件缺少目标 Wiki 节点(target_token),拒绝上传到未确认位置") + + _check_sensitivity_conflict(item, hard_reasons) + + return { + "source_id": source_id, + "title": title, + "publish_role": "source_attachment", + "ready": not hard_reasons, + "blocked_reasons": hard_reasons, + "narrowed": False, + "narrow_reasons": [], + "counts_as_page": False, + "effective_page_status": "", + } + + +def _evaluate_knowledge_page(item: dict, title: str, source_id: str) -> dict: + """Evaluate a knowledge_page item against the iron rules and governance. + + A knowledge_page must land on a docx node with a searchable body. Its own + claims can only narrow the outcome; they can never bypass a hard gate. + """ + hard_reasons: list[str] = [] + narrow_reasons: list[str] = [] + + write_via = str(item.get("write_via") or "") + token = str(item.get("target_token") or "").strip() + obj_type = str(item.get("target_obj_type") or "").strip().lower() + action = str(item.get("proposed_action") or "").strip().lower() + + # --- Hard gate: write_via must be a page-producing path --- + if write_via not in VALID_WRITE_VIA: + hard_reasons.append(f"未知写法:{write_via or '(空)'}") + elif write_via == ATTACHMENT_WRITE_VIA: + # The single most important rule: uploading the original file cannot + # complete a knowledge page. + hard_reasons.append("drive_upload 只能上传原文件,不能完成知识页") + + # --- Hard gate: proposed_action must be known, must be a publishing action, + # and must match the write_via path. update/merge require docs_update (any + # other path would create a duplicate child instead of updating the target); + # add requires a page-creating path (import_docx / docs_update / + # node_create_docx). A non-publishing action must never reach a write. --- + if action not in VALID_ACTIONS: + hard_reasons.append(f"处置动作未分类或非法(proposed_action={item.get('proposed_action')})") + elif action in NONPUBLISH_ACTIONS: + hard_reasons.append(f"非发布动作({action})不应作为知识页写入") + elif action in UPDATE_ACTIONS and write_via != "docs_update": + hard_reasons.append(f"update/merge 只能用 docs_update 更新既有页,当前 write_via={write_via or '(空)'}(避免新增重复页)") + + # --- Hard gate: a real write needs a stable target --- + if not token and write_via != "node_create_docx": + hard_reasons.append("缺少 target_token,无法定位写入目标") + # A new node must carry a confirmed destination; otherwise a user-mode + # create silently falls back to my_library instead of the target space. + if write_via == "node_create_docx": + if is_empty(item.get("parent_token")) and is_empty(item.get("space_id")): + hard_reasons.append("new_docx 缺少确认的建节点位置(parent_token 或 space_id)") + + # --- Hard gate: carrier must be a docx node (the core iron rule) --- + if not obj_type: + hard_reasons.append("未提供 target_obj_type,无法确认载体") + elif obj_type != "docx": + hard_reasons.append(f"知识页载体不是 docx 节点:target_obj_type={item.get('target_obj_type')}") + + # --- Hard gate: material must parse into a searchable body --- + # parse_status fails closed: an unclassified or misspelled value is blocked + # rather than assumed parseable. + parse_status = str(item.get("parse_status") or "").strip().lower() + if parse_status not in VALID_PARSE: + hard_reasons.append(f"解析状态未分类或非法(parse_status={item.get('parse_status')})") + elif parse_status in UNUSABLE_PARSE: + hard_reasons.append(f"资料无法解析为可检索正文(parse_status={parse_status})") + + # --- Shared gates: sensitivity + conflict --- + _check_sensitivity_conflict(item, hard_reasons) + + # --- Governance completeness --- + governance = item.get("governance") + if not isinstance(governance, dict): + governance = {} + if not governance: + hard_reasons.append("缺少 6 行治理表") + page_status = "" + else: + for field in REQUIRED_NON_EMPTY: + if is_empty(governance.get(field)): + hard_reasons.append(f"治理字段缺失:{GOVERNANCE_FIELDS[field]}") + + page_status = str(governance.get("page_status") or "").strip() + if not page_status: + hard_reasons.append(f"治理字段缺失:{GOVERNANCE_FIELDS['page_status']}") + elif page_status not in VALID_PAGE_STATUSES: + hard_reasons.append(f"页面状态非法:{page_status}") + + # Consistency narrowing: a field that is unresolved (待确认/TBD) OR + # entirely missing/empty cannot be sold as 已完成. A missing key is + # treated the same as 待确认 so an omitted row cannot pass as complete. + # (source/scope_visibility are already hard-blocked when empty above.) + unresolved_fields = [ + GOVERNANCE_FIELDS[key] + for key in GOVERNANCE_FIELDS + if key != "page_status" + and (has_unresolved(governance.get(key)) or is_empty(governance.get(key))) + ] + if unresolved_fields and page_status == "已完成": + narrow_reasons.append( + "存在待确认或缺失字段(" + "、".join(unresolved_fields) + "),状态收紧为进行中" + ) + # Consistency narrowing: a partial parse must not be sold as 已完成. + if parse_status == "partial" and page_status == "已完成": + narrow_reasons.append("资料仅部分解析(parse_status=partial),状态收紧为进行中") + + ready = not hard_reasons + effective_status = page_status + narrowed = bool(narrow_reasons) + if narrowed: + effective_status = "进行中" + + return { + "source_id": source_id, + "title": title, + "publish_role": "knowledge_page", + "ready": ready, + "blocked_reasons": hard_reasons, + "narrowed": narrowed, + "narrow_reasons": narrow_reasons, + "counts_as_page": ready, + "effective_page_status": effective_status, + } + + +def evaluate_item(item: dict) -> dict: + """Evaluate one publish-plan item and return its gate verdict.""" + title = str(item.get("title") or item.get("source_id") or "<未命名资料>") + source_id = str(item.get("source_id") or "").strip() + role = str(item.get("publish_role") or "").strip() + + if role == "source_attachment": + return _evaluate_attachment(item, title, source_id) + if role == "knowledge_page": + return _evaluate_knowledge_page(item, title, source_id) + + return { + "source_id": source_id, + "title": title, + "publish_role": role, + "ready": False, + "blocked_reasons": [f"未知 publish_role:{role or '(空)'}"], + "narrowed": False, + "narrow_reasons": [], + "counts_as_page": False, + "effective_page_status": "", + } + + +def main() -> int: + """Load the plan, evaluate every item, and print the gate result JSON.""" + args = parse_args() + try: + plan = load_plan(args.plan) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)) + return 2 + + results = [evaluate_item(item if isinstance(item, dict) else {}) for item in plan["items"]] + writable = sum(1 for item in results if item["ready"]) + blocked = sum(1 for item in results if not item["ready"]) + narrowed = sum(1 for item in results if item["narrowed"]) + attachments = sum(1 for item in results if item["publish_role"] == "source_attachment") + + print(json.dumps({ + "ok": True, + "summary": { + "total": len(results), + "writable": writable, + "blocked": blocked, + "narrowed": narrowed, + "attachments": attachments, + }, + "items": results, + }, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/lark-drive/references/scripts/publish_gate_test.py b/skills/lark-drive/references/scripts/publish_gate_test.py new file mode 100644 index 0000000000..c7a173c2ff --- /dev/null +++ b/skills/lark-drive/references/scripts/publish_gate_test.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Tests for publish_gate.py. Run: python3 publish_gate_test.py""" + +from __future__ import annotations + +import unittest + +import publish_gate + + +def _governance(**overrides) -> dict: + base = { + "source": "退货政策原始 Word|2026-08 版", + "owner": "李四|客服团队", + "version_status": "v1.0|已完成", + "scope_visibility": "全员", + "effective_update": "生效:2026-09-01|更新:2026-09-01|原因:首次入库", + "review_policy": "类型:政策|周期:180|下次复核:2027-03-01", + "page_status": "已完成", + } + base.update(overrides) + return base + + +def _page(**overrides) -> dict: + base = { + "source_id": "sha_abc", + "title": "退货政策说明", + "publish_role": "knowledge_page", + "write_via": "docs_update", + "proposed_action": "add", + "target_obj_type": "docx", + "target_token": "wikcn_NODE", + "sensitivity": "internal", + "sensitive_review_status": "", + "conflict_status": "none", + "parse_status": "parsed", + "attachment_confirmed": False, + "governance": _governance(), + } + base.update(overrides) + return base + + +def _attachment(**overrides) -> dict: + base = { + "source_id": "sha_file", + "title": "退货政策原件.pdf", + "publish_role": "source_attachment", + "write_via": "drive_upload", + "target_token": "wikcn_NODE", + "sensitivity": "internal", + "conflict_status": "none", + "attachment_confirmed": True, + } + base.update(overrides) + return base + + +class KnowledgePageGateTest(unittest.TestCase): + def test_complete_page_is_writable(self): + result = publish_gate.evaluate_item(_page()) + self.assertTrue(result["ready"]) + self.assertEqual(result["blocked_reasons"], []) + self.assertTrue(result["counts_as_page"]) + + def test_non_docx_carrier_blocks(self): + result = publish_gate.evaluate_item(_page(target_obj_type="file")) + self.assertFalse(result["ready"]) + self.assertTrue(any("载体不是 docx" in r for r in result["blocked_reasons"])) + + def test_missing_obj_type_blocks(self): + result = publish_gate.evaluate_item(_page(target_obj_type="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("target_obj_type" in r for r in result["blocked_reasons"])) + + def test_drive_upload_cannot_complete_page(self): + # The single most important iron rule. + result = publish_gate.evaluate_item(_page(write_via="drive_upload")) + self.assertFalse(result["ready"]) + self.assertTrue(any("drive_upload 只能上传原文件" in r for r in result["blocked_reasons"])) + self.assertFalse(result["counts_as_page"]) + + def test_unknown_write_via_blocks(self): + result = publish_gate.evaluate_item(_page(write_via="magic")) + self.assertFalse(result["ready"]) + self.assertTrue(any("未知写法" in r for r in result["blocked_reasons"])) + + def test_update_via_non_docs_update_blocks(self): + # update/merge must use docs_update; import_docx or node_create_docx would + # create a duplicate child page instead of updating the target. + for action in ("update", "merge"): + for via in ("import_docx", "node_create_docx"): + result = publish_gate.evaluate_item( + _page(proposed_action=action, write_via=via, parent_token="wikcn_P") + ) + self.assertFalse(result["ready"], f"{action}/{via}") + self.assertTrue( + any("update/merge 只能用 docs_update" in r for r in result["blocked_reasons"]), + f"{action}/{via}", + ) + + def test_update_via_docs_update_ok(self): + result = publish_gate.evaluate_item(_page(proposed_action="update", write_via="docs_update")) + self.assertTrue(result["ready"]) + + def test_add_via_import_docx_ok(self): + result = publish_gate.evaluate_item(_page(proposed_action="add", write_via="import_docx")) + self.assertTrue(result["ready"]) + + def test_nonpublish_action_blocks(self): + # skip/reference/review carry no write and must never be marked ready, + # even with an otherwise-valid docs_update plan. + for action in ("skip", "reference", "review"): + result = publish_gate.evaluate_item(_page(proposed_action=action, write_via="docs_update")) + self.assertFalse(result["ready"], action) + self.assertTrue(any("非发布动作" in r for r in result["blocked_reasons"]), action) + + def test_unknown_action_fails_closed(self): + result = publish_gate.evaluate_item(_page(proposed_action="publish")) + self.assertFalse(result["ready"]) + self.assertTrue(any("处置动作未分类或非法" in r for r in result["blocked_reasons"])) + + def test_missing_action_fails_closed(self): + result = publish_gate.evaluate_item(_page(proposed_action="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("处置动作未分类或非法" in r for r in result["blocked_reasons"])) + + def test_missing_token_blocks(self): + result = publish_gate.evaluate_item(_page(target_token="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("target_token" in r for r in result["blocked_reasons"])) + + def test_node_create_docx_with_parent_ok(self): + # node_create_docx makes a fresh node, so it needs no existing token, + # but it does need a confirmed destination (parent or space). + result = publish_gate.evaluate_item( + _page(write_via="node_create_docx", target_token="", parent_token="wikcn_PARENT") + ) + self.assertTrue(result["ready"]) + + def test_node_create_docx_with_space_ok(self): + result = publish_gate.evaluate_item( + _page(write_via="node_create_docx", target_token="", space_id="spc_X") + ) + self.assertTrue(result["ready"]) + + def test_node_create_docx_without_destination_blocks(self): + # No parent and no space: a user-mode create would fall back to my_library. + result = publish_gate.evaluate_item( + _page(write_via="node_create_docx", target_token="") + ) + self.assertFalse(result["ready"]) + self.assertTrue(any("建节点位置" in r for r in result["blocked_reasons"])) + + def test_import_docx_is_writable(self): + result = publish_gate.evaluate_item(_page(write_via="import_docx")) + self.assertTrue(result["ready"]) + + def test_prohibited_sensitivity_blocks(self): + result = publish_gate.evaluate_item(_page(sensitivity="prohibited")) + self.assertFalse(result["ready"]) + self.assertTrue(any("prohibited" in r for r in result["blocked_reasons"])) + + def test_restricted_without_approval_blocks(self): + result = publish_gate.evaluate_item( + _page(sensitivity="restricted", sensitive_review_status="pending") + ) + self.assertFalse(result["ready"]) + self.assertTrue(any("受限敏感" in r for r in result["blocked_reasons"])) + + def test_restricted_with_approval_ok(self): + result = publish_gate.evaluate_item( + _page(sensitivity="restricted", sensitive_review_status="approved") + ) + self.assertTrue(result["ready"]) + + def test_unresolved_conflict_blocks(self): + for state in ("suspected", "confirmed"): + result = publish_gate.evaluate_item(_page(conflict_status=state)) + self.assertFalse(result["ready"], state) + self.assertTrue(any("未裁决冲突" in r for r in result["blocked_reasons"]), state) + + def test_resolved_conflict_ok(self): + result = publish_gate.evaluate_item(_page(conflict_status="resolved")) + self.assertTrue(result["ready"]) + + def test_unsupported_parse_blocks(self): + result = publish_gate.evaluate_item(_page(parse_status="unsupported")) + self.assertFalse(result["ready"]) + self.assertTrue(any("无法解析" in r for r in result["blocked_reasons"])) + + def test_unknown_sensitivity_fails_closed(self): + # A misspelled / unclassified sensitivity must block, not pass as safe. + result = publish_gate.evaluate_item(_page(sensitivity="機密")) + self.assertFalse(result["ready"]) + self.assertTrue(any("敏感等级未分类或非法" in r for r in result["blocked_reasons"])) + + def test_missing_sensitivity_fails_closed(self): + result = publish_gate.evaluate_item(_page(sensitivity="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("敏感等级未分类或非法" in r for r in result["blocked_reasons"])) + + def test_unknown_conflict_fails_closed(self): + result = publish_gate.evaluate_item(_page(conflict_status="conflict")) + self.assertFalse(result["ready"]) + self.assertTrue(any("冲突状态未分类或非法" in r for r in result["blocked_reasons"])) + + def test_unknown_parse_fails_closed(self): + result = publish_gate.evaluate_item(_page(parse_status="ok")) + self.assertFalse(result["ready"]) + self.assertTrue(any("解析状态未分类或非法" in r for r in result["blocked_reasons"])) + + def test_missing_page_status_blocks(self): + result = publish_gate.evaluate_item(_page(governance=_governance(page_status=""))) + self.assertFalse(result["ready"]) + self.assertTrue(any("页面状态" in r for r in result["blocked_reasons"])) + + def test_missing_governance_blocks(self): + result = publish_gate.evaluate_item(_page(governance={})) + self.assertFalse(result["ready"]) + self.assertTrue(any("6 行治理表" in r for r in result["blocked_reasons"])) + + def test_empty_required_field_blocks(self): + result = publish_gate.evaluate_item(_page(governance=_governance(source=""))) + self.assertFalse(result["ready"]) + self.assertTrue(any("来源" in r for r in result["blocked_reasons"])) + + def test_invalid_page_status_blocks(self): + result = publish_gate.evaluate_item(_page(governance=_governance(page_status="草稿"))) + self.assertFalse(result["ready"]) + self.assertTrue(any("页面状态非法" in r for r in result["blocked_reasons"])) + + def test_unresolved_field_with_done_narrows(self): + result = publish_gate.evaluate_item( + _page(governance=_governance(owner="待确认", page_status="已完成")) + ) + self.assertTrue(result["ready"]) + self.assertTrue(result["narrowed"]) + self.assertEqual(result["effective_page_status"], "进行中") + + def test_missing_field_with_done_narrows(self): + # A governance object that omits non-required rows entirely (owner, + # version, effective, review) must not pass as 已完成: an absent key is + # treated like 待确认 and narrows the status to 进行中. + gov = { + "source": "x", + "scope_visibility": "全员", + "page_status": "已完成", + } + result = publish_gate.evaluate_item(_page(governance=gov)) + self.assertTrue(result["ready"]) + self.assertTrue(result["narrowed"]) + self.assertEqual(result["effective_page_status"], "进行中") + + def test_full_governance_done_not_narrowed(self): + # A fully-populated table with no unresolved/missing rows stays 已完成. + result = publish_gate.evaluate_item(_page(governance=_governance(page_status="已完成"))) + self.assertTrue(result["ready"]) + self.assertFalse(result["narrowed"]) + self.assertEqual(result["effective_page_status"], "已完成") + + def test_partial_parse_with_done_narrows(self): + result = publish_gate.evaluate_item( + _page(parse_status="partial", governance=_governance(page_status="已完成")) + ) + self.assertTrue(result["ready"]) + self.assertTrue(result["narrowed"]) + self.assertEqual(result["effective_page_status"], "进行中") + self.assertTrue(any("部分解析" in r for r in result["narrow_reasons"])) + + def test_partial_parse_already_inprogress_not_narrowed(self): + result = publish_gate.evaluate_item( + _page(parse_status="partial", governance=_governance(page_status="进行中")) + ) + self.assertTrue(result["ready"]) + self.assertFalse(result["narrowed"]) + + def test_nondict_governance_blocks(self): + result = publish_gate.evaluate_item(_page(governance="oops")) + self.assertFalse(result["ready"]) + self.assertTrue(any("6 行治理表" in r for r in result["blocked_reasons"])) + + +class AttachmentGateTest(unittest.TestCase): + def test_confirmed_attachment_ok(self): + result = publish_gate.evaluate_item(_attachment()) + self.assertTrue(result["ready"]) + self.assertFalse(result["counts_as_page"]) + self.assertEqual(result["effective_page_status"], "") + + def test_attachment_without_confirm_blocks(self): + result = publish_gate.evaluate_item(_attachment(attachment_confirmed=False)) + self.assertFalse(result["ready"]) + self.assertTrue(any("显式确认" in r for r in result["blocked_reasons"])) + + def test_attachment_wrong_write_via_blocks(self): + result = publish_gate.evaluate_item(_attachment(write_via="docs_update")) + self.assertFalse(result["ready"]) + self.assertTrue(any("只能用 drive_upload" in r for r in result["blocked_reasons"])) + + def test_attachment_never_counts_as_page(self): + result = publish_gate.evaluate_item(_attachment()) + self.assertFalse(result["counts_as_page"]) + + def test_attachment_without_target_blocks(self): + result = publish_gate.evaluate_item(_attachment(target_token="")) + self.assertFalse(result["ready"]) + self.assertTrue(any("目标 Wiki 节点" in r for r in result["blocked_reasons"])) + + def test_prohibited_attachment_blocks(self): + result = publish_gate.evaluate_item(_attachment(sensitivity="prohibited")) + self.assertFalse(result["ready"]) + + +class UnknownRoleTest(unittest.TestCase): + def test_unknown_role_blocks(self): + result = publish_gate.evaluate_item(_page(publish_role="whatever")) + self.assertFalse(result["ready"]) + self.assertTrue(any("未知 publish_role" in r for r in result["blocked_reasons"])) + + def test_missing_role_blocks(self): + item = _page() + del item["publish_role"] + result = publish_gate.evaluate_item(item) + self.assertFalse(result["ready"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 1d1133ec59..bbab9ec15d 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -26,6 +26,8 @@ metadata: - 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。 - 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow;该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。 +- 用户要**给知识库各节点写维护要求 / 维护标准 / 收录范围 / 命名规范**,或要“建立标准知识库维护方法,后续同事按标准补资料”,或知识库结构过简(如仅有根节点)需要**先补大纲结构再写维护标准**时,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_base_bootstrap`](../lark-drive/references/lark-drive-workflow-knowledge-base-bootstrap.md) workflow;该 workflow 读取现有节点结构与草稿,必要时先提议并(经确认后)新建子节点大纲,再把通用规范写入根节点、专属维护要求写入各子节点。它只写节点正文、按需新建大纲节点,不移动 / 不删除 / 不重命名已有节点。 +- 用户要**把本地文件 / 文件夹的资料转成知识页写入已有知识库节点**,或要“盘点本地资料、去重后归位入库”时,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_ingest`](../lark-drive/references/lark-drive-workflow-knowledge-ingest.md) workflow;该 workflow 盘点本地资料、据维护规范映射归位、转成飞书 docx 知识页写入并验证,节点不足时据真实资料提议承载节点(经确认后新建)。它只往已有知识库填料,不从零建知识空间(无库时请用户先自建知识库或提供已有库链接再来)。 - 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move` 或 `drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。 - 用户给的是知识库 URL(`.../wiki/`),且后续要查成员/加成员/删成员:先确定下游成员操作的身份(默认 `user`;用户明确要求应用 / bot 视角时用 `bot`),再调用 `lark-cli wiki +node-get --node-token '' --as user --format json`,从 `data.space_id` 获取空间 ID;下游使用 bot 时将示例中的身份改为 `--as bot`。节点解析与后续成员操作必须使用相同身份。 - 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL:**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式: