docs: plan the implementation for issue #11
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 调用方自定义维度设计(issue #11)
|
# 调用方自定义维度设计(issue #11)
|
||||||
|
|
||||||
- **状态**: 待 Codex 审 → 待人类审批
|
- **状态**: **已人类审批(2026-08-17)**;Codex 审查 4 项已全部采纳并修订
|
||||||
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
|
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
|
||||||
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
|
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ date: 2026-08-17
|
|||||||
|
|
||||||
# 调用方自定义维度设计(issue #11)
|
# 调用方自定义维度设计(issue #11)
|
||||||
|
|
||||||
正文: `2026-08-17-issue11-caller-dimensions-design.md`。状态: 待 Codex 审 → 待人类审批。
|
正文: `2026-08-17-issue11-caller-dimensions-design.md`。状态: **已人类审批(2026-08-17)**,进入 writing-plans。
|
||||||
|
|
||||||
|
审批时三个待定项按设计原值定稿,人类未提出改动: `meta` key 数量上限 16 / `str` value 上限 256 / `tenant_id` 上限 128(量级推断,无本项目实测依据);库不自动建索引、不自动启用 RLS(GovDoc 需 DBA 执行模板 SQL 才拿到数据库层隔离);`_BACKFILL` 自动 ALTER 降级议题**不纳入本次**。
|
||||||
|
|
||||||
- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。`chat()`/`embed()` 各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
|
- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。`chat()`/`embed()` 各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
|
||||||
- **范围收窄(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开。
|
- **范围收窄(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开。
|
||||||
|
|||||||
@@ -160,6 +160,11 @@
|
|||||||
"id": "plan:issue10-error-body-retention-plan",
|
"id": "plan:issue10-error-body-retention-plan",
|
||||||
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
|
"label": "实现计划: HTTP 错误响应体留存(Issue #10)",
|
||||||
"type": "plan"
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:issue11-caller-dimensions",
|
||||||
|
"label": "调用方自定义维度实现计划(issue #11)",
|
||||||
|
"type": "plan"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -288,6 +293,13 @@
|
|||||||
"relation": "implements",
|
"relation": "implements",
|
||||||
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
|
"evidence": "7 任务覆盖设计 G1-G4 与 §7 全部验收用例",
|
||||||
"added": "2026-08-16T09:50:57.830855+00:00"
|
"added": "2026-08-16T09:50:57.830855+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:issue11-caller-dimensions",
|
||||||
|
"target": "design:issue11-caller-dimensions",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
|
||||||
|
"added": "2026-08-17T10:09:08.967997+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-08-17 09:53 UTC
|
> 自动生成,更新时间:2026-08-17 10:09 UTC
|
||||||
|
|
||||||
## design (30)
|
## design (30)
|
||||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||||
|
|
||||||
## plan (23)
|
## plan (25)
|
||||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
|
- [2026-08-06-governance-backend-error-plan](plans/2026-08-06-governance-backend-error-plan.md) `plan:2026-08-06-governance-backend-error-plan`
|
||||||
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
|
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
|
||||||
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
|
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
|
||||||
|
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
|
||||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||||
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
|
||||||
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
|
||||||
|
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
||||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||||
|
|
||||||
## schema (1)
|
## schema (1)
|
||||||
|
|||||||
@@ -103,3 +103,6 @@
|
|||||||
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
|
- [2026-08-16 09:50 UTC] 新增边: plan:issue10-error-body-retention-plan --implements--> design:issue10-error-body-retention
|
||||||
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
|
- [2026-08-16 09:50 UTC] 重建索引: 66 篇页面
|
||||||
- [2026-08-17 09:53 UTC] 重建索引: 68 篇页面
|
- [2026-08-17 09:53 UTC] 重建索引: 68 篇页面
|
||||||
|
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
|
||||||
|
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
|
||||||
|
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
|
||||||
|
|||||||
@@ -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 核心论点的验收)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:issue11-caller-dimensions
|
||||||
|
title: "调用方自定义维度实现计划(issue #11)"
|
||||||
|
date: 2026-08-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# 调用方自定义维度实现计划(issue #11)
|
||||||
|
|
||||||
Reference in New Issue
Block a user