ADR-0005: Doclib Interface、Client 与 Server 契约一致性
历史决策记录:保留当时的背景、状态和设计。当前接口与实现归属以 开发入口 和现行规范为准;文中的历史模块路径不保证仍存在。
状态: Accepted 日期: 2026-06-11 相关文档: ../sdk/doclib-client.md, ../architecture.md, 0004-doclib-http-api-resources.md
背景
doclib server 的能力同时被多类入口使用:
mineruCLI。- Python SDK client。
- 本地 HTTP API。
- 未来的 MCP、Web 和桌面 App。
当前实现中,doclib 的能力定义分散在 client、FastAPI routes、service 和部分 Pydantic types 中。长期看会产生几个问题:
- Client 方法、Server route 和文档中的 API 容易漂移。
- 请求体和返回体没有一个稳定的主定义位置。
- 后续 Agent 实现功能时,需要从多处代码反推 doclib 能力边界。
- 同步 SDK 和异步 server 的运行模型不同,不能用单一接口粗暴统一。
需要定义一套更清晰的契约层,使 doclib 提供的能力、请求模型、响应模型、Client 实现和 Server 实现都可以被静态检查和运行时检查约束。
决策
doclib 引入同步接口和异步接口两套一等契约:
DoclibInterface: 同步接口,面向同步 SDK client。AsyncDoclibInterface: 异步接口,面向 async SDK、FastAPI server 实现和异步 in-process 实现。
两套接口保持同名、同参数语义和同返回模型。差异只在同步/异步调用方式:
class DoclibInterface(ABC):
@abstractmethod
def ensure_parse(self, request: ParseRequest) -> ParseResponse: ...
class AsyncDoclibInterface(ABC):
@abstractmethod
async def ensure_parse(self, request: ParseRequest) -> ParseResponse: ...
Interface 层只定义 doclib 能力契约,不包含 HTTP route 元数据,不依赖 FastAPI,也不出现 Request、Response 等 HTTP 实现概念。
Schema 层
所有稳定请求和响应模型集中定义在 doclib interface 包中。建议按领域拆分:
mineru/doclib/interface/
__init__.py
base.py
schemas.py # 或聚合导出
parse.py
docs.py
search.py
config.py
server.py
cleanup.py
routes.py
复杂能力优先使用请求模型作为入参:
def ensure_parse(self, request: ParseRequest) -> ParseResponse: ...
def invalidate(self, request: InvalidateRequest) -> InvalidateResponse: ...
GET 语义的方法不使用 request body model,必须使用 path/query 参数风格:
def list_parses(
self,
*,
ids: list[int] | None = None,
sha256: str | None = None,
tier: str | None = None,
status: ParseStatus | None = None,
page_range: str | None = None,
include_superseded: bool = False,
limit: int = 50,
offset: int = 0,
) -> ListParsesResponse: ...
def get_parse(self, parse_id: int) -> ParseInfo: ...
def get_doc(self, sha256: str, *, expand_files: bool = False) -> DocInfo: ...
def get_doc_content(
self,
sha256: str,
*,
tier: str,
page_range: str | None = None,
after: str | None = None,
limit: int = 30000,
format: str = "markdown",
no_marker: bool = False,
) -> DocContentResponse: ...
def search(
self,
query: str,
*,
file_type: str | None = None,
tier: Tier | None = None,
min_tier: Tier | None = None,
limit: int = 20,
offset: int = 0,
) -> SearchResponse: ...
def find(self, query: str, *, ext: str | None = None, limit: int = 50, offset: int = 0) -> FindResponse: ...
def get_file_by_path(self, path: str) -> FileInfoResponse: ...
第一版不以裸 dict 作为主契约。确实需要携带扩展字段时,应在 response model 中显式保留扩展字段,例如 extra: dict[str, Any] = {}。
Route 元数据
HTTP route 元数据只挂在具体实现方法上,不挂在 interface 方法上。
Server 与 Client 使用同一个 @route 装饰器:
@dataclass(frozen=True)
class RouteInfo:
method: str
path: str
tags: tuple[str, ...] = ()
def route(method: str, path: str, *, tags: tuple[str, ...] = ()) -> Callable:
def decorator(func: Callable) -> Callable:
func._route_info = RouteInfo(method=method, path=path, tags=tags)
return func
return decorator
装饰器只挂载 _route_info,不注册 FastAPI route,不发起 HTTP 请求,也不区分 server/client。
Server 实现:
class DoclibServer(AsyncDoclibInterface):
@route("POST", "/parses", tags=("parse",))
async def ensure_parse(self, request: ParseRequest) -> ParseResponse:
...
Client 实现:
class MineruClient(DoclibInterface):
@route("POST", "/parses", tags=("parse",))
def ensure_parse(self, request: ParseRequest) -> ParseResponse:
return self._request_typed(self.ensure_parse, request, ParseResponse)
这样做的原则是:
- 打开 Client 或 Server 方法时,可以直接看到 HTTP method 和 path。
- Interface 保持纯能力契约,不被 HTTP 绑定。
- Client 和 Server 的 route 字符串允许重复书写,但必须由测试强制保持一致。
Server 注册方式
doclib server 启动时扫描 DoclibServer 实例上的实现方法:
- 找到带
_route_info的 callable。 - 读取 method、path、tags。
- 注册为 FastAPI endpoint。
- endpoint 内部负责将 HTTP path/query/body 映射为 interface schema。
- 调用
DoclibServer实现方法。 - 将 Pydantic response model 转换为 HTTP JSON response。
DoclibServer 可以继续调用现有 async service。Interface 只约束公开能力,不要求 service 层也继承 interface。
Client 调用方式
MineruClient 继承 DoclibInterface。每个 HTTP-backed 方法也带同一个 @route 装饰器。
Client 方法通过自身方法对象读取 _route_info:
def _request_typed(
self,
method_func: Callable,
request: BaseModel | None,
response_type: type[T],
) -> T:
route_info = get_route_info(method_func)
...
Client 不从 Server 实现类读取 route 元数据,避免 SDK client 依赖 server 层或 FastAPI 相关模块。
一致性检查
需要增加运行时测试,作为契约漂移的硬约束。
必须检查:
DoclibInterface与AsyncDoclibInterface方法清单一致。- 两套 interface 的同名方法参数一致,忽略
self和async差异。 - 两套 interface 的同名方法返回模型一致。
MineruClient实现DoclibInterface的所有 abstract methods。DoclibServer实现AsyncDoclibInterface的所有 abstract methods。MineruClient与DoclibInterface的同名方法签名一致。DoclibServer与AsyncDoclibInterface的同名方法签名一致。MineruClient.method._route_info == DoclibServer.method._route_info。- 所有公开 interface 方法在 Client 和 Server 实现上都有 route 元数据。
- Server route 注册结果与实现方法上的 route 元数据一致。
签名比较规则:
- 忽略
self。 - 同步/异步差异不参与比较。
- 参数名、参数顺序、默认值、类型注解必须一致。
- 返回类型注解必须一致。
- Server 实现方法不得额外暴露
Request、Response、BackgroundTasks等 FastAPI 注入参数。
能力范围
第一版 interface 覆盖 doclib 当前对外能力,包括用户能力和管理能力。
Parse / Docs:
ensure_parselist_parsesget_parseinvalidatelist_docsget_docget_doc_content
Search / Info:
searchfindget_file_by_path
Config:
get_configset_configadd_watchlist_watchesremove_watchadd_exclude_rulelist_exclude_rulesremove_exclude_ruleadd_parsing_rulelist_parsing_rulesremove_parsing_rule
Server:
get_server_statusshutdown_server
Cleanup:
cleanup_deleted_filescleanup_orphan_docscleanup_temp_files
后续新增 MCP、Web 或 App 能力时,应优先复用这套 interface,而不是直接新增绕过 interface 的 HTTP route。
替代方案
方案 A: route 元数据挂在 interface 方法上
拒绝。这样会让 interface 层绑定 HTTP 语义,不利于 in-process、MCP 或非 HTTP 实现复用。
方案 B: route 只集中放在 registry 中
拒绝。集中 registry 一致性强,但读具体方法实现时看不到 HTTP method/path,不够直观,后续由 Agent 维护时容易漏看 registry。
方案 C: Client 从 Server 实现类读取 route 元数据
拒绝。这样会让 SDK client 依赖 server 实现层,可能间接引入 FastAPI 或 server-only 依赖,破坏 client/server 边界。
方案 D: Client 和 Server 各自写 route 字符串,无检查
拒绝。直观但缺少硬约束,不能解决 client/server 漂移问题。
影响
代码影响:
- 新增
mineru/doclib/interface/包。 MineruClient改为继承DoclibInterface,返回 typed response models。- 新增
DoclibServer或等价 async server implementation,继承AsyncDoclibInterface。 - FastAPI routes 从手写 router 函数逐步迁移为扫描 server implementation 方法注册。
- 旧的分散 request/response models 迁移到 interface schema 层。
API 影响:
- HTTP path 仍遵循 ADR-0004 的资源模型。
- route 元数据成为 Client 和 Server 实现层的显式约束。
- 文档中的请求体、查询参数和返回体应从 interface schema 同步维护。
测试影响:
- 新增 interface sync/async 签名一致性测试。
- 新增 client/server route 元数据一致性测试。
- 新增 server route 注册 smoke test。
- 新增 typed response parse/serialization 测试。
兼容性影响:
- NEXT 版尚未发布,不需要保留旧 alias 或旧 response shape。
- 迁移期间可以保留旧 routes 文件作为 adapter,但稳定契约应以 interface 层为准。
后续动作
- 创建
mineru/doclib/interface/包,定义DoclibInterface、AsyncDoclibInterface、RouteInfo和route。 - 将当前
mineru/doclib/types.py和 routes 内部 Pydantic models 迁移到 interface schema。 - 按能力范围补齐请求/响应模型。
- 改造
MineruClient继承DoclibInterface并使用@route。 - 新增
DoclibServer实现AsyncDoclibInterface并使用@route。 - 改造 FastAPI app 创建逻辑,扫描
DoclibServer注册 routes。 - 增加签名一致性、route 一致性和 OpenAPI smoke tests。
- 删除或收敛旧
routes/*.py中与新 server implementation 重复的 handler。