|
|
|
@@ -0,0 +1,249 @@
|
|
|
|
|
# 实现计划: 调用方自定义维度(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`),其 `_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` 放置,同款风格):
|
|
|
|
|
|
|
|
|
|
```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` 报错;`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 真实文件用例)
|
|
|
|
|
|
|
|
|
|
**行为**: 覆盖三件事,每件两个后端各测一遍。
|
|
|
|
|
|
|
|
|
|
1. **新建库**: 表列齐全,写入后读回维度一致。
|
|
|
|
|
2. **旧表补列(不可逆性的机械化验收)**: 手工建一张 **22 列的旧表**并插入一行,再用当前 recorder 打开它 → 补列成功、新行写入成功、**老行的 `tenant_id` 读出为空串而非 NULL**。这条直接对应 issue 的核心论点(先启用后加列,老行归属无法还原);断言"是空串"而非"是 NULL",因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞。
|
|
|
|
|
3. **补列失败的降级方向**: 权限不足时逐行降级丢弃而非判死(沿用 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 核心论点的验收)
|