# 知识库对象虚拟动作设计

## 1. 背景

当前 datacloud-data 已支持从 OWL 解析对象定义，并为不同数据源类型生成可执行动作。知识库对象的来源类型为 `KNOWLEDGE_BASE`，现阶段已经具备 chunk 级语义检索能力，执行链路主要包括：

- OWL `EntityDefinition` 解析对象元数据；
- `OntologyClass.ext_property` 保存对象级扩展属性；
- `build_search_schema()` 生成 `search_*` 虚拟动作入参协议；
- `KbSearchExecutor` 执行知识库检索虚拟动作；
- `KnowledgeSearchBackend` / `HttpKnowledgeSearchBackend` 调用知识库元数据服务。

本设计目标是在保持现有对象模型简洁的前提下，为知识库对象动态构建三个虚拟动作：

- `search_{object_code}`：按标签和检索文本查询知识库文件；
- `search_by_file_name_{object_code}`：按目录、文件名称和检索文本查询知识库 chunk，并可聚合返回文本内容；
- `write_{object_code}`：向知识库写入文件文本和标签元数据。

## 2. 设计目标

1. 知识库绑定信息只通过对象级 `ext_property` 扩展，不在 `OntologyClass` 上增加显式字段。
2. 动态表不再向 `ext_property` 写入表编码，继续使用定义中已有的表编码和物理映射。
3. 知识库写入动作复用当前知识库检索执行链路的后端抽象和执行器，不新增平行的 `KbWriteBackend` 或 `KbWriteExecutor`。
4. 动作协议保持稳定、可描述、可测试，便于 Agent 根据 schema 自动调用。
5. HTTP 元数据服务细节封装在 backend 内部，SDK 对外只暴露统一的 action 参数。

## 3. 非目标

1. 不改造动态表对象的表编码来源。
2. 不在 OWL 顶层新增 `kb_id`、`kb_directory`、`dynamic_table_code` 等强类型字段。
3. 不把运行时入参 `labels`、`source_path`、`content` 存入 OWL。
4. 不在本阶段设计完整的知识库文件上传、切片、向量化任务调度系统，只定义 SDK 到知识库服务的写入调用边界。

## 4. OWL 扩展属性约定

知识库对象的 `EntityDefinition.ext_property` 用于保存对象与具体知识库的绑定关系。该字段为 JSON 字符串，解析后进入 `OntologyClass.ext_property`。

OWL 示例：

```xml
<ext_property rdf:datatype="http://www.w3.org/2001/XMLSchema#string">
  {&quot;kb_id&quot;:&quot;kb-sales&quot;,&quot;kb_directory&quot;:&quot;/sales/meeting&quot;}
</ext_property>
```

解析后的 Python 结构：

```python
{
    "kb_id": "kb-sales",
    "kb_directory": "/sales/meeting",
    "backend": "http_knowledge_import",
}
```

字段含义：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `kb_id` | string | 建议必填 | 知识库 ID 或编码，对应元数据服务中的 `knCode`、`knCodeList` 等知识库范围参数 |
| `kb_directory` | string | 可选 | 知识库目录前缀，用于限定文件写入或检索范围 |
| `backend` | string | 可选 | 对象级 backend 实现选择；不配置时使用知识库默认 backend |

`OntologyClass` 只保留通用字段：

```python
ext_property: dict[str, Any]
```

业务需要知识库绑定信息时，通过 `cls.ext_property.get("kb_id")` 和 `cls.ext_property.get("kb_directory")` 获取。需要选择对象级 backend 时，通过 `cls.ext_property.get("backend")` 获取。

### 4.1 Backend 选择规则

知识库对象允许通过 `ext_property.backend` 指定使用哪个 backend 实现。不配置时使用默认实现。backend 实例应由开发者在应用启动阶段提前构建并注入 `OntologyLoader`，OWL 不负责定义 backend 的连接配置。

优先级：

1. `OntologyClass.ext_property["backend"]`；
2. `kb_source_configs[datasource_alias]["backend"]`；
3. 系统默认 backend，例如 `http_knowledge_import`。

示例：

```json
{
  "kb_id": "kb-sales",
  "kb_directory": "/sales/meeting",
  "backend": "http_knowledge_import"
}
```

约束：

1. `backend` 是逻辑名称，应从 SDK 注册表或配置中解析到具体实现类。
2. backend 实现类、endpoint、token、timeout、鉴权方式等由开发者实现和配置。
3. OWL 的 `ext_property` 不保存 endpoint、token、密钥等 backend 配置信息。
4. endpoint、token、timeout 等运行时参数继续从预构建 backend 实例、`kb_source_configs[datasource_alias]` 或 LoaderConfig 注入。
5. 如果 `backend` 未注册，应返回明确配置错误，不静默降级到默认 backend。

推荐 LoaderConfig 形态：

```python
LoaderConfig(
    kb_backends={
        "http_knowledge_import": HttpKnowledgeActionBackend(...),
        "mock_kb": MockKnowledgeActionBackend(...),
    },
    default_kb_backend="http_knowledge_import",
)
```

解析规则：

```python
backend_name = (
    cls.ext_property.get("backend")
    or kb_source_configs[datasource_alias].get("backend")
    or loader_config.default_kb_backend
)
backend = loader_config.kb_backends[backend_name]
```

这样 OWL 只表达“这个对象想用哪个 backend”，具体 backend 怎么连、怎么鉴权、怎么重试，全部由开发者在代码或运行时配置中实现。

## 5. 动态表对象约定

动态表不需要在 `ext_property` 中额外保存表信息。原因：

1. 对象定义已经有表编码或来源表编码。
2. 加入 `dynamic_table_code` 会造成对象定义和扩展字段两处表达同一信息。
3. 后续执行 DB 查询时应继续使用 `OntologyClass.table_name`、字段 `source_column`、`physical_mappings` 等已有模型。

因此 `ext_property` 当前只承担知识库对象扩展绑定职责，不作为动态表物理表信息的第二来源。

## 6. 虚拟动作设计

### 6.1 动作生成规则

当对象满足以下条件时，注入知识库虚拟动作：

```python
cls.source_type == "KNOWLEDGE_BASE"
```

需要动态注入两个动作：

| 动作 | action_family | virtual_backend | 用途 |
| --- | --- | --- | --- |
| `search_{object_code}` | `search` | `kb_search` | 文件级语义检索 |
| `search_by_file_name_{object_code}` | `search_by_file_name` | `kb_search` | 按目录和文件名称限定范围的 chunk 级语义检索 |
| `write_{object_code}` | `write` | `kb_search` | 写入文件文本和标签元数据 |

这里 `write_*` 的 `virtual_backend` 继续使用 `kb_search` 或重命名为更通用的 `kb`。推荐先保持 `kb_search`，减少路由和配置改动；后续如需要表达更准确，可整体迁移为 `kb`，并保留兼容别名。

### 6.2 search 动作协议

`search_{object_code}` 保持当前文件级检索协议：

```json
{
  "query": "续签流程",
  "select": ["status", "tags"],
  "filters": [
    {"field": "status", "op": "eq", "value": "active"}
  ],
  "filter_relation": "AND",
  "order_by": [],
  "limit": 20,
  "offset": 0
}
```

必填字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `query` | string | 语义检索文本 |

可选字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `select` | array[string] | 需要返回的知识库属性字段 |
| `filters` | array[object] | 知识库属性标签过滤条件 |
| `filter_relation` | string | `AND` 或 `OR` |
| `order_by` | array[object] | 排序规则 |
| `limit` | integer | 返回数量 |
| `offset` | integer | 偏移量，当前 HTTP searchFile 服务可暂不使用 |

后端请求映射到元数据服务：

```json
{
  "query": "续签流程",
  "where": {
    "eq": {"fieldName": "status", "value": "active"}
  },
  "metadataFieldList": ["status", "tags"],
  "topK": 20,
  "searchMode": "mixedRecall",
  "knCodeList": ["kb-sales"]
}
```

`knCodeList` 从 `cls.ext_property.kb_id` 获取；对象未配置 `kb_id` 时不从 `kb_source_configs` 回退，避免运行时连接配置承载对象绑定语义。

### 6.3 search_by_file_name 动作协议

`search_by_file_name_{object_code}` 是新增专用动作，使用升级后的 `POST /api/v1/knowledgeItems/search` chunk 级语义检索接口。动作入参只需要 `query` 和 `fileName`；知识库范围 `knCodeList` 与目录条件从对象 `ext_property.kb_id`、`ext_property.kb_directory` 获取。

```json
{
  "query": "续签流程",
  "fileName": "续签流程.md"
}
```

必填字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `query` | string | 语义检索文本 |
| `fileName` | string | 文件名称过滤条件，编译为系统字段 `fileName` 精确匹配 |

后端请求示例：

```json
{
  "query": "续签流程",
  "where": {
    "and": [
      {"eq": {"fieldName": "fileName", "value": "续签流程.md"}},
      {"prefix": {"fieldName": "filePath", "value": "/制度/人事/"}}
    ]
  },
  "metadataFieldList": ["status"],
  "topK": 20,
  "searchMode": "mixedRecall",
  "knCodeList": ["kb-sales"]
}
```

返回对象保持统一 `records`、`total`、`meta` 结构。chunk 级命中记录会保留服务端返回的全部核心属性，包括 `knCode`、`filePath`、`chunkId`、`chunkNo`、`chunkText`、`score`、`startLine`、`endLine`，并把 `chunkText` 同步为通用 `content` 字段。`metadata` 中的字段按对象属性编码展开到 record 顶层。同一 `filePath` 的多个 chunk 会聚合为一条 record，`content` 为按命中顺序拼接后的文本。

### 6.4 write 动作协议

`write_{object_code}` 用于把文件文本及标签元数据写入知识库。

入参 schema：

```json
{
  "labels": {
    "type": "object",
    "additionalProperties": false,
    "description": "知识库属性标签，key 为对象属性编码，value 类型由对象属性类型决定",
    "properties": {
      "customer_name": {
        "type": "string",
        "description": "客户名称"
      },
      "meeting_type": {
        "type": "string",
        "description": "会议类型"
      },
      "tags": {
        "type": "array",
        "items": {"type": "string"},
        "description": "标签"
      },
      "meeting_date": {
        "type": "string",
        "description": "会议日期，格式 yyyy-MM-dd"
      }
    }
  },
  "source_path": {
    "type": "string",
    "description": "上传到知识库后的文件全路径，以 / 开头，不包括知识库名称，如 /sales/meeting/a.md"
  },
  "content": {
    "type": "string",
    "description": "源文件文本信息"
  }
}
```

推荐必填字段：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `content` | string | 是 | 源文件正文文本，不能为空 |
| `labels` | object | 否 | 写入或更新到知识库文件上的属性标签；字段和类型由当前对象属性定义生成 |
| `source_path` | string | 是 | 上传到知识库后的文件全路径，对应 import 接口 `filePath`，以 `/` 开头，不包括知识库名称 |

调用示例：

```json
{
  "labels": {
    "customer_name": "白银有色",
    "meeting_type": "销售复盘",
    "tags": ["sales", "meeting"]
  },
  "source_path": "/sales/meeting/2026-05-18-review.md",
  "content": "会议讨论了本月销售回款、重点客户跟进和下周行动计划。"
}
```

标准返回与现有 action 返回协议保持一致，统一为 `records`、`total`、`meta`。其中 `meta` 是 `records` 的字段描述，不放执行上下文。写入成功后返回 1 条记录，记录中包含文件路径、标签和写入内容：

```json
{
  "records": [
    {
      "filePath": "/sales/meeting/2026-05-18-review.md",
      "content": "会议讨论了本月销售回款、重点客户跟进和下周行动计划。",
      "customer_name": "白银有色",
      "meeting_type": "销售复盘",
      "tags": ["sales", "meeting"]
    }
  ],
  "total": 1,
  "meta": {
    "columns": [
      {
        "name": "filePath",
        "label": "文件路径",
        "type": "string",
        "description": "知识库内文件全路径"
      },
      {
        "name": "content",
        "label": "文件内容",
        "type": "string",
        "description": "写入的源文件文本内容"
      },
      {
        "name": "customer_name",
        "label": "客户名称",
        "type": "string",
        "description": "知识库属性标签"
      },
      {
        "name": "meeting_type",
        "label": "会议类型",
        "type": "string",
        "description": "知识库属性标签"
      },
      {
        "name": "tags",
        "label": "标签",
        "type": "array",
        "description": "知识库属性标签"
      }
    ]
  }
}
```

`search_{object_code}` 的返回也保持同一形态。每条 record 应包含：

| 字段 | 说明 |
| --- | --- |
| `filePath` | 知识库文件路径 |
| `content` | 文件内容摘要、chunk 内容或服务端返回的可读文本 |
| 标签字段 | 对象属性字段，如 `customer_name`、`meeting_type`、`tags` |
| `score` | 检索相关度分数，search 动作返回时可选 |

`meta.fields` 必须描述 `records` 中可出现的字段：

| meta 字段 | 类型 | 说明 |
| --- | --- | --- |
| `name` | string | record 字段名 |
| `label` | string | 字段显示名 |
| `type` | string | 字段类型，来自固定字段或对象属性类型 |
| `description` | string | 字段说明 |

执行上下文如 `object_code`、`datasource_alias`、`kb_id`、`kb_directory` 不作为 `meta` 的主内容返回；如后续确实需要调试信息，应放到 `meta.runtime`，并且不能影响 `meta.fields` 作为字段描述的稳定语义。

## 7. 后端与执行器复用方案

### 7.1 Backend 协议扩展

现有文件 `kb_search_backend.py` 可以扩展为“知识库动作后端”，保留文件名以减少改动。协议新增 write 请求和结果模型。

建议新增数据类：

```python
@dataclass(frozen=True)
class KnowledgeWriteRequest:
    object_code: str
    datasource_alias: str
    kb_id: str | None
    kb_directory: str | None
    labels: dict[str, Any]
    source_path: str
    content: str
    file_description: str | None = None


@dataclass(frozen=True)
class KnowledgeWriteResult:
    records: list[dict[str, Any]]
    total: int
    meta: dict[str, Any] = field(default_factory=dict)

    def to_response(self) -> dict[str, Any]:
        return {
            "records": self.records,
            "total": self.total,
            "meta": self.meta,
        }
```

协议扩展：

```python
class KnowledgeSearchBackend(Protocol):
    async def search(self, request: KnowledgeSearchRequest) -> KnowledgeSearchResult:
        """Execute a knowledge-base search request."""

    async def write(self, request: KnowledgeWriteRequest) -> KnowledgeWriteResult:
        """Write text content and labels into a knowledge base."""
```

如果担心已有测试中的 mock backend 只实现 `search()`，可以采用渐进方式：

1. 先定义 `KnowledgeActionBackend` 新协议，包含 `search()` 和 `write()`；
2. `KbSearchExecutor` 内部接受 `KnowledgeSearchBackend | KnowledgeActionBackend`；
3. 执行 write 时检查 backend 是否有 `write` 方法，没有则返回明确错误。

推荐最终状态是统一协议同时包含 `search()` 和 `write()`，因为知识库对象对 SDK 来说是同一种 backend 能力。

### 7.2 Executor 扩展

不新增 `KbWriteExecutor`。在现有 `KbSearchExecutor` 中增加 write 分支。

推荐结构：

```python
class KbSearchExecutor:
    async def execute(
        self,
        object_code: str,
        arguments: dict[str, Any],
        *,
        action_family: str = "search",
    ) -> dict[str, Any]:
        if action_family == "write":
            return await self.execute_write(object_code, arguments)
        return await self.execute_search(object_code, arguments)

    async def execute_search(
        self,
        object_code: str,
        arguments: dict[str, Any],
    ) -> dict[str, Any]:
        ...

    async def execute_write(
        self,
        object_code: str,
        arguments: dict[str, Any],
    ) -> dict[str, Any]:
        ...
```

`execute_write()` 负责：

1. 读取对象 `cls = loader.get_ontology_class(object_code)`；
2. 校验 `cls.source_type == "KNOWLEDGE_BASE"`；
3. 读取 `datasource_alias`；
4. 读取 `cls.ext_property.kb_id` 和 `cls.ext_property.kb_directory`；
5. 校验 `source_path` 非空且以 `/` 开头；
6. 校验 `content` 非空；
7. 规范化 `labels` 为 `dict[str, Any]`；
8. 构建 `KnowledgeWriteRequest`；
9. 调用 `backend.write()`；
10. 返回 `KnowledgeWriteResult.to_response()`。

### 7.3 Action 路由

当前对象级虚拟动作按 `action_family` 路由：

```python
if action_family == "search":
    return await KbSearchExecutor(self._loader).execute(object_code, params)
```

需要新增 write 分支：

```python
if action_family == "write":
    from datacloud_data_sdk.executor.kb_search_executor import KbSearchExecutor

    return await KbSearchExecutor(self._loader).execute(
        object_code,
        params,
        action_family="write",
    )
```

也可以把 search 分支改为显式传参：

```python
if action_family == "search":
    return await KbSearchExecutor(self._loader).execute(
        object_code,
        params,
        action_family="search",
    )
```

## 8. HTTP 服务映射

### 8.1 search 映射

`search_{object_code}` 默认检索接口：

```text
POST /api/v1/knowledgeItems/searchFile
```

请求字段映射：

| SDK 字段 | HTTP 字段 | 说明 |
| --- | --- | --- |
| `query` | `query` | 检索文本 |
| `limit` | `topK` | 返回数量 |
| `filters` | `where` | 转换为 Agent DSL 过滤 AST |
| `select` | `metadataFieldList` | 需要返回的标签字段 |
| `kb_id` | `knCodeList` | 从 `ext_property` 或配置读取 |

`search_by_file_name_{object_code}` 默认检索接口：

```text
POST /api/v1/knowledgeItems/search
```

请求字段映射：

| SDK 字段 | HTTP 字段 | 说明 |
| --- | --- | --- |
| `query` | `query` | 检索文本 |
| 固定值 20 | `topK` | 返回 chunk 数量 |
| `fileName` | `where.fileName` | 固定文件名称条件，使用 `eq` 操作符 |
| `ext_property.kb_directory` | `where.filePath` | 固定目录条件，使用 `prefix` 操作符，与文件名合取 |
| 对象字段编码列表 | `metadataFieldList` | 返回当前知识库对象声明的元数据字段 |
| `ext_property.kb_id` | `knCodeList` | 知识库范围 |

### 8.2 write 映射

知识库写入使用文档导入接口：

```text
POST /api/v1/knowledgeItems/import
Content-Type: multipart/form-data
```

接口字段：

| 表单字段 | 类型 | 必填 | SDK 来源 |
| --- | --- | --- | --- |
| `knCode` | string | 是 | `cls.ext_property.kb_id` |
| `filePath` | string | 是 | `arguments.source_path` |
| `fileDescription` | string | 否 | 可由 `arguments.file_description` 扩展，默认可为空 |
| `fileContent` | file | 是 | 由 `arguments.content` 动态构造的 Markdown 文件内容 |

`labels` 不作为独立 multipart 字段提交。写入时需要把 `labels` 序列化为 Markdown YAML front matter header，拼接到 `content` 前面，再作为 `fileContent` 上传。知识库服务会解析合法 YAML front matter，并按同名 `propertyName` 自动写入文件元数据。如果 `labels` 中存在知识库系统未定义的属性，服务端会返回上传失败。

推荐配置：

```python
kb_source_configs = {
    "kb_main": {
        "endpoint": "http://knowledge-service",
        "search_file_path": "/api/v1/knowledgeItems/searchFile",
        "search_path": "/api/v1/knowledgeItems/search",
        "import_path": "/api/v1/knowledgeItems/import",
        "search_mode": "mixedRecall",
    }
}
```

SDK write 入参：

```json
{
  "source_path": "/sales/meeting/2026-05-18-review.md",
  "labels": {
    "customer_name": "白银有色",
    "meeting_type": "销售复盘",
    "tags": ["sales", "meeting"]
  },
  "content": "会议正文文本"
}
```

构造后的 `fileContent`：

```markdown
---
customer_name: 白银有色
meeting_type: 销售复盘
tags:
  - sales
  - meeting
---

会议正文文本
```

multipart 调用示例：

```bash
curl -X POST http://knowledge-service/api/v1/knowledgeItems/import \
  -F "knCode=kb-sales" \
  -F "filePath=/sales/meeting/2026-05-18-review.md" \
  -F "fileDescription=销售复盘会议纪要" \
  -F "fileContent=@./2026-05-18-review.md"
```

实现时不要求本地落盘生成临时文件，可以直接用 `httpx` multipart files 参数传入内存字节：

```python
files = {
    "fileContent": (
        filename,
        rendered_markdown.encode("utf-8"),
        "text/markdown",
    )
}
data = {
    "knCode": request.kb_id,
    "filePath": request.source_path,
}
```

## 9. Schema 生成器设计

在 `virtual_action/generator.py` 中新增：

```python
def build_write_schema(scope_name: str, fields: list[Any]) -> dict[str, Any]:
    ...


def build_write_description(
    scope_name: str,
    scope_description: str,
    fields: list[Any],
) -> str:
    ...
```

`labels` 的 schema 必须根据当前知识库对象声明的属性动态生成。规则如下：

1. `labels.properties` 的 key 使用 `OntologyField.field_code`。
2. 每个 label 的 `description` 使用 `OntologyField.field_name` 和字段描述。
3. 每个 label 的值类型由 `OntologyField.field_type` 推导。
4. 默认 `additionalProperties: false`，禁止 Agent 写入当前对象未声明的标签字段。
5. 如果未来需要支持服务端临时扩展标签，应通过显式配置打开扩展能力，不作为默认行为。

字段类型映射：

| `OntologyField.field_type` | JSON Schema | 说明 |
| --- | --- | --- |
| `STRING` | `{"type": "string"}` | 普通文本标签 |
| `TEXT` | `{"type": "string"}` | 长文本标签 |
| `DATE` | `{"type": "string"}` | 日期字符串，优先使用字段 `data_format` 描述格式 |
| `DATETIME` / `TIMESTAMP` | `{"type": "string"}` | 日期时间字符串 |
| `NUMBER` / `DECIMAL` / `DOUBLE` / `FLOAT` | `{"type": "number"}` | 数值标签 |
| `INTEGER` / `BIGINT` | `{"type": "integer"}` | 整数标签 |
| `BOOLEAN` | `{"type": "boolean"}` | 布尔标签 |
| `ARRAY` | `{"type": "array", "items": {"type": "string"}}` | 默认字符串数组；如字段有元素类型配置，再按元素类型生成 |
| `OBJECT` | `{"type": "object"}` | 结构化标签，默认允许内部扩展 |
| 未识别类型 | `{"type": "string"}` | 保守退化为字符串 |

生成示例：

```json
{
  "labels": {
    "type": "object",
    "description": "知识库属性标签，key 必须使用当前对象声明的属性编码",
    "additionalProperties": false,
    "properties": {
      "customer_name": {
        "type": "string",
        "description": "客户名称"
      },
      "amount": {
        "type": "number",
        "description": "销售金额"
      },
      "meeting_date": {
        "type": "string",
        "description": "会议日期，格式 yyyy-MM-dd"
      },
      "tags": {
        "type": "array",
        "items": {"type": "string"},
        "description": "标签"
      }
    },
    "x-dc-field-catalog": [
      {"name": "客户名称", "code": "customer_name"},
      {"name": "销售金额", "code": "amount"},
      {"name": "会议日期", "code": "meeting_date"},
      {"name": "标签", "code": "tags"}
    ]
  }
}
```

实现建议：

```python
def build_write_schema(scope_name: str, fields: list[Any]) -> dict[str, Any]:
    label_properties = {
        field.field_code: _label_schema_for_field(field)
        for field in fields
        if getattr(field, "field_code", "")
    }
    return {
        "type": "object",
        "additionalProperties": False,
        "description": f"向知识库 {scope_name} 写入文件文本和属性标签。",
        "x-dc-action-family": "write",
        "x-dc-scope-type": "object",
        "properties": {
            "labels": {
                "type": "object",
                "additionalProperties": False,
                "properties": label_properties,
                "description": "知识库属性标签，key 必须使用当前对象声明的属性编码。",
                "x-dc-field-catalog": [
                    {"name": _fn(field), "code": _fc(field)} for field in fields
                ],
            },
            "source_path": {
                "type": "string",
                "description": "上传到知识库后的文件全路径，以 / 开头，不包括知识库名称。",
            },
            "content": {
                "type": "string",
                "minLength": 1,
                "description": "源文件正文文本。",
            },
        },
        "required": ["source_path", "content"],
    }
```

## 10. OWL 到运行时链路

完整链路如下：

1. OWL `EntityDefinition.ext_property` 写入 JSON 字符串。
2. `OwlParser` 将字符串解析为 `dict[str, Any]`。
3. `_build_content()` 输出对象字段 `ext_property`。
4. `OntologyLoader._parse_class()` 构造 `OntologyClass(ext_property=...)`。
5. 虚拟动作注入逻辑识别 `source_type == "KNOWLEDGE_BASE"`。
6. 注入 `search_*` 和 `write_*` 两个动作。
7. Action 执行时根据 `action_family` 路由到 `KbSearchExecutor`。
8. Executor 从 `OntologyClass.ext_property` 读取知识库绑定信息。
9. Backend 调用知识库 HTTP 服务。

## 11. 术语转换

知识库对象的字段属性如果绑定了术语，需要在 search/write 入参和返回结果中做术语转换。术语转换是对象字段语义的一部分，不属于知识库 backend 的职责。

适用范围：

1. `search_{object_code}` 的 `filters.value`；
2. `search_{object_code}` 返回 records 中的标签字段；
3. `write_{object_code}` 的 `labels`；
4. `write_{object_code}` 返回 records 中的标签字段。

转换规则：

1. 输入侧：当字段属性配置了 `term_set` / `term_type` / `term_field` 等术语绑定信息时，用户可传术语名称或编码；executor 在调用 backend 前转换为知识库元数据服务期望的存储值。
2. search 过滤：`filters.value` 进入 metadata `where` 前需要完成术语解析。
3. write 标签：`labels` 渲染 YAML front matter 前需要完成术语解析，避免把展示名写入底层元数据。
4. 输出侧：backend 返回 records 后，使用 `ResultTermConverter` 或等价能力把存储值转换为业务可读术语值。
5. `meta.fields` 保持字段描述，不承载术语映射明细；如需要调试术语转换，可放入可选 `meta.runtime`。

推荐执行顺序：

```text
action arguments
  -> schema 校验
  -> 术语输入解析
  -> KnowledgeSearchRequest / KnowledgeWriteRequest
  -> Knowledge backend
  -> records 术语输出转换
  -> records / total / meta.fields
```

测试覆盖：

1. search filter 传入术语名称时，metadata where 使用存储编码。
2. write labels 传入术语名称时，YAML front matter 使用存储编码。
3. search 返回 records 中术语字段转换为业务可读值。
4. write 返回 records 中术语字段转换为业务可读值。

## 12. 错误处理

推荐错误处理策略：

| 场景 | 行为 |
| --- | --- |
| 对象不是 `KNOWLEDGE_BASE` | 返回错误响应或抛出 `ValueError`，调用层记录日志 |
| `datasource_alias` 为空 | 返回空结果；错误信息放到异常或可选 `meta.runtime.error`，`meta.fields` 仍保留字段描述 |
| `source_path` 为空或不是 `/` 开头 | write 动作返回校验错误，不调用后端 |
| `content` 为空 | write 动作返回校验错误，不调用后端 |
| backend 未实现 `write()` | 返回明确错误：`knowledge backend does not support write` |
| HTTP 4xx/5xx | 抛出或转换为 `KbExecutionError` |
| 响应不是 JSON | 抛出或转换为 `KbExecutionError` |
| 元数据服务业务失败 | 使用 `resultCode`、`resultMsg`、`errorCode` 构造错误信息 |

`search_*` 和 `write_*` 的失败响应也保持 action 返回协议一致：

```json
{
  "records": [],
  "total": 0,
  "meta": {
    "columns": [
      {
        "name": "filePath",
        "label": "文件路径",
        "type": "string",
        "description": "知识库内文件全路径"
      },
      {
        "name": "content",
        "label": "文件内容",
        "type": "string",
        "description": "文件文本内容"
      }
    ],
    "runtime": {
      "error": "content is required"
    }
  }
}
```

## 13. 测试方案

### 13.1 OWL 解析测试

覆盖点：

1. `EntityDefinition.ext_property` 可以解析为对象级 `ext_property`。
2. `kb_id`、`kb_directory` 不出现在对象顶层。
3. `OntologyClass` 不包含显式 `kb_id`、`kb_directory` 字段。
4. 动态表对象不依赖 `ext_property.dynamic_table_code`。

现有测试 `test_owl_parser_preserves_object_ext_property` 已覆盖主要解析链路，后续只需补动态表“不写扩展字段”的断言。

### 13.2 虚拟动作注入测试

覆盖点：

1. `KNOWLEDGE_BASE` 对象注入 `search_{object_code}`。
2. `KNOWLEDGE_BASE` 对象注入 `write_{object_code}`。
3. `write_*` 的 `action_family == "write"`。
4. `write_*` 的 `input_schema.required` 包含 `source_path` 和 `content`。
5. DB/API 对象不注入 `write_*` 知识库动作。

### 13.3 Executor 单元测试

使用 fake backend：

```python
class FakeKnowledgeBackend:
    async def search(self, request: KnowledgeSearchRequest) -> KnowledgeSearchResult:
        ...

    async def write(self, request: KnowledgeWriteRequest) -> KnowledgeWriteResult:
        ...
```

覆盖点：

1. `execute_write()` 会把 `ext_property.kb_id` 传入 `KnowledgeWriteRequest.kb_id`。
2. `execute_write()` 会把 `ext_property.kb_directory` 传入 `KnowledgeWriteRequest.kb_directory`。
3. `labels` 非 dict 时会规范化为空 dict 或返回校验错误。
4. `labels` 中出现对象未声明字段时会被 schema 拦截；executor 可增加二次校验。
5. `labels` 字段值类型与对象属性类型不匹配时会被 schema 拦截。
6. `source_path` 为空或不是 `/` 开头时不会调用 backend。
7. `content` 为空时不会调用 backend。
8. `labels` 能正确渲染为 Markdown YAML front matter。
9. backend 异常时返回结构化错误。

### 13.4 HTTP Backend 测试

使用 `pytest_httpx` 或 mock `httpx.AsyncClient`：

1. search 请求体包含 `knCodeList`。
2. write 请求使用 `multipart/form-data`。
3. write 表单字段包含 `knCode`、`filePath`、可选 `fileDescription` 和 `fileContent`。
4. `fileContent` 是包含 YAML front matter 的 Markdown 字节内容。
5. HTTP 非 2xx、业务 `resultCode != 0`、非法 JSON 都能转换为 `KbExecutionError`。

## 14. 迁移与兼容

1. 现有 OWL 如果没有 `ext_property`，知识库 search 仍可从 `kb_source_configs` 获取 `knCodeList`。
2. 新 OWL 推荐写入 `ext_property.kb_id`，使对象和知识库绑定更加明确。
3. 不兼容旧的显式 `kb_id`、`kb_directory`、`dynamic_table_code` 顶层字段；如果历史数据存在，应在导入转换阶段统一迁移到 `ext_property`。
4. `query_{object_code}` 旧别名可继续保留给历史检索流程，但新知识库语义检索推荐使用 `search_{object_code}`。

## 15. 实施步骤

推荐按以下顺序实施：

1. 在 `kb_search_backend.py` 增加 `KnowledgeWriteRequest`、`KnowledgeWriteResult` 和 backend `write()` 协议。
2. 在 `HttpKnowledgeSearchBackend` 增加 `_build_import_url()`、YAML front matter 渲染函数和 `write()`。
3. 在 `KbSearchExecutor` 拆分 `execute_search()`，新增 `execute_write()`。
4. 在 `action.py` 增加 `action_family == "write"` 路由。
5. 在 `virtual_action/generator.py` 增加 `build_write_schema()` 和 `build_write_description()`。
6. 在知识库虚拟动作注入逻辑中新增 `write_{object_code}`。
7. 补充术语输入解析和输出转换。
8. 补充 OWL 解析、动作注入、executor、HTTP backend、术语转换单元测试。
9. 运行格式化、lint、类型检查和相关测试。

## 16. 待确认问题

1. `kb_id` 与 import 接口字段 `knCode` 是否完全等价；如果不是，需要在配置中提供映射字段。
2. `fileDescription` 是否需要作为 write 动作显式入参；当前设计可后续扩展 `file_description`。
3. `ARRAY` 标签是否需要从 OWL 字段扩展中读取元素类型；如果不配置，默认按 string array 处理。
4. `kb_directory` 是否只作为默认目录前缀，还是要强制校验 `source_path` 必须位于该目录下。
5. search 时是否也要自动把 `kb_directory` 拼到 `where.filePath contains` 条件中。
