diff --git a/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md b/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md index fcf8fc0..e749d19 100644 --- a/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md +++ b/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md @@ -23,6 +23,8 @@ issue 只要租户维度。人类决定放大为**调用方自定义维度**的 - **只做调用方自定义的维度**。请求自带信息(模型名、供应商、源名)继续走现有 `model`/`provider`/`source_name`/`model_reported` 列,**库不往新容器写任何自采信息**。 - 保留期与访问控制不在本次范围(issue 第 4 条,另开)。 +**范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖**三条**遥测链路而非两条。issue 与本设计初稿都只说了 `chat()`/`embed()`,但 `OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 同构。**OCR 行与 chat 行落在同一张表**——只覆盖两条会让同一张表里一部分行有租户归属、一部分永远空白,且「先启用后加列则归属无法还原」这条不可逆性论证对 OCR 行同样成立。与 issue #10 同一判断(那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修)。 + ### 1.3 明确不做 不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,另开 issue)。 @@ -86,9 +88,11 @@ issue 只要租户维度。人类决定放大为**调用方自定义维度**的 ### 4.1 公共 API -两条路径对称新增两个 keyword-only 参数(F2/F3): +四个公共方法对称新增两个 keyword-only 参数(F2/F3): -`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`,`embed(texts, *, ..., 同两参数)`。 +`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`;`embed(texts, *, ..., 同两参数)`;`recognize_text(image, *, ..., 同两参数)` 与 `parse_layout(image, *, ..., 同两参数)`。 + +**OCR 两个方法都要改**——只改一个即漏,而漏掉的那半会静默写出无归属的行。 **为什么 `tenant_id` 独立成参而不是 `meta` 里的一个约定 key**: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 `meta` 里则需要库偷偷抽取一个魔法 key,调用方拼错 `tenant_id`/`tenantId` 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。 @@ -110,7 +114,7 @@ issue 只要租户维度。人类决定放大为**调用方自定义维度**的 **超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在**两个公共入口**(`chat()` 与 `embed()`)而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 `overlay` 保护键的现有先例同构(`client.py:223` 的 `validate_request_overlay`),校验函数同样共用一份,不在两个入口各写一遍。 -### 4.3 内部流转: 两条独立链路 +### 4.3 内部流转: 三条独立链路 `ChatRequest` 增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] = field(default_factory=dict)`(F3)。二者是**只读快照**,库内中间件永不修改——与 `sampling` 字段同一纪律(issue #4 决策 A)。 @@ -118,7 +122,9 @@ issue 只要租户维度。人类决定放大为**调用方自定义维度**的 **embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)**。`EmbeddingClient` 不经过 chat 洋葱: `embed()` → `_embed_batch()` → `_attempt()` → `_emit()`,而 `_emit()` 在 `embedding.py:360` **现场构造** `ChatRequest` 仅为复用同一个 Emitter,当前只填了 `session_id`/`parent_call_id`。若只改 `chat()`,结果是 chat 行有维度而 embed 行恒为空——**恰好落空 issue 第 2 条诉求**(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 `_emit()` 构造 `ChatRequest` 时填入。 -**不顺手重构这条链的参数列表**: 该链已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。 +**OCR 是第三条链,同构同办**(2026-08-17 范围补正)。`recognize_text()`/`parse_layout()` → `_call()` → `_attempt()` → `_emit()`,同样在 `_emit()`(`ocr.py:398`)现场构造 `ChatRequest`。两个公共方法都是入口,都要校验并透传。 + +**不顺手重构这两条链的参数列表**: 它们已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 与 OCR 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。 **缓存命中行与终态失败行同样带维度**: 前者读 `request` 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。 @@ -205,7 +211,7 @@ CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created 单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝/**非有限 float**),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。 -**两条链路各测一遍**——`chat()` 与 `embed()` 都必须有"传入维度 → 遥测行带该维度"的用例。embed 侧尤其不能省: 它经 `_embed_batch`/`_attempt`/`_emit` 四层透传,任一层漏传都不会报错、只会让维度恒为空,而这正是 issue 第 2 条诉求的落点。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化)。 +**三条链路各测一遍**——`chat()`、`embed()`、OCR 两方法都必须有"传入维度 → 遥测行带该维度"的用例。embed 与 OCR 尤其不能省: 它们各经四层透传,任一层漏传都不会报错、只会让维度恒为空。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化);OCR 的 `recognize_text` 与 `parse_layout` **各测一个**,只测一个会漏掉另一个的透传缺口。 非有限 float 单独一条: 断言 `chat(meta={"x": float("nan")})` 抛 `ValueError` 而**不是**写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。 diff --git a/research-wiki/designs/issue11-caller-dimensions.md b/research-wiki/designs/issue11-caller-dimensions.md index 6640de8..2bde0ed 100644 --- a/research-wiki/designs/issue11-caller-dimensions.md +++ b/research-wiki/designs/issue11-caller-dimensions.md @@ -11,8 +11,9 @@ date: 2026-08-17 审批时三个待定项按设计原值定稿,人类未提出改动: `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 字段。 -- **范围收窄(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开。 +- **选定方案**: `tenant_id` 提真实列(RLS 硬需求)+ `meta` JSON 容器承载任意调用方自定义 KV(**默认不建索引**)。四个公共方法(`chat`/`embed`/`recognize_text`/`parse_layout`)各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。 +- **范围(2026-08-17 人类决策)**: 只做**调用方自定义**的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 `meta` 写任何自采信息。issue 第 4 条(保留期与访问控制)另开。 +- **范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖 **chat / embed / OCR 三条**遥测链路。issue 与设计初稿都只说了前两条,但 `OcrClient` 经同一 emitter 写遥测(`ocr.py:426`)且行落**同一张表**,漏掉会让同表内一部分行有归属、一部分永远空白,不可逆性论证对其同样成立(同 issue #10 判断)。 - **为什么必须提列而不能纯 JSON**: 两条独立实证。① RLS 挂 `meta->>'tenant_id'` 语法合法但会静默退化——PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**规划,而 `->>` 未标 leakproof;pgsql-general 实证案例的最终解法就是"索引列改成非 JSONB",Tom Lane 警告手工标 leakproof 是安全问题。② 与 RLS 无关的独立问题: planner 对 JSONB 本就无可用统计,`@>` 走硬编码 0.1% 选择率,Heap 复现里行数低估 12 万倍、join 从 300ms 变 584 秒。 - **为什么不做"可配置提升列白名单"**: dbt/Airbyte/Fivetran 三家一致禁止用户自定义列(Fivetran 的后续 MERGE 直接把用户列置 NULL,官方方案是建视图)。本库场景更糟: 两个下游对同名 key 推断出不同类型时,第二个到达者的 `ADD COLUMN` 被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、持续污染。且 `_COLUMNS`/`_INSERT` 从常量变运行时拼接,SQL 注入面从零出现,端口"22 字段冻结"与列序断言全部失效。 - **同类系统佐证**: LiteLLM(同为 LLM 网关、同为每调用一行进 PG)的 `SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` **无任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野。六家 LLM 可观测平台无一例外都是"少数物化列 + 一个 KV blob"。没有任何成熟系统允许任意 key 自动获得列/索引待遇;唯一的自动推断派 ES dynamic mapping 也是唯一有公开事故名的(mapping explosion)。 diff --git a/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md b/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md index 4bc0c47..7cdf7c8 100644 --- a/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md +++ b/research-wiki/plans/2026-08-17-issue11-caller-dimensions.md @@ -6,11 +6,11 @@ - **涉及技术**: 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 行的 `tenant_id` 恒为空串,而 OCR 行与 chat 行落在**同一张表**——多租户下游的审计链会缺一块,且同样不可逆。 +设计初稿只覆盖 `chat()` 与 `embed()`(issue 只诉求这两条)。写计划时核实代码发现**第三条链路**: `OcrClient` 同样经 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 完全同构。OCR 行与 chat 行落在**同一张表**,不处理则同表内一部分行有租户归属、一部分永远空白,且同样不可逆。 -处置: 列为 **Task 6**,与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。此任务**超出已批准设计的字面范围**,执行者若被要求严格限定范围,可跳过 Task 6 并在 CHANGELOG 明确声明"OCR 行不带调用方维度"这一已知限制——但**不得静默跳过**。 +**人类已追认纳入正式范围**(2026-08-17),设计文档 §1.2 已同步补正。Task 6 是必做项,**不是可跳过的分支**——与 issue #10 的先例一致(那次 issue 只报告 chat 的 400,OCR 侧被认定为同一缺陷的其余分支而一并修)。 --- @@ -198,7 +198,7 @@ async def _record( --- -## Task 6: OCR 链路透传(设计范围外,见文首说明) +## Task 6: OCR 链路透传 **文件**: `src/polygateway/ocr.py`(修改)、`tests/unit/test_ocr_client.py`(修改) diff --git a/research-wiki/plans/issue11-caller-dimensions.md b/research-wiki/plans/issue11-caller-dimensions.md index 2e0c9a0..240a0b8 100644 --- a/research-wiki/plans/issue11-caller-dimensions.md +++ b/research-wiki/plans/issue11-caller-dimensions.md @@ -7,10 +7,10 @@ date: 2026-08-17 # 调用方自定义维度实现计划(issue #11) -正文: `2026-08-17-issue11-caller-dimensions.md`(249 行,8 个任务)。实现 `design:issue11-caller-dimensions`。 +正文: `2026-08-17-issue11-caller-dimensions.md`(269 行,8 个任务)。实现 `design:issue11-caller-dimensions`。 - **任务顺序**: 端口与两个遥测后端(Task 2)先于三条调用链(Task 4/5/6)落地——后者写入的字段必须已有列可落。Task 1(校验函数 + `ChatRequest` 字段)是全部任务的前置。 -- **计划阶段的新发现**: 设计只覆盖 `chat()`/`embed()`,核实代码发现**第三条遥测链路**——`OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),`_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,与 embedding 同构。OCR 行与 chat 行落**同一张表**,漏掉则多租户审计链缺一块且同样不可逆。列为 Task 6 并标注**超出已批准设计的字面范围**(与 issue #10 先例一致: 那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修);可以不做,但必须在 CHANGELOG 声明"OCR 行不带调用方维度",**不得静默跳过**。 +- **计划阶段的新发现**: 设计只覆盖 `chat()`/`embed()`,核实代码发现**第三条遥测链路**——`OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),`_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,与 embedding 同构。OCR 行与 chat 行落**同一张表**,漏掉则多租户审计链缺一块且同样不可逆。列为 Task 6,**人类已追认纳入正式范围**(设计 §1.2 同步补正)(与 issue #10 先例一致: 那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修),是必做项。 - **把 issue 的核心论点钉成测试**: Task 7 要求手工建 22 列旧表 → 用当前 recorder 打开 → 断言老行 `tenant_id` 读回**空串而非 NULL**。这直接验收 issue「先启用后加列则归属无法还原」的论点,且断言方向选空串是因为 NULL 在 RLS policy 下是对所有人永久不可见的黑洞,而非"未归属"。 - **几处易实现反的地方已写进验收**: `emit_cache_hit` 必须读**本次请求**的维度而非缓存中历史响应的(构造"请求属租户 A、缓存历史属租户 B"的用例断言落 A);`embed()` 多批时**每一批**的行都要带同一份维度(只断言首行会漏掉"只有第一批带维度"的实现);非法输入必须 `ValueError` 且 recorder **零调用**(证明校验早于遥测)。 - **不做的事**: 不把 embedding/OCR 链上已有的四个同类参数收成值对象(任务外重构);不建索引、不启用 RLS(库只交付模板,执行是下游 DBA 职责)。