diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 798da40..a5c7776 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -175,6 +175,16 @@ "id": "design:issue12-telemetry-retention", "label": "issue #12: 遥测表的正文体量、保留期与访问控制", "type": "design" + }, + { + "id": "plan:plan-issue13-schema-mode", + "label": "实现计划: issue13-schema-mode", + "type": "plan" + }, + { + "id": "plan:plan-issue12-telemetry-retention", + "label": "实现计划: issue12-telemetry-retention", + "type": "plan" } ], "links": [ @@ -310,6 +320,20 @@ "relation": "implements", "evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路", "added": "2026-08-17T10:09:08.967997+00:00" + }, + { + "source": "plan:plan-issue13-schema-mode", + "target": "design:issue13-schema-mode", + "relation": "implements", + "evidence": "research-wiki/plans/2026-08-19-issue13-schema-mode.md", + "added": "2026-08-19T13:10:55.616264+00:00" + }, + { + "source": "plan:plan-issue12-telemetry-retention", + "target": "design:issue12-telemetry-retention", + "relation": "implements", + "evidence": "research-wiki/plans/2026-08-19-issue12-telemetry-retention.md", + "added": "2026-08-19T13:10:57.986963+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 4b841fa..0ad29e7 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,6 +1,6 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-19 12:45 UTC +> 自动生成,更新时间:2026-08-19 13:10 UTC ## design (34) - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` @@ -52,7 +52,7 @@ - [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` -## plan (25) +## plan (29) - [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-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -65,6 +65,8 @@ - [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-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions` +- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention` +- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode` - [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` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` @@ -74,6 +76,8 @@ - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` - [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan` +- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention` +- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode` - [实现计划: 治理后端故障归位为 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` - [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions` diff --git a/research-wiki/log.md b/research-wiki/log.md index fec1205..9f1e182 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -109,3 +109,8 @@ - [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode) - [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention) - [2026-08-19 12:45 UTC] 重建索引: 74 篇页面 +- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue13-schema-mode (plan:plan-issue13-schema-mode) +- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue13-schema-mode --implements--> design:issue13-schema-mode +- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue12-telemetry-retention (plan:plan-issue12-telemetry-retention) +- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue12-telemetry-retention --implements--> design:issue12-telemetry-retention +- [2026-08-19 13:10 UTC] 重建索引: 78 篇页面 diff --git a/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md b/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md new file mode 100644 index 0000000..3c0ec07 --- /dev/null +++ b/research-wiki/plans/2026-08-19-issue12-telemetry-retention.md @@ -0,0 +1,167 @@ +# 实现计划: 遥测正文体量、保留期与访问控制(issue #12) + +- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。 +- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。 +- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。 +- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。 +- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**。 +- **前置依赖**: **issue #13 的计划须先合并**。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。 + +--- + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter` 增 `text_cap` 必填 | +| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings` 增 `telemetry_text_cap` | +| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 | +| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) | +| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) | +| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import | +| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 | +| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 | +| `tests/unit/test_config.py` | 修改 | 配置校验 | +| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) | +| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 | +| `README.md`、`CHANGELOG.md`、`.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 | + +**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。 + +--- + +## 关键接口(跨任务消费,此处定稿) + +截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`): + +```python +def _cap_text(text: str, cap: int | None) -> str: + """超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。""" + +def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]: + """对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。 + + 非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。 + """ +``` + +`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值): + +```python +class TelemetryEmitter: + def __init__( + self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None, + text_cap: int | None, + ) -> None: ... +``` + +`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后: + +```python +telemetry_text_cap: int | None +``` + +`tools/telemetry_retention.py` 的 CLI 契约: + +```text +--backend sqlite|postgres 必填 +--path PATH | --dsn DSN 按 backend 二选一,必填 +--older-than-days N 必填,N >= 0 +--apply 缺省不带即 dry-run(只统计不删) +--batch-size N 仅 postgres,缺省 1000 +--vacuum 仅 sqlite,须与 --apply 同时给 +退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION) +``` + +--- + +## Task 1: 正文截断与 emitter 参数 + +- [ ] **文件**: `src/polygateway/middleware/telemetry.py`、`src/polygateway/client.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`;`tests/unit/test_telemetry.py`、`tests/unit/test_cache.py`。 +- **行为**: + - 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text`、`thinking` 调用 `_cap_text`。 + - `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149`、`embedding.py:131`、`ocr.py:130`)同步传参;测试内十余处构造点一并补齐。 + - **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。 + - **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。 + - `embedding.py:73` 与 `ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。 +- **验收**: + - `cap=None` → 落库正文与今天逐字节相同。 + - `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。 + - 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。 + - 非字符串 content(如 `123`、`None`、嵌套 dict)不抛异常。 + - `response`/`thinking` 同样受 cap。 + - OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。 +- **测试**: + - 上述六条各一例(`tests/unit/test_telemetry.py`)。 + - **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。 + - **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。 + - 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。 +- **提交**: `feat: cap telemetry bodies at a configurable length` + +## Task 2: 配置与装配 + +- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。 +- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings` 增 `telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0` 报 `ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。 +- **验收**: 未设 → `None`;`"0"` 与 `"-1"` 报 `ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。 +- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。 +- **提交**: `feat: wire the telemetry text cap through settings` + +## Task 3: 保留期脚本 + +- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`。 +- **行为**: 按上文 CLI 契约实现。 + - **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。 + - SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。 + - PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。 + - 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。 + - 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。 +- **验收**: 见测试。 +- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite): + - dry-run 后行数不变,stdout 含将删行数与时间范围。 + - `--apply` 后仅超期行被删,未超期行完好。 + - `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。 + - 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。 + - `--vacuum` 不带 `--apply` 时退出码 1。 + - 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py -v` → PASS。 +- **提交**: `feat: add a retention script downstreams can schedule` + +## Task 4: 生产部署模板与其机械化验收 + +- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`。 +- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含: + - **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。 + - **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。 + - **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)`、`pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。 + - **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。 + - **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。 + - 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。 +- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。 +- **测试**(集成,真实 PG,沿用 `least_privilege_dsn` 同款临时 schema + 临时角色隔离,teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言: + - `app` 角色能 INSERT、**不能** DELETE(报权限错)。 + - `report` 角色能读、不能写。 + - 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。 + - 分区表上写入成功且落进当月分区。 + - 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。 +- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。 +- **提交**: `docs: ship a production deployment template with its own test` + +## Task 5: CHANGELOG 与 wiki + +- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`。 +- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。 +- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。 +- **测试**: 无自动化测试。 +- **验证**: `conda run -n PolyGateway make ci` → 全绿。 +- **提交**: `docs: record the retention boundary and its knobs` + +--- + +## 完成判据 + +1. 五个任务的提交点全部落地,`make ci` 全绿。 +2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。 +3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。 +4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。 diff --git a/research-wiki/plans/2026-08-19-issue13-schema-mode.md b/research-wiki/plans/2026-08-19-issue13-schema-mode.md new file mode 100644 index 0000000..470e878 --- /dev/null +++ b/research-wiki/plans/2026-08-19-issue13-schema-mode.md @@ -0,0 +1,172 @@ +# 实现计划: 遥测 schema 档位与裁剪写入(issue #13) + +- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。 +- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。 +- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。 +- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。 +- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。 + +--- + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` | +| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 | +| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 | +| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings` 增 `telemetry_auto_migrate` | +| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` | +| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` | +| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 | +| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 | +| `tests/unit/test_package.py` | 修改 | 公共导出面 | +| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 | +| `.env.example`、`README.md`、`CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 | + +**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。 + +--- + +## 关键接口(跨任务消费,此处定稿) + +`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用): + +```python +COLUMNS: tuple[str, ...] # 24 列,顺序即物理列序(call_id 起、meta 止) +SQLITE_DDL: str # CREATE TABLE IF NOT EXISTS(全量列) +PG_DDL: str +SQLITE_BACKFILL: tuple[tuple[str, str], ...] # (列名, "TEXT NOT NULL DEFAULT ''") +PG_BACKFILL: tuple[tuple[str, str], ...] # (列名, 完整 ALTER 语句) +``` + +两个语句构造函数: + +```python +def insert_sql(backend: str, columns: Sequence[str]) -> str: + """按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。 + + 子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量, + 不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`。 + """ + +def telemetry_schema_sql(backend: str) -> str: + """返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。""" +``` + +recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值): + +```python +class SQLiteRecorder: + def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ... + +class PostgresRecorder: + def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ... +``` + +`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后: + +```python +telemetry_auto_migrate: bool +``` + +--- + +## Task 1: 建 `telemetry/schema.py` 单一事实源 + +- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。 +- **行为**: 把 `sqlite.py` 的 `_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS` 与 `postgres.py` 的 `_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()` 与 `telemetry_schema_sql()`。 +- **验收**: + - 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。 + - `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。 + - `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`。 + - `telemetry_schema_sql` 输出包含全部 24 个列名,且列名出现顺序与 `COLUMNS` 一致;未知 backend 抛 `ValueError`。 +- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`conda run -n PolyGateway make lint` → 通过(import-linter 契约不得报新违规: schema.py 只依赖标准库)。 +- **提交**: `refactor: make the telemetry schema a single source of truth` + +## Task 2: PG 写入去掉冲突目标 + +- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`。 +- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。 +- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12 按 `created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。 +- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。 +- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。 +- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。 +- **提交**: `fix: drop the conflict target so partitioned tables can accept writes` + +## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入 + +- [ ] **文件**: `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;`tests/unit/test_telemetry.py`。 +- **行为**: + - 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`。 + - 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns` 与 `self._insert = insert_sql(backend, effective)`;`record_llm_call` 按 `self._columns` 取值。 + - `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。 + - `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。 + - 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。 + - `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。 + - PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。 + - 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。 +- **验收**: 见测试。 +- **测试**(单元,真实临时 SQLite 文件,`tmp_path`): + - manual + 手工建的 22 列旧表 → 写入成功且能读回、`PRAGMA table_info` 列数**保持 22**(证明未 ALTER)、`caplog` 中恰有一条 warning 且同时含 `tenant_id`、`meta` 与 `ALTER TABLE`。 + - auto + 同款 22 列旧表 → 列数变 24(现状回归)。 + - manual + 全新库 → 建表且 24 列齐全(建表未被停掉)。 + - 缺 `call_id` 的畸形表 → warning 升级措辞,不抛异常。 + - 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。 +- **提交**: `feat: gate the automatic ALTER behind an explicit mode` + +## Task 4: 配置派生与装配 + +- [ ] **文件**: `src/polygateway/config.py`、`src/polygateway/client.py`、`.env.example`;`tests/unit/test_config.py`。 +- **行为**: + - `config.py` 增 `_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。 + - `GatewaySettings` 增 `telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`。 + - `client.py` 的 `_build_telemetry` 把它透传给两个 recorder。 + - `.env.example` 在 `PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。 +- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。 +- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。 +- **提交**: `feat: derive the schema mode from the telemetry backend` + +## Task 5: 公共导出 + +- [ ] **文件**: `src/polygateway/__init__.py`、`tests/unit/test_package.py`。 +- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。 +- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。 +- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。 +- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。 +- **提交**: `feat: expose the telemetry schema SQL to downstreams` + +## Task 6: 真实 Postgres 集成验收 + +- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`。 +- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。 +- **验收**: + - manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。 + - `least_privilege_dsn`(只授 `SELECT, INSERT`,不授 schema CREATE)+ manual → 不再出现 ALTER 失败的 warning,写入照常。 +- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`。 +- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。 +- **提交**: `test: prove manual mode leaves a stale table untouched` + +## Task 7: 文档与承诺 + +- [ ] **文件**: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。 +- **行为**: + - README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。 + - CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。 + - ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。 +- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。 +- **测试**: 无自动化测试;人工核对 README 片段在真实 PG 上可执行(Task 6 的环境里跑一遍)。 +- **验证**: `conda run -n PolyGateway make ci` → 全绿。 +- **提交**: `docs: document the schema mode and the expand-contract promise` + +--- + +## 完成判据 + +1. 七个任务的提交点全部落地,`make ci` 全绿。 +2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。 +3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。 +4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。 diff --git a/research-wiki/plans/plan-issue12-telemetry-retention.md b/research-wiki/plans/plan-issue12-telemetry-retention.md new file mode 100644 index 0000000..37345ab --- /dev/null +++ b/research-wiki/plans/plan-issue12-telemetry-retention.md @@ -0,0 +1,17 @@ +--- +type: plan +node_id: plan:plan-issue12-telemetry-retention +title: "实现计划: issue12-telemetry-retention" +date: 2026-08-19 +--- + +# 实现计划: issue12-telemetry-retention + + +正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。 + +五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。 + +**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13 的 `telemetry_schema_sql()` 与无冲突目标写入)。 + +写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。 diff --git a/research-wiki/plans/plan-issue13-schema-mode.md b/research-wiki/plans/plan-issue13-schema-mode.md new file mode 100644 index 0000000..ed6c0f0 --- /dev/null +++ b/research-wiki/plans/plan-issue13-schema-mode.md @@ -0,0 +1,15 @@ +--- +type: plan +node_id: plan:plan-issue13-schema-mode +title: "实现计划: issue13-schema-mode" +date: 2026-08-19 +--- + +# 实现计划: issue13-schema-mode + + +正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。 + +七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。 + +`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。