From dd0ee282dfc02dcbce855d5260ef3a8ec64ddcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 1 Sep 2026 18:18:28 +0800 Subject: [PATCH] feat: add artifact library and improve public wiki --- .gitignore | 2 + .../docs/技能归纳至WeKnora知识库-实现设计.md | 2533 +++++++++++++++++ .../components/workspace/chats/chat-box.tsx | 52 +- frontend-web/src/core/artifacts/library.ts | 102 + .../src/core/llmwiki/wiki-page-markdown.ts | 2 +- .../src/core/page-layout/sidebar-menu.ts | 1 + .../src/pages/ArtifactLibraryPage.tsx | 433 +++ frontend-web/src/pages/WorkspaceRoutes.tsx | 10 + .../src/strategy-components/api/llmwiki.ts | 58 +- .../llmwiki/WeKnoraWikiPageView.tsx | 64 +- offline-backend-20260512/backend/CLAUDE.md | 19 +- offline-backend-20260512/backend/README.md | 17 +- .../backend/app/gateway/routers/artifacts.py | 278 +- .../backend/app/gateway/routers/llmwiki.py | 324 ++- .../backend/tests/test_artifact_library.py | 142 + .../tests/test_public_llmwiki_embed.py | 142 +- 16 files changed, 4081 insertions(+), 98 deletions(-) create mode 100644 frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md create mode 100644 frontend-web/src/core/artifacts/library.ts create mode 100644 frontend-web/src/pages/ArtifactLibraryPage.tsx create mode 100644 offline-backend-20260512/backend/tests/test_artifact_library.py diff --git a/.gitignore b/.gitignore index f3b7b43e..bd77aabb 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,8 @@ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_health_auth_bypass.py # Keep formal Word OOXML and embedded-font regression coverage versioned. !offline-backend-20260512/backend/tests/test_word_export.py +# Keep generated-file library aggregation and filtering regression coverage versioned. +!offline-backend-20260512/backend/tests/test_artifact_library.py # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py diff --git a/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md new file mode 100644 index 00000000..a8a5b80e --- /dev/null +++ b/frontend-web/docs/技能归纳至WeKnora知识库-实现设计.md @@ -0,0 +1,2533 @@ +# 技能归纳、WeKnora 普通知识库与助手知识库:实现设计 + +本文档用于指导大模型或开发者实现知识库体系改造。整体包含两条线:一条是“将一个或多个技能归纳到一个或多个目标知识库”;另一条是新增一个自研的全局助手知识库,用于承接 WeKnora 已处理、已向量化后的 Wiki、实体和关系数据,也支持技能和管理员本地数据直接导入,并做统一实体对齐、Wiki 展示和版本管理。 + +本文档中,目标知识库既可以是 WeKnora 普通知识库,也可以是自研助手知识库。普通知识库负责向量化、检索、Wiki 和图谱;助手知识库负责全局知识梳理、实体对齐、Wiki 展示、搜索和版本管理。 + +## 1. 背景与目标 + +当前系统已有: + +- 技能管理:后台可管理运行中的技能,技能文件位于 `skills/public`、`skills/custom`。 +- 技能地址管理:已经在管理员技能管理页中以标签页形式接入,支持扫描技能内 `.md`、`.py` 中的地址并批量替换。 +- WeKnora / LLM Wiki 接入:前后端已有知识库、文档、Wiki 页面、Wiki 图谱等 API。 + +本次要新增一条管理员工作流: + +管理员在“技能管理”页面选择多个技能,再选择一个或多个目标知识库,点击归纳。系统将每个技能作为独立来源读取、解析、抽取和归纳,把结果写入对应知识库,并在技能列表和技能详情中展示“该技能已被哪些知识库归纳”。 + +用户已确认的业务约束: + +- 一次操作可以选择多个技能,也可以选择多个目标知识库;系统按 `技能 x 目标知识库` 生成处理矩阵。 +- 同一个技能可以在不同时间归纳到多个知识库,所以最终关系是技能与知识库多对多。 +- V1 支持归纳到多种目标:Wiki WeKnora、非 Wiki WeKnora、FAQ 知识库、系统会话存档知识库、只读知识库,以及自研助手知识库。 +- 技能归纳时只抽取数据、业务说明、实体、概念、对象、事件、关系等知识;Python、TypeScript、Shell 等代码和脚本类文件完全排除,不读取注释、不抽取函数、不上传、不进入 prompt。 +- 技能里的 Markdown、业务数据文件、表格、PDF、Office、图片等要纳入处理范围。 +- 需要同步三类内容:原始资料、归纳后的 Wiki、实体与关系。 +- 关系来源包括 Markdown 自然语言描述、目录和文件引用、模型自动推断。 +- 允许模型推断关系,同时前端需要提供逐条实体/关系预览审核能力。 +- 支持手动重新归纳,也支持自动文件监听、定时后台同步和技能变更后的自动远程写入。 +- 解除关联时弹窗让用户选择:保留知识库内容,或删除该技能贡献的内容。 +- 权限仅管理员。 +- 总技能数约 200 个,需要支持批量进度、后台执行、失败重试。 +- 同一目标知识库内,不同技能抽取出的同一实体需要合并。 +- 重归纳时先生成新版本,成功后再替换旧版本;失败时旧版本继续可用。 +- 新增知识库类型区分:普通知识库是 WeKnora,助手知识库是自研存储。 +- 助手知识库只有一个全局大库,建议命名为“知识梳理”,所有用户可见。 +- 助手知识库只提供 Wiki 页面展示、搜索和数据导入;不落到 WeKnora。 +- 助手知识库数据来源包括 WeKnora 导出、技能直接归纳、管理员本地文件导入。 +- WeKnora 已向量化和处理完成的 Wiki、文档、实体、关系、图谱数据默认沉淀到助手知识库。 +- 普通知识库需要提供导出或“导入到助手知识库”的功能,把 WeKnora 里已经处理好的数据导入助手知识库;导出数据需要包含 Wiki、文档、chunk、实体、关系、图谱。 +- 即使没有配置 WeKnora,也要默认展示助手知识库的创建或初始化入口。 +- 管理员可切换知识库产品版本:`v1` / `v2`,默认 `v1`。 +- 助手知识库普通用户只读,管理员可导入、编辑、删除、查看版本和回滚 Wiki 页面。 + +## 2. 必须支持的扩展能力 + +以下能力原先按非目标处理,最新需求已确认全部要支持。实现时必须把它们作为正式目标,并加上必要的权限、状态和审计护栏。 + +- 支持一次选择多个目标知识库。一个归纳任务可包含多个技能和多个目标,后端按矩阵拆分 item。 +- 支持非 Wiki 知识库、FAQ 知识库、系统会话存档知识库、只读知识库作为目标。不同目标使用不同写入 adapter;只读目标使用本地 sidecar 投影和待推送状态。 +- 支持直接写 WeKnora 的 Neo4j 或底层图数据库,但必须通过受控 graph writer、显式配置开关、事务和审计,不允许 LLM 生成任意 Cypher 直接执行。 +- 支持自动文件监听、定时后台同步、技能变更自动远程写入。自动写入仍必须走 staged 版本和失败保留旧版本机制。 +- 前端提供逐条实体/关系预览审核,管理员可批准、拒绝、编辑、批量处理。 +- 前端和 API 暴露远端 WeKnora ID,并可作为跨系统业务 key 使用;本地仍保留 surrogate id 以保证数据库关系稳定。 +- 支持技能直接归纳到助手知识库,不必先经过 WeKnora。 +- 支持管理员向助手知识库导入任意本地文件,导入前必须做文件类型、安全扫描和敏感信息阻断。 +- 助手知识库 V1 支持 Wiki 页面版本管理、版本对比、发布、回滚和导入审计。 + +## 3. 术语 + +| 术语 | 含义 | +| --- | --- | +| 技能 | DeerFlow 技能目录,通常包含 `SKILL.md`、代码、配置、示例、文档、数据文件等。 | +| 目标知识库 | 管理员选择的一个或多个知识库,可以是 WeKnora 普通知识库,也可以是自研助手知识库。 | +| 普通知识库 | WeKnora 知识库,负责向量化、Wiki、文档检索和图谱能力。 | +| 助手知识库 | DeerFlow 自研知识库,只维护一个全局大库,用于 Wiki 展示、搜索、WeKnora 导入、技能直接导入、本地文件导入和实体对齐。 | +| 知识梳理底库 | 助手知识库的默认全局库名,所有用户可见,普通用户只读。 | +| 产品版本 | 知识库产品形态开关。`v1` 默认为普通知识库体验,`v2` 默认为助手知识库体验。 | +| WeKnora 导出包 | 从普通知识库导出的已处理知识集合,包含 Wiki 页面、文档、chunk、实体、关系、图谱等。 | +| 绑定 | 一个技能与一个目标知识库之间的持久关联。`skill_name + target_type + target_id` 唯一。 | +| 快照 | 某次扫描技能目录得到的内容版本,包含文件清单、摘要、抽取结果和源 digest。 | +| 贡献 | 某个技能快照对目标知识库内实体、关系、Wiki 页面、原始资料的增量内容。 | +| 规范实体 | 在同一个目标知识库内合并后的实体。多个技能可以贡献到同一个实体。 | +| 规范关系 | 在同一个目标知识库内合并后的关系。多个技能可以给同一关系提供证据。 | +| 归纳任务 | 一次管理员发起的批量操作,可包含多个目标知识库和多个技能 item。 | +| item | 归纳任务中的单个技能处理单元。 | + +## 4. 总体方案 + +整体采用“双知识库形态 + 本地持久绑定 + 后台任务 + WeKnora 投影 + 助手底库实体对齐”的架构。 + +```mermaid +flowchart LR + A[管理员选择多个技能] --> B[选择一个或多个目标知识库] + B --> C[创建归纳任务矩阵] + C --> D[逐技能扫描与解析] + D --> E[生成技能快照] + E --> F[LLM 归纳 Wiki 与关系抽取] + F --> R[前端实体/关系预览审核] + R --> G[按目标知识库合并实体/关系] + G --> H[写入 WeKnora 或本地助手库] + G --> I[写入 Wiki/FAQ/会话存档/sidecar] + G --> J[写入 WeKnora GraphRAG 或 Neo4j] + H --> K[校验远端写入] + I --> K + J --> K + K --> L[本地原子激活新版本] + L --> M[技能页展示已归纳知识库] + K --> N[WeKnora 导出已处理数据] + N --> O[沉淀到助手知识库] + O --> P[全局实体对齐] + P --> Q[知识梳理 Wiki 展示与搜索] +``` + +关键原则: + +- DeerFlow 本地数据库保存权威绑定关系、版本状态、来源贡献和远端 artifact 映射。 +- WeKnora 保存可检索知识内容,包括原文、Wiki 页面、FAQ、会话存档、关系投影文档和图谱。 +- 对 WeKnora 图谱能力采取能力探测和分层写入:优先使用 WeKnora API;需要底层图写入时,使用受控 Neo4j graph writer。 +- 重归纳使用 staged artifact,远端写入和校验成功后,本地才激活新版本。 +- 删除或解除关联只影响该技能在目标知识库中的贡献,不误删其他技能共享的实体和关系。 +- 助手知识库只有一个全局库,可以接收技能直接归纳、WeKnora 导出和管理员本地文件导入。 +- WeKnora 的已处理结果默认进入助手知识库,由助手底库做全局实体对齐、Wiki 展示、搜索和版本管理。 +- 未配置 WeKnora 时,仍展示助手知识库创建或初始化能力;与 WeKnora 相关的归纳和导入动作置灰或隐藏。 + +## 5. 现有代码落点 + +实现前必须先读仓库说明: + +- `D:/cmzs-clean/deerflow-code/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/AGENTS.md` +- `D:/cmzs-clean/deerflow-code/offline-backend-20260512/backend/CLAUDE.md` + +建议改动位置: + +| 模块 | 建议文件 | +| --- | --- | +| 后端 router | `offline-backend-20260512/backend/app/gateway/routers/skill_knowledge.py` | +| 后台任务调度 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_dispatcher.py` | +| 后台任务执行 | `offline-backend-20260512/backend/app/gateway/skill_knowledge_job_executor.py` | +| 技能归纳核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/skill_knowledge/` | +| 持久化模型 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/` | +| WeKnora 客户端扩展 | `offline-backend-20260512/backend/packages/harness/deerflow/integrations/weknora/client.py` | +| 助手知识库核心逻辑 | `offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/` | +| 助手知识库持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/` | +| 助手知识库 Gateway | `offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py` | +| WeKnora 导出到助手库任务 | `offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py` | +| 前端 API | `frontend-web/src/strategy-components/api/skill-knowledge.ts` | +| 助手知识库前端 API | `frontend-web/src/strategy-components/api/assistant-knowledge.ts` | +| 管理员技能页 | `frontend-web/src/pages/AdminSkillsPage.tsx` | +| 技能行组件 | `frontend-web/src/components/workspace/settings/skill-settings-page.tsx` | +| WeKnora API hooks | `frontend-web/src/strategy-components/api/llmwiki.ts` | +| 知识库产品版本设置 | `frontend-web/src/core/system-settings/api.ts` | + +注意边界: + +- `packages/harness/deerflow/*` 不能 import `app.*`。 +- 路由、鉴权、FastAPI app state 等放在 `app/gateway/*`。 +- 纯扫描、解析、抽取、合并、投影计划生成放在 `deerflow.*`。 +- 文件 IO、Office/PDF 转换、CPU 密集处理必须通过 `asyncio.to_thread`,避免阻塞事件循环。 + +## 6. WeKnora 能力使用策略 + +现有 WeKnora 客户端已支持: + +- 创建知识库。 +- 上传文件。 +- 导入 URL。 +- 创建手动文档。 +- 创建、更新、删除、查询 Wiki 页面。 +- 获取 Wiki 图谱。 + +V1 需要新增或补齐: + +- 目标知识库能力探测:是否启用 Wiki,是否可写,是否支持 GraphRAG / extract_config。 +- 目标知识库分类和能力标识:Wiki、非 Wiki、FAQ、会话存档、只读、图谱可写。 +- 导出已处理数据的能力:Wiki 页面、文档、chunk、实体、关系、图谱。 +- WeKnora 处理完成后的自动沉淀触发点:在文件向量化、Wiki 生成和图谱抽取完成后,创建“导入助手知识库”的后台任务。 +- 如果 WeKnora 当前实例 Swagger 暴露了图谱相关配置更新接口,则在目标知识库启用 `graph_enabled` 与 `extract_config`。 +- 如果未暴露稳定的实体/关系写入 API,优先使用“关系投影文档 + Wiki 关系页 + Wiki 链接”让 WeKnora 自己抽取图谱;在配置显式开启时,可通过受控 Neo4j direct writer 写入底层图数据库。 + +参考官方资料: + +- [WeKnora Knowledge Graph 文档](https://github.com/Tencent/WeKnora/blob/main/docs/KnowledgeGraph.md) +- [WeKnora knowledge-base API 文档](https://github.com/Tencent/WeKnora/blob/main/docs/api/knowledge-base.md) +- [WeKnora QA 文档](https://github.com/Tencent/WeKnora/blob/main/docs/QA.md) +- [WeKnora API 文档入口](https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md) + +实现要求: + +- 以部署实例的 Swagger 为准,不要硬编码未确认的远端 API。 +- 可以封装 `probe_capabilities()`,返回 `wiki_enabled`、`graph_enabled_supported`、`extract_config_supported`、`wiki_page_upsert_supported`、`file_upload_supported` 等能力。 +- GraphRAG 失败不能导致 Wiki 归纳整体回滚。Wiki 与原始资料成功、GraphRAG 写入失败时,绑定状态为 `partial_failed`,错误信息明确写“图谱投影失败,可重新归纳重试”。 +- 如果 WeKnora 不支持直接导出向量、chunk 或图谱原始结构,导出层应退化为读取 Wiki 页面、文档状态、Wiki graph 和可查询的图谱摘要;助手知识库不强依赖复用 WeKnora embedding 向量。 + +### 6.1 目标知识库适配策略 + +一次归纳任务允许选择多个技能和多个目标知识库。后端不要把它当成一个大事务,而是展开为矩阵: + +```text +items = selected_skills x selected_targets +``` + +每个 item 对应一个 `binding`,一个技能归纳到三个目标知识库,就生成三个 binding。父 job 负责展示整体进度,item 独立成功、失败、取消和重试。 + +目标知识库需要通过 adapter 写入: + +| 目标类型 | 写入策略 | Wiki | 关系/图谱 | +| --- | --- | --- | --- | +| WeKnora Wiki 知识库 | 上传原始资料、upsert Wiki 页面、写 graph projection | 写 WeKnora Wiki | WeKnora GraphRAG + 可选 Neo4j direct writer | +| WeKnora 非 Wiki 知识库 | 上传原始资料和归纳文档,不强制生成 Wiki | 不写 WeKnora Wiki,可写本地 sidecar Wiki | graph projection 文档 + 可选 Neo4j direct writer | +| WeKnora FAQ 知识库 | 把归纳结果转换为 FAQ 条目、来源文档和关系摘要 | 不写或只写 sidecar Wiki | 关系作为 FAQ metadata、projection 文档和图谱边 | +| 系统会话存档知识库 | 把技能归纳结果作为系统会话归档条目写入 | sidecar Wiki 展示 | 关系作为归档 metadata 和图谱边 | +| 只读 WeKnora 知识库 | 不修改远端原库;写本地 sidecar artifact,并生成待推送状态 | sidecar Wiki 展示 | sidecar graph + 待推送 Neo4j plan | +| 助手知识库 | 写入自研底库、实体对齐、生成 Wiki 页面版本 | 写自研 Wiki | 写自研实体/关系表 | + +只读目标说明: + +- “支持只读知识库作为目标”不代表绕过远端权限强行写入。 +- 对只读 WeKnora,系统创建本地 sidecar 投影:归纳结果与该远端知识库绑定展示,但远端写入状态为 `pending_remote_write`。 +- 当管理员后续授予可写权限时,可执行“推送到远端”,把 sidecar staged artifact 写入 WeKnora。 +- 前端必须清楚展示“只读目标:已本地归纳,未写入远端”。 + +### 6.2 Neo4j 直接写入设计 + +需求确认需要支持直接写 WeKnora 的 Neo4j 或底层图数据库。该能力风险较高,必须作为受控能力实现。 + +必须满足: + +- 通过配置显式开启,例如 `weknora.graph.direct_write_enabled = true`。 +- 只允许管理员触发。 +- Neo4j 连接串、账号、库名只允许后端配置,不允许前端传入。 +- LLM 不得生成任意 Cypher 并直接执行。 +- graph writer 只能执行代码中预定义的参数化模板。 +- 所有写入使用事务。 +- 每个节点和边都带稳定业务 key、来源 binding、snapshot、job、confidence 和 evidence。 +- 支持 dry-run plan、写入审计、失败重试、回滚计划。 + +节点 key 建议: + +```text +provider = deerflow +scope = target_type + target_id +entity_key = entity_type + normalized_name 或 external_id +``` + +边 key 建议: + +```text +source_entity_key + predicate + target_entity_key + contribution_scope +``` + +Neo4j 写入只作为图谱层加速和展示,不替代本地 contribution 表。本地 contribution 表仍是删除、回滚、重建、审计的权威来源。 + +### 6.3 实体/关系预览审核 + +前端必须提供逐条实体/关系预览审核能力。审核发生在“抽取完成、写入目标前”。 + +审核对象: + +- 实体。 +- 关系。 +- 实体对齐候选。 +- 关系合并候选。 +- Wiki 页面草稿。 + +管理员操作: + +- 批准。 +- 拒绝。 +- 编辑名称、类型、谓词、别名、摘要、confidence。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 + +审核模式建议: + +| 模式 | 说明 | +| --- | --- | +| `required` | 所有实体/关系必须审核后才能写入。 | +| `auto_high_confidence` | 高置信自动通过,低置信进入审核队列。 | +| `off` | 不阻塞写入,但仍保留预览页面和事后审计。 | + +默认建议 `auto_high_confidence`。如果用户希望“必须逐条审核”,可在系统设置中改为 `required`。 + +审核记录必须入库: + +- 操作人。 +- 操作时间。 +- 原始抽取值。 +- 修改后值。 +- 通过或拒绝原因。 +- 对应 job/item/binding/snapshot。 + +### 6.4 自动同步设计 + +需要支持三类自动同步: + +- 文件监听:技能知识文件变化后自动创建归纳任务。 +- 定时同步:按管理员配置的周期重新扫描并归纳。 +- 技能变更自动远程写入:技能保存、启停、导入后触发相关 binding 同步。 + +自动同步仍需遵守: + +- 只处理非代码知识文件。 +- 代码文件变化不触发归纳。 +- 自动任务必须 debounced,避免保存过程中连续触发。 +- 自动任务必须走 staged 版本,成功后激活,失败保留旧版本。 +- 同一 binding 同一时间只能有一个 active item。 +- 审核模式为 `required` 时,自动任务停在 `awaiting_review`,不写远端。 + +建议新增配置: + +```json +{ + "skill_knowledge_auto_sync": { + "watch_enabled": true, + "schedule_enabled": true, + "schedule_cron": "0 */6 * * *", + "debounce_seconds": 120, + "write_remote_enabled": true + } +} +``` + +### 6.5 远端 ID 暴露与业务 key + +需求确认前端需要暴露 WeKnora 远端 ID,并可作为跨系统业务 key 使用。 + +实现要求: + +- API 返回 `remote_refs`,包含 provider、remote kind、remote id、slug、url。 +- 前端可以展示、复制、筛选远端 ID。 +- 与外部系统交互时,可使用 `provider + remote_id` 作为业务 key。 +- 本地数据库仍保留 `id` 作为 surrogate primary key,避免远端 ID 变更导致本地关系断裂。 + +建议响应结构: + +```json +{ + "id": "local-artifact-id", + "business_key": "weknora:wiki_page:remote-page-id", + "remote_refs": [ + { + "provider": "weknora", + "remote_kind": "wiki_page", + "remote_id": "remote-page-id", + "slug": "skills/example/overview", + "url": "/llmwiki/kb/xxx/wiki/skills/example/overview" + } + ] +} +``` + +不要把远端 ID 当作唯一存储来源;本地仍需要维护 mapping、审计和状态。 + +## 7. 数据处理范围 + +归纳一个技能时,扫描技能根目录下所有有效的非代码知识文件。代码类文件不作为知识源,也不影响知识 digest。 + +必须处理: + +| 类型 | 策略 | +| --- | --- | +| `.md` / `.mdx` / `.txt` | 直接读取,保留标题、段落、链接、表格和自然语言说明;代码块内容默认跳过,只保留“此处存在示例代码”的弱提示。 | +| `.json` / `.yaml` / `.yml` / `.toml` / `.ini` | 仅当它们承载业务数据、数据字典、枚举、模板或知识配置时处理;运行配置、依赖配置、工具入口、工程配置不入库。 | +| `.csv` / `.tsv` | 结构化读取,抽样概要 + 全量原始资料上传。 | +| `.xlsx` / `.xls` | 使用已有文件转换或表格解析能力,保留 sheet、列名、样例和统计。 | +| `.pdf` / `.docx` / `.pptx` | 复用现有 `deerflow.utils.file_conversion` 或 MarkItDown 路径转文本,同时上传原始文件。 | +| 图片 | 上传原始文件;如果已有 VLM/OCR 能力则抽取图片描述,否则记录为二进制附件。 | +| 压缩包 | 安全解压到临时目录后递归处理,必须限制大小、文件数、路径穿越、符号链接。 | +| 其他二进制 | 记录 manifest 并尝试作为原始附件上传;无法解析时不生成文本摘要。 | + +必须排除: + +- `.py` +- `.ts` +- `.tsx` +- `.js` +- `.jsx` +- `.sh` +- `.bat` +- `.ps1` +- 其他脚本、源码、构建脚本、依赖锁文件 +- `.git` +- `node_modules` +- `__pycache__` +- `.pytest_cache` +- `dist` +- `build` +- 临时缓存目录 +- 明确生成物和大体积模型文件 + +必须阻断: + +- `.env` +- 私钥、公钥私钥对、证书私钥 +- token、password、secret、access key 等高置信密钥文件 + +阻断规则是安全底线:发现疑似密钥时,该文件不能上传到 WeKnora,也不能进入 LLM prompt。任务 item 标记为 `partial_failed` 或 `failed`,前端展示“因安全策略跳过敏感文件”,并给出文件路径,不输出密钥内容。 + +代码排除规则: + +- 不读取源码内容。 +- 不抽取源码注释。 +- 不抽取函数、类、import、依赖、入口、调用链。 +- 不上传源码文件。 +- 不把源码路径作为 Wiki 知识展示;最多在管理员任务详情中显示“已跳过代码类文件数量”。 +- 计算 `source_digest` 时只包含知识文件;纯代码变更不应导致技能归纳状态变为 stale。 + +## 8. 中间表示 IR + +扫描和抽取后生成统一 IR,后续 Wiki 生成、实体合并、远端投影都基于 IR,而不是直接从文件内容临时拼装。 + +建议结构: + +```json +{ + "skill": { + "name": "example-skill", + "display_name": "示例技能", + "description": "从 SKILL.md 和元数据提取", + "source_root": "skills/public/example-skill", + "source_digest": "sha256:..." + }, + "files": [ + { + "path": "SKILL.md", + "kind": "markdown", + "sha256": "...", + "size": 12345, + "text_digest": "sha256:...", + "title": "Example Skill", + "summary": "文件摘要", + "anchors": [ + { + "id": "heading-install", + "line_start": 12, + "line_end": 36, + "text_digest": "sha256:..." + } + ], + "references": [ + { + "target_path": "data/schema.yaml", + "evidence": "Markdown link or import", + "confidence": 1.0 + } + ], + "safety": { + "status": "allowed", + "warnings": [] + } + } + ], + "entities": [ + { + "local_id": "ent_001", + "type": "tool", + "name": "web_search", + "aliases": ["网络搜索"], + "description": "用于检索外部信息的工具", + "attributes": { + "entrypoint": "config.yaml tools.web_search" + }, + "evidence": [ + { + "file_path": "config.yaml", + "line_start": 10, + "line_end": 20, + "quote": "短证据,不包含敏感信息" + } + ], + "confidence": 0.94 + } + ], + "relations": [ + { + "local_id": "rel_001", + "source_local_id": "ent_001", + "predicate": "used_by", + "target_local_id": "ent_002", + "description": "web_search 被示例技能用于首步检索", + "relation_source": "llm_inferred", + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 22, + "line_end": 28, + "quote": "短证据" + } + ], + "confidence": 0.88 + } + ], + "wiki": { + "skill_page": { + "slug": "skills/example-skill/overview", + "title": "示例技能", + "content": "Markdown Wiki 内容" + }, + "pages": [] + } +} +``` + +IR 需要版本化: + +- `extractor_version`:扫描、解析、提示词和抽取 schema 的整体版本。 +- `prompt_hash`:LLM 抽取提示词 hash。 +- `model_name`:抽取模型名。 +- `source_digest`:技能目录内容 digest。 + +只要 `source_digest + extractor_version + prompt_hash + model_name` 相同,就可以复用已有 snapshot,避免 200 个技能批量时反复抽取。 + +## 9. 关系抽取规则 + +关系来源分三类: + +| 来源 | 示例 | 置信度策略 | +| --- | --- | --- | +| 确定性结构关系 | 目录包含文件、Markdown 链接、数据文件引用、业务配置引用 | `confidence = 1.0` | +| 显式文本关系 | Markdown 中写明“本技能依赖 A”、“输出 B”、“调用接口 C” | LLM 抽取,但要求证据;通常 `0.75` 以上 | +| 模型推断关系 | 从上下文推断某工具服务于某步骤、某配置控制某行为 | 必须有证据片段和解释;无证据不得入库 | + +无人工审核的前提下,必须设置阈值: + +- `confidence >= 0.75`:写入 Wiki 关系页和图谱投影文档。 +- `0.50 <= confidence < 0.75`:只写入技能 Wiki 的“可能关系”小节,不进入规范关系。 +- `confidence < 0.50`:丢弃。 + +每条关系都必须包含: + +- `source` +- `predicate` +- `target` +- `confidence` +- `relation_source` +- `evidence` + +如果没有证据路径和证据文本,不允许写入规范关系。 + +建议谓词归一化: + +| 原始表达 | 规范 predicate | +| --- | --- | +| 依赖、需要、requires | `depends_on` | +| 调用、使用、uses | `uses` | +| 生成、输出、produces | `produces` | +| 读取、输入、consumes | `consumes` | +| 配置、控制 | `configures` | +| 包含、目录下有 | `contains` | +| 引用、链接到 | `references` | +| 替换、迁移到 | `replaces` | +| 属于 | `belongs_to` | + +实现可以先内置有限谓词表,再允许保留 `custom_predicate`,但 UI 与关系页要展示规范谓词。 + +## 10. 同一知识库内的实体合并 + +实体合并范围只在一个目标知识库内生效,不跨知识库合并。 + +规范实体 key: + +```text +kb_mapping_id + entity_type + normalized_name +``` + +`normalized_name` 规则: + +- Unicode NFKC 归一化。 +- trim。 +- 多空白折叠成单空格。 +- ASCII 字母转小写。 +- 中英文标点归一化。 +- 去除无意义包裹符号,例如反引号、引号。 + +如果实体带有明确外部 ID,可优先使用: + +```text +kb_mapping_id + entity_type + external_id_type + external_id +``` + +合并规则: + +- `normalized_name` 相同且 `entity_type` 兼容:合并。 +- 名称相同但类型不兼容:不合并,例如 `config:web_search` 和 `tool:web_search` 可保持两个实体,必要时用 `configures` 关系连接。 +- LLM 判断别名合并:只有 `confidence >= 0.90` 且有明确证据时允许。 +- 属性冲突不做最后写入覆盖,必须按来源保留 provenance。 + +实体贡献示例: + +```json +{ + "canonical_entity_id": "ce_123", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "local_entity_id": "ent_001", + "attributes": { + "entrypoint": { + "value": "config.yaml tools.web_search", + "evidence": "config.yaml:10" + } + }, + "confidence": 0.94 +} +``` + +删除贡献时: + +- 只删除该 binding 的贡献。 +- 如果规范实体仍有其他贡献,保留实体页并重新生成聚合内容。 +- 如果规范实体贡献数为 0,删除或标记归档该规范实体及其 Wiki 页面。 + +## 11. 关系合并 + +规范关系 key: + +```text +kb_mapping_id + source_canonical_entity_id + normalized_predicate + target_canonical_entity_id +``` + +合并规则: + +- 同一 key 的关系合并为一条规范关系。 +- 每个技能的证据作为 relation contribution 追加。 +- `confidence` 可存储最大值、平均值和证据数,但展示优先显示最高置信证据。 +- 如果来源实体或目标实体被删除到无贡献,相关关系也需要重新计算。 + +关系 Wiki 页面内容必须保留: + +- 关系三元组。 +- 关系说明。 +- 贡献技能列表。 +- 证据路径。 +- 置信度。 +- 最近更新时间。 + +## 12. WeKnora 内容投影 + +每个技能归纳到一个目标知识库后,按目标 adapter 写入三类内容。目标是 WeKnora 时写入 WeKnora;目标是助手知识库或只读 sidecar 时写入 DeerFlow 自研存储。 + +### 12.1 原始资料 + +原始资料分两种: + +- 可直接上传的文件:走 WeKnora 文件上传。 +- 纯文本或需要展开的内容:创建手动文档,保留路径和 fenced code。 + +原始资料命名建议: + +```text +skills/{skill_name}/source/{relative_path} +``` + +文档 metadata 建议: + +```json +{ + "source_type": "deerflow_skill", + "skill_name": "example-skill", + "binding_id": "bind_001", + "snapshot_id": "snap_001", + "source_digest": "sha256:...", + "relative_path": "SKILL.md", + "artifact_kind": "raw_source" +} +``` + +如果 WeKnora 不支持自定义 metadata,则本地 artifact 表必须保存远端 ID、slug、digest、kind 和来源关系。 + +### 12.2 技能 Wiki 页面 + +每个技能至少生成: + +```text +skills/{skill_name}/overview +skills/{skill_name}/files +skills/{skill_name}/entities +skills/{skill_name}/relations +skills/{skill_name}/evidence +``` + +内容要求: + +- `overview`:技能用途、输入、输出、依赖、使用方式、风险提示。 +- `files`:重要文件清单、每个文件摘要、关键入口。 +- `entities`:该技能贡献的实体,使用 Wiki 链接指向规范实体页。 +- `relations`:确定关系和高置信推断关系,使用 Wiki 链接指向规范关系页。 +- `evidence`:证据目录,只包含短摘录和位置,不包含敏感内容。 + +### 12.3 规范实体 Wiki 页面 + +规范实体页 slug: + +```text +entities/{entity_type}/{stable_entity_id}-{slugified_name} +``` + +页面内容: + +- 实体名称和类型。 +- 别名。 +- 归并来源技能。 +- 聚合描述。 +- 属性表,冲突属性按来源列出。 +- 相关关系列表。 +- 证据索引。 + +### 12.4 规范关系 Wiki 页面 + +规范关系页 slug: + +```text +relations/{predicate}/{stable_relation_id} +``` + +页面内容: + +- `[[source_entity]] --predicate--> [[target_entity]]` +- 中文说明。 +- 贡献技能。 +- 证据。 +- 置信度。 +- 更新时间。 + +说明:即便 WeKnora Wiki graph 只能展示无类型链接,关系类型也不能丢。类型和证据由关系页保存,Wiki 链接负责把实体页和关系页连接起来。 + +### 12.5 图谱投影文档 + +为提高 WeKnora GraphRAG 抽取成功率,每个 binding 生成一份机器可读关系文档: + +```text +skills/{skill_name}/graph-projection.jsonl +``` + +每行一个关系: + +```json +{ + "source": "web_search", + "source_type": "tool", + "predicate": "used_by", + "target": "example-skill", + "target_type": "skill", + "description": "web_search 被 example-skill 用于首步检索", + "confidence": 0.88, + "evidence": "SKILL.md:22-28" +} +``` + +同时生成一份人类可读 Markdown: + +```text +skills/{skill_name}/graph-projection.md +``` + +该文档使用短句和固定模板,降低 WeKnora 抽取歧义: + +```markdown +# 技能关系投影:example-skill + +- 实体 `web_search`(tool)与实体 `example-skill`(skill)存在 `used_by` 关系。 + - 说明:web_search 被 example-skill 用于首步检索。 + - 证据:SKILL.md:22-28 + - 置信度:0.88 +``` + +GraphRAG 可用时上传这些投影文档;Neo4j direct writer 开启时,同时把通过审核的规范实体和关系写入底层图数据库;两者都不可用时仍保留 Wiki 关系页和本地关系表。 + +## 13. 重归纳一致性 + +重归纳必须满足“成功后替换,失败不影响旧版本”。 + +流程: + +1. 读取当前 binding 和 active snapshot。 +2. 重新扫描技能目录,生成新 snapshot。 +3. 如果 digest 未变化且未强制重归纳,可以直接返回 `synced`。 +4. 在本地创建 staged artifact 记录。 +5. 写入 WeKnora 新版本原始资料、Wiki 页面、关系投影文档。 +6. 校验远端可读取或状态完成。 +7. 在一个数据库事务中: + - 将旧 artifact 标记为 `retired`。 + - 将 staged artifact 标记为 `active`。 + - 将 binding 的 `current_snapshot_id` 指向新 snapshot。 + - 更新实体和关系贡献。 + - 设置 binding 状态为 `synced` 或 `partial_failed`。 +8. 对旧远端文件执行 best-effort 清理。 +9. 清理失败只记录 warning,不回滚已经激活的新版本。 + +如果第 5 或第 6 步失败: + +- binding 仍指向旧 snapshot。 +- 旧 WeKnora 内容继续有效。 +- staged artifact 标记为 `failed`。 +- item 状态为 `failed` 或 `partial_failed`。 +- 前端显示错误,允许 retry / 重新归纳。 + +Wiki 页面建议采用稳定 slug upsert。原始资料如果 WeKnora 没有 update API,则创建新远端文档,确认成功后再删除旧远端文档。 + +## 14. 解除关联 + +解除一个技能与一个知识库的绑定时,前端必须弹窗让管理员选择: + +| 选项 | 行为 | +| --- | --- | +| 保留知识库内容 | 绑定标记为 `detached_keep`。WeKnora 远端内容、助手库 Wiki revision 或 sidecar 内容保留为冻结知识,不再随技能重归纳更新。技能页不再显示为“当前归纳”,但可在历史中显示“已解除,内容保留”。 | +| 删除该技能贡献的内容 | 删除该 binding 的原始资料、技能 Wiki 页、关系投影文档、助手库 Wiki revision contribution、sidecar artifact 和实体/关系贡献;共享实体/关系只减少该技能贡献,仍有其他贡献则重建聚合页,没有贡献才删除。 | + +如果删除远端内容部分失败: + +- 本地记录 artifact `delete_failed`。 +- binding 状态为 `detached_delete_pending_cleanup`。 +- 提供“重试清理”动作。 + +不要因为某个共享实体由该技能参与过,就删除其他技能还在使用的实体页。 + +## 15. 状态机 + +绑定状态建议: + +| 状态 | 含义 | +| --- | --- | +| `pending` | 已创建绑定,但尚未开始处理。 | +| `queued` | 已进入任务队列。 | +| `running` | 正在处理。 | +| `awaiting_review` | 抽取完成,等待管理员审核实体、关系或 Wiki 草稿。 | +| `synced` | 当前版本已成功归纳到目标知识库。 | +| `pending_remote_write` | 只读目标已完成本地 sidecar 归纳,等待远端具备写权限后推送。 | +| `stale` | 技能源文件已变化,但尚未手动重新归纳。 | +| `partial_failed` | 核心 Wiki/原始资料成功,但部分文件、图谱或清理失败。 | +| `failed` | 当前归纳失败,旧版本若存在则继续有效。 | +| `detached_keep` | 已解除关联,远端内容保留。 | +| `detached_delete_pending_cleanup` | 已解除关联,但删除远端内容未完全成功。 | +| `detached_deleted` | 已解除关联,远端贡献已清理。 | + +item 阶段建议: + +```text +scanning +parsing +summarizing +extracting +awaiting_review +merging +uploading_sources +writing_wiki +writing_graph_projection +verifying +activating +cleanup +completed +``` + +前端展示时,父任务进度由 item 数量和阶段加权计算,不要只显示一个不可解释的 spinner。 + +## 16. 持久化设计 + +下面是建议表结构,字段命名可按项目现有风格调整。 + +### 16.1 `skill_knowledge_bindings` + +保存技能与知识库的多对多关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `target_type` | `weknora` / `assistant`。 | +| `target_mode` | `wiki` / `document` / `faq` / `conversation_archive` / `readonly_sidecar`。 | +| `target_id` | 目标知识库 ID。WeKnora 使用本地 mapping ID,助手库使用全局 base ID。 | +| `remote_business_key` | 暴露给前端和外部系统的远端业务 key,例如 `weknora:kb:xxx`,可为空。 | +| `status` | 绑定状态。 | +| `active` | 是否当前有效绑定。 | +| `current_snapshot_id` | 当前激活的技能快照。 | +| `source_digest` | 最近扫描到的技能源 digest。 | +| `synced_digest` | 最近成功归纳的 digest。 | +| `last_job_id` | 最近一次父任务。 | +| `last_item_id` | 最近一次 item。 | +| `last_synced_at` | 最近成功归纳时间。 | +| `last_error_code` | 最近错误码。 | +| `last_error_message` | 最近错误摘要。 | +| `created_by` | 管理员用户。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(skill_name, target_type, target_id) +``` + +### 16.2 `skill_knowledge_snapshots` + +保存技能扫描和抽取结果。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `skill_name` | 技能名。 | +| `source_digest` | 技能目录 digest。 | +| `extractor_version` | 抽取器版本。 | +| `prompt_hash` | 提示词 hash。 | +| `model_name` | 模型名。 | +| `manifest_json` | 文件清单、hash、安全状态。 | +| `ir_json` | 抽取后的 IR;较大时可存 blob 引用。 | +| `status` | `ready` / `failed`。 | +| `counts_json` | 文件数、实体数、关系数、跳过数。 | +| `created_at` | 创建时间。 | + +约束: + +```text +unique(skill_name, source_digest, extractor_version, prompt_hash, model_name) +``` + +### 16.3 `skill_knowledge_sync_jobs` + +保存一次批量归纳任务。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_count` | 本次目标知识库数量。 | +| `targets_json` | 目标知识库列表,每项包含 `target_type`、`target_mode`、`target_id`、展示名和远端业务 key。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed` / `canceled`。 | +| `total_count` | 技能数。 | +| `success_count` | 成功数。 | +| `partial_failed_count` | 部分失败数。 | +| `failed_count` | 失败数。 | +| `cancel_requested` | 是否请求取消。 | +| `created_by` | 管理员用户。 | +| `created_at` / `started_at` / `completed_at` | 时间戳。 | + +### 16.4 `skill_knowledge_sync_items` + +保存每个技能的任务项。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `job_id` | 父任务。 | +| `binding_id` | 绑定。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `skill_name` | 技能名。 | +| `snapshot_id` | 本次输入快照。 | +| `status` | item 状态。 | +| `phase` | 当前阶段。 | +| `progress` | 0-100。 | +| `active_dedupe_key` | 活跃任务去重 key。 | +| `lease_owner` | 执行器标识。 | +| `lease_until` | 租约过期时间。 | +| `attempts` | 尝试次数。 | +| `last_error_code` | 错误码。 | +| `last_error_message` | 错误摘要。 | +| `created_at` / `updated_at` / `started_at` / `completed_at` | 时间戳。 | + +活跃去重 key: + +```text +skill_name + target_type + target_id +``` + +同一绑定同一时间只能有一个 active item。 + +### 16.5 `skill_knowledge_artifacts` + +保存本地到 WeKnora 的远端 artifact 映射。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `binding_id` | 绑定,可为空;规范实体/关系聚合页可只属于知识库。 | +| `snapshot_id` | 来源快照。 | +| `scope` | `binding` / `kb_aggregate`。 | +| `kind` | `raw_source` / `skill_wiki` / `entity_wiki` / `relation_wiki` / `graph_projection`。 | +| `stable_key` | 本地稳定 key。 | +| `remote_kind` | `file` / `manual_document` / `wiki_page`。 | +| `remote_id` | 远端 ID。后端保存完整映射,前端可按权限展示和复制。 | +| `wiki_slug` | Wiki 页面 slug。 | +| `content_hash` | 写入内容 hash。 | +| `version` | 版本号。 | +| `status` | `staged` / `active` / `retired` / `failed` / `delete_failed`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.6 `skill_knowledge_entities` + +规范实体表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `description` | 聚合描述。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, entity_type, normalized_name) +``` + +### 16.7 `skill_knowledge_entity_contributions` + +规范实体的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_entity_id` | 规范实体 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_entity_id` | IR 中的实体 ID。 | +| `attributes_json` | 该技能贡献的属性。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +### 16.8 `skill_knowledge_relations` + +规范关系表。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `target_type` | 目标类型。 | +| `target_mode` | 目标模式。 | +| `target_id` | 目标 ID。 | +| `source_entity_id` | 源规范实体。 | +| `predicate` | 规范谓词。 | +| `target_entity_id` | 目标规范实体。 | +| `description` | 聚合说明。 | +| `stable_slug` | Wiki slug。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +约束: + +```text +unique(target_type, target_id, source_entity_id, predicate, target_entity_id) +``` + +### 16.9 `skill_knowledge_relation_contributions` + +规范关系的技能贡献。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `canonical_relation_id` | 规范关系 ID。 | +| `binding_id` | 绑定。 | +| `snapshot_id` | 快照。 | +| `local_relation_id` | IR 中的关系 ID。 | +| `relation_source` | `deterministic` / `explicit_text` / `llm_inferred`。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | +| `status` | `active` / `retired`。 | +| `created_at` / `updated_at` | 时间戳。 | + +## 17. 后台任务设计 + +参考现有 deep research job 的持久化后台任务模式: + +- 任务入库后返回 `202 Accepted`。 +- 执行器通过 DB 原子条件 claim item。 +- 每个 item 有 `lease_owner`、`lease_until`。 +- 执行中定期 heartbeat。 +- 服务重启后可恢复过期 lease。 +- cancel 使用 `cancel_requested`,任务在阶段边界检查并安全停止。 + +并发建议: + +- 同时处理技能数:2 到 4。 +- 同时调用 WeKnora 写入:2 到 3。 +- 同时 LLM 抽取:按系统现有限流,建议默认 2。 + +200 个技能批量时: + +- 父 job 只保存聚合进度,不串行阻塞 HTTP 请求。 +- 每个技能 item 独立失败、独立重试。 +- 前端按 job polling,不为每个技能单独发 N 个请求。 +- 获取技能列表时批量查 binding summary,避免 N+1。 +- 快照缓存可复用,跨知识库只重新投影,不重复解析同一技能内容。 + +## 18. 后端 API 设计 + +新增 router: + +```text +/api/skill-knowledge +``` + +所有写接口仅管理员可用。 + +### 18.1 获取技能绑定摘要 + +```http +GET /api/skill-knowledge/bindings?skill_names=a,b,c +``` + +返回: + +```json +{ + "items": [ + { + "skill_name": "example-skill", + "knowledge_bases": [ + { + "binding_id": "bind_001", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "remote_business_key": "weknora:kb:remote-kb-id", + "status": "synced", + "last_synced_at": "2026-08-31T10:00:00Z", + "source_digest": "sha256:...", + "synced_digest": "sha256:...", + "stale": false + } + ] + } + ] +} +``` + +### 18.2 创建归纳任务 + +```http +POST /api/skill-knowledge/sync-jobs +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "targets": [ + { + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id" + }, + { + "target_type": "assistant", + "target_mode": "wiki", + "target_id": "assistant-global-base" + } + ], + "force": false +} +``` + +行为: + +- 校验管理员。 +- 校验技能存在。 +- 校验目标知识库存在,并解析目标 adapter。 +- 对只读目标创建 sidecar binding,不强行写远端。 +- 对每个 `skill x target` upsert binding。 +- 创建父 job 和 item。 +- 如果同一 binding 已有 active item,返回当前 item,不重复创建。 +- 返回 `202`。 + +返回: + +```json +{ + "job_id": "job_001", + "status": "queued", + "skill_count": 2, + "target_count": 2, + "total_count": 4, + "items": [ + { + "item_id": "item_001", + "skill_name": "skill-a", + "target_type": "weknora", + "target_mode": "wiki", + "target_id": "local-kb-id", + "binding_id": "bind_001", + "status": "queued" + } + ] +} +``` + +### 18.3 查询任务 + +```http +GET /api/skill-knowledge/sync-jobs/{job_id} +``` + +返回父 job、item 列表、每个 item 当前阶段、错误摘要、绑定结果。 + +### 18.4 取消任务 + +```http +POST /api/skill-knowledge/sync-jobs/{job_id}/cancel +``` + +设置 `cancel_requested = true`。已激活成功的 item 不回滚;未完成 item 在阶段边界停止。 + +### 18.5 重试 item + +```http +POST /api/skill-knowledge/sync-items/{item_id}/retry +``` + +只允许失败或部分失败 item。重试复用原 binding。 + +### 18.6 对单个绑定重新归纳 + +```http +POST /api/skill-knowledge/bindings/{binding_id}/resync +``` + +请求: + +```json +{ + "force": true +} +``` + +行为:创建一个只包含该 binding 的新 job。 + +### 18.7 解除关联 + +建议使用显式 POST,避免 DELETE body 兼容性问题: + +```http +POST /api/skill-knowledge/bindings/{binding_id}/detach +``` + +请求: + +```json +{ + "remote_content_action": "keep" +} +``` + +或: + +```json +{ + "remote_content_action": "delete" +} +``` + +返回解除结果、清理状态和仍被共享的实体/关系数量。 + +### 18.8 知识库反查技能 + +```http +GET /api/skill-knowledge/knowledge-bases/{knowledge_base_id}/skills +``` + +用于知识库详情页展示“该知识库由哪些技能归纳而来”。 + +### 18.9 可选:源状态扫描 + +```http +POST /api/skill-knowledge/source-status +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "knowledge_base_id": "local-kb-id" +} +``` + +只做本地快速 fingerprint 比对,标记是否可能 stale,不写 WeKnora。 + +### 18.10 审核队列 + +```http +GET /api/skill-knowledge/reviews?job_id=xxx +POST /api/skill-knowledge/reviews/{review_id}/approve +POST /api/skill-knowledge/reviews/{review_id}/reject +PUT /api/skill-knowledge/reviews/{review_id} +POST /api/skill-knowledge/reviews/bulk-approve +POST /api/skill-knowledge/reviews/bulk-reject +``` + +审核通过后 item 才能进入写入阶段。审核模式为 `off` 时,接口仍保留预览和审计能力。 + +### 18.11 自动同步配置 + +```http +GET /api/skill-knowledge/auto-sync-settings +PUT /api/skill-knowledge/auto-sync-settings +POST /api/skill-knowledge/bindings/{binding_id}/enable-auto-sync +POST /api/skill-knowledge/bindings/{binding_id}/disable-auto-sync +``` + +自动同步设置只允许管理员修改。 + +## 19. 前端交互设计 + +### 19.1 技能管理页 + +在 `AdminSkillsPage.tsx` 的“技能列表”页签增加: + +- 技能行 checkbox。 +- 顶部批量操作按钮:“归纳到知识库”。 +- 支持全选当前页、清空选择。 +- 选择状态跨前端分页保留。 +- 技能较多时保持现有搜索/过滤能力。 + +点击“归纳到知识库”打开弹窗: + +- 显示已选技能数量。 +- 多选目标知识库。 +- 按类型分组展示:WeKnora Wiki、WeKnora 非 Wiki、FAQ、系统会话存档、只读 sidecar、助手知识库。 +- 只读目标要展示“本地归纳,暂不写远端”的状态说明。 +- 助手知识库作为目标时,提示“直接写入知识梳理底库,不经过 WeKnora”。 +- 二次确认文案说明:“将生成原始资料、Wiki 页面和关系内容;抽取完成后进入实体/关系预览审核。” + +提交后: + +- 创建后台 job。 +- 跳转或打开“归纳任务”抽屉/页签。 +- 展示总进度、成功、失败、部分失败。 +- 任务运行中每 2-3 秒 polling。 +- item 到达 `awaiting_review` 时,显示“待审核”并提供审核入口。 + +### 19.1.1 实体/关系审核页 + +审核页按 job 聚合展示: + +- 左侧筛选:技能、目标知识库、类型、置信度、来源。 +- 中间列表:实体、关系、实体对齐候选、关系合并候选、Wiki 草稿。 +- 右侧详情:原始值、模型解释、证据、目标写入位置。 + +管理员可以: + +- 单条批准、拒绝、编辑。 +- 批量批准高置信结果。 +- 批量拒绝低置信结果。 +- 修改实体类型、名称、别名。 +- 修改关系 predicate、说明、confidence。 +- 查看远端 ID 或 sidecar ID。 + +审核完成后,任务继续写入目标知识库。 + +### 19.2 技能行提示 + +`SkillRow` 增加归纳状态区域: + +- 未归纳:不显示或显示弱提示“未归纳”。 +- 已归纳:显示 `已归纳至 N 个知识库`。 +- 展开或悬浮可看到知识库 chips: + - 知识库名称 + - 状态 + - 最近归纳时间 + - stale 标识 + +每个绑定提供动作: + +- 查看知识库。 +- 重新归纳。 +- 解除关联。 +- 失败时重试。 + +状态建议: + +| 状态 | UI 文案 | +| --- | --- | +| `synced` | 已归纳 | +| `stale` | 待重新归纳 | +| `running` | 归纳中 | +| `partial_failed` | 部分完成 | +| `failed` | 失败 | +| `detached_keep` | 已解除,内容保留 | + +### 19.3 解除关联弹窗 + +弹窗必须让管理员明确选择: + +- 保留知识库内容。 +- 删除该技能贡献的内容。 + +弹窗文案应提醒: + +- 删除只删除该技能贡献,不删除其他技能共享的实体与关系。 +- 若删除远端内容失败,可稍后重试清理。 + +### 19.4 归纳任务页签 + +建议在管理员技能管理页新增第三个页签: + +```text +技能列表 | 技能地址管理 | 归纳任务 +``` + +任务列表显示: + +- 创建时间。 +- 目标知识库。 +- 操作人。 +- 总技能数。 +- 成功 / 部分失败 / 失败。 +- 状态。 +- 操作:查看详情、取消、重试失败项。 + +详情显示 item: + +- 技能名。 +- 目标知识库。 +- 目标类型。 +- 当前阶段。 +- 进度。 +- 错误摘要。 +- 绑定状态。 + +### 19.6 自动同步设置 + +管理员可在技能归纳设置中配置: + +- 是否开启文件监听。 +- 是否开启定时同步。 +- 同步周期。 +- 自动写远端开关。 +- 审核模式。 +- 单个 binding 是否参与自动同步。 + +### 19.5 知识库详情反向展示 + +如果现有 WeKnora / LLM Wiki 页面有知识库详情入口,增加“来源技能”区域: + +- 技能名。 +- 归纳状态。 +- 最近归纳时间。 +- 查看技能。 +- 重新归纳。 + +## 20. stale 检测与自动同步 + +系统既要支持手动重新归纳,也要支持自动同步。stale 检测负责判断“源知识文件是否变化”;自动同步负责在配置允许时创建后台任务并远程写入。 + +本地标识 stale: + +- 技能编辑、启停、删除等已知写操作完成后,查找相关 binding 并标记 `stale`。 +- 技能列表加载时可做轻量 fingerprint:路径、大小、mtime。 +- 真正归纳时计算完整 SHA digest。 +- 如果归纳过程中源文件变化,最终激活前再次校验 expected digest;不一致则失败并标记 stale,避免一个版本混入两次源内容。 +- 代码文件和脚本文件不参与 fingerprint,也不触发 stale。 + +自动同步: + +- 当 `watch_enabled` 开启时,知识文件变化后自动创建相关 binding 的归纳任务。 +- 当 `schedule_enabled` 开启时,scheduler 按周期扫描并归纳 stale binding。 +- 当技能保存、导入、删除或启停时,可触发相关 binding 的自动任务。 +- 自动任务必须遵循审核模式;如果需要审核,任务停在 `awaiting_review`。 +- 自动任务必须遵循 staged 激活;失败不覆盖旧版本。 + +## 21. 权限与安全 + +权限: + +- 所有归纳、重归纳、解除关联、取消、重试接口仅管理员。 +- 读接口如果包含后台任务错误或源文件路径,也建议仅管理员。 + +文件安全: + +- 技能根必须限制在 `skills/public` 或 `skills/custom`。 +- 禁止路径穿越。 +- 默认不跟随 symlink;如果要支持 symlink,必须确认 resolved path 仍在技能根内。 +- 压缩包解压必须限制总大小、文件数量、单文件大小、目录层级。 +- 不执行技能代码。 +- 不读取或上传密钥文件。 +- 日志和错误信息不得包含文件全文、token、secret。 + +远端安全: + +- 不新增外部 URL 抓取能力。 +- 如果技能内容中包含 URL,只作为文本关系或知识内容处理,不主动访问。 +- WeKnora 调用失败时,记录错误摘要和 request id,不记录认证头。 + +## 22. LLM 抽取设计 + +抽取可分两级: + +- 小技能:一次性把文本摘要和结构信息送入模型。 +- 大技能:先按文件或章节生成局部摘要与候选实体关系,再合并。 + +提示词需要明确: + +- 只基于提供的内容抽取,不凭空编造。 +- 允许推断,但推断必须有 evidence。 +- 输出必须符合 JSON schema。 +- 不输出敏感信息。 +- 关系必须有 confidence。 +- 低置信关系要标记,不进入规范关系。 + +建议 schema: + +```json +{ + "summary": { + "purpose": "技能用途", + "inputs": ["输入"], + "outputs": ["输出"], + "usage": "使用方式", + "risks": ["风险"] + }, + "entities": [ + { + "id": "ent_001", + "type": "skill|tool|api|file|config|dataset|concept|workflow|external_system|output", + "name": "实体名", + "aliases": [], + "description": "描述", + "attributes": {}, + "evidence": [ + { + "file_path": "SKILL.md", + "line_start": 1, + "line_end": 3, + "quote": "短证据" + } + ], + "confidence": 0.9 + } + ], + "relations": [ + { + "id": "rel_001", + "source_entity_id": "ent_001", + "predicate": "uses", + "target_entity_id": "ent_002", + "description": "关系描述", + "relation_source": "explicit_text|llm_inferred", + "evidence": [], + "confidence": 0.86 + } + ] +} +``` + +模型输出后必须做程序校验: + +- JSON schema 校验。 +- entity id 存在性校验。 +- relation source/target 必须存在。 +- evidence path 必须来自本次 manifest。 +- confidence 必须在 0 到 1。 +- 敏感内容二次扫描。 + +## 23. 后端实现步骤 + +建议按以下顺序实现。 + +### 23.1 持久化层 + +新增 `deerflow.persistence.skill_knowledge`: + +- SQL model。 +- memory store,用于测试或无 DB 环境。 +- repository API。 +- migration 或 `create_all` 接入。 + +Repository API 建议: + +```python +class SkillKnowledgeStore: + async def upsert_binding(...) + async def list_bindings(...) + async def create_job(...) + async def create_items(...) + async def claim_next_item(...) + async def heartbeat_item(...) + async def complete_item(...) + async def fail_item(...) + async def create_or_get_snapshot(...) + async def stage_artifacts(...) + async def activate_snapshot(...) + async def detach_binding(...) +``` + +### 23.2 扫描与解析 + +新增: + +```text +deerflow/skill_knowledge/scanner.py +deerflow/skill_knowledge/parsers.py +deerflow/skill_knowledge/safety.py +deerflow/skill_knowledge/digest.py +``` + +输出 manifest 和初始文本块。 + +### 23.3 抽取与归纳 + +新增: + +```text +deerflow/skill_knowledge/extractor.py +deerflow/skill_knowledge/wiki_builder.py +``` + +职责: + +- 构造 prompt。 +- 调用项目已有模型封装。 +- 解析 JSON。 +- 校验 schema。 +- 构建技能 Wiki 页面内容。 + +### 23.4 实体关系合并 + +新增: + +```text +deerflow/skill_knowledge/merge.py +``` + +职责: + +- 名称归一化。 +- 实体 upsert。 +- 关系 upsert。 +- 贡献增删。 +- 共享实体/关系重建计划。 + +### 23.5 WeKnora 投影 + +新增: + +```text +deerflow/skill_knowledge/projector.py +deerflow/skill_knowledge/target_adapters.py +deerflow/skill_knowledge/graph_writer.py +``` + +职责: + +- 根据 IR 和聚合结果生成 artifact plan。 +- 按目标知识库类型选择 adapter。 +- 调用 WeKnora client 上传原文、FAQ、会话归档或 Wiki 页面。 +- 为只读目标写本地 sidecar artifact。 +- 上传 graph projection docs。 +- 在配置允许时通过受控 graph writer 写 Neo4j。 +- 校验远端状态。 + +### 23.6 Gateway + +新增: + +```text +app/gateway/routers/skill_knowledge.py +app/gateway/skill_knowledge_job_dispatcher.py +app/gateway/skill_knowledge_job_executor.py +app/gateway/skill_knowledge_watcher.py +app/gateway/skill_knowledge_scheduler.py +``` + +在 app 初始化中注册: + +- store。 +- dispatcher。 +- watcher。 +- scheduler。 +- router。 + +### 23.7 前端 + +新增: + +```text +frontend-web/src/strategy-components/api/skill-knowledge.ts +frontend-web/src/components/workspace/skills/skill-knowledge-sync-dialog.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-bindings.tsx +frontend-web/src/components/workspace/skills/skill-knowledge-jobs-panel.tsx +``` + +修改: + +- `AdminSkillsPage.tsx`:技能列表批量选择、归纳弹窗、归纳任务页签。 +- `skill-settings-page.tsx`:`SkillRow` 展示归纳知识库 chips 和操作。 +- 知识库详情页:展示来源技能。 + +### 23.8 助手知识库 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/assistant_knowledge/ +offline-backend-20260512/backend/packages/harness/deerflow/persistence/assistant_knowledge/ +offline-backend-20260512/backend/app/gateway/routers/assistant_knowledge.py +offline-backend-20260512/backend/app/gateway/assistant_knowledge_import_executor.py +frontend-web/src/strategy-components/api/assistant-knowledge.ts +frontend-web/src/pages/AssistantKnowledgePage.tsx +``` + +职责: + +- 初始化唯一全局助手知识库“知识梳理”。 +- 从 WeKnora 导出包导入 Wiki、文档、chunk、实体、关系、图谱。 +- 做全局实体对齐和关系合并。 +- 生成助手库 Wiki 页面。 +- 建立搜索索引。 +- 支持普通用户只读、管理员导入和维护。 +- 支持知识库产品版本 `v1` / `v2` 切换。 + +### 23.9 审核队列 + +新增: + +```text +offline-backend-20260512/backend/packages/harness/deerflow/persistence/skill_knowledge/review_model.py +frontend-web/src/components/workspace/skills/skill-knowledge-review-panel.tsx +``` + +职责: + +- 保存实体、关系、对齐候选、Wiki 草稿的审核项。 +- 支持批准、拒绝、编辑、批量处理。 +- 审核完成后唤醒对应 item 继续写入。 +- 保留完整审计。 + +## 24. 测试计划 + +后端测试: + +| 测试 | 覆盖点 | +| --- | --- | +| `test_skill_knowledge_scanner.py` | 文件扫描、排除目录、敏感文件阻断、压缩包安全。 | +| `test_skill_knowledge_extractor.py` | LLM 输出 schema 校验、低置信关系过滤、证据路径校验。 | +| `test_skill_knowledge_entity_merge.py` | 同知识库实体合并、跨知识库不合并、属性冲突 provenance。 | +| `test_skill_knowledge_repository.py` | 绑定唯一、多对多、状态机、detach keep/delete。 | +| `test_skill_knowledge_jobs.py` | job/item claim、lease、重试、取消、崩溃恢复、200 item 聚合进度。 | +| `test_skill_knowledge_weknora_projection.py` | Wiki slug upsert、artifact staged/active、GraphRAG 不可用 partial_failed。 | +| `test_skill_knowledge_api.py` | 管理员鉴权、目标知识库校验、创建任务、查询任务、解除关联。 | +| `test_skill_knowledge_target_adapters.py` | Wiki、非 Wiki、FAQ、会话存档、只读 sidecar、助手库目标写入。 | +| `test_skill_knowledge_review.py` | 实体/关系逐条审核、编辑、批量批准、审核阻塞写入。 | +| `test_skill_knowledge_auto_sync.py` | 文件监听、定时同步、代码文件变化不触发、staged 自动写入。 | +| `test_skill_knowledge_graph_writer.py` | Neo4j 参数化写入、事务、审计、回滚计划、禁用任意 Cypher。 | +| `test_assistant_knowledge_repository.py` | 唯一全局底库、来源、导入任务、实体关系贡献。 | +| `test_assistant_knowledge_import.py` | WeKnora 导出包导入、实体对齐、关系合并、搜索索引。 | +| `test_assistant_knowledge_api.py` | 普通用户只读、管理员写入、未配置 WeKnora 场景。 | + +前端测试: + +- 管理员技能页可以多选技能并打开知识库选择弹窗。 +- 知识库选择弹窗支持多选目标,包含 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- 创建 job 后显示进度并 polling。 +- 抽取后进入实体/关系审核页,审核通过后继续写入。 +- 技能行展示已归纳知识库 chips。 +- 重归纳按钮调用正确接口。 +- 解除关联弹窗必须选择 keep/delete。 +- 200 个技能绑定摘要使用批量接口,不触发 N+1。 +- 未配置 WeKnora 时仍能展示助手知识库初始化入口。 +- 普通知识库详情页可触发导入到助手知识库。 +- 助手知识库搜索、Wiki 页面、来源列表按权限展示。 + +建议命令: + +```bash +cd offline-backend-20260512/backend +PYTHONPATH=. uv run pytest tests/test_skill_knowledge_*.py -v +PYTHONPATH=. uv run pytest tests/test_harness_boundary.py -v +``` + +```bash +cd frontend-web +pnpm typecheck +pnpm test -- skill-knowledge +``` + +## 25. 验收场景 + +必须通过: + +- 管理员选择 3 个技能归纳到 2 个目标知识库,生成 1 个 job、6 个 item、6 个 binding。 +- 同一个技能随后归纳到另一个知识库,新增第二个 binding,技能行显示已归纳至 2 个知识库。 +- 技能可以直接归纳到助手知识库,并生成实体对齐、Wiki 页面版本和搜索索引。 +- 同一个技能同一个 digest 重复归纳,不产生重复 Wiki 页面或重复实体关系。 +- 两个技能在同一知识库中抽取出同名同类型实体,只生成一个规范实体页,并列出两个贡献技能。 +- 解除其中一个技能并选择删除内容后,共享实体仍保留另一个技能贡献。 +- 关系必须带 evidence 和 confidence;低于 0.75 的推断关系不进入规范关系。 +- 重归纳成功后,新版本替换旧版本;旧 artifact 进入 retired。 +- 重归纳失败时,旧版本继续显示为当前版本。 +- 解除关联选择保留内容后,远端 Wiki 和文档保留,但 binding 不再参与后续重归纳。 +- 非管理员访问写接口返回 403。 +- 非 Wiki、FAQ、系统会话存档知识库可作为目标,并通过对应 adapter 写入。 +- 只读知识库可作为目标,结果写入本地 sidecar,远端状态显示 `pending_remote_write`。 +- 直接 Neo4j 写入只在配置开启时执行,且只使用参数化 graph writer。 +- 自动文件监听和定时同步可以创建后台任务,代码文件变化不会触发同步。 +- 前端可逐条审核实体/关系,也可按策略批量处理。 +- 前端展示 WeKnora 远端 ID,并可复制业务 key。 +- WeKnora GraphRAG 不可用时,原始资料和 Wiki 成功,状态为 `partial_failed`,可重试图谱投影。 +- 200 个技能批量归纳时,页面显示整体进度,失败技能可单独重试,后端没有 N+1 查询。 +- 技能中出现 `.env` 或私钥时,该文件不上传、不进 prompt,页面显示安全跳过。 +- 技能中的 `.py`、`.ts`、`.sh` 等代码文件不读取、不上传、不摘要,代码文件变化不触发知识 stale。 +- 未配置 WeKnora 时,助手知识库仍可初始化和查看。 +- 管理员可从普通知识库触发导入到助手知识库。 +- 管理员可向助手知识库导入本地文件,安全扫描通过后生成 Wiki 页面版本。 +- WeKnora 导出包中的 Wiki、文档、chunk、实体、关系、图谱都能进入助手底库。 +- 助手底库把多个来源中的同一人或同一对象合并为一个实体 Wiki 页。 +- 助手 Wiki 支持历史版本、对比和回滚。 +- 助手知识库普通用户只读,管理员可导入、重导、删除来源。 + +## 26. 分阶段交付 + +第一阶段:数据模型与 API 骨架 + +- 建表和 store。 +- 绑定列表、创建 job、查询 job、detach API。 +- 管理员鉴权。 +- 前端技能行能展示空绑定状态。 + +第二阶段:扫描、快照与后台任务 + +- 技能目录扫描。 +- 文件 digest 和 manifest。 +- job/item 执行器、lease、heartbeat、失败重试。 +- 200 技能批量进度。 + +第三阶段:Wiki 归纳与原始资料上传 + +- LLM 摘要和 Wiki 页面生成。 +- WeKnora 原文上传。 +- Wiki page upsert。 +- staged/active 替换。 + +第四阶段:实体关系合并与关系投影 + +- 规范实体和关系表。 +- contribution 机制。 +- 关系 Wiki 页面。 +- graph projection docs。 +- GraphRAG 能力探测和 partial_failed。 + +第五阶段:完整前端体验 + +- 多选技能归纳弹窗。 +- 归纳任务页签。 +- 技能行 chips、详情、重归纳、解除关联。 +- 知识库详情来源技能反查。 + +第六阶段:安全、测试、文档收口 + +- 敏感文件阻断。 +- 压缩包安全。 +- 崩溃恢复。 +- README / CLAUDE 更新。 +- 后端和前端测试。 + +第七阶段:助手知识库与产品版本 + +- 新增唯一全局助手知识库“知识梳理”。 +- 新增 WeKnora 导出到助手库导入任务。 +- 新增技能直接导入助手知识库。 +- 新增管理员本地文件导入助手知识库。 +- 新增助手库实体对齐、关系合并和 Wiki 展示。 +- 新增助手库搜索。 +- 新增助手库 Wiki 页面版本管理、对比和回滚。 +- 新增知识库产品版本 `v1` / `v2` 切换。 +- 处理未配置 WeKnora 时的助手库默认展示。 + +第八阶段:多目标、审核、自动同步和底层图写入 + +- 支持一次选择多个目标知识库。 +- 支持非 Wiki、FAQ、系统会话存档和只读目标 adapter。 +- 支持实体/关系逐条审核。 +- 支持文件监听、定时同步、技能变更自动远程写入。 +- 支持受控 Neo4j direct writer。 +- 支持远端 WeKnora ID 作为业务 key 暴露给前端。 + +## 27. 给实现大模型的硬性指令 + +实现时必须遵守: + +- 先完整阅读根目录 `AGENTS.md`、后端 `AGENTS.md`、后端 `CLAUDE.md`。 +- 不要回滚用户已有改动。 +- 支持直接写 WeKnora 的 Neo4j,但必须通过受控 graph writer、显式配置、参数化模板、事务和审计。 +- 支持把远端 WeKnora ID 作为前端业务 key 暴露,但本地仍保留 surrogate id。 +- 不要让 `deerflow.*` import `app.*`。 +- 不要执行技能里的代码。 +- 不要读取、上传、摘要或展示技能里的代码文件、脚本文件和代码块内容。 +- 不要上传或提示词输入密钥文件。 +- 技能可以直接归纳到助手知识库,必须走同样的安全扫描、审核和实体对齐。 +- 不要创建多个助手知识库;助手知识库只有一个全局“知识梳理”底库。 +- 自动 watcher、定时同步和技能变更远程写入必须使用 staged 激活、去重、审核和失败保留旧版本。 +- 重归纳必须 staged 写入,成功后再激活。 +- 删除绑定贡献时必须按 contribution refcount 处理共享实体和关系。 +- 助手知识库需要 Wiki 页面版本管理、版本对比、回滚和导入任务审计。 +- 直接写 Neo4j 必须通过受控 graph writer 和参数化模板,不允许执行 LLM 生成的任意 Cypher。 +- 远端 WeKnora ID 可以作为前端业务 key 暴露,但本地仍要保留 surrogate id 和 mapping。 +- 后端代码变更后按仓库要求更新相关 README / CLAUDE。 +- 涉及文件编辑使用 `apply_patch` 或项目正常格式化工具,不做无关重构。 + +## 28. 实现任务清单 + +- [ ] 新增 `skill_knowledge` 持久化模型和 store。 +- [ ] 新增绑定、job、item、artifact、entity、relation、contribution 表。 +- [ ] 新增管理员 router `/api/skill-knowledge`。 +- [ ] 接入知识库能力校验,支持 Wiki、非 Wiki、FAQ、会话存档、只读 sidecar 和助手知识库。 +- [ ] 支持一次选择多个目标知识库,并按 `技能 x 目标` 生成 item。 +- [ ] 新增技能扫描器,支持全部非代码知识文件、安全排除和 digest。 +- [ ] 技能扫描器完全排除代码文件、脚本文件和 Markdown 代码块内容。 +- [ ] 新增 snapshot 缓存。 +- [ ] 新增 LLM 抽取器,输出 schema 校验。 +- [ ] 新增 Wiki 页面生成器。 +- [ ] 新增实体归一化与合并逻辑。 +- [ ] 新增关系归一化与合并逻辑。 +- [ ] 新增 WeKnora artifact projector。 +- [ ] 新增 target adapters。 +- [ ] 新增 graph projection 文档生成和上传。 +- [ ] 新增受控 Neo4j direct graph writer。 +- [ ] 新增实体/关系审核队列。 +- [ ] 新增后台 job dispatcher / executor。 +- [ ] 新增文件 watcher、定时同步 scheduler、技能变更自动写入触发。 +- [ ] 实现 resync 成功替换、失败保留旧版本。 +- [ ] 实现 detach keep/delete。 +- [ ] 技能变更后标记 binding stale。 +- [ ] 前端新增 `skill-knowledge.ts` API。 +- [ ] 技能管理页新增多选归纳入口。 +- [ ] 新增选择知识库弹窗。 +- [ ] 新增归纳任务页签。 +- [ ] 技能行展示已归纳知识库状态。 +- [ ] 实现重归纳、重试、取消、解除关联 UI。 +- [ ] 知识库详情展示来源技能。 +- [ ] 新增唯一全局助手知识库“知识梳理”。 +- [ ] 新增助手知识库持久化模型、来源、导入任务、Wiki、实体、关系、贡献表。 +- [ ] 新增 WeKnora 导出到助手知识库任务。 +- [ ] 支持技能直接导入助手知识库。 +- [ ] 支持管理员本地文件导入助手知识库。 +- [ ] 导出范围覆盖 Wiki、文档、chunk、实体、关系、图谱。 +- [ ] 助手知识库实现全局实体对齐和关系合并。 +- [ ] 助手知识库生成 Wiki 页面并支持搜索。 +- [ ] 助手知识库支持 Wiki 页面版本管理、对比、发布和回滚。 +- [ ] 新增知识库产品版本 `v1` / `v2` 设置,默认 `v1`。 +- [ ] 未配置 WeKnora 时仍展示助手知识库创建或初始化入口。 +- [ ] 助手知识库普通用户只读,管理员可导入、重导、删除。 +- [ ] 后端测试覆盖核心状态机和安全边界。 +- [ ] 前端测试覆盖主要交互。 +- [ ] 更新后端 README / CLAUDE 与必要前端文档。 + +## 29. 风险与处理 + +| 风险 | 处理 | +| --- | --- | +| WeKnora 图谱 API 在部署版本不稳定 | 以 Swagger 能力探测为准;Wiki 关系页和 graph projection 文档作为稳定方案。 | +| LLM 推断关系误判 | 强制 evidence、confidence 阈值、低置信不进入规范关系。 | +| 200 技能批量耗时长 | 后台 job、item 并发、snapshot 缓存、前端批量查询。 | +| 重归纳中断导致新旧混杂 | staged artifact + 本地事务激活 + 失败保留旧版本。 | +| 删除共享实体误删其他技能知识 | contribution refcount,删除只移除该 binding 贡献。 | +| 敏感信息进入知识库 | 文件级安全阻断、prompt 前二次扫描、日志脱敏。 | +| 技能目录处理阻塞 Gateway | 文件 IO 和转换统一 `asyncio.to_thread`。 | +| 技能源文件归纳中变化 | expected digest 校验,不一致则失败并标记 stale。 | + +## 30. 新增:普通知识库与助手知识库双体系 + +本节是基于新需求的补充设计。后续实现以本节为准;如果本节与前文旧的 WeKnora-only 表述冲突,优先采用本节。 + +### 30.1 产品形态 + +系统需要区分两类知识库: + +| 类型 | 存储 | 用途 | 可见性 | 写入来源 | +| --- | --- | --- | --- | --- | +| 普通知识库 | WeKnora | 文档向量化、Wiki、检索、图谱、技能归纳目标 | 按现有 WeKnora 逻辑 | 技能归纳、现有 WeKnora 导入 | +| 助手知识库 | DeerFlow 自研库 | 全局知识梳理、实体对齐、Wiki 展示、搜索、版本管理 | 所有人可见,普通用户只读 | WeKnora 导出、WeKnora 自动沉淀、技能直接归纳、管理员本地文件导入 | + +产品版本开关: + +- `v1`:默认值,优先展示现有普通知识库体验。 +- `v2`:优先展示助手知识库体验。 +- 管理员可在系统设置中切换版本。 +- 如果未配置 WeKnora,即使当前版本默认是 `v1`,页面也应展示助手知识库创建/初始化入口;WeKnora 相关能力隐藏或置灰,并提示“未配置普通知识库服务”。 + +建议新增系统设置项: + +```json +{ + "knowledge_base_product_version": "v1" +} +``` + +### 30.2 助手知识库定位 + +助手知识库只有一个全局底库,建议默认名: + +```text +知识梳理 +``` + +它不是 WeKnora 的另一个知识库,也不向 WeKnora 写数据。它是 DeerFlow 自己维护的结构化知识底库。 + +助手知识库对用户展示: + +- Wiki 页面列表。 +- Wiki 页面详情。 +- Wiki 页面版本、对比和回滚。 +- 实体页。 +- 关系页。 +- 搜索。 +- 数据导入记录。 + +助手知识库内部可以存实体、关系、贡献、来源、搜索索引,但前端主体验以 Wiki 为中心,不做复杂图数据库管理页。 + +权限: + +- 普通用户:可查看、搜索。 +- 管理员:可初始化助手知识库、从 WeKnora 导入、重新导入、删除导入来源、编辑或删除 Wiki 页面。 + +### 30.3 数据来源 + +助手知识库的数据来源包括 WeKnora、技能归纳和管理员本地文件。 + +支持四种触发方式: + +| 方式 | 说明 | +| --- | --- | +| 自动沉淀 | WeKnora 普通知识库完成文档向量化、Wiki 生成和图谱抽取后,默认创建一个导入任务,把处理结果沉淀到助手知识库。 | +| 手动导入 | 管理员在普通知识库页面点击“导入到助手知识库”或“导出到知识梳理”,本质仍是导出 WeKnora 数据后导入助手库。 | +| 技能直接归纳 | 管理员在技能管理页选择助手知识库作为目标,系统直接写入自研助手底库。 | +| 本地文件导入 | 管理员在助手知识库页面上传本地文件,系统安全扫描后抽取实体、关系和 Wiki 页面。 | + +限制: + +- 普通用户不能写入助手知识库。 +- 本地文件导入必须走敏感信息扫描、文件类型白名单、大小限制和导入审计。 +- 技能直接归纳仍然完全排除代码文件、脚本文件和 Markdown 代码块内容。 + +### 30.4 WeKnora 导出范围 + +从 WeKnora 导入助手知识库时,需要尽可能导出全部已处理数据: + +- 知识库元数据。 +- Wiki 页面。 +- Wiki 页面之间的链接。 +- 已上传文档信息。 +- 已向量化完成的 chunk 文本、chunk metadata、来源文档映射。 +- WeKnora 可读取的实体。 +- WeKnora 可读取的关系。 +- WeKnora 可读取的图谱节点和边。 +- 技能归纳产生的 source metadata,例如 `skill_name`、`binding_id`、`snapshot_id`。 + +说明: + +- 助手知识库不强依赖复用 WeKnora embedding 向量。若 WeKnora 不暴露 embedding 原始向量,导入 chunk 文本和 metadata 即可。 +- 如果 WeKnora 不暴露稳定的图谱导出 API,则使用现有 Wiki graph、关系投影文档、Wiki 关系页和可查询接口重建实体关系。 +- 导入过程要记录能力缺失:例如“已导入 Wiki 与文档,图谱 API 不可用,关系由 Wiki 关系页重建”。 + +导出包建议: + +```json +{ + "export_id": "wk_export_001", + "source": { + "type": "weknora", + "knowledge_base_id": "local-kb-id", + "knowledge_base_name": "作战知识库", + "exported_at": "2026-09-01T10:00:00Z" + }, + "wiki_pages": [], + "documents": [], + "chunks": [], + "entities": [], + "relations": [], + "graph": { + "nodes": [], + "edges": [] + } +} +``` + +### 30.5 助手库实体对齐 + +助手知识库的核心价值是把来自不同 WeKnora 普通知识库、不同技能来源、不同 Wiki 页中的同一实体合并。 + +实体对齐范围是全局助手库: + +```text +assistant_base_id + entity_type + normalized_name +``` + +如果存在更强 ID,则优先: + +```text +assistant_base_id + entity_type + external_id_type + external_id +``` + +对齐规则: + +- 确定性同一:证件号、统一社会信用代码、装备编号、系统唯一 ID 等完全一致,直接合并。 +- 高置信名称同一:同类型实体 normalized name 一致,合并。 +- 模型推断同一:允许模型判断“张三”“张三同志”“Zhang San”是否同一人,但必须给出 evidence 和 confidence。 +- `confidence >= 0.90` 的模型对齐可以自动合并。 +- `0.70 <= confidence < 0.90` 的模型对齐不自动合并,可记录为候选别名或候选关联。 +- `confidence < 0.70` 丢弃。 + +合并后必须保留来源贡献: + +- 来自哪个 WeKnora 知识库。 +- 来自哪个 Wiki 页面或 chunk。 +- 来自哪个技能 binding。 +- 证据文本。 +- 导入任务 ID。 + +属性冲突不覆盖: + +- 不同来源给出不同姓名、职务、时间、地点、数值时,按来源分组展示。 +- Wiki 页面中展示“多来源信息”而不是最后写入覆盖。 + +### 30.6 助手库关系对齐 + +关系 key: + +```text +assistant_base_id + source_assistant_entity_id + normalized_predicate + target_assistant_entity_id +``` + +关系来源: + +- WeKnora 图谱边。 +- WeKnora Wiki graph 链接。 +- 技能归纳时写入 WeKnora 的关系页。 +- graph projection 文档。 +- 模型从 WeKnora 导出包中推断的高置信关系。 + +关系合并后保留多个证据贡献。 + +关系 Wiki 页面建议: + +```text +assistant/relations/{predicate}/{stable_relation_id} +``` + +关系页内容: + +- 源实体。 +- 关系类型。 +- 目标实体。 +- 关系描述。 +- 来源知识库。 +- 来源 Wiki 页。 +- 证据和置信度。 + +### 30.7 助手库 Wiki 生成 + +助手库最终以 Wiki 页面展示。 + +页面类型: + +| 页面 | slug 示例 | 说明 | +| --- | --- | --- | +| 首页 | `assistant/home` | 知识梳理总览、实体数量、来源数量、最近导入。 | +| 来源页 | `assistant/sources/{source_id}` | 某个 WeKnora 知识库导入结果。 | +| 实体页 | `assistant/entities/{type}/{entity_id}-{name}` | 对齐后的全局实体页面。 | +| 关系页 | `assistant/relations/{predicate}/{relation_id}` | 对齐后的全局关系页面。 | +| 主题页 | `assistant/topics/{topic}` | 从 WeKnora Wiki 分类或目录生成。 | + +实体页结构: + +- 标题:实体名。 +- 类型:人物、组织、地点、装备、事件、概念、数据对象等。 +- 摘要:模型基于多来源融合生成。 +- 别名。 +- 多来源属性。 +- 相关实体。 +- 相关关系。 +- 来源 Wiki 页面。 +- 证据。 + +助手库 Wiki 页面需要版本管理。每次 WeKnora 导入、技能直接归纳、管理员本地文件导入、实体对齐重建或人工编辑,都生成新 revision。新 revision 先处于 draft 或 staged 状态,发布成功后成为 current。 + +版本能力: + +- 查看历史版本。 +- 对比两个版本。 +- 回滚到历史版本。 +- 查看版本来源:导入任务、技能归纳任务、人工编辑。 +- 查看版本发布人和发布时间。 + +### 30.8 助手库搜索 + +助手知识库需要支持搜索。 + +V1 建议实现: + +- Wiki 标题搜索。 +- 实体名称和别名搜索。 +- Wiki 正文全文搜索。 +- 来源知识库过滤。 +- 实体类型过滤。 + +实现选择: + +- SQLite 可使用 FTS5。 +- PostgreSQL 可使用 tsvector 或 trigram。 +- 如果现有持久化层不方便,先实现数据库 LIKE + normalized name 索引,后续再升级全文索引。 + +搜索结果只展示 Wiki 页面,不直接展示底层 chunk。 + +### 30.9 助手库持久化表 + +建议新增: + +```text +deerflow.persistence.assistant_knowledge +``` + +核心表: + +#### `assistant_knowledge_base` + +只允许一条 active 记录。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `name` | 默认“知识梳理”。 | +| `status` | `active` / `disabled`。 | +| `created_by` | 初始化管理员。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_sources` + +记录每个来源。来源可以是 WeKnora 知识库、技能归纳或管理员本地导入。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_type` | `weknora` / `skill` / `local_file`。 | +| `knowledge_base_mapping_id` | DeerFlow 本地 WeKnora 映射 ID,可为空。 | +| `skill_name` | 技能名,可为空。 | +| `upload_batch_id` | 本地文件导入批次,可为空。 | +| `source_name` | 来源名称。 | +| `last_import_job_id` | 最近导入任务。 | +| `status` | `active` / `import_failed` / `deleted`。 | +| `created_at` / `updated_at` | 时间戳。 | + +#### `assistant_knowledge_import_jobs` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_id` | 来源 ID。 | +| `trigger` | `auto_after_weknora_indexed` / `manual_from_weknora` / `skill_direct` / `manual_file_upload` / `scheduled` / `watcher`。 | +| `status` | `queued` / `running` / `completed` / `partial_failed` / `failed`。 | +| `phase` | `exporting` / `scanning` / `parsing` / `awaiting_review` / `aligning_entities` / `aligning_relations` / `writing_wiki` / `indexing_search` / `completed`。 | +| `counts_json` | 导入页面、实体、关系、chunk 数。 | +| `last_error_message` | 错误摘要。 | +| `created_by` | 操作人;自动任务可为空或 system。 | +| `created_at` / `completed_at` | 时间戳。 | + +#### `assistant_knowledge_wiki_pages` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `slug` | 全局唯一 slug。 | +| `title` | 标题。 | +| `page_type` | `home` / `source` / `entity` / `relation` / `topic`。 | +| `current_revision_id` | 当前发布版本。 | +| `content_markdown` | 当前版本冗余内容,便于列表和搜索。 | +| `summary` | 摘要。 | +| `status` | `active` / `deleted`。 | +| `search_text` | 搜索文本。 | +| `updated_at` | 更新时间。 | + +#### `assistant_knowledge_wiki_revisions` + +保存 Wiki 页面版本。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `page_id` | Wiki 页面。 | +| `revision_no` | 递增版本号。 | +| `content_markdown` | 版本内容。 | +| `summary` | 版本摘要。 | +| `source_id` | 来源,可为空。 | +| `import_job_id` | 导入任务,可为空。 | +| `change_type` | `import` / `skill_sync` / `manual_edit` / `entity_rebuild` / `rollback`。 | +| `status` | `draft` / `staged` / `published` / `superseded`。 | +| `created_by` | 创建人。 | +| `created_at` / `published_at` | 时间戳。 | + +#### `assistant_knowledge_entities` + +保存全局对齐后的实体。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `entity_type` | 实体类型。 | +| `normalized_name` | 归一化名称。 | +| `display_name` | 展示名称。 | +| `aliases_json` | 别名。 | +| `attributes_json` | 聚合属性。 | +| `wiki_page_id` | 实体 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_entity_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `entity_id` | 全局实体。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `source_chunk_id` | 来源 chunk,可为空。 | +| `source_skill_name` | 来源技能,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +#### `assistant_knowledge_relations` + +保存全局对齐后的关系。 + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `base_id` | 助手库 ID。 | +| `source_entity_id` | 源实体。 | +| `predicate` | 关系类型。 | +| `target_entity_id` | 目标实体。 | +| `description` | 聚合描述。 | +| `wiki_page_id` | 关系 Wiki 页。 | +| `status` | `active` / `deleted`。 | + +#### `assistant_knowledge_relation_contributions` + +| 字段 | 说明 | +| --- | --- | +| `id` | UUID。 | +| `relation_id` | 全局关系。 | +| `source_id` | WeKnora 来源。 | +| `import_job_id` | 导入任务。 | +| `source_wiki_slug` | 来源 Wiki 页。 | +| `graph_edge_id` | WeKnora 图谱边 ID,可为空。 | +| `evidence_json` | 证据。 | +| `confidence` | 置信度。 | + +### 30.10 助手库 API + +新增 router: + +```text +/api/assistant-knowledge +``` + +建议接口: + +```http +GET /api/assistant-knowledge/base +POST /api/assistant-knowledge/base/initialize +GET /api/assistant-knowledge/wiki-pages +GET /api/assistant-knowledge/wiki-pages/{slug} +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions +GET /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id} +POST /api/assistant-knowledge/wiki-pages/{slug}/revisions/{revision_id}/rollback +GET /api/assistant-knowledge/search?q=xxx&type=entity&source_id=xxx +GET /api/assistant-knowledge/entities/{id} +GET /api/assistant-knowledge/relations/{id} +GET /api/assistant-knowledge/sources +GET /api/assistant-knowledge/import-jobs +GET /api/assistant-knowledge/import-jobs/{id} +POST /api/assistant-knowledge/sources/{source_id}/reimport +POST /api/assistant-knowledge/imports/files +POST /api/assistant-knowledge/imports/skills +DELETE /api/assistant-knowledge/sources/{source_id} +``` + +新增 WeKnora 普通知识库导出或导入触发接口: + +```http +POST /api/llmwiki/knowledge-bases/{knowledge_base_id}/export-to-assistant +``` + +行为: + +- 校验管理员。 +- 校验助手知识库已初始化;未初始化则自动创建“知识梳理”或提示管理员确认创建。 +- 校验 WeKnora 知识库已完成处理。 +- 创建 `assistant_knowledge_import_jobs`。 +- 后台导出 WeKnora 数据并导入助手库。 + +本地文件导入接口: + +```http +POST /api/assistant-knowledge/imports/files +``` + +使用 multipart 上传文件。后端必须先完成安全扫描和敏感信息阻断,再进入解析、抽取、审核、实体对齐和 Wiki 版本生成。 + +技能直接导入接口: + +```http +POST /api/assistant-knowledge/imports/skills +``` + +请求: + +```json +{ + "skill_names": ["skill-a", "skill-b"], + "force": false +} +``` + +该接口也可以由 `/api/skill-knowledge/sync-jobs` 在目标类型为 `assistant` 时内部复用。 + +系统设置接口需要支持: + +```http +GET /api/system-settings/knowledge-base-product-version +PUT /api/system-settings/knowledge-base-product-version +``` + +### 30.11 前端页面 + +知识库入口需要支持产品版本切换。 + +建议: + +- 管理员设置页提供“知识库版本”切换:`v1 普通知识库` / `v2 助手知识库`,默认 `v1`。 +- 知识库页面顶部用 Tabs 或 Segmented Control 区分“普通知识库”和“助手知识库”。 +- 未配置 WeKnora 时,普通知识库 tab 隐藏或禁用;助手知识库 tab 正常显示。 +- 助手知识库未初始化时,展示“创建知识梳理库”按钮。 +- 创建后展示 Wiki 首页、搜索框、来源列表、最近导入和页面版本入口。 +- 普通知识库详情页增加按钮:“导入到助手知识库”。 +- 普通知识库列表中可显示“已沉淀到知识梳理 / 待导入 / 导入失败”状态。 +- 助手知识库页面为管理员提供“导入本地文件”和“导入技能”入口。 + +助手知识库页面建议: + +```text +知识梳理 +├── 搜索 +├── Wiki 首页 +├── 实体 +├── 关系 +├── 来源知识库 +├── 导入记录 +└── 页面版本 +``` + +普通用户只看到搜索、Wiki、实体、关系、来源和只读版本历史。管理员额外看到导入、重新导入、删除来源、版本对比和回滚。 + +### 30.12 自动沉淀时机 + +WeKnora 普通知识库完成以下任意流程后,应触发或排队助手库导入: + +- 管理员从技能归纳到 WeKnora 成功。 +- WeKnora 文档上传并完成向量化。 +- WeKnora Wiki 页面生成或更新完成。 +- WeKnora 图谱抽取完成。 +- 管理员手动点击“导入到助手知识库”。 + +为了避免频繁重复导入: + +- 对每个 WeKnora 知识库维护 `last_export_digest`。 +- 导出前计算 Wiki 页面、文档 chunk、图谱摘要的 digest。 +- digest 未变化时跳过导入。 +- 同一 WeKnora 知识库同一时间只允许一个 active 导入任务。 + +### 30.13 与技能归纳的关系 + +技能归纳支持两条链路。 + +链路 A:先进入 WeKnora,再沉淀助手库。 + +```text +技能知识文件 + -> WeKnora 普通知识库 + -> WeKnora 向量化 / Wiki / 图谱 + -> 导出已处理数据 + -> 助手知识库“知识梳理” + -> 全局实体对齐 + -> Wiki 展示与搜索 +``` + +链路 B:直接进入助手知识库。 + +```text +技能知识文件 + -> 助手知识库“知识梳理” + -> 实体/关系预览审核 + -> 全局实体对齐 + -> Wiki 页面版本发布 + -> 搜索索引 +``` + +代码文件在第一步就被排除,不进入后续任何知识库。 + +### 30.14 新增测试与验收 + +新增后端测试: + +- 未配置 WeKnora 时,助手知识库 base 接口可用,普通知识库导入接口返回明确不可用。 +- 初始化助手知识库后,只存在一个 active base。 +- 普通用户可读助手 Wiki,写接口返回 403。 +- 管理员可从 WeKnora 知识库触发导入任务。 +- 管理员可把技能直接导入助手知识库。 +- 管理员可上传本地文件导入助手知识库。 +- WeKnora 导出包包含 Wiki、文档、chunk、实体、关系、图谱时,助手库全部导入。 +- 同一个人或同一对象来自多个 WeKnora 来源时,在助手库合并为一个实体。 +- 模型推断实体对齐 `confidence >= 0.90` 自动合并,低于阈值不合并。 +- 删除某个来源时,只删除该来源贡献;其他来源贡献仍保留。 +- 助手库搜索能命中 Wiki 标题、实体别名和正文。 +- 助手 Wiki 页面支持 revision、对比和回滚。 +- 技能归纳扫描不读取、不上传、不摘要代码文件,代码文件变化不触发 stale。 + +新增前端测试: + +- 知识库产品版本默认 `v1`。 +- 管理员可切换 `v1` / `v2`。 +- 未配置 WeKnora 时仍可看到“创建知识梳理库”。 +- 普通知识库详情页有“导入到助手知识库”按钮。 +- 助手知识库页面展示 Wiki、搜索、来源、导入记录。 +- 助手知识库页面展示页面版本、对比和回滚入口。 +- 普通用户看不到导入、删除、重新导入和回滚动作。 diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index e2a6c51a..a95ab5b8 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -1,7 +1,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useQuery } from "@tanstack/react-query"; import { FilesIcon, XIcon } from "lucide-react"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import type { GroupImperativeHandle } from "react-resizable-panels"; @@ -15,6 +15,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { apiFetch } from "@/core/api"; import { + artifactPathname, listThreadArtifacts, mergeArtifactPaths, } from "@/core/artifacts/listing"; @@ -87,7 +88,13 @@ const ChatBox: React.FC<{ }) => { const { isMock, thread } = useThread(); const pathname = usePathname(); + const searchParams = useSearchParams(); + const requestedArtifact = useMemo(() => { + const value = searchParams.get("artifact")?.trim(); + return value ? artifactPathname(value) : null; + }, [searchParams]); const threadIdRef = useRef(threadId); + const openedRequestedArtifactRef = useRef(null); const artifactsCountRef = useRef(0); const layoutRef = useRef(null); const latestReferenceBatchIdRef = useRef(null); @@ -120,7 +127,7 @@ const ChatBox: React.FC<{ const { skills } = useSkills(); const canScanArtifactDirectory = - artifactDirectoryModeEnabled && + (artifactDirectoryModeEnabled || !!requestedArtifact) && !disableArtifacts && !isMock && !!threadId && @@ -138,17 +145,21 @@ const ChatBox: React.FC<{ ); const checkpointArtifactSignature = checkpointArtifacts.join("\n"); const availableArtifacts = useMemo( - () => - artifactDirectoryModeEnabled - ? mergeArtifactPaths( - checkpointArtifacts, - (artifactListingQuery.data?.files ?? []).map((file) => file.path), - ) - : checkpointArtifacts, + () => { + if (!artifactDirectoryModeEnabled && !requestedArtifact) { + return checkpointArtifacts; + } + const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map( + (file) => file.path, + ); + if (requestedArtifact) discoveredArtifacts.push(requestedArtifact); + return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts); + }, [ artifactDirectoryModeEnabled, artifactListingQuery.data?.files, checkpointArtifacts, + requestedArtifact, ], ); @@ -191,6 +202,29 @@ const ChatBox: React.FC<{ thread.isLoading, ); }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); + useEffect(() => { + if ( + disableArtifacts || + !requestedArtifact || + !threadId || + threadId === "new" + ) { + return; + } + const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`; + if (openedRequestedArtifactRef.current === requestKey) return; + openedRequestedArtifactRef.current = requestKey; + selectArtifact(requestedArtifact); + setArtifactsOpen(true); + setSideTab("document"); + }, [ + disableArtifacts, + requestedArtifact, + selectArtifact, + setArtifactsOpen, + threadId, + ]); + useEffect(() => { if (!shouldFetchBackendReferenceBatches) { setBackendReferenceBatches([]); diff --git a/frontend-web/src/core/artifacts/library.ts b/frontend-web/src/core/artifacts/library.ts new file mode 100644 index 00000000..a088791a --- /dev/null +++ b/frontend-web/src/core/artifacts/library.ts @@ -0,0 +1,102 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +export type ArtifactLibraryKind = + | "all" + | "document" + | "spreadsheet" + | "text_data" + | "archive" + | "other"; + +export interface ArtifactLibraryItem { + threadId: string; + threadTitle: string; + agentId: string | null; + threadType: string | null; + path: string; + name: string; + sizeBytes: number; + mimeType: string | null; + modifiedAt: string; + fileKind: Exclude; +} + +export interface ArtifactLibraryResponse { + total: number; + offset: number; + limit: number; + items: ArtifactLibraryItem[]; +} + +export async function fetchArtifactLibrary({ + query, + fileKind, + offset, + limit, + refresh = false, + signal, +}: { + query?: string; + fileKind: ArtifactLibraryKind; + offset: number; + limit: number; + refresh?: boolean; + signal?: AbortSignal; +}): Promise { + const search = new URLSearchParams({ + file_kind: fileKind, + offset: String(offset), + limit: String(limit), + }); + const normalizedQuery = query?.trim(); + if (normalizedQuery) search.set("query", normalizedQuery); + if (refresh) search.set("refresh", "true"); + + const response = await apiFetch( + `${getBackendBaseURL()}/api/artifact-library?${search.toString()}`, + { method: "GET", signal }, + ); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `Failed to load artifact library (${response.status})${detail ? `: ${detail}` : ""}`, + ); + } + + const data = (await response.json()) as { + total?: number; + offset?: number; + limit?: number; + items?: Array<{ + thread_id: string; + thread_title: string; + agent_id?: string | null; + thread_type?: string | null; + path: string; + name: string; + size_bytes: number; + mime_type?: string | null; + modified_at: string; + file_kind: Exclude; + }>; + }; + + return { + total: data.total ?? 0, + offset: data.offset ?? offset, + limit: data.limit ?? limit, + items: (data.items ?? []).map((item) => ({ + threadId: item.thread_id, + threadTitle: item.thread_title, + agentId: item.agent_id ?? null, + threadType: item.thread_type ?? null, + path: item.path, + name: item.name, + sizeBytes: item.size_bytes, + mimeType: item.mime_type ?? null, + modifiedAt: item.modified_at, + fileKind: item.file_kind, + })), + }; +} diff --git a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts index 17a2a60f..6a47dfc0 100644 --- a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts +++ b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts @@ -149,7 +149,7 @@ export function buildWikiIndexCatalogMarkdown( buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl, indexGroups: WikiIndexGroup[] = [], ): string { - if (indexGroups.length > 0) { + if (indexGroups.some((group) => group.items.length > 0)) { const sections: string[] = []; for (const group of indexGroups) { if (group.items.length === 0) continue; diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 9409b02f..5c3cfa2d 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -61,6 +61,7 @@ export const sidebarMenuItems: MenuItem[] = [ icon: HelpCircle, children: [ { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, + { id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" }, { id: "multi-agent-roundtable", label: "多智能体会商", diff --git a/frontend-web/src/pages/ArtifactLibraryPage.tsx b/frontend-web/src/pages/ArtifactLibraryPage.tsx new file mode 100644 index 00000000..576003e6 --- /dev/null +++ b/frontend-web/src/pages/ArtifactLibraryPage.tsx @@ -0,0 +1,433 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + ArrowDownWideNarrow, + ChevronLeft, + ChevronRight, + Download, + FileX2, + Library, + Loader2, + MessageSquareShare, + RefreshCw, + Search, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + WorkspaceBody, + WorkspaceContainer, + WorkspaceHeader, +} from "@/components/workspace/workspace-container"; +import { downloadArtifactFile } from "@/core/artifacts/download"; +import { + fetchArtifactLibrary, + type ArtifactLibraryItem, + type ArtifactLibraryKind, +} from "@/core/artifacts/library"; +import { useI18n } from "@/core/i18n/hooks"; +import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils"; +import { formatDateTime } from "@/core/utils/datetime"; +import { + getFileExtensionDisplayName, + getFileIcon, +} from "@/core/utils/files"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZE = 50; + +const FILTERS: Array<{ value: ArtifactLibraryKind; label: string }> = [ + { value: "all", label: "全部" }, + { value: "document", label: "文档" }, + { value: "spreadsheet", label: "表格" }, + { value: "text_data", label: "文本与数据" }, + { value: "archive", label: "压缩包" }, + { value: "other", label: "其他" }, +]; + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** index; + const digits = index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} ${units[index]}`; +} + +function sourceConversationPath(item: ArtifactLibraryItem): string { + const basePath = + item.threadType === "bootstrap" + ? bootstrapResumePath(item.threadId) + : pathOfThread({ + thread_id: item.threadId, + metadata: item.agentId ? { agent_id: item.agentId } : undefined, + }); + const search = new URLSearchParams({ artifact: item.path }); + return `${basePath}?${search.toString()}`; +} + +function FileRow({ + item, + downloading, + onDownload, +}: { + item: ArtifactLibraryItem; + downloading: boolean; + onDownload: (item: ArtifactLibraryItem) => void; +}) { + const sourcePath = sourceConversationPath(item); + const displayName = desensitize(item.name); + const sourceTitle = desensitize(item.threadTitle || "新对话"); + + return ( + + + + + {getFileIcon(item.name, "size-5")} + + + + {displayName} + + + {getFileExtensionDisplayName(item.name)} · 来自「{sourceTitle}」 + + + {getFileExtensionDisplayName(item.name)} 文件 + + + + + + + {sourceTitle} + + + + {formatDateTime(item.modifiedAt)} + + + {formatBytes(item.sizeBytes)} + + +
+ + +
+ + + ); +} + +function LoadingRows() { + return Array.from({ length: 5 }, (_, index) => ( + + +
+ +
+ + +
+
+ + + + + + + )); +} + +export default function ArtifactLibraryPage() { + const { t } = useI18n(); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [fileKind, setFileKind] = useState("all"); + const [page, setPage] = useState(1); + const [downloadingKey, setDownloadingKey] = useState(null); + const forceRefreshRef = useRef(false); + + useEffect(() => { + document.title = `资料库 - ${t.pages.appName}`; + }, [t.pages.appName]); + + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300); + return () => window.clearTimeout(timer); + }, [search]); + + useEffect(() => { + setPage(1); + }, [debouncedSearch, fileKind]); + + const offset = (page - 1) * PAGE_SIZE; + const libraryQuery = useQuery({ + queryKey: ["artifact-library", debouncedSearch, fileKind, page], + queryFn: async ({ signal }) => { + const refresh = forceRefreshRef.current; + try { + return await fetchArtifactLibrary({ + query: debouncedSearch, + fileKind, + offset, + limit: PAGE_SIZE, + refresh, + signal, + }); + } finally { + forceRefreshRef.current = false; + } + }, + retry: false, + }); + + const total = libraryQuery.data?.total ?? 0; + const items = libraryQuery.data?.items ?? []; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const hasPrevious = page > 1; + const hasNext = page < totalPages; + + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); + + const itemKeys = useMemo( + () => new Set(items.map((item) => `${item.threadId}:${item.path}`)), + [items], + ); + useEffect(() => { + if (downloadingKey && !itemKeys.has(downloadingKey)) setDownloadingKey(null); + }, [downloadingKey, itemKeys]); + + const handleDownload = async (item: ArtifactLibraryItem) => { + const key = `${item.threadId}:${item.path}`; + if (downloadingKey) return; + setDownloadingKey(key); + try { + await downloadArtifactFile({ filepath: item.path, threadId: item.threadId }); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : "下载失败"); + } finally { + setDownloadingKey(null); + } + }; + + const handleRefresh = () => { + forceRefreshRef.current = true; + void libraryQuery.refetch(); + }; + + return ( + + + +
+
+
+
+
+
+ +

资料库

+
+

+ 汇总当前账号在对话中生成的文件,不包含上传文件、图片、音频和视频。 +

+
+ +
+ +
+ + setSearch(event.target.value)} + /> +
+ +
+
+ {FILTERS.map((filter) => ( + + ))} +
+
+ + 最近生成 +
+
+
+
+ + +
+
+ {libraryQuery.isLoading ? "正在统计文件…" : `共 ${total} 个文件`} +
+ +
+ + + + + + + + + + + + {libraryQuery.isLoading ? ( + + ) : ( + items.map((item) => { + const key = `${item.threadId}:${item.path}`; + return ( + void handleDownload(target)} + /> + ); + }) + )} + +
文件名来源对话生成时间大小 + 操作 +
+
+ + {!libraryQuery.isLoading && libraryQuery.isError && ( +
+ +
+

资料库加载失败

+

+ {libraryQuery.error instanceof Error + ? libraryQuery.error.message + : "请稍后重试"} +

+
+ +
+ )} + + {!libraryQuery.isLoading && !libraryQuery.isError && items.length === 0 && ( +
+ +
+

+ {debouncedSearch || fileKind !== "all" + ? "没有找到匹配的文件" + : "还没有生成文件"} +

+

+ 对话中生成的文档、表格和数据文件会自动出现在这里。 +

+
+
+ )} + + {!libraryQuery.isLoading && !libraryQuery.isError && totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 12c5e6de..92bab8fc 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -28,6 +28,7 @@ import { RouteBaseProvider } from '@/core/route-base'; import ChatsPage from './ChatsPage'; import ChatPage from './ChatPage'; +import ArtifactLibraryPage from './ArtifactLibraryPage'; import IframeChatPage from './IframeChatPage'; import AgentsPage from './AgentsPage'; import AgentChatPage from './AgentChatPage'; @@ -93,6 +94,7 @@ function shouldShowCompactTopActions(pathname: string): boolean { pathname === '/page/workspace/agents' || pathname === '/page/workspace/skills' || pathname === '/page/workspace/memory' || + pathname === '/page/workspace/library' || pathname === '/page/workspace/knowledge' || pathname.startsWith('/page/workspace/knowledge/') ); @@ -444,6 +446,14 @@ export default function WorkspaceRoutes({ path="chats/:thread_id" element={} /> + + + + } + /> } diff --git a/frontend-web/src/strategy-components/api/llmwiki.ts b/frontend-web/src/strategy-components/api/llmwiki.ts index 40f0fd38..7e4202d7 100644 --- a/frontend-web/src/strategy-components/api/llmwiki.ts +++ b/frontend-web/src/strategy-components/api/llmwiki.ts @@ -29,7 +29,7 @@ export interface LlmWikiKnowledgeBase { remote_status: "available" | "missing"; name: string; description: string; - type: "document" | "faq"; + type: "document" | "faq" | "wiki"; publication_status: "private" | "pending" | "published" | "rejected"; owner_user_id?: string | null; is_owner: boolean; @@ -37,6 +37,9 @@ export interface LlmWikiKnowledgeBase { knowledge_count: number; chunk_count: number; processing_count: number; + share_count?: number; + is_processing?: boolean; + is_temporary?: boolean; embedding_model_id?: string | null; vector_store_id?: string | null; vector_store_name?: string | null; @@ -44,19 +47,65 @@ export interface LlmWikiKnowledgeBase { vector_store_engine_type?: string | null; vector_store_status?: string | null; chunking_config?: Record; + image_processing_config?: Record; indexing_strategy?: Record; capabilities?: Record; extract_config?: Record; + faq_config?: Record; wiki_config?: Record; question_generation_config?: Record; + auto_tag_config?: Record; + vlm_config?: Record; + asr_config?: Record; summary_model_id?: string | null; storage_provider?: string | null; created_at?: string | null; updated_at?: string | null; published_at?: string | null; + remote_created_at?: string | null; + remote_updated_at?: string | null; is_conversation_deposit?: boolean; } +export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase { + wiki: { + status: "ready" | "empty" | "unavailable"; + page_count: number; + loaded_page_count: number; + stats_complete: boolean; + page_type_counts: Record; + page_status_counts: Record; + index_mode: "explicit" | "generated" | "none" | "unknown"; + has_explicit_index: boolean; + index_slug: string | null; + default_slug: string | null; + created_at: string | null; + updated_at: string | null; + embed_route: string; + }; + local_index: { + enabled: boolean; + wiki_index_enabled: boolean; + state: LlmWikiWikiIndexStatus["state"]; + fully_vectorized: boolean; + retrieval_mode: "vector" | "wiki_api"; + remote_page_count: number; + local_page_count: number; + ready_page_count: number; + failed_page_count: number; + vector_count: number; + last_started_at?: string | null; + last_completed_at?: string | null; + last_success_at?: string | null; + updated_at?: string | null; + }; +} + +export interface PublicLlmWikiKnowledgeBaseList { + knowledge_bases: PublicLlmWikiKnowledgeBase[]; + total: number; +} + export interface LlmWikiDocument { id: string; type?: string; @@ -742,6 +791,13 @@ export async function getPublicLlmWikiKnowledgeBase( ); } +export async function listPublicLlmWikiKnowledgeBases(): Promise { + return readJson( + await apiFetch(`${PUBLIC_API()}/knowledge-bases`), + "读取公开知识库列表", + ); +} + export async function listPublicLlmWikiWikiPages( knowledgeBaseId: string, page: number, diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx index 88297f48..2e8606d5 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView.tsx @@ -54,6 +54,7 @@ import { import { WikiProtectedImage } from "./WikiProtectedImage"; const LIST_PATH = "/page/workspace/knowledge"; +const GENERATED_PUBLIC_INDEX_SLUG = "__index__"; function cleanText(value?: string | null) { return (value || "").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); @@ -144,13 +145,40 @@ export function WeKnoraWikiPageView({ !!noteId && publicEmbed, ); const pages = publicEmbed ? publicPages : privatePages; - const pagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]); - const indexPage = useMemo( - () => (publicEmbed ? pagesList.find(isWikiIndexPage) : undefined), - [pagesList, publicEmbed], + const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]); + const explicitIndexPage = useMemo( + () => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined), + [publicEmbed, remotePagesList], + ); + const generatedIndexPage = useMemo( + () => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage + ? { + id: "__generated_public_index__", + slug: GENERATED_PUBLIC_INDEX_SLUG, + title: "索引", + page_type: "index", + status: "generated", + content: "# 索引\n\n知识库页面目录", + summary: "", + aliases: [], + source_refs: [], + in_links: [], + out_links: [], + version: 0, + } + : undefined, + [explicitIndexPage, publicEmbed, remotePagesList.length], + ); + const indexPage = explicitIndexPage || generatedIndexPage; + const pagesList = useMemo( + () => generatedIndexPage ? [generatedIndexPage, ...remotePagesList] : remotePagesList, + [generatedIndexPage, remotePagesList], ); const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]); const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : ""); + const generatedIndexActive = generatedIndexPage?.slug === wikiSlug; + const generatedIndexRequested = + publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage; const privatePage = useLlmWikiWikiPage( noteId || null, wikiSlug || null, @@ -159,7 +187,11 @@ export function WeKnoraWikiPageView({ const publicPage = usePublicLlmWikiWikiPage( noteId || null, wikiSlug || null, - !!noteId && !!wikiSlug && publicEmbed, + !!noteId && + !!wikiSlug && + publicEmbed && + !generatedIndexActive && + !generatedIndexRequested, ); const page = publicEmbed ? publicPage : privatePage; const publicWikiIndex = usePublicLlmWikiWikiIndex( @@ -170,7 +202,9 @@ export function WeKnoraWikiPageView({ const [directoryQuery, setDirectoryQuery] = useState(""); const [collapsedDirectories, setCollapsedDirectories] = useState>(() => new Set()); const routedPage = (location.state as { wikiPage?: LlmWikiWikiPage } | null)?.wikiPage; - const pageData = page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined); + const pageData = generatedIndexActive + ? generatedIndexPage + : page.data || (routedPage?.slug === wikiSlug ? routedPage : undefined); const filteredPages = useMemo( () => filterWikiPagesWithAncestors(pagesList, directoryQuery), [directoryQuery, pagesList], @@ -345,6 +379,24 @@ export function WeKnoraWikiPageView({ ); } + if (publicEmbed && pages.error) { + return ( +
+
+ +
+
+
无法读取 Wiki 目录
+
+ {pages.error instanceof Error ? pages.error.message : "公开 Wiki 服务暂时不可用"} +
+
+ +
+ ); + } return (
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index bfd7fff9..2894db84 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -321,7 +321,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` | -| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | +| **Artifacts** (`/api/threads/{id}/artifacts`, `/api/artifact-library`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `GET /api/artifact-library` scans every current-user normal thread's authoritative `outputs/` directory (including files omitted from `present_files`), excludes uploads and image/audio/video media, joins the owning conversation title/agent route metadata, and exposes search/filter/pagination with a bounded 15-second per-user cache plus `refresh=true` bypass. Frontend route `/page/workspace/library` links each item back to the source chat with `?artifact=` so the chat opens the existing artifact panel directly. | | **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`. | | **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. | | **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. | @@ -1527,8 +1527,10 @@ same DOM value, so a fetched-and-revoked object URL is not acceptable. Third-party pages embed published Wiki content through the chrome-free route `/#/embed/knowledge/{mapping_id}` (optional exact-page `wikiSlug`). It defaults to a recognized Wiki index page (title `索引`/`index`, page type `index`, or an -`index`/`home`/`readme` slug), promotes that page to the first public-directory -entry, then falls back to an overview/root page when no index exists. It retains +`index`/`home`/`readme` slug) and promotes that page to the first public-directory +entry. When upstream pages exist without an explicit index shell, the frontend +injects a virtual `__index__` page and builds its catalogue from the page list; +an all-empty dynamic index response must also fall back to that page list. It retains directory navigation and supports the shared public-embed `theme` / `bg` / `text` appearance inputs. WeKnora dynamically renders index page-type counts, nested category headings, page summaries, and `data-slug` links even though the @@ -1544,9 +1546,14 @@ the loaded page title, slug, or alias and rewrites them to exact embedded `wikiSlug` routes. Preserve WeKnora's `wiki-content-link` interaction: primary brand color, medium weight, dashed underline becoming solid on hover, focus outline, and in-reader SPA navigation rather than a page reload. `GET -/api/public/llmwiki/knowledge-bases` is the -anonymous discovery endpoint and returns only each published ordinary mapping's -`id` and `name` (plus the collection total). All anonymous reader calls use that +/api/public/llmwiki/knowledge-bases` is the anonymous discovery endpoint. It +validates published ordinary mappings against WeKnora, drops stale mappings, +and returns safe comprehensive metadata from the WeKnora list/detail/page APIs: +document/chunk/processing/share counts, non-secret configuration and timestamps, +Wiki page type/status counts and explicit/generated index entry, plus aggregate +local-vector state/counts/timestamps. Never expose remote WeKnora ids, +tenant/creator identity, storage credentials, API credentials, raw vectors, or +per-page indexing errors. All anonymous reader calls use that public namespace and the `/knowledge-bases/{id}` Wiki-page children. Those endpoints must resolve with a synthetic anonymous user and `is_admin=False`, explicitly require `publication_status=published`, and hard-deny the system diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 20fc63f7..65270ad7 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -153,6 +153,7 @@ FastAPI application providing REST endpoints for frontend integration: | `POST /api/threads/{id}/uploads` | Upload files (auto-converts PDF/PPT/Excel/Word to Markdown, rejects directory paths) | | `GET /api/threads/{id}/uploads/list` | List uploaded files | | `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | +| `GET /api/artifact-library` | List the current user's generated conversation files across normal threads, with filename/conversation search, file-kind filtering, pagination, and an optional cache-bypassing refresh. The filesystem `outputs/` directories remain authoritative; uploads and image/audio/video files are excluded. | | `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts | | `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. Whole-document artifact/report rewrites likewise create their reversible version snapshots from writer-local ORM data and never refresh after commit, so replica lag cannot restore an otherwise successful rewrite. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Whole-report rewrite accepts an optional `reportOutline` Markdown template; the durable job persists it and applies it as the chapter structure constraint. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. | | `/api/enterprise-research/*` | Temporarily disabled. Its router and persistence initialization are not registered, so these endpoints return 404 and no enterprise-research tables are created or migrated during startup. | @@ -865,8 +866,10 @@ report-structure column for databases stamped by either old variant. Published knowledge bases also have a login-free, chrome-free reader at `/#/embed/knowledge/{mapping_id}` for direct third-party iframe use. With no `wikiSlug`, it selects the Wiki **索引** page first (recognized by title, page -type, or `index`/`home`/`readme` slug), pins that page to the top of the public -directory, and otherwise falls back to an overview/root page; +type, or `index`/`home`/`readme` slug) and pins that page to the top of the +public directory. If WeKnora has pages but no explicit index page, the reader +creates a virtual `__index__` landing page from the public directory; empty +dynamic-index groups also fall back to those page summaries; `?wikiSlug=...` deep-links one page. The reader supports `theme=light|dark`, `bg`, and `text` embed appearance parameters. Index-article catalogue sections (page-type counts, nested categories, page summaries) are @@ -882,8 +885,14 @@ only from the read-only `/api/public/llmwiki/knowledge-bases/{id}/...` namespace which rechecks `publication_status=published` on every request and denies the system conversation-deposit knowledge base. Private or unpublished bases are never exposed by this route. Anonymous consumers can discover the available -public Wiki libraries through `GET /api/public/llmwiki/knowledge-bases`; each -entry deliberately contains only the DeerFlow mapping `id` and display `name`. +public Wiki libraries through `GET /api/public/llmwiki/knowledge-bases`. The +endpoint validates each published mapping against WeKnora, omits stale remote +mappings, and returns safe comprehensive metadata: description/type, +document/chunk/processing/share counts, non-secret configuration and +timestamps, Wiki page/type/status counts plus explicit/generated index mode and +default embed route, and aggregate local-vector state/counts/timestamps. It +never returns the WeKnora id, tenant/creator identity, storage credentials, API +credentials, raw vectors, or per-page indexing errors. Protected `resource://` and supported object-storage images are fetched through the current mapping instead of being exposed as upstream URLs. Authenticated readers use `GET /api/llmwiki/knowledge-bases/{id}/files?file_path=...`; public diff --git a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py index b5738d00..e88b5d4f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py @@ -1,11 +1,15 @@ +import asyncio import logging import mimetypes +import time import zipfile -from datetime import datetime, timezone +from collections import OrderedDict +from datetime import UTC, datetime from pathlib import Path +from typing import Literal from urllib.parse import quote -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import FileResponse, PlainTextResponse, Response from pydantic import BaseModel @@ -13,6 +17,7 @@ from app.gateway.authz import require_permission from app.gateway.path_utils import aresolve_thread_virtual_path from deerflow.config.paths import get_paths from deerflow.runtime.thread_paths import aresolve_path_user_id +from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) @@ -38,6 +43,84 @@ EDITABLE_TEXT_SUFFIXES = { _VIRTUAL_USER_DATA_PREFIX = "mnt/user-data" +ArtifactLibraryKind = Literal["all", "document", "spreadsheet", "text_data", "archive", "other"] + +_MEDIA_MIME_PREFIXES = ("image/", "audio/", "video/") +_MEDIA_SUFFIXES = { + ".aac", + ".aiff", + ".ape", + ".avi", + ".bmp", + ".flac", + ".gif", + ".heic", + ".ico", + ".jpeg", + ".jpg", + ".m4a", + ".m4v", + ".mkv", + ".mov", + ".mp3", + ".mp4", + ".ogg", + ".png", + ".svg", + ".tiff", + ".wav", + ".webm", + ".webp", + ".wma", +} +_DOCUMENT_SUFFIXES = { + ".doc", + ".docx", + ".epub", + ".odt", + ".pdf", + ".ppt", + ".pptx", + ".rtf", +} +_SPREADSHEET_SUFFIXES = {".csv", ".ods", ".tsv", ".xls", ".xlsx"} +_TEXT_DATA_SUFFIXES = { + ".bash", + ".c", + ".conf", + ".cpp", + ".css", + ".go", + ".h", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsonl", + ".log", + ".markdown", + ".md", + ".mmd", + ".py", + ".rs", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", +} +_ARCHIVE_SUFFIXES = {".7z", ".gz", ".rar", ".skill", ".tar", ".tgz", ".zip"} +_ARTIFACT_LIBRARY_CACHE_TTL_SECONDS = 15.0 +_ARTIFACT_LIBRARY_CACHE_MAX_USERS = 128 +_ARTIFACT_LIBRARY_SCAN_CONCURRENCY = 8 +_ARTIFACT_LIBRARY_THREAD_BATCH_SIZE = 500 +_artifact_library_cache: OrderedDict[str, tuple[float, list[dict]]] = OrderedDict() + def coerce_artifact_virtual_path(path: str) -> str: """Normalize an artifacts URL path to a sandbox virtual path. @@ -60,6 +143,26 @@ def coerce_artifact_virtual_path(path: str) -> str: class ArtifactUpdateRequest(BaseModel): content: str + + +class ArtifactLibraryItem(BaseModel): + thread_id: str + thread_title: str + agent_id: str | None = None + thread_type: str | None = None + path: str + name: str + size_bytes: int + mime_type: str | None = None + modified_at: str + file_kind: str + + +class ArtifactLibraryResponse(BaseModel): + total: int + offset: int + limit: int + items: list[ArtifactLibraryItem] def _build_content_disposition(disposition_type: str, filename: str) -> str: @@ -139,21 +242,184 @@ def collect_outputs_listing(outputs_dir: Path) -> list[dict]: resolved.relative_to(outputs_root) except (OSError, ValueError): continue - relative = resolved.relative_to(outputs_root).as_posix() - mime_type, _ = mimetypes.guess_type(resolved.name) - stat = resolved.stat() + try: + relative = resolved.relative_to(outputs_root).as_posix() + mime_type, _ = mimetypes.guess_type(resolved.name) + stat = resolved.stat() + except OSError: + # A run may atomically replace or remove a file while this directory + # is being scanned. Treat that transient entry as absent instead of + # failing the complete thread/library listing. + continue files.append( { "path": f"mnt/user-data/outputs/{relative}", "name": resolved.name, "size_bytes": stat.st_size, "mime_type": mime_type, - "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(), } ) files.sort(key=lambda f: f["modified_at"], reverse=True) return files + + +def artifact_library_kind(name: str) -> str: + """Classify one non-media artifact for the library filters.""" + suffix = Path(name).suffix.lower() + if suffix in _DOCUMENT_SUFFIXES: + return "document" + if suffix in _SPREADSHEET_SUFFIXES: + return "spreadsheet" + if suffix in _TEXT_DATA_SUFFIXES: + return "text_data" + if suffix in _ARCHIVE_SUFFIXES: + return "archive" + return "other" + + +def is_artifact_library_file(file: dict) -> bool: + """Return whether a generated file belongs in the document-only library.""" + mime_type = str(file.get("mime_type") or "").lower() + if mime_type.startswith(_MEDIA_MIME_PREFIXES): + return False + return Path(str(file.get("name") or file.get("path") or "")).suffix.lower() not in _MEDIA_SUFFIXES + + +async def _search_artifact_library_threads(thread_store) -> list[dict]: + """Load every normal conversation owned by the current request user.""" + rows: list[dict] = [] + seen: set[str] = set() + offset = 0 + while True: + batch = await thread_store.search( + limit=_ARTIFACT_LIBRARY_THREAD_BATCH_SIZE, + offset=offset, + exclude_system=True, + ) + if not batch: + break + added = 0 + for row in batch: + thread_id = str(row.get("thread_id") or "") + if not thread_id or thread_id in seen: + continue + seen.add(thread_id) + rows.append(row) + added += 1 + if len(batch) < _ARTIFACT_LIBRARY_THREAD_BATCH_SIZE or added == 0: + break + offset += len(batch) + return rows + + +async def _scan_artifact_library(thread_store) -> list[dict]: + """Scan generated outputs for all current-user conversation threads.""" + rows = await _search_artifact_library_threads(thread_store) + paths = get_paths() + semaphore = asyncio.Semaphore(_ARTIFACT_LIBRARY_SCAN_CONCURRENCY) + + async def scan_thread(row: dict) -> list[dict]: + thread_id = str(row.get("thread_id") or "") + if not thread_id: + return [] + async with semaphore: + try: + path_user_id = await aresolve_path_user_id(thread_id) + outputs_dir = paths.sandbox_outputs_dir(thread_id, user_id=path_user_id) + files = await asyncio.to_thread(collect_outputs_listing, outputs_dir) + except Exception: + logger.warning("Failed to scan artifacts for library thread %s", thread_id, exc_info=True) + return [] + + metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} + agent_id = metadata.get("agent_id") + thread_type = metadata.get("thread_type") + title = str(row.get("display_name") or "新对话") + items: list[dict] = [] + for file in files: + if not is_artifact_library_file(file): + continue + items.append( + { + "thread_id": thread_id, + "thread_title": title, + "agent_id": agent_id if isinstance(agent_id, str) and agent_id else None, + "thread_type": thread_type if isinstance(thread_type, str) and thread_type else None, + **file, + "file_kind": artifact_library_kind(str(file.get("name") or "")), + } + ) + return items + + nested = await asyncio.gather(*(scan_thread(row) for row in rows)) + items = [item for group in nested for item in group] + items.sort( + key=lambda item: ( + str(item.get("modified_at") or ""), + str(item.get("thread_id") or ""), + str(item.get("path") or ""), + ), + reverse=True, + ) + return items + + +async def _cached_artifact_library(thread_store, *, refresh: bool) -> list[dict]: + user_id = str(get_effective_user_id() or "default") + now = time.monotonic() + cached = _artifact_library_cache.get(user_id) + if not refresh and cached and cached[0] > now: + _artifact_library_cache.move_to_end(user_id) + return cached[1] + + items = await _scan_artifact_library(thread_store) + _artifact_library_cache[user_id] = (now + _ARTIFACT_LIBRARY_CACHE_TTL_SECONDS, items) + _artifact_library_cache.move_to_end(user_id) + while len(_artifact_library_cache) > _ARTIFACT_LIBRARY_CACHE_MAX_USERS: + _artifact_library_cache.popitem(last=False) + return items + + +@router.get( + "/artifact-library", + response_model=ArtifactLibraryResponse, + summary="List Generated Files Across Conversations", + description="Aggregate non-media files generated in the current user's normal conversations.", +) +@require_permission("threads", "read") +async def list_artifact_library( + request: Request, + query: str | None = Query(default=None, max_length=200), + file_kind: ArtifactLibraryKind = Query(default="all"), + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + refresh: bool = Query(default=False), +) -> ArtifactLibraryResponse: + """Return a flat, newest-first library of generated conversation files. + + The filesystem remains the source of truth: every owned, non-system thread's + ``outputs`` directory is scanned, including files written without an + explicit ``present_files`` call. Uploads and image/audio/video outputs are + intentionally outside this collection. + """ + from app.gateway.deps import get_thread_store + + items = await _cached_artifact_library(get_thread_store(request), refresh=refresh) + normalized_query = (query or "").strip().casefold() + filtered = [ + item + for item in items + if (file_kind == "all" or item["file_kind"] == file_kind) and (not normalized_query or normalized_query in str(item.get("name") or "").casefold() or normalized_query in str(item.get("thread_title") or "").casefold()) + ] + page = filtered[offset : offset + limit] + return ArtifactLibraryResponse( + total=len(filtered), + offset=offset, + limit=limit, + items=[ArtifactLibraryItem.model_validate(item) for item in page], + ) @router.get( diff --git a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py index 488c26b5..452138f3 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llmwiki.py @@ -55,6 +55,7 @@ from deerflow.config.agents_config import load_agent_config from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url from deerflow.config.system_settings import load_system_settings, save_system_settings from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError +from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectorized from deerflow.integrations.weknora.local_index.search import WikiIndexError from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime from deerflow.persistence.llmwiki import LlmWikiStore @@ -496,15 +497,7 @@ def _should_follow_weknora_binary_redirect(path: str) -> bool: """Keep protected preview/image bytes inside the DeerFlow proxy.""" segments = _path_segments(path) - return ( - len(segments) == 5 - and segments[:3] == ["api", "v1", "knowledge"] - and segments[4] == "preview" - ) or ( - len(segments) == 5 - and segments[:3] == ["api", "v1", "knowledge-bases"] - and segments[4] == "files" - ) + return (len(segments) == 5 and segments[:3] == ["api", "v1", "knowledge"] and segments[4] == "preview") or (len(segments) == 5 and segments[:3] == ["api", "v1", "knowledge-bases"] and segments[4] == "files") def _forward_request_headers(request: Request) -> dict[str, str]: @@ -1833,6 +1826,9 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u "knowledge_count": int(source.get("knowledge_count") or 0), "chunk_count": int(source.get("chunk_count") or 0), "processing_count": int(source.get("processing_count") or 0), + "share_count": int(source.get("share_count") or 0), + "is_processing": bool(source.get("is_processing") or int(source.get("processing_count") or 0) > 0), + "is_temporary": bool(source.get("is_temporary", False)), "embedding_model_id": source.get("embedding_model_id"), "vector_store_id": source.get("vector_store_id"), "vector_store_name": source.get("vector_store_name"), @@ -1840,16 +1836,23 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u "vector_store_engine_type": source.get("vector_store_engine_type"), "vector_store_status": source.get("vector_store_status"), "chunking_config": source.get("chunking_config") if isinstance(source.get("chunking_config"), dict) else {}, + "image_processing_config": (source.get("image_processing_config") if isinstance(source.get("image_processing_config"), dict) else {}), "indexing_strategy": source.get("indexing_strategy") if isinstance(source.get("indexing_strategy"), dict) else {}, "capabilities": source.get("capabilities") if isinstance(source.get("capabilities"), dict) else {}, "extract_config": source.get("extract_config") if isinstance(source.get("extract_config"), dict) else {}, + "faq_config": source.get("faq_config") if isinstance(source.get("faq_config"), dict) else {}, "wiki_config": source.get("wiki_config") if isinstance(source.get("wiki_config"), dict) else {}, "question_generation_config": (source.get("question_generation_config") if isinstance(source.get("question_generation_config"), dict) else {}), + "auto_tag_config": source.get("auto_tag_config") if isinstance(source.get("auto_tag_config"), dict) else {}, + "vlm_config": source.get("vlm_config") if isinstance(source.get("vlm_config"), dict) else {}, + "asr_config": source.get("asr_config") if isinstance(source.get("asr_config"), dict) else {}, "summary_model_id": source.get("summary_model_id"), "storage_provider": (source.get("storage_provider_config", {}).get("provider") if isinstance(source.get("storage_provider_config"), dict) else None), "created_at": row.get("created_at"), "updated_at": row.get("updated_at"), "published_at": row.get("published_at"), + "remote_created_at": source.get("created_at"), + "remote_updated_at": source.get("updated_at"), } @@ -1877,11 +1880,7 @@ async def _public_mapping(request: Request, mapping_id: str) -> dict[str, Any]: write=False, is_admin=False, ) - if ( - row is None - or row.get("publication_status") != "published" - or _is_conversation_deposit_mapping(row) - ): + if row is None or row.get("publication_status") != "published" or _is_conversation_deposit_mapping(row): raise HTTPException(status_code=404, detail="Published knowledge base not found") return row @@ -2537,24 +2536,260 @@ async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest) return {"success": True, "provider": "weknora", "health": health} +_PUBLIC_WIKI_SUMMARY_CONCURRENCY = 8 +_PUBLIC_WIKI_PAGE_SIZE = 500 +_PUBLIC_WIKI_PAGE_LIMIT = 100 +_GENERATED_PUBLIC_WIKI_INDEX_SLUG = "__index__" + + +def _is_public_wiki_index_page(page: dict[str, Any]) -> bool: + page_type = str(page.get("page_type") or "").strip().lower() + title = str(page.get("title") or "").strip().lower() + slug_name = str(page.get("slug") or "").strip("/").rsplit("/", 1)[-1].lower() + return page_type == "index" or title in {"索引", "index"} or slug_name in {"index", "home", "readme"} + + +def _public_wiki_default_page(pages: list[dict[str, Any]]) -> dict[str, Any] | None: + return ( + next((page for page in pages if _is_public_wiki_index_page(page)), None) + or next((page for page in pages if str(page.get("page_type") or "").lower() == "overview"), None) + or next( + (page for page in pages if not str(page.get("parent_slug") or "").strip() and int(page.get("depth") or 0) == 0), + None, + ) + or (pages[0] if pages else None) + ) + + +def _count_public_wiki_values(pages: list[dict[str, Any]], key: str, fallback: str) -> dict[str, int]: + counts: dict[str, int] = {} + for page in pages: + value = str(page.get(key) or fallback).strip().lower() or fallback + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _public_wiki_time_bound(pages: list[dict[str, Any]], key: str, *, latest: bool) -> str | None: + values = [str(page.get(key) or "").strip() for page in pages] + values = [value for value in values if value] + if not values: + return None + return max(values) if latest else min(values) + + +async def _all_public_wiki_page_views( + client: WeKnoraClient, + remote_id: str, +) -> tuple[list[dict[str, Any]], int, bool]: + pages_by_slug: dict[str, dict[str, Any]] = {} + remote_total = 0 + complete = False + for page_number in range(1, _PUBLIC_WIKI_PAGE_LIMIT + 1): + result = await client.list_wiki_pages( + remote_id, + page=page_number, + page_size=_PUBLIC_WIKI_PAGE_SIZE, + query="", + ) + raw_pages = result.get("pages") if isinstance(result, dict) else [] + page_items = raw_pages if isinstance(raw_pages, list) else [] + for page_item in page_items: + if not isinstance(page_item, dict): + continue + page = _wiki_page_view(page_item) + if page is None: + continue + slug = str(page.get("slug") or "").strip() + pages_by_slug[slug or f"__page_{len(pages_by_slug)}"] = page + reported_total = int(result.get("total") or 0) + remote_total = max(remote_total, reported_total, len(pages_by_slug)) + total_pages = max(1, int(result.get("total_pages") or 1)) + if not page_items or page_number >= total_pages or (reported_total > 0 and len(pages_by_slug) >= remote_total): + complete = True + break + return list(pages_by_slug.values()), max(remote_total, len(pages_by_slug)), complete + + +def _public_wiki_summary( + mapping_id: str, + pages: list[dict[str, Any]], + *, + total: int, + complete: bool, +) -> dict[str, Any]: + explicit_index = next((page for page in pages if _is_public_wiki_index_page(page)), None) + default_page = _public_wiki_default_page(pages) + if explicit_index is not None: + index_mode = "explicit" + index_slug: str | None = str(explicit_index.get("slug") or "") or None + elif pages: + index_mode = "generated" + index_slug = _GENERATED_PUBLIC_WIKI_INDEX_SLUG + else: + index_mode = "none" + index_slug = None + default_slug = index_slug or (str(default_page.get("slug") or "") if default_page else None) + route = f"/embed/knowledge/{quote(mapping_id, safe='')}" + if default_slug: + route = f"{route}?{urlencode({'wikiSlug': default_slug})}" + return { + "status": "ready" if pages else "empty", + "page_count": total, + "loaded_page_count": len(pages), + "stats_complete": complete, + "page_type_counts": _count_public_wiki_values(pages, "page_type", "page"), + "page_status_counts": _count_public_wiki_values(pages, "status", "unknown"), + "index_mode": index_mode, + "has_explicit_index": explicit_index is not None, + "index_slug": index_slug, + "default_slug": default_slug, + "created_at": _public_wiki_time_bound(pages, "created_at", latest=False), + "updated_at": _public_wiki_time_bound(pages, "updated_at", latest=True), + "embed_route": route, + } + + +def _unavailable_public_wiki_summary(mapping_id: str) -> dict[str, Any]: + return { + "status": "unavailable", + "page_count": 0, + "loaded_page_count": 0, + "stats_complete": False, + "page_type_counts": {}, + "page_status_counts": {}, + "index_mode": "unknown", + "has_explicit_index": False, + "index_slug": None, + "default_slug": None, + "created_at": None, + "updated_at": None, + "embed_route": f"/embed/knowledge/{quote(mapping_id, safe='')}", + } + + +def _public_local_index_summary( + request: Request, + mapping: dict[str, Any], + state: dict[str, Any] | None, +) -> dict[str, Any]: + app_state = request.app.state + local_config = getattr( + getattr(getattr(app_state, "config", None), "llmwiki", None), + "local_wiki_index", + None, + ) + enabled = bool(getattr(local_config, "enabled", False)) + current = dict(state or {}) + state_name = str(current.get("state") or "not_synced") + if state_name == "idle" and int(current.get("index_revision") or 0) == 0 and not current.get("last_completed_at") and not current.get("last_success_at"): + state_name = "not_synced" + embedding = getattr(app_state, "llmwiki_embedding", None) + fingerprint = embedding.fingerprint if embedding is not None else None + fully_vectorized = bool(mapping.get("wiki_index_enabled", True)) and is_fully_vectorized( + current, + fingerprint, + ) + return { + "enabled": enabled, + "wiki_index_enabled": bool(mapping.get("wiki_index_enabled", True)), + "state": state_name, + "fully_vectorized": fully_vectorized, + "retrieval_mode": "vector" if fully_vectorized else "wiki_api", + "remote_page_count": int(current.get("remote_page_count") or 0), + "local_page_count": int(current.get("local_page_count") or 0), + "ready_page_count": int(current.get("ready_page_count") or 0), + "failed_page_count": int(current.get("failed_page_count") or 0), + "vector_count": int(current.get("vector_count") or 0), + "last_started_at": current.get("last_started_at"), + "last_completed_at": current.get("last_completed_at"), + "last_success_at": current.get("last_success_at"), + "updated_at": current.get("updated_at"), + } + + @router.get("/api/public/llmwiki/knowledge-bases") async def list_public_knowledge_bases(request: Request) -> dict[str, Any]: - """List the minimal identity of every knowledge base published as a public Wiki.""" + """List live, published Wiki libraries with safe metadata and index health.""" - rows = await _store(request).list_visible( - "__public_wiki__", - scope="public", - is_admin=False, - ) - knowledge_bases = [ - { - "id": str(row["id"]), - "name": str(row.get("name") or ""), - } - for row in rows - if row.get("publication_status") == "published" - and not _is_conversation_deposit_mapping(row) + rows = [ + row + for row in await _store(request).list_visible( + "__public_wiki__", + scope="public", + is_admin=False, + ) + if row.get("publication_status") == "published" and not _is_conversation_deposit_mapping(row) ] + client = _client_or_503() + try: + remote_items = await client.list_knowledge_bases() + except WeKnoraError as exc: + _raise_upstream(exc) + remote_by_id = {str(item.get("id")): item for item in remote_items if isinstance(item, dict) and item.get("id")} + # A successful upstream list is authoritative: stale DeerFlow mappings are + # omitted so discovery never advertises a Wiki that its detail route cannot open. + rows = [row for row in rows if str(row.get("weknora_id") or "") in remote_by_id] + mapping_ids = [str(row["id"]) for row in rows] + index_states: dict[str, dict[str, Any]] = {} + index_store = getattr(request.app.state, "llmwiki_index_store", None) + if index_store is not None and mapping_ids: + try: + index_states = {str(state.get("knowledge_base_mapping_id") or ""): state for state in await index_store.list_index_status(mapping_ids) if isinstance(state, dict)} + except Exception: + logger.warning("Failed to read public Wiki local-index summaries", exc_info=True) + + semaphore = asyncio.Semaphore(_PUBLIC_WIKI_SUMMARY_CONCURRENCY) + + async def build_summary(row: dict[str, Any]) -> dict[str, Any]: + mapping_id = str(row["id"]) + remote_id = str(row["weknora_id"]) + remote = remote_by_id[remote_id] + async with semaphore: + try: + remote = {**remote, **await client.get_knowledge_base(remote_id)} + except WeKnoraError as exc: + if exc.status_code == 404: + return {} + logger.warning( + "Failed to load public Wiki detail metadata: mapping_id=%s", + mapping_id, + exc_info=True, + ) + try: + pages, page_total, complete = await _all_public_wiki_page_views(client, remote_id) + wiki = _public_wiki_summary( + mapping_id, + pages, + total=page_total, + complete=complete, + ) + except WeKnoraError as exc: + logger.warning( + "Failed to load public Wiki page summary: mapping_id=%s", + mapping_id, + exc_info=True, + ) + wiki = ( + _public_wiki_summary(mapping_id, [], total=0, complete=True) + if exc.status_code == 404 + else _unavailable_public_wiki_summary(mapping_id) + ) + return { + **_mapping_view( + row, + remote, + actor_user_id="__public_wiki__", + is_admin=False, + ), + "wiki": wiki, + "local_index": _public_local_index_summary( + request, + row, + index_states.get(mapping_id), + ), + } + + knowledge_bases = [summary for summary in await asyncio.gather(*(build_summary(row) for row in rows)) if summary] return {"knowledge_bases": knowledge_bases, "total": len(knowledge_bases)} @@ -2596,13 +2831,7 @@ async def list_public_wiki_pages( except WeKnoraError as exc: _raise_upstream(exc) raw_pages = result.get("pages") if isinstance(result, dict) else [] - pages = [ - view - for page_item in (raw_pages if isinstance(raw_pages, list) else []) - if isinstance(page_item, dict) - for view in [_wiki_page_view(page_item)] - if view is not None - ] + pages = [view for page_item in (raw_pages if isinstance(raw_pages, list) else []) if isinstance(page_item, dict) for view in [_wiki_page_view(page_item)] if view is not None] return {**result, "pages": pages} @@ -2650,11 +2879,7 @@ def _public_wiki_index_item(value: Any) -> dict[str, Any] | None: "slug": slug, "title": str(value.get("title") or slug), "summary": str(value.get("summary") or ""), - "category_path": ( - [str(item) for item in value.get("category_path", []) if str(item).strip()] - if isinstance(value.get("category_path"), list) - else [] - ), + "category_path": ([str(item) for item in value.get("category_path", []) if str(item).strip()] if isinstance(value.get("category_path"), list) else []), "wiki_path": str(value.get("wiki_path") or ""), "depth": int(value.get("depth") or 0), } @@ -2680,20 +2905,12 @@ async def _all_public_wiki_index(client: WeKnoraClient, remote_id: str) -> dict[ version = max(version, int(payload.get("version") or 0)) raw_groups = payload.get("groups") if isinstance(payload.get("groups"), list) else [] group = next( - ( - value - for value in raw_groups - if isinstance(value, dict) and value.get("type") == page_type - ), + (value for value in raw_groups if isinstance(value, dict) and value.get("type") == page_type), {}, ) total = max(total, int(group.get("total") or 0)) raw_items = group.get("items") if isinstance(group.get("items"), list) else [] - items.extend( - item - for value in raw_items - if (item := _public_wiki_index_item(value)) is not None - ) + items.extend(item for value in raw_items if (item := _public_wiki_index_item(value)) is not None) next_cursor = str(group.get("next_cursor") or "") if not next_cursor or next_cursor in seen_cursors: break @@ -3201,12 +3418,7 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any except WikiIndexError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None mapping_by_id = {str(row["id"]): row for row in rows} - result["results"] = [ - _sanitize_conversation_deposit_value(item) - if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {})) - else item - for item in result.get("results") or [] - ] + result["results"] = [_sanitize_conversation_deposit_value(item) if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {})) else item for item in result.get("results") or []] return result diff --git a/offline-backend-20260512/backend/tests/test_artifact_library.py b/offline-backend-20260512/backend/tests/test_artifact_library.py new file mode 100644 index 00000000..98665552 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_artifact_library.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.gateway import deps +from app.gateway.routers import artifacts + + +class _FakeThreadStore: + def __init__(self, rows: list[dict]) -> None: + self.rows = rows + self.calls: list[dict] = [] + + async def search(self, **kwargs) -> list[dict]: + self.calls.append(kwargs) + offset = int(kwargs.get("offset", 0)) + limit = int(kwargs.get("limit", 100)) + return self.rows[offset : offset + limit] + + +@pytest.mark.asyncio +async def test_artifact_library_aggregates_generated_non_media_files(tmp_path: Path, monkeypatch): + thread_one = tmp_path / "thread-one" / "outputs" + thread_two = tmp_path / "thread-two" / "outputs" + thread_one.mkdir(parents=True) + thread_two.mkdir(parents=True) + + report = thread_one / "研判报告.docx" + table = thread_one / "数据汇总.xlsx" + image = thread_one / "配图.png" + audio = thread_two / "录音.mp3" + notes = thread_two / "结论.md" + archive = thread_two / "证据.zip" + for path, content in ( + (report, b"doc"), + (table, b"sheet"), + (image, b"image"), + (audio, b"audio"), + (notes, b"# notes"), + (archive, b"zip"), + ): + path.write_bytes(content) + + os.utime(report, (100, 100)) + os.utime(table, (200, 200)) + os.utime(notes, (300, 300)) + os.utime(archive, (400, 400)) + + rows = [ + { + "thread_id": "thread-one", + "display_name": "战场环境综合研判", + "metadata": {"agent_id": "analysis-agent"}, + }, + { + "thread_id": "thread-two", + "display_name": "生成发展报告", + "metadata": {}, + }, + ] + store = _FakeThreadStore(rows) + + class _Paths: + def sandbox_outputs_dir(self, thread_id: str, *, user_id: str) -> Path: + assert user_id == "owner" + return tmp_path / thread_id / "outputs" + + async def _resolve_owner(_thread_id: str) -> str: + return "owner" + + monkeypatch.setattr(artifacts, "get_paths", lambda: _Paths()) + monkeypatch.setattr(artifacts, "aresolve_path_user_id", _resolve_owner) + + items = await artifacts._scan_artifact_library(store) + + assert [item["name"] for item in items] == ["证据.zip", "结论.md", "数据汇总.xlsx", "研判报告.docx"] + assert [item["file_kind"] for item in items] == ["archive", "text_data", "spreadsheet", "document"] + assert items[-1]["thread_title"] == "战场环境综合研判" + assert items[-1]["agent_id"] == "analysis-agent" + assert all(item["name"] not in {"配图.png", "录音.mp3"} for item in items) + assert store.calls[0]["exclude_system"] is True + + +@pytest.mark.asyncio +async def test_artifact_library_endpoint_filters_searches_and_pages(tmp_path: Path, monkeypatch): + output_dir = tmp_path / "thread-one" / "outputs" + output_dir.mkdir(parents=True) + (output_dir / "任务报告.md").write_text("report", encoding="utf-8") + (output_dir / "任务数据.csv").write_text("a,b", encoding="utf-8") + + store = _FakeThreadStore( + [ + { + "thread_id": "thread-one", + "display_name": "任务研判对话", + "metadata": {}, + } + ] + ) + + class _Paths: + def sandbox_outputs_dir(self, thread_id: str, *, user_id: str) -> Path: + return tmp_path / thread_id / "outputs" + + async def _resolve_owner(_thread_id: str) -> str: + return "owner" + + monkeypatch.setattr(artifacts, "get_paths", lambda: _Paths()) + monkeypatch.setattr(artifacts, "aresolve_path_user_id", _resolve_owner) + monkeypatch.setattr(artifacts, "get_effective_user_id", lambda: "owner") + monkeypatch.setattr(deps, "get_thread_store", lambda _request: store) + artifacts._artifact_library_cache.clear() + + raw_endpoint = artifacts.list_artifact_library.__wrapped__ + response = await raw_endpoint( + request=SimpleNamespace(), + query="研判对话", + file_kind="text_data", + offset=0, + limit=1, + refresh=True, + ) + + assert response.total == 1 + assert response.offset == 0 + assert response.limit == 1 + assert [item.name for item in response.items] == ["任务报告.md"] + + spreadsheet_response = await raw_endpoint( + request=SimpleNamespace(), + query="任务数据", + file_kind="spreadsheet", + offset=0, + limit=50, + refresh=False, + ) + assert spreadsheet_response.total == 1 + assert spreadsheet_response.items[0].name == "任务数据.csv" diff --git a/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py b/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py index 658e6043..e1ec9345 100644 --- a/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py +++ b/offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py @@ -68,7 +68,7 @@ def _published_row() -> dict[str, object]: @pytest.mark.asyncio -async def test_public_knowledge_base_list_exposes_only_published_wiki_identity() -> None: +async def test_public_knowledge_base_list_exposes_live_safe_metadata_and_generated_index(monkeypatch) -> None: store = PublicListStore( [ { @@ -83,21 +83,116 @@ async def test_public_knowledge_base_list_exposes_only_published_wiki_identity() "owner_user_id": "system", "name": "对话沉淀", }, + { + **_published_row(), + "id": "stale-kb", + "weknora_id": "remote-missing", + }, ] ) + class Client: + async def list_knowledge_bases(self): + return [ + { + "id": "remote-one", + "name": "远端公开知识库", + "description": "远端公开说明", + "type": "wiki", + "knowledge_count": 12, + "chunk_count": 34, + "processing_count": 1, + "share_count": 3, + "created_at": "2026-07-01T08:00:00+08:00", + "updated_at": "2026-08-01T08:00:00+08:00", + "tenant_id": 10001, + "creator_id": "secret-creator", + "storage_config": {"secret_key": "must-not-leak"}, + } + ] + + async def get_knowledge_base(self, knowledge_base_id: str): + assert knowledge_base_id == "remote-one" + return { + "id": knowledge_base_id, + "vector_store_name": "System default", + "vector_store_source": "env", + "vector_store_engine_type": "elasticsearch", + "vector_store_status": "available", + "wiki_config": {"enabled": True}, + } + + async def list_wiki_pages(self, knowledge_base_id: str, **kwargs): + assert knowledge_base_id == "remote-one" + assert kwargs == {"page": 1, "page_size": 500, "query": ""} + return { + "pages": [ + { + "id": "page-one", + "slug": "concept/start", + "title": "开始使用", + "page_type": "concept", + "status": "completed", + "created_at": "2026-07-02T08:00:00+08:00", + "updated_at": "2026-08-02T08:00:00+08:00", + }, + { + "id": "page-two", + "slug": "summary/overview", + "title": "知识概览", + "page_type": "summary", + "status": "completed", + "created_at": "2026-07-03T08:00:00+08:00", + "updated_at": "2026-08-03T08:00:00+08:00", + }, + ], + "total": 2, + "page": 1, + "page_size": 500, + "total_pages": 1, + } + + monkeypatch.setattr(llmwiki, "_client_or_503", lambda: Client()) + payload = await llmwiki.list_public_knowledge_bases(_request(store)) assert store.calls == [("__public_wiki__", "public", False)] - assert payload == { - "knowledge_bases": [ - { - "id": "1063edee-332a-43b4-bbd9-73bf0a708b94", - "name": "公开知识库", - } - ], - "total": 1, + assert payload["total"] == 1 + item = payload["knowledge_bases"][0] + assert item["id"] == "1063edee-332a-43b4-bbd9-73bf0a708b94" + assert item["name"] == "远端公开知识库" + assert item["description"] == "远端公开说明" + assert item["type"] == "wiki" + assert item["knowledge_count"] == 12 + assert item["chunk_count"] == 34 + assert item["processing_count"] == 1 + assert item["share_count"] == 3 + assert item["is_processing"] is True + assert item["remote_created_at"] == "2026-07-01T08:00:00+08:00" + assert item["remote_updated_at"] == "2026-08-01T08:00:00+08:00" + assert item["vector_store_engine_type"] == "elasticsearch" + assert item["wiki"] == { + "status": "ready", + "page_count": 2, + "loaded_page_count": 2, + "stats_complete": True, + "page_type_counts": {"concept": 1, "summary": 1}, + "page_status_counts": {"completed": 2}, + "index_mode": "generated", + "has_explicit_index": False, + "index_slug": "__index__", + "default_slug": "__index__", + "created_at": "2026-07-02T08:00:00+08:00", + "updated_at": "2026-08-03T08:00:00+08:00", + "embed_route": "/embed/knowledge/1063edee-332a-43b4-bbd9-73bf0a708b94?wikiSlug=__index__", } + assert item["local_index"]["state"] == "not_synced" + assert item["local_index"]["fully_vectorized"] is False + assert item["owner_user_id"] is None + assert "weknora_id" not in item + assert "tenant_id" not in item + assert "creator_id" not in item + assert "storage_config" not in item @pytest.mark.asyncio @@ -178,6 +273,35 @@ async def test_public_wiki_page_list_is_normalized(monkeypatch) -> None: assert payload["pages"][0]["aliases"] == [] +def test_public_wiki_summary_prefers_an_explicit_index_page() -> None: + payload = llmwiki._public_wiki_summary( + "mapping-one", + [ + { + "slug": "guide/start", + "title": "开始使用", + "page_type": "article", + "status": "completed", + }, + { + "slug": "home", + "title": "知识首页", + "page_type": "index", + "status": "completed", + }, + ], + total=2, + complete=True, + ) + + assert payload["status"] == "ready" + assert payload["index_mode"] == "explicit" + assert payload["has_explicit_index"] is True + assert payload["index_slug"] == "home" + assert payload["default_slug"] == "home" + assert payload["embed_route"] == "/embed/knowledge/mapping-one?wikiSlug=home" + + @pytest.mark.asyncio async def test_public_wiki_file_is_authorized_and_streamed(monkeypatch) -> None: store = PublicStore(_published_row())