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.
19 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报错;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)报错(注意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)。
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.py 或 test_client.py): 相同 messages + 相同 cache_namespace、仅 meta 不同的两次调用,第二次仍应命中缓存。设计明确 meta 不进缓存 key(cache_namespace 已负责租户隔离);没有这条测试,实现者顺手把 meta 并进 key 不会被任何断言拦住,后果是存量缓存全量冷启动且此后命中率持续偏低——这类退化不报错、只表现为变慢。
- 提交点:
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 增两个形参,位置排在现有末参 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_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 既有测试形态,不新造机制)。两端的失败构造方式不同,不可笼统写"各测一遍":
- 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那条既有纪律),不断言"写入仍成功"——那在只读库上本就不可能。
- Postgres: 用只有
验收标准: 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 的 指南-遥测与成本 与 参考-公共API 两页
按 docs-convention.md §2「新公共 API / 新能力」行,须同步「对应指南页 + 参考-公共API + 侧边栏 + CHANGELOG」。本次扩写 指南-遥测与成本(新增"多租户与自定义维度"一节)而非新建页,故侧边栏与 Home.md 不动——新增页才需要同步导航。若执行时判断内容多到该独立成页(如 指南-多租户),则必须一并改 _Sidebar.md 与 Home.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 核心论点的验收)