Files
PolyGateway/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md
T
iomgaa 80aa2b216d docs: tighten the issue #11 plan after Codex review
The tenant_id rule was wrong in a way that would have shipped: the plan
said reject when strip() is empty, but the design says reject leading and
trailing whitespace outright. " t1" survives the weaker rule and then
compares unequal to "t1" inside an RLS policy, so a caller who pads the
value silently loses rows.

Adds the test that guards a promise nothing else was guarding -- same
messages and namespace with different meta must still hit the cache.
Without it, folding meta into the key passes every other assertion and
costs a full cache cold start plus a permanently lower hit rate, which
degrades quietly instead of failing.

Also pins _record's new parameter positions, splits the backfill-failure
setup per backend (ownership check on PG, read-only file on SQLite, and
says what SQLite cannot assert), puts the red-green gate on the
integration task, and names the two wiki pages.
2026-08-17 06:18:50 -04:00

19 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。
  • 保真校验: 本计划不涉及参考实现迁移,保真校验不适用

计划阶段的新发现(执行前需知)

设计只覆盖 chat()embed() 两条链路(issue 只诉求这两条)。写计划时核实代码发现第三条链路: OcrClient 同样经 TelemetryEmitter.emit_attempt 写遥测(ocr.py:426),其 _emitocr.py:398 现场构造 ChatRequest,结构与 embedding 完全同构。若不处理,OCR 行的 tenant_id 恒为空串,而 OCR 行与 chat 行落在同一张表——多租户下游的审计链会缺一块,且同样不可逆。

处置: 列为 Task 6,与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。此任务超出已批准设计的字面范围,执行者若被要求严格限定范围,可跳过 Task 6 并在 CHANGELOG 明确声明"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 核心论点的验收)