# 实现计划: 调用方自定义维度(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`),其 `_emit` 在 `ocr.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.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` 放置,同款风格):

```python
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 约定①):

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

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

```python
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`/端口的追加位置保持一致便于逐行比对):

```python
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 真实文件用例)

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

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.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 核心论点的验收)
