diff --git a/docs/zh-CN/admin-guide/_category.yaml b/docs/zh-CN/admin-guide/_category.yaml new file mode 100644 index 000000000..99ee36d70 --- /dev/null +++ b/docs/zh-CN/admin-guide/_category.yaml @@ -0,0 +1,2 @@ +title: 管理指南 +position: 4 diff --git a/docs/zh-CN/admin-guide/api-keys.md b/docs/zh-CN/admin-guide/api-keys.md new file mode 100644 index 000000000..5cc2ba10e --- /dev/null +++ b/docs/zh-CN/admin-guide/api-keys.md @@ -0,0 +1,118 @@ +# API Key 管理 + +> **读者定位**:ApeRAG 系统管理员 / 高级用户。 +> +> **范围**:API Key 的创建、轮换、查询、吊销 admin 操作。技术面(ORM / 服务层 / DI)见 [`architecture/identity-governance-model-platform-marketplace.md`](../architecture/identity-governance-model-platform-marketplace.md) 的 governance 章节。 + +## 概述 + +API Key 是 ApeRAG 提供的程序化访问凭证,与 fastapi-users 的 Cookie 会话认证并存: + +- **人工访问**:Web UI / Cookie 认证(登录态) +- **程序化访问**:API Key(`Authorization: Bearer sk-...`) + +所有通过 API Key 发起的 HTTP 请求会被映射到 key 所属用户身份,权限与该用户一致。API Key 不可跨用户共享。 + +## 实体模型(管理员视角) + +API Key 由 governance 域拥有,字段要点: + +| 字段 | 含义 | 说明 | +| --- | --- | --- | +| `id` | Key 记录主键 | 形如 `keyXXXX...`,由系统生成 | +| `key` | Bearer Token 明文 | 形如 `sk-<32 位十六进制>`,仅创建时返回给用户,后续仅保存在数据库 | +| `user` | 所属用户 ID | 对应 identity 域的 `User.id` | +| `description` | 备注 | 可选 | +| `status` | 状态 | `ACTIVE` / `DELETED`(软删除) | +| `is_system` | 是否系统生成 | 区分用户自建与系统内部使用的 key | +| `last_used_at` | 最近一次使用时间 | 便于发现闲置 key | +| `gmt_created` / `gmt_updated` / `gmt_deleted` | 审计时间戳 | 软删除不物理移除记录 | + +## 常见管理操作 + +### 查询某个用户的所有 API Key + +```http +GET /api/v1/apikeys +Authorization: Bearer sk- +``` + +返回的列表中,管理员默认只能看到自己的 key。若需跨用户查询,走审计日志(见 [audit-log.md](./audit-log.md))。 + +### 创建 API Key + +```http +POST /api/v1/apikeys +Content-Type: application/json +Authorization: Bearer sk- + +{ + "description": "CI runner for apecloud/aperag" +} +``` + +响应会包含一次性的 `key` 明文(形如 `sk-xxxxxxxxxxxx...`)。 + +> ⚠️ **只会返回这一次**。保存到 CI / 密钥管理器后再也取不回。如果丢失,必须走「吊销 + 重建」流程,不要考虑明文恢复。 + +### 更新备注 + +```http +PUT /api/v1/apikeys/{apikey_id} +Content-Type: application/json + +{ + "description": "rotated 2026-04 per security policy" +} +``` + +只能改 `description`。如果要换 token,请参考下面的「轮换(rotate)」节。 + +### 吊销(软删除) + +```http +DELETE /api/v1/apikeys/{apikey_id} +``` + +此操作会: + +1. 把 `status` 置为 `DELETED`,填写 `gmt_deleted` +2. 之后使用该 token 的所有请求都会被拒绝(401) +3. **不会**清除 `key` 字段,便于事后审计追溯哪个 token 曾发起过什么操作 +4. 审计日志中的历史记录保留 + +## 轮换(rotate) + +ApeRAG 目前没有原子化的「保留 id、换 token」接口。推荐的轮换步骤: + +1. `POST /apikeys`:创建新 key(得到新 `sk-...`) +2. 将新 key 分发到调用方(CI、外部系统) +3. 观察几天,确认旧 key 不再产生流量(通过 `last_used_at` 或审计日志) +4. `DELETE /apikeys/{old_id}`:吊销旧 key + +建议将轮换写进组织的安全 playbook,不依赖内存。 + +## 系统生成的 API Key + +`is_system = true` 的 API Key 是 ApeRAG 内部为其他能力自动签发的凭证(例如某些一次性工具流)。这些 key: + +- **不应被人工吊销**,否则会影响系统功能 +- 通常没有 `description`,但会绑定明确的 `user` +- 在管理员查询接口中会显式标出 `is_system` + +## 跨 domain 边界(为什么 API Key 属于 governance 不属于 identity) + +从概念上,API Key 是「某个用户的访问凭证」;但在 ApeRAG 12 域模型里,它归 **governance** 域而非 **identity** 域,原因是: + +- `identity` 域管理「谁是谁」(User / Role / OAuthAccount) +- `governance` 域管理「谁在什么时间做了什么」(API Key / Audit Log / Audit Resource) + +API Key 更接近 **访问凭证 + 操作归属**,语义上和审计日志同源,因此在 Phase 4 模块化重构中归 governance 域(`aperag/domains/governance/db/models.py`)。 + +跨域读侧通过 `governance.ports.UserView` 读取 `User.username` 等展示字段,不持有 `User` ORM 实例(遵守 G16 边界规则,详见 architecture 章节)。 + +## 相关文档 + +- [`architecture/identity-governance-model-platform-marketplace.md`](../architecture/identity-governance-model-platform-marketplace.md) — governance 域架构实现 + `UserView` Protocol +- [`admin-guide/audit-log.md`](./audit-log.md) — 审计日志查询 +- [`reference/prompt-api.md`](../reference/prompt-api.md) — 示例 API 调用 diff --git a/docs/zh-CN/admin-guide/audit-log.md b/docs/zh-CN/admin-guide/audit-log.md new file mode 100644 index 000000000..a692c9c43 --- /dev/null +++ b/docs/zh-CN/admin-guide/audit-log.md @@ -0,0 +1,157 @@ +# 审计日志(Audit Log) + +> **读者定位**:ApeRAG 系统管理员(`role == "admin"`)、SRE、合规审计人员。 +> +> **范围**:审计日志的数据模型、查询接口、权限边界、敏感字段过滤。架构层实现细节见 [`architecture/identity-governance-model-platform-marketplace.md`](../architecture/identity-governance-model-platform-marketplace.md) 的 governance 章节。 + +## 概述 + +`AuditLog` 记录系统里所有**变更类 HTTP 操作**(POST / PUT / DELETE)的执行痕迹,用于: + +- 事后追溯("这条数据是谁、什么时候、通过什么请求改掉的") +- 异常排查(HTTP 4xx / 5xx 的请求体、响应体、错误信息) +- 安全合规(接入点、IP、User-Agent、Request ID 关联) + +审计日志由 `@audit(resource_type=..., api_name=...)` 装饰器自动写入,业务 handler 无需手动调用。装饰器实现位于 `aperag/utils/audit_decorator.py`,服务层位于 `aperag/domains/governance/service/audit_service.py`。 + +## 数据模型 + +`AuditLog` 实体(`aperag/domains/governance/db/models.py`)字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | UUID 字符串 | 主键 | +| `user_id` | 字符串,可空 | 操作者用户 ID(匿名接口可能为空) | +| `username` | 字符串,可空 | 操作者用户名(冗余存储,避免 join) | +| `resource_type` | `AuditResource` 枚举 | 见下节枚举列表 | +| `resource_id` | 字符串 | 从 `path` 中解析出来的资源 ID(查询时解析,不在写入时落库) | +| `api_name` | 字符串 | 由 `@audit` 装饰器传入,形如 `CreateCollection` | +| `http_method` | 字符串 | `POST` / `PUT` / `DELETE` 等 | +| `path` | 字符串 | 请求路径(如 `/api/v1/collections/col-abc123`) | +| `status_code` | 整数 | HTTP 响应码 | +| `request_data` | JSON 字符串 | 请求体,敏感字段已过滤 | +| `response_data` | JSON 字符串 | 响应体,敏感字段已过滤 | +| `error_message` | 字符串 | 失败时保留异常信息 | +| `ip_address` | 字符串 | 客户端 IP | +| `user_agent` | 字符串 | 浏览器 / CLI 标识 | +| `request_id` | 字符串 | 用于跨服务 trace 关联 | +| `start_time` / `end_time` | 毫秒时间戳 | `end_time - start_time` 即耗时 | +| `gmt_created` | 时间戳 | 落库时间 | + +### AuditResource 枚举 + +写入的资源类型由 `@audit(resource_type="...")` 传入,取值必须在 `AuditResource` 枚举内: + +`collection` / `document` / `bot` / `chat` / `message` / `api_key` / `llm_provider` / `llm_provider_model` / `model_service_provider` / `user` / `config` / `invitation` / `auth` / `chat_completion` / `search` / `llm` / `flow` / `system` / `index` + +## 敏感字段过滤 + +写入前,`audit_service` 会递归扫描 `request_data` / `response_data`,将以下字段名(忽略大小写)的值替换为 `***FILTERED***`: + +`password` / `token` / `api_key` / `secret` / `authorization` / `access_token` / `refresh_token` / `private_key` / `credential` + +> 注意:过滤基于**字段名子串匹配**,不会扫描字段值本身。若自定义字段需要脱敏,用上面任一关键词命名即可(例如 `oauth_token`)。 + +## 查询接口 + +### 列表查询 + +```http +GET /api/v1/audit-logs +Authorization: Bearer sk- +``` + +支持的 query 参数: + +| 参数 | 说明 | +| --- | --- | +| `user_id` | 按用户 ID 过滤。**非 admin 时该参数被忽略,强制回填为当前用户** | +| `username` | 按用户名过滤 | +| `resource_type` | 任一 `AuditResource` 枚举值 | +| `resource_id` | 资源 ID 精确匹配 | +| `api_name` | API 操作名精确匹配 | +| `http_method` | `POST` / `PUT` / `DELETE` | +| `status_code` | HTTP 响应码精确匹配 | +| `start_date` / `end_date` | ISO-8601 时间区间 | +| `page` / `page_size` | 分页,默认 `page=1` / `page_size=20`,`page_size` 上限 100 | +| `sort_by` / `sort_order` | 排序字段 + `asc` / `desc`,默认按时间倒序 | +| `search` | 模糊搜索(在 api_name / path 上做 LIKE) | + +### 单条查询 + +```http +GET /api/v1/audit-logs/{audit_id} +``` + +返回完整的 `AuditLog` + 运行时计算的 `resource_id`(从 `path` 正则提取)和 `duration_ms`。 + +### 权限规则 + +``` +if user.role == "admin": + # 可见所有人的日志 + pass +else: + # 强制只看自己的日志,忽略 user_id / username 参数 + filter_user_id = user.id +``` + +该 literal 比较**不依赖** identity 域的 `Role` 枚举(G15 边界规则 — governance 域不能 import `Role`)。admin 角色名以字符串 `"admin"` 写死在 governance handler 里。 + +## 常见管理场景 + +### 追溯某个知识库被谁删除 + +```http +GET /api/v1/audit-logs?resource_type=collection&api_name=DeleteCollection&page_size=100 +``` + +按时间倒序扫,结合 `resource_id`(会从 `/collections/{id}` 中解析出来)定位目标记录,记录中的 `user_id` + `username` + `ip_address` 即操作者身份。 + +### 排查 5xx 错误 + +```http +GET /api/v1/audit-logs?status_code=500&start_date=2026-04-20T00:00:00Z +``` + +返回结果中的 `error_message` 字段会保留异常 repr;`request_id` 可以在日志系统中 grep 到完整调用栈。 + +### 统计 API 调用量 + +```http +GET /api/v1/audit-logs?api_name=CreateChatCompletion&start_date=2026-04-01T00:00:00Z +``` + +后端目前只提供 listing,不直接提供 aggregation 接口。如果要做 per-day / per-user 的统计报表,需要从 AuditLog 表直接跑 SQL(或者把表同步到 OLAP)。 + +### 查看某个用户的近期活动 + +```http +GET /api/v1/audit-logs?user_id=&page_size=50 +``` + +admin 视角可以看任何用户;普通用户只能看自己(后端强制覆盖)。 + +## 保留与清理策略 + +当前版本没有内置的保留期清理任务。所有 `AuditLog` 记录永久保存在业务数据库中。如需控制表大小: + +- **推荐**:把 AuditLog 定期(月 / 季度)导出到冷存储(S3 / 对象存储),然后按保留策略删除。 +- 不推荐直接 `DELETE FROM audit_log WHERE gmt_created < ?`,因为会导致合规窗口内的证据缺失;必须在导出归档之后再清。 + +> 未来可能会提供自动归档 Job,目前先靠运维侧脚本实现。 + +## 跨 domain 边界(为什么 AuditLog 属于 governance 不属于 identity) + +AuditLog 记录"谁在什么时间做了什么",语义上归 **governance** 域。`user_id` 和 `username` 字段是冗余存储(不是 FK 到 identity.User),这个选择有两个原因: + +1. **审计日志必须在用户被删除后仍然保留**。如果用 FK 关联,用户删除会触发级联或者 SET NULL,污染审计证据。 +2. **governance 域不持有 identity.User ORM 实例**(G16 边界规则)。governance 读侧若需要展示 `username` 等识别信息,走 `governance.ports.UserView` Protocol,由 identity 域在启动时注入。 + +详见 [`architecture/identity-governance-model-platform-marketplace.md`](../architecture/identity-governance-model-platform-marketplace.md) governance 章节。 + +## 相关文档 + +- [`admin-guide/api-keys.md`](./api-keys.md) — API Key 管理(同属 governance 域) +- [`architecture/identity-governance-model-platform-marketplace.md`](../architecture/identity-governance-model-platform-marketplace.md) — governance 域架构 +- `docs/modularization/architecture.md` — 12 域 canonical SSoT(boundary gates / UserView Protocol / G15 literal compare 规则) diff --git a/docs/zh-CN/admin-guide/quota-system.md b/docs/zh-CN/admin-guide/quota-system.md new file mode 100644 index 000000000..89269618e --- /dev/null +++ b/docs/zh-CN/admin-guide/quota-system.md @@ -0,0 +1,244 @@ +# 配额管理(Quota System) + +> **读者定位**:ApeRAG 系统管理员(`role == "admin"`)、SRE、运维。 +> +> **范围**:用户配额的查询、调整、重算、系统默认值。架构层 Protocol+DI(`quota_service` 为 2 条 permanent CRITICAL_WIRINGS 之一)见 [`architecture/conversation-agent-evaluation.md`](../architecture/conversation-agent-evaluation.md) 或 canonical SSoT `docs/modularization/architecture.md`。 + +## 概述 + +ApeRAG 使用**每用户配额**来限制可创建的核心资源数量。配额分两部分: + +- **limit**(上限):由系统默认值初始化,admin 可按用户单独调整。 +- **usage**(当前用量):由业务层在创建 / 删除资源时原子地 `check_and_consume_quota` / `release_quota` 更新。 + +配额检查走事务内 `SELECT ... FOR UPDATE` 行锁,避免并发创建导致的超额;超限时抛 `QuotaExceededException`,接口返回 403。 + +## 当前支持的配额类型 + +| Key | 含义 | 默认值 | +| --- | --- | --- | +| `max_collection_count` | 每用户知识库数量上限 | 10 | +| `max_document_count` | 每用户跨所有知识库的文档总数上限 | 1000 | +| `max_document_count_per_collection` | 每个知识库内的文档数量上限 | 100 | +| `max_bot_count` | 每用户智能体(Bot)数量上限,不含系统默认 bot | 5 | + +> 默认值存在 `ConfigModel` 表中 key 为 `system_default_quotas` 的一行 JSON;若该行不存在则回退到代码里的硬编码默认(上表)。 + +### 不在配额内的资源 + +- Chat、Message、AgentTurn:不计入配额,数量由聊天使用自然产生。 +- ApiKey:不计入配额(管理侧另有 `is_system` 标记区分系统生成 key)。 +- Subscription(marketplace 订阅):不计入配额。 +- Model Service Provider:不计入配额,由 admin 集中管理。 + +## 数据模型 + +`UserQuota` 实体(`aperag/db/models.py`,复合主键 `(user, key)`): + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `user` | 字符串 | 用户 ID,主键之一 | +| `key` | 字符串 | 配额类型(上表 Key 列),主键之一 | +| `quota_limit` | 整数 | 上限 | +| `current_usage` | 整数 | 当前用量 | +| `gmt_created` / `gmt_updated` / `gmt_deleted` | 时间戳 | 审计字段 | + +`ConfigModel`(同文件)里 key=`system_default_quotas` 的行保存 JSON: + +```json +{ + "max_collection_count": 10, + "max_document_count": 1000, + "max_document_count_per_collection": 100, + "max_bot_count": 5 +} +``` + +## 查询接口 + +### 当前用户的配额 + +```http +GET /api/v1/quotas +Authorization: Bearer sk- +``` + +返回 `UserQuotaInfo`:`user_id` / `username` / `email` / `role` / `quotas[]`,其中 `quotas[i]` 形如: + +```json +{ + "quota_type": "max_collection_count", + "quota_limit": 10, + "current_usage": 3, + "remaining": 7 +} +``` + +普通用户只能查自己;如果 `user_id` 或 `search` 参数传进来但 caller 不是 admin,会返回 403。 + +### admin 查某个用户 + +```http +GET /api/v1/quotas?user_id= +Authorization: Bearer sk- +``` + +### admin 按用户名 / 邮箱 / ID 搜索 + +```http +GET /api/v1/quotas?search= +``` + +`search` 是精确匹配 username / email / user.id 之一(注意不是模糊搜索),返回 `UserQuotaList`。当查询结果为 0 时返回 404;其他情况返回 list(即便只有一条结果)。 + +## 管理操作 + +### 调整单个用户的配额 + +```http +PUT /api/v1/quotas/{user_id} +Content-Type: application/json +Authorization: Bearer sk- + +{ + "max_collection_count": 20, + "max_document_count": 2000 +} +``` + +- 支持单个和批量更新(请求体里只传要改的 key)。 +- 如果某个配额 key 之前不存在,会创建新行。 +- `null` 值字段会被忽略(不要想用 null 来清空某个配额;当前没有清空的语义)。 +- 接口只改 `quota_limit`,**不会重置 `current_usage`**。 + +响应返回每项的 `old_limit` → `new_limit`: + +```json +{ + "success": true, + "message": "Quotas updated successfully", + "user_id": "u-xxx", + "updated_quotas": [ + {"quota_type": "max_collection_count", "old_limit": 10, "new_limit": 20} + ] +} +``` + +### 重算当前用量 + +```http +POST /api/v1/quotas/{user_id}/recalculate +Authorization: Bearer sk- +``` + +用于**校正漂移**。接口会实际扫描数据库计算真实用量: + +- `max_collection_count`:`SELECT COUNT(*) FROM collection WHERE user=? AND status != 'DELETED'` +- `max_document_count`:跨所有非删除 collection 的非删除 document 总数 +- `max_bot_count`:该用户的非删除 bot,且排除系统默认 bot(`title != "Default Agent Bot"`) + +然后把 `current_usage` 写回 UserQuota 行。**注意**:重算不包括 `max_document_count_per_collection`(那是 per-collection 限制,没有单值可以汇总)。 + +> 什么时候需要重算:业务异常崩溃后(资源已创建但 quota 没 consume),或者从外部脚本批量插入了数据之后。正常路径下不需要手动触发。 + +### 调整系统默认配额 + +```http +GET /api/v1/system/default-quotas +PUT /api/v1/system/default-quotas +Content-Type: application/json + +{ + "quotas": { + "max_collection_count": 15, + "max_document_count": 1500, + "max_document_count_per_collection": 150, + "max_bot_count": 8 + } +} +``` + +- 新注册用户会按当前 system default 初始化自己的 UserQuota 行(由 identity 域的 `on_after_register` 钩子调用 `quota_service.initialize_user_quotas`)。 +- **改系统默认值不会影响已存在用户**。已存在用户若要跟上新默认,走 `PUT /api/v1/quotas/{user_id}` 单个改。 + +## 运行时行为 + +### 消耗配额 + +业务创建资源时调用 `quota_service.check_and_consume_quota(user_id, quota_type, amount, session)`: + +1. `SELECT ... FOR UPDATE` 锁定该 user+key 行。 +2. 若 `current_usage + amount > quota_limit`,抛 `QuotaExceededException`(接口层转成 HTTP 403)。 +3. 否则 `current_usage += amount`,flush。 + +调用方**必须**把自己的 session 传进去,确保配额更新和资源创建在同一事务里;否则事务回滚时 quota 不会自动回退。 + +### 释放配额 + +软删除 / 硬删除资源时调用 `quota_service.release_quota(user_id, quota_type, amount, session)`: + +- `current_usage = max(0, current_usage - amount)`(不会变负)。 +- 同样建议在资源删除事务里调用,保证一致性。 + +### 新用户初始化 + +`on_after_register` 钩子调用 `initialize_user_quotas`:对每个系统默认 key,若 UserQuota 行不存在则按系统默认 limit 创建,`current_usage=0`。重复调用安全(已存在的行不会被覆盖)。 + +## 权限模型 + +- **普通用户**:只能 `GET /api/v1/quotas`(本人)。所有其他 quota 接口返回 403。 +- **admin(`role == "admin"`)**: + - 查任意用户 / 搜索 + - 改任意用户 limit + - 触发任意用户 recalculate + - 读 / 写 system default + +`role` 的 admin 判定在 quota 接口层仍走 `current_user.role != Role.ADMIN` 枚举比较(`quota_service` 保留在 `aperag/service/` 标准基础设施位置,不受 G15 governance literal compare 规则约束)。这与 audit-log / api-key 接口(governance 域内用 `user.role != "admin"` 字面量)口径**不同**,是 historical artifact,未来 quota_service 若收编进 identity 域会一并收口。 + +## 常见运维场景 + +### 给某用户临时放宽 + +```http +PUT /api/v1/quotas/ +{"max_document_count": 5000} +``` + +### 排查"为什么创建 collection 失败 403" + +1. `GET /api/v1/quotas?user_id=` 看 `max_collection_count` 的 `current_usage` vs `quota_limit`。 +2. 若 `current_usage` 大于实际 collection 数(漂移),`POST /api/v1/quotas//recalculate` 修正。 +3. 若 `quota_limit` 太小,`PUT /api/v1/quotas/` 提高上限。 + +### 批量提高所有新用户默认 + +`PUT /api/v1/system/default-quotas` — 改完只影响此后注册的用户,老用户不变。 + +### 统计各配额的饱和度 + +目前没有直接接口,需要从 DB 跑 SQL: + +```sql +SELECT key, + COUNT(*) AS users, + SUM(current_usage) AS total_used, + SUM(quota_limit) AS total_limit, + AVG(current_usage::float / NULLIF(quota_limit, 0)) AS avg_saturation +FROM user_quota +GROUP BY key; +``` + +## 跨 domain 边界 + +`quota_service` 是 **2 条 permanent Protocol+DI seam** 之一(另一条是 `prompt_template_service`),在 `aperag/app.py` 启动时注入到 `conversation.bot_service._quota_ops`(G18 alt CRITICAL_WIRINGS registry)。 + +设计原因:`quota_service` 是标准基础设施(不属于任何业务域的主干),被 knowledge_base / conversation / agent_runtime 多个域交叉消费;为了不把 quota 绑到任一业务域,把它留在 `aperag/service/` 并通过消费方的 `ports.py` 显式声明为 Protocol。 + +详见 `docs/modularization/architecture.md` Section 4(canonical rules:direct import / Protocol+DI transitional / standalone-infra permanent)和 Section 5(Runtime seams)。 + +## 相关文档 + +- [`admin-guide/api-keys.md`](./api-keys.md) — API Key 管理 +- [`admin-guide/audit-log.md`](./audit-log.md) — 审计日志(配额变更会被 `@audit` 装饰器记录) +- [`architecture/conversation-agent-evaluation.md`](../architecture/conversation-agent-evaluation.md) — `quota_service` 在 conversation 域的 DI wire-up +- `docs/modularization/architecture.md` — canonical SSoT diff --git a/docs/zh-CN/architecture/identity-governance-model-platform-marketplace.md b/docs/zh-CN/architecture/identity-governance-model-platform-marketplace.md new file mode 100644 index 000000000..13d80e903 --- /dev/null +++ b/docs/zh-CN/architecture/identity-governance-model-platform-marketplace.md @@ -0,0 +1,597 @@ +# Architecture: `identity` / `governance` / `model_platform` / `marketplace` Domains + +> **读者定位**:后端架构师、Phase 4 四域代码 owner、需要理解"用户身份 / 访问凭证 / 模型配置 / 市场订阅"之间 dependency 边界的开发者。 +> +> **范围**:ApeRAG 12 canonical domain 中的 4 个 Phase 4 域:`identity` / `governance` / `model_platform` / `marketplace`。本文只写 **current state**。Phase 0→6 演进史见 `docs/modularization/architecture.md` 和 `docs/modularization/breaking-changes/phase4.md`。 +> +> **Baseline**:`origin/main @ 28a9f531` 代码面 + `docs/modularization/architecture.md` Section 2.1–2.4 / 3 / 4(canonical SSoT)。 + +## 目录 + +1. [Why 4 个域放一起讲](#why-4-个域放一起讲) +2. [`identity` 域](#identity-域) +3. [`governance` 域](#governance-域) +4. [`model_platform` 域](#model_platform-域) +5. [`marketplace` 域](#marketplace-域) +6. [跨域 dependency graph](#跨域-dependency-graph) +7. [Canonical 规则(G15 / G16 / G17)](#canonical-规则g15--g16--g17) +8. [Boundary gates](#boundary-gates) +9. [Legacy shims 与 Phase 7+ 去向](#legacy-shims-与-phase-7-去向) +10. [相关文档](#相关文档) + +--- + +## Why 4 个域放一起讲 + +这 4 个域在 Phase 4 被同时从 `aperag/db/models.py` 和 `aperag/views/*.py` 聚合模块里 **hard-cut** 出来(Step 4-S2a/b/c/d + 4-S5a/b/c/d)。它们共享若干 canonical 约定: + +- **都声明自己的 `AuthenticatedUser(Protocol)`** — 14 份(其他域也有),刻意不合并(见 Section "Canonical 规则")。 +- **governance / model_platform / marketplace 都禁止 import `identity.Role` 枚举**(G15 边界规则),admin 判断走 `user.role == "admin"` 字面量。 +- **没有任何一个非 identity 域能持有 `identity.User` ORM 实例**(G16),要读 User 字段就走 `UserView` Protocol 或者 `identity_user_ops` 写 facade。 +- **3 条 identity-side DI adapter** 把 `UserManager.on_after_register` 的三个副作用(默认 bot / 默认 chat / quota 初始化)串到其他域,是 Phase 4 为什么单独引入 G17 的原因。 + +把这 4 个域放在一篇说,能把这些横向约定讲清楚、不重复。 + +--- + +## `identity` 域 + +### 职责 + +"谁是谁" — 认证 + 授权主体 + OAuth 账户绑定。 + +### 目录结构 + +``` +aperag/domains/identity/ +├── __init__.py +├── db/ +│ └── models.py # User / OAuthAccount / Role +├── schemas.py +├── ports.py # AuthenticatedUser + BotInitOps + ChatInitOps + QuotaInitOps +└── service/ + ├── user_manager.py # fastapi-users UserManager + on_after_register + └── identity_user_ops.py # User 写 facade(G16-legal 入口) +``` + +注意 **identity 没有自己的 `api/routes.py`**:fastapi-users 自己提供 `/auth/*`、`/users/*` 路由;domain 只提供 ORM + service + port 层。 + +### ORM 实体 + +| 实体 | 说明 | +| --- | --- | +| `User` | 主认证实体,fastapi-users-backed;字段含 `id` / `username` / `email` / `role` / `is_active` / `is_verified` / `is_superuser` / `hashed_password` / `chat_collection_id` / `gmt_created` / `gmt_deleted` 等 | +| `OAuthAccount` | 第三方 OAuth 绑定,一个 User 可以绑定多个 provider(GitHub / Google / Microsoft / ...);外键回 `User.id` | +| `Role` | 字符串枚举 `admin` / `rw` / `ro`,保存在 `User.role` | + +### `Role` 枚举的特殊位置 + +`Role` 定义在 `aperag/domains/identity/db/models.py`,但它**同时**被 `aperag/db/models.py`(legacy 聚合)的 `Invitation` 类 class-body 引用: + +```python +# aperag/db/models.py +from aperag.domains.identity.db.models import Role # ← G15 允许的唯一一次 top-level import +... +class Invitation(Base): + ... + role = Column(EnumColumn(Role), nullable=False) # class-body 加载期就需要 Role +``` + +**原因**:SQLAlchemy `Column(EnumColumn(Role), ...)` 在类定义时就要求 `Role` 已解析成具体 class,不能延迟到方法内部。`Invitation` 是 Phase 4 结束时还没迁出 legacy 聚合的 transitional 表(Phase 7+ 候选),因此要容忍这一 special case。G15 gate 里对 `aperag/db/models.py` 这个 top-level import 做了白名单豁免。 + +其他任何非 identity 文件若 import `Role` 都会被 G15 判为违规。详见 Section "Boundary gates"。 + +### `UserManager.on_after_register` 副作用 + +fastapi-users 注册用户后调用 `on_after_register`。`aperag/domains/identity/service/user_manager.py` 在这里触发 **4 个初始化副作用**: + +1. **第一个注册用户自动提权为 admin**(`Role.ADMIN`)— identity 内部,`user_db.session` 直接写 +2. **创建两条 API Key**:一个 `is_system=true`(系统内部),一个 `is_system=false`(默认用户 key)— 直接 `async_db_ops.create_api_key`,governance 域 ORM 的写入走 `db_ops` +3. **初始化用户 quota**:`_get_quota_init_ops().initialize_user_quota(user_id)` +4. **创建默认资源**: + - `_get_bot_init_ops().create_default_bot_for_user(user_id)` → 默认 Agent Bot(`title="Default Agent Bot"`) + - `_get_chat_init_ops().create_default_chat_for_user(user_id)` → 默认 chat collection + +步骤 3 + 4 里的 `*InitOps` 是 **consumer-owned Protocol**:identity 域声明接口,具体 adapter 在 `aperag/app.py` 启动时注入。这是 Phase 4 引入的"identity consumer / legacy provider"跨域形态。 + +### 三条 identity DI slot(G17 CRITICAL_WIRINGS) + +`aperag/domains/identity/ports.py` 定义: + +```python +@runtime_checkable +class BotInitOps(Protocol): + async def create_default_bot_for_user(self, user_id: str) -> None: ... + +@runtime_checkable +class ChatInitOps(Protocol): + async def create_default_chat_for_user(self, user_id: str) -> None: ... + +@runtime_checkable +class QuotaInitOps(Protocol): + async def initialize_user_quota(self, user_id: str) -> None: ... +``` + +`user_manager.py` 提供 setter: + +```python +def set_bot_init_ops(ops: BotInitOps) -> None: ... +def set_chat_init_ops(ops: ChatInitOps) -> None: ... +def set_quota_init_ops(ops: QuotaInitOps) -> None: ... +``` + +wire-up 发生在 `aperag/app.py`(import-time 执行,FastAPI 启动前): + +```python +class _BotInitOpsAdapter: + async def create_default_bot_for_user(self, user_id: str) -> None: + from aperag.db.models import BotType + from aperag.schema.view_models import BotCreate + from aperag.service.bot_service import bot_service + await bot_service.create_bot(user_id, BotCreate(..., bot_type=BotType.AGENT, title="Default Agent Bot"), skip_quota_check=True) + +class _ChatInitOpsAdapter: + async def create_default_chat_for_user(self, user_id: str) -> None: + from aperag.service.chat_collection_service import chat_collection_service + ... + +class _QuotaInitOpsAdapter: + async def initialize_user_quota(self, user_id: str) -> None: + from aperag.service.quota_service import quota_service + await quota_service.initialize_user_quotas(user_id) + +_id_set_bot_init_ops(_BotInitOpsAdapter()) +_id_set_chat_init_ops(_ChatInitOpsAdapter()) +_id_set_quota_init_ops(_QuotaInitOpsAdapter()) +``` + +关键点: + +- **3 个 adapter 都 lazy-import** provider 模块(`bot_service` / `chat_collection_service` / `quota_service`),避免 `identity` 域 startup 时强依赖未移入 domain 的 legacy 模块。 +- 3 个 provider 目前都 **不在 identity 域**:`bot_service` 实际被 Phase 5 移到 conversation 域(通过 `A is B is C` 三元 shim 让 `aperag.service.bot_service` == `aperag.domains.conversation.service.bot_service`);`quota_service` / `chat_collection_service` 仍在 `aperag/service/` 作为 standalone-infra。 +- 即便 provider 是 domain-moved,identity 这边**仍通过 adapter 走 Protocol+DI**,而不是直接 import — 因为 identity 域保守处理:"consumer-owned Protocol" 是 identity 首选契约,遵循 lesson 9a-quad。 + +这 3 条 slot 合称 **G17 的 3 条 identity adapter**,加上 Phase 3 knowledge_base 的 4 条 slot,共 **7 条**构成 G17 registry。详见 Section "Boundary gates" 的 G17。 + +### `identity_user_ops` — User 字段的写 facade + +G16 禁止非 identity 域 import `User` ORM 做写入。但业务总会有跨域场景需要改 User 字段(例如 knowledge_base 在用户创建默认 chat collection 时要 set `User.chat_collection_id`)。 + +解决方案:`aperag/domains/identity/service/identity_user_ops.py`: + +```python +async def set_chat_collection(session: AsyncSession, user_id: str, collection_id: str) -> None: + user = await session.get(User, user_id) + if user is None: + return + user.chat_collection_id = collection_id + session.add(user) +``` + +消费方(如 `chat_collection_service`)这样用: + +```python +from aperag.domains.identity.service.identity_user_ops import set_chat_collection +await set_chat_collection(session, user_id, collection_id) +``` + +这是 **lesson 9a-sexdec(User write hierarchy)的终态 level-1**: + +1. **首选**:走 `identity_user_ops.` facade(identity-owned 写入) +2. **次选**(单调用点 + PM 确认):inline text SQL +3. **禁止**(G16):非 identity 域 `from aperag.db.models import User` 做 ORM 写 + +Phase 4 结束时 hierarchy level-1 已经覆盖所有真实写入路径;剩余只是 Phase 6 cleanup 继续收缩 inline SQL。 + +### `AuthenticatedUser` Protocol(identity 域本地声明) + +即使在 identity 自己的 routes(fastapi-users 提供)里需要类型收窄,identity 也**不**把 `AuthenticatedUser` 推广为跨域共享合约:每个域声明自己的 `AuthenticatedUser(Protocol)`,只 pin 自己实际读取的属性。14 份刻意重复(不是忘了合并,是 Phase 4 design-lock 的 canonical choice)。 + +见 SSoT Section 4 和本文 Section "Canonical 规则"。 + +--- + +## `governance` 域 + +### 职责 + +"谁在什么时间做了什么" — API 访问凭证 + 审计日志。 + +### 目录结构 + +``` +aperag/domains/governance/ +├── __init__.py +├── db/ +│ └── models.py # ApiKey / AuditLog / ApiKeyStatus / AuditResource +├── schemas.py +├── ports.py # AuthenticatedUser + UserView +├── service/ +│ ├── api_key_service.py +│ └── audit_service.py +└── api/ + └── routes.py # /apikeys/* + /audit-logs/* +``` + +### ORM 实体 + +| 实体 | 说明 | +| --- | --- | +| `ApiKey` | Bearer Token,形如 `sk-`;字段含 `user` / `description` / `status` / `is_system` / `last_used_at` / `gmt_*` | +| `AuditLog` | 变更操作审计,字段含 `user_id` / `username` / `resource_type` / `resource_id` / `api_name` / `http_method` / `path` / `status_code` / `request_data` / `response_data` / `start_time` / `end_time` 等 | +| `ApiKeyStatus` | `ACTIVE` / `DELETED` | +| `AuditResource` | 资源类型枚举(collection / document / bot / chat / ... 19 种) | + +用户面接口见 [`admin-guide/api-keys.md`](../admin-guide/api-keys.md) + [`admin-guide/audit-log.md`](../admin-guide/audit-log.md)。 + +### `UserView` Protocol — governance 读 User 的唯一路径 + +governance 的审计 / admin 检查偶尔需要展示 `User.username` 等识别字段。按 G16,governance 不能 import `User` ORM。所以 `ports.py` 声明: + +```python +@runtime_checkable +class UserView(Protocol): + """Read-only user view consumed by governance services.""" + id: Any + role: str + # 其他 pin 的属性按实际需求补 +``` + +identity 域的 `User` ORM 结构上满足这个 Protocol(`User.id` + `User.role` 存在),governance 不 import identity,identity 也不 import governance — consumer 侧声明 Protocol + provider 侧 duck-typed 满足,典型 lesson 9a-quad 模式。 + +### G15 literal compare(governance 的 admin 判定) + +governance handler 做 admin 检查时**不** import `Role.ADMIN`: + +```python +# aperag/domains/governance/api/routes.py +if user.role == "admin": + stmt = select(AuditLog).where(AuditLog.id == audit_id) +else: + stmt = select(AuditLog).where(AuditLog.id == audit_id, AuditLog.user_id == user.id) +``` + +`user.role` 的类型就是 Protocol 里声明的 `str`。identity 内部 `Role` 枚举的成员字符串值(`"admin"` / `"rw"` / `"ro"`)作为 canonical 约定散在各域的 literal compare 里。要加新 role 就同时改 enum + 各 domain 的 literal compare — 这点 Phase 4 被 PM 确认为**可接受的代价**,换来的好处是 enum 类型不需要跨域传播。 + +### 跨域 dependency + +governance 对其他域的 import: + +- ✅ 无 inbound ORM / service import 到 identity / model_platform / marketplace / KB +- ✅ 使用 `aperag.utils.audit_decorator.@audit`(基础设施,跨域使用) + +governance 被谁消费: + +- 业务 handler 通过 `@audit(resource_type=..., api_name=...)` 装饰器写 AuditLog — 内部走 `aperag.utils.audit_decorator` → `audit_service`;调用方不需要直接 import governance +- identity `UserManager.on_after_register` 直接 `async_db_ops.create_api_key(...)` 写 ApiKey(不走 governance service,是 db_ops 层直接写。**这条是 identity → governance ORM 的直接读写,符合 G16 的"identity 对本域外 ORM 只读写一次"特权例外**) + +--- + +## `model_platform` 域 + +### 职责 + +"模型平台配置" — LLM provider 信息 + per-provider 模型清单。 + +**不是**"runtime LLM 调用":`aperag/llm/*`(embedding / rerank / completion 的 HTTP 封装)是跨域共享基础设施,**不属于** model_platform 域。见 SSoT Section 2.3 注释。 + +### 目录结构 + +``` +aperag/domains/model_platform/ +├── __init__.py +├── db/ +│ └── models.py # LLMProvider / LLMProviderModel / APIType +├── schemas.py +├── ports.py # AuthenticatedUser +├── service/ +│ ├── llm_provider_service.py # provider CRUD +│ ├── llm_available_model_service.py # per-provider models 查询 +│ └── default_model_service.py # system default model config +└── api/ + ├── llm_routes.py # v1 API: /llm/embeddings, /llm/rerank + └── providers_v2_routes.py # v2 API: /llm/providers, /llm/providers/{id}/models, /default-models +``` + +### ORM 实体 + +| 实体 | 说明 | +| --- | --- | +| `LLMProvider` | 某个 provider(OpenAI / Anthropic / Alibaba Bailian / ...)的配置:dialect / base_url / 默认 API key / per-user override 规则 / public vs private | +| `LLMProviderModel` | provider 下某个具体 model 的元数据:`name` / `api`(completion / embedding / rerank)/ context window / tags / 能力标记 | +| `APIType` | `completion` / `embedding` / `rerank` | + +`ModelServiceProvider` 是一张历史表(用户 / admin 自定义的模型服务 endpoint),仍留在 `aperag/db/models.py`,Phase 7+ 决定去向。 + +### 2-router 拆分 + +```python +# aperag/app.py +from aperag.domains.model_platform.api.llm_routes import router as llm_routes_router +from aperag.domains.model_platform.api.providers_v2_routes import router as providers_v2_router +... +app.include_router(llm_routes_router, prefix="/api/v1") +app.include_router(providers_v2_router, prefix="/api/v1") +``` + +两份 router 都在同一个 `model_platform.api` 子包下,区别: + +- `llm_routes.py` — 运行时推理类接口(`POST /llm/embeddings` / `POST /llm/rerank`),消费方是业务代码(不是前端 UI) +- `providers_v2_routes.py` — 配置管理类接口(`GET|POST|PUT|DELETE /llm/providers`、`/llm/providers/{id}/models`、`/default-models`),消费方是前端 admin / 用户 UI + +**为什么 2 router 不合并**:inference vs config 两条路径的生命周期、安全策略、OpenAPI tag 都不一样。合并会引入一份难以维护的超大 router;Phase 5 conversation 域的 `chat_router` + `bots_router` 复用了同样的 2-router pattern。 + +### Service 层 + +三个 service 各司其职: + +- `llm_provider_service`:provider CRUD + API key 管理(含 per-user override 逻辑) +- `llm_available_model_service`:列出 provider 下的可用 model,支持 tag 过滤 +- `default_model_service`:system default model 配置(admin 设置全局默认 embedding / completion / rerank model) + +`llm_provider_service.py` 里的函数并非类方法,而是**模块级函数**(`create_llm_provider` / `get_llm_provider` / ...),为了兼容 Phase 4 前的 legacy 调用形态;Phase 6+ 可能统一收进类。 + +--- + +## `marketplace` 域 + +### 职责 + +"知识库的公开分享与订阅" — publish / unpublish / subscribe / access check。 + +### 目录结构 + +``` +aperag/domains/marketplace/ +├── __init__.py +├── db/ +│ └── models.py # CollectionMarketplace / UserCollectionSubscription +├── schemas.py +├── ports.py # AuthenticatedUser 只 +├── service/ +│ ├── marketplace_service.py # publish / unpublish / list +│ └── marketplace_collection_service.py # subscriber-access 读路径 +└── api/ + └── routes.py # /marketplace/* +``` + +### ORM 实体 + +| 实体 | 说明 | +| --- | --- | +| `CollectionMarketplace` | 某个 Collection 的 publish 状态(`DRAFT` / `PUBLISHED`),unique on `collection_id` | +| `UserCollectionSubscription` | 某用户对某已发布 collection 的订阅记录 | +| `CollectionMarketplaceStatusEnum` | `DRAFT`(只 owner 可见) / `PUBLISHED`(公开可见) | + +### ports.py 极简 + +`marketplace/ports.py` 只声明 `AuthenticatedUser(Protocol)`,且只 pin `id` 一个属性 — marketplace handler 只读 owner id,不做 admin-only 检查,不读其他 User 字段。也没有 `UserView` 这种跨域读 User 的 Protocol(因为 marketplace 根本不展示 user info)。 + +### Q2 rename — `check_marketplace_access` 的公开化 + +Phase 4 msg=6ab7d211 Q2 的一次 canonical 澄清: + +- 原名 `_check_marketplace_access`(前导下划线,semi-private) +- 新名 `check_marketplace_access`(公开方法名) + +改动原因:KB 的 consumer Protocol `knowledge_base.ports.MarketplaceCollectionOps` 声明这个方法为公开合约的一部分,service 侧的 `_` 前缀变成矛盾;改名后 service 的类 structurally 满足 Protocol,**不再需要** `aperag/app.py` 的 `_MarketplaceCollectionOpsAdapter`。 + +这是 Phase 4 canonical 落实的典型例子:"方法命名 / 可见性是 consumer Protocol 合约的一部分,不只是 provider 的私事"。 + +### KB consumer 消费 marketplace 的方式 + +knowledge_base 域有自己的 `ports.py` 声明 5 条 consumer Protocol,其中一条是: + +```python +# aperag/domains/knowledge_base/ports.py +@runtime_checkable +class MarketplaceCollectionOps(Protocol): + async def check_marketplace_access( + self, user_id: str, collection_id: str, ... + ) -> bool: ... + ... +``` + +`aperag/app.py`: + +```python +from aperag.domains.knowledge_base.service.collection_service import ( + set_marketplace_collection_ops as _kb_set_marketplace_collection_ops, +) +_kb_set_marketplace_collection_ops(_legacy_marketplace_collection_service) +``` + +这里 `_legacy_marketplace_collection_service` 是 `aperag/service/marketplace_collection_service.py` 的 shim 实例;shim 内部 delegate 到已经 domain-moved 的 `aperag/domains/marketplace/service/marketplace_collection_service.py`。通过 `A is B is C` 三元 shim,wire 的实际对象就是 domain service。shim 保留是 Phase 7+ 去除候选(见 SSoT Section 6)。 + +### 跨域 dependency + +marketplace service 的 inbound: + +- ✅ 只 import `knowledge_base.schemas` 的 view models(`Document` / `DocumentList` / `DocumentPreview`)做订阅方文档 preview — 属于 domain-moved 直接 import +- ❌ 不 import identity / governance / model_platform + +marketplace 被消费: + +- knowledge_base 通过 `MarketplaceCollectionOps` Protocol + DI 消费(见 KB 章节架构) + +--- + +## 跨域 dependency graph + +聚焦 4 个 Phase 4 域 + 它们的非本域消费方(KB / conversation),忽略 infra / shared: + +``` + ┌──────────────────────────────────────────────────┐ + │ aperag/app.py(adapter + DI wire-up) │ + └──┬───────────────────┬──────────────────┬────────┘ + │ _*InitOpsAdapter │ MktCollAdapter │ KB quota/etc + ▼ ▼ ▼ + ┌──────────┐ ┌─────────────┐ ┌──────────────┐ + │ identity │◀──Protocol─────────────────│ identity │ + │ (User / │ │ │ │ ports │ + │ Role) │───identity_user_ops─▶│ │ (BotInitOps │ + └────┬─────┘ │ │ │ ChatInitOps │ + │ │ │ │ QuotaInit) │ + │ │ │ └──────────────┘ + │ │ │ + │ ┌─────────┴──┐ ┌─────┴────────┐ ┌─────────────┐ + │ │ governance │ │ model_plat.. │ │ marketplace │ + │ │ (ApiKey / │ │ (LLMProv..) │ │ (CollMkpl.) │ + │ │ AuditLog) │ │ │ │ │ + │ └──────┬─────┘ └──────────────┘ └──────┬──────┘ + │ │ │ + │ │ UserView │ check_marketplace_access + │ │ Protocol │ (Q2 public rename) + │ ▼ ▼ + │ (structural) ┌────────────────┐ + │ │ knowledge_base │ + │ │ (consumer) │ + └──────── User ORM read/write ──────────┤ ports │ + └────────────────┘ +``` + +说明: + +- **实线箭头**:直接代码依赖(import / 消费) +- **虚线 / "Protocol"**:consumer-owned Protocol,provider 结构满足,不互相 import +- **`aperag/app.py`**:adapter + DI wire 的唯一物理位置,import-time 执行 + +--- + +## Canonical 规则(G15 / G16 / G17) + +### 14 份 `AuthenticatedUser(Protocol)` 刻意不合并 + +每个 domain(`identity` / `governance` / `model_platform` / `marketplace` / `knowledge_base` / `conversation` / `agent_runtime` / `evaluation` / `indexing` / `retrieval` / `knowledge_graph` / `web_access` + `chat_collection_service` 局部 + `auth.py` 本地)各自声明自己的 `AuthenticatedUser(Protocol)`,pin 各自 handler 读的属性。 + +理由: + +1. **最窄契约原则**:每个 handler 真正需要的属性差别大(marketplace 只 pin `id`;governance pin `id` + `role`;KB 的有些 handler pin `id` + `role` + `is_superuser`)。合并到一个全字段 Protocol 会让所有 handler 名义上依赖所有字段,违反最窄契约。 +2. **跨域依赖方向控制**:共享 Protocol 需要放在某个域,那个域就变成所有域的上游依赖源;目前每个域 own 自己的 Protocol,避免形成 dependency graph 中的特权节点。 +3. **重复成本可接受**:14 份 3-5 行代码 ≤ 70 行,维护成本显著低于引入共享抽象的复杂度代价。 + +未来若统一契约,会作为 Phase 7+ F5 candidate 处理,不在 Phase 4 scope。详见 SSoT Section 4 和 `docs/modularization/breaking-changes/phase4.md`。 + +### G15 — `Role` enum 仅限 identity 域持有 + +`aperag/domains/identity/db/models.py` 的 `Role` 枚举**不允许**被 identity 外的任何文件 import(包括 governance / model_platform / marketplace / KB / conversation / agent_runtime / evaluation / indexing / retrieval / KG / web_access)。 + +唯一例外:`aperag/db/models.py` 的 `Invitation` 类 class-body 引用,通过 G15 白名单豁免。 + +实施方式:`tests/unit_test/test_modularization_boundaries.py` 的 G15 gate AST-walk 扫 `ImportFrom(module="aperag.domains.identity.db.models", names=[..., "Role", ...])`,把 identity 和白名单文件外的 import 都判为违规。 + +admin 判断的 canonical 写法: + +```python +# ✅ Right(G15 compliant) +if user.role == "admin": + ... + +# ❌ Wrong(G15 violation) +from aperag.domains.identity.db.models import Role +if user.role == Role.ADMIN: + ... +``` + +### G16 — `User` ORM 仅限 identity 域持有 + +同样方式:禁止非 identity 文件 `from aperag.db.models import User` 或 `from aperag.domains.identity.db.models import User`。 + +读 User 字段 → 走 consumer-owned `UserView(Protocol)`(governance 有这一 Protocol 就是典型)。 +写 User 字段 → 走 `identity_user_ops.` facade。 +特殊写 → inline text SQL(单调用点 + PM ack)。 + +### G17 — Phase 4 DI smoke(3 identity + 4 KB = 7 条) + +`tests/unit_test/test_modularization_boundaries.py::test_phase4_di_critical_wirings_at_app_startup`:import `aperag.app` 后断言 7 个 CRITICAL_WIRINGS 的 DI slot 都已经 set 到非 None: + +1. `aperag.domains.identity.service.user_manager._bot_init_ops` ≠ None +2. `aperag.domains.identity.service.user_manager._chat_init_ops` ≠ None +3. `aperag.domains.identity.service.user_manager._quota_init_ops` ≠ None +4. `aperag.domains.knowledge_base.service.collection_service._marketplace_collection_ops` ≠ None +5. `aperag.domains.knowledge_base.service.collection_service._search_pipeline_ops` ≠ None +6. `aperag.domains.knowledge_base.service.collection_service._quota_ops` ≠ None +7. `aperag.domains.knowledge_base.service.collection_service._embedding_ops`(或类似名)≠ None + +G17 **不包含** Phase 5 的 2 条 permanent standalone-infra seam — 那是 G18 alt 的职责。G17 / G18 alt 分离的理由见 SSoT Section 4。 + +--- + +## Boundary gates + +### G1 — legacy aggregate import ban + +- 禁止 `from aperag.service.<...> import ...`(除 shim 白名单 3 条:quota / prompt_template / search_pipeline) +- 禁止 `from aperag.schema.view_models import ...` 在 domain 代码里 +- 禁止 `from aperag.db.models import ...` 在 domain 代码里(`Invitation` 例外) + +### G14 — 禁止 legacy route decorator + +domain 的 `api/routes.py` 里不允许用 `@aperag.views..get(...)` 这种 legacy 路由装饰器 — 必须用 domain 本地的 `APIRouter()`。 + +### G19 — 禁止 `from __future__ import annotations` 在 FastAPI 路由文件里 + +lesson 9a-quatuordec:PEP 563 延迟 annotation 求值会让 FastAPI `response_class` + `status_code=204` 的组合返回 500(annotation 里 `None` 被字符串化后 FastAPI 无法识别)。AST gate 扫 routes.py 有没有这条 import。 + +这条 gate 是 Phase 4 给 identity / governance / model_platform / marketplace 四域的 routes.py **特别校验**的 — 之前 `aperag/views/` 里有一些文件带着这条 import,Phase 4 hard-cut 时顺带扫清。 + +### Gate 索引 + +完整 gate 清单(G1 / G4 / G10 / G14 / KB consumer-owned / KB DI smoke / G15 / G16 / G17 / G18 alt / G19)见 `docs/modularization/architecture.md` Section 4 + `tests/unit_test/test_modularization_boundaries.py`。 + +--- + +## Legacy shims 与 Phase 7+ 去向 + +### `aperag/db/models.py` + +保留 53 个 re-export symbol + `Role` top-level import(给 `Invitation` class-body 用)+ `Invitation` class 本身。Phase 7+ 看是否把 `Invitation` 迁到某个域(identity 或者独立)一起清。 + +### `aperag/schema/view_models.py` + +6 个 dual-hook try-block 用于 Scenario A identity preservation —— 保证 domain `schemas.X is view_models.X`。运行时通过 `sys.modules.get("aperag.schema.view_models")` string lookup 避免 G1 AST 扫描判违规。是 Phase 4 + Phase 5 的 canonical choice,Phase 7+ 删除候选。 + +### `aperag/service/*_service.py` + +- **19 条普通 shim**(给 Phase 4 前的 `aperag.service._service` import 保留入口):Phase 7+ 删除候选,需要先把所有 import 改成 domain 路径。 +- **3 条 standalone-infra shim**(quota / prompt_template / search_pipeline):**不是** Phase 7+ 候选 — 这 3 条 service 的 canonical home 就在 `aperag/service/`,走 Protocol+DI seam。对应 G18 alt 的 2 条 permanent CRITICAL_WIRINGS。 + +### `aperag/views/*.py` + +- **13 条普通 shim**:Phase 7+ 候选 +- 其余 non-domain legacy(settings / prompts / auth):长期保留 + +### `ModelServiceProvider` 表 + +仍在 `aperag/db/models.py`。Phase 7+ 再看是归入 model_platform 还是独立域。 + +--- + +## 相关文档 + +- **canonical SSoT**:`docs/modularization/architecture.md` + - Section 2.1–2.4 — identity / governance / model_platform / marketplace domain 定义 + - Section 3 — canonical 规则(direct import / Protocol+DI / User write hierarchy) + - Section 4 — boundary gates + - Section 5 — permanent DI seam(G18 alt,2 条,**不在** 本文 4 域内) + - Section 6 — legacy shim lifecycle + - Section 8 — Phase 7+ F1-F15 候选 + +- **admin guide(本 4 域的用户面操作)**: + - [`admin-guide/api-keys.md`](../admin-guide/api-keys.md) — API Key 管理 + - [`admin-guide/audit-log.md`](../admin-guide/audit-log.md) — 审计日志 + - [`admin-guide/quota-system.md`](../admin-guide/quota-system.md) — 配额管理(跨 G18 alt seam) + +- **user guide(marketplace 用户面)**: + - [`user-guide/collection-marketplace.md`](../user-guide/collection-marketplace.md) — 发布 / 订阅流程(起稿中) + +- **其他架构**: + - [`architecture/overview.md`](./overview.md) — 入口 + - [`architecture/domains.md`](./domains.md) — 12 域通览 + - [`architecture/conversation-agent-evaluation.md`](./conversation-agent-evaluation.md) — Phase 5/6 三域架构 + - `architecture/indexing-retrieval-kg.md` — Phase 3 四域架构(起稿中) + - [`architecture/web-access.md`](./web-access.md) — web_access 域 + +- **历史 / 决策**: + - `docs/modularization/breaking-changes/phase4.md` — Phase 4 hard-cut breaking changes + - `docs/modularization/` — Phase 0→6 完整演进记录 diff --git a/docs/zh-CN/architecture/web-access.md b/docs/zh-CN/architecture/web-access.md new file mode 100644 index 000000000..531460798 --- /dev/null +++ b/docs/zh-CN/architecture/web-access.md @@ -0,0 +1,244 @@ +# Architecture: `web_access` Domain + +> **读者定位**:架构师、后端开发者,以及需要理解"为什么 ApeRAG 能从互联网抓取内容"的 SRE / 集成方。 +> +> **范围**:`web_access` canonical domain 的职责、目录结构、provider 抽象、跨 domain 消费路径。本文只写 **current state**;模块化演进历史见 `docs/modularization/architecture.md`。 +> +> **Baseline**:`origin/main @ 28a9f531` 代码面 + `docs/modularization/architecture.md` Section 2.12(canonical SSoT)。 + +## 概述 + +`web_access` 是 ApeRAG 12 个 canonical domain 之一,提供**从互联网抓取信息**的能力: + +- **web search**:关键词 / 站内搜索,返回 `WebSearchResultItem[]` +- **web read**:把若干 URL 的正文提取出来,返回 `WebReadResponse` + +这两个能力既通过 HTTP 路由对外暴露(`/api/v2/web/search` / `/api/v2/web/read`),也被其他 domain 作为内部依赖消费(知识库 URL 导入、MCP server 工具)。 + +这个 domain **没有 ORM 实体**(无 `db/models.py`):它只做"功能聚合",数据由其他 domain(identity 域的 provider API key 配置、knowledge_base 域的 document)持有。这一点在 SSoT Section 2.12 被明确标注。 + +## 目录结构 + +``` +aperag/domains/web_access/ +├── __init__.py +├── api/ +│ ├── __init__.py +│ └── routes.py # FastAPI APIRouter,两个端点 +├── schemas.py # Pydantic view models(Phase 2a hard-cut 从 view_models 迁入) +├── reader/ +│ ├── base_reader.py # Provider 抽象基类 +│ ├── reader_service.py # ReaderService 编排 +│ └── providers/ +│ ├── jina_read_provider.py +│ └── trafilatura_read_provider.py +├── search/ +│ ├── base_search.py +│ ├── search_service.py # SearchService 编排 +│ └── providers/ +│ ├── duckduckgo_search_provider.py +│ └── jina_search_provider.py +└── utils/ + ├── content_processor.py + └── url_validator.py +``` + +与其他 canonical domain 不同的地方: + +- **没有 `db/models.py`**:无 ORM 实体 +- **没有 `ports.py`(consumer-owned Protocol)**:本 domain 是被消费方,不主动消费任何 User-related ORM;只在 `api/routes.py` 顶部声明了一个本地 `AuthenticatedUser(Protocol)` 用于 `Depends(required_user)` 类型收窄 +- **多 provider 子包**:`search/providers/` + `reader/providers/` 属于"可插拔 provider"模式的典型落地 + +## 路由注册 + +在 `aperag/app.py`: + +```python +from aperag.domains.web_access.api.routes import router as web_access_router +... +app.include_router(web_access_router, prefix="/api/v2", tags=["web_access"]) +``` + +注意 **prefix 是 `/api/v2`**,不是 `/api/v1`。web_access 和 retrieval 两个 domain 目前在 v2 命名空间下;其他 domain 大多数在 v1。这是 Phase 2a 引入新 domain 时刻意选择的分区,避免把新 endpoint 混进 v1 的 legacy aggregate URL 表里。 + +## Search 能力 + +### 端点 + +```http +POST /api/v2/web/search +Authorization: Bearer sk-... +Content-Type: application/json + +{ + "query": "ApeRAG 最新技术路线", + "max_results": 5, + "timeout": 30, + "locale": "zh-CN", + "source": "example.com" +} +``` + +- `query`:关键词(可空,但与 `source` 至少要有一个) +- `source`:限制到某个域名(`site:example.com query` 语义)。可以只提供 `source` 做"站内浏览" +- `max_results` / `timeout` / `locale`:常规可选参数 + +### Provider 选择策略 + +`/api/v2/web/search` handler 的策略(见 `api/routes.py`): + +1. 查当前用户是否配置了 JINA API key(`async_db_ops.query_provider_api_key("jina", user_id=..., need_public=True)`) +2. 若有 → 尝试 JINA 搜索 +3. 若 JINA 失败或无 key → 回落到 DuckDuckGo + +DuckDuckGo 本身在 provider 内部还有一次 backend 降级(`auto` → `html` → `lite`),提升 zero-config 可用性。 + +### Soft-fail + +Provider 出错时,handler **不返回 500**:它把搜索失败翻译成 `WebSearchResponse{results: [], meta: {search_status: "unavailable" | "empty" | "disabled", error_code: ...}}`,由调用方自行判断是"搜索无结果"、"provider 挂了"还是"未启用"。 + +### 结果合并 + +当两条 provider path 都有结果(比如 JINA 主搜 + fallback 的 DuckDuckGo 补齐)时,`_merge_and_rank_results` 按 URL 去重,按 `rank` 排序,限制到 `max_results`。 + +## Read 能力 + +### 端点 + +```http +POST /api/v2/web/read +Authorization: Bearer sk-... +Content-Type: application/json + +{ + "url_list": ["https://example.com/a", "https://example.com/b"], + "timeout": 30, + "locale": "en-US" +} +``` + +- `url_list`:必填,至少一个 URL,长度限制在消费方各自强制(例如 knowledge_base 上游限制为 10 个以内) +- 单个 URL 就是 `url_list: ["..."]`,不提供单独的 `url` 字段 + +### Provider 选择策略 + +1. 查当前用户的 JINA API key +2. 若有 → JINA 优先,失败回退到 Trafilatura +3. 若无 → 仅用 Trafilatura(本地 HTML 解析,无外部依赖) + +Trafilatura 是纯本地库,适合内网部署 / 不想把 URL 外泄给第三方的场景。 + +### 失败处理 + +Read 不做 soft-fail:某个 URL 抓取失败会体现在结果 item 的 `success=false`;但整个 endpoint 失败会抛 500。 + +## Provider 抽象 + +`search/base_search.py` 和 `reader/base_reader.py` 各自定义基类: + +- `BaseSearchProvider` / `BaseReaderProvider`:定义 `async search(...)` / `async read(...)` 接口 +- Concrete provider 继承基类,`config` 作为初始化参数传入 provider-specific 选项 + +`SearchService` / `ReaderService` 的工厂方法按 `provider_name` 字符串从 registry 里挑: + +```python +provider_registry = { + "duckduckgo": DuckDuckGoProvider, + "ddg": DuckDuckGoProvider, + "jina": JinaSearchProvider, + "jina_search": JinaSearchProvider, +} +``` + +添加新 provider 的路径: + +1. 在 `providers/` 下新建文件,继承 `BaseSearchProvider` / `BaseReaderProvider` +2. 在 service 的 `provider_registry` 里注册 +3. 不需要改 HTTP 路由层,也不需要改 schema + +## 跨 domain 消费 + +### knowledge_base + +`knowledge_base/service/document_service.py` 通过 URL 导入功能把网页内容做成 document: + +```python +from aperag.domains.web_access.reader.reader_service import read_with_jina_fallback +from aperag.domains.web_access.schemas import WebReadRequest +``` + +**直接 import**(不走 Protocol+DI),因为 `web_access` 是已 domain-moved 的 provider(canonical rule:domain-moved provider → direct import;详见 `docs/modularization/architecture.md` Section 3)。 + +该消费路径有自己的业务约束: + +- URL 数量上限 10(在 knowledge_base 消费方强制,不是 web_access 的约束) +- 抓到内容后会走 knowledge_base 的 ingestion pipeline:embedder + chunker + vector store + +### MCP server + +`aperag/mcp/server.py` 把 `web_access` 的 search / read 暴露成 MCP tool: + +```python +@mcp_server.tool +async def web_search(query: str = "", max_results: int = 5, ...): + ... +``` + +这样接 Claude Desktop / Cursor 等 MCP host 时,它们能直接调 ApeRAG 的 web search 能力。 + +### Agent Runtime(间接消费) + +Agent Runtime 通过 bot 配置里的 `web_search` / `web_read` tool 在 turn 里按需调用同一批 provider。这条路径经过 tool 封装层,不直接 import `web_access`;详见 `architecture/conversation-agent-evaluation.md` 里的 tool 章节。 + +## Identity 耦合(仅读 `id`) + +`web_access/api/routes.py` 顶部有一个本地 `AuthenticatedUser(Protocol)` 声明: + +```python +class AuthenticatedUser(Protocol): + """Minimal auth-context contract the `web_access` domain depends on. + web_access only reads the authenticated user's id ...""" + id: object +``` + +原因: + +1. **G16 规则**:非 identity 域不能 import `aperag.db.models.User` ORM +2. 这里只需要用户 `id` 做 JINA API key 的 per-user lookup,不需要 `role` / `email` 等其他字段 +3. 声明本地 Protocol 比"collapse 到 Any"更诚实地表达依赖契约 + +SQLAlchemy `User` 类会 structurally 满足这个 Protocol(鸭子类型),所以 `Depends(required_user)` 不用改就能工作。 + +> 相关 canonical 细节(G15 literal-compare admin、UserView Protocol、14 份 AuthenticatedUser 有意重复未合并)见 `docs/modularization/architecture.md` Section 4。 + +## Schema 归属(Phase 2a hard-cut) + +早期 `WebSearchRequest` / `WebReadResponse` 等 Pydantic 类定义在 `aperag/schema/view_models.py` 大聚合里。Phase 2a 把它们搬到 `web_access/schemas.py`,同时保证 **OpenAPI 组件名 + shape byte-for-byte 一致**,这样前端 SDK 生成文件不会 diff。 + +聚合 `view_models.py` 里保留一层 re-export shim(给 Phase 2a 之前的 import 继续工作),属于 Phase 7+ 的 cleanup 候选。 + +## 失败模式与运维 + +### JINA API key 泄露 + +API key 按 user 粒度配置,单个用户 key 泄露只影响该用户的搜索配额。Admin 通过 model provider 配置页面撤换即可。 + +### DuckDuckGo 限流 + +`duckduckgo-search` 包可能被 DDG 反爬限流。observe 到大量 `RatelimitException` 时: + +- 检查是否有异常调用源(单 IP 大量请求) +- 临时给用户配 JINA API key,跳过 DDG + +### Trafilatura 提取失败 + +部分 JavaScript-heavy 页面 Trafilatura 提取不出正文。配 JINA API key 后会走 JINA(能执行 JS 渲染),通常可解决。 + +## 相关文档 + +- `docs/modularization/architecture.md` Section 2.12 — canonical SSoT 的 `web_access` domain 定义 +- [`architecture/domains.md`](./domains.md) — 12 domain 通览 +- `architecture/indexing-retrieval-kg.md` — knowledge_base URL 导入消费链路(起稿中) +- [`architecture/conversation-agent-evaluation.md`](./conversation-agent-evaluation.md) — Agent Runtime tool 层 +- [`integration/openai-compat.md`](../integration/openai-compat.md) — 相关集成接口 +- [`user-guide/content-import.md`](../user-guide/content-import.md) — URL 导入的用户面流程 diff --git a/docs/zh-CN/integration/openai-compat.md b/docs/zh-CN/integration/openai-compat.md new file mode 100644 index 000000000..d1db0e59a --- /dev/null +++ b/docs/zh-CN/integration/openai-compat.md @@ -0,0 +1,214 @@ +# OpenAI 兼容接口 + +> **读者定位**:想把 ApeRAG Bot 当作 OpenAI Chat Completion endpoint 接入第三方工具(SDK / Dify / Zapier / 自研应用)的用户。 +> +> **范围**:`POST /api/v1/chat/completions` 端点的调用方式、鉴权、query 参数、stream / non-stream 差异、局限性。 + +## 能做什么 + +ApeRAG 暴露一个与 OpenAI Chat Completion 协议**部分兼容**的端点,用户可以: + +- 用现有的 OpenAI SDK(只需改 `base_url`)把 ApeRAG Bot 当 LLM 调用 +- 在 Dify、LobeChat、Cherry Studio 等支持 OpenAI 兼容后端的工具里接入 ApeRAG +- 保留 ApeRAG 的能力(RAG 检索、自定义 prompt、工具调用),同时享受 OpenAI 协议的生态 + +端点本身不是一个 LLM — 它是 ApeRAG 某个 Bot 的 HTTP 封装。实际的 LLM 调用、RAG 检索、工具执行都走 Agent Runtime V3 的完整 pipeline。 + +## 基本调用 + +### 端点 + +``` +POST /api/v1/chat/completions +``` + +### 鉴权 + +Bearer API Key(与普通 ApeRAG API 相同,见 [`admin-guide/api-keys.md`](../admin-guide/api-keys.md)): + +``` +Authorization: Bearer sk-xxxxxxxxxxxxxxxx +``` + +### Query 参数 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `bot_id` | 推荐 | 绑定具体的 Agent Bot。省略时当前版本会失败(没有默认 Bot 概念,该字段名义上 optional 是为接口签名对齐) | +| `chat_id` | 否 | 复用已有会话,便于保留历史上下文。省略则创建一个临时(ephemeral)会话,不持久化历史 | +| `language` | 否 | 响应语言,透传给 Agent Runtime。默认 `en-US`。支持:`en-US` / `zh-CN` / `zh-TW` / `ja-JP` / `ko-KR` / `fr-FR` / `de-DE` / `es-ES` / `it-IT` / `pt-BR` / `ru-RU` | + +### Request Body + +```json +{ + "model": "aperag", + "messages": [ + {"role": "user", "content": "What is RAG?"} + ], + "stream": false, + "temperature": 0.7, + "max_tokens": 1024 +} +``` + +字段说明: + +- `model`:**当前被忽略**,Bot 配置里的 LLM 才是真正生效的模型。为了 OpenAI SDK 的兼容,请随便填一个合法字符串(例如 `"aperag"`)。 +- `messages`:OpenAI 格式的消息数组。只有最后一条 `user` 消息会触发新一轮 turn;前面的 messages 只在 `chat_id` 为空时被当作历史上下文的简化形式。若 `chat_id` 不为空,以 ApeRAG 自己的 chat history 为准。 +- `stream`:`true` 返回 SSE 流(`text/event-stream`),`false` 返回完整 JSON。 +- `temperature` / `max_tokens` / `max_completion_tokens` / `timeout`:**目前被忽略**。真正生效的是 Bot 配置里的参数。保留在 schema 里是为了 OpenAI SDK 不报错。 + +### 非流式响应 + +```json +{ + "id": "", + "object": "chat.completion", + "created": 1714000000, + "model": "aperag", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "RAG (Retrieval-Augmented Generation) is ..."}, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} +} +``` + +> ⚠️ **`usage` 永远返回 0**。当前版本没有实现 token 统计(原因:ApeRAG 内部 turn 可能经过多个 LLM / tool,单一 token 计数没有清晰语义)。依赖 usage 做计费的下游请走 Bot 级的 audit log 而非这个字段。 + +### 流式响应 + +设置 `"stream": true` 时返回 `text/event-stream`: + +``` +data: {"id":"...","object":"chat.completion.chunk","created":...,"model":"aperag","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} + +data: {"id":"...","object":"chat.completion.chunk","created":...,"model":"aperag","choices":[{"index":0,"delta":{"content":"RAG"},"finish_reason":null}]} + +data: {"id":"...","object":"chat.completion.chunk","created":...,"model":"aperag","choices":[{"index":0,"delta":{"content":" is ..."},"finish_reason":null}]} + +data: {"id":"...","object":"chat.completion.chunk","created":...,"model":"aperag","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} +``` + +格式与 OpenAI 的 `chat.completion.chunk` 一致:首条含 `role: assistant`,中间若干条 `delta.content` 增量,最后一条 `finish_reason: stop`。**目前没有发 `data: [DONE]` 行**,下游 SDK 若严格依赖该 sentinel 需要额外处理。 + +## 错误响应 + +错误按 OpenAI 格式返回: + +```json +{ + "error": { + "message": "Invalid JSON request body", + "type": "server_error", + "code": "internal_error" + } +} +``` + +常见错误: + +| HTTP | message | 原因 | +| --- | --- | --- | +| 400 | `Invalid JSON request body` | body 不是合法 JSON | +| 401 | fastapi-users 默认 | Bearer token 缺失 / 失效 / 已吊销 | +| 403 | quota message | 触发 quota 限制(见 [`admin-guide/quota-system.md`](../admin-guide/quota-system.md)) | +| 404 | bot not found | `bot_id` 不存在或无访问权限 | +| 500 | internal_error | Agent Runtime 异常 | + +## 在 OpenAI SDK 里调用 + +### Python + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-xxxxxxxxxxxxxxxx", + base_url="https:///api/v1", +) + +# 非流式 +resp = client.chat.completions.create( + model="aperag", + messages=[{"role": "user", "content": "What is RAG?"}], + extra_query={"bot_id": "bot-xxx"}, +) +print(resp.choices[0].message.content) + +# 流式 +stream = client.chat.completions.create( + model="aperag", + messages=[{"role": "user", "content": "What is RAG?"}], + stream=True, + extra_query={"bot_id": "bot-xxx", "chat_id": "chat-xxx"}, +) +for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="") +``` + +OpenAI SDK 不直接支持自定义 query param,用 `extra_query={"bot_id": ..., "chat_id": ..., "language": ...}` 即可。 + +### cURL + +```bash +curl -X POST "https:///api/v1/chat/completions?bot_id=bot-xxx" \ + -H "Authorization: Bearer sk-xxxxxxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "aperag", + "messages": [{"role": "user", "content": "What is RAG?"}], + "stream": false + }' +``` + +### 在 Dify / LobeChat 里接入 + +把 OpenAI-compatible provider 的 `base_url` 设为 `https:///api/v1`,`api_key` 用 ApeRAG API Key。然后在每条消息 URL 里拼 `?bot_id=` — 具体配置取决于工具侧 UI。 + +## 不兼容点与注意事项 + +### 不支持 + +- **`tools` / `tool_calls` 字段**:ApeRAG Bot 的 tool 调用是在 runtime 内部完成的,不透传到 HTTP 响应里。 +- **`functions`(旧版 function calling)**:同上。 +- **`logprobs` / `top_logprobs`**:不支持。 +- **`n > 1`(多 choice)**:不支持,永远返回单个 choice。 +- **`response_format: json_object`**:不支持,返回纯文本。 +- **`seed` / `logit_bias`**:不支持。 + +### 行为差异 + +- **`model` 字段被忽略**:真正生效的 LLM 由 Bot 配置决定,`model` 字段仅作 SDK 兼容占位。 +- **`usage` 全为 0**:当前版本不做 token 统计。 +- **流式无 `data: [DONE]`**:严格依赖 SDK 需手工兜底。 +- **没有 `moderation` endpoint**:只开放 `/chat/completions`。 +- **rate limit header 未实现**:限流只通过 quota system,没有 `X-RateLimit-*` header。 +- **timeout**:Agent Runtime 默认 300s 超时;Bot 里配置的 timeout 以 Bot 为准,请求层面的 `timeout` 字段被忽略。 + +### 与原生 ApeRAG 接口的区别 + +ApeRAG 自身的对话接口是 `/api/v1/bots/{bot_id}/chats/{chat_id}/messages`(见 [`reference/prompt-api.md`](../reference/prompt-api.md)),返回更丰富的结构(turn_id / timeline events / artifacts / references)。OpenAI 兼容接口把这些都**摊平成纯文本**,丢失结构化能力。 + +**什么时候用 OpenAI 兼容接口**: + +- 接第三方工具(Dify / LobeChat / 自己的 ChatGPT UI) +- 已有大量 OpenAI SDK 代码要迁移 + +**什么时候用原生接口**: + +- 需要展示引用(references) +- 需要 timeline / artifact 细节 +- 需要访问 turn 级元数据 + +## 相关文档 + +- [`admin-guide/api-keys.md`](../admin-guide/api-keys.md) — 获取调用用的 Bearer Token +- [`admin-guide/quota-system.md`](../admin-guide/quota-system.md) — quota 限制 +- [`reference/prompt-api.md`](../reference/prompt-api.md) — ApeRAG 原生对话接口 +- [`user-guide/chat-interaction.md`](../user-guide/chat-interaction.md) — 用户交互流程 diff --git a/docs/zh-CN/user-guide/collection-marketplace.md b/docs/zh-CN/user-guide/collection-marketplace.md new file mode 100644 index 000000000..ac0b9e974 --- /dev/null +++ b/docs/zh-CN/user-guide/collection-marketplace.md @@ -0,0 +1,235 @@ +# 知识库市场(Collection Marketplace) + +> **读者定位**:两类最终用户 — (1) 希望把自己的知识库公开分享给他人的 **所有者(owner)**;(2) 希望发现 / 订阅他人已发布知识库的 **订阅者(subscriber)**。 +> +> **范围**:发布 / 取消发布、浏览、订阅 / 取消订阅、订阅后的只读访问。架构侧(CollectionMarketplace / UserCollectionSubscription ORM + G16 边界)见 [`architecture/identity-governance-model-platform-marketplace.md`](../architecture/identity-governance-model-platform-marketplace.md) 的 marketplace 章节。 + +## 概念 + +ApeRAG 的 **Collection Marketplace**(知识库市场)让你把自己的知识库公开给其他用户浏览和订阅: + +- **所有者**:可以把自己的 collection `publish` 到市场,公开可见;也可以随时 `unpublish` 下架。 +- **订阅者**:可以浏览市场里所有已发布的 collection,`subscribe` 感兴趣的 collection 后以**只读**方式访问里面的文档、知识图谱等。 + +市场行为与 RBAC 权限体系是正交的:订阅不会给你该 collection 的写权限;它只是把原 owner 的 collection 内容以只读视图开放给你。原 owner 对数据的任何变更(添加文档、重建索引、解除分享)都会即时反映到订阅者那边。 + +## 状态模型 + +一个 collection 在市场里只有两种状态: + +- **`DRAFT`(草稿 / 未发布)**:只 owner 可见。这是所有新创建 collection 的默认状态。 +- **`PUBLISHED`(已发布)**:所有登录用户都可以浏览;任何其他用户都可以订阅。 + +`unpublish` 把状态从 `PUBLISHED` 转回 `DRAFT`(技术上是软删除对应的 marketplace 记录)。一旦取消发布,已订阅的用户会在下次访问时被拒绝。 + +## 所有者视角 + +### 发布知识库到市场 + +在 ApeRAG Web UI 的 collection 详情页点击"分享到市场"按钮;或走 HTTP: + +```http +POST /api/v1/collections/{collection_id}/sharing +Authorization: Bearer sk- +``` + +返回 204 No Content。发布后: + +- 该 collection 出现在 `GET /api/v1/marketplace/collections` 列表 +- 其他登录用户可以订阅 +- 未登录 / 匿名用户也能看到它出现在公开列表(当前设计允许匿名浏览市场目录) + +### 查看发布状态 + +```http +GET /api/v1/collections/{collection_id}/sharing +``` + +返回: + +```json +{ + "is_published": true, + "published_at": "2026-04-15T08:23:00Z" +} +``` + +### 取消发布 + +```http +DELETE /api/v1/collections/{collection_id}/sharing +``` + +返回 204。取消后: + +- 该 collection 从市场列表里消失 +- **现有订阅者不会被强制退订**,但他们访问该 collection 时会收到 403 / 404 +- 原 owner 的使用不受影响 + +### 发布前的准备 + +- **检查内容**:所有文档 / 链接 / 知识图谱都会被订阅者看到,下架前先确认没有敏感内容。 +- **加 collection summary**:在详情页点"生成摘要"或走 `POST /api/v1/collections/{id}/summary`;订阅者能在市场列表里看到摘要,有助于发现。 +- **调整标题和描述**:这些字段是订阅者看到的第一眼信息。 +- **确认索引完整**:发布时不会自动触发索引重建;若某些文档还在 `PENDING` / `FAILED` 状态,订阅者访问时会看到空白或错误。 + +## 订阅者视角 + +### 浏览市场目录 + +```http +GET /api/v1/marketplace/collections?page=1&page_size=30 +``` + +不需要认证也能调用(匿名浏览);若带 token,响应里的 subscription status 会标记你是否已订阅。 + +返回 `SharedCollectionList`:分页列表,每项包含 collection 标题、摘要、owner username、发布时间、订阅人数等。 + +### 订阅一个 collection + +```http +POST /api/v1/marketplace/collections/{collection_id}/subscribe +Authorization: Bearer sk- +``` + +成功返回 `SharedCollection`(订阅后的只读视图)。 + +失败情况: + +| HTTP | 说明 | +| --- | --- | +| 400 | `Collection is not published to marketplace` — 该 collection 已取消发布 | +| 400 | `Cannot subscribe to your own collection` — 不能订阅自己的 collection | +| 409 | `Already subscribed to this collection` — 已订阅过,不需要重复 | + +### 查看自己订阅的列表 + +```http +GET /api/v1/marketplace/collections/subscriptions?page=1&page_size=30 +Authorization: Bearer sk- +``` + +返回你当前**未取消**的所有订阅。 + +### 取消订阅 + +```http +DELETE /api/v1/marketplace/collections/{collection_id}/subscribe +Authorization: Bearer sk- +``` + +返回 `{"message": "Successfully unsubscribed"}`。再次订阅同一 collection 是允许的(如果它还在 `PUBLISHED` 状态)。 + +### 访问订阅后的 collection 内容 + +订阅后,你可以通过 marketplace 专用 endpoints 只读访问: + +| Endpoint | 返回 | +| --- | --- | +| `GET /api/v1/marketplace/collections/{id}` | 元数据(title / description / summary / owner) | +| `GET /api/v1/marketplace/collections/{id}/documents` | 文档列表(分页 / 排序 / 搜索) | +| `GET /api/v1/marketplace/collections/{id}/documents/{doc_id}/preview` | 文档 preview | +| `GET /api/v1/marketplace/collections/{id}/documents/{doc_id}/object` | 原始文档字节(支持 Range header) | +| `GET /api/v1/marketplace/collections/{id}/graph` | 知识图谱(节点 / 边结构)| + +所有这些接口都走相同的 access 检查: +1. 若 collection 未发布 → 404 +2. 若 collection 已发布但订阅方式不符(目前所有登录用户 = 允许读)→ 403 +3. 否则返回数据 + +**重要**:所有 marketplace 文档 / 图谱查询在后端都用 **owner 的 user_id** 去读 KB 内容,而不是订阅者自己的 user_id。这意味着你看到的是 owner 最新状态,包括 owner 刚添加的文档、刚重建的索引。 + +### marketplace 内容不能被订阅者修改 + +下面的操作在订阅的 marketplace collection 上都会返回 403 / 404: + +- 添加文档 / 删除文档 +- 触发重建索引 +- 编辑 collection 描述 +- 生成 / 编辑 collection summary +- 修改知识图谱 + +唯一的写操作是订阅 / 取消订阅本身。 + +## 在 Chat / Bot / Agent 里使用订阅的 collection + +订阅的 collection 可以直接在聊天 / bot 配置里**作为数据源使用**(前提是你已订阅): + +- 在 Bot 配置的"知识库"选择框里,订阅的 collection 会出现在"已订阅"分组 +- 在 Chat 侧边栏直接发起基于订阅 collection 的对话 +- Agent Runtime 在检索工具调用时会根据 Bot 配置自动走订阅权限 + +如果 owner 取消发布 / 下架,Bot 下次调用时会拿到空结果(不会静默失败,UI 会显示"该知识库不再可用")。 + +## 知识图谱(KG)视角 + +如果 owner 在自己的 collection 里启用了 Knowledge Graph(详见 `architecture/indexing-retrieval-kg.md`),订阅者可以通过 `GET /api/v1/marketplace/collections/{id}/graph?label=*&max_nodes=1000&max_depth=3` 只读查询: + +- `label`:过滤节点 label(`*` 表示全部) +- `max_nodes`:返回节点数上限(默认 1000,上限 10000) +- `max_depth`:BFS 深度(默认 3,上限 10) + +与非 marketplace collection 的 graph 查询参数保持一致,只是底层访问权限走 `marketplace_collection_service.check_marketplace_access` 验证。 + +## 实体与数据模型(简版) + +| 表 | 字段要点 | 说明 | +| --- | --- | --- | +| `CollectionMarketplace` | `collection_id` (unique) / `status` (`DRAFT` \| `PUBLISHED`) / `gmt_created` / `gmt_deleted` | 每个 collection 最多 1 条活跃 marketplace 记录 | +| `UserCollectionSubscription` | `user_id` / `collection_id` / `gmt_created` / `gmt_deleted` | 每 (user, collection) 在同一时刻最多 1 条活跃订阅 | + +这两张表在 `aperag/domains/marketplace/db/models.py`。 + +**注意**:`UserCollectionSubscription` 只记录"订阅了什么",**不记录**"阅读历史"或"对订阅内容的个人标注"— marketplace 目前是纯目录 + 订阅关系,不持有订阅者侧的用户数据。 + +## 发布 / 订阅 / 匿名访问的权限矩阵 + +| 操作 | 匿名 | 登录非 owner | 登录 owner | 已订阅非 owner | admin | +| --- | --- | --- | --- | --- | --- | +| 列出市场 collections | ✅ | ✅ | ✅ | ✅ | ✅ | +| 查看 collection metadata(marketplace endpoint) | ❌ 403 | ✅ | ✅ | ✅ | ✅ | +| 订阅 | — | ✅ | ❌ 400(self) | ❌ 409(dup) | ✅ | +| 取消订阅 | — | ❌(没订阅) | — | ✅ | ✅ | +| 读文档 / 图谱 | ❌ 403 | ✅ | ✅ | ✅ | ✅ | +| 发布 / 取消发布 | ❌ | ❌ 403 | ✅ | — | ✅ | +| 写 / 修改数据 | ❌ | ❌ 403 | ✅(走普通 KB 接口) | ❌ 403 | ✅ | + +> ⚠️ 注意"列出市场 collections"允许匿名;"查看 metadata"和"读文档 / 图谱"**需要登录**但不一定需要订阅。订阅目前主要用途是"把这个 collection 放进我的订阅列表"便于快速访问,不是 hard access gate。可能随安全策略调整。 + +## 常见问题 + +### 发布后别人看得到我的原始文件吗? + +能。`GET /marketplace/collections/{id}/documents/{doc_id}/object` 允许登录用户按 owner 权限读原始文件字节。发布前请确认没有敏感文件(合同 / PII / 密钥)。 + +### 取消发布后,已订阅用户的 bot 会怎样? + +下次访问时拿到 404 / 403;Bot UI 会标记该知识库"不再可用"。建议发布前和订阅者沟通,避免突发下架。 + +### owner 删除了文档,订阅者能看到吗? + +不能。订阅者看到的是 owner 侧的当前状态:owner 删了就没了。软删除的文档(`DocumentStatus=DELETED`)也被 filter 掉。 + +### 一个用户可以订阅多少个 collection? + +当前没有硬上限;每个订阅一条 `UserCollectionSubscription` 行。如果需要限制,可以走 quota system 扩展(目前 quota 不含订阅计数)。 + +### collection 的 summary 谁看得到? + +所有能看到 collection metadata 的用户都能看到 summary。summary 是发布面向订阅者的主要发现路径。 + +### 可以只对特定用户分享吗? + +**目前不支持**。marketplace 只有"全公开 / 不公开"两档。如果需要细粒度分享(特定用户 / 组),可以走自定义应用层集成(例如用你自己的 API key 模式代理访问)。未来版本可能加 unlisted share link 选项。 + +### 分享到市场会影响 quota 吗? + +不会。Marketplace 订阅不计入订阅者的 `max_collection_count` quota — 那个 quota 只数自己 owned 的 collection。 + +## 相关文档 + +- [`user-guide/document-upload.md`](./document-upload.md) — 发布前准备的文档上传流程 +- [`user-guide/content-import.md`](./content-import.md) — URL / 文本导入 +- [`user-guide/knowledge-export.md`](./knowledge-export.md) — 打包导出(订阅者不能导出,只 owner 可) +- [`architecture/identity-governance-model-platform-marketplace.md`](../architecture/identity-governance-model-platform-marketplace.md) — marketplace 架构 +- `docs/modularization/architecture.md` — 12 域 canonical SSoT