Files
PolyGateway/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md
T
iomgaa bf2fbd6c5e docs: fold the OCR path into the approved scope for issue #11
OcrClient emits through the same helper and its rows land in the same
table as chat rows. Covering only chat and embed would leave one table
holding rows that have a tenant and rows that never will, and the
issue's own irreversibility argument applies to those rows too.

The design said two paths because the issue said two paths. Corrected
at the source rather than only in the plan, so a later reader does not
find OCR work with no design behind it.
2026-08-17 06:22:47 -04:00

18 KiB

实现计划: 调用方自定义维度(issue #11)

  • 目标: 让调用方能在每次调用上附带租户标识与任意自定义维度,并落进遥测表——tenant_id 为真实列(可挂 RLS),其余进 meta JSON 容器。
  • 方案概述: llm_calls 增两列(tenant_id TEXT NOT NULL DEFAULT ''meta JSON);三个公共入口(chat/embed/OCR 两方法)各增两个带默认值的 keyword-only 参数;校验在入口收口并抛裸 ValueError;TelemetryRecorder 端口 22 → 24 字段。库不建索引、不启用 RLS,只交付 policy 模板。
  • 依据设计: research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md(已人类审批 2026-08-17)。
  • 涉及技术: Python 3.11+、frozen dataclass、asyncpg、sqlite3、pytest。
  • 保真校验: 本计划不涉及参考实现迁移,保真校验不适用

范围: 三条遥测链路(2026-08-17 人类追认)

设计初稿只覆盖 chat()embed()(issue 只诉求这两条)。写计划时核实代码发现第三条链路: OcrClient 同样经 TelemetryEmitter.emit_attempt 写遥测(ocr.py:426),其 _emitocr.py:398 现场构造 ChatRequest,结构与 embedding 完全同构。OCR 行与 chat 行落在同一张表,不处理则同表内一部分行有租户归属、一部分永远空白,且同样不可逆。

人类已追认纳入正式范围(2026-08-17),设计文档 §1.2 已同步补正。Task 6 是必做项,不是可跳过的分支——与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。


文件结构

文件 动作 职责
src/polygateway/types.py 修改 新增 validate_caller_dimensions();ChatRequest 增两字段
src/polygateway/ports.py 修改 TelemetryRecorder.record_llm_call 22 → 24 字段
src/polygateway/telemetry/sqlite.py 修改 DDL 增两列、_BACKFILL_COLUMNS 增两项、_COLUMNS 增两项
src/polygateway/telemetry/postgres.py 修改 同上(_DDL/_BACKFILL/_COLUMNS)
src/polygateway/middleware/telemetry.py 修改 _record 归一化并透传;三个 emit 入口从 request 读取
src/polygateway/client.py 修改 chat() 增两参数并校验
src/polygateway/embedding.py 修改 embed() 增两参数;沿 _embed_batch/_attempt/_emit 透传
src/polygateway/ocr.py 修改 recognize_text/parse_layout 增两参数;沿 _call/_attempt/_emit 透传
tests/unit/test_types.py 修改 校验函数红线
tests/unit/test_telemetry.py 修改 三个 emit 入口带维度
tests/unit/test_client.pytest_embedding.pytest_ocr_client.py 修改 三条链路各自的端到端透传
tests/unit/test_ports.py 修改 端口 24 字段契约
tests/integration/test_postgres_telemetry.py 修改 真实 PG 补列与写入
README.mdCHANGELOG.md、Gitea wiki 修改 能力表、RLS 模板、版本说明

依赖顺序: Task 1 → Task 2 → Task 3 → (Task 4 / 5 / 6 可并行) → Task 7 → Task 8。


关键接口(跨任务消费,此处定稿)

校验函数(types.py,紧邻 validate_request_overlay 放置,同款风格):

def validate_caller_dimensions(
    tenant_id: str | None,
    meta: Mapping[str, Any] | None,
    *,
    origin: str,
) -> tuple[str | None, dict[str, Any]]:
    """校验调用方维度并返回浅拷贝;origin 用于把错误指回调用点。"""

ChatRequest 新字段(必须带默认值,ARCH §5.1 约定①):

tenant_id: str | None = None
meta: Mapping[str, Any] = field(default_factory=dict)

TelemetryRecorder.record_llm_call 新增两个无默认值参数(ports.py 现有纪律),排在 reasoning_tokens 之后:

tenant_id: str,   # 已归一化: None → ''
meta: str,        # 已序列化: 空 dict → '{}'

归一化在 emitter 完成,不在 recorder——与 sampling 列由 canonical_sampling_json() 在 emitter 侧定型是同一先例。recorder 只负责落库,不做语义判断。


Task 1: 校验函数与请求字段

文件: src/polygateway/types.py(修改)、tests/unit/test_types.py(修改)

行为:

validate_request_overlay 之后新增 validate_caller_dimensions(),按 Phase 组织(照搬既有风格):

  • Phase 1 tenant_id: None 直接放行;非 str 报错;tenant_id != tenant_id.strip() 报错(首尾空白一律拒绝,不是"strip 后为空才拒绝"——" t1""t1" 会在 RLS policy 的等值比较下变成两个不同租户,静默漏数据);strip() 后为空亦报错(空串是哨兵值的地盘);长度 > 128 报错。
  • Phase 2 meta 键形态: 非 str 报错;不匹配 ^[a-z0-9_.]{1,64}$ 报错;以 pg_ 开头报错(保留前缀)。
  • Phase 3 meta 键数量: > 16 报错。
  • Phase 4 meta 值: 类型不属 (str, int, float, bool) 报错(注意 boolint 子类,先判 bool 无妨,两者都合法);floatnot math.isfinite(v) 报错;str 且长度 > 256 报错。
  • 返回 (tenant_id, dict(meta or {})) —— 拷贝,防调用方复用同一 dict 逐次改值造成竞态(同 overlay 先例)。

ChatRequest 增两字段(见"关键接口")。字段 docstring 说明: 只读快照,库内中间件永不修改;meta 不进缓存 key(cache_namespace 已负责租户隔离,ARCH §7.5)。

ChatRequest.meta 必须保存校验函数返回的那个浅拷贝,不是调用方传入的原 dict——否则调用方复用同一 dict 逐次改值会让已在洋葱中流转的请求跟着变(同 overlay 拷贝语义的理由)。

验收标准: 每条红线抛 ValueError 且消息含 origin;合法输入返回浅拷贝且与入参不是同一对象。

测试要求(先失败后通过): 逐条红线各一个用例——tenant_id 空串/纯空白/" t1"/"t1 "(首尾空白)/超长/非 str;meta 键非 str/含大写/含连字符/超 64 字符/pg_ 前缀/17 个键;值为 list/dict/None/nan/inf/-inf/超 256 字符的 str。另加合法路径用例: tenant_id=None + meta={} 放行、meta 值为 bool/int/float 有限值放行、返回值是拷贝(改返回值不影响入参)。

验证命令: conda run -n PolyGateway pytest tests/unit/test_types.py -v → 全 PASS

另需一条缓存隔离测试(放 tests/unit/test_cache.pytest_client.py): 相同 messages + 相同 cache_namespacemeta 不同的两次调用,第二次仍应命中缓存。设计明确 meta 不进缓存 key(cache_namespace 已负责租户隔离);没有这条测试,实现者顺手把 meta 并进 key 不会被任何断言拦住,后果是存量缓存全量冷启动且此后命中率持续偏低——这类退化不报错、只表现为变慢。

  • 提交点: feat: validate the dimensions a caller may attach to a call

Task 2: 端口与两个遥测后端 schema

文件: src/polygateway/ports.pysrc/polygateway/telemetry/sqlite.pysrc/polygateway/telemetry/postgres.pytests/unit/test_ports.py(均修改)

行为:

ports.py: record_llm_calltenant_id: strmeta: str(无默认值),docstring 的"22 字段冻结"改为 24 并说明新字段已归一化。

sqlite.py 三处同步改(顺序必须一致):

  • _DDLreasoning_tokens 之后追加 tenant_id TEXT NOT NULL DEFAULT ''meta TEXT NOT NULL DEFAULT '{}';
  • _BACKFILL_COLUMNS 追加 ("tenant_id", "TEXT NOT NULL DEFAULT ''")("meta", "TEXT NOT NULL DEFAULT '{}'") —— SQLite 硬性要求 NOT NULL 列必须带非 NULL 常量默认值,缺默认值会报 Cannot add a NOT NULL column with default value NULL;
  • _COLUMNS 追加两项。

postgres.py 同三处:

  • _DDL 追加 tenant_id TEXT NOT NULL DEFAULT ''meta JSONB NOT NULL DEFAULT '{}'::jsonb;
  • _BACKFILL 追加两条 ALTER TABLE llm_calls ADD COLUMN ...(默认值均为非易失常量,PG 11+ 不重写全表);
  • _COLUMNS 追加两项。

新列必须排在末尾(created_at 与既有补列之后)——旧表只能 ALTER 追加,新建库若插在前面两条路径的物理列序会分叉(sqlite.py:53 既有注释)。

验收标准: 两个后端的 _COLUMNS 逐字同名同序;新建库与旧表补列后列集合一致。

测试要求(先失败后通过): 扩展现有列序断言测试,断言两后端 _COLUMNS 相等且末两项为 ("tenant_id", "meta");test_ports.py 断言 record_llm_call 的参数集合含新两项且无默认值(用 inspect.signature 实测,不凭记忆)。

验证命令: conda run -n PolyGateway pytest tests/unit/test_ports.py tests/unit/test_telemetry.py -v → PASS

  • 提交点: feat: give the telemetry table a tenant column and a meta container

Task 3: Emitter 透传与归一化

文件: src/polygateway/middleware/telemetry.py(修改)、tests/unit/test_telemetry.py(修改)

行为:

_record 增两个形参,位置排在现有末参 reasoning_tokens 之后(它是 keyword-only,顺序不影响调用,但与 _COLUMNS/端口的追加位置保持一致便于逐行比对):

async def _record(
    self, *, ...,
    reasoning_tokens: int | None,
    tenant_id: str | None,          # 新增: 未归一化,None 合法
    meta: Mapping[str, Any],        # 新增: 未序列化,空 dict 合法
) -> None:

在传给 recorder 前归一化: tenant_id or '';json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False),空 dict 直接用字面量 '{}'

allow_nan=False 是第二道闸(主防线是 Task 1 的入口校验)——json.dumps 默认把 nan 写成 NaN 字面量,那不是合法 JSON,PG 的 JSONB 会拒收,失败会被 emitter 的降级 try 吞成 warning,即把调用方的输入错误变成静默丢遥测。

三个 emit 入口统一从 request.tenant_id / request.meta 读取,不各自组装(遥测调用点收敛铁律):

  • emit_attemptemit_terminal_failure: 直接读 request;
  • emit_cache_hit: 同样读 request 而非缓存中的历史响应——维度是"本次调用由谁发起",不是历史那次。

验收标准: 三条路径写出的行都带维度;tenant_id=None'';meta={}'{}' 而非 NULL。

测试要求(先失败后通过): 用 fake recorder 断言三个入口各自收到的 tenant_id/meta 值;emit_cache_hit 单独一个用例——构造"请求带租户 A、缓存中的历史响应属于租户 B"的场景,断言落库的是 A(这是最容易实现反的一处);meta 序列化后键有序(sort_keys=True,便于跨行比对)。

验证命令: conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v → PASS

  • 提交点: feat: carry caller dimensions through the single telemetry helper

Task 4: chat() 公共入口

文件: src/polygateway/client.py(修改)、tests/unit/test_client.py(修改)

行为: chat() 签名末尾增 tenant_id: str | None = Nonemeta: Mapping[str, Any] | None = None(带默认值的 keyword-only,签名冻结承诺不破)。在既有 validate_request_overlay 调用旁调 validate_caller_dimensions(..., origin="chat(tenant_id=..., meta=...)"),把返回值填进 ChatRequest。校验必须在进洋葱之前——洋葱内的一切失败都会被遥测层降级成 warning,校验放里面等于没有校验。

验收标准: 不传两参数时行为与改动前逐字一致(既有调用点零改动);传入非法值时 chat()ValueError未产生任何遥测行

测试要求(先失败后通过): 端到端——chat(..., tenant_id="t1", meta={"batch": "b-42"}) 后 fake recorder 收到的行带这两个值;非法 metaValueError 且 recorder 零调用(断言"校验早于遥测",这是 §4.2 的核心承诺);不传参数时 recorder 收到 '''{}'

验证命令: conda run -n PolyGateway pytest tests/unit/test_client.py -v → PASS

  • 提交点: feat: let chat() take a tenant and caller-defined dimensions

Task 5: embed() 链路透传

文件: src/polygateway/embedding.py(修改)、tests/unit/test_embedding.py(修改)

行为: embed() 增两个 keyword-only 参数并在入口校验(origin="embed(tenant_id=..., meta=...)"),沿 _embed_batch()_attempt()_emit() 逐层透传,在 _emit()(embedding.py:360)构造 ChatRequest 时填入。

该链路已在逐层传 session_id/parent_call_id,再加两个即四个同类参数。不顺手把它们收成值对象——那会改动 embedding 全部内部签名,属任务外重构。本次只做加法。

验收标准: 多批(texts 长度 > batch_size)时每一批的行都带同一份维度——维度属于本次 embed() 调用,不随批次变化。

测试要求(先失败后通过): 单批与多批各一个用例,断言 fake recorder 收到的每一行都带维度(多批用例要断言行数 > 1 且全部一致,否则"只有第一批带维度"的实现会漏网);非法值抛 ValueError 且零遥测。

验证命令: conda run -n PolyGateway pytest tests/unit/test_embedding.py -v → PASS

  • 提交点: feat: carry caller dimensions down the embedding chain

Task 6: OCR 链路透传

文件: src/polygateway/ocr.py(修改)、tests/unit/test_ocr_client.py(修改)

行为: recognize_text()parse_layout() 各增两个 keyword-only 参数并在入口校验(origin 分别标明方法名),沿 _call()_attempt()_emit() 透传,在 _emit()(ocr.py:398)构造 ChatRequest 时填入。结构与 Task 5 同构。

验收标准: 两个公共方法都覆盖(只改一个即漏)。

测试要求(先失败后通过): 两个方法各一个用例,断言遥测行带维度;非法值抛 ValueError 且零遥测。

验证命令: conda run -n PolyGateway pytest tests/unit/test_ocr_client.py -v → PASS

  • 提交点: feat: carry caller dimensions through the OCR chain

Task 7: 真实后端集成验收

文件: tests/integration/test_postgres_telemetry.py(修改)、tests/unit/test_telemetry.py(补 SQLite 真实文件用例)

行为: 覆盖三件事,每件两个后端各测一遍。

  1. 新建库: 表列齐全,写入后读回维度一致。
  2. 旧表补列(不可逆性的机械化验收): 手工建一张 22 列的旧表并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、老行的 tenant_id 读出为空串而非 NULL。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。
  3. 补列失败的降级方向: 补列失败时逐行降级丢弃而非判死(沿用 issue #9 既有测试形态,不新造机制)。两端的失败构造方式不同,不可笼统写"各测一遍":
    • Postgres: 用只有 SELECT, INSERT ON llm_calls 权限的角色连接——ALTER TABLE 的 ownership 检查早于 IF NOT EXISTS 的存在性判断,故必然失败。断言: 记 warning、_failed 置位、后续 INSERT 仍尝试。
    • SQLite: 无角色权限模型,等价构造是文件只读(chmod 444 或以 file:...?mode=ro 打开)。但只读库连 INSERT 也做不了,故此处只断言"补列失败不清空 self._conn、不抛出 __init__"(即 sqlite.py:112 那条既有纪律),不断言"写入仍成功"——那在只读库上本就不可能。

验收标准: PG 与 SQLite 行为对称;补列走既有 _BACKFILL,不新增 DDL 路径。

测试要求(先失败后通过): 上述三项即测试本体,同样适用红绿证据门——先写出断言看它因缺列/缺维度而失败,再实现至通过,保留失败输出。Postgres 用真实实例(CLAUDE.md §4.6: Redis/PG 相关测试不 mock)。

验证命令: conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v → PASS conda run -n PolyGateway pytest tests/ -q → 全套件 PASS(命令末尾不接管道,否则退出码失真)

  • 提交点: test: prove old telemetry tables gain the tenant column safely

Task 8: 文档同步

文件: README.mdCHANGELOG.md、Gitea wiki 的 指南-遥测与成本参考-公共API 两页

docs-convention.md §2「新公共 API / 新能力」行,须同步「对应指南页 + 参考-公共API + 侧边栏 + CHANGELOG」。本次扩写 指南-遥测与成本(新增"多租户与自定义维度"一节)而非新建页,故侧边栏与 Home.md 不动——新增页才需要同步导航。若执行时判断内容多到该独立成页(如 指南-多租户),则必须一并改 _Sidebar.mdHome.md 分流表。

行为:

  • CHANGELOG: 新增"未发布"段,写清新增两列、两个新参数(三条链路)、校验规则与上限数值、以及库不建索引/不启用 RLS 的边界
  • README: 能力表补调用方维度;数字型断言若涉及遥测字段数,用 inspect.signature 实测后再写(发布流程 §4.4.1 第 1 步的教训)。
  • 参考-公共API: 更新 chat()embed()(以及 Task 6 若执行则含 OCR 两方法)的签名——该页纪律是"以源码实测为准",改前先对照实际签名,不凭计划文本写。
  • 指南-遥测与成本: 新增一节"多租户与自定义维度",含设计 §4.5 定稿的 RLS 模板(ENABLE + FORCE + USING/WITH CHECK 双写 + NULLIF(current_setting(..., true), ''))与复合索引 (tenant_id, created_at),并写明三个陷阱: 表属主默认豁免 RLS;租户上下文必须在显式事务内set_config(..., true)(asyncpg 默认 autocommit,单发 SET LOCAL 会当场失效而 PG 只发 warning 不报错,表现为 fail-closed 到零行);只写 USING 不写 WITH CHECK 时租户 A 能插入标着 B 的行。
  • wiki 必须明确: 执行这些 DDL 是下游 DBA 的职责,库不会代劳;不执行则 tenant_id 只是一个普通列,没有数据库层强制。

验收标准: 三处文档对"库做什么、下游做什么"的表述一致,不出现"库自动启用 RLS"之类的措辞。

验证命令: conda run -n PolyGateway make lint → PASS;人工核对 wiki 页面渲染。

  • 提交点: docs: document caller dimensions and the RLS template

全局验收

  • conda run -n PolyGateway make lint → PASS(含 import-linter 依赖契约)
  • conda run -n PolyGateway make test → PASS,覆盖率不低于改动前
  • 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 Phase 2 合并前硬门)
  • 三条链路各自的"传入维度 → 落库"证据齐全(chat / embed / OCR)
  • 旧表补列后老行读出空串的证据(issue 核心论点的验收)