Eight tasks against the approved design, ordered so the port and both telemetry backends land before the three call paths that feed them. Writing the plan turned up a third telemetry path the design missed: OcrClient emits through the same helper and builds its ChatRequest on the spot, just as embedding does. OCR rows share the table with chat rows, so leaving them out would put a hole in a multi-tenant caller's audit trail, and the same irreversibility argument applies. Listed as Task 6 and flagged as beyond the approved scope -- it may be dropped, but only by stating the limitation in the CHANGELOG, not silently. The integration task pins the issue's own argument as a test: build a 22-column table, open it with the current recorder, and assert the old rows read back as the empty string rather than NULL -- NULL under an RLS policy is invisible to everyone, not merely unassigned.
16 KiB
实现计划: 调用方自定义维度(issue #11)
- 目标: 让调用方能在每次调用上附带租户标识与任意自定义维度,并落进遥测表——
tenant_id为真实列(可挂 RLS),其余进metaJSON 容器。 - 方案概述:
llm_calls增两列(tenant_id TEXT NOT NULL DEFAULT ''、metaJSON);三个公共入口(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),其 _emit 在 ocr.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.py、test_embedding.py、test_ocr_client.py |
修改 | 三条链路各自的端到端透传 |
tests/unit/test_ports.py |
修改 | 端口 24 字段契约 |
tests/integration/test_postgres_telemetry.py |
修改 | 真实 PG 补列与写入 |
README.md、CHANGELOG.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报错;strip()后为空报错(空串是哨兵值的地盘);长度 > 128 报错。 - Phase 2
meta键形态: 非str报错;不匹配^[a-z0-9_.]{1,64}$报错;以pg_开头报错(保留前缀)。 - Phase 3
meta键数量: > 16 报错。 - Phase 4
meta值: 类型不属(str, int, float, bool)报错(注意bool是int子类,先判bool无妨,两者都合法);float且not math.isfinite(v)报错;str且长度 > 256 报错。 - 返回
(tenant_id, dict(meta or {}))—— 拷贝,防调用方复用同一 dict 逐次改值造成竞态(同overlay先例)。
ChatRequest 增两字段(见"关键接口")。字段 docstring 说明: 只读快照,库内中间件永不修改;meta 不进缓存 key(cache_namespace 已负责租户隔离,ARCH §7.5)。
验收标准: 每条红线抛 ValueError 且消息含 origin;合法输入返回浅拷贝且与入参不是同一对象。
测试要求(先失败后通过):
逐条红线各一个用例——tenant_id 空串/纯空白/超长/非 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
- 提交点:
feat: validate the dimensions a caller may attach to a call
Task 2: 端口与两个遥测后端 schema
文件: src/polygateway/ports.py、src/polygateway/telemetry/sqlite.py、src/polygateway/telemetry/postgres.py、tests/unit/test_ports.py(均修改)
行为:
ports.py: record_llm_call 增 tenant_id: str 与 meta: str(无默认值),docstring 的"22 字段冻结"改为 24 并说明新字段已归一化。
sqlite.py 三处同步改(顺序必须一致):
_DDL在reasoning_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 增两个形参,并在传给 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_attempt、emit_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 = None 与 meta: 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 收到的行带这两个值;非法 meta 抛 ValueError 且 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 真实文件用例)
行为: 覆盖三件事,每件两个后端各测一遍。
- 新建库: 表列齐全,写入后读回维度一致。
- 旧表补列(不可逆性的机械化验收): 手工建一张 22 列的旧表并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、老行的
tenant_id读出为空串而非 NULL。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。 - 补列失败的降级方向: 权限不足时逐行降级丢弃而非判死(沿用 issue #9 既有测试形态,不新造机制)。
验收标准: 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.md、CHANGELOG.md、Gitea wiki(按 research-wiki/docs-convention.md §2 清单)
行为:
- CHANGELOG: 新增"未发布"段,写清新增两列、两个新参数(三条链路)、校验规则与上限数值、以及库不建索引/不启用 RLS 的边界。
- README: 能力表补调用方维度;数字型断言若涉及遥测字段数,用
inspect.signature实测后再写(发布流程 §4.4.1 第 1 步的教训)。 - wiki: 新增一节"多租户与自定义维度",含设计 §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 核心论点的验收)