Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dce063ebcd | |||
| 6850ed0d54 | |||
| a64c680f64 | |||
| cb1e08f1f3 | |||
| bb621cad4d | |||
| d80feafb93 | |||
| 6691e52195 | |||
| c4dd403e0f | |||
| 42b7f0defc | |||
| 80bb8aba71 | |||
| 61cace71c4 | |||
| 0357d5afb3 | |||
| 3db7d9979c | |||
| c13c4fd28f | |||
| 398b16e136 | |||
| b31df842a8 | |||
| 944b3f3798 | |||
| 1f08194b96 | |||
| fb67b68440 | |||
| 4117eae501 | |||
| 25a90ebd05 | |||
| 1201a04449 | |||
| abcdf6930e | |||
| 31942f55f1 | |||
| 779a9b29a3 | |||
| 48ba36dd94 | |||
| 39a319e34e | |||
| 16743ca11d | |||
| e5ad67408a | |||
| ab0e924849 | |||
| 607523ef5c | |||
| 95104af2a5 | |||
| 5d2ad40336 | |||
| 22609c90d6 | |||
| a0cf8546dc | |||
| ce10881eb0 | |||
| bd82f16921 | |||
| 2c9c5a3aa7 | |||
| 9185eab8c9 |
@@ -41,10 +41,35 @@ for CODE_DIR in "${CODE_DIRS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 复杂度只对"本次改动触及的函数"硬拦: 全量拦截会让存量债务挡住一切提交
|
||||||
|
# (含纯文档提交),久之只剩绕过一条路;存量 ≥C 函数降为告警,随触及随还。
|
||||||
|
# 用 HEAD 对比工作区+暂存区: 钩子在 `git add && git commit` 同一命令里先于 add 运行
|
||||||
if command -v radon &> /dev/null; then
|
if command -v radon &> /dev/null; then
|
||||||
RADON_OUTPUT=$(radon cc "$CODE_DIR" -n C -s 2>&1 || true)
|
mapfile -t CHANGED_PY < <(git diff HEAD --name-only --diff-filter=AM -- "$CODE_DIR" | grep '\.py$' || true)
|
||||||
if echo "$RADON_OUTPUT" | grep -qE '^\s+[FMC]\s'; then
|
if [[ ${#CHANGED_PY[@]} -gt 0 ]]; then
|
||||||
ERRORS+="[radon] 存在圈复杂度 ≥ C 的函数:\n$RADON_OUTPUT\n"
|
TOUCHED=$(git diff HEAD -U0 -- "${CHANGED_PY[@]}" | python3 -c '
|
||||||
|
import json, re, subprocess, sys
|
||||||
|
hunks, path = {}, None
|
||||||
|
for line in sys.stdin:
|
||||||
|
if line.startswith("+++ "):
|
||||||
|
path = line[6:].strip() if line.startswith("+++ b/") else None
|
||||||
|
m = re.match(r"@@ -\S+ \+(\d+)(?:,(\d+))? @@", line)
|
||||||
|
if m and path:
|
||||||
|
start, count = int(m.group(1)), int(m.group(2) or 1)
|
||||||
|
hunks.setdefault(path, []).append((start, start + max(count, 1) - 1))
|
||||||
|
data = json.loads(subprocess.run(["radon", "cc", "-j", "-n", "C", *hunks], capture_output=True, text=True).stdout or "{}")
|
||||||
|
for path, blocks in data.items():
|
||||||
|
for b in blocks if isinstance(blocks, list) else []:
|
||||||
|
if any(s <= b["endline"] and e >= b["lineno"] for s, e in hunks.get(path, [])):
|
||||||
|
print("%s:%s %s - %s (%s)" % (path, b["lineno"], b["name"], b["rank"], b["complexity"]))
|
||||||
|
' 2>&1 || true)
|
||||||
|
if [[ -n "$TOUCHED" ]]; then
|
||||||
|
ERRORS+="[radon] 本次改动触及的函数圈复杂度 ≥ C:\n$TOUCHED\n"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
LEGACY_COUNT=$(radon cc "$CODE_DIR" -n C 2>/dev/null | grep -cE '^\s+[FMC]\s' || true)
|
||||||
|
if [[ "$LEGACY_COUNT" -gt 0 ]]; then
|
||||||
|
WARNINGS+="[radon] $CODE_DIR/ 存量 ${LEGACY_COUNT} 个函数圈复杂度 ≥ C(未被本次改动触及,不阻塞)。\n"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -31,7 +31,7 @@ LLM__QWEN__1__TIMEOUT_S=120
|
|||||||
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
|
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
|
||||||
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
|
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
|
||||||
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
|
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
|
||||||
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
# LLM__QWEN__1__MISSING_DONE=retry # SSE 真截断(无 [DONE] 且无 finish+其后实测 usage): retry(默认) | salvage;1.3.10 起 finish+实测 usage+干净关闭的无 DONE 流恒视为完整
|
||||||
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
||||||
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
||||||
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
|
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
|
||||||
@@ -193,6 +193,15 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
|||||||
# OCR__MONKEY__2__TIMEOUT_S=300
|
# OCR__MONKEY__2__TIMEOUT_S=300
|
||||||
# OCR__MONKEY__2__TRUST_ENV=false
|
# OCR__MONKEY__2__TRUST_ENV=false
|
||||||
# OCR__RETRY__MAX_ATTEMPTS=3 # per-scope 韧性键与 LLM scope 同一套
|
# OCR__RETRY__MAX_ATTEMPTS=3 # per-scope 韧性键与 LLM scope 同一套
|
||||||
|
# 整份文档解析(parse_document,1.3.11):建议用**独立 scope** 指向同一服务,自带超时/重试/期限:
|
||||||
|
# OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866
|
||||||
|
# OCR_DOC__MONKEY__1__API_KEY=none
|
||||||
|
# OCR_DOC__MONKEY__1__MODEL=monkey-ocr
|
||||||
|
# OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 4s × 余量(实测 1.5–4 s/页,共用 GPU 争用再乘 3–5 倍);不是总耗时上界
|
||||||
|
# OCR_DOC__MONKEY__1__MAX_CONCURRENCY=2 # 服务为 sync-only 后端时取 1(每实例 /parse 全局串行)
|
||||||
|
# OCR_DOC__MONKEY__1__TRUST_ENV=false
|
||||||
|
# OCR_DOC__RETRY__MAX_ATTEMPTS=2 # 超时/GET 失败的重试会重新 POST 整份文档重算,压低
|
||||||
|
# OCR_DOC__CALL_DEADLINE_S=900 # 单份硬上限;须 ≤ PGW_LEASE_TTL_S(缺省 1500)
|
||||||
|
|
||||||
# ══ SOAK_OCR scope(P7 OCR 压测;tools/soak/run_soak.py --scenario P7 --scope SOAK_OCR)══
|
# ══ SOAK_OCR scope(P7 OCR 压测;tools/soak/run_soak.py --scenario P7 --scope SOAK_OCR)══
|
||||||
# 双真实实例 + 黑洞(连接超时)+ 坏端口(连接拒绝)故障池;
|
# 双真实实例 + 黑洞(连接超时)+ 坏端口(连接拒绝)故障池;
|
||||||
@@ -200,3 +209,7 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
|||||||
# SOAK_OCR__GLOBAL__MAX_CONCURRENCY=16
|
# SOAK_OCR__GLOBAL__MAX_CONCURRENCY=16
|
||||||
# SOAK_OCR__GLOBAL__RPM=300
|
# SOAK_OCR__GLOBAL__RPM=300
|
||||||
# SOAK_OCR_FAULT_SOURCES=monkey_3,monkey_4
|
# SOAK_OCR_FAULT_SOURCES=monkey_3,monkey_4
|
||||||
|
|
||||||
|
# OpenAI 保真模式是 per-call response_mode="openai",无新增环境键。
|
||||||
|
# raw-only reasoning_effort 放调用 overlay;对应源不得设置 ENABLE_THINKING 或
|
||||||
|
# REASONING_EFFORT,请求 reasoning_effort=None 只继承源配置,不撤销源级意图。
|
||||||
|
|||||||
@@ -1,5 +1,85 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.3.11(2026-10-22)
|
||||||
|
|
||||||
|
#34: OCR 新增整份文档解析 `OcrClient.parse_document`——CHSAnalyzer3 要把几百篇医学论文 PDF 整份转成带页码的正文与表格,而库只会返回版面框,且 PDF 被写死的 `image.jpg` 标签当成图片上传。两个症状同根: 库把 OCR 输入建模成「一张图 + 写死的标签」、输出建模成「只有框」,没有「文档」这个概念。设计 `research-wiki/designs/2026-10-21-issue34-ocr-document-design.md`。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **`OcrClient.parse_document(document, *, session_id, parent_call_id, tenant_id, meta, call_deadline_s) -> OcrDocumentResult`**: PDF 或图片整份交给 MonkeyOCR,返回逐块 `OcrDocumentElement`(继承 `OcrLayoutElement`,仅多一个 `text`:`text`/`title` 纯文本、`interline_equation` 裸 LaTeX、`table` HTML、`image` 空串)、`page_sizes` 与整篇 `markdown`。治理链路(限流/熔断/重试/换源/遥测/调用方维度/期限)复用 `parse_layout` 的同一循环。
|
||||||
|
- 公共类型 `OcrDocumentElement` / `OcrDocumentResult`(包根导出);端口 `OcrDocumentPort`;transport 协议 `OcrDocumentTransport`(**独立于 `OcrTransport`**,不动旧 `runtime_checkable` Protocol 的成员,既有实现与测试替身不受影响)。
|
||||||
|
- `CallOperation` / 遥测 `operation` 列新增取值 `parse_document`(无 DDL、无新列);占位 `<ocr:document image_bytes=N>`,response 摘要 `<document elements=N pages=M md_chars=K>` **永不写入正文**。
|
||||||
|
- 内容只取自 `_middle.json` 与配对的 `.md`,**有意不读 `_content_list.json`**(它是展示格式,星号、反引号、波浪线、美元符会被转义,实测 134 个文字块中 23 个含 `\$`)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **`recognize_text(PDF)` 的静默错误结果**: 此前一切上传都标 `image.jpg`,而 MonkeyOCR 按扩展名分派。PDF 走图片分支时 `/ocr/text` 返回 `success:true` 与幻觉文本(实测 `"The image contains a single character: 1"`),下游完全无感。上传标签现在按**内容**判定(图片魔数优先,再看前 1KB 内的 `%PDF-`),三个方法共用一处;非 PDF 一律沿用 `image.jpg`(字节级不变)。
|
||||||
|
|
||||||
|
### 行为变更
|
||||||
|
|
||||||
|
- **`parse_layout(PDF)` 的上传分支改变**(图片数据集 → PDF 数据集)。差分实测: 文本型与无文字层的位图型 2 页 PDF,`_middle.json` / `_content_list.json` / `.md` **逐字相同**;**旋转页、带批注页未测,不构成保证**。该路径无已知消费者(医院线只传图片)。
|
||||||
|
- **`parse_document` 对坏输入的处置**(仅新路径;共享分类不变): 无法识别的字节本地拒绝、不发请求(`RequestRejectedError`,不带 `status_code`);服务把一切失败包成 `500 "Parsing failed: …"`,其中已知的确定性坏输入(`Failed to open stream` / `Cannot open empty stream` / `cannot save with zero pages` / `is no PDF`)归 `RequestRejectedError`(`status_code=500`)——**不重试、熔断记成功不计窗口**。其余 500 仍是瞬时错误。标记集合可经 `MonkeyOcrTransport(deterministic_failure_markers=...)` 覆盖。
|
||||||
|
- 结果包解析的外部数据异常边界统一落 `ResultInvalidError`(含加密成员、未知压缩方法、巨大整数、深嵌套 JSON),不漏出原生异常——漏出会让客户端既不写终态遥测行、又不归还半开探针。
|
||||||
|
- 文档路径流式下载并限制整包 256 MiB;`_middle.json` ≤ 64 MiB、`.md` ≤ 16 MiB;解析在线程里执行,取消与 `call_deadline_s` **基本不被占住**(`json.loads` 持有 GIL:实测 54 MiB 的 middle 解析约 1 秒、事件循环最大停顿约 0.3 秒;取消后线程自然收尾、占用默认 executor,正常大小是毫秒级)。`parse_layout` 的口径(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样**。
|
||||||
|
|
||||||
|
- **共用的第一段协议新增 `download_url` 校验**: 服务响应里的畸形 `download_url`(控制字符、坏方括号、越界端口)此前会让 `httpx.InvalidURL` 等原生异常漏出 `OcrClient`(不写终态遥测行、半开探针不归还),现归瞬时错误、不发第二段请求。`parse_layout` 共用这一处,属顺带修复;**不限制主机**(基线接受绝对地址)。
|
||||||
|
- **嗅探副作用**: 前 1KB 内含 `%PDF-` 字样的非 PDF 文本在 `recognize_text` / `parse_layout` 上的上传标签由 `image.jpg` 变为 `doc.pdf`;BMP 魔数现要求 DIB 头大小字段合法(不再单凭 `BM` 两字节)。
|
||||||
|
- 文档路径读取错误响应体只读前 64 KiB(摘要只需要几 KiB),不再把巨大的错误体整个读进内存。
|
||||||
|
|
||||||
|
### 已知限制与运维须知(详见 README「1.3.11 OCR 整份文档解析」)
|
||||||
|
|
||||||
|
- **服务端丢弃脚注、页眉页脚、页码**,表格脚注里的显著性/缩写说明不会出现在结果里。
|
||||||
|
- **`timeout_s` 不是总耗时上界**(逐次网络操作的超时);长文档的硬上限靠 `call_deadline_s`,并应 `≤ lease_ttl_s`。库内无基于延迟的熔断,长文档不会被判慢。
|
||||||
|
- 若 MonkeyOCR 部署为 sync-only 后端(出厂默认 `lmdeploy`),`/parse` 每实例全局串行,长文档会队头阻塞同实例上医院线的请求;请向运维确认 backend。
|
||||||
|
- 多行/多 span 文本块、`inline_equation` span、嵌套 caption/footnote 子块三条路径无真实样本,仅由构造 fixture 测试。
|
||||||
|
- 后续: #35(共享分类对确定性坏输入 500 仍归瞬时)、#36(`recognize_text` 不校验服务响应的 `success`)、#37(`parse_layout` 结果包解析仍漏出原生异常、`download_url` 不限制主机)。
|
||||||
|
|
||||||
|
## 1.3.10(2026-10-15)
|
||||||
|
|
||||||
|
#30/#31 同根修复: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游的正常结局被当瞬时错误重试到耗尽(magpie/glm 不发 `[DONE]` 时单次调用挂 1216s;只有思考的 length 截断每次写满 max_tokens 后重抽)。本版改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存读写共用一份判定。设计 `research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **缺 `[DONE]` 但完整的流**(#31,**两种模式**): finish_reason 已到、其同帧或之后收到实测 usage、连接干净关闭 → 完整交回,用量记 measured,`stream_end="eof_after_finish"`。文本模式默认 `MISSING_DONE=retry` 原本同样会重试到耗尽;该键现在只管真截断(无 finish 或 finish 后无实测 usage)。
|
||||||
|
- **只有思考的 length 截断**(#30): 保真模式原样交回(content 空、思考完整、finish_reason=length),不再重抽。
|
||||||
|
- **`null` 与未知字段**(#30): 任意层 `null` 视为缺省(`refusal: null`、`tool_calls: null` 不再判失败,**非流式同样修复**);保真模式未知字段按 OpenAI 流式惯例无损合并(字符串拼接、对象递归、带 `index` 数组按 index 合并、`index/type/id/format` 同值幂等),Gemini `extra_content` 一类工具层扩展可原样回传。
|
||||||
|
- **`reasoning` 别名**: vLLM/OpenRouter 的 `reasoning` 字段计入活性(此前只有思考的流会被 TTFT 看门狗误杀),文本模式无非空 `reasoning_content` 时投影为 `thinking`。
|
||||||
|
- **截断结果不再写缓存**: 文本 salvage 结果此前会被缓存并在 TTL 内反复回放。
|
||||||
|
|
||||||
|
### 行为变更
|
||||||
|
|
||||||
|
- **文本模式 length/过滤且无正文 → `ResultInvalidError`**(原 `TransientError` 重试到耗尽): 不重试、不换源、熔断记成功不计窗口;**不经 `AllSourcesExhausted` 包装**,直接到调用方。原因码见异常消息(`length_without_content` / `filtered_without_content`)。文本模式"只有思考 + stop"维持瞬时(证据收集中)。
|
||||||
|
- **finish_reason 为 `network_error`/`error`**(GLM 等上游自报失败)即使有正文也判瞬时。
|
||||||
|
- **保真模式**: 只有思考/拒答/空白 + 明确 finish(非上游错误)→ 原样交回;length 截断的半截工具(缺 id/name)→ `ResultInvalidError` `tool_truncated`;无参函数全程只发 `arguments: null`(其余字段齐全)→ `ResultInvalidError` `tool_arguments_null`(确定性方言,交付值不改写)。
|
||||||
|
- 文本模式 `choice.index: null` 视为 0(此前整帧被丢弃);`tool_calls` 中全为 `null` 的元素按 `tool_calls: null` 处理,不产出会被上游拒收的空数组。
|
||||||
|
- **缓存**: 只有自然结束(stop/tool_calls/无 finish 的旧口径)且有正文的结果写入;length/过滤/未登记 finish 值、salvaged、空正文均交付但不缓存,读到此类条目按 miss。未登记 finish 值按源告警一次。
|
||||||
|
- **活性口径增补**: 未知字段的非空字符串片段(如 `reasoning`)算进展,`index/type/id/format` 等标签键不算;`stall_ms` 计量随之覆盖。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- `LLMResponse` / `TransportResult` 末尾追加 `finish_reason: str | None`、`stream_end: StreamEnd | None`(`done` / `eof_after_finish` / `salvaged`;非流式、缓存命中为 None);包根导出 `StreamEnd`。
|
||||||
|
- 遥测 `llm_calls` 末尾追加可空列 `stream_end`(INSERT 字段 38 → 39,物理列 39 → 40;旧行 NULL 不回填;auto 档幂等补列,manual 档裁剪写入并点名缺列,补列 SQL 由 `telemetry_schema_sql()` 自取)。
|
||||||
|
|
||||||
|
### 破坏性(请先读)
|
||||||
|
|
||||||
|
- **`TelemetryRecorder.record_llm_call` 新增必填 keyword `stream_end`**: 自建 recorder 未跟进会在装配期被形状闸拒绝(`**fields` 形态不受影响)。
|
||||||
|
|
||||||
|
### 提醒
|
||||||
|
|
||||||
|
- 保真模式交付 `finish_reason=length` 的工具调用时 arguments 可能是半截 JSON: **执行工具前先查 `finish_reason`**。
|
||||||
|
- 未知字段合并冲突时会**删除该字段**并按(源, 路径)告警一次: 结果可能缺字段,以告警为准。典型形态: 带 `index` 的数组(如 `reasoning_details`)后续帧元素缺 `index` 即判形态改变,**整个字段被删**。
|
||||||
|
- 诚实边界: 中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分;防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
|
||||||
|
- 旧版本写入的 salvage/length 文本缓存无法辨认: 受影响集合须在升级后首次读写前更换 namespace/salt(同 1.3.9 提醒)。
|
||||||
|
|
||||||
|
## 1.3.9(2026-10-01)
|
||||||
|
|
||||||
|
- 新增 `chat(response_mode="openai")`,返回 `LLMResponse.completion`:assistant 消息、工具调用、finish_reason、上游 response_id、usage 快照;支持流式聚合及非流式。
|
||||||
|
- 修复 SSE 顶层 error 后接 DONE 被当成功、同帧 content/reasoning_content 丢失;工具实际进展纳入看门狗、首 token 与中段对冲。
|
||||||
|
- 保真仅支持单 choice,拒绝 structured 组合;工具参数逐字拼接,不执行或修复工具;缺 DONE 不允许 salvage。
|
||||||
|
- 保真缓存独立身份并完整回放;缺协议、畸形协议及无正文无工具的坏条目均按 miss 回源。**旧文本缓存可能含曾被吞错的半截结果,受影响集合须在升级首次读写前更换 namespace/salt(含 per-call 覆盖)**,库不会自动辨认旧污染。
|
||||||
|
- 新模式不绕过 managed/raw 冲突校验;raw-only 仍须源级/请求级均无受管意图。`Transport.complete` 新增必填 `response_mode`,自定义实现需适配。
|
||||||
|
- 分发采用 Apache-2.0;README 补充常驻事件循环线程桥接,不新增同步 API。
|
||||||
|
|
||||||
## 1.3.8(2026-09-14)
|
## 1.3.8(2026-09-14)
|
||||||
|
|
||||||
流式中段沉默的计量与应对(issue #27 / #28):`generation_ms` 口径重定义为**净生成时间**(扣除所有超阈值中段沉默)并配 `stall_ms`/`stall_count` 归因字段与遥测两列;另加一条**默认关闭**的中段沉默对冲(主路首 token 之后相邻增量 chunk 间隔超阈值时,向异源并发再发一次)。两键均不设时行为逐字等于 1.3.7(默认关闭回归门: 既有 unit/contracts 全套件语义断言一行未改)。
|
流式中段沉默的计量与应对(issue #27 / #28):`generation_ms` 口径重定义为**净生成时间**(扣除所有超阈值中段沉默)并配 `stall_ms`/`stall_count` 归因字段与遥测两列;另加一条**默认关闭**的中段沉默对冲(主路首 token 之后相邻增量 chunk 间隔超阈值时,向异源并发再发一次)。两键均不设时行为逐字等于 1.3.7(默认关闭回归门: 既有 unit/contracts 全套件语义断言一行未改)。
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
> [!URGENT]
|
> [!URGENT]
|
||||||
> **实验室内部通用基础库(生产级、非 MVP、零业务假设)**
|
> **实验室内部通用基础库(生产级、非 MVP、零业务假设)**
|
||||||
|
>
|
||||||
> 1. 本项目是被多个科研/生产项目依赖的**库**,不是应用:稳定性、并发性、防御性、可观测与测试不可为"简单"让步(YAGNI 仍适用,但不削减健壮性)。库的 bug 会同时击穿所有下游项目。
|
> 1. 本项目是被多个科研/生产项目依赖的**库**,不是应用:稳定性、并发性、防御性、可观测与测试不可为"简单"让步(YAGNI 仍适用,但不削减健壮性)。库的 bug 会同时击穿所有下游项目。
|
||||||
> 2. 你的所有思考过程和回复必须使用 **简体中文**。
|
> 2. 你的所有思考过程和回复必须使用 **简体中文**。
|
||||||
|
|
||||||
## 1. 项目元数据
|
## 1. 项目元数据
|
||||||
|
|
||||||
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
|
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
|
||||||
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
|
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
|
||||||
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
|
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
|
||||||
@@ -35,11 +37,13 @@ make ci # 只读验证(check + test)
|
|||||||
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
|
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
|
||||||
|
|
||||||
### Phase 1: 规划与设计
|
### Phase 1: 规划与设计
|
||||||
|
|
||||||
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
|
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
|
||||||
2. 功能产生运行时数据时**必须**调用 `structured-logging`。
|
2. 功能产生运行时数据时**必须**调用 `structured-logging`。
|
||||||
3. **里程碑级/跨多文件**功能编码前**必须**调用 `writing-plans`;小改动自判。审核门控: design 走 Claude 自审 → Codex 审 → **人类审**;plan 走 Claude 自审 → Codex 审 → 直接执行。
|
3. **里程碑级/跨多文件**功能编码前**必须**调用 `writing-plans`;小改动自判。审核门控: design 走 Claude 自审 → Codex 审 → **人类审**;plan 走 Claude 自审 → Codex 审 → 直接执行。
|
||||||
|
|
||||||
### Phase 2: 执行与验证
|
### Phase 2: 执行与验证
|
||||||
|
|
||||||
1. **测试结果门**: 合并前每个行为变更必须有"先失败后通过"的测试证据(`test-driven-development`);bug 修复必带回归测试;不规定中间怎么走。
|
1. **测试结果门**: 合并前每个行为变更必须有"先失败后通过"的测试证据(`test-driven-development`);bug 修复必带回归测试;不规定中间怎么走。
|
||||||
2. **独立验证**: 里程碑级/跨多文件/合并前**必须**派全新上下文的 verifier subagent(`verification-before-completion`);任何规模的完成声明都必须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。
|
2. **独立验证**: 里程碑级/跨多文件/合并前**必须**派全新上下文的 verifier subagent(`verification-before-completion`);任何规模的完成声明都必须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。
|
||||||
3. **反 gold-plating**: 不做任务外的重构、抽象与"顺手清理"。
|
3. **反 gold-plating**: 不做任务外的重构、抽象与"顺手清理"。
|
||||||
@@ -47,6 +51,7 @@ make ci # 只读验证(check + test)
|
|||||||
## 4. 核心规则
|
## 4. 核心规则
|
||||||
|
|
||||||
### 4.1 核心原则(按优先级)
|
### 4.1 核心原则(按优先级)
|
||||||
|
|
||||||
- **P1 YAGNI**: 不写当前用不到的代码;但并发控制、防御校验、可观测埋点、错误隔离与测试是"当前需要",不在削减之列。
|
- **P1 YAGNI**: 不写当前用不到的代码;但并发控制、防御校验、可观测埋点、错误隔离与测试是"当前需要",不在削减之列。
|
||||||
- **P2 高可读性**: 领域术语命名;注释解释"为什么"。
|
- **P2 高可读性**: 领域术语命名;注释解释"为什么"。
|
||||||
- **P3 单一职责**: 一句话说不清职责(需要"和")= 拆分。
|
- **P3 单一职责**: 一句话说不清职责(需要"和")= 拆分。
|
||||||
@@ -58,7 +63,7 @@ make ci # 只读验证(check + test)
|
|||||||
### 4.2 库铁律(本项目特有,违反即 bug)
|
### 4.2 库铁律(本项目特有,违反即 bug)
|
||||||
|
|
||||||
| 铁律 | 内容 |
|
| 铁律 | 内容 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 零业务假设 | 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol |
|
| 零业务假设 | 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol |
|
||||||
| 纯 asyncio 中立 | 无全局状态、无框架假设、无模块级单例;同一 `GatewayClient` 在 arq worker 与裸脚本中行为一致 |
|
| 纯 asyncio 中立 | 无全局状态、无框架假设、无模块级单例;同一 `GatewayClient` 在 arq worker 与裸脚本中行为一致 |
|
||||||
| 取消可穿透 | `asyncio.CancelledError` 永不捕获吞没;重试循环、限流等待、流式读取全部可被取消;in-flight 资源在 finally 释放 |
|
| 取消可穿透 | `asyncio.CancelledError` 永不捕获吞没;重试循环、限流等待、流式读取全部可被取消;in-flight 资源在 finally 释放 |
|
||||||
@@ -69,6 +74,7 @@ make ci # 只读验证(check + test)
|
|||||||
| 无缓存毒化 | 缓存 key 必含 model + messages 摘要 + 命名空间/租户 + salt;多模态 content 先摘要再 hash |
|
| 无缓存毒化 | 缓存 key 必含 model + messages 摘要 + 命名空间/租户 + salt;多模态 content 先摘要再 hash |
|
||||||
|
|
||||||
### 4.3 代码开发规范
|
### 4.3 代码开发规范
|
||||||
|
|
||||||
- 模块/类/方法必须有**中文 Docstring**;复杂逻辑用 `# Phase N` 注释组织。
|
- 模块/类/方法必须有**中文 Docstring**;复杂逻辑用 `# Phase N` 注释组织。
|
||||||
- 校验分层: 外部输入校验用显式异常(Python `-O` 移除 assert,禁止 assert 承担生产校验);assert 仅用于内部不变量。
|
- 校验分层: 外部输入校验用显式异常(Python `-O` 移除 assert,禁止 assert 承担生产校验);assert 仅用于内部不变量。
|
||||||
- 类名 `PascalCase`,私有前缀 `_`;导入顺序标准库→第三方→项目内。
|
- 类名 `PascalCase`,私有前缀 `_`;导入顺序标准库→第三方→项目内。
|
||||||
@@ -76,6 +82,7 @@ make ci # 只读验证(check + test)
|
|||||||
- 不考虑向后兼容,直接修改原文件。**例外**: `LLMResponse` 等已被三项目消费的公共类型,字段只增不删不改名(迁移兼容约束,见 ARCHITECTURE.md §5.1)。
|
- 不考虑向后兼容,直接修改原文件。**例外**: `LLMResponse` 等已被三项目消费的公共类型,字段只增不删不改名(迁移兼容约束,见 ARCHITECTURE.md §5.1)。
|
||||||
|
|
||||||
### 4.4 Git 工作流
|
### 4.4 Git 工作流
|
||||||
|
|
||||||
- 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。
|
- 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。
|
||||||
|
|
||||||
### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步)
|
### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步)
|
||||||
@@ -87,7 +94,7 @@ make ci # 只读验证(check + test)
|
|||||||
按顺序执行,**构建之前**必须先改完所有文档:
|
按顺序执行,**构建之前**必须先改完所有文档:
|
||||||
|
|
||||||
| # | 动作 | 要点 |
|
| # | 动作 | 要点 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
|
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
|
||||||
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
|
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
|
||||||
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
|
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
|
||||||
@@ -106,11 +113,13 @@ Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI
|
|||||||
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
|
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
|
||||||
|
|
||||||
### 4.5 配置管理
|
### 4.5 配置管理
|
||||||
|
|
||||||
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
|
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
|
||||||
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
|
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
|
||||||
- 装配只有两条路: `GatewayClient.from_env()`/`from_settings()`(工厂)或构造函数全量注入(测试/高级);库内部任何组件不得自读环境变量。
|
- 装配只有两条路: `GatewayClient.from_env()`/`from_settings()`(工厂)或构造函数全量注入(测试/高级);库内部任何组件不得自读环境变量。
|
||||||
|
|
||||||
### 4.6 测试组织
|
### 4.6 测试组织
|
||||||
|
|
||||||
- `tests/{unit,integration,e2e}`;真实场景优先(录制的真实网关响应二次构造优于凭空 mock)。
|
- `tests/{unit,integration,e2e}`;真实场景优先(录制的真实网关响应二次构造优于凭空 mock)。
|
||||||
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
|
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
|
||||||
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
|
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
|
||||||
@@ -140,7 +149,7 @@ project_root/
|
|||||||
## 6. 上下文导航
|
## 6. 上下文导航
|
||||||
|
|
||||||
| 需求 | 路径 |
|
| 需求 | 路径 |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| 架构全貌: 决策 D1-D14 及讨论过程、端口清单、错误分类、子系统设计、迁移验收 | `research-wiki/ARCHITECTURE.md`(单一事实源) |
|
| 架构全貌: 决策 D1-D14 及讨论过程、端口清单、错误分类、子系统设计、迁移验收 | `research-wiki/ARCHITECTURE.md`(单一事实源) |
|
||||||
| 开发顺序与里程碑状态 | `research-wiki/ROADMAP.md`(活文档,随进度更新) |
|
| 开发顺序与里程碑状态 | `research-wiki/ROADMAP.md`(活文档,随进度更新) |
|
||||||
| 三项目迁移文档(ARCHITECTURE §11 的展开,库设计的常驻约束) | `research-wiki/migrations/`(govdoc-saas / video-tree-trm5 / chsanalyzer) |
|
| 三项目迁移文档(ARCHITECTURE §11 的展开,库设计的常驻约束) | `research-wiki/migrations/`(govdoc-saas / video-tree-trm5 / chsanalyzer) |
|
||||||
@@ -154,6 +163,7 @@ project_root/
|
|||||||
| 第一次抽库尝试(教训与蓝本) | `reference/GovDoc-SaaS/packages/docagent-core/` |
|
| 第一次抽库尝试(教训与蓝本) | `reference/GovDoc-SaaS/packages/docagent-core/` |
|
||||||
|
|
||||||
## 7. 输出规范
|
## 7. 输出规范
|
||||||
|
|
||||||
- 所有输出**中文**;文档优先表格/伪代码/Mermaid;禁止超 15 行代码块入文档、禁止连续超 5 条碎片列点;设计 ≤400 行、计划 ≤1000 行、报告 ≤300 行。
|
- 所有输出**中文**;文档优先表格/伪代码/Mermaid;禁止超 15 行代码块入文档、禁止连续超 5 条碎片列点;设计 ≤400 行、计划 ≤1000 行、报告 ≤300 行。
|
||||||
- **例外**: `research-wiki/ARCHITECTURE.md` 不受行数限制——它的准绳是"后来的 AI/人类无需还原原始讨论即可准确理解全部决策及理由",宁详勿略(详细 ≠ 琐碎:记录论证与取舍,不堆砌实现细节)。
|
- **例外**: `research-wiki/ARCHITECTURE.md` 不受行数限制——它的准绳是"后来的 AI/人类无需还原原始讨论即可准确理解全部决策及理由",宁详勿略(详细 ≠ 琐碎:记录论证与取舍,不堆砌实现细节)。
|
||||||
|
|
||||||
@@ -163,7 +173,7 @@ project_root/
|
|||||||
> **Skill 的 description 即触发边界。** 下表标注 **MANDATORY** 的情形是硬门(设计人类门、合并前测试证据门、合并前独立验证门、commit 格式),不得跳过;其余情形按 description 边界自判——自判看任务实质(规模/风险/是否触及公共承诺),不是省事。任何 skill 流程不得引入任务外的重构或抽象。优先级: 用户显式指令 > Skill 详细流程 > 本文件宏观规则。
|
> **Skill 的 description 即触发边界。** 下表标注 **MANDATORY** 的情形是硬门(设计人类门、合并前测试证据门、合并前独立验证门、commit 格式),不得跳过;其余情形按 description 边界自判——自判看任务实质(规模/风险/是否触及公共承诺),不是省事。任何 skill 流程不得引入任务外的重构或抽象。优先级: 用户显式指令 > Skill 详细流程 > 本文件宏观规则。
|
||||||
|
|
||||||
| Skill | 触发边界 |
|
| Skill | 触发边界 |
|
||||||
|-------|---------|
|
| ------- | --------- |
|
||||||
| `brainstorming` | **MANDATORY**: 公共 API/端口签名/架构边界/新子系统变更;其余自判 |
|
| `brainstorming` | **MANDATORY**: 公共 API/端口签名/架构边界/新子系统变更;其余自判 |
|
||||||
| `writing-plans` | **MANDATORY**: 里程碑级/跨多文件功能;小改动自判 |
|
| `writing-plans` | **MANDATORY**: 里程碑级/跨多文件功能;小改动自判 |
|
||||||
| `test-driven-development`(测试结果门) | **MANDATORY**: 合并前——行为变更须有先失败后通过的测试证据 |
|
| `test-driven-development`(测试结果门) | **MANDATORY**: 合并前——行为变更须有先失败后通过的测试证据 |
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -19,17 +19,19 @@
|
|||||||
| 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** |
|
| 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** |
|
||||||
| 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__HEDGE__AFTER_S`,仅 chat):一次尝试挂起超阈值时向**异源**并发再发一次,先回者赢、输家取消。流式以「首 token 未至」为触发判据(不误杀慢生成),非流式只有纯总时长阈值;对冲走完整限流/熔断准入,拿不到配额静默放弃。成本须知:开启即用配额换延迟(触发窗口内 in-flight 翻倍),输家取消按 est 保留预扣且**可能已被上游计费**(取消止不住上游);输家不喂熔断,遥测 attempt 行标 `error="hedge_cancelled"` 可按 `logical_call_id` join 对账;两路皆败只计一次重试预算;与 `call_deadline_s` 正交组合(装配守卫强制对冲阈值 < 期限)
|
| 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__HEDGE__AFTER_S`,仅 chat):一次尝试挂起超阈值时向**异源**并发再发一次,先回者赢、输家取消。流式以「首 token 未至」为触发判据(不误杀慢生成),非流式只有纯总时长阈值;对冲走完整限流/熔断准入,拿不到配额静默放弃。成本须知:开启即用配额换延迟(触发窗口内 in-flight 翻倍),输家取消按 est 保留预扣且**可能已被上游计费**(取消止不住上游);输家不喂熔断,遥测 attempt 行标 `error="hedge_cancelled"` 可按 `logical_call_id` join 对账;两路皆败只计一次重试预算;与 `call_deadline_s` 正交组合(装配守卫强制对冲阈值 < 期限)
|
||||||
| 中段沉默对冲(可选,超业界现状能力) | **默认关闭**(`{SCOPE}__HEDGE__STALL_AFTER_S`,仅流式 chat):主路**首 token 之后**相邻增量 chunk 沉默超阈值时向异源并发再发一次,与首 token 对冲 `HEDGE__AFTER_S` **独立生效**、可独设可组合;仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念)。**诚实标注:中段沉默触发并发副本在主流网关/库中无公开先例**(业界收敛为看门狗掐流重试)。成本须知:下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲,开启即用配额换延迟,输家可能已被上游计费;看门狗掐死**喂**熔断而对冲输家取消**不喂**(不对称声明),详见[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
|
| 中段沉默对冲(可选,超业界现状能力) | **默认关闭**(`{SCOPE}__HEDGE__STALL_AFTER_S`,仅流式 chat):主路**首 token 之后**相邻增量 chunk 沉默超阈值时向异源并发再发一次,与首 token 对冲 `HEDGE__AFTER_S` **独立生效**、可独设可组合;仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念)。**诚实标注:中段沉默触发并发副本在主流网关/库中无公开先例**(业界收敛为看门狗掐流重试)。成本须知:下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲,开启即用配额换延迟,输家可能已被上游计费;看门狗掐死**喂**熔断而对冲输家取消**不喂**(不对称声明),详见[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
|
||||||
|
| OpenAI 消息保真(1.3.9) | `chat(response_mode="openai")` 返回完整 assistant 消息/工具调用、finish_reason、上游 ID 与 usage;单 choice,复用治理,不执行工具;默认仍为文本模式 |
|
||||||
|
| 完成判定(1.3.10) | 以上游 `finish_reason` 为准判定"生成结束、结果可交付":缺 `[DONE]` 但 finish 与其后的实测 usage 齐全即完整(两模式);length/过滤等确定性结局不再被当瞬时错误重试;未知/`null` 字段不再判失败;`LLMResponse.finish_reason` / `stream_end` 给出结束原因与方式,详见[1.3.10 统一完成判定](#1310-统一完成判定) |
|
||||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||||
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token(含 `reasoning` 别名)刷活性不计结果;截断流(无 `[DONE]` 且无 finish+其后实测 usage)默认判瞬时(`MISSING_DONE=salvage` 的文本打捞结果交付但不入缓存) |
|
||||||
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
|
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
|
||||||
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
|
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
|
||||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 38 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 39 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
||||||
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`,及生成时间 `generation_ms`——**1.3.8 起为净口径**:赢家那次 transport 调用墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部,排除准入排队/退避/触发前等待;1.3.7 的墙钟值由 `generation_ms + stall_ms` 还原——与沉默归因 `stall_ms` / `stall_count`(成对报告,None = 无沉默观测)、对冲计数 `hedges` / `hedge_won`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断)与[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
|
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:五种响应(chat / embedding / OCR 三种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`,及生成时间 `generation_ms`——**1.3.8 起为净口径**:赢家那次 transport 调用墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部,排除准入排队/退避/触发前等待;1.3.7 的墙钟值由 `generation_ms + stall_ms` 还原——与沉默归因 `stall_ms` / `stall_count`(成对报告,None = 无沉默观测)、对冲计数 `hedges` / `hedge_won`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断)与[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
|
||||||
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
|
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
|
||||||
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);五个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
||||||
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
||||||
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
|
||||||
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析)+ 整份文档解析 `parse_document`(1.3.11:PDF 按内容标注,逐块类型/页码/bbox/文字 + 整篇 Markdown,详见[1.3.11 OCR 整份文档解析](#1311-ocr-整份文档解析)),bbox 数值防御下沉,逐源健康预检 `check_health()` |
|
||||||
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
| Embedding | 分批、维度校验、与 chat 同一治理栈 |
|
||||||
|
|
||||||
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。
|
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。
|
||||||
@@ -124,7 +126,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
|
|||||||
| 定义 | **净生成时间** = 赢家那次 transport 调用的墙钟 − Σ(所有 > `STALL_THRESHOLD_MS`(缺省 1000ms)的相邻增量 chunk 沉默);仍**排除**准入排队/退避/对冲触发前等待(同 1.3.7) |
|
| 定义 | **净生成时间** = 赢家那次 transport 调用的墙钟 − Σ(所有 > `STALL_THRESHOLD_MS`(缺省 1000ms)的相邻增量 chunk 沉默);仍**排除**准入排队/退避/对冲触发前等待(同 1.3.7) |
|
||||||
| 边界 | 仍含 **TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择) |
|
| 边界 | 仍含 **TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择) |
|
||||||
| 还原公式 | **墙钟 = `generation_ms + stall_ms`**(`stall_ms is None` 时墙钟 = `generation_ms`) |
|
| 还原公式 | **墙钟 = `generation_ms + stall_ms`**(`stall_ms is None` 时墙钟 = `generation_ms`) |
|
||||||
| `stall_ms` / `stall_count` | 沉默总额(毫秒)与超阈值相邻间隔个数,**成对报告**(只报净数无法行动);chunk = 任何增量产出(`content` 或 `reasoning_content` 均计),空 chunk / usage 帧 / SSE keepalive 不计时 |
|
| `stall_ms` / `stall_count` | 沉默总额(毫秒)与超阈值相邻间隔个数,**成对报告**(只报净数无法行动);chunk = 任何增量产出(`content`、`reasoning_content`、工具实际增量,1.3.10 起另含未知字段的非空字符串片段如 `reasoning`),空 chunk / usage 帧 / SSE keepalive / 仅标签键(`index`/`type`/`id`/`format`)不计时 |
|
||||||
| `stall_ms is None` 分支条件 | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**;None = 「无沉默观测」而非 0,下游预算公式以此为分支条件 |
|
| `stall_ms is None` 分支条件 | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**;None = 「无沉默观测」而非 0,下游预算公式以此为分支条件 |
|
||||||
| 与基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
|
| 与基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
|
|||||||
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;三层调整接口同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`);无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) |
|
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;三层调整接口同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`);无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) |
|
||||||
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S` 生效;守卫: `≥ min(各源 inter_token_timeout_s 或 timeout_s)`(对冲窗永不开)或 `≥ call_deadline_s` → 装配期 **ValueError**,单源 scope → warning;三层调整接口同权(env 键 / `GatewaySettings.hedge_stall_after_s` / `GatewayClient(hedge_stall_after_s=...)`);Embedding/OCR scope 配任一对冲键 → warning 后静默退化 |
|
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S` 生效;守卫: `≥ min(各源 inter_token_timeout_s 或 timeout_s)`(对冲窗永不开)或 `≥ call_deadline_s` → 装配期 **ValueError**,单源 scope → warning;三层调整接口同权(env 键 / `GatewaySettings.hedge_stall_after_s` / `GatewayClient(hedge_stall_after_s=...)`);Embedding/OCR scope 配任一对冲键 → warning 后静默退化 |
|
||||||
|
|
||||||
对冲语义(两阶段触发窗/复检防误触发/取消穿透)与成本披露见 [CHANGELOG 未发布段](CHANGELOG.md);运维「挂起率」统计须知不对称:看门狗掐死**喂**熔断失败计数,对冲输家取消**不喂**熔断。
|
对冲语义(两阶段触发窗/复检防误触发/取消穿透)与成本披露见 [CHANGELOG 1.3.8 段](CHANGELOG.md);运维「挂起率」统计须知不对称:看门狗掐死**喂**熔断失败计数,对冲输家取消**不喂**熔断。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -145,7 +147,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||||
"polygateway[redis,postgres,structured]>=1.3.8,<2"
|
"polygateway[redis,postgres,structured]>=1.3.11,<2"
|
||||||
```
|
```
|
||||||
|
|
||||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||||
@@ -206,6 +208,7 @@ from polygateway.ocr import OcrClient
|
|||||||
ocr = OcrClient.from_env("OCR") # OCR__MONKEY__1__* 多源
|
ocr = OcrClient.from_env("OCR") # OCR__MONKEY__1__* 多源
|
||||||
text = await ocr.recognize_text(image_bytes) # 文本转录
|
text = await ocr.recognize_text(image_bytes) # 文本转录
|
||||||
layout = await ocr.parse_layout(image_bytes) # 版面解析(带 bbox 的元素列表)
|
layout = await ocr.parse_layout(image_bytes) # 版面解析(带 bbox 的元素列表)
|
||||||
|
doc = await ocr.parse_document(pdf_bytes) # 整份文档(PDF 或图片):逐块文字 + 整篇 Markdown
|
||||||
health = await ocr.check_health() # 逐源预检 {"monkey_1": True, ...}
|
health = await ocr.check_health() # 逐源预检 {"monkey_1": True, ...}
|
||||||
|
|
||||||
embed = EmbeddingClient.from_env("EMBED") # EMBED__*__* + EMBED__BATCH_SIZE
|
embed = EmbeddingClient.from_env("EMBED") # EMBED__*__* + EMBED__BATCH_SIZE
|
||||||
@@ -240,7 +243,7 @@ resp = await client.chat(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
|
**1.2.1 起**,公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`,1.3.11 起含 `parse_document`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
|
||||||
|
|
||||||
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
|
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
|
||||||
|
|
||||||
@@ -610,3 +613,176 @@ make ci # 只读全量验证
|
|||||||
## 兼容性承诺
|
## 兼容性承诺
|
||||||
|
|
||||||
`LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。
|
`LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。
|
||||||
|
|
||||||
|
## 1.3.9 OpenAI 工具消息保真(单 choice)
|
||||||
|
|
||||||
|
`chat(..., response_mode="openai")` 仍返回 `LLMResponse`,完整协议结果在
|
||||||
|
`response.completion`:`message`、`finish_reason`、`response_id`、`usage`。
|
||||||
|
`content/thinking` 是旧文本投影(可剥离 think 标签);回传代理或继续对话应使用
|
||||||
|
completion.message,不能从旧投影重建工具响应。嵌套 JSON 是独立快照但仍可修改。
|
||||||
|
|
||||||
|
```python
|
||||||
|
response = await client.chat(
|
||||||
|
messages,
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={"tools": tools, "tool_choice": "auto"},
|
||||||
|
)
|
||||||
|
completion = response.completion
|
||||||
|
messages.append(completion.message)
|
||||||
|
# 工具执行与 role=tool 结果由调用方添加;库不会执行工具。
|
||||||
|
```
|
||||||
|
|
||||||
|
保真是 JSON 字段和值保真,不是 HTTP 字节透明代理。仅支持单 choice(`n=1`),
|
||||||
|
可含多个并行 function tool_calls;arguments 逐字拼接,不解析、修复或执行。
|
||||||
|
SSE 的 role/content/reasoning_content/tool_calls 按原语义聚合;1.3.10 起 `null` 视为缺省,
|
||||||
|
未知增量字段按流式惯例无损合并(冲突删键并告警,见下节);
|
||||||
|
非流式保留整个 message 扩展字段,不返回顶层 logprobs/created 等其他字段。
|
||||||
|
上游实际模型仍读 `model_reported`,配置别名是 `model`。
|
||||||
|
|
||||||
|
| 边界 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 空文本且有完整工具 | 正常成功;不完整工具失败(length 截断的半截工具归 ResultInvalid),其余见 1.3.10 结局表 |
|
||||||
|
| error 帧 | 所有模式立即判失败并走已有重试;后续 DONE 不挽救 |
|
||||||
|
| 缺 DONE | 1.3.10 起 finish + 其后实测 usage + 干净 EOF 即完整;真截断仍不受 salvage 放宽,防截断工具被执行 |
|
||||||
|
| finish_reason=length | 原样返回(含只有思考的回答),不修补参数;**执行工具前先查 finish_reason**,arguments 可能是半截 JSON |
|
||||||
|
| structured | 不可与保真并用;自行传 overlay.response_format 不启用库的修复/重问阶梯 |
|
||||||
|
| raw 推理 | 保真不是校验逃生口;raw-only 要求源 ENABLE_THINKING/REASONING_EFFORT 均未设置,请求档为 None,再通过 overlay.reasoning_effort 传原值 |
|
||||||
|
|
||||||
|
工具实际增量与正文/推理共用首 token、inter-token、stall 对冲及沉默计量;注释、
|
||||||
|
usage、role-only、重复工具身份不算进展。非流式无中段事件,保持纯时长阈值对冲。
|
||||||
|
`Transport.complete` 的自定义实现须接收必填 `response_mode`;不支持保真时明确
|
||||||
|
RequestRejected,不能伪造 completion 或静默退回文本。
|
||||||
|
|
||||||
|
缓存按返回模式隔离;缺协议、畸形协议或无正文无工具的条目按 miss 回源。命中保留历史上游 response_id/usage,生成本次治理 call_id,
|
||||||
|
本次 attempts=0、cost=0。**旧文本缓存可能含修复前 error+DONE 的截断产物**:
|
||||||
|
受影响调用集合须在首次读写前更换从未承载旧语义的 namespace 或 salt,包含工厂、
|
||||||
|
全量注入和 per-call 覆盖,保留租户前缀与 epoch。未迁移仍可能读旧值,库不自动清除。
|
||||||
|
遥测继续单一出口,不新增工具全文列;既有 TEXT_CAP 不覆盖 tool_calls.arguments。
|
||||||
|
|
||||||
|
### 线程模型的异步桥接
|
||||||
|
|
||||||
|
库不新增同步客户端。socketserver 等工作线程应向**一个常驻事件循环线程**提交:
|
||||||
|
client 必须在该 loop 内创建、使用并关闭,不能每请求 asyncio.run 或跨 loop 共用
|
||||||
|
httpx/Redis 组件。下例的 loop 和 client 已由宿主完成初始化:
|
||||||
|
|
||||||
|
```python
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
client.chat(messages, response_mode="openai", overlay=overlay), loop
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = future.result(timeout=timeout_s)
|
||||||
|
except TimeoutError:
|
||||||
|
future.cancel() # 断连也应取消;取消不等于上游未计费。
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
应用退出时先停止接单、取消/等待在途调用,在同一个 loop 中 await client.aclose(),
|
||||||
|
再停 loop 并 join 线程;注入组件仍由所有者关闭。不要从 loop 本身阻塞调用 future.result。
|
||||||
|
|
||||||
|
## 1.3.10 统一完成判定
|
||||||
|
|
||||||
|
#30/#31 同根: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单作为成功判据,上游的正常结局
|
||||||
|
(不发 `[DONE]`、只有思考的 length 截断、`refusal: null`)被当瞬时错误重试到耗尽。1.3.10
|
||||||
|
改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存四处共用一份判定。
|
||||||
|
|
||||||
|
| 结束方式(`stream_end`) | 条件 |
|
||||||
|
| --- | --- |
|
||||||
|
| `done` | 收到 `[DONE]` |
|
||||||
|
| `eof_after_finish` | 未发 `[DONE]`,但 finish 已到、其同帧或之后收到的 usage 为实测,连接干净关闭(用量记 measured) |
|
||||||
|
| `salvaged` | 文本模式 `MISSING_DONE=salvage` 打捞的截断结果(用量降 estimated,**不写缓存**) |
|
||||||
|
| `None` | 非流式 / 缓存命中 / 失败行 |
|
||||||
|
|
||||||
|
| 结局(首个匹配行生效) | `openai` 模式 | `text` 模式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| finish 为 `network_error`/`error`(上游自报失败) | 瞬时错误 | 瞬时错误 |
|
||||||
|
| 工具快照不成立 | length → `ResultInvalidError`;其余瞬时 | — |
|
||||||
|
| 无任何产物 + length/过滤 | 原样交回 | `ResultInvalidError`(不重试、不计熔断) |
|
||||||
|
| 无任何产物 + 其余(零增量抖动) | 瞬时错误 `empty_completion` | 同左 |
|
||||||
|
| 有正文 | 交付 | 交付 |
|
||||||
|
| 只有思考/拒答/空白 + length/过滤 | 原样交回 | `ResultInvalidError` |
|
||||||
|
| 只有思考/拒答/空白 + stop 等 | 原样交回 | 瞬时错误(证据收集中) |
|
||||||
|
| 只有思考/拒答/空白 + 无 finish | 瞬时错误 | 同左 |
|
||||||
|
|
||||||
|
`ResultInvalidError` 不经 `AllSourcesExhausted` 包装,直接到调用方;原因码在异常消息里
|
||||||
|
(`length_without_content` / `filtered_without_content` / `tool_truncated` 等,遥测 `error`
|
||||||
|
列可检索)。只有自然结束(stop/tool_calls/function_call,或未上报 finish 的旧口径)且投影正文非空
|
||||||
|
(或工具完整)的结果才写缓存;未登记的 finish 值按正常交付、不缓存并告警一次。无参函数只发
|
||||||
|
`arguments: null` 的工具判 `ResultInvalidError`(`tool_arguments_null`,确定性方言,不改写不重试)。文本模式 `thinking` 在无非空 `reasoning_content` 时取
|
||||||
|
`reasoning`(vLLM/OpenRouter 新名)。保真模式未知字段:字符串拼接、对象递归、带 `index` 的
|
||||||
|
数组按 index 合并、`index/type/id/format` 同值幂等;冲突时**删除该字段**并按(源, 路径)
|
||||||
|
告警一次——结果可能缺字段,以告警为准。诚实边界:中转在上游出错后主动正常关闭连接与真
|
||||||
|
完整在传输层不可区分,防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
|
||||||
|
|
||||||
|
遥测 `llm_calls` 追加可空列 `stream_end`;`TelemetryRecorder.record_llm_call` 新增必填
|
||||||
|
keyword `stream_end`(**自建 recorder 须跟进,否则装配期被形状闸拒绝**)。
|
||||||
|
|
||||||
|
## 1.3.11 OCR 整份文档解析
|
||||||
|
|
||||||
|
#34: `OcrClient.parse_document(bytes)` 把 PDF(或图片)整份交给 MonkeyOCR,返回逐块的
|
||||||
|
类型 / 页码 / bbox / 文字与整篇 Markdown。治理链路(限流、熔断、重试、遥测、
|
||||||
|
`tenant_id` / `meta`、`call_deadline_s`)与 `parse_layout` 完全一致;`parse_layout` /
|
||||||
|
`recognize_text` 的签名与返回类型不变。
|
||||||
|
|
||||||
|
```python
|
||||||
|
doc = await ocr.parse_document(pdf_bytes) # 上传类型由库按内容判定,无需也无法声明
|
||||||
|
for el in doc.elements: # 服务阅读序;页内按页序
|
||||||
|
el.page_index, el.type, el.bbox, el.text
|
||||||
|
doc.page_sizes[el.page_index] # 与 bbox 同一原生坐标系,页码零基
|
||||||
|
doc.markdown # 服务生成的整篇 Markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `elements[].text` | `text` / `title`: 纯文本(行内公式保留 `$…$`);`interline_equation`: 裸 LaTeX;`table`: HTML 表体;`image`: 空串 |
|
||||||
|
| `elements[].type` | 开放字符串(服务给什么就是什么)。嵌套的 caption / footnote 子块若出现,作为独立元素紧随父块输出 |
|
||||||
|
| `markdown` | 服务生成的整篇原文,**含悬空的 `` 图片引用**——库不外传图片 |
|
||||||
|
|
||||||
|
### 行为变更(请先读)
|
||||||
|
|
||||||
|
| 变更 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| PDF 上传标签 | MonkeyOCR 按**上传文件名的扩展名**分派。此前所有上传都标 `image.jpg`;现在**前 1KB 内含 `%PDF-` 且起始不是已知图片魔数**的字节改标 `doc.pdf`(容忍垃圾前缀;副作用是前 1KB 里恰好出现 `%PDF-` 字样的非 PDF 文本也会被改标),**其余一律沿用 `image.jpg`**。三个方法共用这一个判定 |
|
||||||
|
| `recognize_text(PDF)` | **修复静默错误**: PDF 标成 `image.jpg` 时服务返回 `success:true` 与幻觉文本(实测 `"The image contains a single character: 1"`);现在返回整份全文(无页码,请改用 `parse_document`) |
|
||||||
|
| `parse_layout(PDF)` | 上传分支由图片数据集改为 PDF 数据集。已测的两种 2 页 PDF(文本型、无文字层的位图型)结果与此前**逐字相同**;**旋转页、带批注页未测**,不构成保证。医院线只传图片,不受影响 |
|
||||||
|
| 坏输入(仅 `parse_document`) | 无法识别的字节(HTML 页面、乱码、空串)本地拒绝、不发请求;服务对截断/伪造 PDF 返回的已知确定性 500(`Failed to open stream` 等)归 `RequestRejectedError`(`status_code=500`),**不重试、不计熔断**。其余 500 仍是瞬时错误 |
|
||||||
|
|
||||||
|
### 部署配方(文档线)
|
||||||
|
|
||||||
|
长文档与医院线的图片请求共用同一个 MonkeyOCR 服务时,建议文档线用**独立 scope**,自带超时 / 重试 / 熔断:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866
|
||||||
|
OCR_DOC__MONKEY__1__API_KEY=none
|
||||||
|
OCR_DOC__MONKEY__1__MODEL=monkey-ocr
|
||||||
|
OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 4s × 余量(实测 1.5–4 s/页;共用 GPU 的争用再乘 3–5 倍)
|
||||||
|
OCR_DOC__MONKEY__1__MAX_CONCURRENCY=2 # 服务为同步串行时取 1,见下
|
||||||
|
OCR_DOC__MONKEY__1__TRUST_ENV=false
|
||||||
|
OCR_DOC__RETRY__MAX_ATTEMPTS=2
|
||||||
|
OCR_DOC__CALL_DEADLINE_S=900 # 单份文档的硬上限;须 ≤ PGW_LEASE_TTL_S(缺省 1500)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运维须知
|
||||||
|
|
||||||
|
| # | 事项 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1 | **`timeout_s` 不是总耗时上界**: 它是逐次网络操作的超时。POST 阶段服务处理期间无字节,读超时约等于处理时长上限;GET 下载只要持续有分块到达,总时长可超过任意多个 `timeout_s`。**硬上限只来自 `call_deadline_s`**。permit 租约与半开探针租约到期后会被回收 / 接管,所以文档线应让 `call_deadline_s ≤ lease_ttl_s` |
|
||||||
|
| 2 | 库内**没有任何基于延迟的熔断 / 慢判定**,长文档不会因为"慢"被判故障;唯一把慢变成失败的是 httpx 超时(瞬时错误,计熔断) |
|
||||||
|
| 3 | **重试放大**: POST 成功(服务已算完)而 GET 失败时,重试会重新 POST 整份文档重算;超时重试同理,且服务端是否因断连取消计算无证据——请把 `RETRY__MAX_ATTEMPTS` 压低 |
|
||||||
|
| 4 | **无页数预检**: 库不依赖 PDF 解析库,发送前不知页数。数百页的文档会占满超时被重投,页数过滤是调用方的职责 |
|
||||||
|
| 5 | **服务端磁盘**: 每次调用在服务 `/app/tmp` 与 `/static` 留下产物(几百篇约几百 MB 到 GB 级),库不管理,请告知服务运维 |
|
||||||
|
| 6 | **多 worker 共享并发上限**需要 `PGW_LIMITER_BACKEND=redis` 且各进程同 scope;默认 memory 后端的 `MAX_CONCURRENCY` 是进程内的 |
|
||||||
|
| 7 | **服务端并发模型**: 若 MonkeyOCR 部署为 sync-only 后端(出厂默认 `lmdeploy`),`/parse` 在每个实例上**全局串行**——"并发 2"不提速,`timeout_s` 须覆盖"排队 + 处理",且一份 15 页文档(按实测 1.5–4 s/页估算,约 22–60 秒)的队头阻塞会拖慢同实例上医院线的请求。请向运维确认 backend(服务启动日志有一行 `async-capable` / `sync-only`),sync-only 时文档线每实例并发 1、错峰运行 |
|
||||||
|
| 8 | 源 `timeout_s` 与 permit 租约的装配守卫只比较 1×(`timeout_s ≤ lease_ttl_s`);跨 scope 的守卫改造另行立项,不在本版 |
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
- **服务端丢弃脚注、页眉页脚、页码**(版面模型把它们标为丢弃类,span 内容为空,未被 OCR):论文表格脚注里的显著性与缩写说明不会出现在 `elements` 或 `markdown` 里,库无从补偿。
|
||||||
|
- 多行 / 多 span 的文本块、`inline_equation` span、嵌套 caption / footnote 子块这三条路径**没有真实样本**(实测样本全是 1 行 1 span,默认版面模型不产生嵌套子块),只由构造 fixture 测试。
|
||||||
|
- **内存**: 文档路径整包上限 256 MiB(流式累计),合并分块会让瞬时峰值接近 2 倍上限;**字节上限不等于内存上限**——JSON 解析后的对象体积可达原文数十倍(实测 11 MiB 的空数组 JSON 解析后约 270 MiB),异常大的包请按此预留内存。真实样本约 72 KB/页,正常大小是毫秒级;`_middle.json` 的 64 MiB 上限与整包 256 MiB 上限都只界定字节数,不界定成员条数。
|
||||||
|
- `parse_document` 仅接受 PDF 与魔数表内的图片(JPEG / PNG / TIFF / BMP / GIF / WebP);表外的可解码图片格式请走 `parse_layout`。
|
||||||
|
- `parse_layout` 的校验口径保持原样(bbox 整数化退化拒绝、`page_idx` 非法回退枚举下标、无体积上限);`parse_document` 更严的有零页拒绝与 `page_idx` 必须等于页序,更宽的是 bbox 只要求有限且有序。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
PolyGateway 使用 [Apache License 2.0](LICENSE)。内部移植代码的原创 owner 已明确授权;
|
||||||
|
分发范围不包含 `reference/` 外部参考仓。
|
||||||
|
|||||||
+2
-1
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.3.8"
|
version = "1.3.11"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license = {file = "LICENSE"}
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
|
|||||||
|
|
||||||
**背景(调研结论)**: Video-Tree 用 `/ocr/text` 把帧文字作为"硬证据"并置进 VLM 提示词,**裸调**(无重试/限流/熔断,单帧失败跳过,手写双端点轮询)——迁移后免费升级为全治理。CHSAnalyzer 用 `/parse` 定位表格 bbox,已全治理。GovDoc 无 OCR 且是明确架构否决(旧系统被"OCR 挂死"拖垮,教训反哺 §6.4 取消语义)。多后端预留有直接证据:CHSAnalyzer provider 白名单已含 `glm`(延后),设计文档明确三种后端图片输入形态各异(VLM=base64、Monkey=multipart、GLM=URL)——因此端口收 `bytes`,编码差异封装在 invoker 内部。
|
**背景(调研结论)**: Video-Tree 用 `/ocr/text` 把帧文字作为"硬证据"并置进 VLM 提示词,**裸调**(无重试/限流/熔断,单帧失败跳过,手写双端点轮询)——迁移后免费升级为全治理。CHSAnalyzer 用 `/parse` 定位表格 bbox,已全治理。GovDoc 无 OCR 且是明确架构否决(旧系统被"OCR 挂死"拖垮,教训反哺 §6.4 取消语义)。多后端预留有直接证据:CHSAnalyzer provider 白名单已含 `glm`(延后),设计文档明确三种后端图片输入形态各异(VLM=base64、Monkey=multipart、GLM=URL)——因此端口收 `bytes`,编码差异封装在 invoker 内部。
|
||||||
|
|
||||||
|
**1.3.11 增补(#34,整份文档解析)**: 端口族增第三成员 `OcrDocumentPort.parse_document(bytes)`(PDF 或图片整份 → 逐块 `OcrDocumentElement` + `page_sizes` + 整篇 `markdown`)。根因与取舍见 `designs/2026-10-21-issue34-ocr-document-design.md`: M3 的"服务按内容处理"只在 35 张图片样本上验证过,而 MonkeyOCR **按上传文件名扩展名分派**(PDF 标 `image.jpg` 时 `/ocr/text` 返回 `success:true` 的幻觉文本);库把输入建模成"一张图 + 写死的标签"、输出建模成"只有框",没有"文档"这个概念。决策要点: ① 新 Protocol `OcrDocumentTransport` **独立于** `OcrTransport`(旧 `runtime_checkable` Protocol 不加成员);② 上传类型由库按内容判定(图片魔数优先 → 前 1KB 内 `%PDF-`),不提供调用方声明参数;③ 内容只取自 `_middle.json` + 配对 `.md`,不读 `_content_list.json`(展示格式,转义 `* ` ~ $`);④ 确定性坏输入的 500 只在新路径窄化为"请求被拒",共享分类不变(#35)。
|
||||||
|
|
||||||
**影响**: 协议细节与后处理分界见 §7.10;`TableLocator` 这类业务化端口留在业务侧,由库端口结果组装。
|
**影响**: 协议细节与后处理分界见 §7.10;`TableLocator` 这类业务化端口留在业务侧,由库端口结果组装。
|
||||||
|
|
||||||
### D10 音频只留端口
|
### D10 音频只留端口
|
||||||
@@ -413,7 +415,7 @@ flowchart TB
|
|||||||
| 值 | 含义 | 生产者 | cost |
|
| 值 | 含义 | 生产者 | cost |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
||||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
|
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1;1.3.10 起即 `stream_end="salvaged"`) | 按 token 换算 |
|
||||||
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
||||||
|
|
||||||
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
|
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
|
||||||
@@ -424,13 +426,13 @@ flowchart TB
|
|||||||
|
|
||||||
### 5.2 其他类型与 `chat()` 公共签名
|
### 5.2 其他类型与 `chat()` 公共签名
|
||||||
|
|
||||||
`ChatRequest`(model/messages/结构化输出参数/per-call 覆盖项)、`Usage`(tokens + elapsed,OCR 无计费填 0)、`OcrTextResult`(text + 溯源五件 source_name/usage/latency_ms/call_id/raw)、`OcrLayoutResult`(elements 含 bbox/type/page_index + page_sizes + 同套溯源五件;元素自 `_middle.json` para_blocks 全量提取,M3 设计 §1.2 取证)。全部 frozen dataclass。空结果语义:合法"无内容"用空值/None 表达,调用失败必须走异常——二者严格区分。
|
`ChatRequest`(model/messages/结构化输出参数/per-call 覆盖项)、`Usage`(tokens + elapsed,OCR 无计费填 0)、`OcrTextResult`(text + 溯源五件 source_name/usage/latency_ms/call_id/raw)、`OcrLayoutResult`(elements 含 bbox/type/page_index + page_sizes + 同套溯源五件;元素自 `_middle.json` para_blocks 全量提取,M3 设计 §1.2 取证)、`OcrDocumentResult`(1.3.11,#34: `OcrDocumentElement`=版面元素+`text`,加 page_sizes、整篇 `markdown` 与同套溯源五件;**独立定义不继承** `OcrLayoutResult`——父类 `call_stats` 带默认值,子类无默认字段会在导入期 `TypeError`)。全部 frozen dataclass。空结果语义:合法"无内容"用空值/None 表达,调用失败必须走异常——二者严格区分。
|
||||||
|
|
||||||
**`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。
|
**`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。
|
||||||
|
|
||||||
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
|
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
|
||||||
|
|
||||||
**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
|
**调用方维度追加(2026-08-17,issue #11)**: 五个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout` / `parse_document`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
|
||||||
|
|
||||||
① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
|
① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
|
||||||
|
|
||||||
@@ -470,7 +472,9 @@ flowchart TB
|
|||||||
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
|
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
|
||||||
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
|
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
|
||||||
| HTTP 400 | `RequestRejectedError` |
|
| HTTP 400 | `RequestRejectedError` |
|
||||||
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
|
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应)。1.3.10 起仅限**零产物**或无法区分抖动与结局的情形;上游明确的 length/过滤结局见 §15 |
|
||||||
|
| finish_reason 为 `network_error`/`error`(上游自报失败,1.3.10) | `TransientError` |
|
||||||
|
| 文本模式 length/过滤且无正文;保真模式 length 截断的半截工具(1.3.10) | `ResultInvalidError`(确定性结局,不重试、不计熔断) |
|
||||||
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
|
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
|
||||||
|
|
||||||
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
|
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
|
||||||
@@ -566,7 +570,7 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.8 遥测与成本
|
### 7.8 遥测与成本
|
||||||
|
|
||||||
**必录字段**(继承三项目 15 字段规范;当前 38 个 INSERT 字段,物理表列 39 = 38 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**、**stall_ms**、**stall_count**。
|
**必录字段**(继承三项目 15 字段规范;当前 39 个 INSERT 字段,物理表列 40 = 39 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**、**stall_ms**、**stall_count**、**stream_end**(1.3.10,§15)。
|
||||||
|
|
||||||
**`stall_ms`/`stall_count` 两列(2026-09-11,1.3.8,issue #27,端口 36 → 38)**: 沉默归因进库,口径见 §7.2 净口径段。取 INTEGER 而非 REAL——落库值是 retry 层已取整的 int,与换算唯一处纪律(N8)一致。追加在 `total_latency_ms` 之后,可空无默认、旧行 NULL 不回填;`emit_cache_hit`/`emit_terminal_failure` 与失败/取消 attempt 行恒 NULL(缓存命中与终态行无 transport 观测;被取消的对冲输家无 TransportResult,其 `hedge_cancelled` 行 stall 恒 NULL,存在性由该行表达)。`generation_ms` **仍不进遥测列**(YAGNI)——墙钟由 `generation_ms + stall_ms` 还原。
|
**`stall_ms`/`stall_count` 两列(2026-09-11,1.3.8,issue #27,端口 36 → 38)**: 沉默归因进库,口径见 §7.2 净口径段。取 INTEGER 而非 REAL——落库值是 retry 层已取整的 int,与换算唯一处纪律(N8)一致。追加在 `total_latency_ms` 之后,可空无默认、旧行 NULL 不回填;`emit_cache_hit`/`emit_terminal_failure` 与失败/取消 attempt 行恒 NULL(缓存命中与终态行无 transport 观测;被取消的对冲输家无 TransportResult,其 `hedge_cancelled` 行 stall 恒 NULL,存在性由该行表达)。`generation_ms` **仍不进遥测列**(YAGNI)——墙钟由 `generation_ms + stall_ms` 还原。
|
||||||
|
|
||||||
@@ -592,7 +596,7 @@ flowchart TB
|
|||||||
| 列 | 语义 |
|
| 列 | 语义 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) |
|
| `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) |
|
||||||
| `operation` | `chat` / `embed` / `recognize_text` / `parse_layout`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 |
|
| `operation` | `chat` / `embed` / `recognize_text` / `parse_layout` / `parse_document`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 |
|
||||||
| `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** |
|
| `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** |
|
||||||
| `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 |
|
| `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 |
|
||||||
| `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) |
|
| `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) |
|
||||||
@@ -666,6 +670,7 @@ flowchart TB
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) |
|
| `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) |
|
||||||
| `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) |
|
| `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) |
|
||||||
|
| `OcrDocumentPort.parse_document(document: bytes)`(1.3.11,#34) | `POST /parse`(同上两段协议) | 上传类型按内容判定;流式 GET(整包 ≤256 MiB)→ 线程内解析 `*_middle.json` + 配对 `.md` | `OcrDocumentResult`(逐块 text + bbox + 页码,整篇 markdown) |
|
||||||
|
|
||||||
设计要点:输入统一 `bytes`(路径读取/多帧批量拼接留业务侧);bbox 返回 OCR 原生页面坐标,几何映射(裁剪偏移/归一化/marker 推算)留业务侧;`None`/空表达"合法无内容",异常表达"调用失败";ZIP 内容不可解析抛 `ResultInvalidError`(坏图≠坏服务);OCR 复用同一套治理算法件、循环形态同 EmbeddingClient 先例(见 D9 修订;无 token 计费,Usage 填 0,latency_ms 照记);多后端经 provider 注册表扩展(GLM 已在 CHSAnalyzer 白名单,输入形态为 URL,届时封装在其 invoker 内部,端口签名不变)。数值防御(bbox 有限性/顺序/退化校验)随协议解析下沉进库。OCR transport 支持 per-source `trust_env` 开关(LAN 直连绕过本地代理,Video-Tree `ocr.py:46` 教训;2026-07-20 缺口 R9);客户端暴露 `check_health() -> dict[str, bool]` 逐源健康预检(M3 设计 §3.3 细化:bool 无法承载逐源结果,业务 `all()` 即得启动门布尔;Video-Tree A/B 评测消费;缺口 R10 已闭)。协议事实(2026-07-21 实测): `/parse` 的 download_url 为**相对路径**(`/static/*.zip`),transport 相对 base_url 解析并兼容绝对 URL。
|
设计要点:输入统一 `bytes`(路径读取/多帧批量拼接留业务侧);bbox 返回 OCR 原生页面坐标,几何映射(裁剪偏移/归一化/marker 推算)留业务侧;`None`/空表达"合法无内容",异常表达"调用失败";ZIP 内容不可解析抛 `ResultInvalidError`(坏图≠坏服务);OCR 复用同一套治理算法件、循环形态同 EmbeddingClient 先例(见 D9 修订;无 token 计费,Usage 填 0,latency_ms 照记);多后端经 provider 注册表扩展(GLM 已在 CHSAnalyzer 白名单,输入形态为 URL,届时封装在其 invoker 内部,端口签名不变)。数值防御(bbox 有限性/顺序/退化校验)随协议解析下沉进库。OCR transport 支持 per-source `trust_env` 开关(LAN 直连绕过本地代理,Video-Tree `ocr.py:46` 教训;2026-07-20 缺口 R9);客户端暴露 `check_health() -> dict[str, bool]` 逐源健康预检(M3 设计 §3.3 细化:bool 无法承载逐源结果,业务 `all()` 即得启动门布尔;Video-Tree A/B 评测消费;缺口 R10 已闭)。协议事实(2026-07-21 实测): `/parse` 的 download_url 为**相对路径**(`/static/*.zip`),transport 相对 base_url 解析并兼容绝对 URL。
|
||||||
|
|
||||||
@@ -789,3 +794,36 @@ src/polygateway/
|
|||||||
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
|
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
|
||||||
| Q4 | conda 环境名 | `PolyGateway` |
|
| Q4 | conda 环境名 | `PolyGateway` |
|
||||||
| Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 |
|
| Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 |
|
||||||
|
|
||||||
|
|
||||||
|
## 14. OpenAI 单 choice 消息保真(2026-10-01,#29 已批准增量)
|
||||||
|
|
||||||
|
本节明确修订 §5/§6.2/§7.1/§7.2/§7.5/§7.6 中原仅文本契约,详细边界见 [设计](designs/2026-10-01-issue29-openai-fidelity-design.md)。不新增 Agent Loop 或第二套治理。
|
||||||
|
|
||||||
|
| 接缝 | 增量契约 |
|
||||||
|
|---|---|
|
||||||
|
| 公共返回 | chat 追加 response_mode=text/openai,默认 text;LLMResponse/TransportResult 末尾追加 completion,内含 assistant message、finish_reason、response_id、usage;旧字段继续文本兼容投影 |
|
||||||
|
| 请求所有权 | messages 字段保真;overlay 仍受 model/messages/stream/stream_options 保护;raw-only 复用既有路径,模式不绕过 managed 校验 |
|
||||||
|
| 协议 | openai 单 choice;工具按 index 排序,name/arguments 逐片拼接;SSE 支持 role/content/reasoning_content/tool_calls,未知增量拒绝;非流式保留消息扩展字段;成功必含 assistant role |
|
||||||
|
| 失败 | 所有 chat 模式 error 帧立即 TransientError;openai 缺 DONE 一律重试、不 salvage;tool-only 成功,纯空/不完整工具失败;text salvage 原语义不变 |
|
||||||
|
| 活性 | 同帧正文/推理/工具实际进展只记一次;role/usage/finish/空帧/重复身份不算;沿既有看门狗、首 token、stall 对冲与唯一取整点 |
|
||||||
|
| 缓存 | 仅 openai key 新增 response_mode 语义维度,默认 text key 不变;完整快照显式重建,缺 completion/role 或无有效正文/工具按 miss(与 transport 共用内核纯值判据,不依赖 transport 实现);历史上游 id 回放,本次治理 id 重建 |
|
||||||
|
| 迁移 | 旧 text 污染无法自动判断,受影响集合必须迁移 namespace/salt;新模式天然隔离;无新遥测列/表,统一 helper 与取消结算不变 |
|
||||||
|
|
||||||
|
本增量不会执行工具;length 结束原因与参数原样交回,由下游判断可执行性。嵌套 JSON 是独立可变快照,不承诺深冻结。许可已由原创 owner 明确批准 Apache-2.0。
|
||||||
|
|
||||||
|
## 15. 统一完成判定(2026-10-14,#30/#31 已授权增量)
|
||||||
|
|
||||||
|
本节修订 §6.2 空补全行、§7 `usage_source` 映射与 §14 协议/失败/缓存三行,详细边界见 [设计](designs/2026-10-14-issue30-31-completion-verdict-design.md)。
|
||||||
|
|
||||||
|
| 接缝 | 增量契约 |
|
||||||
|
|---|---|
|
||||||
|
| 根因 | 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游正常结局落 Transient 被重试放大;本版以上游 `finish_reason` 与传输完整性为准 |
|
||||||
|
| 内核 | `types.py` 纯函数 `_stream_is_complete` / `_classify_finish` / `_judge_outcome` / `_is_cacheable`,流式、非流式、缓存读写四处共用;只返回判定值,错误由调用点翻译 |
|
||||||
|
| 流完整性 | `[DONE]` → `done`;无 DONE 但 finish 已到且其同帧或之后的 usage 为实测、干净 EOF → `eof_after_finish`;其余为截断:保真不打捞,文本按 `missing_done` |
|
||||||
|
| 结局 | 首匹配表:上游错误 finish → Transient;坏工具(length → ResultInvalid);零产物 + length/过滤 → 保真交付、文本 ResultInvalid;零产物其余 → Transient(保 2026-07-20 裁决);有正文交付;有产物无正文按 finish 分模式 |
|
||||||
|
| 字段 | 五层 null 视缺省;保真未知字段无损合并(字符串拼接、index 数组按 index、标签键幂等),冲突删键 + 有界告警;未知字符串片段计活性 |
|
||||||
|
| 缓存 | 可缓存 ⊂ 可交付:salvaged、无正文、length/过滤/未知 finish 不写,读到即 miss;判定先于命中覆盖 |
|
||||||
|
| 公共面 | `LLMResponse`/`TransportResult` 追加 `finish_reason`、`stream_end`;遥测加 `stream_end` 列,`record_llm_call` 加必填参数 |
|
||||||
|
|
||||||
|
`usage_source` 与 `stream_end` 分工:前者答"账单可信吗",后者答"内容完整吗"。`eof_after_finish ⇒ measured`;`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。诚实边界:中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分。
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
## 1. 总览与状态
|
## 1. 总览与状态
|
||||||
|
|
||||||
| 阶段 | 内容 | 接入方 | 状态 |
|
| 阶段 | 内容 | 接入方 | 状态 |
|
||||||
|---|---|---|---|
|
| --- | --- | --- | --- |
|
||||||
| P0 奠基 | 调研、ARCHITECTURE.md、CLAUDE.md、skill Fable 5 改造、hooks 硬边界、脚手架(git/pyproject/Makefile/conda 环境/CI 绿) | — | ✅ 完成(2026-07-20) |
|
| P0 奠基 | 调研、ARCHITECTURE.md、CLAUDE.md、skill Fable 5 改造、hooks 硬边界、脚手架(git/pyproject/Makefile/conda 环境/CI 绿) | — | ✅ 完成(2026-07-20) |
|
||||||
| M1 核心 | 内核类型 + httpx transport + 治理中间件(内存后端)+ 多源多账号 + 缓存 + SQLite 遥测 + 结构化输出 + from_env | GovDoc、Video-Tree | ✅ 完成(2026-07-20;239 测试全绿、覆盖 92%、真实网关+真实 Redis 验收通过、独立 verifier 问题清零;见 designs/2026-07-20-m1-core-design.md 状态行) |
|
| M1 核心 | 内核类型 + httpx transport + 治理中间件(内存后端)+ 多源多账号 + 缓存 + SQLite 遥测 + 结构化输出 + from_env | GovDoc、Video-Tree | ✅ 完成(2026-07-20;239 测试全绿、覆盖 92%、真实网关+真实 Redis 验收通过、独立 verifier 问题清零;见 designs/2026-07-20-m1-core-design.md 状态行) |
|
||||||
| M2 分布式 | Redis 限流/熔断后端、多源 × Redis 联合验证、背压、Postgres 遥测、成本、Embedding(Q3)、压测 harness | CHSAnalyzer(治理) | ✅ 完成(2026-07-21;契约双后端全绿 + 时间语义真实等待变体 10/10、跨连接联合验证、CHS 对标十项打钩、P3 真实验收 300 次双进程七不变量 PASS、独立 verifier 0 Critical 且 Important 清零;见 designs/2026-07-20-m2-distributed-design.md 与 findings/m2-verifier-fixes.md。压测收官: P5 故障混编 40 次八不变量 PASS,P6 首跑 58.1% 暴露三大治理盲区(findings/2026-07-21-p6-soak-baseline.md)→ **M2.5 治理韧性**六轮迭代(双通道熔断/健康选源/AIMD/429 免预算/健康门槛降权/连败抑制)后同场景 **98.96%**、八不变量全 PASS,验收见 findings/2026-07-21-m25-acceptance.md 与 designs/2026-07-21-m25-resilience-design.md) |
|
| M2 分布式 | Redis 限流/熔断后端、多源 × Redis 联合验证、背压、Postgres 遥测、成本、Embedding(Q3)、压测 harness | CHSAnalyzer(治理) | ✅ 完成(2026-07-21;契约双后端全绿 + 时间语义真实等待变体 10/10、跨连接联合验证、CHS 对标十项打钩、P3 真实验收 300 次双进程七不变量 PASS、独立 verifier 0 Critical 且 Important 清零;见 designs/2026-07-20-m2-distributed-design.md 与 findings/m2-verifier-fixes.md。压测收官: P5 故障混编 40 次八不变量 PASS,P6 首跑 58.1% 暴露三大治理盲区(findings/2026-07-21-p6-soak-baseline.md)→ **M2.5 治理韧性**六轮迭代(双通道熔断/健康选源/AIMD/429 免预算/健康门槛降权/连败抑制)后同场景 **98.96%**、八不变量全 PASS,验收见 findings/2026-07-21-m25-acceptance.md 与 designs/2026-07-21-m25-resilience-design.md) |
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
**内部开发顺序**(编号即依赖顺序,同号可并行):
|
**内部开发顺序**(编号即依赖顺序,同号可并行):
|
||||||
|
|
||||||
| 步 | 内容 | 顺序理由 |
|
| 步 | 内容 | 顺序理由 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
|
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
|
||||||
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
|
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
|
||||||
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) |
|
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) |
|
||||||
@@ -72,9 +72,13 @@
|
|||||||
|
|
||||||
**已交付增补(2026-09-14)**: 流式中段沉默计量与对冲(issue #27/#28,1.3.8)——`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部;1.3.7 墙钟口径由 `generation_ms + stall_ms` 还原,披露见 CHANGELOG 未发布段),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加同两列(INSERT 字段 36→38);`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立于 `HEDGE__AFTER_S` 生效、默认关闭;无业界公开先例,超业界现状能力),`{SCOPE}__STREAM__STALL_THRESHOLD_MS` 计量阈值(默认 1000,三层可调)。设计 designs/2026-09-11-streaming-stall-design.md(人类已批准 + 独立审查补遗 B1-B4/N1-N9),实施 plans/2026-09-11-streaming-stall.md,验证 findings/2026-09-11-streaming-stall-validation.md。后续储备:自适应分位阈值(防反馈环,现不做)、流式透传下的 commit 点重审、embedding/OCR 对冲(现不做)。
|
**已交付增补(2026-09-14)**: 流式中段沉默计量与对冲(issue #27/#28,1.3.8)——`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部;1.3.7 墙钟口径由 `generation_ms + stall_ms` 还原,披露见 CHANGELOG 未发布段),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加同两列(INSERT 字段 36→38);`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立于 `HEDGE__AFTER_S` 生效、默认关闭;无业界公开先例,超业界现状能力),`{SCOPE}__STREAM__STALL_THRESHOLD_MS` 计量阈值(默认 1000,三层可调)。设计 designs/2026-09-11-streaming-stall-design.md(人类已批准 + 独立审查补遗 B1-B4/N1-N9),实施 plans/2026-09-11-streaming-stall.md,验证 findings/2026-09-11-streaming-stall-validation.md。后续储备:自适应分位阈值(防反馈环,现不做)、流式透传下的 commit 点重审、embedding/OCR 对冲(现不做)。
|
||||||
|
|
||||||
|
**已交付增补(2026-10-22,1.3.11)**: OCR 整份文档解析(issue #34,1.3.11)——`OcrClient.parse_document` + `OcrDocumentPort`/`OcrDocumentTransport`,PDF 上传按内容标注(同时修复 `recognize_text(PDF)` 的静默幻觉文本),结果只取自 `_middle.json` + 配对 `.md`;新路径对确定性坏输入归"请求被拒"。后续: #35(共享分类)、#36(`recognize_text` 不校验 `success`)、#37(`parse_layout` 原生异常漏出与 `download_url` 主机)。设计/计划见 `designs/2026-10-21-issue34-ocr-document-design.md`、`plans/2026-10-21-issue34-ocr-document.md`。
|
||||||
|
|
||||||
|
**已交付增补(2026-10-15,1.3.10 已发布)**: 统一完成判定(issue #30/#31,1.3.10)——以上游 `finish_reason` 与传输完整性判定流完成与结局,流式/非流式/缓存读写共用内核纯函数;缺 `[DONE]` 但 finish+实测 usage 完整即成功(两模式),文本 length/过滤无正文改 `ResultInvalidError`,保真模式 null/未知字段无损合并;`LLMResponse` 增 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。设计 designs/2026-10-14-issue30-31-completion-verdict-design.md(人类授权 Claude 与独立 subagent 两轮审查后直接执行),实施 plans/2026-10-14-issue30-31-completion-verdict.md。后续储备:文本 n>1 混流修复、文本"只有思考+stop"证据收集。
|
||||||
|
|
||||||
## 7. 开放决策依赖
|
## 7. 开放决策依赖
|
||||||
|
|
||||||
| 决策 | 阻塞点 | 需拍板时间 |
|
| 决策 | 阻塞点 | 需拍板时间 |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| Q1 打包分发(git+ssh vs 内网 index) | 不阻塞开发,阻塞 M4 发布 | M4 前 |
|
| Q1 打包分发(git+ssh vs 内网 index) | 不阻塞开发,阻塞 M4 发布 | M4 前 |
|
||||||
| Q3 Embedding 是否纳入 | ~~影响 M2 范围~~ **已拍板纳入 M2**(2026-07-20) | ✅ 已决 |
|
| Q3 Embedding 是否纳入 | ~~影响 M2 范围~~ **已拍板纳入 M2**(2026-07-20) | ✅ 已决 |
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class OcrTransport(Protocol): # 协议细节;不含治理
|
|||||||
| 关切 | 决策 | 蓝本出处 |
|
| 关切 | 决策 | 蓝本出处 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| HTTP 客户端 | per-source `httpx.AsyncClient`(惰性建,keyed by source.name),`trust_env=source.trust_env`、`timeout=source.timeout_s` | **R9 销账**: SourceConfig.trust_env + `TRUST_ENV` 配置键 M1 已落地,openai_compat.py:203 已尊重,本 transport 同款;VT LAN 直连绕代理场景配 `OCR__MONKEY__N__TRUST_ENV=false` |
|
| HTTP 客户端 | per-source `httpx.AsyncClient`(惰性建,keyed by source.name),`trust_env=source.trust_env`、`timeout=source.timeout_s` | **R9 销账**: SourceConfig.trust_env + `TRUST_ENV` 配置键 M1 已落地,openai_compat.py:203 已尊重,本 transport 同款;VT LAN 直连绕代理场景配 `OCR__MONKEY__N__TRUST_ENV=false` |
|
||||||
| multipart 上传 | `files={"file": ("image.jpg", image, "image/jpeg")}` 固定名(CHS invokers.py:496 原样;服务按内容处理,35 样本实测通过,核验输出存档 `data/soak/m3_xcheck_results.txt`) | CHS |
|
| multipart 上传 | `files={"file": ("image.jpg", image, "image/jpeg")}` 固定名(CHS invokers.py:496 原样;服务按内容处理,35 样本实测通过,核验输出存档 `data/soak/m3_xcheck_results.txt`)。**勘误(2026-10-22,#34)**: "服务按内容处理"只在图片样本上验证过,**不适用于 PDF**——服务按文件名扩展名分派(`.pdf`/`.jpg`/`.jpeg`/`.png`),PDF 标 `image.jpg` 时 `/ocr/text` 返回 `success:true` 的幻觉文本;上传标签现按内容判定,见 `2026-10-21-issue34-ocr-document-design.md` §1 E3/E4/E10 | CHS |
|
||||||
| `/ocr/text` 解析 | 2xx + JSON + `content` 必须为 str,缺失/非 str → `ResultInvalidError`;空串合法 | VT(`.get("content","")` 的静默兜底**有意替换**为显式校验——P5 严禁默认值掩盖错误) |
|
| `/ocr/text` 解析 | 2xx + JSON + `content` 必须为 str,缺失/非 str → `ResultInvalidError`;空串合法 | VT(`.get("content","")` 的静默兜底**有意替换**为显式校验——P5 严禁默认值掩盖错误) |
|
||||||
| `/parse` 两段协议 | POST `/parse` → 校验 `success is True` + `download_url` 非空 str → GET(相对路径 urljoin base_url,绝对 URL 原样)→ ZIP | CHS invokers.py:489-530 逐段保真;相对路径显式化(取证 §1.2) |
|
| `/parse` 两段协议 | POST `/parse` → 校验 `success is True` + `download_url` 非空 str → GET(相对路径 urljoin base_url,绝对 URL 原样)→ ZIP | CHS invokers.py:489-530 逐段保真;相对路径显式化(取证 §1.2) |
|
||||||
| `_middle.json` 校验 | ZIP 内首个 `*_middle.json`;`pdf_info` 必须 list;每页: dict、`page_size` 为 2 正有限数、`para_blocks` 缺省按空列表、每块 type 为 str、bbox 为 4 有限数且 x2>x1、y2>y1 且**整数化后不退化**(库返回 float 原生 bbox,此校验专为 CHS shim 的 `int()` 裁剪路径兜底——亚像素宽的退化框裁剪即空图,实现中须注释此由来防止被当死代码删除) | CHS `_finite_number`/`_parse_table_result`(invokers.py:427-479)数值防御**全量下沉**,提取面从"首表"泛化为"全元素"(取证 §1.2 证明无损) |
|
| `_middle.json` 校验 | ZIP 内首个 `*_middle.json`;`pdf_info` 必须 list;每页: dict、`page_size` 为 2 正有限数、`para_blocks` 缺省按空列表、每块 type 为 str、bbox 为 4 有限数且 x2>x1、y2>y1 且**整数化后不退化**(库返回 float 原生 bbox,此校验专为 CHS shim 的 `int()` 裁剪路径兜底——亚像素宽的退化框裁剪即空图,实现中须注释此由来防止被当死代码删除) | CHS `_finite_number`/`_parse_table_result`(invokers.py:427-479)数值防御**全量下沉**,提取面从"首表"泛化为"全元素"(取证 §1.2 证明无损) |
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
title: OpenAI消息保真设计
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# #29 OpenAI 消息保真设计
|
||||||
|
|
||||||
|
> **修订指针(2026-10-14)**: §4.1"未知 delta 拒绝"、§4.2"reasoning-only 不算成功"与"缺 DONE 一律失败"、§5 活性口径、§6 缓存判据已由 [统一完成判定设计](2026-10-14-issue30-31-completion-verdict-design.md)(1.3.10)修订,以后者为准。
|
||||||
|
|
||||||
|
> 状态:方向已获人类批准;计划独立审查 P1/P2 已修订并实施,首轮独立代码审查发现的缓存空产物及测试双向依赖已修复,等待父会话最终复核;按人类最终指定准备 1.3.9,取代未发布的 1.4.0;尚未发布。
|
||||||
|
> 基线:`0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8;日期:2026-10-01。
|
||||||
|
> 需求:[issue #29](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/29)。
|
||||||
|
> 权威约束:ARCHITECTURE §2.3、§5、§6、§7.1–7.8;实现前按本文修订对应架构段落。
|
||||||
|
|
||||||
|
## 1. 批准记录与范围
|
||||||
|
|
||||||
|
用户已明确同意:现有 `chat()` 增加显式 OpenAI 保真模式,`LLMResponse` 增加完整协议结果,复用治理链,修复工具聚合、活性、error 帧和缓存隔离;首版单 choice、不执行工具、不增加 Agent Loop。用户随后确认内部移植代码均为其原创且本人 owner,并明确授权 PolyGateway 使用 Apache-2.0;dissect3 也是其项目。以上不再重复请示。
|
||||||
|
|
||||||
|
Codex 不可用,人类已授权原生 subagent 替代独立审查。具体 API 拼写与防御约束由本设计落实,经独立审查后实施;若审查要求改变已批准边界,才重新讨论。设计阶段已结束;实现验证记录见计划末尾,独立代码审查/发布前禁止宣称 issue 已解决。
|
||||||
|
|
||||||
|
“保真”是受支持 OpenAI Chat Completions 消息字段和值保真,不是 HTTP 字节级透明代理,不覆盖任意供应商增量方言。model、stream、stream_options 仍由库控制;没有逐块回调、工具执行、同步客户端、美元预算或失败用量取证新功能。
|
||||||
|
|
||||||
|
## 2. 源码核验与方案比较
|
||||||
|
|
||||||
|
| 已核实接缝 | 现状与修复目标 |
|
||||||
|
| --- | --- |
|
||||||
|
| `transports/openai_compat.py::_sse_delta` | 只读首 choice;content 早返回丢同帧 reasoning;忽略 tool_calls/error |
|
||||||
|
| `_complete_stream` / `_complete_once` | 纯文本判空;raw 只存 usage;需新增协议快照与 tool-only 成功语义 |
|
||||||
|
| `client.py::chat` / `_build_payload` | messages 已直传,overlay 已合并,无需白名单重写 |
|
||||||
|
| `thinking.py::validate_thinking_raw` | raw-only 已支持;受管与 raw 冲突是既定保护,不得绕过 |
|
||||||
|
| `middleware/retry.py::_build_response` | 唯一响应构造出口,新增字段在此搬运,不新增治理循环 |
|
||||||
|
| `middleware/cache.py` | 已有完整消息/overlay 身份,但嵌套新类型需重建、模式需隔离 |
|
||||||
|
|
||||||
|
| 方案 | 优势 | 代价/结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A:chat 显式模式 + LLMResponse 内嵌协议结果 | 生命周期、取消、缓存、遥测和对冲单一实现;兼容旧投影 | 需定义组合约束和缓存身份;**选定** |
|
||||||
|
| B:新增 chat_completion 入口,复用私有核心 | 接口像 OpenAI | 重复公共签名、输入校验和终态封装,收益不足,否决 |
|
||||||
|
| C:独立 SDK 客户端或代理自聚合 | 局部改动直观 | 无法自动解决响应上浮/缓存/线路错误,复制治理,否决 |
|
||||||
|
|
||||||
|
## 3. 公共契约
|
||||||
|
|
||||||
|
### 3.1 模式与类型
|
||||||
|
|
||||||
|
在 `types.py` 定义并从包根导出 `ResponseMode` 和 `OpenAIChatCompletion`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ResponseMode = Literal["text", "openai"]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenAIChatCompletion:
|
||||||
|
"""单 choice 的协议消息快照;嵌套 JSON 并非深度不可变。"""
|
||||||
|
message: dict[str, Any]
|
||||||
|
finish_reason: str | None
|
||||||
|
response_id: str | None
|
||||||
|
usage: dict[str, Any] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
`GatewayClient.chat` 末尾追加 keyword-only `response_mode: ResponseMode = "text"`;`ChatRequest` 末尾同字段同默认。`TransportResult`、`LLMResponse` 末尾追加 `completion: OpenAIChatCompletion | None = None`,已有字段不改名、不改位置。保真模式成功时 completion 必非 None;文本模式不承诺提供 completion。
|
||||||
|
|
||||||
|
`Transport.complete` 追加必填 keyword-only `response_mode: ResponseMode`,依照现有端口纪律不使用默认值掩盖漏传。默认 transport 实现和测试 transport 一并适配;不新增 SDK 适配器(当前文件树尚无该实现)。自定义 transport 须实现新参数与返回契约,不支持模式时抛 RequestRejectedError,不静默回退。
|
||||||
|
|
||||||
|
completion 是独立于上游容器的深拷贝 JSON 快照;frozen 只保证字段不能重绑,不伪称深度不可变。调用方可用 `copy.deepcopy(response.completion.message)` 构造下一轮;其修改不得污染别次调用、缓存条目或另一路对冲。不上 MappingProxyType,避免与既有 dataclasses.asdict/JSON 序列化冲突。
|
||||||
|
|
||||||
|
### 3.2 请求与组合校验
|
||||||
|
|
||||||
|
| 条件 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 未知模式 | 公共入口 ValueError,尚未创建逻辑调用上下文 |
|
||||||
|
| openai + structured 非 None | 公共入口 ValueError;不注入 schema、不自动重问;overlay 的 response_format 仍可直传 |
|
||||||
|
| openai + n | 仅允许缺省或严格整数 1(不接受 bool);调用 overlay 在入口校验,选中源 extra_body 及最终 payload 在 HTTP 前校验,不接受被 overlay 遮蔽的非法源级 n |
|
||||||
|
| text + n | 保留旧行为,本次不扩大旧文本多 choice 语义;明确只投影第一 choice |
|
||||||
|
| 消息 | 保留 assistant/tool/reasoning_content/扩展字段,JSON 值不改写;保真入口校验可序列化并深拷贝,避免并发/重试期间调用方改动 |
|
||||||
|
| overlay | 复用既有保护键、源级→请求级优先级;保真调用对快照作深拷贝,不原地改调用方容器 |
|
||||||
|
|
||||||
|
源级 n 错误属选源后的请求拒绝,抛 RequestRejectedError,finally 结算规则不变。保真响应必须恰好一个 choice;SSE choice.index 必须为整数 0,usage-only choices=[] 合法;非流式一个 choice 可省略 index,存在时必须为 0。上游返回多 choice 是协议失败 TransientError,不能静默截取。
|
||||||
|
|
||||||
|
### 3.3 推理所有权不随返回模式变化
|
||||||
|
|
||||||
|
复用 `effective_effort`、`resolve_thinking`、`validate_thinking_raw`,不改能力表与档位映射。raw-only 示例前提:源级 enable_thinking=None、reasoning_effort=None,请求级 reasoning_effort=None,然后 overlay 传 `{"reasoning_effort": "max"}`。`chat(reasoning_effort=None)` 是继承源,不是撤销源意图;raw 与任何有效受管意图(含 NONE)并存仍拒绝。raw-only 的 applied_effort 仍 None,不编造受管档位。
|
||||||
|
|
||||||
|
### 3.4 兼容投影与上游身份
|
||||||
|
|
||||||
|
完整协议 `message` 不过 `_finalize_text`,保留 content 的缺省/null/空串区别、空白、think 标签;非流式保留整个 message 的 JSON 扩展字段。`LLMResponse.content/thinking` 仍是旧 `_finalize_text` 投影,content 为 null/缺省时投影空串;只用于旧消费面和现有遥测,代理回传以 completion 为准。
|
||||||
|
|
||||||
|
response_id 是上游 id,允许未上报为 None,绝不拿 call_id 补齐;finish_reason 原样保存,缺失为 None,不从工具存在与否猜测;usage 保存原对象快照(最后一个非 null usage),不因 token 归一或 salvage 修改原文。字段存在但类型不符是协议失败。`length` 原样返回,不修复工具参数;下游决定是否执行。
|
||||||
|
|
||||||
|
## 4. 协议聚合与完成规则
|
||||||
|
|
||||||
|
新增内部模块 `transports/_openai_completion.py`,只处理 chat 单 choice 协议校验、每尝试聚合与快照;不做 I/O、重试、计时、provider 推理或缓存。现有 openai_compat 保留 HTTP、看门狗、投影、usage 归一化与领域上下文。
|
||||||
|
|
||||||
|
### 4.1 SSE 聚合状态
|
||||||
|
|
||||||
|
每次 complete 独立创建聚合器,不放 transport 实例共享状态。逐 JSON 帧先校验对象并查 error,再读取 choices/delta、元信息和 usage;坏类型统一转 TransientError,不让 AttributeError/TypeError 逃出治理。
|
||||||
|
|
||||||
|
| 字段 | 合并规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| content / reasoning_content | 非 null 必为 str,逐片拼接;同帧同时收集;不 trim;从未出现则不造键,仅 null 则保留 null |
|
||||||
|
| role | openai 模式若出现必须 assistant;只出现 role 不算活性;最终成功必须含 assistant role;缺失失败,不伪造字段 |
|
||||||
|
| tool_calls | 必为列表;每项 index 必为非负严格整数;按 index 分桶,末尾按 index 排序,索引不输出到最终工具对象 |
|
||||||
|
| id / type | 每工具的身份字段,缺失片段不覆盖;相同重复幂等,冲突拒绝;不把重复完整 id 拼两次 |
|
||||||
|
| function.name / function.arguments | 字符串增量按到达顺序逐字拼接,允许空片段;arguments 不 JSON 解析/修复/重排 |
|
||||||
|
| response id / finish_reason | 首个非 null 固定,后续相同值允许、冲突拒绝;model_reported 沿用既有首有效值口径 |
|
||||||
|
|
||||||
|
最终有效工具需非空 id、type="function"、非空 function.name,以及至少出现一次字符串 arguments(允许空串);重复工具 id、缺字段、非 function 工具类型都拒绝,不伪造或丢弃。index 可非连续,按排序输出,不按列表位置猜 index。JSON 参数内容及工具业务 schema 不由库验证。
|
||||||
|
|
||||||
|
SSE、非流式及缓存最终快照均要求 role="assistant";缺失或其他 role 为协议失败/缓存 miss;合法 role-only 开头不算活性。
|
||||||
|
|
||||||
|
非流式工具是完整列表:保留条目及扩展字段,仅校验上述必要字段/类型/唯一 id,不要求 index;存在的扩展字段不删除。SSE 保真仅支持 role/content/reasoning_content/tool_calls;未知 delta 字段或未知工具增量字段无法推定合并语义,明确拒绝为协议失败,避免宣称任意扩展字段无损;元数据如 logprobs 不属于本协议结果字段,文档明确不返回。
|
||||||
|
|
||||||
|
### 4.2 成功、截断与错误
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| openai:空正文 + 有完整工具 | 成功,不触发空补全重试 |
|
||||||
|
| openai:非空投影正文、无工具 | 成功;存在任何不完整工具则仍拒绝,不用正文掩盖坏工具 |
|
||||||
|
| openai:无正文且无有效工具 | 维持 empty_completion 瞬时失败;reasoning-only 不算成功 |
|
||||||
|
| openai:EOF 无 DONE | 无论 missing_done=salvage、是否已有正文/工具/finish,都失败;不打捞潜在可执行结果 |
|
||||||
|
| text:EOF 无 DONE | 保持已有 early_eof/retry/salvage 及 estimated/unavailable 行为 |
|
||||||
|
| 任一 chat 模式出现顶层 error 键 | 立即 TransientError,即使值 null/空对象;不读取后续 usage/DONE 挽救,不缓存 |
|
||||||
|
| 非流式 200 error / 非 JSON / 坏 envelope | TransientError;HTTP 非 200 分类沿用现有规则 |
|
||||||
|
|
||||||
|
text 仍不接收 tool-only 成功,不承诺工具结果;共用解析路径的 error 检查、同帧双文本采集和真实工具进展活性修复适用于两模式。纯文本消费不新增工具完整性要求,以免把未承诺的工具结果变成默认请求失败。
|
||||||
|
|
||||||
|
## 5. 活性、治理、取消和可观测
|
||||||
|
|
||||||
|
一帧有非空正文、非空推理、非空工具 name/arguments 增量或首次建立工具身份时,产生**一次**进展事件。重复相同身份、空 arguments、空 delta、role-only、usage-only、finish-only、注释不刷新活性。同帧多个字段和两个工具仍只记录一个时间戳,避免制造零间隔或虚增 stall_count。
|
||||||
|
|
||||||
|
`stream_with_liveness_timeouts` 继续包进展迭代器;openai_compat 用同一事件更新时间戳、TTFT、max_gap、first_token_event、content_event。保持 `_stall_from_chunk_times` 与 retry 唯一取整点不变;真正沉默照常触发中段对冲。非流式不置两事件,stall=None,只保留纯时长阈值对冲。
|
||||||
|
|
||||||
|
请求经原 Telemetry→Cache→Structured(None 直通)→Retry→准入→Transport 链。重试、异源对冲、失败预算、源健康、限流结算均原样复用;输家取消仍保守 est 结算、不喂熔断,赢家携自身完整快照。CancelledError 全栈穿透,连接与 permit finally 释放,无新的 shield 或后台任务。
|
||||||
|
|
||||||
|
遵循 structured-logging:不新增数据库列/表/Recorder 参数。`_build_response` 搬运 completion;错误由 transport 抛出,经过既有 RetryMW._emit→TelemetryEmitter 单出口记 attempt/terminal 行。SSE error 使用现有 summarize_body/compose_message 有界摘要,携 source_name/operation=chat,最多记录有界错误体,不在日志新增完整工具参数、密钥或成功消息。
|
||||||
|
|
||||||
|
现有 `llm_calls` schema(`research-wiki/schemas/llm-calls.md`)及覆盖指标复用。确定性验收指标:error→成功恰好 attempts=2;tool-only attempts=1;命中 attempts=0;失败不写缓存;无孤儿 permit/task。失败 usage 仍 unavailable,费用仍未知,不删除失败事件;不扩展遥测全文覆盖,tool_calls.arguments 不受现有 TEXT_CAP 截断的已知边界须继续声明。
|
||||||
|
|
||||||
|
## 6. 缓存语义
|
||||||
|
|
||||||
|
`build_cache_key` 追加 `response_mode: ResponseMode = "text"`;仅非 text 时将 response_mode 写入 key 对象。默认文本 key 逐字不变;这是请求语义维度,不引入包版本/revision。tools、tool_choice、raw effort 已在 sampling/source fingerprint;assistant/tool 消息全字段已在 digest_messages,直接复用。
|
||||||
|
|
||||||
|
CacheMW 显式序列化和重建 OpenAIChatCompletion,读时验证容器、单消息/工具必要字段与元信息类型。openai 条目 completion 缺失/null/畸形或无有效产物视 miss+warning,不能补默认伪造协议成功;text 旧条目允许缺新字段。transport 与缓存共用 types 内部纯值判据:完整工具或非空投影正文;无工具时还要求协议原正文非空,不能靠缓存旧投影伪装成功;不收紧快照构造用途。错误条目回源成功后覆写,失败不写。
|
||||||
|
|
||||||
|
缓存中的 message/usage 与调用结果独立;命中保留历史上游 id/finish/usage,生成本次 call_id、清除历史 call_stats,最终返回本次 attempts=0;cost=0 延续原口径。缓存读写和序列化失败仅 warning,不冒泡,取消例外穿透;将 `_serialize` 异常纳入缓存降级边界,避免新增嵌套字段扩大失败面。
|
||||||
|
|
||||||
|
旧文本缓存可能已被 error+DONE 毒化,不能自动判别。发布文档要求受影响调用集合首次读写前迁移 namespace/salt,保留租户前缀与 epoch;覆盖工厂和 per-call 身份。未迁移仍会回放旧值,不能宣称修复自动清除历史缓存;openai 新身份天然不命中旧文本。
|
||||||
|
|
||||||
|
## 7. 旧行为审计与非功能约束
|
||||||
|
|
||||||
|
| 既有行为 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 旧字段/默认文本/structured 阶梯/受管推理 | 保留;默认文本同帧漏 reasoning 属修复 |
|
||||||
|
| HTTP 分类、retry/backoff、两类对冲、TPM/熔断 | 保留,不改算法与预算 |
|
||||||
|
| 空补全、missing_done salvage | 文本保留;openai tool-only 放行、缺 DONE 强制失败 |
|
||||||
|
| Redis TTL、命名空间、遥测幂等与单 helper | 保留;新增模式隔离与嵌套协议结果回放 |
|
||||||
|
| 并发与取消 | 每尝试状态隔离,重试/对冲不得复用聚合器,CancelledError 穿透 |
|
||||||
|
| 降级方向 | 缓存/遥测 warning;准入后端 fail-closed;不新增后端 |
|
||||||
|
| 持久化/原子性/恢复 | 仅成功后一次写完整缓存值;沿用后端原子 set;无新的任务持久化或断点状态 |
|
||||||
|
| 幂等与重复 | 重试/对冲可能产生重复上游计费,库不执行工具,因此不产生本地工具副作用;不承诺上游生成幂等 |
|
||||||
|
|
||||||
|
## 8. 测试、许可证与交付门
|
||||||
|
|
||||||
|
单元测试复用 MockTransport/SSE/受控事件设施;新增两工具交错分片、同帧 content+reasoning、原样下一轮、raw=max、stream=False、缺 DONE、安全判错、缓存和治理组合。每个行为组须保留基线红、新实现绿日志;不以本阶段基线通过冒充新功能证据。
|
||||||
|
|
||||||
|
真实 Redis 只测完整协议缓存往返/隔离与取消关联契约,不 mock Lua。真实模型跑专用通用函数工具双轮冒烟(标 slow,输出 Markdown),复用 `.env` 配置与预算,不在源码硬编码凭据;网络/额度失败如实失败,不改成无覆盖成功。不默认重跑全模型推理矩阵;本次不改 thinking 能力算法。
|
||||||
|
|
||||||
|
许可证:采用 Apache-2.0 已授权;实施前核查实际分发文件及注明移植/参考的片段,区分参考协议事实与复制代码。reference/ 只读且不打包;AGPL 仓存在不自动污染本库,也不由授权覆盖第三方权利。发现实际复制的不兼容片段须上报,不擅自改许可证。加入标准 LICENSE 与 pyproject 元数据;只有确有通知义务才加 NOTICE,不捏造第三方来源。
|
||||||
|
|
||||||
|
源码 docstring、README、CHANGELOG、ARCHITECTURE 同步;无新 env 键,`.env.example` 仅补 raw-only 源配置提示(不改默认)。Gitea Wiki 当前下线,不重建过期页面。线程代理文档说明:client 在同一常驻事件循环线程内创建/使用/关闭;工作线程 run_coroutine_threadsafe,等待超时/断连取消 Future;不逐请求 asyncio.run,不跨 loop 共享 client。
|
||||||
|
|
||||||
|
发布由父会话在独立代码审查、全套件、相关 slow、构建包 LICENSE/元数据核验之后执行;沿 CLAUDE 发布九步,外部验证 registry/Release/包页和下游下载,不仅 bump。issue 只有在可安装产物及验收证据齐备后关闭;本阶段不触发这些动作。
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-10-14-issue30-31-completion-verdict-design
|
||||||
|
title: 统一完成判定设计(#30/#31)
|
||||||
|
date: 2026-10-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# #30/#31 统一完成判定设计
|
||||||
|
|
||||||
|
> 状态: 方向已获人类批准(2026-10-14,四项取舍与五个拍板点按推荐);人类授权"Claude 与独立 subagent 确认无误后直接执行",不再走人类设计审阅。两轮独立审查已闭合(R1 3 阻塞 + 9 应修;R2 通过,2 应修已并入),见 §11。
|
||||||
|
> 基线: `22609c9`(1.3.9);目标版本 **1.3.10**;分支 `fix/issue30-31-completion-verdict`。
|
||||||
|
> 需求: [#30](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/30)、[#31](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/31)。
|
||||||
|
> 修订对象: [1.3.9 保真设计](2026-10-01-issue29-openai-fidelity-design.md) §4.1/§4.2/§5/§6、ARCHITECTURE §6.2/§7/§14。
|
||||||
|
|
||||||
|
## 1. 问题与根因
|
||||||
|
|
||||||
|
两条 issue 是**同一根因**: 库判定"生成结束了、结果可交付"时,依赖库自己的推断(`[DONE]` 存在、正文非空、字段在白名单内、快照字段类型),而不是上游的权威声明(`finish_reason`)与传输完整性(干净 EOF + 结尾 usage)。推断失败一律落 `TransientError` → 重试/换源/计熔断,把确定性结局放大成 `max_attempts` 次付费重抽并推高健康源的熔断计数。
|
||||||
|
|
||||||
|
| 表现 | 位置(1.3.9) | 后果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| #31 缺 `[DONE]` 但 finish+usage 完整 | `openai_compat.py:668-671`(保真无条件拒);`_check_done:714`(文本 `missing_done=retry` 默认同样拒) | magpie/glm 两模式均重试到耗尽;实测 1216s 挂起、dissect3 38 次终败 |
|
||||||
|
| #30-1 reasoning-only + `length` | `types.py:478-490`、`_reject_empty_completion` | 每次写满 max_tokens 后丢弃重抽 |
|
||||||
|
| #30-2 未知键 / `tool_calls:null` | `_openai_completion.py:97-98,112-114,139-140,146-147`;**非流式也中**: `OpenAIChatCompletion` 拒 `tool_calls: None`(`types.py:451-453`,已实测) | 渠道一换 100% 失败,且以"瞬时"形态出现 |
|
||||||
|
| 隐患: 只有 `reasoning` 字段的流 | 活性只认 content/reasoning_content | 两模式被看门狗当无进展 → TTFT 超时 |
|
||||||
|
| 隐患: 文本 salvage 的截断结果被缓存 | `cache.py:197-204` 写路径无判定 | 半截结果 TTL 内反复回放 |
|
||||||
|
|
||||||
|
补充事实: GLM 的 finish_reason 含 `network_error`/`sensitive`;2026-07-20 空补全裁决针对的是 stop/无 finish 的"零增量"抖动(`findings/2026-07-21-m25-acceptance.md:48`);vLLM 等实现每帧带累计 usage,finish 之前的 usage 不证明走完。
|
||||||
|
|
||||||
|
## 2. 方案比较
|
||||||
|
|
||||||
|
| 方案 | 内容 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A 局部放行 | 按 issue 字面在三处各加例外 | 否决: 流式/非流式/缓存判据继续分叉;文本 #31 残留;`network_error` 被当成功 |
|
||||||
|
| B 统一完成判定(选定) | 内核纯函数分两步(流完整性 → 结局分类),流式/非流式/缓存读写四处共用;"可交付"与"可缓存"拆两判据;未知字段无损合并 | 一次消除根因;代价是公共字段追加与遥测端口加参 |
|
||||||
|
| C 透明透传 | 保真模式不做结局判定,原样交回 | 否决: 零增量抖动回归(违背 2026-07-20 裁决),截断流也被交出 |
|
||||||
|
|
||||||
|
## 3. 公共契约变更(只增不改)
|
||||||
|
|
||||||
|
| 位置 | 变更 |
|
||||||
|
| --- | --- |
|
||||||
|
| `types.py` | 新增 `StreamEnd = Literal["done", "eof_after_finish", "salvaged"]`,包根导出 |
|
||||||
|
| `TransportResult` / `LLMResponse` 末尾 | 追加 `finish_reason: str \| None = None`、`stream_end: StreamEnd \| None = None` |
|
||||||
|
| `OpenAIChatCompletion` 校验 | `tool_calls: null` 合法(视同无工具,原样保留键) |
|
||||||
|
| `TelemetryRecorder.record_llm_call` | 追加**无默认值** keyword-only `stream_end: str \| None`(**破坏性**: 自建 recorder 装配期被形状闸拒绝,CHANGELOG 单列) |
|
||||||
|
| `llm_calls` 表 | 末尾追加可空列 `stream_end TEXT`(落点见 §7) |
|
||||||
|
| `Transport.complete` 签名 | **不变** |
|
||||||
|
|
||||||
|
`finish_reason` 取值: 流式取 choice 0(保真已强制单 choice;文本取 `index` 缺省或为 0 的 choice)首个非 null 值;非流式取 `choices[0].finish_reason`。保真模式非字符串维持 1.3.9 拒绝(Transient);文本模式非字符串记 None。`finish_reason` 不进遥测列(未获批)。
|
||||||
|
|
||||||
|
| 场景 | `stream_end` |
|
||||||
|
| --- | --- |
|
||||||
|
| 流式收到 `[DONE]` | `"done"` |
|
||||||
|
| 干净 EOF + finish + 结尾 measured usage | `"eof_after_finish"` |
|
||||||
|
| 文本 `missing_done=salvage` 打捞 | `"salvaged"` |
|
||||||
|
| 非流式 / 缓存命中 / 失败行 | `None`(与 `ttft_ms`/`stall_ms` 同"无从观测"口径) |
|
||||||
|
|
||||||
|
与 `usage_source` 分工: 后者答"账单可信吗",前者答"内容完整吗"。`eof_after_finish ⇒ measured`(由 §4.2 条件保证);`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。
|
||||||
|
|
||||||
|
## 4. 统一完成判定
|
||||||
|
|
||||||
|
### 4.1 位置与形态
|
||||||
|
|
||||||
|
全部放 `types.py` 内部纯函数(下划线前缀,不导出): CacheMW 要复用,import-linter 禁止 middleware 依赖 transports,而内核层 `ports : types : errors : streaming` 允许。函数只返回判定值,**不抛领域错误**(错误需 `source_name` 等上下文,由调用点翻译),不捕获任何异常,取消天然穿透。
|
||||||
|
|
||||||
|
| 函数 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `_tool_snapshot_ok(message)` | 工具快照是否成立(id/type/function.name/arguments 规则 + 唯一 id);`OpenAIChatCompletion.__post_init__` 复用 |
|
||||||
|
| `_classify_finish(value)` | 映射到 finish 类别(下表) |
|
||||||
|
| `_stream_is_complete(...)` | 第一步,返回 `StreamEnd \| None`(None = 截断) |
|
||||||
|
| `_judge_outcome(...)` | 第二步,返回 `deliver` 或 `(reject_transient \| reject_result, 原因码)` |
|
||||||
|
| `_is_cacheable(...)` | §6 判据 |
|
||||||
|
|
||||||
|
| finish 类别 | 值(大小写敏感) |
|
||||||
|
| --- | --- |
|
||||||
|
| natural | `stop`、`tool_calls`、`function_call` |
|
||||||
|
| truncated | `length` |
|
||||||
|
| filtered | `content_filter`、`sensitive` |
|
||||||
|
| upstream_error | `network_error`、`error` |
|
||||||
|
| unknown | 其余非空字符串: 按 natural 交付、**不可缓存**、按源+值告警一次(文案写明"该值不缓存",便于下游定位命中率下降);`end_turn`/`stop_sequence` 等无证据不预先归类 |
|
||||||
|
| absent | None |
|
||||||
|
|
||||||
|
### 4.2 第一步: 流完整性(仅流式)
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 读取异常 / 活性超时 / error 帧 / 畸形帧 | 照旧 Transient(不动) |
|
||||||
|
| 收到 `[DONE]` | `done` |
|
||||||
|
| 干净 EOF、无 DONE、finish 已见、finish 帧**同帧或之后**收到的 usage 被 `_resolve_usage` 判为 measured | `eof_after_finish`,usage 记 measured(不降级) |
|
||||||
|
| 干净 EOF、其余情形 | 截断: openai → Transient `missing_done`(仍不打捞);text → 原 `_check_done` 语义(零产物 early_eof、`retry` 抛、`salvage` 记 `salvaged`) |
|
||||||
|
|
||||||
|
聚合器每尝试记录 `finish_seen` 与 `usage_after_finish`: 收到非 null usage 时,若 `finish_seen` 已真或同帧带 finish,则置后者为真;是否 measured 以**最后一个**非 null usage(与计费所用 `sink["usage"]` 同一对象)在流结束时判定;usage-only 帧(`choices: []`)在 finish 之后到达是常规形态,正确置位。状态随重试/对冲每尝试新建。
|
||||||
|
|
||||||
|
诚实边界: 中转站在上游出错后主动正常结束响应,与真完整在传输层不可区分;防线是"finish 必须存在、不属 upstream_error、且其后有 measured usage",文档如实写明。
|
||||||
|
|
||||||
|
### 4.3 第二步: 结局分类
|
||||||
|
|
||||||
|
产物定义——**保真模式**: 任何产物 = 消息中任一非空字符串片段(content、reasoning_content、reasoning、refusal、合并保留的未知字符串,空白也算)或至少一个非 null 工具条目;**文本模式**: 任何产物 = 投影 content 或 thinking(含 §4.4 别名)非空串(含空白),未知字段与工具增量只计活性不计产物,`_check_done` 零产物判据同口径。两模式**正文产物** = 投影 content strip 后非空,或(仅保真)工具快照成立且非空。
|
||||||
|
|
||||||
|
表格**自上而下取首个匹配行**:
|
||||||
|
|
||||||
|
| # | 条件 | openai 模式 | text 模式 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | finish 为 upstream_error | Transient `upstream_finish_error` | 同左 |
|
||||||
|
| 2 | (仅保真)存在工具条目但 `_tool_snapshot_ok` 不成立 | truncated → ResultInvalid `tool_truncated`;其余 → Transient `tool_incomplete` | 不适用 |
|
||||||
|
| 3 | 无任何产物 + truncated/filtered | 交付(#30;隐藏推理模型写满预算的形态) | ResultInvalid `length_without_content` / `filtered_without_content` |
|
||||||
|
| 4 | 无任何产物 + natural/unknown/absent | Transient `empty_completion`(保 2026-07-20 裁决) | 同左 |
|
||||||
|
| 5 | 有正文产物 | 交付 | 交付 |
|
||||||
|
| 6 | 有产物无正文 + truncated | 交付(#30) | ResultInvalid `length_without_content` |
|
||||||
|
| 7 | 有产物无正文 + filtered | 交付 | ResultInvalid `filtered_without_content` |
|
||||||
|
| 8 | 有产物无正文 + natural/unknown | 交付 | Transient `empty_completion`(待证据;消息带 finish 与 thinking 长度) |
|
||||||
|
| 9 | 有产物无正文 + absent(流式仅 DONE 收尾,或非流式 body finish 为 null) | Transient `empty_completion` | 同左 |
|
||||||
|
|
||||||
|
ResultInvalid 理由: `retry.py:409-414` 对其"不重试、不换源、熔断记成功不计窗口";length/过滤是预算或内容策略的确定性结局,不是源坏了。对冲下原路 ResultInvalid 等对冲路裁定,两败原样上抛(`retry.py:548-560`);StructuredMW 只捕 parse 抛出的 ResultInvalid,不会误重问(`structured.py:79`)。已知代价: 该分支 TPM 全额退款、用量不落成本(`actual=0`),与 Transient 丢成本同级,登记为已知边界。文本 length 改抛 ResultInvalid 后不再经 `AllSourcesExhausted` 包装,§10 要求核对下游 catch 点。
|
||||||
|
|
||||||
|
原因码以固定格式 `"{源} {code}: …"` 写进异常 message,从而进遥测 `error` 列(LIKE 可检索);不新增列、不改 `_failure_reason`(Transient 一族熔断原因记 `network_error` 属已知口径)。
|
||||||
|
|
||||||
|
保真模式行 2 之外的工具快照成立 + `length`: 交付,arguments 可能是半截 JSON——docstring 与 CHANGELOG 提醒调用方先查 `finish_reason` 再执行工具。非流式跳过第一步,直接以 body 的 finish_reason 走第二步。
|
||||||
|
|
||||||
|
### 4.4 文本模式 reasoning 别名
|
||||||
|
|
||||||
|
投影 `thinking`: 本次尝试出现过**非空** `reasoning_content` 则只用它,否则用 `reasoning`。保真 `completion.message` 保留上游原键不改名。两模式活性、产物摘要、`thinking_observation` 均基于该投影。
|
||||||
|
|
||||||
|
## 5. 字段合并与活性
|
||||||
|
|
||||||
|
### 5.1 null 与已知字段
|
||||||
|
|
||||||
|
null 规则**优先于**已知字段类型校验: chunk、choice、delta、工具条目、function 五层任意键值为 null 视为缺省,数组中的 null 元素(如 `tool_calls: [null]`)跳过;键从未出现时保留 `key: null`(含 `tool_calls`、`refusal`、`role`、`function.arguments`)。保真成功仍须最终 role=assistant(全程只有 `role: null` 即缺 role,照旧失败)。其余已知字段(content/reasoning_content/role/tool_calls 及工具 id/type/function.name/arguments)非 null 时语义逐字沿用 1.3.9。
|
||||||
|
|
||||||
|
### 5.2 未知字段(delta、`tool_calls[]` 条目、`function` 三层统一)
|
||||||
|
|
||||||
|
| 值 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标签键 `index`/`type`/`id`/`format`(任意未知层级) | 同值幂等,不拼接;不同值 = 冲突 |
|
||||||
|
| 其他字符串 | 按到达顺序拼接(OpenAI SDK 流式累加惯例) |
|
||||||
|
| 数字/布尔 | 同值幂等;不同值 = 冲突(不学 SDK 相加) |
|
||||||
|
| 对象 | 递归套用本表 |
|
||||||
|
| 数组且元素均为带整数 `index` 的对象 | 按 index 合并(`reasoning_details` 一类);合并策略由该键首次出现形态决定,之后形态改变 = 冲突 |
|
||||||
|
| 其他数组 | 追加 |
|
||||||
|
| 冲突 | 删除该键路径,本尝试内不再接收;按 (源, 归一路径) 告警一次 |
|
||||||
|
|
||||||
|
冲突删键是有损的: docstring 与 CHANGELOG 写明"可能缺字段,以告警为准"。上游方言确定,重拉同样结果,不视为缓存毒化。
|
||||||
|
|
||||||
|
### 5.3 活性
|
||||||
|
|
||||||
|
已知字段活性逐字沿用 1.3.9(非空 content/reasoning_content、工具 name/arguments 非空增量、首次建立工具身份)。未知字段仅当本帧在非标签键上并入非空字符串片段(含追加型数组中的字符串元素)时算进展(`reasoning` 由此计入);同帧只记一次。残余风险如实登记: 上游每帧重复下发的非标签字符串(如 `status`)会被拼接并持续刷新看门狗。文本模式同口径。README 中"增量 chunk = content 或 reasoning_content"的 stall/TTFT 口径表述同步更新为本节口径。
|
||||||
|
|
||||||
|
### 5.4 告警节流
|
||||||
|
|
||||||
|
节流集合放 transport 实例(与 `_warned_mismatches` 分立,纯 asyncio 中立),键为 (源, 归一路径) 与 (源, 未知 finish 值):路径中数组下标归一为 `[]`,finish 值截断至 64 字符;集合上限 1024,饱和后记一次"告警已饱和"不再增长。日志只写源名与路径,不回显值(可能含工具参数)。聚合器以只读集合交出冲突路径,transport 负责告警。
|
||||||
|
|
||||||
|
## 6. 可交付 vs 可缓存
|
||||||
|
|
||||||
|
可缓存 ⊂ 可交付。两模式同一判据 `_is_cacheable`,读写共用:
|
||||||
|
|
||||||
|
| 条件(任一成立即不可缓存) | 理由 |
|
||||||
|
| --- | --- |
|
||||||
|
| `stream_end == "salvaged"` | 截断结果,修现存毒化 |
|
||||||
|
| 无正文产物 | 不把空回答固化进 TTL |
|
||||||
|
| finish 类别为 truncated/filtered/unknown | 截断有随机性,同 key 换时可成功;未知值不可证明是自然结束 |
|
||||||
|
| openai 模式 completion 原文 content 为空且无工具(沿 1.3.9) | 不信任旧投影 |
|
||||||
|
|
||||||
|
| 路径 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 写 | 不可缓存 → 跳过,debug 日志 |
|
||||||
|
| 读 | `_is_cacheable` 在命中覆盖(`cache.py:224` `fields.update`)**之前**、对存储原值判定;不可缓存 → miss + warning |
|
||||||
|
| 读: 非法 `stream_end` | 不在 Literal 域内 → miss + warning(关乎内容完整性,故与 `_coerce_observation` 的宽容方向相反) |
|
||||||
|
| 读: 顶层无 finish_reason | 回退 `completion.finish_reason`(1.3.9 保真 length 条目因此可识别);文本旧条目两字段缺省 → None → 仍可命中 |
|
||||||
|
| 命中返回 | `stream_end=None`;`finish_reason` 取判定所用值(顶层缺省时为回退值),非字符串视 miss |
|
||||||
|
| 序列化 | `_RESPONSE_FIELDS` 由 `dataclasses.fields` 自动派生,无需改动 |
|
||||||
|
|
||||||
|
旧 salvage/length 文本污染无法辨认: CHANGELOG 继续要求受影响集合迁移 namespace/salt。
|
||||||
|
|
||||||
|
## 7. 遥测落点
|
||||||
|
|
||||||
|
| 位置 | 变更 |
|
||||||
|
| --- | --- |
|
||||||
|
| `middleware/telemetry.py` `_AttemptUsage.of(response)` | 读 `response.stream_end`(本次 LLMResponse 有字段,不像 1.3.8 走 `emit_attempt` kw,避免约 50 处调用点) |
|
||||||
|
| `_record` | 新增参数,单一 helper 下沉 recorder |
|
||||||
|
| `emit_cache_hit` / `emit_terminal_failure` / 失败与取消 attempt 行 | 显式 None |
|
||||||
|
| `telemetry/schema.py` | SQLite/PG 两份 CREATE 末尾、`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS`(列序对齐)、`COLUMNS`;`telemetry_schema_sql()` 随之输出 |
|
||||||
|
| sqlite/postgres recorder | 按 `COLUMNS` 写入;auto 档幂等补列、manual 档裁剪写入并点名缺列(沿 1.3.8) |
|
||||||
|
| tests | 约 20 处测试替身 `record_llm_call` 补参数 |
|
||||||
|
| 文档 | README "必录字段数"按 `inspect.signature` 实测更新;`schemas/llm-calls.md` 加列并修正陈旧计数 |
|
||||||
|
|
||||||
|
## 8. 旧行为审计
|
||||||
|
|
||||||
|
| 既有行为 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 保真缺 DONE 一律失败 | **替换**: §4.2 判完整时成功;其余仍失败、仍不打捞 |
|
||||||
|
| 文本 `missing_done=retry` 遇缺 DONE 即抛 | **替换**: 同上;`missing_done` 只管真截断 |
|
||||||
|
| 文本 salvage + estimated | 保留,另标 `salvaged`,不再写缓存 |
|
||||||
|
| 零增量 + DONE → Transient | **保留**(`test_stream_zero_content_with_done_is_transient` 红线回归) |
|
||||||
|
| 保真 reasoning-only/空产物失败 | **替换**: §4.3 行 3/6/7/8 |
|
||||||
|
| 未知 delta/工具/function 字段拒绝;null 已知字段拒绝 | **替换**: §5 |
|
||||||
|
| 非流式 `tool_calls: null` 拒绝 | **替换**: 合法 |
|
||||||
|
| 多 choice/index≠0 拒绝、最终 role 必须 assistant、error 帧立即失败 | 保留 |
|
||||||
|
| 工具快照强校验 | 保留;仅 length 截断时改归 ResultInvalid |
|
||||||
|
| finish_reason 冲突拒绝(Transient) | 保留(登记: 确定性方言,将来可改 ResultInvalid) |
|
||||||
|
| 已知字段活性口径 | 保留;未知字段按 §5.3 增补 |
|
||||||
|
| 缓存写路径无判定 | **替换**: 按 `_is_cacheable` |
|
||||||
|
| 文本 n>1 混流 | 不在范围,另开 issue |
|
||||||
|
|
||||||
|
## 9. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 回答 |
|
||||||
|
| --- | --- |
|
||||||
|
| 并发与取消 | 新状态在每尝试聚合器/sink 内;判定为纯函数,不新增 try/except,`CancelledError` 原路穿透;finally 结算与 permit 释放不变 |
|
||||||
|
| 降级方向 | 缓存跳写/视 miss 只 debug/warning;遥测新列缺失按 manual 档裁剪 + 告警,不冒泡;限流/熔断不涉及 |
|
||||||
|
| 幂等与重复 | 减少重复付费重抽;标签键与身份字段幂等;告警节流有界 |
|
||||||
|
| 持久化与原子性 | 仅新增一可空列,补列沿既有探测后 ALTER 幂等流程;缓存仍成功后单次原子 set |
|
||||||
|
| 错误分类 | 仅落 Transient 与 ResultInvalid 两类,不新增类;原因码见 §4.3 |
|
||||||
|
| 可观测 | `stream_end` 字段 + 遥测列;冲突路径与未知 finish 有界告警;遵 structured-logging 更新 `schemas/llm-calls.md` |
|
||||||
|
|
||||||
|
## 10. 测试、文档与交付
|
||||||
|
|
||||||
|
全部用假 SSE / MockTransport 复现,每条先红后绿;真实中转冒烟标 slow 按发布清单 diff 交集跑。
|
||||||
|
|
||||||
|
| 组 | 用例 |
|
||||||
|
| --- | --- |
|
||||||
|
| #31 验收 | 1 stop+usage+EOF 成功、`eof_after_finish`、measured、attempts=1;2 tool_calls 同上工具完整;3 无 finish 即关 → 失败;4 finish 无 usage → 失败;5 中途 `RemoteProtocolError` → 失败;6 带 DONE → `done` 不变 |
|
||||||
|
| #31 补充 | usage 早于 finish(累计 usage)+EOF → 不完整;finish 后 usage 为 `{}` → 不完整;`network_error`+usage+EOF → Transient;文本模式 1/3/4(`retry`、`salvage` 两档) |
|
||||||
|
| #30 验收 | reasoning-only+length → 交付、content 空、思考完整、attempts=1;流式 `refusal:null`+`tool_calls:null` 成功;非流式 message 带 `tool_calls:null`+`refusal:null` 成功 |
|
||||||
|
| 结局表 | §4.3 每行至少一例,两模式、流式与非流式;文本 length → ResultInvalid 且熔断未计失败;零增量+DONE(带 stop/不带 finish)仍 Transient;content 非空 + 坏工具 + stop → Transient |
|
||||||
|
| 合并/活性 | 只有 `reasoning` 刷新活性不超时;二次构造 OpenRouter `reasoning_details` 往返(`format`/`id` 不拼接);工具层嵌套对象原样保留可回传;数值冲突删键、告警一次、调用成功;节流饱和;`role:null`/`arguments:null` 视缺省 |
|
||||||
|
| 缓存 | 四类不可缓存均不写、注入后读视 miss;非法 `stream_end` 视 miss;1.3.9 旧文本条目仍命中;1.3.9 保真 length 条目经回退识别为不可缓存;命中 `stream_end=None` |
|
||||||
|
| 遥测 | sqlite/pg(integration)新列写入、旧库补列、manual 档裁剪;形状闸拒绝缺参 recorder |
|
||||||
|
| 改写 | `test_openai_never_salvages`;`test_invalid_delta_is_domain_error` 中 `{"audio": {}}`、`{"index":0,"extra":1}`、null 已知字段参数;提交说明列出 |
|
||||||
|
|
||||||
|
同步: 源码 docstring、README(能力表、`1.3.*` 安装约束核对、活性口径、字段数)、CHANGELOG 1.3.10(行为变更段、破坏性段: recorder 端口、namespace/salt 提醒、半截工具参数提醒、冲突删键提醒)、ARCHITECTURE §6.2/§7/§14、1.3.9 设计加修订指针、`schemas/llm-calls.md`、`research-wiki/migrations/` 三份核对下游对 ResultInvalid 的 catch 点、`docs-convention.md` §2 清单。另开 issue: 文本 n>1 混流;文本"有产物无正文 + stop"证据收集。发布按 CLAUDE.md §4.4.1 九步,#30/#31 在 registry 可安装且验收证据齐备后关闭。
|
||||||
|
|
||||||
|
证据: `retry.py:404-450,548-560`;`cache.py:197-236`;`structured.py:79`;[openai-python `_streaming.py`](https://github.com/openai/openai-python/blob/main/src/openai/_streaming.py) 与 `lib/streaming/_deltas.py`;[vLLM reasoning outputs](https://docs.vllm.ai/en/latest/features/reasoning_outputs/);GLM finish 枚举来自第三方镜像,实现前以真实中转探针再核;Gemini `extra_content` 路径未核实原文,测试按通用嵌套对象构造。
|
||||||
|
|
||||||
|
## 11. 审查修订记录
|
||||||
|
|
||||||
|
| 轮次 | 意见 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 | B1 `tool_calls:null` 被快照拒(非流式现存) | 采纳: §3/§5.1/§10 |
|
||||||
|
| R1 | B2 结局表行重叠 | 采纳: 首匹配行序 + `_tool_snapshot_ok` 前置 |
|
||||||
|
| R1 | B3 文本"任何产物"不可计算 | 采纳: §4.3 分模式定义 |
|
||||||
|
| R1 | S1 eof_after_finish 但 usage 非 measured | 采纳: 条件加 measured |
|
||||||
|
| R1 | S2/S3 活性冲突与标签拼接 | 采纳: §5.2/§5.3 |
|
||||||
|
| R1 | S4 告警集合无界 | 采纳: §5.4 |
|
||||||
|
| R1 | S5 无产物 + length 与 #30 原文冲突 | 采纳(人类授权代决): 保真交付、文本 ResultInvalid、stop/absent 仍 Transient |
|
||||||
|
| R1 | S6-S9 缓存顺序、遥测落点、原因码、null 优先 | 采纳: §6/§7/§4.3/§5.1 |
|
||||||
|
| R1 | 建议 1-7 | 采纳 1/2/3/5/6/7;建议 4 不标不可缓存(上游确定性,非毒化) |
|
||||||
|
| R2 | 结论通过;N1 measured 以最后 usage 判定、N2 非空 reasoning_content | 采纳: §4.2/§4.4 |
|
||||||
|
| R2 | 建议 1-4: 命中回退值、未知 finish 告警文案、数组字符串活性与残余风险、null 五层与数组 null 元素 | 全部采纳: §6/§4.1/§5.3/§5.1 |
|
||||||
|
| 代码审查 | I1 保真可缓存须同时要求投影正文非空(think 标签剥净后为空不缓存) | 采纳: `_is_cacheable` |
|
||||||
|
| 代码审查 | I2 无参函数 `arguments` 全程 null → 快照不成立被判 Transient 重试 | 人类授权代决: 归 ResultInvalid `tool_arguments_null`(确定性方言);不改写交付值(改写破坏保真,重试救不回) |
|
||||||
|
| 代码审查 | I3 文本 `index:null` 整帧丢弃、`index:False` 冒充 0 | 采纳: null 视 0,bool 拒 |
|
||||||
|
| 代码审查 | Minor: `[null]` 产出空 `tool_calls`、非流式缺 role 先于判定、告警路径长度有界、README 表述 | 采纳;index 数组缺 index 整键删除登记于 CHANGELOG 提醒 |
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-10-21-issue34-ocr-document-design
|
||||||
|
title: OCR 整份文档解析设计(#34)
|
||||||
|
date: 2026-10-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# #34 OCR 整份文档解析设计
|
||||||
|
|
||||||
|
> 状态: **已获人类批准(2026-10-21,H1/H5/H6 按推荐批准;H2 授权另开两条 issue;H3 待下游确认,不阻塞;H4 发布逐步等指令)**。已完成 Claude 自审与一轮独立 subagent 论证(§12);Codex 一轮审查已完成并逐条处置(§12 审查记录)。
|
||||||
|
> 基线: `25a90eb`(1.3.10);目标版本 **1.3.11**;分支 `fix/issue34-ocr-document`。
|
||||||
|
> 需求: [#34](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/34)(CHSAnalyzer3 把几百篇医学论文 PDF 整份转成带页码正文与表格)。
|
||||||
|
> 修订对象: [M3 OCR 设计](2026-07-21-m3-ocr-design.md) §4 "multipart 固定名 image.jpg,服务按内容处理"(该论断只在图片样本上验证过,见 §1 E3/E4)、ARCHITECTURE §7.10。
|
||||||
|
|
||||||
|
## 1. 问题与根因
|
||||||
|
|
||||||
|
issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张图 + 写死的标签",把输出建模成"只有框",**没有"文档"这个概念**——输入类型既不声明也不检测,输出不含内容。M3 设计 §4 的"服务按内容处理"是从 35 张图片样本外推到 PDF 的未验证假设。
|
||||||
|
|
||||||
|
以下事实 2026-10-21 在真实 MonkeyOCR 服务实测取得(样本为第三方 AI 生成论文,仅本地存证,**不入库**):
|
||||||
|
|
||||||
|
| # | 事实 | 设计影响 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| E1 | 服务按**扩展名**分派,仅 `.jpg/.jpeg/.png/.pdf`(大小写不敏感);其余(`.webp`/无扩展名)→ HTTP 500 `{"detail":"Parsing failed: 400: Unsupported file type…"}` | 上传标签必须落在这四个之内;服务把客户端错误包成 500 |
|
||||||
|
| E2 | 内容与扩展名不符(jpg 字节标 `doc.pdf`)→ HTTP 500 `Parsing failed: is no PDF` | **调用方声明类型会新增一个失败入口**,故类型由库按内容判定 |
|
||||||
|
| E3 | `/parse`: 同一 PDF 以 `image.jpg` 与 `doc.pdf` 上传,`_middle.json`/`_content_list.json`/`.md` **逐字相同**(只有 message 与包内文件名前缀不同)——文本型 2 页 PDF 与**无文字层的 2 页位图 PDF**(扫描件模拟,`pdftotext` 字符数 0)两种均验证; **旋转页、带批注页未测**;PNG/TIFF/BMP 字节标 `image.jpg` 亦成功(服务按内容解码);前缀 200 字节垃圾的 PDF 以 `doc.pdf` 上传仍被解析 | 问题一在 `/parse` 上**目前未观察到结果差异**,但不是保证(E10);非 PDF 沿用 `image.jpg` 才是零回归;前缀垃圾的 PDF 服务可解析,嗅探须容忍 |
|
||||||
|
| E4 | `/ocr/text`: PDF 字节标 `image.jpg` → HTTP 200 `success:true`,content=`"The image contains a single character: 1"`(**静默错误结果**);标 `doc.pdf` → 整份全文(无页码) | 问题一的真实危害在 `/ocr/text`,issue 未提及;须一并修 |
|
||||||
|
| E5 | 截断 PDF / 伪 `%PDF-` 头 / 乱码图 / 空文件 → 全部 **HTTP 500** `{"detail":"Parsing failed: <原因>"}` | 当前 ≥500 一律 `TransientError`,见 §6 |
|
||||||
|
| E6 | 结果包含 `*.md`、`*_middle.json`、`*_content_list.json`、三份 `*.pdf`;**含图/表的文档另有 `images/*.jpg`**(4 页含 4 图 ≈598KB;无图的 2 页文档没有该目录) | 包体随插图增长;只读取需要的成员,不解出图片 |
|
||||||
|
| E7 | `_content_list.json` 与 `_middle.json` 逐块 1:1(三样本 29/29、89/89、30/30),但 content_list **无 bbox**、type 词汇不同(`title→text+text_level`、`interline_equation→equation`),且**文本里 `$` 被转义成 `\$`**(134 个 text/title 块中 23 个) | 不用 content_list,见 §3 Q3 |
|
||||||
|
| E8 | middle 的 text/title 块恰 1 行 1 span,`content` 与 content_list 反转义后 134/134 一致;table 的 span 带 `html`;interline_equation 的 span 内容为裸 LaTeX;image 嵌套 `image_body` 仅含 `image_path`;嵌套 `*_caption/*_footnote` 子块**从未出现** | 内容可只从 middle 取得;caption/footnote 路径无真实样本 |
|
||||||
|
| E9 | 速度约 **1.5–4 s/页**(2026-10-21 实测:2 页 3.5s/4.0s[位图型]/5.1s/8s[issue 原文],4 页 6.2–6.4s;样本极少、无争用,不是 SLA。验证者指出初稿的「约 1.5 s/页」偏乐观,已订正)。库内**无任何基于延迟的熔断/慢判定**(`grep latency` 在 breaker/admission/sources 无命中) | 长文档不会被"判慢",真正的风险是 §7 的超时被算成失败 |
|
||||||
|
| E10 | 服务源码(本机 `~/Code/MonkeyOCR`,`a4d853f`,版本 1.1.0 与样本 `_version_name` 一致)核对: ① 分派只看上传文件名后缀(白名单 `api/main.py:435-441`),`pdf` 走 `PymuDocDataset`、其余走 `ImageDataset`(`api/main.py:164-170`;后者先 `convert_to_pdf()`,`magic_pdf/data/dataset.py:231-235`)——**PDF 标成 `image.jpg` 走的是图片数据集分支**,E3 的"逐字相同"只在那份 2 页文本型 PDF 上验证,扫描件/旋转页/带批注页无证据; ② `/parse` 把一切异常(含 400)改写成 `500 "Parsing failed: …"`(`:501-503`),连 `Model not initialized` 也变成同前缀 | 支持嗅探(让 PDF 走它本该走的分支);**前缀 `Parsing failed:` 不能单独作为"坏输入"判据**,只有具体标记白名单可行 |
|
||||||
|
| E11 | `content_list` 是**展示格式**: 文本经 `markdown_utils` 转义 `* \` ~ $` 四个字符(`ocr_mkcontent.py:148`);医学论文里 `*`(显著性)与 `~` 比 `$` 更常见,污染面大于 E7 的 23/134 | 强化 Q3: 绝不读 content_list 取文字 |
|
||||||
|
| E12 | **服务端丢弃**脚注/页眉/页脚/页码/印章(默认版面模型 PP-DocLayout_plus-L 把这些标签映射到 `Abandon` → `discarded_blocks`,span `content` 全空,即**未被 OCR**);`figure_title` 映射为普通 `text` 块,故默认部署**不产生**嵌套 `*_caption/*_footnote` 子块(换 `doclayout_yolo` 才可能);`level` 恒为 1;`need_drop` 在该流水线硬编码为 False | 表格脚注(显著性/缩写说明)在服务端就已丢失,库找不回——写成"服务限制"告知下游;`level` 不加字段;嵌套子块仅作最小防御 |
|
||||||
|
| E13 | `/ocr/text` 对**无法解码的输入**返回 `success:true` + 幻觉文本: 乱码字节(`GARBAGE-NOT-AN-IMAGE`)得到与 E4 完全相同的 `"The image contains a single character: 1"`——即 E4 的本质是"服务把 PDF 当作无法解码的图"。另据源码,该端点内部异常会返回 **HTTP 200 + `success:false` + `content:""`**(`api/main.py:622-629`),而 `recognize_text` 只校验 `content` 为 str、不看 `success`(`monkey_ocr.py:240-247`)。后者**未经线上触发,仅源码推断** | 标签修正只能消除"PDF 被当图"这一类; 对无法解码输入的静默错误是服务行为,库侧要治需另行设计(§12 H2) |
|
||||||
|
| E14 | 服务并发模型: `/parse` 推理在 sync-only 后端下持有**全局 `asyncio.Lock`**(`api/main.py:183-189`);`supports_async` 取决于 chat 后端是否有 `async_batch_inference`,出厂默认 `lmdeploy` 为 sync-only(`model_configs.yaml`),**线上实际 backend 未知**(启动日志有 `async-capable`/`sync-only` 一行) | 若 sync-only: 每实例 `/parse` 全局串行,我们的长文档会**队头阻塞医院线**; `timeout_s` 须覆盖"排队+处理" |
|
||||||
|
|
||||||
|
## 2. 现状代码面
|
||||||
|
|
||||||
|
| 位置 | 现状 |
|
||||||
|
| --- | --- |
|
||||||
|
| `transports/monkey_ocr.py:207-210` | `_files()` 固定 `("image.jpg", …, "image/jpeg")`,`/ocr/text` 与 `/parse` 共用 |
|
||||||
|
| `monkey_ocr.py:136-144,147-166` | `_parse_block` 只取 type/bbox;`_parse_pages` 对非法 `page_idx` 回退枚举下标;bbox 含 CHS shim 专用的整数化退化校验 |
|
||||||
|
| `monkey_ocr.py:79-100` | `_classify_status`: ≥500/429→Transient、401/403→SourceDead、其余→RequestRejected |
|
||||||
|
| `types.py:1187-1250` | `OcrLayoutElement(type,bbox,page_index)`、`OcrLayoutResult`、`OcrLayoutTransportResult` |
|
||||||
|
| `ocr.py:625-628` | `_summarize` 用 `hasattr(result,"text")` 区分文本/版面——**对新结果类型是陷阱** |
|
||||||
|
| `ports.py:105-121` | `OcrTransport` 是 `@runtime_checkable`,加成员会让所有现有实现(测试替身、下游 fake)在 `isinstance` 上不合格 |
|
||||||
|
|
||||||
|
## 3. 备选方案对比
|
||||||
|
|
||||||
|
### Q1 公共面形态
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A(推荐)** | 新增 `OcrClient.parse_document` + `OcrDocumentPort`;新类型 `OcrDocumentElement(OcrLayoutElement)`(仅增 `text`)与 `OcrDocumentResult`;`parse_layout`/`recognize_text` 签名与返回类型不动 | 医院线零风险;文档线的内容解析故障不波及版面路径 |
|
||||||
|
| B | 给 `OcrLayoutElement` 追加 `text` 字段,`parse_layout` 同时返回内容 | 违反 issue "parse_layout 不动";`text: str\|None` 在一条路径上恒为 None;医院线耦合到新增的包成员解析,任一故障波及它 |
|
||||||
|
| C | 只加 `filename/media_type` 参数并把 ZIP 原样交给调用方 | 把校验、数值防御、阅读序整理全推给下游,违背库的价值;每个下游各写一份解析 |
|
||||||
|
|
||||||
|
### Q2 上传类型如何确定
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A(推荐)** | transport 按内容嗅探,判定顺序固定: ① 起始为已知图片魔数(JPEG/PNG/TIFF/BMP/GIF/WebP)→ 图片;② 否则前 1024 字节内含 `%PDF-` → PDF(Adobe 阅读器的头部容忍惯例,E3 实测服务亦可解析);③ 否则未知。PDF → `doc.pdf`/`application/pdf`,**图片与未知一律沿用 `image.jpg`/`image/jpeg`**。单点 `_upload_part()`,三个方法共用 | 图片魔数先判,含 XMP 元数据的图片不会因内嵌 `%PDF-` 字样被误判;非 PDF 路径字节级不变;顺带修 E4 |
|
||||||
|
| B | 调用方传 `media_type`/`filename`(**issue #34 原文提议**) | 声明与内容不符 → E2 的 500;若放在旧两方法上,仍走共享分类 → Transient → 重试并计熔断: 笔误变熔断事件。库本就能从内容判定,声明是冗余信息; 唯一得益场景(前缀垃圾 PDF)已被 A 的 ② 覆盖。**这是对 issue 提议的有意替换,列入 H5 由人类批准** |
|
||||||
|
| C | 嗅探全部图片格式并精确标注(png→`image.png`) | 零收益;webp/gif 等无对应扩展名反而要拒绝,把现在能成功的路径(E3)变成失败 |
|
||||||
|
|
||||||
|
三个方法对同一输入的结局(Codex 审查要求显式化):
|
||||||
|
|
||||||
|
| 输入 | `recognize_text` | `parse_layout` | `parse_document` |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 正常 PDF | `doc.pdf`,整份全文(E4 修复) | `doc.pdf`(上传分支变更,见 §8) | `doc.pdf`,完整文档结果 |
|
||||||
|
| 前缀垃圾的 PDF | 同上 | 同上 | 同上 |
|
||||||
|
| 魔数表内的图片(JPEG/PNG/TIFF/BMP/GIF/WebP) | `image.jpg`(不变) | `image.jpg`(不变) | `image.jpg`,1 页结果 |
|
||||||
|
| 魔数表之外的可解码图片 | `image.jpg`(不变,服务按内容解码) | `image.jpg`(不变) | **本地 `RequestRejectedError`**(请改走 `parse_layout`;表内格式的扩充另议) |
|
||||||
|
| 无法识别的字节(HTML 页面、乱码、空) | `image.jpg`(不变,服务给幻觉文本 E13) | `image.jpg`(不变,服务 500) | **本地 `RequestRejectedError`,不发 HTTP** |
|
||||||
|
|
||||||
|
### Q3 文字内容从哪里来
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A(推荐)** | **只读 `_middle.json`**(已是 bbox/type/页码/阅读序的来源),从 `lines[].spans[]` 取 `content`/`html`;`.md` 原文单独取 | 单一来源,无对位、无类型映射、无版本漂移故障面;不受 `\$` 转义污染 |
|
||||||
|
| B | `_content_list.json` 取文字 + middle 取 bbox,按页内顺序对位并严格校验(本设计初稿方案) | 1:1 是上游构造性成立但**无契约**;漂移时整份 `ResultInvalid`,几百篇批处理会"一批全挂";文本含 `\$` 需反转义,且 title 的 level 等字段样本中无信息量(21/21 均为 1) |
|
||||||
|
| C | 只用 content_list、不要 bbox | 不满足 issue 的"每块含 bbox" |
|
||||||
|
|
||||||
|
### Q4 确定性坏输入(E5)的分类
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A(推荐)** | **只在 `parse_document` 路径**: ① 本地魔数预检(既非 PDF 也非已知图片魔数 → 本地 `RequestRejectedError`,零 HTTP); ② 500 + `Parsing failed:` 且含已知确定性标记 → `RequestRejectedError`(保留 `status_code=500`)。共享 `_classify_status` 默认行为字节级不变 | 既有 `parse_layout`/`recognize_text` 的行为零变更;未知 500 仍 Transient(安全方向) |
|
||||||
|
| B | 直接改共享 `_classify_status`,所有 OCR 方法受益 | 改变医院线对坏图的重试/熔断行为,属公共行为变更,应单独走设计 → **另开 issue**(待人类决定) |
|
||||||
|
| C | 不处理 | 批处理后果见 §6 |
|
||||||
|
|
||||||
|
### Q5 长文档超时
|
||||||
|
|
||||||
|
| 方案 | 做法 | 权衡 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A(推荐)** | 不加逐调用 HTTP 超时;文档线用**独立 scope**(如 `OCR_DOC`)指向同一服务 URL,自带 `TIMEOUT_S`/重试/熔断阈值;文档 + 测试钉死"无延迟判慢" | 与现有租约不变式一致 |
|
||||||
|
| B | `parse_document(timeout_s=…)` 逐调用超时 | `RateLimiter.try_acquire(source_key, est_tokens)`(`ports.py:137`)无 TTL 参数,租约长度是构造期常量,逐调用放大超时会让请求活过租约 → 并发配额被突破 |
|
||||||
|
|
||||||
|
## 4. 公共 API(冻结面,人类门审这里)
|
||||||
|
|
||||||
|
### 4.1 类型(`types.py`,全部 frozen dataclass)
|
||||||
|
|
||||||
|
| 类型 | 字段 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OcrDocumentElement(OcrLayoutElement)` | 继承 `type/bbox/page_index`;新增 `text: str` | `text` 随 type: `text`/`title` → 纯文本(行内公式保留 `$…$`);`interline_equation` → 裸 LaTeX;`table` → HTML 表体;`image` → `""`;嵌套的非 body 子块(`*_caption`/`*_footnote`)作为**独立元素**输出,type 取子块类型 |
|
||||||
|
| `OcrDocumentResult` | `elements: list[OcrDocumentElement]`、`page_sizes: list[tuple[float,float]]`、`markdown: str` + 溯源五件 `source_name/usage/latency_ms/call_id/raw` + `call_stats=None` | **独立定义,不继承** `OcrLayoutResult`: 父类末尾 `call_stats` 带默认值,子类无默认字段 `markdown` 会在导入期 `TypeError` |
|
||||||
|
| `OcrDocumentTransportResult` | `elements`、`page_sizes`、`markdown`、`raw` | transport → client 内部产物 |
|
||||||
|
|
||||||
|
约束与理由:
|
||||||
|
|
||||||
|
| 点 | 决定 |
|
||||||
|
| --- | --- |
|
||||||
|
| 为何继承 | 文档元素"是"版面元素 + 内容;按 bbox 裁剪的下游函数可同时吃两种。代价: dataclass `__eq__` 要求同类,`OcrDocumentElement != OcrLayoutElement`;`OcrLayoutElement` docstring 加警示"追加带默认值字段前须评估子类" |
|
||||||
|
| 为何不加 `level`/`captions`/`footnotes` | 样本中 21 个 title 全是 level 1(无信息量);caption/footnote 在全部样本里恒为空——为从未见过的路径加公共字段不可验证。嵌套子块独立成元素的方案零新增字段,且服务将来开始关联 caption 时不丢信息 |
|
||||||
|
| `markdown` | 整篇产物,属 result 不属元素。**含悬空图片引用** ``(库不外传图片),docstring 注明 |
|
||||||
|
| 阅读序 | 与服务 `para_blocks` 顺序一致,页内按页序;嵌套子块紧随其父块 |
|
||||||
|
|
||||||
|
### 4.2 端口(`ports.py`)
|
||||||
|
|
||||||
|
| 端口 | 签名 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OcrDocumentPort`(新,`runtime_checkable`) | `async def parse_document(self, document: bytes) -> OcrDocumentResult` | 对应 `/parse` 整份文档 |
|
||||||
|
| `OcrDocumentTransport`(新,`runtime_checkable`) | `async def parse_document(self, *, document: bytes, source: SourceConfig, call_id: str) -> OcrDocumentTransportResult` | **不往 `OcrTransport` 加成员**(会破坏全部现有实现的 `isinstance`);`MonkeyOcrTransport` 同时实现两者 |
|
||||||
|
|
||||||
|
`OcrClient.parse_document(document, *, session_id=None, parent_call_id=None, tenant_id=None, meta=None, call_deadline_s=None)`: 签名与 `parse_layout` 对称。入口校验先于 `_CallContext`(同 `_call` 既有顺序): `bytes` 非空、`validate_caller_dimensions`、`ensure_call_deadline`、**transport 须满足 `OcrDocumentTransport`,否则 `TypeError`**(装配错误当场报)。
|
||||||
|
|
||||||
|
### 4.3 其它公共面
|
||||||
|
|
||||||
|
| 项 | 变更 |
|
||||||
|
| --- | --- |
|
||||||
|
| `CallOperation` / `CALL_OPERATIONS`(`types.py:279,285`) | 新增 `"parse_document"`。`CALL_OPERATIONS` 目前**无任何读者**,新增测试钉死 `get_args(CallOperation) == CALL_OPERATIONS` |
|
||||||
|
| 包根导出 | `OcrDocumentElement`、`OcrDocumentResult` |
|
||||||
|
| 遥测 | 同一张 `llm_calls`,`operation="parse_document"`;占位仍为 `<ocr:document image_bytes=N>`(**不改字面**: 改名会破坏既有分组与 `test_telemetry.py` 对格式的锁定);response 摘要 `<document elements=N pages=M md_chars=K>`,**永不写入正文**;无新列、无 DDL 变更 |
|
||||||
|
| 配置 | **无新键**。文档线独立 scope 是部署方式,不是新配置(`.env.example` 加示例段) |
|
||||||
|
|
||||||
|
## 5. Transport 行为(`transports/monkey_ocr.py`)
|
||||||
|
|
||||||
|
| 关切 | 决策 |
|
||||||
|
| --- | --- |
|
||||||
|
| 上传标签 | `_files()` 改为 `_upload_part(data)`,按 §3 Q2 的固定顺序嗅探: PDF → `("doc.pdf", data, "application/pdf")`,其余 → 原样 `("image.jpg", data, "image/jpeg")`。三个方法共用。包内文件名前缀由 `image_` 变 `doc_`,解包靠后缀 `endswith("_middle.json")`(`monkey_ocr.py:179`),不受影响 |
|
||||||
|
| 本地预检(仅 document) | 嗅探结果为"未知"(HTML 付费墙页面、乱码、空串)→ `RequestRejectedError`(**不带 status_code**,服务未响应),不发 HTTP。已知图片魔数: JPEG `FFD8FF`、PNG、TIFF(`II*\0`/`MM\0*`)、BMP(`BM`)、GIF、WebP(`RIFF…WEBP`);魔数表之外的可解码图片格式在 document 路径会被拒,需要时由调用方走 `parse_layout` |
|
||||||
|
| 两段协议 | 与 `parse_layout` 同: POST `/parse` → 校验 `success is True` + `download_url` → GET → ZIP。document 路径的 GET 改**流式读取并累计字节**,整包上限 256 MiB(模块常量),超限 → `ResultInvalidError`;HTTP 状态错误须先 `aread()` 再 `raise_for_status()`,保持"响应体摘要随所有分支"的 issue #10 口径 |
|
||||||
|
| 解析卸载 | ZIP 解包 + JSON 解析 + 结构校验是 bytes→结果的**纯函数**,在 `asyncio.to_thread` 中执行: 同步解析大对象会占住事件循环,使取消与 `call_deadline_s`(基于 `asyncio.timeout`,`deadline.py:69-79`)无法及时生效。取消后线程自然收尾、结果丢弃。`parse_layout` 不改 |
|
||||||
|
| 外部数据异常边界 | 纯函数内**统一**把外部字节引起的已知异常转 `ResultInvalidError`: 函数级 `ValueError`(含 `JSONDecodeError`/`UnicodeDecodeError`)、`TypeError`、`KeyError`、`OverflowError`(巨大整数转 float,实测 `float(10**400)` 抛它)、`RecursionError`(深嵌套 JSON)、`zipfile.BadZipFile`/`LargeZipFile`;**仅包在 `archive.open/read` 外**的窄 `try`: `RuntimeError`(标准库对加密成员抛它)、`NotImplementedError`(未知压缩方法)、`zlib.error`、`EOFError`。取消(`CancelledError`)不在其内,原样穿透。理由: `ocr.py:303-321` 的 `_call` 只接 `PolyGatewayError`,漏出的原生异常既不写终态遥测行,又会让持有的半开探针不被归还 |
|
||||||
|
| ZIP 成员与体积 | 读 `*_middle.json`(取第一个,同 layout);`.md` 按 `middle 名去掉 "_middle.json" + ".md"` 精确配对(不取"第一个 .md");缺失 → `ResultInvalidError`。读取前检查 `ZipInfo.file_size`: middle ≤ 64 MiB、md ≤ 16 MiB,超限 → `ResultInvalidError`。**不用 `extractall`**,不解出 `images/`;成员数由整包上限间接约束。仅 document 路径;`parse_layout` 不改(医院线零变更) |
|
||||||
|
| 文字提取 | 块内 `lines[].spans[]`: 取 `content`;`inline_equation` 类型 span 包 `$…$`(上游惯例,**样本中从未出现**);行内 span 按 `""` 拼、行间按 `"\n"` 拼。**样本全是 1 行 1 span**,多行/多 span 路径无真实样本,仅构造 fixture 测试,文档标"未经实测" |
|
||||||
|
| 其它页内容 | `discarded_blocks` 忽略(E12: 服务端已丢弃脚注/页眉页脚/页码,span 内容全空,库无物可取);`need_drop` 不读取、不失败(该流水线硬编码为 False) |
|
||||||
|
| 嵌套子块 | 默认部署不产生(E12);仅作最小防御: 非 `*_body` 的嵌套子块若有文字,作为独立元素紧随父块输出,**不静默丢弃**。该路径无真实样本,仅构造 fixture 测试 |
|
||||||
|
| `raw` | `{"success": True, "download_url": …}`,与 `parse_layout` 同形 |
|
||||||
|
|
||||||
|
### 5.1 结构契约(缺失 / 空值 / 类型错误 / 未知类型逐项裁决)
|
||||||
|
|
||||||
|
架构要求严格区分合法"无内容"与调用失败(`ARCHITECTURE.md:427,672`)。下表是实现与测试的唯一依据。
|
||||||
|
|
||||||
|
| 情形 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pdf_info` 缺失或非 list | `ResultInvalidError` |
|
||||||
|
| `pdf_info == []`(零页) | `ResultInvalidError`: 成功解析的文档至少 1 页(与 layout 路径"elements 空合法"不同,差异写入 docstring) |
|
||||||
|
| 页非 dict / `page_size` 非 2 个正有限数 | `ResultInvalidError`(复用 `_parse_page_size`) |
|
||||||
|
| `page_idx` 非 int、为 bool、为负、或 ≠ 页在列表中的位置 | `ResultInvalidError`(layout 路径的"回退枚举"会使 `page_sizes` 按 `page_index` 索引的契约静默错位) |
|
||||||
|
| `para_blocks` 缺失 | 合法空页(空白页 / 无可识别块);非 list → `ResultInvalidError` |
|
||||||
|
| 块非 dict、`type` 缺失/非 str/空白 | `ResultInvalidError` |
|
||||||
|
| `bbox` | 须为长度 4 的有限数值且 `x1 ≤ x2`、`y1 ≤ y2`(允许零宽/零高,**不做**整数化退化校验——那条专为 CHS shim 的 `int()` 裁剪);反序/非有限 → `ResultInvalidError`。坐标系与 `page_sizes` 同为服务原生坐标,页码从 0 起;越界框不检查、不裁剪(图内语义归调用方,`ARCHITECTURE.md:250`) |
|
||||||
|
| 含 `lines` 的块(text/title/interline_equation/未知类型) | `lines` 须 list,每行 dict 且 `spans` 为 list,每 span 为 dict 且 `content` 为 str;违反 → `ResultInvalidError`;`lines == []` → `text=""` |
|
||||||
|
| table | 嵌套 `table_body` 的 span 取 `html`,无则取 `latex`(上游有该分支);二者皆无 → `ResultInvalidError`(表格无内容不静默为空串) |
|
||||||
|
| image | 嵌套 `image_body` → `text=""` |
|
||||||
|
| 未知类型且既无 `lines` 也无 `blocks` | `text=""`,`type` 原样保留(开放字符串) |
|
||||||
|
| `.md` | 成员缺失 → `ResultInvalidError`;按 `utf-8-sig` 解码(吞 BOM),失败 → `ResultInvalidError`;空串合法 |
|
||||||
|
|
||||||
|
## 6. 错误分类与批处理后果
|
||||||
|
|
||||||
|
| 失败 | 分类 | 治理结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 上传前: 非 bytes/空 | `TypeError`/`ValueError` | 统计边界外(同 `_call` 既有) |
|
||||||
|
| 嗅探为"未知"(§5) | `RequestRejectedError`(无 status) | 不重试、不换源;仅归还探针,**不计熔断** |
|
||||||
|
| 500 + `Parsing failed:` + 已知标记 | `RequestRejectedError`(`status_code=500`) | 不重试;熔断**记成功**且不计窗口(`_gate_on_terminal`: `exc.status_code is not None`);不喂选源器 |
|
||||||
|
| 429 | `TransientError`(`status_code=429`) | 照扣重试预算、喂选源器失败; **熔断两通道均不计**(`memory/breaker.py:235-244`: `rate_limited` 是背压不是故障) |
|
||||||
|
| 其余 5xx、超时、网络错误 | `TransientError` | 重试 + 计熔断(现状) |
|
||||||
|
| `CallDeadlineExceeded`(整体期限到期) / 外部取消 | 专用异常 / `CancelledError` | **不是**源故障: 不计熔断; 在飞探针归还; 终态遥测行照写(`ocr.py:303-321`)。期限不会被混入 `TransientError` |
|
||||||
|
| 401/403 | `SourceDeadError` | 现状 |
|
||||||
|
| 其余 4xx、`success!=true`(附 200) | `RequestRejectedError` | 现状 |
|
||||||
|
| ZIP 坏 / 加密或未知压缩成员 / middle 或 md 缺失 / 超限 / 结构异常(§5.1)/ `page_idx` 错位 / 零页 | `ResultInvalidError` | 不重试;熔断记成功不计窗口(`_gate_on_terminal`: `ocr.py:493-499`) |
|
||||||
|
|
||||||
|
已知标记(模块常量,小写子串匹配,取 E5 实测): `failed to open stream`、`cannot open empty stream`、`cannot save with zero pages`、`is no pdf`。**不含** `unsupported file type`: 嗅探后它只可能由服务契约变化或我方标签 bug 引起,应响亮失败并计熔断。`MonkeyOcrTransport` 构造参数可覆盖该元组,不新增 `SourceConfig` 字段或环境变量键。
|
||||||
|
|
||||||
|
**为什么 Q4 不能不做**(读代码确认的机制链): ① 熔断按 `source_name` 计数(内存 `memory/breaker.py:56-61`;Redis 键 `pgw:gate:{scope}:{source}`,`backends/redis/breaker.py:308-311`);② 有效阈值 = `max(配置值, 源并发×2)`(`config.py:503-508`);③ 连续失败通道仅在**窗口无充分健康证据**时开路——"充分健康证据"= 窗口样本 ≥ `min_calls`(默认 10)**且**失败率 < `fail_rate`(默认 0.6)(`memory/breaker.py:84-90`、开路判据 `:252`),故样本不足 **或** 失败率 ≥ 0.6 时连败即开路。每份文档约 15 秒、并发 2,窗口凑不够 10 个样本,抑制不生效;④ 一份坏 PDF 重试 `max_attempts` 次即可在低并发配置下开路,冷却内同 scope 的好文档被 `fail_fast` 拒绝。影响范围是**同 scope**: 默认 memory 后端是进程内,只有 Redis 熔断后端加相同 scope 才跨进程(是否波及医院线取决于部署)。⑤ 下游语义: `migrations/chsanalyzer.md:72` 记录 CHSAnalyzer 把库侧"服务不可用"一族按 arq 延期重投且不耗业务失败预算——**若 CHSAnalyzer3 沿用,永久损坏的 PDF 会被无限延期。此点未向下游证实,需人类确认。**
|
||||||
|
|
||||||
|
白名单的脆弱性(诚实声明): 标记是 PyMuPDF 的界面文案,随服务版本漂移; 服务把所有异常都包成 `Parsing failed:`,若把磁盘满等真故障也包成同样文案会被误判为坏输入。**误判代价是"该文档失败而非延期"(下游重跑失败集合即可),反向代价是熔断误开**——取前者。
|
||||||
|
|
||||||
|
## 7. 长文档与资源(issue 要求 4)
|
||||||
|
|
||||||
|
| 结论 | 依据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 默认值与熔断**不会把长文档判为慢** | 无延迟型判定(§1 E9);选源器只数成败(`sources.py:61-64`);`StallClock` 只计非生产性等待(`ocr.py:343-344`) |
|
||||||
|
| 把"慢"变成失败的路径有两条,性质不同 | ① httpx 超时: `monkey_ocr.py:63-68` → `TransientError`(`__cause__` 为 `TimeoutException`)→ 重试 + 计熔断; ② `call_deadline_s` 到期 → `CallDeadlineExceeded`,**不计熔断**(§6) |
|
||||||
|
| **`timeout_s` 不是总耗时上界**(Codex 审查纠正) | `httpx.Timeout(source.timeout_s)` 是**逐次网络操作**的超时(`httpcore` 每次读单独计时): POST 读超时≈服务处理时长上限(服务处理期间无字节),但 GET 下载只要持续有分块到达,总时长可超过任意多个 `timeout_s`。故初稿"最坏约 2×`timeout_s`"不成立,**不能据此推出租约安全** |
|
||||||
|
| 租约与探针租约的真实关系 | `config.py:338-345` 只守卫 `timeout_s ≤ lease_ttl_s`(默认 1500);限流租约到期即被移除(`memory/limiter.py:98-103`),探针租约到期可被接管(`memory/breaker.py:161-163`,派生 TTL 见 `config.py:509-518`)。**硬保证只能来自 `call_deadline_s`**: 文档线应配 `call_deadline_s ≤ lease_ttl_s`,使任何一次尝试都不会活过租约; 守卫改造(跨 scope 配置语义)**不在本次**,另列 follow-up |
|
||||||
|
| 配置口径(写入 `.env.example` 与 README) | 文档线独立 scope + `MAX_CONCURRENCY=2`(满足"并发不超过 2";sync-only 见下行)+ `RETRY__MAX_ATTEMPTS=2` + `timeout_s ≥ 最大页数×4s×余量`(实测 1.5–4 s/页;共用 GPU 争用再乘 3–5 倍)+ **`call_deadline_s ≤ lease_ttl_s` 给单份封顶**。有效熔断阈值 `max(配置, 并发×2)=4` > 单份坏文档的重试次数 |
|
||||||
|
| 重试放大 | POST 成功(服务已算完)而 GET 失败时,重试**重新 POST 整份文档重算**; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消 |
|
||||||
|
| 无页数预检 | 库不依赖 PDF 库,发送前不知页数; 300 页文档会占满超时被重投。页数过滤是下游职责 |
|
||||||
|
| 内存 | document 路径 GET 改流式并累计字节,**整包上限 256 MiB**(§5);只解必要成员,成员再受声明大小上限约束。**诚实标注(验证者 M-4)**: 合并分块 `b"".join` 与随后的 `BytesIO` 会让瞬时峰值接近 2 倍上限,README 已写明预留。httpx 非流式 `get` 会先 `aread()` 整包(`httpx/_client.py:1635-1637`),这是改流式的原因; `parse_layout` 不改 |
|
||||||
|
| 服务端磁盘 | 每次调用在服务 `/app/tmp` 与 `/static` 留产物,几百篇约几百 MB 到 GB 级——**告知服务运维**(库不管理) |
|
||||||
|
| 并发上限的进程边界 | `MAX_CONCURRENCY` 默认进程内; 多 worker 共享上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope |
|
||||||
|
| **服务端串行与队头阻塞(E14)** | 若线上为 sync-only: ① 每实例 `/parse` 全局串行,"并发 2"不提速,第二份文档在服务端排队 → `timeout_s` 须覆盖"排队+处理"≈ 并发数×单份耗时×余量; ② 一份 15 页文档(按 1.5–4 s/页估算约 22–60 秒)的队头阻塞可能把医院线(`table_locator`)请求推到它们自己的 `timeout_s`,触发**它们**的 Transient 与熔断——"我们的长文档误伤他们"比"他们的判定误伤我们"更现实; ③ 7867 实例与 embedding 服务同卡(部署记录),重负载互相影响,不在库治理范围。**须向运维确认 backend**(启动日志 `model_manager.py:37-38`)。指引: sync-only 时文档线每实例并发 1、错峰运行、页数过滤在调用方 |
|
||||||
|
| 服务限制(告知下游) | 脚注/页眉页脚/页码在服务端即被丢弃(E12): 论文表格脚注的显著性与缩写说明不会出现在结果里; `markdown`/`elements` 均无此内容。库不能也不应补偿 |
|
||||||
|
|
||||||
|
## 8. 旧版行为审计
|
||||||
|
|
||||||
|
| 行为 | 裁决 |
|
||||||
|
| --- | --- |
|
||||||
|
| `_files()` 固定 `image.jpg` | **替换**: 嗅探为 PDF → `doc.pdf`; 其余**保留**(字节级不变) |
|
||||||
|
| `parse_layout` 收到 PDF | **有意改变上传分支**(`ImageDataset` → `PymuDocDataset`,E10)。差分证据: 文本型与位图型 2 页 PDF 结果逐字相同(E3); **旋转页、带批注页未测,不构成保证**。该路径无已知消费者(医院线只传图片,CHSAnalyzer3 走新方法),故列 H6 由人类批准,而不是声称"零风险" |
|
||||||
|
| `recognize_text` 收到 PDF | **修复**: 静默错误结果(E4)变为整份全文; 文档注明"不承诺 PDF 语义,请用 `parse_document`" |
|
||||||
|
| `parse_layout` 的 bbox 整数化退化校验 / `page_idx` 回退 / 无 ZIP 体积上限 | **保留不动**(医院线零变更); document 路径各自更严/更宽,差异写入 docstring |
|
||||||
|
| `_classify_status` 共享分类 | **保留**(默认参数不变) |
|
||||||
|
| `OcrTransport` 协议 | **保留**,不加成员 |
|
||||||
|
| `check_health`、重试/选源/限流/熔断/遥测编排 | **保留**: `parse_document` 复用同一 `_call/_run/_attempt` 循环,只扩 `_OcrKind` 与 `_invoke` 分派 |
|
||||||
|
|
||||||
|
## 9. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 回答 |
|
||||||
|
| --- | --- |
|
||||||
|
| 并发与取消 | 无新共享可变状态; `CancelledError` 路径与 `parse_layout` 一致(退避 sleep/限流等待/两段 HTTP 均可取消,permit 在 `finally` settle+release,探针取消归还); **新增**的 ZIP 解析在 `to_thread` 中,取消时立即返回(线程自然收尾、结果丢弃),不会像同步解析那样占住事件循环使取消/期限失效。`call_deadline_s` 到期会取消在飞请求,服务端是否继续计算未知 |
|
||||||
|
| 降级方向 | 准入阶段(选源/限流/熔断)后端故障 → 报错不放行(铁律,复用既有 `SourceAdmission`); 已发出请求之后的记账写回(`_record_quietly`,`ocr.py:511-517`)与遥测写失败 → warning 降级。**不能**把"后端故障一律上抛"理解为所有阶段 |
|
||||||
|
| 幂等与重复 | 只读幂等,重放安全; 代价见 §7"重试放大" |
|
||||||
|
| 持久化与原子性 | 库内无持久化; ZIP 解包在内存(`BytesIO`),不落盘 |
|
||||||
|
| 零业务假设 | 无医学/论文词汇进入库代码与 fixtures; `type` 仍为开放字符串 |
|
||||||
|
|
||||||
|
## 10. 测试策略
|
||||||
|
|
||||||
|
fixtures 一律**合成**(`tests/unit/test_monkey_ocr.py` 的 `_zip_bytes`/`_page` 风格上扩一个 document builder),**不提交任何真实 ZIP**(第三方内容许可不明; 医疗数据不入库,同 M3 设计 §11)。
|
||||||
|
|
||||||
|
| 层 | 内容(每条先红后绿) |
|
||||||
|
| --- | --- |
|
||||||
|
| unit/transport | 嗅探: 纯 PDF、前缀垃圾的 PDF、起始为图片魔数但内含 `%PDF-` 字样的图片(须判图片)→ 各自标签,非 PDF 逐字 `image.jpg`/`image/jpeg`,**三个方法各钉一次**(`test_multipart_shape` 现有断言保持成立); 文档解析: title/text(含 `$X$`)/interline_equation/带 nested `table_body` 的 table/带 `image_body` 的 image 的 text 取值; 垃圾 `content_list.json` 与 `images/x.jpg` 并存时输出与其无关; nested caption/footnote 成独立元素(标未经实测); `inline_equation` 包 `$`; 多行/多 span 拼接; `markdown` 精确配对与缺失; 体积上限; §5.1 契约表**逐行一条用例**(零页、`page_idx` 为 bool/负/错位、`para_blocks` 缺失=空页、非 list、块无 type、反序/非有限 bbox、零宽 bbox 合法、`lines` 各层类型错误、table 无 html/latex、未知类型、`.md` 缺失/非法编码/BOM/空串); `discarded_blocks` 不进结果 |
|
||||||
|
| unit/资源与异常边界 | 加密成员、未知压缩方法、`10**400` 整数、深嵌套 JSON、CRC 损坏 → 全部 `ResultInvalidError`(**不漏出原生异常**),并断言经 `OcrClient` 后终态遥测行已写、半开探针已归还; 整包流式累计超限(大图片包 + 小 middle/md)→ ResultInvalid; 声明大小超限成员 → ResultInvalid; 解析期间取消 → 立即 `CancelledError` 穿透(用阻塞式解析桩验证事件循环未被占住) |
|
||||||
|
| unit/分类 | 魔数预检(HTML 页面、空串、未知魔数); 四个已知标记 → `RequestRejectedError(status_code=500)`; 未知 500 仍 Transient; `unsupported file type` 仍 Transient; 默认 `_classify_status` 行为不变(现有 `test_http_status` 保持) |
|
||||||
|
| unit/client | 桩 transport 走完整治理循环; `isinstance` 缺 `OcrDocumentTransport` → `TypeError`; **无延迟判慢**: 在成功尝试内推进远大于 `timeout_s` 的时钟,断言成功、熔断 CLOSED、`fails==0`(**含"熔断已有失败计数"与半开探针两种前置状态**,不只测初始 CLOSED); 对照: 超时 Transient → 计失败 → 重试; **持续慢速下载**(每个分块都在 read 超时内、总时长超过 `call_deadline_s`)→ `CallDeadlineExceeded` 而非 Transient,且不计熔断; 429 → 照扣重试预算但熔断窗口不变; ResultInvalid/本地预检不计熔断窗口; 取消穿透且探针归还; 遥测行 `operation="parse_document"`、占位字面量、摘要不含正文; `_summarize` 三类结果各一条(新结果必须显式区分,防止静默套用 layout 摘要而漏记 `md_chars`); 终态行; 调用方维度(`tenant_id`/`meta`)与 `reasoning_applies=False` |
|
||||||
|
| unit/契约 | `get_args(CallOperation) == CALL_OPERATIONS`; `test_ports.py` 新增 Protocol 的 `runtime_checkable` 用例,并断言 `OcrTransport` 成员集合**未变** |
|
||||||
|
| integration(`slow`,opt-in,串行) | 手写最小合法 PDF(无第三方内容)打真实服务: `parse_document` 返回 ≥1 页、元素均有 `text` 类型; 同一 PDF 的 `recognize_text` 不再返回 E4 的幻觉文本; 损坏 PDF → `RequestRejectedError` 且熔断仍 CLOSED。成败取决于外部服务当下状态,按 CLAUDE.md §4.6 标 `slow`,不进日常套件 |
|
||||||
|
|
||||||
|
## 11. 落地时需同步的位置(grep 全引用面所得)
|
||||||
|
|
||||||
|
| 类别 | 位置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 代码 | `types.py`(Literal/常量/新类型)、`ports.py`、`ocr.py`(`_OcrKind`/`_AttemptOutcome`/`_invoke`/`_emit`/`_summarize` 签名)、`transports/monkey_ocr.py`、`__init__.py`(导出 + `__version__`)、`pyproject.toml` |
|
||||||
|
| 校验工具 | `tools/check_wiki_alignment.py:37-46`(`_SIGNATURE_TARGETS` 加 `OcrClient.parse_document`) |
|
||||||
|
| wiki 注册 | `research-wiki/schemas/llm-calls.md`(operation 取值)、`metrics/ocr-call-success.md`、`metrics/call-telemetry-coverage.md`(无推理入口列表) |
|
||||||
|
| 文档 | `README.md`(方法数、能力表、安装版本约束)、`CHANGELOG.md`、`.env.example`(文档线 scope 示例)、`ARCHITECTURE.md` §5.1/§7.10/D9 注记、M3 设计 §4 勘误(指向本设计 E3/E4)、`migrations/chsanalyzer.md`(CHSAnalyzer3 新消费路径) |
|
||||||
|
| 测试 | `test_telemetry.py` 的 operation 参数化、`test_ports.py`、`test_monkey_ocr.py`、`test_ocr_client.py`、`tests/integration/test_monkey_ocr_live.py` |
|
||||||
|
| 无需迁移 | `llm_calls.operation` 列为 `TEXT` 无 CHECK(`telemetry/schema.py`),新值不需 DDL; 外部按 operation 分组的看板会自然多一组 |
|
||||||
|
|
||||||
|
CLAUDE.md §4.4.1 发布流程按其清单执行(README 在构建前改完; 1.3.11 与历史把新增公共方法放 patch 的惯例一致)。
|
||||||
|
|
||||||
|
## 12. 评审与待人类拍板
|
||||||
|
|
||||||
|
**已做**: Claude 自审; 一轮独立 subagent(全新上下文、只读)对 Q1-Q5 与遥测/引用面逐项论证,并核对本机 MonkeyOCR 源码(E10-E14,关键行已由我对源码与线上各复核一次)。**采纳其修正**: 弃初稿"content_list+middle 对位"(Q3,以 `\$` 污染 23/134 为据)、砍 `level/captions/footnotes`(Q1)、`OcrDocumentTransport` 另起 Protocol 而非扩 `OcrTransport`(§4.2)、document 路径的 `page_idx` 严格与 bbox 宽松(§5)、确定性 500 只做新路径窄化(Q4)、ZIP 体积上限、`_summarize` 改法。**更正我方事实**: 初稿称"结果包无 `images/`",实为含图文档才有(E6)。**Codex 独立审**: 一轮已完成,初审结论"否——先修正再审批";逐条核验后修订,见文末"审查记录"。
|
||||||
|
|
||||||
|
**需要人类决定**:
|
||||||
|
|
||||||
|
| # | 决策 | 推荐 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| H1 | §4 公共面(新方法 + 新 Protocol + 继承式元素、`text` 唯一新增字段、嵌套子块独立成元素) | 批准 |
|
||||||
|
| H2 | 既有端点的分类/校验问题: 本设计只在新路径窄化; 以下两项属**医院线既有行为变更**,另开 issue 供评估: (a) 共享 `_classify_status` 对确定性坏输入 500 仍归 Transient; (b) `recognize_text` 不校验 `success`(服务内部异常时返回 200+`success:false`+空文本,被当"合法无文字"且熔断记成功,E13,源码推断未线上复现) | 同意,并授权我建这两个 issue |
|
||||||
|
| H5 | **需求替换**: issue #34 原文提议 `parse_document(data, *, filename 或 media_type)` 由调用方声明类型; 本设计改为库按内容嗅探、**不提供声明参数**(理由见 §3 Q2: 冗余信息、笔误会变熔断事件、前缀垃圾 PDF 已被嗅探覆盖) | 批准替换; 若你更想要显式参数,代价是新增公共参数 + 与嗅探结果不符时的拒绝规则 |
|
||||||
|
| H6 | **有意改变既有方法的上传分支**: `parse_layout(PDF)` 与 `recognize_text(PDF)` 的 PDF 将走 `PymuDocDataset`/`doc.pdf`(前者结果在已测样本上逐字相同,旋转/批注页未测;后者是修复 E4) | 批准 |
|
||||||
|
| H3 | CHSAnalyzer3 是否把"服务不可用"一族按延期重投处理(§6 ⑤)——决定 `RequestRejectedError` 路径是否必要地"关键" | 请确认; 不影响实现,影响下游使用指引 |
|
||||||
|
| H4 | 实现完成后的发布(合并 main、tag、构建、上传 registry、建 Release、关闭 #34)**逐步等你明确指令**,不随实现自动执行 | 同意 |
|
||||||
|
|
||||||
|
## 审查记录
|
||||||
|
|
||||||
|
Codex 一轮独立只读审查(上游无法读取 Gitea issue,需求判断基于设计转述)。结论: **否,先修正**。逐条核验与处置:
|
||||||
|
|
||||||
|
| # | Codex 意见 | 核验 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 阻断 | "2×`timeout_s`"不是总耗时上界,租约安全结论失效 | **属实**: httpcore 逐次读计时,GET 持续分块可超任意多个 `timeout_s`; 租约/探针租约到期语义见 `memory/limiter.py:98-103`、`memory/breaker.py:161-163` | §7 删除\"2×即安全\",改为硬保证只来自 `call_deadline_s ≤ lease_ttl_s`; 测试加慢速持续下载 |
|
||||||
|
| 2 高 | 调用方声明类型被自动嗅探替换,理由与 Q4 自洽性不足; 前缀垃圾 PDF 描述矛盾; \"1024 字节\"依据未核 | **部分属实**: 替换确属需求变更,须显式批准; 前缀垃圾 PDF 服务**实测可解析**,故改嗅探为\"图片魔数优先 → 前 1KB 内 `%PDF-`\",该场景不再被拒; 1024 字节来自 Adobe 阅读器的头部容忍惯例,不是 ISO 32000 正文,已改述为兼容性描述 | §3 Q2 重写 + 三方法结局表; 新增 H5 |
|
||||||
|
| 3 高 | `parse_layout(PDF)`\"零风险/不变\"与 E10 矛盾 | **属实**: 分支确实变了。补做位图型 PDF 差分(逐字相同); 旋转/批注页仍未测 | §8 改述\"有意改变\",新增 H6 |
|
||||||
|
| 4 高 | 成员大小限制不限整包内存; 同步解析使取消/期限失效 | **属实**(`httpx/_client.py:1635-1637` 非流式先 `aread()`; `deadline.py` 基于 `asyncio.timeout` 无法打断同步代码) | §5: document 路径流式 + 整包 256 MiB 上限 + `to_thread` 卸载; 测试加解析期间取消 |
|
||||||
|
| 5 中 | ZIP/结构异常边界缺失,加密成员/未知压缩/巨大整数会漏出原生异常 | **属实**: 实测 `float(10**400)` 抛 `OverflowError`,不在既有异常元组内; 漏出后 `_call` 不写终态行、探针不归还 | §5 明确异常转换边界,§10 加专项测试 |
|
||||||
|
| 6 中 | 逐块文字/空结果/未知类型/编码契约不完整 | **属实** | 新增 §5.1 结构契约表,§10 逐行一条用例 |
|
||||||
|
| 7 中 | bbox 放宽与\"继承使裁剪函数可通吃\"自相矛盾 | **属实** | 改为 `x1≤x2` 且 `y1≤y2`(允许零宽,去掉整数化退化),坐标系/页码约定写明 |
|
||||||
|
| 8 中 | 429 记账写错; 窗口条件写反; 遗漏 `call_deadline_s` | **全部属实**(`memory/breaker.py:84-90,235-244`): \"失败率<0.6\"是健康证据而非\"证据不足\" | §6/§7 订正,增 429 与期限两行 |
|
||||||
|
| 9 低 | `_summarize` 风险描述不准 | **属实**: 实际是漏记 `md_chars` | 措辞订正 |
|
||||||
|
| 引用核对 | `api/main.py:435-441` 行号偏移(分派在 `:164-170`); `config.py:503-507` 应为 `:508`; Redis 键缺 `pgw:` 前缀 | **属实** | 已订正 |
|
||||||
|
|
||||||
|
未采纳: 无(九条均成立,部分处置方式有调整)。**订正后未再送 Codex 复审**——修订量大,建议人类审阅时一并关注 §3 Q2、§5、§5.1、§7。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# #29 分发来源前置核查
|
||||||
|
|
||||||
|
日期:2026-10-01;基线 0e29dea;在 T1 前执行。本记录不是法律认证。
|
||||||
|
|
||||||
|
| 范围 | 实际核查与结论 |
|
||||||
|
|---|---|
|
||||||
|
| 跟踪源码 | `git ls-files src` 列出 polygateway Python 模块与 py.typed,无 vendor 子树、外部 LICENSE/NOTICE、打包外部代码目录 |
|
||||||
|
| 内部移植 | rg 检索移植/参考/Copyright/SPDX/copied/adapted:streaming、sources、Redis Lua、ports、openai_compat 的来源为三个内部项目;用户已明确均本人原创并授权 Apache-2.0 |
|
||||||
|
| 外部参考声明 | 阅读 types.py Effort、providers.py ThinkingWire/注册表、thinking.py 能力证据段;使用的是协议词汇/参数形态/实测结论,未发现来自外部仓的成段实现或第三方版权头;不把相同协议字符串认定为复制实现 |
|
||||||
|
| 打包边界 | pyproject 仅从 src 发现包,reference 不跟踪、不纳入分发;本次不复制外部参考实现 |
|
||||||
|
| 许可来源 | 系统 `/usr/share/common-licenses/Apache-2.0` 提供标准许可正文;构建时核对包内文本与元数据 |
|
||||||
|
|
||||||
|
未发现需额外通知的实际复制片段,因此不编造 NOTICE。外部参考仓含 AGPL 不等于本库受其许可约束;用户对原创代码的授权也不能覆盖第三方。实施/审查若发现新的实际复制证据,停止并提交主管处理,不用本文替代核查。
|
||||||
|
|
||||||
|
## 审查意见处置
|
||||||
|
|
||||||
|
P1 采纳:协议快照缺 role 不可直接进入下一轮,SSE/非流式/缓存统一要求 assistant;不补造 role,默认 text 不变。
|
||||||
|
P2 采纳:将本次来源核查前移到 T0,T6 仅复核及落实 LICENSE/打包/文档。两项均是已批准方向的正确性修订,无需新增用户授权。
|
||||||
@@ -225,6 +225,31 @@
|
|||||||
"id": "finding:2026-09-09-134-thinking-contracts-validation",
|
"id": "finding:2026-09-09-134-thinking-contracts-validation",
|
||||||
"label": "1.3.4 T0–T4 与 T7 确定性验证",
|
"label": "1.3.4 T0–T4 与 T7 确定性验证",
|
||||||
"type": "finding"
|
"type": "finding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"label": "OpenAI消息保真设计",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:2026-10-01-issue29-openai-fidelity",
|
||||||
|
"label": "OpenAI消息保真实现计划",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review:2026-10-01-issue29-cache-review",
|
||||||
|
"label": "保真缓存审查修复与1.4.0准备",
|
||||||
|
"type": "review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:2026-09-10-24-hedged-requests",
|
||||||
|
"label": "issue #24 长尾对冲请求与裸生成时间实施计划",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:2026-09-10-24-hedged-requests-design",
|
||||||
|
"label": "长尾对冲请求(issue #24)设计",
|
||||||
|
"type": "design"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -472,6 +497,48 @@
|
|||||||
"relation": "implements",
|
"relation": "implements",
|
||||||
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
|
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
|
||||||
"added": "2026-09-09T13:42:00.298915+00:00"
|
"added": "2026-09-09T13:42:00.298915+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-10-01-issue29-openai-fidelity",
|
||||||
|
"target": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "已批准方向与独立审查P1/P2修订",
|
||||||
|
"added": "2026-10-01T17:52:42.425248+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "schema:llm-calls",
|
||||||
|
"target": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "复用既有单出口与schema,无新增列;工具error为attempt失败",
|
||||||
|
"added": "2026-10-01T17:52:43.831331+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "review:2026-10-01-issue29-cache-review",
|
||||||
|
"target": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"relation": "informs",
|
||||||
|
"evidence": "统一缓存与transport的有效产物判据,保持内核依赖边界",
|
||||||
|
"added": "2026-10-01T18:27:16.434742+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-09-10-24-hedged-requests",
|
||||||
|
"target": "design:2026-09-10-24-hedged-requests-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "计划头部与设计 §9 批准状态(2026-09-10 H1-H8 全数获批)",
|
||||||
|
"added": "2026-09-10T16:48:37.849653+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-10-21-issue34-ocr-document",
|
||||||
|
"target": "design:2026-10-21-issue34-ocr-document-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "实现 #34 已批准设计 H1/H5/H6",
|
||||||
|
"added": "2026-10-22T02:45:49.844612+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "review:2026-10-22-issue34-branch-review",
|
||||||
|
"target": "plan:2026-10-21-issue34-ocr-document",
|
||||||
|
"relation": "informs",
|
||||||
|
"evidence": "合并前验收与审查结论",
|
||||||
|
"added": "2026-10-22T06:15:33.014451+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+28
-11
@@ -1,9 +1,8 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-09-09 18:01 UTC
|
> 自动生成,更新时间:2026-10-22 06:15 UTC
|
||||||
|
|
||||||
## design (43)
|
|
||||||
|
|
||||||
|
## design (49)
|
||||||
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
|
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
|
||||||
- [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`
|
||||||
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
|
||||||
@@ -25,6 +24,9 @@
|
|||||||
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
|
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
|
||||||
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
|
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
|
||||||
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
|
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
|
||||||
|
- [2026-09-09-136-call-budgets-design](designs/2026-09-09-136-call-budgets-design.md) `design:2026-09-09-136-call-budgets-design`
|
||||||
|
- [2026-09-10-24-hedged-requests-design](designs/2026-09-10-24-hedged-requests-design.md) `design:2026-09-10-24-hedged-requests-design`
|
||||||
|
- [2026-09-11-streaming-stall-design](designs/2026-09-11-streaming-stall-design.md) `design:2026-09-11-streaming-stall-design`
|
||||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
@@ -38,6 +40,8 @@
|
|||||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||||
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
||||||
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
||||||
|
- [OCR 整份文档解析设计(#34)](designs/2026-10-21-issue34-ocr-document-design.md) `design:2026-10-21-issue34-ocr-document-design`
|
||||||
|
- [OpenAI消息保真设计](designs/2026-10-01-issue29-openai-fidelity-design.md) `design:2026-10-01-issue29-openai-fidelity-design`
|
||||||
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
||||||
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||||
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
||||||
@@ -45,21 +49,26 @@
|
|||||||
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
|
||||||
- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort`
|
- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort`
|
||||||
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
|
||||||
|
- [统一完成判定设计(#30/#31)](designs/2026-10-14-issue30-31-completion-verdict-design.md) `design:2026-10-14-issue30-31-completion-verdict-design`
|
||||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||||
|
|
||||||
## finding (17)
|
## finding (22)
|
||||||
|
|
||||||
- [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
|
- [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
|
||||||
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
|
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
|
||||||
- [1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查](findings/2026-09-09-135-release-completion.md) `finding:2026-09-09-135-release-completion`
|
- [1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查](findings/2026-09-09-135-release-completion.md) `finding:2026-09-09-135-release-completion`
|
||||||
|
- [1.3.6 可选调用期限与取消结算验证记录](findings/2026-09-10-136-call-deadline-validation.md) `finding:2026-09-10-136-call-deadline-validation`
|
||||||
|
- [1.3.8 流式中段沉默:净口径与中段对冲验证报告](findings/2026-09-11-streaming-stall-validation.md) `finding:2026-09-11-streaming-stall-validation`
|
||||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||||
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
||||||
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
||||||
|
- [2026-09-11-streaming-stall-hedging-research](findings/2026-09-11-streaming-stall-hedging-research.md) `finding:2026-09-11-streaming-stall-hedging-research`
|
||||||
|
- [2026-10-01-issue29-license-audit](findings/2026-10-01-issue29-license-audit.md) `finding:2026-10-01-issue29-license-audit`
|
||||||
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
||||||
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
|
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
|
||||||
|
- [issue #24 长尾对冲请求与裸生成时间验证报告](findings/2026-09-10-24-hedged-requests-validation.md) `finding:2026-09-10-24-hedged-requests-validation`
|
||||||
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
||||||
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
||||||
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
||||||
@@ -68,10 +77,11 @@
|
|||||||
- [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 (38)
|
## plan (44)
|
||||||
|
|
||||||
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
|
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
|
||||||
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
|
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
|
||||||
|
- [1.3.6 可选调用期限与取消结算修复实施计划](plans/2026-09-10-136-call-deadline.md) `plan:2026-09-10-136-call-deadline`
|
||||||
|
- [1.3.8 流式中段沉默:计量口径与对冲实施计划](plans/2026-09-11-streaming-stall.md) `plan:2026-09-11-streaming-stall`
|
||||||
- [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`
|
||||||
@@ -90,12 +100,15 @@
|
|||||||
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
|
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
|
||||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||||
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
|
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
|
||||||
|
- [issue #24 长尾对冲请求与裸生成时间实施计划](plans/2026-09-10-24-hedged-requests.md) `plan:2026-09-10-24-hedged-requests`
|
||||||
- [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`
|
||||||
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
||||||
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
||||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||||
|
- [OCR 整份文档解析实现计划(#34)](plans/2026-10-21-issue34-ocr-document.md) `plan:2026-10-21-issue34-ocr-document`
|
||||||
|
- [OpenAI消息保真实现计划](plans/2026-10-01-issue29-openai-fidelity.md) `plan:2026-10-01-issue29-openai-fidelity`
|
||||||
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
|
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
|
||||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
- [响应可观测字段扩展实现计划](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`
|
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||||
@@ -106,18 +119,22 @@
|
|||||||
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
|
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
|
||||||
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
|
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
|
||||||
- [推理开关能力建模与 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`
|
||||||
|
- [统一完成判定实现计划(#30/#31)](plans/2026-10-14-issue30-31-completion-verdict.md) `plan:2026-10-14-issue30-31-completion-verdict`
|
||||||
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
- [调用方自定义维度实现计划(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`
|
||||||
|
|
||||||
## review (1)
|
## review (7)
|
||||||
|
- [#34 OCR 整份文档解析 合并前验收与审查报告](reviews/2026-10-22-issue34-branch-review.md) `review:2026-10-22-issue34-branch-review`
|
||||||
|
- [2026-09-11-138-branch-review](reviews/2026-09-11-138-branch-review.md) `review:2026-09-11-138-branch-review`
|
||||||
|
- [2026-09-11-138-verifier](reviews/2026-09-11-138-verifier.md) `review:2026-09-11-138-verifier`
|
||||||
|
- [2026-09-11-streaming-stall-design-review](reviews/2026-09-11-streaming-stall-design-review.md) `review:2026-09-11-streaming-stall-design-review`
|
||||||
|
- [2026-09-11-streaming-stall-plan-review](reviews/2026-09-11-streaming-stall-plan-review.md) `review:2026-09-11-streaming-stall-plan-review`
|
||||||
|
- [保真缓存审查修复与1.4.0准备](reviews/2026-10-01-issue29-cache-review.md) `review:2026-10-01-issue29-cache-review`
|
||||||
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
||||||
|
|
||||||
## schema (1)
|
## schema (1)
|
||||||
|
|
||||||
- [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
- [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls`
|
||||||
|
|
||||||
## metric (2)
|
## metric (2)
|
||||||
|
|
||||||
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
|
||||||
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
|
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
|
||||||
|
|||||||
@@ -160,3 +160,21 @@
|
|||||||
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
|
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
|
||||||
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
|
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
|
||||||
- [2026-09-09 18:01 UTC] 重建索引: 102 篇页面
|
- [2026-09-09 18:01 UTC] 重建索引: 102 篇页面
|
||||||
|
- [2026-09-10 16:48 UTC] 新增边: plan:2026-09-10-24-hedged-requests --implements--> design:2026-09-10-24-hedged-requests-design
|
||||||
|
- [2026-09-10 16:48 UTC] 重建索引: 107 篇页面
|
||||||
|
- [2026-10-01 17:52 UTC] 新增 design: OpenAI消息保真设计 (design:2026-10-01-issue29-openai-fidelity-design)
|
||||||
|
- [2026-10-01 17:52 UTC] 新增 plan: OpenAI消息保真实现计划 (plan:2026-10-01-issue29-openai-fidelity)
|
||||||
|
- [2026-10-01 17:52 UTC] 新增边: plan:2026-10-01-issue29-openai-fidelity --implements--> design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
- [2026-10-01 17:52 UTC] 新增边: schema:llm-calls --implements--> design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
- [2026-10-01 17:52 UTC] 重建索引: 119 篇页面
|
||||||
|
- [2026-10-01 18:24 UTC] 新增 review: 保真缓存审查修复与1.4.0准备 (review:2026-10-01-issue29-cache-review)
|
||||||
|
- [2026-10-01 18:27 UTC] 新增边: review:2026-10-01-issue29-cache-review --informs--> design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
- [2026-10-01 18:27 UTC] 重建索引: 120 篇页面
|
||||||
|
- [2026-10-02 02:23 UTC] 重建索引: 120 篇页面
|
||||||
|
- [2026-10-22 02:35 UTC] 重建索引: 123 篇页面
|
||||||
|
- [2026-10-22 02:45 UTC] 新增边: plan:2026-10-21-issue34-ocr-document --implements--> design:2026-10-21-issue34-ocr-document-design
|
||||||
|
- [2026-10-22 02:45 UTC] 重建索引: 124 篇页面
|
||||||
|
- [2026-10-22 04:02 UTC] 重建索引: 124 篇页面
|
||||||
|
- [2026-10-22 04:23 UTC] 重建索引: 124 篇页面
|
||||||
|
- [2026-10-22 06:15 UTC] 新增边: review:2026-10-22-issue34-branch-review --informs--> plan:2026-10-21-issue34-ocr-document
|
||||||
|
- [2026-10-22 06:15 UTC] 重建索引: 125 篇页面
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ date: 2026-07-20
|
|||||||
|
|
||||||
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
|
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 |
|
| 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout、parse_document 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 |
|
||||||
| chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 |
|
| chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 |
|
||||||
| 四种行来源 | 真实成功=applied;失败=effective;cache_hit/scope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 |
|
| 四种行来源 | 真实成功=applied;失败=effective;cache_hit/scope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 |
|
||||||
| live 覆盖 | 逐轮 PASS/FAIL/UNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS |
|
| live 覆盖 | 逐轮 PASS/FAIL/UNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS |
|
||||||
@@ -31,3 +31,9 @@ date: 2026-07-20
|
|||||||
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
|
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
|
||||||
|
|
||||||
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
|
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
|
||||||
|
|
||||||
|
## #29 验收增量(2026-10-01)
|
||||||
|
|
||||||
|
复用指标及 llm_calls,不另建表:error→正常 attempts=2、tool-only attempts=1、
|
||||||
|
cache_hit attempts=0;协议失败不写缓存,取消不得遗留 permit/task。
|
||||||
|
确定性证据位于 `tests/unit/test_openai_fidelity.py`;完整协议不新增遥测正文列。
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ date: 2026-07-21
|
|||||||
|
|
||||||
# OCR 治理调用成功率与错误分类分布
|
# OCR 治理调用成功率与错误分类分布
|
||||||
|
|
||||||
- **数据源**: `llm_calls` 表(复用,无新表;M3 设计 §7)。OCR 行特征: `provider="monkey"`、tokens=0、cost=NULL、ttft/inter=NULL、cache_hit=0;`messages` 为 `<ocr:text|layout image_bytes=N>` 占位(图像 bytes 绝不入库),`response` 为 text 截断 200 字 / `<elements n=X pages=Y>` 摘要。
|
- **数据源**: `llm_calls` 表(复用,无新表;M3 设计 §7)。OCR 行特征: `provider="monkey"`、tokens=0、cost=NULL、ttft/inter=NULL、cache_hit=0;`messages` 为 `<ocr:text|layout|document image_bytes=N>` 占位(图像 bytes 绝不入库),`response` 为 text 截断 200 字 / `<elements n=X pages=Y>` 摘要。
|
||||||
- **埋点位置**(唯一路径,禁止旁路): `src/polygateway/ocr.py` OcrClient 治理循环内经 `TelemetryEmitter.emit_attempt`(单 helper 铁律),每次尝试(成功/失败/取消)必录;写失败 warning 降级不冒泡。
|
- **埋点位置**(唯一路径,禁止旁路): `src/polygateway/ocr.py` OcrClient 治理循环内经 `TelemetryEmitter.emit_attempt`(单 helper 铁律),每次尝试(成功/失败/取消)必录;写失败 warning 降级不冒泡。
|
||||||
- **指标口径**:
|
- **指标口径**:
|
||||||
- 调用成功率 = `error IS NULL` 行数 / 总行数(按 provider="monkey" 过滤,窗口按 run/日);
|
- 调用成功率 = `error IS NULL` 行数 / 总行数(按 provider="monkey" 过滤,窗口按 run/日);
|
||||||
- 错误分类分布 = `error` 非空行按异常类名前缀(Transient/SourceDead/RequestRejected/ResultInvalid/CircuitOpen/AllSourcesExhausted)归组计数;
|
- 错误分类分布 = `error` 非空行按异常类名前缀(Transient/SourceDead/RequestRejected/ResultInvalid/CircuitOpen/AllSourcesExhausted)归组计数;
|
||||||
- 端点维度经 `messages` 占位前缀(`ocr:text` vs `ocr:layout`)区分;
|
- 端点维度经 `messages` 占位前缀(`ocr:text` / `ocr:layout` / `ocr:document`)区分,或直接用 `operation` 列(`recognize_text` / `parse_layout` / `parse_document`);文档行的 `response` 摘要为 `<document elements=N pages=M md_chars=K>`,**不含正文**;
|
||||||
- 延迟 = `latency_ms` 分位数(text 与 layout 分列——layout 含两段 HTTP,显著更慢)。
|
- 延迟 = `latency_ms` 分位数(text、layout、document 分列——后两者含两段 HTTP,显著更慢;document 随页数增长,约 1.5–4 s/页[2026-10-21 实测:2 页 3.5–8 秒、4 页 6.2–6.4 秒;样本极少、无争用,非 SLA],不要与 layout 混算分位数)。
|
||||||
- **评估基线**: 待 P7 OCR soak 首跑后建立(健康双真源池预期成功率 ≥98%,故障池口径阈值在 P7 计划冻结);单调用延迟基线以 integration 实测为初值(2026-07-21 手测: /ocr/text 约 3-5s,/parse 约 5-15s)。
|
- **评估基线**: 待 P7 OCR soak 首跑后建立(健康双真源池预期成功率 ≥98%,故障池口径阈值在 P7 计划冻结);单调用延迟基线以 integration 实测为初值(2026-07-21 手测: /ocr/text 约 3-5s,/parse 约 5-15s)。
|
||||||
|
|||||||
@@ -202,3 +202,19 @@ stack = ExtractionProviderStack(
|
|||||||
| **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer |
|
| **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer |
|
||||||
| 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 |
|
| 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 |
|
||||||
| 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 |
|
| 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 |
|
||||||
|
|
||||||
|
## 1.3.10 影响核对(2026-10-14,#30/#31)
|
||||||
|
|
||||||
|
CHS 已把 `ResultInvalidError` 作终态(坏结果≠坏服务,熔断记成功),1.3.10 文本 length/过滤且无正文落该分类与既有语义同向,worker `_TERMINAL` 分支无需改;缺 `[DONE]` 而完整的流从此直接成功。
|
||||||
|
|
||||||
|
## 1.3.11 CHSAnalyzer3 的整份文档消费路径(2026-10-22,#34)
|
||||||
|
|
||||||
|
CHSAnalyzer3 要把几百篇医学论文 PDF(每篇约 8–15 页,并发 ≤2,与医院线共用 MonkeyOCR)整份转成带页码的正文与表格。库侧提供 `OcrClient.parse_document`(设计 `designs/2026-10-21-issue34-ocr-document-design.md`):
|
||||||
|
|
||||||
|
| 事项 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 入口 | `await ocr.parse_document(pdf_bytes, tenant_id=..., meta=...)`;结果 `elements[]`(`type`/`page_index`/`bbox`/`text`)、`page_sizes`、`markdown`。`parse_layout` 医院线路径**不动** |
|
||||||
|
| 表格 | `type=="table"` 的元素 `text` 是 HTML 表体;**表格脚注、页眉页脚、页码在服务端即被丢弃**(显著性/缩写说明会缺失),库无从补偿——抽取逻辑不要假设能拿到 |
|
||||||
|
| 部署 | 文档线用独立 scope(如 `OCR_DOC`),`MAX_CONCURRENCY=2`、`RETRY__MAX_ATTEMPTS=2`、`CALL_DEADLINE_S ≤ PGW_LEASE_TTL_S`、`TIMEOUT_S` 按最大页数估算;配方见 README「1.3.11 OCR 整份文档解析」。若服务为 sync-only 后端,每实例并发 1 并错峰,避免队头阻塞医院线 |
|
||||||
|
| 坏文件 | 无法识别的字节本地拒绝;截断/伪造 PDF 的已知确定性 500 归 `RequestRejectedError`(`status_code=500`),不重试、不计熔断。**页数过滤、可读性预检归调用方** |
|
||||||
|
| ⚠️ 待确认(H3) | 本文件 §9 记录 CHSAnalyzer 把库侧「服务不可用」一族(`GatewayUnavailableError`:`CircuitOpenError`/`AllSourcesExhausted`)翻译成 `ProviderUnavailableError` → arq 延期重投且不耗业务失败预算。**CHSAnalyzer3 是否沿用该语义未向下游证实**: 若沿用,一份永久损坏的 PDF 若落入 `AllSourcesExhausted(retry_exhausted)`(例如共享分类对未收录的确定性 500 仍归瞬时,见 #35)会被无限延期——请在 CHSAnalyzer3 侧为「同一文档连续 N 次延期」设上限 |
|
||||||
|
|||||||
@@ -170,3 +170,7 @@ loop = AgentLoop(client, max_steps=...,
|
|||||||
| R7 | 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 | M1 |
|
| R7 | 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 | M1 |
|
||||||
|
|
||||||
以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费**零改动**,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、`.env` 改键。
|
以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费**零改动**,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、`.env` 改键。
|
||||||
|
|
||||||
|
## 1.3.10 影响核对(2026-10-14,#30/#31)
|
||||||
|
|
||||||
|
装配处 `retryable_exceptions=(TransientError, AllSourcesExhausted)`(§4)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,AgentLoop 步级重试**不再**重抽该步——这正是本次修复的意图(确定性结局重抽只花钱)。业务侧若想对 length 截断做"加大 max_tokens 再试",须显式 except `ResultInvalidError` 并按异常消息原因码 `length_without_content` 分支;其余缺 `[DONE]` 而完整的流(#31)从此直接成功。
|
||||||
|
|||||||
@@ -200,3 +200,7 @@ ocr = FrameOcrAdapter(OcrTextPort_from_env()) # 业务适配器包装库 OCR 端
|
|||||||
---
|
---
|
||||||
|
|
||||||
*不确定项声明*: 库侧 `from_env(role=...)`、OCR 装配工厂的具体 API 名称以 M1/M3 设计文档定稿为准,本文 §5.2/§6 中相应伪代码届时同步修订;`telemetry.db` 是否存在项目外的离线分析脚本消费方未能实测(项目内 grep 无读取方)。
|
*不确定项声明*: 库侧 `from_env(role=...)`、OCR 装配工厂的具体 API 名称以 M1/M3 设计文档定稿为准,本文 §5.2/§6 中相应伪代码届时同步修订;`telemetry.db` 是否存在项目外的离线分析脚本消费方未能实测(项目内 grep 无读取方)。
|
||||||
|
|
||||||
|
## 1.3.10 影响核对(2026-10-14,#30/#31)
|
||||||
|
|
||||||
|
AgentLoop 注入的 `(TransientError, AllSourcesExhausted)`(§4 点 3)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,步级重试不再重抽该步(意图如此)。需要"加大 max_tokens 再试"的业务须显式 except 并按原因码 `length_without_content` 分支。§保留项 3"缺 `[DONE]` 判瞬时、不写缓存"在 1.3.10 收窄为"无 finish 或 finish 后无实测 usage 才算截断";finish+实测 usage+干净 EOF 视完整。
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ generation_sink.append((wall_ms, stall_ms_i, stall_count_i))
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| loader | `_load_hedge`(:745-769)扩读 `STALL_AFTER_S`:`_first` + `_cast` + `ensure_call_deadline`(origin 传实际命中键名),返回字典加 `"hedge_stall_after_s"` 键;未设 = None(关闭) |
|
| loader | `_load_hedge`(:745-769)扩读 `STALL_AFTER_S`:`_first` + `_cast` + `ensure_call_deadline`(origin 传实际命中键名),返回字典加 `"hedge_stall_after_s"` 键;未设 = None(关闭) |
|
||||||
| 字段 | `GatewaySettings` 加 `hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传 |
|
| 字段 | `GatewaySettings` 加 `hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传 |
|
||||||
| 守卫(全部落进 `check_hedge_assembly` :772-849,签名加 keyword-only `stall_after_s` 参;**返回契约钉死(审查 B2)**:返回 `tuple[float | None, float | None]` = (归一化 after_s, 归一化 stall_after_s),`config.py:384` 与 `client.py:252` 两处调用点随之解构,`test_config.py:1174/:1184` 列入 T4 同步清单(`is None` 断言按新返回形态改写);**两阈值皆 None 才早退**——"同 `after is None` 早退形态"字面执行会让仅 stall 配置绕过守卫②③④,禁止) | ①值域 = `ensure_call_deadline` 同款(有限正数);②`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,与 openai_compat.py:573 回落同款)→ **ValueError**(对冲窗永不开,装配期直接拒绝;stall 场景无非流式残留收益,warning 论证不足,N5);③`stall_after_s ≥ call_deadline_s`(两者皆设)→ ValueError(与 `after_s` 同款,config.py:825-828 邻域);④单源 scope 设阈值 → warning 允许(1.3.7 §5 同款);⑤**B4**:Embedding/OCR scope 装配 warning 落 `EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`):内嵌 gateway 的 `hedge_after_s` **或** `hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化"(1.3.7 `AFTER_S` 同病顺带补齐,缺陷修复不扩公共面);chat 侧装配 warning 明确不做(per-call stream,装配期无信号,B4 裁决) |
|
| 守卫(全部落进 `check_hedge_assembly` :772-849,签名加 keyword-only `stall_after_s` 参;**返回契约钉死(审查 B2)**:返回 `tuple[float | None, float | None]`= (归一化 after_s, 归一化 stall_after_s),`config.py:384` 与 `client.py:252`两处调用点随之解构,`test_config.py:1174/:1184`列入 T4 同步清单(`is None` 断言按新返回形态改写);**两阈值皆 None 才早退**——"同 `after is None` 早退形态"字面执行会让仅 stall 配置绕过守卫②③④,禁止) | ①值域 = `ensure_call_deadline` 同款(有限正数);②`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,与 openai_compat.py:573 回落同款)→ **ValueError**(对冲窗永不开,装配期直接拒绝;stall 场景无非流式残留收益,warning 论证不足,N5);③`stall_after_s ≥ call_deadline_s`(两者皆设)→ ValueError(与 `after_s` 同款,config.py:825-828 邻域);④单源 scope 设阈值 → warning 允许(1.3.7 §5 同款);⑤**B4**:Embedding/OCR scope 装配 warning 落 `EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`):内嵌 gateway 的 `hedge_after_s` **或** `hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化"(1.3.7 `AFTER_S` 同病顺带补齐,缺陷修复不扩公共面);chat 侧装配 warning 明确不做(per-call stream,装配期无信号,B4 裁决) |
|
||||||
| client | `GatewayClient.__init__` 加 `hedge_stall_after_s: float \| None = None` keyword-only,:252-257 守卫调用扩传;RetryMW 构造(:264-283)传 `stall_after_s=` 归一化值(取守卫返回二元组第二元,B2);`from_settings`(:525 邻域)透传 |
|
| client | `GatewayClient.__init__` 加 `hedge_stall_after_s: float \| None = None` keyword-only,:252-257 守卫调用扩传;RetryMW 构造(:264-283)传 `stall_after_s=` 归一化值(取守卫返回二元组第二元,B2);`from_settings`(:525 邻域)透传 |
|
||||||
|
|
||||||
### 3.6 T4:retry 编排改动(P2 唯一实现形态)
|
### 3.6 T4:retry 编排改动(P2 唯一实现形态)
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-10-01-issue29-openai-fidelity
|
||||||
|
title: OpenAI消息保真实现计划
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# #29 OpenAI 消息保真实现计划
|
||||||
|
|
||||||
|
> 状态:计划及首轮代码独审已完成,P1/P2 均采纳;缓存空产物和测试双向依赖已修复,发布目标按人类最终指定改为 1.3.9(取代未发布的 1.4.0),最终独审与 T8 外部发布仍由父会话执行。
|
||||||
|
> 设计:[批准方向的设计落实稿](../designs/2026-10-01-issue29-openai-fidelity-design.md)。
|
||||||
|
> 目标:同一 chat 治理链返回可供下游继续工具对话的完整单 choice 协议结果,拒绝截断和 error 帧伪成功。
|
||||||
|
> 方案:增加 response_mode 与嵌套 completion;协议解析与聚合内聚在 transport 内部模块,治理层仅传模式/搬结果;旧文本默认保持兼容。
|
||||||
|
> 技术:Python 3.12+、frozen dataclass、httpx SSE、现有 asyncio 看门狗/对冲、pytest、真实 Redis;不加运行时依赖。
|
||||||
|
|
||||||
|
## 0. 工作区、审批与执行纪律
|
||||||
|
|
||||||
|
| 项目 | 已核验值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工作目录 | `/home/iomgaa/.config/superpowers/worktrees/PolyGateway/issue29-openai-fidelity` |
|
||||||
|
| 分支 | `feat/issue29-openai-fidelity` |
|
||||||
|
| 基线 | `0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8 |
|
||||||
|
| 原工作区 | `/home/iomgaa/Projects/PolyGateway`,main 上已有未提交文档与 .pi,不得覆盖或夹带提交 |
|
||||||
|
| 基线命令 | `conda run -n PolyGateway pytest tests/unit -q` |
|
||||||
|
| 基线结果 | 1627 passed in 12.25s;仅单元基线,不代表集成/slow/新增功能通过 |
|
||||||
|
|
||||||
|
所有 Python 命令用 PolyGateway conda 环境;始终从上述隔离目录执行,确认 pytest 配置 pythonpath=src,避免误测主工作区 editable 安装。不要复制主树 `.env` 到受跟踪路径;需要真实验证时显式读取已有配置,不输出秘密。reference/ 不存在于新 worktree 的跟踪树,不为规划复制/修改参考仓。
|
||||||
|
|
||||||
|
技术方向、内部原创权属和 Apache-2.0 已获用户明确批准;细化设计与计划由独立 subagent 审查,Codex 不再调用。只在新产品/权利边界未获授权时暂停。父会话掌握审查、后续编码、合并/发布权限;当前任务不执行这些步骤。
|
||||||
|
|
||||||
|
本计划不涉及参考实现迁移,保真校验不适用;涉及既有移植流式看门狗的消费面,必须对照本库 `streaming.py`、1.3.8 stall 设计逐段保持其超时/取消/换算语义,不能借工具支持改算法。
|
||||||
|
|
||||||
|
## 1. 文件结构与职责锁定
|
||||||
|
|
||||||
|
| 路径(相对工作目录) | 动作与职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/polygateway/types.py` | 新 ResponseMode/OpenAIChatCompletion;末尾追加 ChatRequest.response_mode、两个响应 completion |
|
||||||
|
| `src/polygateway/__init__.py` | 导出新公共类型;最终发布时同步版本 |
|
||||||
|
| `src/polygateway/ports.py` | complete 增必填模式参数;说明工具进展与单 choice 契约 |
|
||||||
|
| `src/polygateway/client.py` | chat 新模式校验、消息/overlay 快照、组合拒绝;不重写治理入口 |
|
||||||
|
| `src/polygateway/transports/_openai_completion.py`(新) | 协议结构校验、每尝试聚合、非流式快照;无 I/O/计时 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | error 检查、解析器接线、投影/usage/空补全/DONE、统一进展事件 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 模式传入、completion 搬运;无重试/对冲算法改写 |
|
||||||
|
| `src/polygateway/middleware/cache.py` | 模式 key、嵌套序列化/重建、缺协议 miss、序列化降级 |
|
||||||
|
| `tests/unit/test_openai_completion.py`(新) | 纯协议聚合与非流式校验的源命名测试 |
|
||||||
|
| `tests/unit/test_openai_compat.py` | 真实 transport 的 SSE/非流式/error/raw/DONE/活性测试 |
|
||||||
|
| `tests/unit/test_types.py`, `test_client.py`, `test_ports.py`, `test_cache.py` | 公共类型、校验、端口、缓存往返与兼容 |
|
||||||
|
| `tests/unit/test_retry.py`, `test_hedge.py`, `test_stall_hedge.py`, `test_deadline.py`, `test_backpressure.py` | 模式传递、错误重试、工具活性/对冲、取消与结算 |
|
||||||
|
| `tests/integration/test_governance_stack.py`, `test_redis_cache.py`, `test_redis_cross_connection.py` | 完整链路、真实缓存往返、必要 fake 签名适配 |
|
||||||
|
| `tests/e2e/conftest.py` | 现有取证 transport 包装显式转传模式,不改变 thinking 矩阵判据 |
|
||||||
|
| `tests/e2e/test_openai_tools_live.py`(新) | 受控少量真实工具双轮冒烟,独立 slow 用例 |
|
||||||
|
| `tests/unit/test_package.py` | LICENSE 文件/包元数据回归 |
|
||||||
|
| `LICENSE`, `pyproject.toml` | 标准 Apache-2.0 与打包元数据;不捆绑 reference/ |
|
||||||
|
| `README.md`, `CHANGELOG.md`, `.env.example`, `research-wiki/ARCHITECTURE.md` | 新模式、限制、raw 配置、缓存迁移、同步桥接说明 |
|
||||||
|
| `research-wiki/findings/2026-10-01-issue29-license-audit.md`(新) | 分发源码第三方来源核查证据与范围 |
|
||||||
|
| `research-wiki/graph/edges.json`, `index.md`, `log.md`, `schemas/llm-calls.md`, `metrics/call-telemetry-coverage.md` | 审查通过后的设计/计划/复用遥测关系注册,不新增表或指标实体 |
|
||||||
|
|
||||||
|
其余测试若显式调用 complete 必须补参数,仅机械适配;不重构不相关 fixture。`thinking.py`/能力表/streaming.py 算法不在预期改动内。不要为“兼容 SDK”创建不存在的 openai_sdk.py。
|
||||||
|
|
||||||
|
## 2. 跨任务接口
|
||||||
|
|
||||||
|
T1 定义公共字段,T2–T5 均消费同一类型名,禁止再起 tool_response/raw_response 别名。
|
||||||
|
|
||||||
|
```python
|
||||||
|
ResponseMode = Literal["text", "openai"]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenAIChatCompletion:
|
||||||
|
"""单 choice 协议结果,message/usage 是独立 JSON 快照。"""
|
||||||
|
message: dict[str, Any]
|
||||||
|
finish_reason: str | None
|
||||||
|
response_id: str | None
|
||||||
|
usage: dict[str, Any] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
公共与端口变化均是追加参数/字段:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GatewayClient.chat 的末尾 keyword-only 参数;其余参数保持原样。
|
||||||
|
response_mode: ResponseMode = "text"
|
||||||
|
# ChatRequest 末尾字段。
|
||||||
|
response_mode: ResponseMode = "text"
|
||||||
|
# Transport.complete 末尾必填 keyword-only 参数。
|
||||||
|
response_mode: ResponseMode
|
||||||
|
# TransportResult / LLMResponse 末尾字段。
|
||||||
|
completion: OpenAIChatCompletion | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
`build_cache_key` 在已有 keyword-only 参数之后增加 `response_mode: ResponseMode = "text"`,仅非 text 时参与摘要。缓存重建只依赖内核类型/本层校验,不 import transports;若复用协议类型重建的最低形态检查,把纯 JSON 快照检查放类型自身的具名构造方法,不把 transport 的 SSE 状态机下沉到内核。
|
||||||
|
|
||||||
|
T2 内部模块只由同目录 transport 消费,不成为公共导出。采用以下窄接口(内部类型在该模块定义):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatDelta:
|
||||||
|
"""一帧提取结果;progress 表示真实生成进展。"""
|
||||||
|
content: str
|
||||||
|
reasoning_content: str
|
||||||
|
progress: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChatCompletionAccumulator:
|
||||||
|
"""每次 transport 尝试独享的单 choice 聚合状态。"""
|
||||||
|
def __init__(self, *, response_mode: ResponseMode) -> None: ...
|
||||||
|
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta: ...
|
||||||
|
def finish(self) -> OpenAIChatCompletion: ...
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
上述 `...` 是签名约定而非待交付占位实现。聚合器抛含判据的 TransientError(operation=chat);openai_compat 负责补选中源上下文、复用有界摘要。聚合器不判断 DONE、不读时钟;transport 掌握线路完整性。text 模式可共用帧提取与进展识别,但不调用严格 openai finish,保持旧文本工具容忍边界。
|
||||||
|
|
||||||
|
## 3. 分任务与独立验收
|
||||||
|
|
||||||
|
### T0 — 审查与来源前置核查
|
||||||
|
|
||||||
|
- [x] T1 前已核查分发来源,见 findings/2026-10-01-issue29-license-audit.md;发现不兼容复制即停止。
|
||||||
|
- [ ] 父会话安排全新上下文原生 subagent 审查设计/计划,重点:单 choice、未知增量字段、身份重复/冲突、raw/managed、缓存错误降级及取消。
|
||||||
|
- [ ] 核对每条意见,修订文档并记录接受/不接受的理由;如改变用户已批边界,走主管决定,不静默改契约。
|
||||||
|
- [ ] 在隔离树注册文档关系,复用 llm-calls schema/call-telemetry-coverage 指标;不在主树运行生成器。
|
||||||
|
- [ ] 文档审查通过后才调用 commit skill 提交设计/计划;当前阶段禁止提交。
|
||||||
|
|
||||||
|
验证命令:`wc -l research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md research-wiki/plans/2026-10-01-issue29-openai-fidelity.md` → 分别 ≤400、≤1000;`git diff --check` → 无错误。Wiki 命令经 `conda run -n PolyGateway python .claude/tools/research_wiki.py --help` 核对实际参数后执行 add_entity/add_edge/rebuild_index;检查生成 diff 不改原有条目正文。
|
||||||
|
|
||||||
|
### T1 — 公共契约贯穿完整治理链
|
||||||
|
|
||||||
|
文件:types.py、__init__.py、ports.py、client.py、middleware/retry.py;单元 types/client/ports/retry;所有显式 fake complete 签名。
|
||||||
|
|
||||||
|
先补测试:默认 text 不变、新类型可导入、末尾带默认字段不破旧构造;非法 response_mode、openai+structured、n=2/0/True 在适用边界拒绝;保真消息与嵌套 overlay 脱离调用方容器,返回上一轮 assistant 和 role=tool 请求 JSON 逐字段相同。
|
||||||
|
|
||||||
|
实现:chat 校验放在 context/handler 前;选源后非法 source.extra_body.n 在 transport HTTP 前 RequestRejected;模式作为必填端口参数穿透重试/对冲,_build_response 原样搬 completion。不把模式塞进发给上游的 payload。
|
||||||
|
|
||||||
|
验收:调用级拒绝不发 HTTP;源级拒绝结算/释放正常;自定义 transport fake 看见准确模式;既有旧构造回归全绿。命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_client.py tests/unit/test_ports.py tests/unit/test_retry.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
先红测试须为行为断言或缺失 API 的明确证据,记录基线 SHA 与测试名;绿后提交点:`feat: add explicit OpenAI response contract`。
|
||||||
|
|
||||||
|
### T2 — 内聚协议解析器与 error 帧修复
|
||||||
|
|
||||||
|
文件:新增 _openai_completion.py、openai_compat.py;新增 test_openai_completion.py,扩展 test_openai_compat.py。
|
||||||
|
|
||||||
|
补测 SSE/非流式/缓存缺 role、非 assistant role;合法 role-only 起始帧后工具成功。先红测试至少含下表各组;复用现有 SSE/MockTransport,不引入第二套 HTTP fake 框架。
|
||||||
|
|
||||||
|
| 组 | 输入与精确预期 |
|
||||||
|
| --- | --- |
|
||||||
|
| 并行工具 | 2 工具交错 index;同帧两工具;name/arguments 分片;空白/Unicode/引号逐字相等;最终按 index 排序 |
|
||||||
|
| 身份 | id/type 同值重复只保留一次,冲突失败;缺 id/type/name/arguments、重复 id、bool/负数 index、非 function 类型失败 |
|
||||||
|
| 文本 | 同帧正文+推理均保留;缺省/null/空串区别;原消息 think 标签不剥,旧投影仍剥 |
|
||||||
|
| envelope | 顶层数组/null、choices/delta/tool_calls 非容器、坏字段类型、多 choice/非零 choice index 均受控失败 |
|
||||||
|
| 非流式 | 完整消息、扩展字段、usage/finish/id 全保留;工具参数不做业务 JSON 修复;原响应对象改动不影响快照 |
|
||||||
|
| error | 首帧 error、正文后 error+usage+DONE、error=null、error={}、salvage 配置下 error 都立即失败;text/openai 两模式参数化 |
|
||||||
|
|
||||||
|
复用现有 summarize_body/compose_message,断言异常与遥测诊断有界,不打印工具参数正文。默认文本模式不因无用工具字段的严格 finish 校验而新增失败,但不吞顶层 error。无新 HTTP 状态映射。
|
||||||
|
|
||||||
|
命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_http_error_body.py -q` → PASS;提交点:`fix: preserve chat deltas and reject upstream error frames`。
|
||||||
|
|
||||||
|
### T3 — 完成语义、活性与非流式接线
|
||||||
|
|
||||||
|
文件:openai_compat.py、ports.py docstring;test_openai_compat.py、test_streaming.py。
|
||||||
|
|
||||||
|
先红:tool-only + DONE 成功 attempts=1;空文本且空工具失败;正文+坏工具不成功;openai 缺 DONE 即使 missing_done=salvage 仍失败;text salvage 保持;finish_reason=length 原样交回,不解析 arguments。非流式工具同样成功,两事件永不置位、ttft/stall=None。
|
||||||
|
|
||||||
|
按设计接线:一帧一进展,同帧多个字段只记一个 chunk_time;真正非空工具增量/首次身份触发事件,重复身份和 role/usage/finish/空片段不触发。看门狗仍包进展迭代器,stall 派生/净额取整不动。
|
||||||
|
|
||||||
|
测试以受控 Event/FakeClock 驱动,避免用几十毫秒竞争制造脆弱测试:持续工具片段撑过 TTFT/inter-token;只有注释/usage 必超时;工具首进展设置 first_token_event;真实停顿计入 max_inter_token/stall,两字段与正文相同口径。
|
||||||
|
|
||||||
|
命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_streaming.py -q` → PASS;提交点:`feat: complete tool responses with unified stream liveness`。
|
||||||
|
|
||||||
|
### T4 — 完整协议缓存往返与隔离
|
||||||
|
|
||||||
|
文件:middleware/cache.py;test_cache.py、tests/integration/test_redis_cache.py。
|
||||||
|
|
||||||
|
先红测试:同消息 text/openai key 不同、旧默认 text key 字节不变;tools、tool_choice、raw effort、tool result、assistant arguments 改动均 miss;相同请求完整 completion round-trip,非流式扩展字段不丢,message 的 null/缺省可区别;usage 缺省/null 均为 None;openai 缺 completion/null/坏 message/坏工具条目 miss 后回源,不冒 dict 假装 dataclass。
|
||||||
|
|
||||||
|
检查命中历史 response_id 不变、本次 call_id 重建、attempts=0、cache_hit=true、cost=0;变更一次命中消息不污染下一次命中;旧 text 缓存继续可读。失败路径无 set;序列化/backend set 抛错 warning 后仍返回结果;CancelledError 原样穿透。
|
||||||
|
|
||||||
|
实现保真重建校验不能 import transport(import-linter 必守);内核值类型可提供纯值构造校验,协议拼接算法只保留在 transport;避免两份 SSE/tool 聚合逻辑。
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_cache.py -q
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q
|
||||||
|
conda run -n PolyGateway lint-imports
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 Redis 专用测试 namespace,沿既有 fixture 清理自有 key,不 flush 共享 DB;未配置 skip 必须记录为未覆盖,发布前补实测。提交点:`fix: isolate and round-trip OpenAI completion caches`。
|
||||||
|
|
||||||
|
### T5 — 完整链路、对冲、取消与遥测不变量
|
||||||
|
|
||||||
|
文件:test_retry.py、test_hedge.py、test_stall_hedge.py、test_deadline.py、test_backpressure.py、tests/integration/test_governance_stack.py、test_redis_cross_connection.py、tests/e2e/conftest.py;生产 retry 仅修必要搬运漏项,不改算法。
|
||||||
|
|
||||||
|
先红→绿证据须覆盖真实 OpenAICompatTransport 接入 GatewayClient,而不是只用返回预制 completion 的 fake 证明解析成功:
|
||||||
|
|
||||||
|
| 场景 | 断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第一次部分正文/工具→error→DONE,第二次正常 | 只返回第二次 completion,attempts=2;第一次 error 的字段不会污染结果或缓存 |
|
||||||
|
| 工具持续进展 | 首 token 对冲及时止窗,中段 event 复位,未错误触发 |
|
||||||
|
| 真沉默触发对冲 | 副路完整工具结果胜出,返回自己的 id/usage,输家不泄露半成品 |
|
||||||
|
| primary 超时但副路尚在运行 | 不提前判总失败;两败仍只烧一次超级尝试预算 |
|
||||||
|
| 流中/准入中/退避中取消与 deadline | CancelledError/CallDeadlineExceeded 类型保留,permit/inflight/task 收口,TPM 保守结算 |
|
||||||
|
| 观测与降级 | 正常/失败/cache_hit 遥测沿单 helper;attempt 行/terminal 行口径不变;后端写失败不影响结果 |
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_hedge.py tests/unit/test_stall_hedge.py tests/unit/test_deadline.py tests/unit/test_backpressure.py -q
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_governance_stack.py tests/integration/test_redis_cross_connection.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
不删除“不需要失败计费”对应的现有失败遥测/attempts/结算;本任务不新增失败 token 取证。提交点:`test: cover governed tool retries hedges and cancellation`。
|
||||||
|
|
||||||
|
### T6 — 来源审计、Apache-2.0 与用户文档
|
||||||
|
|
||||||
|
文件:LICENSE、pyproject.toml、test_package.py、新 license-audit finding、README、CHANGELOG、.env.example、ARCHITECTURE;设计/计划 Wiki 注册关系。
|
||||||
|
|
||||||
|
| 工作 | 验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| 来源审计复核(初审前置 T0) | 查 tracked 分发文件的移植/参考/版权注释及具体片段;列内部原创授权记录,外部只读 reference 的路径/许可/实际使用范围;未发现不等于法律认证 |
|
||||||
|
| 第三方义务 | 对实际复制片段核实许可、保留通知;发现 AGPL 不兼容复制须上报,不借用户授权覆盖第三方 |
|
||||||
|
| LICENSE | 使用标准 Apache-2.0 全文,不自行重写;版权归属沿项目元数据,不猜用户真实姓名;NOTICE 仅在实际需要时新增 |
|
||||||
|
| 包元数据 | 当前 setuptools>=68,采用 `license = {file = "LICENSE"}` 兼容现有后端;验证 wheel/sdist 包含 license,不为 PEP 639 单独提高构建依赖 |
|
||||||
|
| 文档 | 新 chat 示例 ≤15 行;response_mode 与 raw 所有权分开;单 choice、未知 SSE 方言失败、非流式、length、无 DONE 不打捞、缓存旧值迁移、取消/线程桥接限制写清 |
|
||||||
|
|
||||||
|
`.env.example` 只解释 raw-only 必须去掉源级受管意图,不加模式 env 键。README 应说明“完整协议结果”仅四字段及已定义 SSE 字段,不能暗示返回所有顶层 logprobs/created 等;上游模型使用 LLMResponse.model_reported,不把 model 配置别名当真实版本。
|
||||||
|
|
||||||
|
ARCHITECTURE 更新 §5 类型/签名、§6.2 空补全与 error、§7.1 DONE 与工具协议、§7.2/7.6 工具活性、§7.5 缓存维度,并明确这些只是批准方向的增量,不引入 Agent Loop。站点下线,无具体 Wiki 页面可同步。
|
||||||
|
|
||||||
|
验证:`conda run -n PolyGateway pytest tests/unit/test_package.py -q` 先红后绿;`git diff --check`;构建验收见 T8。提交点:`docs: document faithful chat and Apache-2.0 distribution`。
|
||||||
|
|
||||||
|
### T7 — 少量真实工具冒烟与独立验证
|
||||||
|
|
||||||
|
新增 tests/e2e/test_openai_tools_live.py,复用 tests/e2e/conftest.py 的请求取证/输出机制,不为本次重跑整个 thinking 矩阵。fixture 的 complete 显式转传 response_mode 是机械接线,不改取证判分算法。
|
||||||
|
|
||||||
|
测试使用通用无业务领域的函数,如 echo_value;强制工具选择但工具在测试内执行纯函数,库本体不执行。流式、非流式各一次工具请求,随后带原 assistant+tool 消息做第二轮;断言 ID、finish、arguments 可用和请求消息相等。并行两工具完整逐字拼接的硬门由确定性 fixture 保证,不把模型偶尔不发两个工具解释为库聚合正确。
|
||||||
|
|
||||||
|
真实测试标 slow/requires_llm,预算与超时不紧于现有 `.env` 生产配置。raw=max 使用已配支持模型的 raw-only 源;不发明新模型/API。Markdown 安全报告记录模式、源/model_reported、attempts、finish、工具数、结果/失败原因;不输出 key、敏感消息或完整参数。费用/网络异常如实呈现,不宽泛 skip。
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest -m slow tests/e2e/test_openai_tools_live.py tests/e2e/test_smoke_gateway.py -q
|
||||||
|
make ci
|
||||||
|
```
|
||||||
|
|
||||||
|
全套件由当前前台执行;若预计长跑需 tmux,禁用缓冲、保存原退出码,不在 pytest 末尾接管道。生成唯一日志路径 `tests/outputs/issue29/<任务>-<red|green>-<时间>.log`,不提交产物。无新增功能代码的当前阶段仅跑过单元基线,不预填完成框。
|
||||||
|
|
||||||
|
父会话安排全新上下文 verifier 独立查实际 diff 与测试输出。若修改了准入/取消/退避生产逻辑,补 `tests/integration/test_redis_governance_time.py` 和 `test_redis_cross_connection.py` 对应 slow 子集;仅机械传参/协议解析时记录无 Lua/退避算法交集的理由。慢测试 skip/失败不得当通过。
|
||||||
|
|
||||||
|
- [ ] 审查员检查依赖铁律、未知字段策略、旧文本回归与大对象保留成本。
|
||||||
|
- [ ] 每项行为能链接红绿日志、命令、SHA、退出码;基线失败必须归因,不能“重跑绿了”掩盖。
|
||||||
|
- [ ] 合并前测试/独立审查通过;提交均调用 commit skill,不夹带主树既有文档差异。
|
||||||
|
|
||||||
|
### T8 — 发布与 issue 外部验收(父会话持有,不由本设计任务执行)
|
||||||
|
|
||||||
|
发布版本已由人类最终指定为 1.3.9(取代未发布的 1.4.0),不能在规划中冒称已发布。遵守 CLAUDE.md §4.4.1 全步骤:README/CHANGELOG/两处版本先完成 → 合并 main/push → 合并后 lint/全套件/相交 slow → tag push → 构建/twine check → registry 上传 → 实际下载核验 → Release/挂库/包页核验。
|
||||||
|
|
||||||
|
| 发布门 | 可接受证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 构建 | `conda run -n PolyGateway python -m build` 和 twine check 成功;解包核 LICENSE、许可元数据、completion 类型/参数/解析器均在产物中 |
|
||||||
|
| 安装 | 在独立临时环境从私有索引下载/安装已发布版本;必要时 --no-build-isolation 避免索引无 setuptools;核来源/hash,不误用本地 editable |
|
||||||
|
| 外部页面 | registry 包正文与仓库链接、Gitea Release/tag 实际可见;不以 POST 返回码代替最终状态 |
|
||||||
|
| 下游 | 以发布包运行不含业务逻辑的 tool 双轮接入验收;给 dissect3 说明同 loop 线程桥接和 raw-only 配置,不在本计划越权修改其业务仓 |
|
||||||
|
| 关闭 issue | 再查询 open issue,#29 评论附版本/验收/限制与缓存迁移说明后关闭;若出现新 issue 单独定范围,不假称本计划涵盖未来问题 |
|
||||||
|
|
||||||
|
## 4. 自审清单与残余边界
|
||||||
|
|
||||||
|
| 需求 | 覆盖任务 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工具双并行、消息往返、raw=max、非流式 | T1–T3、T7 |
|
||||||
|
| error+DONE 所有模式失败、attempts=2 | T2、T5 |
|
||||||
|
| 缺 DONE 不 salvage 工具、length 不修复 | T3 |
|
||||||
|
| 活性、对冲、取消、准入/结算 | T3、T5 |
|
||||||
|
| 完整缓存、模式隔离、旧默认 key 与污染迁移 | T4、T6 |
|
||||||
|
| Apache 授权与第三方来源、分发验证 | T6、T8 |
|
||||||
|
| 测试先红后绿、独立审查、外部发布完成 | 各任务、T7、T8 |
|
||||||
|
|
||||||
|
当前残余:首轮代码独审已完成,修复后待父会话最终复核;严格 SSE 方言会拒绝未定义增量字段,不承诺任意 OpenAI 兼容网关;旧文本毒化缓存只能由使用方迁移身份;公共 dataclass 嵌套 JSON 可变,靠快照隔离而非深冻结;模型真实冒烟依赖外部可用性。以上均需在文档和验收中如实保留,不当成全部自动解决。
|
||||||
|
|
||||||
|
## 5. 本轮实施记录(2026-10-01,未发布)
|
||||||
|
|
||||||
|
P1 最终 assistant role、P2 来源核查前置均已采纳,证据见来源核查 finding。
|
||||||
|
T1–T6 代码/文档/许可已落实;T7 工具真实冒烟、Redis、质量门已执行,独立代码审查由父会话接续;T8 禁止本 worker 执行。
|
||||||
|
|
||||||
|
| 验证 | 实际结果/证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 基线红 | tests/outputs/issue29/baseline-red.log:error+DONE 未抛、双文本丢 reasoning、模式 API 缺失,3 failed |
|
||||||
|
| 扩展红绿 | expanded-red.log 19 failed(git archive 0e29dea 的 src,不改主树);expanded-green.log 同19 passed |
|
||||||
|
| 防御红绿 | defense-red.log 3 failed:usage 裸错误/空 tool_calls 丢失;defense-green.log 同3 passed |
|
||||||
|
| 全量 | ci-final.log + ci-final.exit=0:1812 passed、23 skipped、110 deselected,覆盖率95%;之后仅增测试/文档,无生产变更 |
|
||||||
|
| 最后定向 | targeted-final.log:264 passed,包含后增的非流式事件/取消/managed冲突/名称分片回归 |
|
||||||
|
| 真实缓存 | test_redis_cache.py 6 passed,专用随机 key,无共享数据清理 |
|
||||||
|
| 真实工具 | live-final.log + live-final.exit=0:流式/非流式双轮2 passed,101.34s;安全Markdown在 tests/outputs/issue29/live |
|
||||||
|
| 构建 | /tmp/pgw-issue29-dist-final 的 wheel/sdist、twine check、LICENSE/源码字节核验通过;仍1.3.8本地测试产物,绝非发布 |
|
||||||
|
|
||||||
|
23 skips 为17个已转专用时间语义的 Redis 契约变体、6个缺私有图像语料的 OCR 测试;未把这些当本次通过。110 deselected 为默认 slow;未重跑全模型 thinking 矩阵,因为只机械转传端口参数、未改其判据或能力表。
|
||||||
|
|
||||||
|
未提交代码,供父会话独立验收完整工作区。conda 启动有基础环境 RequestsDependencyWarning(pytest本体无该告警),pi-lens 使用错误环境时的缺包/枚举提示已用 conda 实证排除,不据此改变依赖。
|
||||||
|
Wiki add_entity 会覆写无frontmatter同名文件,现场检查发现后已从本会话原始 tool-write 精确恢复两文档,并加 frontmatter 后保留 P1/P2 修订;没有丢失设计内容。
|
||||||
|
|
||||||
|
补充最终证据:unit-final.log 1678 passed(23.84s),smoke.log + smoke.exit=0 为既有真实 chat 冒烟4 passed(97.18s);未跑昂贵全模型矩阵。源代码最后变更早于 ci-final,后续只增测试/文档;最后单元覆盖全部新增测试。
|
||||||
|
|
||||||
|
## 6. 审查修复与1.4.0准备(2026-10-01,未发布)
|
||||||
|
|
||||||
|
已读取首轮独立报告并核验采纳 P2 空产物缓存、Minor 测试双向依赖,详见 [审查处置](../reviews/2026-10-01-issue29-cache-review.md)。本轮只在隔离 feature 树写入,主树既有脏文档未改;不合并、不 push/tag/上传、不关issue。
|
||||||
|
|
||||||
|
| 本轮命令/证据(日志均在 tests/outputs/issue29,不提交) | 实际结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新缓存回归修复前:review-cache-red.log | 10 failed、1 passed:五种无效产物×两种缓存投影误命中;合法tool-only通过 |
|
||||||
|
| 四模块定向:review-cache-green.log | 268 passed,17.71s;包含11项新增缓存回归 |
|
||||||
|
| 真实Redis+包测试:review-redis-package.log | 13 passed,2.66s(6 Redis、7包) |
|
||||||
|
| 首次 lint / make ci | 测试import顺序失败;已修正,不作为通过证据 |
|
||||||
|
| tmux make ci:review-ci-final.log / .exit=0 | 1827 passed、23 skipped、110 deselected,317.28s;95%覆盖;格式102文件、ruff通过、依赖契约1 kept/0 broken |
|
||||||
|
| 远端 tags 与 registry simple | 最大均1.3.8,没有1.4.0;版本两处改1.4.0,README约束>=1.4.0,<2,CHANGELOG定版 |
|
||||||
|
| build / twine / 解包:review-build-final.log、review-twine.log、review-artifacts.log | 1.4.0 wheel/sdist 均通过;所有Python源码、README、标准LICENSE逐字相同,Version/License-File正确且无reference |
|
||||||
|
| README数字断言 | inspect.signature 实测 TelemetryRecorder.record_llm_call 为38字段,未凭记忆更新 |
|
||||||
|
|
||||||
|
wheel SHA256 `3186d01d7710c3b5ef727b59f95e8c2db0821277a79491ffb913dd2ce34a888d`;sdist SHA256 `b254dedf65d25673cf57986a613a1f4e9895da8b5be1e6ee241f00065a95ad90`。这些是隔离树本地构建,不是registry产物。解包探针首次使用email默认ASCII解码比较中文README产生误报,改为METADATA正文原始字节比较后通过,未改包来迎合错误探针。
|
||||||
|
|
||||||
|
跳过项仍为17个独立slow Redis时间变体及6个缺私有图像语料OCR;110 slow未选中。本轮不重复付费网关:复用前轮流式/非流式工具双轮2通过和chat冒烟4通过,当前生产改动只统一空产物判据,工具/文本正常行为已有定向与全套件证明;合并后slow发布门由父执行。构建有setuptools许可表格弃用提醒(兼容setuptools>=68设计),并有基础conda RequestsDependencyWarning;均不等于当前构建或测试失败。
|
||||||
|
|
||||||
|
按commit skill提交feature全部实现、修复、许可与发布准备,原main脏文件及运行产物不纳入。提交后交父最终独立复核;T8外部发布尚未完成,不能提前关闭#29。
|
||||||
|
|
||||||
|
## 7. 版本定案与历史文档归档(2026-10-01 EDT / 2026-10-02 UTC)
|
||||||
|
|
||||||
|
用户最终指定本次发布 __1.3.9__,取代尚未打 tag/上传的 1.4.0。上节 1.4.0 构建散列、测试结果与历史提交标题均是当时实证,保留原样,不冒充 1.3.9 产物。README 安装约束、能力标题、CHANGELOG 与两处版本同步;自定义 `Transport.complete(response_mode=...)` 适配要求及旧缓存更换 namespace/salt 提醒保持不变。CHANGELOG 使用执行环境本地日期 2026-10-01(EDT),知识库时间戳明确使用 UTC。
|
||||||
|
|
||||||
|
历史六文档独立归档提交 `ce10881`;采纳独审发现,仅补 #24 implements 边缺少的 plan/design 两节点。验证旧新节点/边/日志并集完整、120 个索引链接与计数一致、三文件仅空白/空引用格式、无冲突标记。剩余 15 个历史未注册端点以及 wiki lint 的 15 条孤立/稀疏提示均未扩大处理。原始备份 `/tmp/pgw140-release` 及 stash 保留。
|
||||||
|
|
||||||
|
本轮只准备与本地构建,不合并、不 push/tag/上传、不关闭 issue;后续独审及发布外部验收仍是硬门。相关功能代码未变,可复用 `/tmp/pgw140-release/main-ci.log` 的 1828 项通过、95% 覆盖及 `main-slow.log` 的 6 项真实网关通过(205.81s);本轮另执行包/协议定向、质量检查和 1.3.9 构建解包验证。
|
||||||
|
|
||||||
|
| 本轮证据(`/tmp/pgw139-prepare/`) | 实际结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| source.log / tests.log | 显式 `PYTHONPATH=/home/iomgaa/Projects/PolyGateway/src` 确认源码位置及1.3.9;包+缓存+协议四模块276 passed,12.43s,无新增行为测试 |
|
||||||
|
| check.log | `make check` 格式、ruff、依赖契约通过,1 kept/0 broken |
|
||||||
|
| docs-validation.log / wiki-lint.log | #24两端点补齐;原新记录保全、120链接计数通过;存量15个端点缺口及15条lint提示未清理 |
|
||||||
|
| remote-tags.txt / registry-simple.html | 2026-10-02 UTC读取远端tag与匿名simple均无1.3.9/1.4.0,无版本冲突;发布前仍须复查 |
|
||||||
|
| build.log / twine.log / artifacts.log / unpacked-import.log | 1.3.9 wheel/sdist构建、twine及全Python源码/README/LICENSE逐字核验通过;解包路径独立导入成功,38遥测字段实测不变 |
|
||||||
|
|
||||||
|
本地 wheel SHA256 `45306557152fd44f8135928041e47924834c3b4039c586f72d521f51b4590dd3`;sdist SHA256 `8f455973e7fef71811e2b86f5a14cb03469d893ac9720b902f522d2b1a8dcd40`。产物位于 `/tmp/pgw139-prepare/dist`,不是已发布包。构建仅有已知许可证表格弃用提示、conda基础环境依赖警告;不为清警告增加依赖。
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-10-14-issue30-31-completion-verdict
|
||||||
|
title: 统一完成判定实现计划(#30/#31)
|
||||||
|
date: 2026-10-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# #30/#31 统一完成判定实现计划
|
||||||
|
|
||||||
|
**目标**: 按[设计](../designs/2026-10-14-issue30-31-completion-verdict-design.md)(下称"设计")实现统一完成判定,修复 #30/#31 并发布 1.3.10。
|
||||||
|
|
||||||
|
**方案概述**: 在 `types.py` 内核放一组纯判定函数(流完整性 → 结局分类 → 可缓存),transport 两条路径、CacheMW 读写共用;聚合器改为 null 优先 + 未知字段无损合并;`LLMResponse`/`TransportResult` 追加 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。
|
||||||
|
|
||||||
|
**技术**: Python 3.12、httpx、pydantic、pytest(asyncio)、SQLite/PG 遥测、Redis 缓存。环境 `conda run -n PolyGateway`。
|
||||||
|
|
||||||
|
本计划不涉及参考实现迁移,保真校验不适用。
|
||||||
|
|
||||||
|
## 0. 执行纪律
|
||||||
|
|
||||||
|
- 分支 `fix/issue30-31-completion-verdict`。每个任务一次提交,经 `commit` skill;pre-commit 钩子会跑全套件 + ruff + "改动触及函数复杂度 < C",故**每个提交点必须全绿**,改到的既有高复杂度函数(`add_chunk` F42、`_complete_stream`/`_complete_once` C、`_rehydrate` C、`OpenAIChatCompletion.__post_init__` D)须在同一提交内拆到 B 及以下;钩子(2026-10-14 经人类同意改为只拦"本次改动触及"的块,存量 ≥C 降为告警,提交 5d2ad40)按 radon 块判定,**类块**(`ChatCompletionAccumulator` C16、`OpenAIChatCompletion` D24)同样须降到 B 及以下。不改 `OpenAICompatTransport.complete()`(C14),新增逻辑一律放在拆出的 helper 中。
|
||||||
|
- 每个任务的统一验证(与钩子一致): `conda run -n PolyGateway pytest tests/ -q`(含 integration,本地有 Redis/PG 时真跑)+ `conda run -n PolyGateway ruff check src` + `conda run -n PolyGateway radon cc -n C <本任务改动文件>`(输出必须为空,或仅含本任务未触及的存量块)。各任务列出的子集命令只作开发期快速回路。
|
||||||
|
- 测试结果门: 每个任务的新增/改写测试先在未改实现的代码上运行并记录**失败**输出(粘贴进提交说明正文的"Red:"一行,写明失败用例名与断言),再实现至通过。
|
||||||
|
- 后台长跑命令末尾禁止接管道(CLAUDE.md §3)。
|
||||||
|
|
||||||
|
## 1. 文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/polygateway/types.py` | 新增 `StreamEnd`、`FinishClass`、`_OutputSummary`、`_Verdict` 与七个纯函数(§2 列出;另 `_effective_finish`);`TransportResult`/`LLMResponse` 末尾追加两字段;`OpenAIChatCompletion` 接受 `tool_calls: None` 并复用 `_tool_snapshot_ok` |
|
||||||
|
| `src/polygateway/__init__.py` | 导出 `StreamEnd`;`__version__` → 1.3.10(T6) |
|
||||||
|
| `src/polygateway/transports/_openai_completion.py` | 聚合器: null 优先、未知字段合并、标签键、冲突路径、finish/usage 位置记录、reasoning 别名;`parse_chat_completion` 容忍 null |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | 两条路径改走判定;有界告警节流;`finish_reason`/`stream_end` 填充;投影别名 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | `_build_response` 搬运两字段 |
|
||||||
|
| `src/polygateway/middleware/cache.py` | 写/读按 `_is_cacheable`;`stream_end` 校验;finish 回退 |
|
||||||
|
| `src/polygateway/ports.py` | `record_llm_call` 追加 `stream_end` |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage.stream_end`、`_record` 搬运、两 emit 显式 None |
|
||||||
|
| `src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py` | 新列 + 补列 |
|
||||||
|
| tests | 新增 `tests/unit/test_completion_verdict.py`;改写/扩充 `test_openai_completion.py`、`test_openai_fidelity.py`、`test_openai_compat.py`、`test_cache.py`、`test_telemetry.py`、`test_types.py`、`test_ports.py`、`tests/integration/test_postgres_telemetry.py` 及各 `record_llm_call` 替身 |
|
||||||
|
| 文档 | README、CHANGELOG、ARCHITECTURE §6.2/§7/§14、1.3.9 设计修订指针、`schemas/llm-calls.md`、`migrations/` 三份、pyproject 版本 |
|
||||||
|
|
||||||
|
## 2. 跨任务接口(T1 定义,后续任务只消费)
|
||||||
|
|
||||||
|
```python
|
||||||
|
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
|
||||||
|
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
|
||||||
|
|
||||||
|
def _classify_finish(value: str | None) -> FinishClass: ...
|
||||||
|
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool: ...
|
||||||
|
def _stream_is_complete(
|
||||||
|
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
|
||||||
|
) -> StreamEnd | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _OutputSummary:
|
||||||
|
any_output: bool # 设计 §4.3 "任何产物"
|
||||||
|
body_output: bool # 设计 §4.3 "正文产物"
|
||||||
|
tools_present: bool # 至少一个非 null 工具条目(仅保真有意义)
|
||||||
|
tools_ok: bool # tools_present 时 _tool_snapshot_ok 结果;无工具恒 True
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Verdict:
|
||||||
|
deliver: bool
|
||||||
|
error_kind: Literal["transient", "result_invalid"] | None # deliver 时为 None
|
||||||
|
code: str | None # 设计 §4.3 原因码
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _summarize_text(content: str, thinking: str) -> _OutputSummary: ...
|
||||||
|
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary: ...
|
||||||
|
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict: ...
|
||||||
|
def _is_cacheable(
|
||||||
|
*, mode: ResponseMode, content: str, finish_reason: str | None,
|
||||||
|
stream_end: str | None, completion: OpenAIChatCompletion | None,
|
||||||
|
) -> bool: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
缓存读写共用(T1 定义,T4 消费),顶层优先、回退 `completion.finish_reason`,避免读写口径分叉:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`types.py` 无 `from __future__ import annotations`: `StreamEnd`/`FinishClass` 必须定义在 `TransportResult`/`LLMResponse` 之前。
|
||||||
|
|
||||||
|
聚合器对外只读属性(T2 定义,T3 消费): `finish_reason: str | None`、`finish_seen: bool`、`usage_after_finish: bool`、`conflicts: frozenset[str]`(归一路径)、`raw_message() -> dict[str, Any]`(未校验的合并消息深拷贝,含按 index 排序组装的 `tool_calls`,与 `finish()` 同一组装函数,供 `_tool_snapshot_ok` 判定)、`finish() -> OpenAIChatCompletion`(保真快照,校验失败抛 Transient,与 1.3.9 同)。`ChatDelta` 追加字段 `reasoning: str`(别名片段,默认 `""`)。
|
||||||
|
|
||||||
|
## 3. 任务
|
||||||
|
|
||||||
|
### T1 内核判定函数与公共字段
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/types.py`、`src/polygateway/__init__.py`;测试 `tests/unit/test_completion_verdict.py`(新)、`tests/unit/test_types.py`、`tests/unit/test_package.py`(导出)。
|
||||||
|
- 行为:
|
||||||
|
- `_classify_finish` 按设计 §4.1 表,大小写敏感;None → `absent`。
|
||||||
|
- `_tool_snapshot_ok`: `tool_calls` 缺省或 None → True;否则必须是 list,每个非 None 元素满足 1.3.9 规则(非空唯一 id、`type == "function"`、非空 `function.name`、字符串 `arguments`);list 中 None 元素跳过。`OpenAIChatCompletion.__post_init__` 改为调用它(并拆分以降复杂度),`tool_calls: None` 合法且原样保留键。
|
||||||
|
- `_stream_is_complete`: `done` → `"done"`;否则 `finish_reason is not None and usage_after_finish and usage_measured` → `"eof_after_finish"`;否则 None。
|
||||||
|
- `_summarize_text`: any = `content != "" or thinking != ""`;body = `content.strip() != ""`;tools_present False、tools_ok True。
|
||||||
|
- `_summarize_message`: any = 递归遍历 message 中除 `role`、`tool_calls` 外所有值存在非空字符串,或 tools_present;tools_present = `tool_calls` 为含非 None 元素的 list;tools_ok = `_tool_snapshot_ok(message)`;body = `projected_content.strip() != ""` 或 (tools_present 且 tools_ok)。
|
||||||
|
- `_judge_outcome`: 严格按设计 §4.3 九行首匹配;原因码字面量: `upstream_finish_error`、`tool_truncated`、`tool_incomplete`、`length_without_content`、`filtered_without_content`、`empty_completion`。行 2 仅 `mode == "openai"` 时生效。
|
||||||
|
- `_is_cacheable`: 设计 §6 四条,任一成立 False;`stream_end` 不在 `StreamEnd` 域且非 None → False;openai 模式且 `completion is None` → False。
|
||||||
|
- `TransportResult`、`LLMResponse` 末尾追加 `finish_reason: str | None = None`、`stream_end: StreamEnd | None = None`(放在现有最后一个字段之后,不扰动位置构造);docstring 写清 None 语义与 `usage_source` 分工。
|
||||||
|
- 包根导出 `StreamEnd`;`_has_completion_output` 本任务**保留不动**(T3/T4 迁移后删除)。
|
||||||
|
- 测试(新文件): `_classify_finish` 全值表参数化;`_stream_is_complete` 真值表(含 usage 早于 finish、usage 非 measured);`_judge_outcome` 九行 × 两模式参数化(每行至少一例,含"正文非空+坏工具+stop → transient tool_incomplete");`_summarize_message` 对 refusal-only、reasoning-only、whitespace content、未知嵌套字符串、`tool_calls: [None]`;`_is_cacheable` 四类否例 + 正例 + 非法 stream_end。`test_types.py`: `OpenAIChatCompletion` 接受 `tool_calls=None`、拒非 list、`[None]` 合法;两新字段默认 None。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit/test_completion_verdict.py tests/unit/test_types.py tests/unit/test_package.py -q` → 全过;`make lint` 无新增问题。
|
||||||
|
- [ ] 提交 `feat: add kernel completion verdict functions and response fields`
|
||||||
|
|
||||||
|
### T2 聚合器: null 优先、未知字段合并、finish/usage 记录
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/transports/_openai_completion.py`;测试 `tests/unit/test_openai_completion.py`。
|
||||||
|
- 保留 `message`、`tools` 实例属性名与语义(`test_openai_completion.py:70,93` 直接依赖)。
|
||||||
|
- 行为(设计 §5、§4.2):
|
||||||
|
- 每帧: 校验 chunk 对象;`usage` 非 null 时记录为 `last_usage`,若 `finish_seen` 或同帧 choice 带非 null finish → `usage_after_finish = True`。
|
||||||
|
- choices: 保真维持单 choice/index 0 校验;文本模式取 `index` 缺省或为 0 的第一个 choice,其余忽略。
|
||||||
|
- finish_reason: 保真走 `_identity`(非字符串/冲突 → Transient,同 1.3.9);文本模式首个字符串值生效,非字符串忽略。任一模式见到非 null 即 `finish_seen = True`。
|
||||||
|
- null 五层(设计 §5.1): chunk 层 `choices: null`、choice 层 `delta: null` 视为缺省;例外: 顶层 `error` 键(含 `error: null`)仍由 `_check_chat_error` 按 error 帧拒绝(不动,保 `test_error_before_done_is_failure`)。
|
||||||
|
- delta 与工具条目、function 三层: 值为 None 的键 → 若目标无此键则 `setdefault(key, None)`(保真),不做类型校验;`tool_calls` list 中 None 元素跳过。
|
||||||
|
- 已知字段非 None 时逐字沿用 1.3.9 校验与合并(content/reasoning_content 拼接、role 必须 assistant、工具 id/type `_identity`、name/arguments 拼接)。
|
||||||
|
- 未知字段(delta 层除 role/content/reasoning_content/tool_calls;工具层除 index/id/type/function;function 层除 name/arguments): 交给 `_merge_unknown(target, key, value, path)`,规则见设计 §5.2;标签键集合 `{"index", "type", "id", "format"}`;冲突 → 从 target 删除该键、把归一路径(数组下标替换为 `[]`,如 `delta.reasoning_details[].format`)加入 `conflicts` 与本尝试"封禁路径"集合,后续同路径值丢弃。`_merge_unknown` 返回"是否并入非空字符串片段"。
|
||||||
|
- `reasoning`: 在保真模式作为普通未知字符串合并进 message;两模式都把其片段放入 `ChatDelta.reasoning`。
|
||||||
|
- progress: 1.3.9 已知字段口径 **或** 任一 `_merge_unknown` 返回 True(文本模式也调用合并函数计算活性,但合并结果不对外)。
|
||||||
|
- 新增只读属性见 §2;`finish()` 改为: 组装 message(含排序后工具)后构造 `OpenAIChatCompletion`(校验失败仍抛 Transient)。
|
||||||
|
- `parse_chat_completion` 拆为两步: `check_completion_body(body) -> tuple[dict, str | None]`(只校验 body 为对象、恰好一个 choice、index 缺省或为 0、message 为对象、保真下 finish 为字符串或 null,失败抛 Transient;返回原始 message 与 finish)与 `build_completion(message, body) -> OpenAIChatCompletion`(构造快照,失败抛 Transient)。快照构造前剔除 `tool_calls` 中的 None 元素(与流式跳过一致,避免回传 `[null]` 被上游 400);`tool_calls: null` 原样保留。
|
||||||
|
- `_merge_unknown` 按值类型拆分私有函数(标签键、字符串、标量、对象、index 数组、其他数组),每个 ≤ B。
|
||||||
|
- 拆分 `add_chunk` 为 `_take_meta`、`_take_choice`、`_take_delta`、`_take_tools`、`_take_tool` 等私有方法,每个 ≤ B。
|
||||||
|
- 测试: 改写 `test_invalid_delta_is_domain_error` 参数: 删除 `{"audio": {}}`、`{"index":0,"extra":1}` 与 null 已知字段参数(改为正例),保留 role 非 assistant、content 非字符串、tool index 非法等仍应拒绝的参数。新增: `refusal:null`+`tool_calls:null` 帧成功且快照含 `refusal: None`;只有 `reasoning` 的帧 progress=True 且 `ChatDelta.reasoning` 有值;二次构造的 OpenRouter `reasoning_details` 分片(每帧重复 `format`/`id`,`text` 分片)合并后 `format` 不重复、`text` 拼接完整;工具层嵌套对象 `extra_content={"google":{"thought_signature":"s"}}` 原样保留;数值冲突 → 键被删、`conflicts` 含归一路径、调用成功;同路径冲突后再到达的值被丢弃;形态改变(字符串→对象、index 数组→普通数组)即冲突;布尔值冲突;其他数组追加;同帧多个字段/多个未知字符串只产生一次 progress;chunk `choices:null`、choice `delta:null` 视缺省;`role:null`、`arguments:null` 视缺省;全程只有 `role:null` → `finish()` 失败;非流式 `tool_calls:[None, 合法工具]` 快照只含合法工具;`usage_after_finish` 四种顺序(finish 前 usage、finish 同帧、finish 后 usage-only 帧、无 usage);文本模式 index=1 的 choice 被忽略;重复 tool id 不拼接(既有用例保持)。
|
||||||
|
- 验证: §0 统一验证;快速回路 `conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_fidelity.py tests/unit/test_openai_compat.py -q`。T2 期间 transport 仍调用 `parse_chat_completion` 的组合(保留为两步的薄包装),行为不变。
|
||||||
|
- [ ] 提交 `fix: merge unknown and null stream fields losslessly in completion accumulator`
|
||||||
|
|
||||||
|
### T3 transport 两条路径接入判定
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_openai_fidelity.py`、`tests/unit/test_openai_compat.py`。
|
||||||
|
- 行为:
|
||||||
|
- `_complete_stream`: 流结束后先算 `usage_measured = _resolve_usage(sink.get("usage") or {})[2] == "measured"`,再 `stream_end = _stream_is_complete(done=..., finish_reason=acc.finish_reason, usage_after_finish=acc.usage_after_finish, usage_measured=...)`。
|
||||||
|
- None 且 openai → Transient `"{源} missing_done: …"`;None 且 text → 沿 `_check_done`(零产物按 `_summarize_text` 的 any 判定 early_eof;`retry` 抛;`salvage` → `stream_end="salvaged"`)。
|
||||||
|
- `salvaged` 时 usage 沿 `_resolve_stream_usage(..., salvaged=True)`;`done`/`eof_after_finish` 时 salvaged=False。
|
||||||
|
- 投影: `thinking` = 非空 reasoning_content 片段拼接,否则 `ChatDelta.reasoning` 片段拼接;再走既有 `_finalize_text`(think 标签)。
|
||||||
|
- 判定: openai → `summary = _summarize_message(acc.raw_message(), content)`;text → `_summarize_text(content, thinking)`;`verdict = _judge_outcome(mode, summary, _classify_finish(acc.finish_reason))`;非 deliver → 按 `error_kind` 抛 `TransientError` 或 `ResultInvalidError`,message 固定格式 `f"{source.name} {code}: finish_reason={fr!r}, thinking_len={len(thinking)}"`,带 `source_name`、`operation="chat"`;`ResultInvalidError` 显式 `raw_text=""`(不回显推理内容)。deliver 且 openai → `acc.finish()` 构造快照。
|
||||||
|
- 删除 `_reject_empty_completion` 与 1.3.9 的"保真缺 DONE"分支;`_check_done` 只保留文本截断语义。
|
||||||
|
- `_complete_once`: 跳过第一步。保真: 先 `check_completion_body` 取原始 message 与 finish,用原始 message(剔除 None 工具后)算 `_summarize_message` 与 `_judge_outcome`,**仅 deliver 时**调 `build_completion` 构造快照——否则坏工具 + length 会在构造时抛 Transient,到不了 `tool_truncated`。文本: finish 取 `choices[0].get("finish_reason")`,非字符串记 None;同一判定;`stream_end=None`;投影别名同上(`message.get("reasoning_content") or message.get("reasoning")`,仅取字符串)。
|
||||||
|
- 两路径 `TransportResult` 填 `finish_reason`、`stream_end`。
|
||||||
|
- 告警节流: 实例属性 `_warned_fields: set[tuple[str, str]]`,上限 1024,饱和时记一次 `"告警集合已饱和,后续同类告警省略"`;对 `acc.conflicts` 中每个路径、以及 `_classify_finish == "unknown"` 的 finish 值(截断 64 字符,文案写明"该 finish_reason 不缓存")各告警一次;日志只含源名与路径/值,不回显字段内容。
|
||||||
|
- 拆分 `_complete_stream`/`_complete_once` 至 ≤ B(建议抽 `_consume_stream`、`_verdict_or_raise`、`_project_text`)。
|
||||||
|
- 测试(假 SSE,复用文件内既有 helper):
|
||||||
|
- #31 验收 1-6(设计 §10),断言 `stream_end`、`usage_source`、attempts=1(经 GatewayClient 或 RetryMW 的既有测试设施)。
|
||||||
|
- usage 早于 finish + EOF → 失败;finish 后 usage `{}` → 失败;`network_error`+usage+EOF → Transient。
|
||||||
|
- 文本模式: stop+usage+EOF 无 DONE(`missing_done="retry"`)→ 成功 `eof_after_finish`;无 finish + EOF → retry 抛、salvage 记 `salvaged`。
|
||||||
|
- #30 验收: 保真 reasoning-only+length → 交付,`completion.message["content"]` 缺省或空、`reasoning_content` 完整、`finish_reason=="length"`;文本同输入 → `ResultInvalidError` 且 message 含 `length_without_content`;保真 content 空+length(无任何产物)→ 交付;文本同 → ResultInvalid;保真/文本 零增量+DONE(带 stop、不带 finish)→ Transient(`test_stream_zero_content_with_done_is_transient` 保持不改);非流式 message 带 `tool_calls:null`+`refusal:null` → 成功;工具缺 name + length → ResultInvalid `tool_truncated`;content 非空 + 坏工具 + stop → Transient。
|
||||||
|
- 只有 `reasoning` 字段的流不触发 TTFT 超时(用既有看门狗测试设施设置小 ttft)且文本 `thinking` 等于拼接值;同时有非空 reasoning_content 时以其为准。
|
||||||
|
- 冲突/未知 finish 告警各只一次(loguru sink: `logger.add(list.append, level="WARNING")`,先例 `test_openai_compat.py:877`;caplog 抓不到 loguru);饱和用例预先往 `_warned_fields` 灌 1024 条再触发一次,断言饱和告警一次。
|
||||||
|
- 结局表传输层覆盖: 设计 §4.3 九行 × 两模式,每行至少流式一例 + 非流式一例(行 2 只保真);非流式"工具缺 name + length → ResultInvalid `tool_truncated`";文本 length 用例经 GatewayClient 断言 attempts=1 且熔断窗口失败计数为 0;#30 reasoning-only+length 经 GatewayClient 断言 attempts=1。
|
||||||
|
- 改写 `test_openai_never_salvages`: 断言"无 finish 的 EOF 在 `missing_done=salvage` 下仍失败",并新增"finish+usage+EOF 成功"作为对照。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过。
|
||||||
|
- [ ] 提交 `fix: judge stream completion by finish reason and trailing usage`
|
||||||
|
|
||||||
|
### T4 retry 搬运与缓存读写判据
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/middleware/retry.py`、`src/polygateway/middleware/cache.py`、`src/polygateway/types.py`(删除 `_has_completion_output`);测试 `tests/unit/test_cache.py`、`tests/unit/test_retry.py`、`tests/integration/test_redis_cache.py`。
|
||||||
|
- 行为:
|
||||||
|
- `_build_response` 搬运 `finish_reason`、`stream_end`。
|
||||||
|
- 写路径: 序列化前 `_is_cacheable(mode=request.response_mode, content=response.content, finish_reason=response.finish_reason, stream_end=response.stream_end, completion=response.completion)` 为 False → `logger.debug` 跳写。
|
||||||
|
- 读路径(`_rehydrate`): 重建 completion 后、`fields.update` 之前: `stored_end = fields.get("stream_end")` 非 None 且不在域 → miss+warning;`finish = fields.get("finish_reason")`,非 None 且非 str → miss;为 None 且 completion 存在 → 回退 `completion.finish_reason`;`_is_cacheable(...)` False → miss+warning;命中覆盖加 `stream_end=None`、`finish_reason=finish`。拆分 `_rehydrate` 至 ≤ B。
|
||||||
|
- 删除 `_has_completion_output` 及其 import(全仓 grep 为零)。
|
||||||
|
- 读写判定的 finish 统一取 `_effective_finish(response.finish_reason, response.completion)`(读路径对存储值同样调用)。
|
||||||
|
- 测试: 不可缓存六类(salvaged、reasoning-only length 保真、文本 length 带正文、unknown finish、保真 reasoning-only + stop(交付但无正文产物)、`content_filter` 带正文)均不写入(fake cache backend 断言 set 未调用)且第二次调用 attempts=1;手工注入上述条目 → 读视 miss;非法 `stream_end` 注入 → miss;1.3.9 旧文本条目(无新键)→ 命中;1.3.9 保真条目(无顶层 finish、completion.finish_reason="length")→ miss;命中返回 `stream_end is None`、`finish_reason` 为存储/回退值;retry 测试断言 LLMResponse 两字段来自 TransportResult。Redis integration 一例往返两新字段。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q`(需本地 Redis)→ 全过。
|
||||||
|
- [ ] 提交 `fix: gate cache writes and reads on a shared cacheability verdict`
|
||||||
|
|
||||||
|
### T5 遥测 `stream_end` 列与 recorder 端口
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`、`src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/unit/test_ports.py`、`tests/integration/test_postgres_telemetry.py`,以及全仓 `def record_llm_call` 测试替身(`grep -rn "def record_llm_call" tests`)。
|
||||||
|
- 行为:
|
||||||
|
- `TelemetryRecorder.record_llm_call` 在 `stall_count` 之后追加 keyword-only `stream_end: str | None`(无默认),docstring 注明 1.3.10。
|
||||||
|
- `_AttemptUsage` 加 `stream_end: str | None = None`,`of()` 读 `response.stream_end`;`emit_attempt` 把它传给 `_record`;`_record` 加同名参数并传给 recorder;`emit_cache_hit`、`emit_terminal_failure` 显式 `stream_end=None`。
|
||||||
|
- `schema.py`: SQLite 与 PG CREATE 末尾 `stream_end TEXT`;`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS` 追加 `("stream_end", "TEXT")`;`COLUMNS` 追加 `"stream_end"`。sqlite/postgres 若有硬编码列数或参数位次随之更新(以 `COLUMNS` 为准)。
|
||||||
|
- 替身与计数(按审查实测清单): ① 固定字段 fixture `tests/unit/test_telemetry.py:142` `_record_minimal`、`tests/integration/test_postgres_telemetry.py:121` `_minimal_fields` 补 `stream_end: None`(sqlite 按 `fields[col]` 取值,缺键 KeyError);② 硬编码计数 38→39 / 列清单追加: `test_ports.py:325`、`test_telemetry.py:53`(`_EXPECTED_COLUMNS`)`:271`、`:301-303`、`:779`、`:829`,`test_postgres_telemetry.py:33`、`:778`、`:1151`、`:1352`(执行时以 grep 复核行号);③ `test_ports.py:112` `_DummyRecorder` 补参数,`stream_end` 加入 `:332` 无默认值参数化列表;④ `**fields` 替身(14 处)无需改动。源码 docstring "38 字段"同步: `ports.py:293`、`middleware/telemetry.py:373`、`sqlite.py:146`、`schema.py:8`。形状闸 `middleware/telemetry.py:323` `_assert_recorder_shape` 从协议签名派生,不改闸本身。
|
||||||
|
- 测试: 成功流式行写 `done`/`eof_after_finish`;非流式、缓存命中、失败、取消、终态行为 NULL;旧库(无列)auto 档补列后写入;manual 档缺列 → 裁剪写入且告警点名 `stream_end`;形状闸拒绝缺 `stream_end` 参数的 recorder;PG integration 同上(需本地 PG,沿既有 fixture)。所有替身补参数。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -q` → 全过;`conda run -n PolyGateway python -c "import inspect, polygateway.ports as p; print(len(inspect.signature(p.TelemetryRecorder.record_llm_call).parameters) - 1)"` 记录字段数供 T6 使用。
|
||||||
|
- [ ] 提交 `feat: record stream end mode in telemetry`
|
||||||
|
|
||||||
|
### T6 文档、版本与另开 issue
|
||||||
|
|
||||||
|
- 文件: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md`(顶部加"§4.1/§4.2/§5/§6 部分规则已由 2026-10-14 设计修订"指针)、`research-wiki/schemas/llm-calls.md`、`research-wiki/migrations/*.md`、`pyproject.toml`、`src/polygateway/__init__.py`。
|
||||||
|
- 内容:
|
||||||
|
- CHANGELOG `## 1.3.10(未发布)`: 修复段(#31 两模式、#30 两部分、非流式 `tool_calls:null`、reasoning 别名、salvage 不再缓存);**行为变更**段(文本 length/过滤无正文 → ResultInvalid 不再经 `AllSourcesExhausted`;未知 finish 不缓存;活性口径增补);**破坏性**段(`record_llm_call` 新增必填 `stream_end`);提醒(工具快照成立+length 时 arguments 可能半截;冲突删键以告警为准;旧缓存污染迁移 namespace/salt)。
|
||||||
|
- README: 能力表补完成判定/`stream_end`;安装约束 `1.3.*` 核对;遥测字段数用 T5 实测值;活性口径一句。
|
||||||
|
- ARCHITECTURE: §6.2 空补全/missing_done 行、§7 `usage_source` 与 `stream_end` 映射、§14 协议/失败/缓存行按设计改写。
|
||||||
|
- `schemas/llm-calls.md`: 加 `stream_end` 列与取值表,修正陈旧计数。
|
||||||
|
- `migrations/` 三份: 核对下游对 `ResultInvalidError` 的 catch 点,写一行结论(有/无受影响路径)。
|
||||||
|
- 版本 1.3.10 两处一致。
|
||||||
|
- `docs-convention.md` §2 清单逐项过: Gitea Wiki 站点下线,由 README/CHANGELOG/ARCHITECTURE/schemas 承接;`.env.example` 无配置键变更(写明);`research-wiki/ROADMAP.md` 状态同步。
|
||||||
|
- 另开 Gitea issue(tea CLI): "文本模式 n>1 时多 choice 增量混流";"文本模式有产物无正文 + stop 的证据收集(决定是否改 ResultInvalid)"。
|
||||||
|
- 验证: `grep -n "1.3.10" pyproject.toml src/polygateway/__init__.py`;`conda run -n PolyGateway pytest tests/unit/test_package.py -q` → 过。
|
||||||
|
- [ ] 提交 `docs: document completion verdict and bump version to 1.3.10`
|
||||||
|
|
||||||
|
### T7 合并前验证、审查与发布
|
||||||
|
|
||||||
|
- [ ] `make lint`、`make test` 全绿(覆盖率不降);`tests/integration` 中 Redis/PG 相关全绿。
|
||||||
|
- [ ] 派全新上下文 verifier subagent(`verification-before-completion`),逐条对照设计 §4-§7、§10 与本计划验收,输出证据表。
|
||||||
|
- [ ] `requesting-code-review` 整分支审查;按 `receiving-code-review` 核验并修复。
|
||||||
|
- [ ] slow 子集(按 diff 交集): 动了公开入口与 transport → `pytest -m slow tests/e2e/test_smoke_gateway.py tests/e2e/test_openai_tools_live.py`;不跑 `test_thinking_live.py`(未动 thinking 能力表)。另做一次 magpie/glm 真实探针: 记录是否出现 `eof_after_finish`,作为 #31 外部证据。
|
||||||
|
- [ ] 按 CLAUDE.md §4.4.1 九步发布 1.3.10(CHANGELOG 定版日期、合并 main `--no-ff`、main 上重跑、tag、构建、上传、下载验证、Release + 包关联 + 页面核对)。
|
||||||
|
- [ ] 在 #30/#31 评论验收证据与版本,关闭。
|
||||||
|
|
||||||
|
## 4. 设计覆盖自查
|
||||||
|
|
||||||
|
| 设计条目 | 任务 |
|
||||||
|
| --- | --- |
|
||||||
|
| §3 公共字段、`StreamEnd` 导出、`tool_calls:null` | T1 |
|
||||||
|
| §3 recorder 端口、列 | T5 |
|
||||||
|
| §4.1 纯函数与 finish 分类 | T1 |
|
||||||
|
| §4.2 流完整性、usage 位置与 measured | T2(记录)+T3(判定) |
|
||||||
|
| §4.3 结局表、原因码、错误格式 | T1(判定)+T3(翻译) |
|
||||||
|
| §4.4 reasoning 别名 | T2+T3 |
|
||||||
|
| §5.1-5.3 null、合并、标签键、活性 | T2 |
|
||||||
|
| §5.4 告警节流 | T3 |
|
||||||
|
| §6 缓存读写 | T4 |
|
||||||
|
| §7 遥测落点 | T5 |
|
||||||
|
| §8 旧行为审计中的保留项(零增量+DONE、error 帧、多 choice) | T3 回归测试 |
|
||||||
|
| §10 文档、另开 issue、发布 | T6+T7 |
|
||||||
|
|
||||||
|
## 5. 审查修订记录
|
||||||
|
|
||||||
|
| 轮次 | 意见 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 | B1 非流式先构造快照导致 `tool_truncated` 不可达 | 采纳: T2 拆 `check_completion_body`/`build_completion`,T3 先判定后构造 |
|
||||||
|
| P1 | S1 T5 替身/计数清单 | 采纳: T5 逐项列出 |
|
||||||
|
| P1 | S2 类块复杂度、S3 验证与钩子一致 | 采纳: §0 |
|
||||||
|
| P1 | S4 保留内部属性、S5 五层 null、S6 缓存漏两类、S7 结局表与熔断断言、S8 合并规则测试 | 采纳: T2/T3/T4 |
|
||||||
|
| P1 | 建议 1-8 | 全部采纳;建议 4 选"剔除 None 工具元素" |
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-10-21-issue34-ocr-document
|
||||||
|
title: OCR 整份文档解析实现计划(#34)
|
||||||
|
date: 2026-10-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# #34 OCR 整份文档解析实现计划
|
||||||
|
|
||||||
|
> 实现已获批准的设计 [2026-10-21-issue34-ocr-document-design](../designs/2026-10-21-issue34-ocr-document-design.md)(下称「设计」,章节号引自它)。
|
||||||
|
> 分支 `fix/issue34-ocr-document`;基线 `25a90eb`(1.3.10);目标 **1.3.11**。**发布(合并/tag/上传 registry/建 Release/关 #34)不在本计划内,等人类逐步指令(设计 H4)。**
|
||||||
|
> 保真校验: 本计划不涉及参考实现迁移,保真校验不适用。
|
||||||
|
|
||||||
|
**目标**: 让 OCR 客户端支持整份 PDF 文档解析,返回带页码/bbox/类型/文字的逐块结果与整篇 Markdown,并修正上传类型标签与确定性坏输入的分类。
|
||||||
|
|
||||||
|
**方案概述**: 新增 `OcrClient.parse_document` 与独立 Protocol `OcrDocumentTransport`;transport 按内容嗅探 PDF 并设置上传标签;结果只从 `_middle.json` + 配对 `.md` 解析(不读 `_content_list.json`);新路径对一组已知的确定性 500 改归「请求被拒」。`parse_layout`/`recognize_text` 的签名与返回类型不动。
|
||||||
|
|
||||||
|
**技术**: Python 3.12、httpx(流式 GET)、`asyncio.to_thread`、标准库 `zipfile`/`json`;**零新依赖**。
|
||||||
|
|
||||||
|
## 0. 全局约束(每个任务都适用)
|
||||||
|
|
||||||
|
| 约束 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 先红后绿 | 每个任务先写测试并**亲眼看到因目标行为缺失而失败**,红灯输出存 `scratchpad/<任务>-red.log`,再实现到绿。测试结果门要求的证据,最终报告逐条引用 |
|
||||||
|
| 提交门 | `.claude/scripts/hooks/pre-commit-guard.sh` 在提交时跑**全套** `pytest tests/` 并拒绝「本次改动触及的函数圈复杂度 ≥ C」(radon ≥ 11)。故所有新函数须拆小(见 T3 的分解),提交必须调用 `commit` skill、**不带 AI 签名** |
|
||||||
|
| 命令 | 一律 `conda run -n PolyGateway ...`;长命令输出重定向到文件并**直接取退出码**,不接管道(CLAUDE.md §3 执行模式) |
|
||||||
|
| 复杂度 | 新函数的复杂度在实现前只能标「预计」,**每次提交前对改动模块跑 `radon cc <文件> -n C`**,有输出即拆分;不得提前宣称已通过 |
|
||||||
|
| 层次 | `import-linter` 契约:`ports/types/errors` 最内层;`transports/` 只实现端口、不依赖 `middleware/`;每个任务结束跑 `make lint` |
|
||||||
|
| 中文 | 模块/类/方法中文 docstring;注释解释「为什么」;日志用 loguru,禁 `print` |
|
||||||
|
| 不顺手清理 | 只动本计划列出的文件与函数;`parse_layout` 的行为(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样** |
|
||||||
|
| 基线 | 动手前 OCR 相关单测 257 passed(`test_monkey_ocr/test_ocr_client/test_ports/test_types/test_package`) |
|
||||||
|
|
||||||
|
## 1. 文件结构
|
||||||
|
|
||||||
|
| 文件 | 动作 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/polygateway/types.py` | 改 | `CallOperation`+`CALL_OPERATIONS` 增 `"parse_document"`;新增 `OcrDocumentElement`/`OcrDocumentResult`/`OcrDocumentTransportResult`;`OcrLayoutElement` docstring 加继承警示 |
|
||||||
|
| `src/polygateway/ports.py` | 改 | 新增 `OcrDocumentPort`、`OcrDocumentTransport`(**不动** `OcrTransport` 成员) |
|
||||||
|
| `src/polygateway/transports/_monkey_document.py` | **新** | 结果包解析纯函数(无 HTTP、无治理):`parse_document_package`;承接并**原样迁入** `_finite_number`/`_parse_page_size`(避免与 `monkey_ocr.py` 循环导入;`monkey_ocr.py` 反向导入它们) |
|
||||||
|
| `src/polygateway/transports/monkey_ocr.py` | 改 | `_sniff_media`/`_upload_part`/`_files`;`_classify_status`+`_translate_http_errors` 增 `rejected_markers`;`MonkeyOcrTransport.parse_document`、流式下载、第一段协议抽共用 |
|
||||||
|
| `src/polygateway/ocr.py` | 改 | `OcrClient.parse_document`;`_OcrKind` 增 `"document"`;私有方法形参 `image`→`payload`;`_invoke`/`_summarize` 分派 |
|
||||||
|
| `src/polygateway/__init__.py` | 改 | 导出 `OcrDocumentElement`/`OcrDocumentResult`(版本号放 T9) |
|
||||||
|
| `tools/check_wiki_alignment.py` | 改 | `_SIGNATURE_TARGETS` 加 `OcrClient.parse_document` |
|
||||||
|
| `research-wiki/schemas/llm-calls.md`、`metrics/ocr-call-success.md`、`metrics/call-telemetry-coverage.md` | 改 | operation 取值与摘要格式 |
|
||||||
|
| `tests/unit/{test_types,test_ports,test_package,test_monkey_ocr,test_ocr_client,test_telemetry}.py` | 改 | 见各任务 |
|
||||||
|
| `research-wiki/{index.md,log.md,graph/edges.json}` | 改(自动生成) | T7 的 `make wiki` 产物,随该任务提交 |
|
||||||
|
| `tests/unit/test_monkey_document.py` | **新** | 结果包解析的契约用例(设计 §5.1 逐行一条) |
|
||||||
|
| `tests/integration/test_monkey_ocr_document_live.py` | **新** | `slow` 活体用例(T8)。**不改**既有 `test_monkey_ocr_live.py`: 它带模块级 `skipif`(依赖图片语料)且用例未标 `slow`,默认套件会收集它(实测 6 项),混入新用例会改变提交门的行为 |
|
||||||
|
| `CHANGELOG.md`、`README.md`、`.env.example`、`pyproject.toml`、`research-wiki/{ARCHITECTURE.md,designs/2026-07-21-m3-ocr-design.md,migrations/chsanalyzer.md}` | 改 | T9 |
|
||||||
|
|
||||||
|
## 2. 跨任务关键接口(执行时以此为准)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# types.py
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OcrDocumentElement(OcrLayoutElement):
|
||||||
|
text: str # 随 type: text/title→纯文本; interline_equation→裸 LaTeX; table→HTML; image→""
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OcrDocumentTransportResult:
|
||||||
|
elements: list[OcrDocumentElement]
|
||||||
|
page_sizes: list[tuple[float, float]]
|
||||||
|
markdown: str
|
||||||
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OcrDocumentResult: # 独立定义,不继承 OcrLayoutResult(父类 call_stats 带默认值)
|
||||||
|
elements: list[OcrDocumentElement]
|
||||||
|
page_sizes: list[tuple[float, float]]
|
||||||
|
markdown: str
|
||||||
|
source_name: str
|
||||||
|
usage: Usage
|
||||||
|
latency_ms: int
|
||||||
|
call_id: str
|
||||||
|
raw: dict[str, Any]
|
||||||
|
call_stats: CallStats | None = None
|
||||||
|
|
||||||
|
# ports.py
|
||||||
|
@runtime_checkable
|
||||||
|
class OcrDocumentPort(Protocol):
|
||||||
|
async def parse_document(self, document: bytes) -> OcrDocumentResult: ...
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OcrDocumentTransport(Protocol):
|
||||||
|
async def parse_document(
|
||||||
|
self, *, document: bytes, source: SourceConfig, call_id: str
|
||||||
|
) -> OcrDocumentTransportResult: ...
|
||||||
|
|
||||||
|
# transports/_monkey_document.py (全部无 I/O,可在线程中运行)
|
||||||
|
MAX_MIDDLE_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_MARKDOWN_BYTES = 16 * 1024 * 1024
|
||||||
|
def parse_document_package(
|
||||||
|
zip_bytes: bytes,
|
||||||
|
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]: ... # 失败一律 ResultInvalidError
|
||||||
|
|
||||||
|
# transports/monkey_ocr.py
|
||||||
|
_MediaKind = Literal["pdf", "image", "unknown"]
|
||||||
|
def _sniff_media(data: bytes) -> _MediaKind: ...
|
||||||
|
def _upload_part(data: bytes) -> tuple[str, bytes, str]: ...
|
||||||
|
_MAX_PACKAGE_BYTES = 256 * 1024 * 1024
|
||||||
|
_DEFAULT_REJECTED_MARKERS = ( # 小写子串;取设计 §6 实测
|
||||||
|
"failed to open stream", "cannot open empty stream",
|
||||||
|
"cannot save with zero pages", "is no pdf",
|
||||||
|
)
|
||||||
|
def _classify_status(exc, source_name, operation, rejected_markers: tuple[str, ...] = ()): ...
|
||||||
|
|
||||||
|
# ocr.py
|
||||||
|
async def parse_document(
|
||||||
|
self, document: bytes, *, session_id: str | None = None, parent_call_id: str | None = None,
|
||||||
|
tenant_id: str | None = None, meta: Mapping[str, Any] | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
|
) -> OcrDocumentResult: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 任务
|
||||||
|
|
||||||
|
### T1 内核类型与端口 — [ ] 一次提交
|
||||||
|
|
||||||
|
- **文件**: `types.py`、`ports.py`、`__init__.py`(仅导出)、`tests/unit/test_types.py`、`tests/unit/test_ports.py`、`tests/unit/test_package.py`(其显式导出名单加两个新名)。
|
||||||
|
- **行为**: 按 §2 增加三个数据类与两个 Protocol;`CallOperation`/`CALL_OPERATIONS` 增 `"parse_document"`;`OcrLayoutElement` docstring 加「追加带默认值字段前须评估子类 `OcrDocumentElement`(无默认字段会在导入期 `TypeError`)」;`OcrDocumentResult` 不继承 `OcrLayoutResult`,docstring 写明原因;`OcrDocumentResult.markdown` 的 docstring 写明「含悬空 `` 引用——库不外传图片」,`OcrDocumentElement` docstring 写明 `text` 随 type 的含义(设计 §4.1)。
|
||||||
|
- **测试(先红)**:
|
||||||
|
- `test_document_element_is_layout_element`: `isinstance(el, OcrLayoutElement)` 为真,且 `OcrDocumentElement(type="t",bbox=(0,0,1,1),page_index=0,text="x") != OcrLayoutElement(type="t",bbox=(0,0,1,1),page_index=0)`(dataclass `__eq__` 要求同类,钉死该已知语义)。
|
||||||
|
- `test_document_types_frozen`: 三个新类型赋值抛 `FrozenInstanceError`。
|
||||||
|
- `test_call_operations_matches_literal`: `get_args(CallOperation) == CALL_OPERATIONS`(该常量此前无任何读者)。
|
||||||
|
- `test_ocr_document_ports_runtime_checkable`: 合格/缺方法的桩各一;`test_ocr_transport_members_unchanged`: `OcrTransport` 的成员集合恰为 `{recognize_text, parse_layout, check_health}`(防止有人把新方法塞进旧 Protocol);`test_ports.py:190-196` 的参数化补 `(OcrDocumentTransport, "parse_document")` 断言无 reasoning 档。
|
||||||
|
- `test_package.py` 已有导出一致性用例须覆盖两个新导出名。
|
||||||
|
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_ports.py tests/unit/test_package.py -q --no-cov > out.log 2>&1; echo $?` → `0`;`make lint` 通过。
|
||||||
|
- **提交**: `feat: add OCR document types and ports`。
|
||||||
|
|
||||||
|
### T2 上传类型嗅探 — [ ] 一次提交
|
||||||
|
|
||||||
|
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。
|
||||||
|
- **行为**(设计 §3 Q2、§5): `_sniff_media` 判定顺序固定——① 起始为图片魔数(JPEG `\xff\xd8\xff`、PNG `\x89PNG\r\n\x1a\n`、TIFF `II*\x00`/`MM\x00*`、BMP `BM`、GIF `GIF87a`/`GIF89a`、WebP `RIFF????WEBP`)→ `"image"`;② 否则前 1024 字节内含 `%PDF-` → `"pdf"`;③ 否则 `"unknown"`。`_upload_part`: pdf → `("doc.pdf", data, "application/pdf")`,**其余(含 unknown)原样** `("image.jpg", data, "image/jpeg")`。`MonkeyOcrTransport._files` 改为 `{"file": _upload_part(data)}`,`recognize_text`/`parse_layout` 无需改动调用点。
|
||||||
|
- **测试(先红)**: 参数化 `_sniff_media`(纯 PDF、前缀 200 字节垃圾的 PDF、JPEG/PNG/TIFF 两种字节序/BMP/GIF/WebP、**起始为 JPEG 魔数但 1KB 内含 `%PDF-` 字样的图片须判 image**、HTML、乱码、空串);`test_upload_label_per_method`: `recognize_text`/`parse_layout` 对 PDF 字节发出 `filename="doc.pdf"` 与 `Content-Type: application/pdf`,对非 PDF 逐字 `filename="image.jpg"`(现有 `test_multipart_shape` 保持通过)。
|
||||||
|
- **验证**: `pytest tests/unit/test_monkey_ocr.py -q --no-cov` 全绿。
|
||||||
|
- **提交**: `fix: label PDF uploads by content instead of a fixed image name`。
|
||||||
|
|
||||||
|
### T3 结果包解析纯函数 — [ ] 一次提交(本计划最大任务)
|
||||||
|
|
||||||
|
- **文件**: `transports/_monkey_document.py`(新)、`transports/monkey_ocr.py`(仅把 `_finite_number`/`_parse_page_size` 改为从新模块导入,行为不变;新模块**不得**反向导入 `monkey_ocr`)、`tests/unit/test_monkey_document.py`(新)。
|
||||||
|
- **行为**: 严格按设计 §5/§5.1。**异常边界(Codex 审查纠正)**: 广义数据异常转换放在 **`parse_document_package` 最外层**,必须同时覆盖「读取」与「全部结构解析」——`except (ValueError, TypeError, KeyError, OverflowError, RecursionError, zipfile.BadZipFile, zipfile.LargeZipFile)` → `ResultInvalidError("OCR 文档结果包内容无效: …")`(`CancelledError` 不在其内);`_read_members` 内**仅包在 `archive.open/read` 外**的窄 `try` 另接 `RuntimeError`(加密成员)/`NotImplementedError`(未知压缩)/`zlib.error`/`EOFError`。已是 `ResultInvalidError` 的原样透传(先 `except ResultInvalidError: raise`)。
|
||||||
|
- **函数分解(预计均 < C,以提交前 radon 实测为准)**:
|
||||||
|
- `parse_document_package`: 仅编排 `_read_members` → `_parse_pages`,并持有外层异常边界。
|
||||||
|
- `_read_members(zip_bytes) -> tuple[object, str]`: 开 ZIP、取首个 `*_middle.json`、`.md` 按 `去掉 "_middle.json" + ".md"` 精确配对、读取前检查 `ZipInfo.file_size`(middle ≤ `MAX_MIDDLE_BYTES`、md ≤ `MAX_MARKDOWN_BYTES`)、`json.loads`、`utf-8-sig` 解码。
|
||||||
|
- `_parse_pages(payload)`: `pdf_info` 须 list 且**非空**;逐页 `_page_elements`。
|
||||||
|
- `_page_elements(page, position)`: 页须 dict;`_parse_page_size`;`_page_index(page, position)`(独立小函数: 须 int、非 bool、非负且等于 position,缺失同样报错);`para_blocks` 缺失=空页、非 list 报错。
|
||||||
|
- `_block_elements(block, page_index)`: 块须 dict、`type` 须 str 且 `strip()` 非空;`_loose_bbox`;父元素 + 非 `*_body` 嵌套子块各成独立元素(紧随父块、保持顺序)。
|
||||||
|
- 文字提取拆四个小函数: `_lines_text(lines)` → `_line_text(line)` → `_span_text(span)`(`content` 须 str;`inline_equation` 包 `$…$`);`_body_text(block)` 只做分派,表格走 `_table_text(sub_blocks)`(找 `table_body` 子块 → 其 span 取 `html`,无则 `latex`,皆无报错),image 走 `""`。
|
||||||
|
- **测试(先红,`test_monkey_document.py`,合成 builder,不含任何真实文本)**: 设计 §5.1 契约表**逐行一条,参数化列明各分支**:
|
||||||
|
- 页级: 零页;`pdf_info` 缺失/非 list;页非 dict;`page_size` 缺失/非 2 元/含 bool/非正/非有限/巨大整数;`page_idx` 缺失/非 int/bool/负/错位;`para_blocks` 缺失=空页、非 list。
|
||||||
|
- 块级: 块非 dict;`type` 缺失/非 str/空串/**纯空白**;bbox 长度≠4/含非数值/含 bool/非有限/反序(x 与 y 各一)/**零宽与零高均合法**/**越界页面框不被裁剪**;`lines` 非 list/行非 dict/`spans` 非 list/span 非 dict/`content` 非 str/`lines: []`→`""`;table 取 html、取 latex、皆无、无 `table_body` 子块;image(用真实结构的 `image_body`+`image_path`)→`""`;未知类型无 lines 无 blocks→`""` 且 type 保留;嵌套 caption/footnote 成独立元素且**紧随父块**(标「未经实测」);`inline_equation` 包 `$`;多行多 span 拼接。
|
||||||
|
- 包级: `.md` 缺失/非 UTF-8/带 BOM/空串;**多个 `_middle.json` 取首个**;**干扰 `.md`(名称不配对)不被取用**;`discarded_blocks` 不进结果;`need_drop=true` 的页**不失败、不影响结果**;ZIP 里放垃圾 `*_content_list.json` 与 `images/x.jpg`,输出与其无关。
|
||||||
|
- 资源与异常边界: 加密成员、未知压缩方法、CRC 损坏、声明大小超限(middle 与 md 各一)、**`10**400` 整数出现在页结构里(而非仅 JSON 层)**、深嵌套 JSON、span 内嵌套异常类型——全部 `ResultInvalidError`,**不漏出原生异常**。
|
||||||
|
- 回归: 既有 `_parse_middle_json` 对相同输入行为不变(bbox 整数化退化仍拒绝、`page_idx` 非法仍回退枚举)。
|
||||||
|
- docstring 验收: `parse_document_package` docstring 写明与 layout 路径的三处差异(零页拒绝、`page_idx` 严格、bbox 宽松)。
|
||||||
|
- **验证**: `pytest tests/unit/test_monkey_document.py tests/unit/test_monkey_ocr.py -q --no-cov` 全绿;`radon cc src/polygateway/transports/_monkey_document.py src/polygateway/transports/monkey_ocr.py -n C` 无输出。
|
||||||
|
- **提交**: `feat: parse document result packages from middle json only`。
|
||||||
|
|
||||||
|
### T4 确定性 500 窄化(仅新路径可启用) — [ ] 一次提交
|
||||||
|
|
||||||
|
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。
|
||||||
|
- **行为**: `_classify_status(exc, source_name, operation, rejected_markers=())`: 当 `status == 500` 且 `rejected_markers` 非空且响应体(小写)同时含 `parsing failed:` 与任一标记 → 返回 `RequestRejectedError`(**保留 `status_code=500`**,其余 ctx 与摘要同既有);否则走原分类。判定拆成独立小函数 `_is_deterministic_failure(body, markers)`。`_translate_http_errors(source_name, operation, *, rejected_markers=())` 透传。`MonkeyOcrTransport.__init__` 增关键字参数 `deterministic_failure_markers: tuple[str, ...] = _DEFAULT_REJECTED_MARKERS`,存为 `self._rejected_markers`。**默认调用(不传 markers)行为字节级不变。**
|
||||||
|
- **测试(先红)**: 四个标记各一(用设计 §1 E5 的真实响应体形态 `{"detail":"Parsing failed: …"}`)→ `RequestRejectedError` 且 `status_code==500`、摘要保留;同标记但**不带** `Parsing failed:` 前缀 → 仍 Transient;`Parsing failed: 500: Model not initialized` → Transient;`Unsupported file type` → Transient;空 markers → 与现状一致(现有 `TestErrorTranslation` 全部通过);构造参数可覆盖标记集合;`429`/`503`/`401` 在传入 markers 时分类不变。
|
||||||
|
- **红灯预期**: 分类断言失败(返回 `TransientError` 而非 `RequestRejectedError`),不是导入/fixture 错误。
|
||||||
|
- **验证**: `pytest tests/unit/test_monkey_ocr.py -q --no-cov` 全绿。
|
||||||
|
- **提交**: `feat: let document calls treat deterministic parse failures as rejected input`。
|
||||||
|
|
||||||
|
### T5 transport.parse_document — [ ] 一次提交
|
||||||
|
|
||||||
|
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。
|
||||||
|
- **行为**: 把 `parse_layout` 的「POST `/parse` → 校验 `success is True` → 取 `download_url`」抽为共用私有方法(`parse_layout` 行为与错误消息**逐字不变**,传空 markers);新增 `parse_document(*, document, source, call_id)`,并把下载拆成独立私有方法 `_download_package(client, download_url, source) -> bytes`:
|
||||||
|
1. `_sniff_media(document) == "unknown"` → `RequestRejectedError`(无 `status_code`,`operation="parse"`),**不发 HTTP**。
|
||||||
|
2. POST(传 `self._rejected_markers`)。
|
||||||
|
3. `_download_package`: `client.stream("GET")`;**先处理错误状态**——`resp.is_error` 时 `await resp.aread()` 再 `raise_for_status()`(保持响应体摘要随所有分支、保持 401/429/5xx 分类);**成功状态之后**才检查 `Content-Length` 超 `_MAX_PACKAGE_BYTES` 即拒,并对 `aiter_bytes()` 逐块累计,超限 → `ResultInvalidError`(不得让结果包资源检查改变错误分类)。
|
||||||
|
4. `await asyncio.to_thread(parse_document_package, content)`。
|
||||||
|
5. 返回 `OcrDocumentTransportResult(raw={"success": True, "download_url": …})`。
|
||||||
|
- **测试(先红)**:
|
||||||
|
- 全链路(相对与绝对 `download_url`)、`markdown` 与元素取值;`parse_layout` 既有 `TestParseLayout` 全部通过。
|
||||||
|
- **新入口上传标签矩阵**: PDF/前缀垃圾 PDF → `doc.pdf`;图片 → `image.jpg`;未知字节 → 本地 `RequestRejectedError` 且 `MockTransport` 收到 **0 次请求**。
|
||||||
|
- POST 返回四个标记之一 → `RequestRejectedError(500)`,未知 500 → Transient;`success!=true` → `RequestRejectedError(status_code=200)`;GET 的 401/429/500 响应体摘要保留且分类不变;**错误状态 + 超限 `Content-Length` 仍按 HTTP 分类**(不得变成 `ResultInvalidError`);成功状态下 `Content-Length` 超限与多块累计超限 → `ResultInvalidError`。
|
||||||
|
- **解析期间取消不占事件循环**(写法固定): `monkeypatch` 把 transport 引用的 `parse_document_package` 换成同步桩,桩内 `threading.Event.wait(timeout=5)` 阻塞,进入时用 `loop.call_soon_threadsafe(entered.set)` 通知(**不得**在线程里直接用 `asyncio.Event`);测试 `await entered.wait()`,确认另一个协程仍能运行,然后 `task.cancel()`,在释放线程事件**之前**断言 `CancelledError`;`finally` 释放并等待线程收尾。
|
||||||
|
- **验证**: `pytest tests/unit/test_monkey_ocr.py tests/unit/test_monkey_document.py -q --no-cov` 全绿;radon 同 T3。
|
||||||
|
- **提交**: `feat: add parse_document to the MonkeyOCR transport`。
|
||||||
|
|
||||||
|
### T6 OcrClient.parse_document — [ ] 一次提交
|
||||||
|
|
||||||
|
- **文件**: `ocr.py`、`tests/unit/test_ocr_client.py`、`tests/unit/test_telemetry.py`。
|
||||||
|
- **行为**: 按 §2 签名新增公共方法(与 `parse_layout` 对称): 先 `validate_caller_dimensions(origin="parse_document(tenant_id=..., meta=...)")`,再 `_document_transport()` 校验(`isinstance(self._transport, OcrDocumentTransport)`,否则 `TypeError`,**早于 `_CallContext`**)。其余改动:
|
||||||
|
- `_OcrKind` 增 `"document"`;私有方法形参 `image`→`payload`。**旧入口错误文案不变**: `_call` 的校验文案按 kind 取名(`text`/`layout` 仍是「image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)」「image 不能为空」,`document` 为「document 必须是 bytes」「document 不能为空」)。
|
||||||
|
- **四处结果联合类型同步**: `_AttemptOutcome.result`(`ocr.py:91`)、`_invoke` 返回(`:486`)、`_emit` 的 `result`(`:582`)、`_summarize` 入参(`:625`)都加 `OcrDocumentTransportResult`;用于 `isinstance` 的 `OcrDocumentTransport` 与三种 TransportResult 从 `TYPE_CHECKING` 提到运行时导入。
|
||||||
|
- `_invoke` 的 document 分支调 `_document_transport().parse_document(document=payload, ...)`;`_summarize` 改 `isinstance` 三分支(document→`<document elements=N pages=M md_chars=K>`,text→截断正文,layout→原摘要);占位字面量不变 `<ocr:document image_bytes=N>`(`_request_for` 的 f-string 单点)。
|
||||||
|
- 遥测调用点 `_emit` 照旧传 `reasoning_applies=False`(单一 helper 铁律,不复制参数列表)。
|
||||||
|
- **测试(先红)**:
|
||||||
|
- `test_ocr_client.py`: `ScriptedOcrTransport` 增 `parse_document` 与脚本动作 `"document"`(并把现有「非 text 一律 layout」改为三分支);成功路径字段(元素/`markdown`/`usage(0,0)`/`call_stats`/gate 记成功);输入校验(`TypeError`/`ValueError` 与旧文案不变的回归);transport 缺 `parse_document` → `TypeError` 且**无遥测行、无统计**;`call_deadline_s` 非法值/继承值与 `parse_layout` 同行为;换源/退避/`ResultInvalid` 不计窗口(`gate.successes==[("m1", False)]`)、本地拒绝不记成功、`status_code=500` 的 `RequestRejectedError` 记成功不计窗口且不喂选源器;超时 Transient → 计失败 → 重试(文档路径的对照);429 → 照扣重试预算、熔断窗口不变;取消穿透且在飞探针归还。
|
||||||
|
- **无延迟判慢**: 扩展 `ClockAdvancingOcrTransport` 的 `"document"` 动作,source `timeout_s` 设小值,在成功尝试内把 `FakeClock` 推进远超它;断言成功、gate 为 CLOSED、`fails==0`。分别构造两种前置状态: 「CLOSED 但已有失败计数」与「OPEN 冷却后的半开探针」(门与 client 共用同一注入时钟,显式配置 `probe_ttl_s`/租约 TTL,避免把租约接管混进断言)。
|
||||||
|
- **持续慢速 → `CallDeadlineExceeded`**(写法固定,真实事件循环时间): 真实 `MonkeyOcrTransport` + `httpx.MockTransport`,POST 返回合法 `download_url`,GET 返回自定义 `httpx.AsyncByteStream`,每产出一个小块前 `await asyncio.sleep(0.01)`;`call_deadline_s=0.1`;经真实 `OcrClient` 调用,断言 `CallDeadlineExceeded`(不是 `TransientError`)、流已关闭、permit 释放、探针归还、熔断窗口不变,并用计数证明确实已进入多块下载。**局限写进测试 docstring**: MockTransport 不执行 httpcore 的逐次读超时,此用例证明整体期限而非 read timeout。
|
||||||
|
- **真实解析异常贯穿客户端**: 真实 `MonkeyOcrTransport` + MockTransport 返回含 `10**400` 整数 / 加密成员的 ZIP,经真实 `OcrClient`,断言 `ResultInvalidError`、**终态遥测行已写**、半开探针已归还、熔断窗口不变(不能只用桩直接抛预制 `ResultInvalidError`)。
|
||||||
|
- `test_telemetry.py`(其 `_StubOcrTransport` 同步增 `parse_document`): `operation` 参数化加 `parse_document`;文档行 `operation=="parse_document"`、messages 占位、response 摘要含 `md_chars` 且**不含正文**、`tenant_id`/`meta` 落库、记录行 **`reasoning_effort is None`**(`reasoning_applies=False` 是传给 emitter 的参数,落库字段是 `reasoning_effort`)、终态失败行类名前缀;`_summarize` 三类各一条;`text_cap` 对文档占位同样生效。
|
||||||
|
- **验证**: `pytest tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_ports.py -q --no-cov` 全绿;`make lint`;`radon cc src/polygateway/ocr.py -n C` 无新增输出。
|
||||||
|
- **提交**: `feat: add OcrClient.parse_document with full governance`。
|
||||||
|
|
||||||
|
### T7 注册面与对齐工具 — [ ] 一次提交
|
||||||
|
|
||||||
|
- **文件**: `tools/check_wiki_alignment.py`、`research-wiki/schemas/llm-calls.md`(operation 取值,第 35 行附近)、`research-wiki/metrics/ocr-call-success.md`、`research-wiki/metrics/call-telemetry-coverage.md`、`make wiki` 重建的 `research-wiki/{index.md,log.md,graph/edges.json}`。
|
||||||
|
- **行为**: `_SIGNATURE_TARGETS` 加 `("OcrClient.parse_document", OcrClient.parse_document)`;三份 wiki 页补 `parse_document`。
|
||||||
|
- **红绿证据**: 红 = 改动前执行 `conda run -n PolyGateway python -c "import runpy; ns = runpy.run_path('tools/check_wiki_alignment.py', run_name='x'); assert any(n == 'OcrClient.parse_document' for n, _ in ns['_SIGNATURE_TARGETS'])"` 以 `AssertionError` 退出非零;绿 = 改动后同命令退出 0。`make wiki-check` 若依赖外部 Gitea Wiki 仓库而不可达,如实记录「不可达,未执行」,**不编造通过**。
|
||||||
|
- **提交**: `docs: register parse_document in telemetry schema and alignment targets`。
|
||||||
|
|
||||||
|
### T8 活体集成用例(`slow`,opt-in) — [ ] 一次提交
|
||||||
|
|
||||||
|
- **文件**: `tests/integration/test_monkey_ocr_document_live.py`(**新文件**,模块级 `pytestmark = pytest.mark.slow`,**不**带图片语料的 `skipif`)。
|
||||||
|
- **行为**: 测试内手写最小合法 PDF 构造函数 `_minimal_pdf(text)`(5 个对象、**计算** xref 偏移、大号 Helvetica 文字,无第三方内容、无新依赖)。统一放在 `class TestLiveDocument`,三条用例,串行、单份小文档(服务与医院线共用,不压测):
|
||||||
|
1. `test_parse_document_returns_pages_and_text`: PDF 文字取 `POLYGATEWAY SYNTHETIC SAMPLE`;断言 ≥1 页、`page_sizes[0]` 均为正、`"SYNTHETIC"` 出现在 `markdown.upper()` 与某个元素 `text.upper()` 中(**正向断言**,空串不能通过)。
|
||||||
|
2. `test_recognize_text_on_pdf_is_not_the_hallucination`: 同一 PDF 的 `recognize_text`: `text.strip()` 非空且 `"single character"` 不在其中,并含 `"SYNTHETIC"`。
|
||||||
|
3. `test_corrupt_documents_do_not_open_the_breaker`: 以阈值 2 的环境,**连续两份**截断但保留 `%PDF-` 头的 PDF → 均 `RequestRejectedError`;随后有效 PDF 仍成功(若坏文档被误计失败,两次即达阈值,第三次会被熔断拒绝)。
|
||||||
|
- **红绿证据**: 本任务是**活体验收**,不存在「先红」;诚实记录为「无行为红灯,证据 = 单元层 T2/T4/T5 的红绿 + 本任务的活体输出」。手动命令选中全部三条: `pytest tests/integration/test_monkey_ocr_document_live.py -m slow -q --no-cov`,输出存 `tests/outputs/issue34/`(不提交);服务不可达时如实记录,**不 skip 掩盖**。默认套件下 `pytest tests/integration/test_monkey_ocr_document_live.py -q --no-cov` 应显示 3 deselected。
|
||||||
|
- **提交**: `test: add live PDF document parsing checks`。
|
||||||
|
|
||||||
|
### T9 文档与版本 — [ ] 一次提交(bump 不得裸发,CLAUDE.md §4.4.1)
|
||||||
|
|
||||||
|
- **性质**: 文档/版本任务,无行为红灯;证据 = `test_package` 的版本一致性用例 + 对版本串与方法名的 `grep` 输出。
|
||||||
|
- **文件与内容**:
|
||||||
|
- `pyproject.toml` 与 `src/polygateway/__init__.py` 的版本 → `1.3.11`。
|
||||||
|
- `CHANGELOG.md`: 新增「未发布」段(发布时定版): 新增公共面(`parse_document`/`OcrDocumentElement`/`OcrDocumentResult`/`OcrDocumentPort`/`OcrDocumentTransport`/`CallOperation "parse_document"`);**行为变更**: PDF 上传改标 `doc.pdf`(`recognize_text` 修复静默错误、`parse_layout(PDF)` 上传分支变更并写明已测[文本型、位图型]与未测[旋转页、批注页]范围);新路径确定性坏输入归「请求被拒」;**已知限制**: 服务端丢弃脚注/页眉页脚/页码、`markdown` 含悬空 `images/` 引用、多行/多 span/`inline_equation`/嵌套 caption 路径未经实测、`parse_document` 不接受魔数表之外的图片格式;关联 #34,后续 #35/#36。
|
||||||
|
- `README.md`: OCR 小节加 `parse_document` 用法与能力表一行、方法数、文档线配置示例;**设计 §7 的运维要点逐条落字**: ① 文档线独立 scope、`MAX_CONCURRENCY=2`、`RETRY__MAX_ATTEMPTS=2`、`TIMEOUT_S ≥ 最大页数×1.5s×余量(争用取 5 倍以上)`、`OCR_DOC__CALL_DEADLINE_S ≤ PGW_LEASE_TTL_S`(`timeout_s` 不是总耗时上界);② 重试放大(GET 失败会重新 POST 整份文档重算);③ 无页数预检,过滤归调用方;④ 服务端磁盘产物累积,需告知服务运维;⑤ 多 worker 共享并发上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope;⑥ sync-only 后端的排队与对医院线的队头阻塞、须向运维确认 backend(启动日志 `async-capable`/`sync-only`);⑦ 服务丢弃脚注等;⑧ 租约守卫只比 1× `timeout_s` 的缺口与「守卫改造另行立项」。**安装命令的版本约束逐项核对**(CLAUDE.md §4.4.1 第 1 步)。
|
||||||
|
- `.env.example`: OCR 段加文档线独立 scope 示例(`OCR_DOC__MONKEY__1__*`、`OCR_DOC__CALL_DEADLINE_S` 等,键名以 `config.py` 为准)。
|
||||||
|
- `research-wiki/ARCHITECTURE.md`: §5.1 类型清单、§7.10 端口表加 `OcrDocumentPort`、D9 注记、**:597 的 operation 枚举加 `parse_document`、:433 的「四个公共方法」补新增入口**;`designs/2026-07-21-m3-ocr-design.md` §4 加勘误(「服务按内容处理」只验证图片,指向本设计 E3/E4/E10);`migrations/chsanalyzer.md` 加 CHSAnalyzer3 新消费路径与 H3 待确认项。
|
||||||
|
- `research-wiki/ROADMAP.md` 若有 OCR 状态行则同步。
|
||||||
|
- **提交**: `docs: document parse_document and bump version to 1.3.11`。
|
||||||
|
|
||||||
|
### T10 验证与审查(合并前硬门)— [ ] 验收任务,无独立红灯;审查修复另提
|
||||||
|
|
||||||
|
1. `make ci`(只读验证: `ruff format --check` + `ruff check` + `lint-imports` + 全套件)——输出重定向文件取退出码,**不接管道**。
|
||||||
|
2. 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明;对改动的三个模块运行 radon,**以实测为准**报告。
|
||||||
|
3. 派**全新上下文 verifier subagent**(`verification-before-completion`): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。
|
||||||
|
4. `requesting-code-review` 整分支审查(含 Codex `--fresh --wait`);逐条核验后修订,不盲从。
|
||||||
|
5. 活体: T8 手动跑一次的输出;并用本会话在 `/tmp/claude-1000/-home-iomgaa-Projects-PolyGateway/a9dd153e-445b-42f7-9808-4ac914c3f176/scratchpad/ocr/` 里**本地存证**的样本(`x/`、`r/`、`t/`、`scanA/`,第三方内容,**不入库**)对 `parse_document_package` 做离线冒烟: 先检查目录存在,核对 `elements` 数量 = middle 的 `para_blocks` 总数(29/89/30/29)、`text` 与 `content_list` 反转义后一致、`markdown` 等于 `.md` 原文;**样本不存在则在报告中列为未验证项**,不得声称已验证。
|
||||||
|
6. 最终报告 ≤300 行,写入 `research-wiki/reviews/`;向人类汇报**未验证项**(多行/多 span、嵌套 caption、旋转/批注页差分、线上 backend、CHSAnalyzer3 延期语义、真实 httpcore read-timeout 行为)。
|
||||||
|
7. **停在此处,等待人类的发布指令**(合并 main、打 tag、构建、上传、验证已发布、建 Release、关闭 #34 逐步执行,CLAUDE.md §4.4.1)。
|
||||||
|
|
||||||
|
## 4. 自审清单
|
||||||
|
|
||||||
|
| 检查 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 设计每条决策有对应任务 | Q1→T1/T6;Q2→T2/T5;Q3→T3;Q4→T4/T5;Q5 与 §7 运维要点→T6(测试)/T9(逐条落字);§5.1 契约→T3(参数化逐分支);§6 分类表→T4-T6;§8 旧行为→T2/T5/T3 回归断言;H5/H6 变更说明→T9 CHANGELOG |
|
||||||
|
| 跨任务签名一致 | `parse_document_package` 返回三元组(T3)被 T5 `to_thread` 消费;`rejected_markers`(T4)被 T5 透传;`_document_transport()`(T6)是唯一 isinstance 点;四处结果联合类型(T6)已逐处列出 |
|
||||||
|
| 无占位语 | 无 TBD/TODO;关键接口已在 §2 给出实际签名;时间/并发类测试的写法已固定 |
|
||||||
|
| 复杂度 | 只能「预计」;T3 已拆到 `_line_text/_span_text/_table_text/_page_index`;每次提交前 radon 实测 |
|
||||||
|
| 证据完整性 | 每个行为任务写明红灯预期;T7 有可执行红绿命令;T8-T10 如实声明无行为红灯 |
|
||||||
|
| 范围 | 不含 #35/#36 的改动;不含发布动作;不改 `parse_layout` 的校验口径与旧入口文案;不动既有 `test_monkey_ocr_live.py` |
|
||||||
|
|
||||||
|
## 5. 计划审查记录
|
||||||
|
|
||||||
|
Codex 一轮只读审查,结论「修订后进入执行」。逐条核验并全部采纳(第 1 条为我方计划自身的缺陷): ① T3 异常边界放错位置 → 移到最外层(已验证 `_parse_pages` 在 `_read_members` 之后执行);② T8 与既有集成测试文件冲突 → 改新文件(已验证默认收集 6 项旧用例);③ 三类时间/取消测试写法 → 已固定;④ T5 顺序 → 先错误状态后体积;⑤ T6 遥测字段名与联合类型 → 订正(已验证落库字段是 `reasoning_effort`);⑥ 覆盖缺口 → 补入 T3/T5/T9;⑦ 红绿证据 → 逐任务声明;⑧ T8 断言强度与 T10 样本前置 → 订正;⑨ 复杂度表述 → 改「预计」;⑩ 文件清单/文案兼容 → 订正。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
type: review
|
||||||
|
node_id: review:2026-10-01-issue29-cache-review
|
||||||
|
title: 保真缓存审查修复与1.4.0准备
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# 保真缓存审查修复与1.4.0准备
|
||||||
|
|
||||||
|
## 审查采纳
|
||||||
|
|
||||||
|
| 级别/问题 | 核验与处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| Important/P2:空协议缓存绕过成功判据 | 已实证:新测试在修复前 10 failed、1 passed;role-only、null/空/空白正文+空工具、reasoning-only,分别带空投影/伪造旧投影均误命中。采纳并修复。 |
|
||||||
|
| Minor:协议/治理测试互相导入 | 已核验双向依赖;将帧构造放基础协议测试中,上层 fidelity 单向复用,没有新增 helpers 抽象。 |
|
||||||
|
|
||||||
|
内核 `types._has_completion_output` 供 transport 和缓存共用:先经协议类型结构校验,有完整工具即有效;无工具须原协议正文和兼容投影均非空。快照类型仍允许描述空消息,不将成功判据强塞进构造器。缓存使用已有 warning/miss,text 旧缓存行为不变。没有 middleware→transport 依赖。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
定向 `test_cache/test_openai_completion/test_openai_fidelity/test_openai_compat` 为 268 passed;日志 `tests/outputs/issue29/review-cache-{red,green}.log`(运行产物不提交)。完整质量门1827 passed/23 skipped/110 deselected、95%覆盖、退出0;真实Redis+包13通过;1.4.0 wheel/sdist源码/README/LICENSE逐字校验及twine通过,详见计划最新记录;首轮 lint 发现一个测试 import 顺序,修正后重新运行全门,未掩盖首次失败。
|
||||||
|
|
||||||
|
## 发布准备边界
|
||||||
|
|
||||||
|
1.4.0 对应新增公共协议支持及 transport 端口参数;2026-10-01 远端 tags 和匿名 registry simple 最大版本均1.3.8,无1.4.0冲突。README安装约束、能力表、CHANGELOG定版、两处版本同步。新增 `.env.example` 仅四行说明,无凭据。源码/许可来源核查复用已独审 finding;新产物仍须父会话最终复核后发布。本轮不合并、不 push/tag/上传、不关issue;原main脏文件未改。
|
||||||
|
|
||||||
|
## 后续版本定案(2026-10-01 EDT / 2026-10-02 UTC)
|
||||||
|
|
||||||
|
用户最终指定发布 **1.3.9**,取代本文当时准备但从未发布的 1.4.0。本文标题、历史构建及审查证据保留原值;当前发布目标与新构建核验见[计划 §7](../plans/2026-10-01-issue29-openai-fidelity.md)。版本号变更不撤销自定义 Transport 的 `response_mode` 适配要求。
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
type: review
|
||||||
|
node_id: review:2026-10-22-issue34-branch-review
|
||||||
|
title: "#34 OCR 整份文档解析 合并前验收与审查报告"
|
||||||
|
date: 2026-10-22
|
||||||
|
severity: approved
|
||||||
|
---
|
||||||
|
|
||||||
|
# #34 OCR 整份文档解析 合并前验收与审查报告
|
||||||
|
|
||||||
|
- 分支 `fix/issue34-ocr-document`,基线 `25a90eb`(1.3.10),Head `cb1e08f`(17 个提交,目标 1.3.11;本报告随后追加一个文档提交)。
|
||||||
|
- 设计 `designs/2026-10-21-issue34-ocr-document-design.md`(已获人类批准:H1/H5/H6 批准,H2 授权另开 #35/#36,H3 待下游确认,H4 发布逐步等指令);计划 `plans/2026-10-21-issue34-ocr-document.md`。
|
||||||
|
- **结论**: 实现与验证完成,Critical 0、Important 已全部修复。**发布(合并/tag/上传/Release/关 #34)未执行,等人类指令。**
|
||||||
|
|
||||||
|
## 1. 需求覆盖(issue #34 → 实现)
|
||||||
|
|
||||||
|
| issue 期望 | 实现 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 上传按类型设 filename/Content-Type | 库按**内容**判定(H5 有意替换「调用方声明」): 图片魔数优先 → 前 1KB 内 `%PDF-` → `doc.pdf`,其余沿用 `image.jpg`;三个方法共用 | `test_monkey_ocr.py::TestSniffMedia`、`TestUploadLabelPerMethod`、`TestParseDocument::test_upload_label_matrix_for_the_document_entry`;活体 `recognize_text(PDF)` 返回真实文本而非幻觉串 |
|
||||||
|
| 逐块 类型/页码/bbox/文字,服务阅读序 | `OcrDocumentElement`(继承 `OcrLayoutElement` + `text`);只读 `_middle.json`;表格为 HTML | `test_monkey_document.py`(§5.1 契约逐行);4 份真实包离线冒烟 29/89/30/29 块全部与 middle/content_list(反转义)一致 |
|
||||||
|
| 整篇 Markdown | `OcrDocumentResult.markdown`,按名精确配对 `.md` | 同上;真实包 `markdown == .md` 原文 |
|
||||||
|
| 长文档超时与熔断确认 | 库内无延迟型判定(grep 已证);`timeout_s` 非总耗时上界,硬上限 = `call_deadline_s ≤ lease_ttl_s`;文档线独立 scope 配方 | `TestNoLatencyJudgement`(含已有失败计数/半开探针两种前置状态)、持续慢速下载 → `CallDeadlineExceeded`、README「运维须知」 |
|
||||||
|
| `parse_layout` 不动 | 签名/返回类型/错误文案不变;仅上传分支(H6)与共用第一段协议新增 `download_url` 校验 | 独立审查对 5501 个输入与 2486 个场景做基线差分,仅 `%PDF-` 字样载荷的上传标签有差异(已登记) |
|
||||||
|
|
||||||
|
## 2. 本会话证据(命令 → 结果)
|
||||||
|
|
||||||
|
| 声明 | 命令 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 全套通过 | `pytest tests/ -q --cov=src/polygateway`(退出码取自文件,无管道) | 退出 0;**2239 passed, 23 skipped, 113 deselected(slow)**;总覆盖 96% |
|
||||||
|
| 格式/lint/依赖契约 | `make ci` | 退出 0;108 files formatted、ruff All checks passed、`Contracts: 1 kept, 0 broken` |
|
||||||
|
| 复杂度 | `radon cc <ocr.py, _monkey_document.py, monkey_ocr.py> -n C -s` | 无 C 及以上(仅 `openai_compat.py::complete` 为未触及的既有 C) |
|
||||||
|
| 活体 | `pytest tests/integration/test_monkey_ocr_document_live.py -m slow` | 3 passed(8.85s);默认套件 3 deselected |
|
||||||
|
| 真实多页 PDF 端到端 | `OcrClient.from_env("OCR_DOC")` 调 `rich.pdf`(4 页)/`tbl.pdf`(2 页含表) | 89/30 个元素,与直连存证一致;表格 HTML 取出;各 1 次尝试 |
|
||||||
|
| 红→绿 | 每任务 `scratchpad/T*-red.log` → `T*-green.log` | T1–T6 红灯原因均为目标行为缺失(导入失败或 `AttributeError: no attribute 'parse_document'`) |
|
||||||
|
| 测试非空转 | 变异检查(还原/改坏实现 → 测试转红 → 恢复) | 本会话十余类变异全部被抓到: 异常边界放错位置(47 个失败)、`status==500`/前缀判据、同步解析、体积检查先于错误状态、`_summarize`、早期装配校验、整包上限、`aread`/有界读取、`reasoning_applies`、探针归还、`download_url` 校验、`isinstance` 分派;独立审查另做 86 个变异、0 存活 |
|
||||||
|
|
||||||
|
## 3. 评审轮次与处置
|
||||||
|
|
||||||
|
| 轮次 | 审查者 | 结论 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 设计论证 | 独立 Plan subagent(含本机 MonkeyOCR 源码核对) | 弃「content_list 对位」改 middle-only 等 | 全部采纳(设计 §12) |
|
||||||
|
| 设计审查 | Codex(只读) | 否→修订后进入;9 条全部成立 | 全部修订(设计「审查记录」) |
|
||||||
|
| 计划审查 | Codex(只读) | 修订后进入执行;10 条全部成立,第 1 条为我方计划缺陷 | 全部修订(计划 §5) |
|
||||||
|
| 合并前独立验证 | 全新上下文 verifier | 无 Critical;Important 4 条 | I-1 异常漏出、I-2 整包上限无测试、I-3 业务词汇、I-4 三处空转测试——**全部修复**并用变异证明 |
|
||||||
|
| **整分支 Codex 审查** | Codex | **未完成**: 沙箱报 usage limit,只读了设计/计划/部分代码即终止,无任何分级结论 | **以全新上下文的独立 Claude 对抗式审查替代**(见下行);Codex 额度恢复后可补跑,不影响本报告其余结论 |
|
||||||
|
| 对抗式代码审查 | 全新上下文 Claude(自写模糊/变异脚本,假服务压测) | 建议合并;Critical 0,Important 3 | 见 §4 |
|
||||||
|
|
||||||
|
## 4. 对抗式审查发现与处置
|
||||||
|
|
||||||
|
| # | 发现 | 核验 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| I-1 | `_summarize` 由 `hasattr` 改 `isinstance`,鸭子类型 transport 结果以 `AttributeError` 漏出 | 本机复现(`SimpleNamespace(text=...)`) | 改回**结构分派**(有 `markdown` → 文档;有 `text` → 文本;其余版面),加回归测试 |
|
||||||
|
| I-2 | 文档路径的错误体 `aread()` 无上限(600 MiB 的 500 使 RSS 涨到 1.8 GiB) | 属实(基线非流式 `get` 同样,但新代码声称有界) | 错误体只读前 64 KiB 并复制成已缓冲响应;加测试证明只消费上限那么多 |
|
||||||
|
| I-3 | 畸形 `download_url`(控制字符/坏方括号/越界端口)让 `httpx.InvalidURL` 漏出;探针不归还 | 本机复现;`parse_layout` 基线同样 | 在共用的 `_post_parse` 一处校验,归瞬时错误、不发第二段请求;`parse_layout` 顺带受益(CHANGELOG 登记);**不限制主机**(基线接受绝对地址) |
|
||||||
|
| M-1 | 嗅探副作用: 前 1KB 含 `%PDF-` 字样的非 PDF 文本被改标;`BM` 两字节魔数太弱 | 属实 | BMP 加 DIB 头大小校验;CHANGELOG/README 登记副作用 |
|
||||||
|
| M-2 | 字节上限≠内存上限(JSON 解析后可达 23 倍;成员条数无上限) | 属实,仅服务异常时成立 | README 改为诚实口径;不实现 EOCD 预读(真实样本约 72 KB/页) |
|
||||||
|
| M-3 | `to_thread` 不是「立即生效」: GIL 下事件循环仍有停顿,取消后线程自然收尾 | 属实 | 文档改「基本不被占住」并给实测数 |
|
||||||
|
| M-4 | `parse_layout` 的 ZIP 路径仍会漏原生异常(基线即有) | 属实,按设计保持原样 | **后续项**(见 §6) |
|
||||||
|
| M-5/6/7 | 设计文档结局表与实现不一致;类 docstring 过时;测试注释含业务词汇 | 属实 | 全部修正 |
|
||||||
|
| M-8 | `page_idx` 必须等于页序,服务若跳页则整份失败 | 设计取舍 | 保留;列入未验证项 |
|
||||||
|
|
||||||
|
## 5. 没能验证的部分(如实登记)
|
||||||
|
|
||||||
|
- **线上服务**: 旋转页/带批注页/加密 PDF 的 `doc.pdf` vs `image.jpg` 差分;带密码 PDF 的服务端报错文案(若不在标记集合,走瞬时重试并计熔断);线上 backend 是否 sync-only(队头阻塞);真实 httpcore 逐次读超时行为(MockTransport 不执行它);`need_drop` 恒为 False 的线上证实。
|
||||||
|
- **样本局限**: 多行/多 span 文本块、`inline_equation` span、嵌套 caption/footnote 三条路径无真实样本,仅由构造 fixture 测试(已在 CHANGELOG/README 标注「未经实测」)。
|
||||||
|
- **未执行**: `make wiki-check`(需外部 Gitea Wiki 克隆路径,站点自 2026-08-02 下线);Redis 限流/熔断后端与 Postgres 遥测对新入口的组合(无对应环境新增用例,既有套件中的后端用例通过);整分支 Codex 审查(额度)。
|
||||||
|
- **下游**: H3(CHSAnalyzer3 是否把「服务不可用」一族按延期重投处理)未向下游证实。
|
||||||
|
|
||||||
|
## 6. 后续项与待人类指令
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| #35 共享 `_classify_status` 对确定性坏输入 500 仍归瞬时 | 已开 |
|
||||||
|
| #36 `recognize_text` 不校验 `success` | 已开 |
|
||||||
|
| `parse_layout` ZIP 路径的原生异常漏出(OverflowError/NotImplementedError/RuntimeError)与 `download_url` 主机限制 | 人类 2026-10-22 批准新开,已开 #37 |
|
||||||
|
| 发布: 合并 main → push → tag `v1.3.11` → 构建 → 上传 registry → 验证已发布 → 建 Release/挂仓库 → 关闭 #34 | 人类 2026-10-22 下令发布,按 CLAUDE.md §4.4.1 逐步执行;CHANGELOG 定版日期 2026-10-22 |
|
||||||
@@ -32,7 +32,7 @@ date: 2026-07-20
|
|||||||
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
|
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
|
||||||
| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
|
| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
|
||||||
| scope | TEXT | 池名(2026-09-09,issue #19/#23);Emitter 构造期注入,三类行都带,**不拿 `source_name` 顶替** |
|
| scope | TEXT | 池名(2026-09-09,issue #19/#23);Emitter 构造期注入,三类行都带,**不拿 `source_name` 顶替** |
|
||||||
| operation | TEXT | `chat` / `embed` / `recognize_text` / `parse_layout`,由调用点给定;与 `PolyGatewayError.operation` 是两个语义,链路上不得读后者填本列 |
|
| operation | TEXT | `chat` / `embed` / `recognize_text` / `parse_layout` / `parse_document`(2026-10-22,#34),由调用点给定;与 `PolyGatewayError.operation` 是两个语义,链路上不得读后者填本列 |
|
||||||
| logical_call_id | TEXT | 一次逻辑调用一个 ID(重试/换源/重问/分批共享);上下文缺席落 NULL,**不造 ID** |
|
| logical_call_id | TEXT | 一次逻辑调用一个 ID(重试/换源/重问/分批共享);上下文缺席落 NULL,**不造 ID** |
|
||||||
| event_kind | TEXT | 三态 `attempt` / `cache_hit` / `terminal_failure`——三类行的唯一机械判据。见下方口径 |
|
| event_kind | TEXT | 三态 `attempt` / `cache_hit` / `terminal_failure`——三类行的唯一机械判据。见下方口径 |
|
||||||
| http_status_code | INTEGER | 失败 attempt 行的实收状态码(中转改写过就记改写后的,**不猜回原值**);成功行与终态行恒 NULL,且 200 也可能是失败行(MonkeyOCR `success != true`) |
|
| http_status_code | INTEGER | 失败 attempt 行的实收状态码(中转改写过就记改写后的,**不猜回原值**);成功行与终态行恒 NULL,且 200 也可能是失败行(MonkeyOCR `success != true`) |
|
||||||
@@ -177,3 +177,26 @@ ORDER BY model, calls DESC;
|
|||||||
## 1.3.4 测试侧证据(不新增 schema)
|
## 1.3.4 测试侧证据(不新增 schema)
|
||||||
|
|
||||||
`tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
|
`tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
|
||||||
|
|
||||||
|
## #29 复用声明(2026-10-01)
|
||||||
|
|
||||||
|
OpenAI 保真没有新增遥测表/列;仍经 RetryMW._emit → TelemetryEmitter。
|
||||||
|
error 帧是失败 attempt,正文与思考仍为兼容投影,不新增工具全文列。
|
||||||
|
本页原标题/表格停留 36 字段;实际 `telemetry/schema.py` 已含 stall_ms/stall_count
|
||||||
|
共 38 个 INSERT 字段(§7.8 已登记)。本次不改 DDL,真实定义以源码为准。
|
||||||
|
|
||||||
|
## 1.3.10 `stream_end` 列(#31,2026-10-14)
|
||||||
|
|
||||||
|
`llm_calls` 末尾追加可空 `stream_end TEXT`:INSERT 字段 38 → 39,物理列(含 `created_at`)39 → 40;
|
||||||
|
上文"36 字段"标题为历史口径,以 `telemetry/schema.py` 的 `COLUMNS` 为准。
|
||||||
|
|
||||||
|
| 值 | 含义 | 行 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `done` | 收到 `[DONE]` | 流式成功 attempt |
|
||||||
|
| `eof_after_finish` | 无 DONE,finish + 其后实测 usage + 干净 EOF | 流式成功 attempt |
|
||||||
|
| `salvaged` | 文本 `missing_done=salvage` 打捞 | 流式成功 attempt |
|
||||||
|
| NULL | 非流式 / 缓存命中 / 失败 / 取消 / 终态行,或补列前旧行 | — |
|
||||||
|
|
||||||
|
来源: emitter 经 `_AttemptUsage.of(response)` 读 `LLMResponse.stream_end`(不走 `emit_attempt` kw);
|
||||||
|
命中与终态入口显式 None。评估复用 `metric:call-telemetry-coverage`;新增观察口径
|
||||||
|
`SELECT stream_end, count(*) FROM llm_calls WHERE event_kind='attempt' GROUP BY 1` 统计中转不发 DONE 的比例。
|
||||||
|
|||||||
@@ -44,15 +44,20 @@ from polygateway.types import (
|
|||||||
Effort,
|
Effort,
|
||||||
EmbeddingResponse,
|
EmbeddingResponse,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
OcrDocumentElement,
|
||||||
|
OcrDocumentResult,
|
||||||
OcrLayoutElement,
|
OcrLayoutElement,
|
||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
|
OpenAIChatCompletion,
|
||||||
|
ResponseMode,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
StreamEnd,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
ThinkingObservation,
|
ThinkingObservation,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.3.8"
|
__version__ = "1.3.11"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
@@ -70,8 +75,13 @@ __all__ = [
|
|||||||
"GatewayUnavailableError",
|
"GatewayUnavailableError",
|
||||||
"GovernanceBackendError",
|
"GovernanceBackendError",
|
||||||
"LLMResponse",
|
"LLMResponse",
|
||||||
|
"ResponseMode",
|
||||||
|
"StreamEnd",
|
||||||
|
"OpenAIChatCompletion",
|
||||||
"ModelPrice",
|
"ModelPrice",
|
||||||
"OcrClient",
|
"OcrClient",
|
||||||
|
"OcrDocumentElement",
|
||||||
|
"OcrDocumentResult",
|
||||||
"OcrLayoutElement",
|
"OcrLayoutElement",
|
||||||
"OcrLayoutResult",
|
"OcrLayoutResult",
|
||||||
"OcrSettings",
|
"OcrSettings",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -48,6 +49,7 @@ from polygateway.types import (
|
|||||||
ChatRequest,
|
ChatRequest,
|
||||||
Effort,
|
Effort,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
ResponseMode,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
_CallContext,
|
_CallContext,
|
||||||
coerce_effort,
|
coerce_effort,
|
||||||
@@ -372,9 +374,15 @@ class GatewayClient:
|
|||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
meta: Mapping[str, Any] | None = None,
|
meta: Mapping[str, Any] | None = None,
|
||||||
call_deadline_s: float | None = None,
|
call_deadline_s: float | None = None,
|
||||||
|
response_mode: ResponseMode = "text",
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||||
|
|
||||||
|
`response_mode="openai"` 返回 `completion` 完整单 choice 消息快照;
|
||||||
|
不支持与 structured 并用,不执行工具。空正文但完整工具是合法结果;
|
||||||
|
缺 [DONE] 不打捞。默认 text 保持既有文本投影。返回模式不绕过受管推理
|
||||||
|
校验;raw-only 要求源级与请求级推理意图均为 None。
|
||||||
|
|
||||||
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||||
参数不影响既有调用点(issue #4)。
|
参数不影响既有调用点(issue #4)。
|
||||||
@@ -393,6 +401,23 @@ class GatewayClient:
|
|||||||
`CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、
|
`CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、
|
||||||
已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。
|
已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。
|
||||||
"""
|
"""
|
||||||
|
if response_mode not in ("text", "openai"):
|
||||||
|
raise ValueError("response_mode 必须为 text 或 openai")
|
||||||
|
if response_mode == "openai":
|
||||||
|
if structured is not None:
|
||||||
|
raise ValueError("openai 模式不能与 structured 并用")
|
||||||
|
if (
|
||||||
|
overlay is not None
|
||||||
|
and "n" in overlay
|
||||||
|
and (type(overlay["n"]) is not int or overlay["n"] != 1)
|
||||||
|
):
|
||||||
|
raise ValueError("openai 模式仅支持 n=1")
|
||||||
|
try:
|
||||||
|
json.dumps(messages, allow_nan=False)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ValueError("messages 必须可 JSON 序列化") from exc
|
||||||
|
messages = copy.deepcopy(messages)
|
||||||
|
overlay = copy.deepcopy(dict(overlay)) if overlay is not None else None
|
||||||
if structured is not None and not self._structured_available:
|
if structured is not None and not self._structured_available:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
||||||
@@ -429,6 +454,7 @@ class GatewayClient:
|
|||||||
context = _CallContext(now=self._now)
|
context = _CallContext(now=self._now)
|
||||||
request = ChatRequest(
|
request = ChatRequest(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
|
response_mode=response_mode,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
parent_call_id=parent_call_id,
|
parent_call_id=parent_call_id,
|
||||||
cache_salt=cache_salt,
|
cache_salt=cache_salt,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ SOURCE_REASONS = frozenset(
|
|||||||
class PolyGatewayError(Exception):
|
class PolyGatewayError(Exception):
|
||||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
||||||
|
|
||||||
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
`body_text` 是**HTTP 失败或 200 chat error 帧的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
||||||
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
||||||
|
|
||||||
============== ==================================================
|
============== ==================================================
|
||||||
|
|||||||
@@ -17,7 +17,16 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
|
from polygateway.types import (
|
||||||
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
|
LLMResponse,
|
||||||
|
OpenAIChatCompletion,
|
||||||
|
ResponseMode,
|
||||||
|
ThinkingObservation,
|
||||||
|
_effective_finish,
|
||||||
|
_is_cacheable,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -107,6 +116,7 @@ def build_cache_key(
|
|||||||
*,
|
*,
|
||||||
sampling: Mapping[str, Any] | None = None,
|
sampling: Mapping[str, Any] | None = None,
|
||||||
reasoning_effort: Effort | None = None,
|
reasoning_effort: Effort | None = None,
|
||||||
|
response_mode: ResponseMode = "text",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
||||||
|
|
||||||
@@ -127,6 +137,8 @@ def build_cache_key(
|
|||||||
"messages": digest_messages(messages),
|
"messages": digest_messages(messages),
|
||||||
"namespace": namespace,
|
"namespace": namespace,
|
||||||
}
|
}
|
||||||
|
if response_mode != "text":
|
||||||
|
key_obj["response_mode"] = response_mode
|
||||||
if salt is not None:
|
if salt is not None:
|
||||||
key_obj["salt"] = salt
|
key_obj["salt"] = salt
|
||||||
if sampling:
|
if sampling:
|
||||||
@@ -137,6 +149,54 @@ def build_cache_key(
|
|||||||
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _response_cacheable(mode: ResponseMode, response: LLMResponse) -> bool:
|
||||||
|
"""写路径判据;与读路径共用 `_is_cacheable` 与 `_effective_finish`,口径不分叉。"""
|
||||||
|
return _is_cacheable(
|
||||||
|
mode=mode,
|
||||||
|
content=response.content,
|
||||||
|
finish_reason=_effective_finish(response.finish_reason, response.completion),
|
||||||
|
stream_end=response.stream_end,
|
||||||
|
completion=response.completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_fields(raw: str, mode: ResponseMode) -> dict[str, Any]:
|
||||||
|
"""缓存原文 → LLMResponse 字段;不可缓存或形态坏抛 ValueError(调用方按 miss)。
|
||||||
|
|
||||||
|
可缓存判定对**存储原值**进行、先于命中覆盖: 覆盖后 `stream_end` 恒 None,
|
||||||
|
在那之后判会把 salvaged 条目放行(设计 §6)。
|
||||||
|
"""
|
||||||
|
data = json.loads(raw)
|
||||||
|
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
||||||
|
completion = fields.get("completion")
|
||||||
|
if completion is not None:
|
||||||
|
if not isinstance(completion, dict):
|
||||||
|
raise ValueError("缓存 completion 必须为对象")
|
||||||
|
fields["completion"] = OpenAIChatCompletion(**completion)
|
||||||
|
finish = _effective_finish(fields.get("finish_reason"), fields.get("completion"))
|
||||||
|
if finish is not None and not isinstance(finish, str):
|
||||||
|
raise ValueError("缓存 finish_reason 必须为字符串或 null")
|
||||||
|
if not _is_cacheable(
|
||||||
|
mode=mode,
|
||||||
|
content=fields.get("content", ""),
|
||||||
|
finish_reason=finish,
|
||||||
|
stream_end=fields.get("stream_end"),
|
||||||
|
completion=fields.get("completion"),
|
||||||
|
):
|
||||||
|
# 非法 stream_end 也落这里: 它关乎内容完整性,不像 thinking_observation
|
||||||
|
# 那样可以宽容降级
|
||||||
|
raise ValueError("缓存条目不可缓存(截断/空正文/非自然结束/非法 stream_end)")
|
||||||
|
fields["finish_reason"] = finish
|
||||||
|
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
||||||
|
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
||||||
|
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
||||||
|
if "thinking_observation" in fields:
|
||||||
|
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
||||||
|
if "applied_effort" in fields:
|
||||||
|
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
class CacheMW:
|
class CacheMW:
|
||||||
"""洋葱第二层(遥测内、结构化外)。
|
"""洋葱第二层(遥测内、结构化外)。
|
||||||
|
|
||||||
@@ -176,6 +236,7 @@ class CacheMW:
|
|||||||
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
|
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
|
||||||
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
|
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
|
||||||
reasoning_effort=request.reasoning_effort,
|
reasoning_effort=request.reasoning_effort,
|
||||||
|
response_mode=request.response_mode,
|
||||||
)
|
)
|
||||||
cached = await self._safe_get(key)
|
cached = await self._safe_get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -183,7 +244,17 @@ class CacheMW:
|
|||||||
if hit is not None:
|
if hit is not None:
|
||||||
return hit
|
return hit
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
await self._safe_set(key, self._serialize(response))
|
if not _response_cacheable(request.response_mode, response):
|
||||||
|
# 交付照常,只是不进 TTL: 截断/空正文/非自然结束固化后会把一次偶然
|
||||||
|
# 变成必然(设计 §6)。debug 级: 这是常规分流,不是故障
|
||||||
|
logger.debug("结果不可缓存(截断/空正文/非自然结束),跳过写入")
|
||||||
|
return response
|
||||||
|
try:
|
||||||
|
serialized = self._serialize(response)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("缓存序列化失败,跳过缓存: {}", type(exc).__name__)
|
||||||
|
else:
|
||||||
|
await self._safe_set(key, serialized)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# —— 命中路径 ——
|
# —— 命中路径 ——
|
||||||
@@ -191,21 +262,15 @@ class CacheMW:
|
|||||||
def _rehydrate(self, raw: str, request: ChatRequest) -> LLMResponse | None:
|
def _rehydrate(self, raw: str, request: ChatRequest) -> LLMResponse | None:
|
||||||
"""反序列化 + 按本次调用的 structured 档零网络重建;失败按未命中。"""
|
"""反序列化 + 按本次调用的 structured 档零网络重建;失败按未命中。"""
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
fields = _restore_fields(raw, request.response_mode)
|
||||||
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
|
||||||
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
||||||
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
|
||||||
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
|
||||||
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
|
||||||
if "thinking_observation" in fields:
|
|
||||||
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
|
||||||
if "applied_effort" in fields:
|
|
||||||
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
|
|
||||||
fields.update(
|
fields.update(
|
||||||
cache_hit=True,
|
cache_hit=True,
|
||||||
latency_ms=0,
|
latency_ms=0,
|
||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
|
# 结束方式描述的是"那次流",命中没有流可言(与 ttft_ms 同口径)
|
||||||
|
stream_end=None,
|
||||||
call_id=str(uuid.uuid4()),
|
call_id=str(uuid.uuid4()),
|
||||||
structured_data=structured_data,
|
structured_data=structured_data,
|
||||||
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
|
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ class RetryMW:
|
|||||||
gen_started = self._now()
|
gen_started = self._now()
|
||||||
result = await self._transport.complete(
|
result = await self._transport.complete(
|
||||||
messages=request.messages,
|
messages=request.messages,
|
||||||
|
response_mode=request.response_mode,
|
||||||
source=source,
|
source=source,
|
||||||
stream=request.stream,
|
stream=request.stream,
|
||||||
overlay=request.overlay,
|
overlay=request.overlay,
|
||||||
@@ -739,6 +740,9 @@ class RetryMW:
|
|||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=result.content,
|
content=result.content,
|
||||||
|
completion=result.completion,
|
||||||
|
finish_reason=result.finish_reason,
|
||||||
|
stream_end=result.stream_end,
|
||||||
thinking=result.thinking,
|
thinking=result.thinking,
|
||||||
model=source.model,
|
model=source.model,
|
||||||
provider=source.provider,
|
provider=source.provider,
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ class _AttemptUsage:
|
|||||||
# 两字段留在此处只为让"尝试视图"形状完整,默认 None 即"无沉默观测"
|
# 两字段留在此处只为让"尝试视图"形状完整,默认 None 即"无沉默观测"
|
||||||
stall_ms: int | None = None
|
stall_ms: int | None = None
|
||||||
stall_count: int | None = None
|
stall_count: int | None = None
|
||||||
|
# 1.3.10(#31): 与 stall 相反,结束方式**从 response 取**——LLMResponse 本次有
|
||||||
|
# 该字段,免去约 50 处 emit_attempt 调用点加参;失败尝试 None = 无流可言
|
||||||
|
stream_end: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
||||||
@@ -239,6 +242,7 @@ class _AttemptUsage:
|
|||||||
model_reported=response.model_reported,
|
model_reported=response.model_reported,
|
||||||
reasoning_tokens=response.reasoning_tokens,
|
reasoning_tokens=response.reasoning_tokens,
|
||||||
thinking_observation=response.thinking_observation,
|
thinking_observation=response.thinking_observation,
|
||||||
|
stream_end=response.stream_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -370,7 +374,7 @@ def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class TelemetryEmitter:
|
class TelemetryEmitter:
|
||||||
"""从请求与结果组装 38 字段并写入 recorder;一切写失败降级 warning。"""
|
"""从请求与结果组装 39 字段并写入 recorder;一切写失败降级 warning。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -464,6 +468,7 @@ class TelemetryEmitter:
|
|||||||
total_latency_ms=None,
|
total_latency_ms=None,
|
||||||
stall_ms=stall_ms,
|
stall_ms=stall_ms,
|
||||||
stall_count=stall_count,
|
stall_count=stall_count,
|
||||||
|
stream_end=usage.stream_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_cache_hit(
|
async def emit_cache_hit(
|
||||||
@@ -514,6 +519,8 @@ class TelemetryEmitter:
|
|||||||
# 缓存命中行未产生 transport 观测: 无沉默可言,显式 None
|
# 缓存命中行未产生 transport 观测: 无沉默可言,显式 None
|
||||||
stall_ms=None,
|
stall_ms=None,
|
||||||
stall_count=None,
|
stall_count=None,
|
||||||
|
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
|
||||||
|
stream_end=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_terminal_failure(
|
async def emit_terminal_failure(
|
||||||
@@ -569,6 +576,8 @@ class TelemetryEmitter:
|
|||||||
# 终态行无具体源与 transport 观测: 显式 None
|
# 终态行无具体源与 transport 观测: 显式 None
|
||||||
stall_ms=None,
|
stall_ms=None,
|
||||||
stall_count=None,
|
stall_count=None,
|
||||||
|
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
|
||||||
|
stream_end=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _record(
|
async def _record(
|
||||||
@@ -615,6 +624,8 @@ class TelemetryEmitter:
|
|||||||
# 本方法只搬运(同 reasoning_effort 先例)——
|
# 本方法只搬运(同 reasoning_effort 先例)——
|
||||||
stall_ms: int | None,
|
stall_ms: int | None,
|
||||||
stall_count: int | None,
|
stall_count: int | None,
|
||||||
|
# —— 1.3.10: 结束方式(#31);入口已定型,本方法只搬运 ——
|
||||||
|
stream_end: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||||
@@ -685,6 +696,7 @@ class TelemetryEmitter:
|
|||||||
total_latency_ms=total_latency_ms,
|
total_latency_ms=total_latency_ms,
|
||||||
stall_ms=stall_ms,
|
stall_ms=stall_ms,
|
||||||
stall_count=stall_count,
|
stall_count=stall_count,
|
||||||
|
stream_end=stream_end,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
+123
-36
@@ -39,13 +39,17 @@ from polygateway.middleware.breaker import BreakerGate
|
|||||||
from polygateway.middleware.ratelimit import QuotaGate
|
from polygateway.middleware.ratelimit import QuotaGate
|
||||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
|
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
|
||||||
from polygateway.ports import OutcomeAwareSelector
|
from polygateway.ports import OcrDocumentTransport, OutcomeAwareSelector
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
CallStats,
|
CallStats,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
OcrDocumentResult,
|
||||||
|
OcrDocumentTransportResult,
|
||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
|
OcrLayoutTransportResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
|
OcrTextTransportResult,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
Usage,
|
Usage,
|
||||||
_CallContext,
|
_CallContext,
|
||||||
@@ -69,15 +73,18 @@ if TYPE_CHECKING:
|
|||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
CallOperation,
|
CallOperation,
|
||||||
OcrLayoutTransportResult,
|
|
||||||
OcrTextTransportResult,
|
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
_RESPONSE_TEXT_CAP = 200 # 遥测行 text 截断长度(与 embedding 口径一致)
|
_RESPONSE_TEXT_CAP = 200 # 遥测行 text 截断长度(与 embedding 口径一致)
|
||||||
|
|
||||||
_OcrKind = Literal["text", "layout"]
|
_OcrKind = Literal["text", "layout", "document"]
|
||||||
|
|
||||||
|
# 输入校验文案里的名字。旧两个入口(text/layout)的报错文本保持不变,文档入口自称 document
|
||||||
|
_PAYLOAD_NAME: dict[_OcrKind, str] = {"text": "image", "layout": "image", "document": "document"}
|
||||||
|
|
||||||
|
_TransportResult = OcrTextTransportResult | OcrLayoutTransportResult | OcrDocumentTransportResult
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -88,14 +95,14 @@ class _FailedAttempt:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _AttemptOutcome:
|
class _AttemptOutcome:
|
||||||
result: OcrTextTransportResult | OcrLayoutTransportResult
|
result: _TransportResult
|
||||||
source: SourceConfig
|
source: SourceConfig
|
||||||
call_id: str
|
call_id: str
|
||||||
latency_ms: int
|
latency_ms: int
|
||||||
|
|
||||||
|
|
||||||
class OcrClient:
|
class OcrClient:
|
||||||
"""治理化 OCR 入口: 同时实现 OcrTextPort 与 OcrLayoutPort(D9 端口族)。
|
"""治理化 OCR 入口: 同时实现 OcrTextPort、OcrLayoutPort 与 OcrDocumentPort(D9 端口族;文档入口见 #34)。
|
||||||
|
|
||||||
两端点打同一服务实例池,共享同一 scope 的限流/熔断/选源状态;
|
两端点打同一服务实例池,共享同一 scope 的限流/熔断/选源状态;
|
||||||
与其他 scope 共享后端实例即共享全局闸(显式传入,禁止隐式全局)。
|
与其他 scope 共享后端实例即共享全局闸(显式传入,禁止隐式全局)。
|
||||||
@@ -254,6 +261,53 @@ class OcrClient:
|
|||||||
call_stats=call_stats,
|
call_stats=call_stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def parse_document(
|
||||||
|
self,
|
||||||
|
document: bytes,
|
||||||
|
*,
|
||||||
|
session_id: str | None = None,
|
||||||
|
parent_call_id: str | None = None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
meta: Mapping[str, Any] | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
|
) -> OcrDocumentResult:
|
||||||
|
"""一次治理整份文档解析(/parse → ZIP);PDF 或图片,逐块带类型/页码/bbox/文字。
|
||||||
|
|
||||||
|
上传类型由 transport 按内容判定,调用方无需也无法声明。结果的 `markdown` 是服务
|
||||||
|
生成的整篇原文,含悬空的图片引用;服务端会丢弃脚注、页眉页脚与页码。
|
||||||
|
|
||||||
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||||
|
`call_deadline_s` 同 `recognize_text`(issue #22): `None` = 继承装配值。长文档的
|
||||||
|
硬上限靠它——源的 `timeout_s` 是逐次网络操作的超时,不是总耗时上界。
|
||||||
|
"""
|
||||||
|
# 校验早于链路,理由同 recognize_text;transport 能力校验也在统计边界之外
|
||||||
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||||
|
tenant_id, meta, origin="parse_document(tenant_id=..., meta=...)"
|
||||||
|
)
|
||||||
|
self._document_transport()
|
||||||
|
outcome, call_stats = await self._call(
|
||||||
|
"document",
|
||||||
|
"parse_document",
|
||||||
|
document,
|
||||||
|
session_id,
|
||||||
|
parent_call_id,
|
||||||
|
dimension_tenant_id,
|
||||||
|
dimensions,
|
||||||
|
call_deadline_s,
|
||||||
|
)
|
||||||
|
result = outcome.result
|
||||||
|
return OcrDocumentResult(
|
||||||
|
elements=result.elements,
|
||||||
|
page_sizes=result.page_sizes,
|
||||||
|
markdown=result.markdown,
|
||||||
|
source_name=outcome.source.name,
|
||||||
|
usage=Usage(0, 0),
|
||||||
|
latency_ms=outcome.latency_ms,
|
||||||
|
call_id=outcome.call_id,
|
||||||
|
raw=result.raw,
|
||||||
|
call_stats=call_stats,
|
||||||
|
)
|
||||||
|
|
||||||
async def check_health(self) -> dict[str, bool]:
|
async def check_health(self) -> dict[str, bool]:
|
||||||
"""逐源并发健康预检(R10);探测失败=False 不上抛,取消穿透。"""
|
"""逐源并发健康预检(R10);探测失败=False 不上抛,取消穿透。"""
|
||||||
names = [s.name for s in self._sources]
|
names = [s.name for s in self._sources]
|
||||||
@@ -272,37 +326,38 @@ class OcrClient:
|
|||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
operation: CallOperation,
|
operation: CallOperation,
|
||||||
image: bytes,
|
payload: bytes,
|
||||||
session_id: str | None,
|
session_id: str | None,
|
||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
call_deadline_s: float | None = None,
|
call_deadline_s: float | None = None,
|
||||||
) -> tuple[_AttemptOutcome, CallStats]:
|
) -> tuple[_AttemptOutcome, CallStats]:
|
||||||
if not isinstance(image, bytes):
|
name = _PAYLOAD_NAME[kind]
|
||||||
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
if not isinstance(payload, bytes):
|
||||||
if not image:
|
raise TypeError(f"{name} 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
||||||
raise ValueError("image 不能为空")
|
if not payload:
|
||||||
# 期限校验与 `image` 校验同列(仍在 `_CallContext` 之前、创建 awaitable 之前)
|
raise ValueError(f"{name} 不能为空")
|
||||||
|
# 期限校验与 `payload` 校验同列(仍在 `_CallContext` 之前、创建 awaitable 之前)
|
||||||
deadline = (
|
deadline = (
|
||||||
self._call_deadline_s
|
self._call_deadline_s
|
||||||
if call_deadline_s is None
|
if call_deadline_s is None
|
||||||
else ensure_call_deadline(call_deadline_s, f"{operation}(call_deadline_s=...)")
|
else ensure_call_deadline(call_deadline_s, f"{operation}(call_deadline_s=...)")
|
||||||
)
|
)
|
||||||
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
|
# M1 例外: `payload` 校验在 `_call` 内而非公开方法,故上下文在该校验
|
||||||
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
|
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
|
||||||
context = _CallContext(now=self._now)
|
context = _CallContext(now=self._now)
|
||||||
try:
|
try:
|
||||||
return await with_call_deadline(
|
return await with_call_deadline(
|
||||||
self._run(
|
self._run(
|
||||||
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
|
kind, operation, payload, session_id, parent_call_id, tenant_id, meta, context
|
||||||
),
|
),
|
||||||
deadline_s=deadline,
|
deadline_s=deadline,
|
||||||
scope=self._scope,
|
scope=self._scope,
|
||||||
)
|
)
|
||||||
except PolyGatewayError as exc:
|
except PolyGatewayError as exc:
|
||||||
await self._emit_terminal(
|
await self._emit_terminal(
|
||||||
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context, exc
|
kind, operation, payload, session_id, parent_call_id, tenant_id, meta, context, exc
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -310,7 +365,7 @@ class OcrClient:
|
|||||||
await self._emit_terminal(
|
await self._emit_terminal(
|
||||||
kind,
|
kind,
|
||||||
operation,
|
operation,
|
||||||
image,
|
payload,
|
||||||
session_id,
|
session_id,
|
||||||
parent_call_id,
|
parent_call_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -324,7 +379,7 @@ class OcrClient:
|
|||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
operation: CallOperation,
|
operation: CallOperation,
|
||||||
image: bytes,
|
payload: bytes,
|
||||||
session_id: str | None,
|
session_id: str | None,
|
||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
@@ -351,7 +406,7 @@ class OcrClient:
|
|||||||
outcome = await self._attempt(
|
outcome = await self._attempt(
|
||||||
kind,
|
kind,
|
||||||
operation,
|
operation,
|
||||||
image,
|
payload,
|
||||||
*picked,
|
*picked,
|
||||||
reasons,
|
reasons,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -377,7 +432,7 @@ class OcrClient:
|
|||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
operation: CallOperation,
|
operation: CallOperation,
|
||||||
image: bytes,
|
payload: bytes,
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
permit: Permit,
|
permit: Permit,
|
||||||
entry: GateDecision,
|
entry: GateDecision,
|
||||||
@@ -398,7 +453,7 @@ class OcrClient:
|
|||||||
# 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身(同 RetryMW 口径);
|
# 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身(同 RetryMW 口径);
|
||||||
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,属同一段生成耗时
|
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,属同一段生成耗时
|
||||||
gen_started = self._now()
|
gen_started = self._now()
|
||||||
result = await self._invoke(kind, image, source, call_id)
|
result = await self._invoke(kind, payload, source, call_id)
|
||||||
gen_ms = int((self._now() - gen_started) * 1000)
|
gen_ms = int((self._now() - gen_started) * 1000)
|
||||||
await self._record_quietly(self._breaker.record_success(entry))
|
await self._record_quietly(self._breaker.record_success(entry))
|
||||||
await self._record_quietly(self._quota.mark_progress())
|
await self._record_quietly(self._quota.mark_progress())
|
||||||
@@ -408,7 +463,7 @@ class OcrClient:
|
|||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
operation,
|
operation,
|
||||||
image,
|
payload,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
started,
|
started,
|
||||||
@@ -425,7 +480,7 @@ class OcrClient:
|
|||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
operation,
|
operation,
|
||||||
image,
|
payload,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
started,
|
started,
|
||||||
@@ -443,7 +498,7 @@ class OcrClient:
|
|||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
operation,
|
operation,
|
||||||
image,
|
payload,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
started,
|
started,
|
||||||
@@ -464,7 +519,7 @@ class OcrClient:
|
|||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
operation,
|
operation,
|
||||||
image,
|
payload,
|
||||||
source,
|
source,
|
||||||
call_id,
|
call_id,
|
||||||
started,
|
started,
|
||||||
@@ -482,11 +537,31 @@ class OcrClient:
|
|||||||
await settle_and_release(permit, 0)
|
await settle_and_release(permit, 0)
|
||||||
|
|
||||||
async def _invoke(
|
async def _invoke(
|
||||||
self, kind: _OcrKind, image: bytes, source: SourceConfig, call_id: str
|
self, kind: _OcrKind, payload: bytes, source: SourceConfig, call_id: str
|
||||||
) -> OcrTextTransportResult | OcrLayoutTransportResult:
|
) -> _TransportResult:
|
||||||
if kind == "text":
|
if kind == "text":
|
||||||
return await self._transport.recognize_text(image=image, source=source, call_id=call_id)
|
return await self._transport.recognize_text(
|
||||||
return await self._transport.parse_layout(image=image, source=source, call_id=call_id)
|
image=payload, source=source, call_id=call_id
|
||||||
|
)
|
||||||
|
if kind == "document":
|
||||||
|
return await self._document_transport().parse_document(
|
||||||
|
document=payload, source=source, call_id=call_id
|
||||||
|
)
|
||||||
|
return await self._transport.parse_layout(image=payload, source=source, call_id=call_id)
|
||||||
|
|
||||||
|
def _document_transport(self) -> OcrDocumentTransport:
|
||||||
|
"""文档入口所需的 transport 能力校验(唯一的 isinstance 点)。
|
||||||
|
|
||||||
|
`OcrDocumentTransport` 独立于 `OcrTransport`: 往旧 Protocol 加成员会让所有现有
|
||||||
|
实现(测试替身、下游自带 fake)一夜之间不合格。代价是这里必须在运行时校验——
|
||||||
|
公共入口在进入统计边界前调用一次,让装配错误当场报。
|
||||||
|
"""
|
||||||
|
if not isinstance(self._transport, OcrDocumentTransport):
|
||||||
|
raise TypeError(
|
||||||
|
"parse_document 需要实现 OcrDocumentTransport.parse_document 的 transport: "
|
||||||
|
f"{type(self._transport).__name__} 不满足"
|
||||||
|
)
|
||||||
|
return self._transport
|
||||||
|
|
||||||
# —— 辅助(与 embedding 同口径)——
|
# —— 辅助(与 embedding 同口径)——
|
||||||
|
|
||||||
@@ -519,7 +594,7 @@ class OcrClient:
|
|||||||
def _request_for(
|
def _request_for(
|
||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
image: bytes,
|
payload: bytes,
|
||||||
session_id: str | None,
|
session_id: str | None,
|
||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
@@ -533,7 +608,7 @@ class OcrClient:
|
|||||||
维度恒为空、`logical_call_id` 恒为 NULL。
|
维度恒为空、`logical_call_id` 恒为 NULL。
|
||||||
"""
|
"""
|
||||||
return ChatRequest(
|
return ChatRequest(
|
||||||
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
|
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(payload)}>"}],
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
parent_call_id=parent_call_id,
|
parent_call_id=parent_call_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -545,7 +620,7 @@ class OcrClient:
|
|||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
operation: CallOperation,
|
operation: CallOperation,
|
||||||
image: bytes,
|
payload: bytes,
|
||||||
session_id: str | None,
|
session_id: str | None,
|
||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
@@ -557,7 +632,7 @@ class OcrClient:
|
|||||||
await emit_terminal_once(
|
await emit_terminal_once(
|
||||||
self._emitter,
|
self._emitter,
|
||||||
request=self._request_for(
|
request=self._request_for(
|
||||||
kind, image, session_id, parent_call_id, tenant_id, meta, context
|
kind, payload, session_id, parent_call_id, tenant_id, meta, context
|
||||||
),
|
),
|
||||||
context=context,
|
context=context,
|
||||||
error=error,
|
error=error,
|
||||||
@@ -570,7 +645,7 @@ class OcrClient:
|
|||||||
self,
|
self,
|
||||||
kind: _OcrKind,
|
kind: _OcrKind,
|
||||||
operation: CallOperation,
|
operation: CallOperation,
|
||||||
image: bytes,
|
payload: bytes,
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
call_id: str,
|
call_id: str,
|
||||||
started: float,
|
started: float,
|
||||||
@@ -579,14 +654,14 @@ class OcrClient:
|
|||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
context: _CallContext,
|
context: _CallContext,
|
||||||
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
|
result: _TransportResult | None = None,
|
||||||
error: PolyGatewayError | str | None = None,
|
error: PolyGatewayError | str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
|
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
|
||||||
if self._emitter is None:
|
if self._emitter is None:
|
||||||
return
|
return
|
||||||
request = self._request_for(
|
request = self._request_for(
|
||||||
kind, image, session_id, parent_call_id, tenant_id, meta, context
|
kind, payload, session_id, parent_call_id, tenant_id, meta, context
|
||||||
)
|
)
|
||||||
latency_ms = int((self._now() - started) * 1000)
|
latency_ms = int((self._now() - started) * 1000)
|
||||||
response = None
|
response = None
|
||||||
@@ -622,7 +697,19 @@ class OcrClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _summarize(result: OcrTextTransportResult | OcrLayoutTransportResult) -> str:
|
def _summarize(result: _TransportResult) -> str:
|
||||||
|
"""遥测行 response 摘要。**永不写入文档正文**:文档只记规模(元素数/页数/md 字数)。
|
||||||
|
|
||||||
|
**按结构分派而不是 isinstance**: 旧实现就是 `hasattr(result, "text")`,下游测试替身
|
||||||
|
可能返回非库类型的鸭子对象,换成 isinstance 会让它们在开启遥测后以原生
|
||||||
|
AttributeError 漏出。顺序要紧:文档结果有 `markdown`,先于只看 `text` 的判定,否则
|
||||||
|
会被静默套成 layout 摘要而漏记 `md_chars`。
|
||||||
|
"""
|
||||||
|
if hasattr(result, "markdown"):
|
||||||
|
return (
|
||||||
|
f"<document elements={len(result.elements)} pages={len(result.page_sizes)} "
|
||||||
|
f"md_chars={len(result.markdown)}>"
|
||||||
|
)
|
||||||
if hasattr(result, "text"):
|
if hasattr(result, "text"):
|
||||||
return result.text[:_RESPONSE_TEXT_CAP]
|
return result.text[:_RESPONSE_TEXT_CAP]
|
||||||
return f"<elements n={len(result.elements)} pages={len(result.page_sizes)}>"
|
return f"<elements n={len(result.elements)} pages={len(result.page_sizes)}>"
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ from .types import (
|
|||||||
Effort,
|
Effort,
|
||||||
EmbeddingTransportResult,
|
EmbeddingTransportResult,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
OcrDocumentResult,
|
||||||
|
OcrDocumentTransportResult,
|
||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
OcrLayoutTransportResult,
|
OcrLayoutTransportResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
OcrTextTransportResult,
|
OcrTextTransportResult,
|
||||||
|
ResponseMode,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
SourceStats,
|
SourceStats,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
@@ -40,6 +43,10 @@ class Middleware(Protocol):
|
|||||||
class Transport(Protocol):
|
class Transport(Protocol):
|
||||||
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
|
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
|
||||||
|
|
||||||
|
`response_mode` 为必填返回契约:openai 成功必须提供 completion,
|
||||||
|
不支持者应 RequestRejected,不能静默退回文本。工具的实际增量与首次身份
|
||||||
|
同正文/推理共用活性事件;同帧只置位一次,重复身份不算进展。
|
||||||
|
|
||||||
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
|
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
|
||||||
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
|
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
|
||||||
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
|
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
|
||||||
@@ -70,6 +77,7 @@ class Transport(Protocol):
|
|||||||
reasoning_effort: Effort | None,
|
reasoning_effort: Effort | None,
|
||||||
first_token_event: asyncio.Event | None,
|
first_token_event: asyncio.Event | None,
|
||||||
content_event: asyncio.Event | None,
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult: ...
|
) -> TransportResult: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +104,26 @@ class OcrLayoutPort(Protocol):
|
|||||||
async def parse_layout(self, image: bytes) -> OcrLayoutResult: ...
|
async def parse_layout(self, image: bytes) -> OcrLayoutResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OcrDocumentPort(Protocol):
|
||||||
|
"""OCR 整份文档解析端口(#34;对应 MonkeyOCR POST /parse → GET ZIP,PDF 或图片)。"""
|
||||||
|
|
||||||
|
async def parse_document(self, document: bytes) -> OcrDocumentResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OcrDocumentTransport(Protocol):
|
||||||
|
"""整份文档解析的协议细节(#34 设计 §4.2);不含任何治理。
|
||||||
|
|
||||||
|
**独立于 `OcrTransport`**: 往那个 `runtime_checkable` Protocol 加成员,会让所有
|
||||||
|
现有实现(测试替身、下游自带 fake)在 `isinstance` 上一夜之间不合格。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def parse_document(
|
||||||
|
self, *, document: bytes, source: SourceConfig, call_id: str
|
||||||
|
) -> OcrDocumentTransportResult: ...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class OcrTransport(Protocol):
|
class OcrTransport(Protocol):
|
||||||
"""一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。
|
"""一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。
|
||||||
@@ -284,7 +312,7 @@ class TelemetryStatusProvider(Protocol):
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class TelemetryRecorder(Protocol):
|
class TelemetryRecorder(Protocol):
|
||||||
"""遥测后端;38 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8),唯一调用点是 TelemetryEmitter。
|
"""遥测后端;39 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8/1.3.10),唯一调用点是 TelemetryEmitter。
|
||||||
|
|
||||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||||
@@ -351,4 +379,5 @@ class TelemetryRecorder(Protocol):
|
|||||||
total_latency_ms: int | None,
|
total_latency_ms: int | None,
|
||||||
stall_ms: int | None,
|
stall_ms: int | None,
|
||||||
stall_count: int | None,
|
stall_count: int | None,
|
||||||
|
stream_end: str | None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||||
|
|
||||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
||||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 38 个 INSERT 字段 +
|
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 39 个 INSERT 字段 +
|
||||||
`created_at` = 39;列数断言一律按物理列数写,两套口径混用是最易错处。
|
`created_at` = 40;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||||
|
|
||||||
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
||||||
互不依赖(import-linter 契约执法)。
|
互不依赖(import-linter 契约执法)。
|
||||||
@@ -64,7 +64,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
attempts INTEGER,
|
attempts INTEGER,
|
||||||
total_latency_ms INTEGER,
|
total_latency_ms INTEGER,
|
||||||
stall_ms INTEGER,
|
stall_ms INTEGER,
|
||||||
stall_count INTEGER
|
stall_count INTEGER,
|
||||||
|
stream_end TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -108,7 +109,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
attempts INTEGER,
|
attempts INTEGER,
|
||||||
total_latency_ms INTEGER,
|
total_latency_ms INTEGER,
|
||||||
stall_ms INTEGER,
|
stall_ms INTEGER,
|
||||||
stall_count INTEGER
|
stall_count INTEGER,
|
||||||
|
stream_end TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -145,6 +147,8 @@ SQLITE_BACKFILL = (
|
|||||||
# 没记过这件事",与"观测过且零沉默"(0)是两回事
|
# 没记过这件事",与"观测过且零沉默"(0)是两回事
|
||||||
("stall_ms", "INTEGER"),
|
("stall_ms", "INTEGER"),
|
||||||
("stall_count", "INTEGER"),
|
("stall_count", "INTEGER"),
|
||||||
|
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
|
||||||
|
("stream_end", "TEXT"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
||||||
@@ -174,6 +178,8 @@ _PG_BACKFILL_DECLS = (
|
|||||||
# 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上)
|
# 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上)
|
||||||
("stall_ms", "INTEGER"),
|
("stall_ms", "INTEGER"),
|
||||||
("stall_count", "INTEGER"),
|
("stall_count", "INTEGER"),
|
||||||
|
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
|
||||||
|
("stream_end", "TEXT"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
||||||
@@ -226,6 +232,8 @@ COLUMNS = (
|
|||||||
# —— 1.3.8 沉默归因(issue #27)——
|
# —— 1.3.8 沉默归因(issue #27)——
|
||||||
"stall_ms",
|
"stall_ms",
|
||||||
"stall_count",
|
"stall_count",
|
||||||
|
# —— 1.3.10 结束方式(#31)——
|
||||||
|
"stream_end",
|
||||||
)
|
)
|
||||||
|
|
||||||
_COLUMN_SET = frozenset(COLUMNS)
|
_COLUMN_SET = frozenset(COLUMNS)
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class SQLiteRecorder:
|
|||||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;字段集合即 38 字段冻结签名(ports.TelemetryRecorder)。
|
"""写一行遥测;字段集合即 39 字段冻结签名(ports.TelemetryRecorder)。
|
||||||
|
|
||||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""MonkeyOCR 结果包解析: bytes → 文档元素的纯函数(#34 设计 §5/§5.1)。
|
||||||
|
|
||||||
|
本模块**无 HTTP、无治理、无全局状态**,所以能在 `asyncio.to_thread` 里运行——
|
||||||
|
同步解析大对象会占住事件循环,使取消与 `call_deadline_s`(基于 `asyncio.timeout`)
|
||||||
|
无法及时生效。
|
||||||
|
|
||||||
|
内容只取自 `_middle.json`(bbox/类型/页码/阅读序的权威来源,spans 里直接带
|
||||||
|
`content`/`html`)与配对的 `.md`。**有意不读 `_content_list.json`**: 它是展示格式,
|
||||||
|
文本里 `* \\` ~ $` 会被转义成 `\\$` 之类(实测 134 个 text/title 块中 23 个含 `\\$`,
|
||||||
|
取它会污染 LaTeX),且无 bbox、类型词汇与 middle 不同,逐块对位还会引入版本漂移
|
||||||
|
时整份失败的新故障面。
|
||||||
|
|
||||||
|
与 layout 路径(`monkey_ocr._parse_middle_json`,既有调用方在用,保持原样)的三处差异:
|
||||||
|
零页拒绝(成功解析的文档至少 1 页)、`page_idx` 必须等于页序(layout 路径回退枚举
|
||||||
|
下标,会让 page_sizes 按 page_index 索引的契约静默错位)、bbox 只要求有限且有序
|
||||||
|
(整数化退化校验专为 CHS shim 的 `int()` 裁剪,文档消费者不裁剪,一个退化框不该
|
||||||
|
让数百个块的整份文档失败)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import lzma
|
||||||
|
import math
|
||||||
|
import zipfile
|
||||||
|
import zlib
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from polygateway.errors import ResultInvalidError
|
||||||
|
from polygateway.types import OcrDocumentElement
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
MAX_MIDDLE_BYTES = 64 * 1024 * 1024
|
||||||
|
"""`_middle.json` 声明大小上限。样本约 72KB/页,64MiB 足够数百页;超限即拒,
|
||||||
|
防止一个畸形/恶意包把解析线程的内存吃满。"""
|
||||||
|
|
||||||
|
MAX_MARKDOWN_BYTES = 16 * 1024 * 1024
|
||||||
|
"""`.md` 声明大小上限(样本约 3KB/页)。"""
|
||||||
|
|
||||||
|
_MIDDLE_SUFFIX = "_middle.json"
|
||||||
|
|
||||||
|
# ZIP 层「外部数据不可读」的标准库约定用法。只在 ZIP I/O 区域(构造 + 读取)内捕获,
|
||||||
|
# 不放到外层边界——宽泛捕获 RuntimeError/OSError 会掩盖编程错误:
|
||||||
|
# 加密成员 → RuntimeError;未知压缩方法/版本字段损坏 → NotImplementedError;
|
||||||
|
# DEFLATE 流损坏 → zlib.error / EOFError;BZIP2 流损坏 → OSError;LZMA 流损坏 → LZMAError
|
||||||
|
_ZIP_IO_ERRORS = (RuntimeError, NotImplementedError, zlib.error, EOFError, OSError, lzma.LZMAError)
|
||||||
|
|
||||||
|
|
||||||
|
def finite_number(value: object) -> float:
|
||||||
|
"""JSON 数值 → 有限浮点;布尔与非有限数拒绝(CHS invokers.py:427 逐字)。"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise ValueError("字段不是数值")
|
||||||
|
number = float(value)
|
||||||
|
if not math.isfinite(number):
|
||||||
|
raise ValueError("字段不是有限数值")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def parse_page_size(page: dict) -> tuple[float, float]:
|
||||||
|
"""`page_size` → (宽, 高);须为 2 个正有限数(CHS invokers.py 数值防御,原样迁入)。"""
|
||||||
|
page_size = page.get("page_size")
|
||||||
|
if not isinstance(page_size, list) or len(page_size) != 2:
|
||||||
|
raise ValueError("page_size 无效")
|
||||||
|
width, height = (finite_number(item) for item in page_size)
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("page_size 必须为正数")
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_document_package(
|
||||||
|
zip_bytes: bytes,
|
||||||
|
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]:
|
||||||
|
"""ZIP → (elements, page_sizes, markdown);一切形态异常归 `ResultInvalidError`。
|
||||||
|
|
||||||
|
与 layout 路径的三处差异(理由见模块 docstring): ① 零页拒绝(成功解析的文档至少 1 页);
|
||||||
|
② `page_idx` 必须等于页序,不回退枚举下标;③ bbox 只要求有限且有序,不做整数化退化校验。
|
||||||
|
|
||||||
|
外层异常边界**同时盖住「读取」与「全部结构解析」**: `finite_number` 里
|
||||||
|
`float(10**400)` 会抛 `OverflowError`,它发生在读取之后。放错位置的后果是原生
|
||||||
|
异常漏出——`OcrClient._call` 只接 `PolyGatewayError`,于是既不写终态遥测行,又让
|
||||||
|
持有的半开探针不被归还。`CancelledError` 是 `BaseException`,天然不在其内。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload, markdown = _read_members(zip_bytes)
|
||||||
|
elements, page_sizes = _parse_pages(payload)
|
||||||
|
except ResultInvalidError:
|
||||||
|
raise
|
||||||
|
except (
|
||||||
|
ValueError, # 含 JSONDecodeError / UnicodeDecodeError
|
||||||
|
TypeError,
|
||||||
|
KeyError,
|
||||||
|
OverflowError,
|
||||||
|
RecursionError,
|
||||||
|
zipfile.BadZipFile,
|
||||||
|
zipfile.LargeZipFile,
|
||||||
|
) as exc:
|
||||||
|
raise ResultInvalidError(f"OCR 文档结果包内容无效: {exc}") from exc
|
||||||
|
return elements, page_sizes, markdown
|
||||||
|
|
||||||
|
|
||||||
|
def _read_members(zip_bytes: bytes) -> tuple[object, str]:
|
||||||
|
"""取首个 `*_middle.json` 与按名配对的 `.md`,返回 (middle 解码对象, md 文本)。
|
||||||
|
|
||||||
|
`ZipFile(...)` 的构造与成员读取在**同一个**窄 `try` 里: 独立验证发现中央目录版本字段
|
||||||
|
损坏会在构造阶段抛 `NotImplementedError`,而旧写法只包了成员读取。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(BytesIO(zip_bytes)) as archive:
|
||||||
|
middle_raw, markdown_raw = _read_pair(archive)
|
||||||
|
except _ZIP_IO_ERRORS as exc:
|
||||||
|
raise ResultInvalidError(f"OCR 文档结果包无法读取: {exc}") from exc
|
||||||
|
return json.loads(middle_raw), markdown_raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pair(archive: zipfile.ZipFile) -> tuple[bytes, bytes]:
|
||||||
|
"""定位并读取 middle 与配对的 md;缺失或超限抛 `ResultInvalidError`。"""
|
||||||
|
names = archive.namelist()
|
||||||
|
middle_names = [n for n in names if n.endswith(_MIDDLE_SUFFIX)]
|
||||||
|
if not middle_names:
|
||||||
|
raise ResultInvalidError("OCR 文档结果包缺少 _middle.json")
|
||||||
|
middle_name = middle_names[0]
|
||||||
|
# 精确配对而非"取第一个 .md": 包里可能有多个 .md,取错就是把别的文档当本文档
|
||||||
|
markdown_name = middle_name[: -len(_MIDDLE_SUFFIX)] + ".md"
|
||||||
|
if markdown_name not in names:
|
||||||
|
raise ResultInvalidError(f"OCR 文档结果包缺少与 {middle_name} 配对的 {markdown_name}")
|
||||||
|
return (
|
||||||
|
_read_member(archive, middle_name, MAX_MIDDLE_BYTES),
|
||||||
|
_read_member(archive, markdown_name, MAX_MARKDOWN_BYTES),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_member(archive: zipfile.ZipFile, name: str, cap: int) -> bytes:
|
||||||
|
"""读一个成员:先查**声明大小**再解压。
|
||||||
|
|
||||||
|
`zipfile` 读取不会超过声明大小(超出即 CRC 失败),所以声明大小检查足以界定内存。
|
||||||
|
解压异常由调用方 `_read_members` 的窄 `try` 统一转换。
|
||||||
|
"""
|
||||||
|
info = archive.getinfo(name)
|
||||||
|
if info.file_size > cap:
|
||||||
|
raise ResultInvalidError(
|
||||||
|
f"OCR 文档结果包成员 {name} 声明大小 {info.file_size} 超过上限 {cap}"
|
||||||
|
)
|
||||||
|
with archive.open(info) as stream:
|
||||||
|
return stream.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pages(
|
||||||
|
payload: object,
|
||||||
|
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]]]:
|
||||||
|
"""`pdf_info` → 全部页的元素与页尺寸;零页拒绝。"""
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("pdf_info"), list):
|
||||||
|
raise ValueError("_middle.json 缺少 pdf_info 列表")
|
||||||
|
pages = payload["pdf_info"]
|
||||||
|
if not pages:
|
||||||
|
raise ResultInvalidError("OCR 文档结果包 pdf_info 为空: 成功解析的文档至少应有 1 页")
|
||||||
|
elements: list[OcrDocumentElement] = []
|
||||||
|
page_sizes: list[tuple[float, float]] = []
|
||||||
|
for position, page in enumerate(pages):
|
||||||
|
page_elements, page_size = _parse_page(page, position)
|
||||||
|
elements.extend(page_elements)
|
||||||
|
page_sizes.append(page_size)
|
||||||
|
return elements, page_sizes
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_page(
|
||||||
|
page: object, position: int
|
||||||
|
) -> tuple[list[OcrDocumentElement], tuple[float, float]]:
|
||||||
|
"""单页 → (该页元素, 页尺寸);`para_blocks` 缺失按空页处理。"""
|
||||||
|
if not isinstance(page, dict):
|
||||||
|
raise ValueError("页面结构无效")
|
||||||
|
page_size = parse_page_size(page)
|
||||||
|
page_index = _page_index(page, position)
|
||||||
|
blocks = page.get("para_blocks", []) # 缺失 = 空页(空白页/无可识别块)是合法"无内容"
|
||||||
|
if not isinstance(blocks, list):
|
||||||
|
raise ValueError("para_blocks 无效")
|
||||||
|
return [e for block in blocks for e in _block_elements(block, page_index)], page_size
|
||||||
|
|
||||||
|
|
||||||
|
def _page_index(page: dict, position: int) -> int:
|
||||||
|
"""`page_idx` 必须是等于页序的整数(非 bool)。
|
||||||
|
|
||||||
|
不沿用 layout 路径的"非法则回退枚举下标": 回退会掩盖服务端跳页/乱序,而
|
||||||
|
`page_sizes` 是按 page_index 索引的,静默错位比响亮失败更糟。
|
||||||
|
"""
|
||||||
|
raw = page.get("page_idx")
|
||||||
|
if isinstance(raw, bool) or not isinstance(raw, int) or raw != position:
|
||||||
|
raise ValueError(f"page_idx 无效或与页序不符: {raw!r} != {position}")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _block_type(block: object) -> str:
|
||||||
|
"""校验块是 dict 且 `type` 为非空白字符串,返回 type。"""
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
raise ValueError("块结构无效")
|
||||||
|
block_type = block.get("type")
|
||||||
|
if not isinstance(block_type, str) or not block_type.strip():
|
||||||
|
raise ValueError("块缺少 type")
|
||||||
|
return block_type
|
||||||
|
|
||||||
|
|
||||||
|
def _loose_bbox(raw: object) -> tuple[float, float, float, float]:
|
||||||
|
"""长度 4 的有限数值且有序(允许零宽/零高);差异理由见模块 docstring。"""
|
||||||
|
if not isinstance(raw, list) or len(raw) != 4:
|
||||||
|
raise ValueError("bbox 无效")
|
||||||
|
x1, y1, x2, y2 = (finite_number(item) for item in raw)
|
||||||
|
if x2 < x1 or y2 < y1:
|
||||||
|
raise ValueError("bbox 边界顺序无效")
|
||||||
|
return (x1, y1, x2, y2)
|
||||||
|
|
||||||
|
|
||||||
|
def _sub_blocks(block: dict) -> list[dict]:
|
||||||
|
"""嵌套子块列表(缺失为空);子块须是 dict 且 type 合法。"""
|
||||||
|
subs = block.get("blocks", [])
|
||||||
|
if not isinstance(subs, list):
|
||||||
|
raise ValueError("blocks 无效")
|
||||||
|
for sub in subs:
|
||||||
|
_block_type(sub) # 顺带校验子块是 dict 且 type 合法
|
||||||
|
return subs
|
||||||
|
|
||||||
|
|
||||||
|
def _is_body(sub: dict) -> bool:
|
||||||
|
"""子块是否正文部分(type 以 `_body` 结尾,如 table_body / image_body)。"""
|
||||||
|
return sub["type"].endswith("_body")
|
||||||
|
|
||||||
|
|
||||||
|
def _block_elements(block: object, page_index: int) -> list[OcrDocumentElement]:
|
||||||
|
"""父块 → 元素,外加其非 `*_body` 的嵌套子块(紧随父块、保持服务顺序)。"""
|
||||||
|
block_type = _block_type(block)
|
||||||
|
assert isinstance(block, dict) # _block_type 已校验;仅为类型收窄(内部不变量)
|
||||||
|
parent = OcrDocumentElement(
|
||||||
|
type=block_type,
|
||||||
|
bbox=_loose_bbox(block.get("bbox")),
|
||||||
|
page_index=page_index,
|
||||||
|
text=_block_text(block_type, block),
|
||||||
|
)
|
||||||
|
return [parent, *_extra_elements(block, page_index)]
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_elements(block: dict, page_index: int) -> list[OcrDocumentElement]:
|
||||||
|
"""caption/footnote 一类子块:默认部署不产生,出现时有文字就独立成元素,不静默丢弃。"""
|
||||||
|
extras: list[OcrDocumentElement] = []
|
||||||
|
for sub in _sub_blocks(block):
|
||||||
|
if _is_body(sub):
|
||||||
|
continue
|
||||||
|
text = _lines_text(sub.get("lines", []))
|
||||||
|
if text:
|
||||||
|
extras.append(
|
||||||
|
OcrDocumentElement(
|
||||||
|
type=sub["type"],
|
||||||
|
bbox=_loose_bbox(sub.get("bbox")),
|
||||||
|
page_index=page_index,
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return extras
|
||||||
|
|
||||||
|
|
||||||
|
_TEXT_BEARING_TYPES = frozenset({"text", "title", "interline_equation"})
|
||||||
|
"""必带 `lines` 的块类型。缺 `lines` 不能当作"空文本"——与 table 缺内容就报错同口径,
|
||||||
|
否则下游分不清"服务识别出空块"和"服务给了坏结构"。其余未知类型无 lines 仍是合法空文本。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _block_text(block_type: str, block: dict) -> str:
|
||||||
|
"""块 → 文字;随 type 分派(table 取 html/latex,image 为空串,其余取 lines)。"""
|
||||||
|
if block_type == "table":
|
||||||
|
return _table_text(_sub_blocks(block))
|
||||||
|
if block_type == "image":
|
||||||
|
return "" # 图内文字服务不识别;span 只有 image_path
|
||||||
|
if block_type in _TEXT_BEARING_TYPES and "lines" not in block:
|
||||||
|
raise ValueError(f"{block_type} 块缺少 lines")
|
||||||
|
if "lines" in block:
|
||||||
|
return _lines_text(block["lines"])
|
||||||
|
# 未知容器类型: 其 `*_body` 子块的文字并入父元素(非 body 子块已由 _extra_elements 输出)
|
||||||
|
return "\n".join(
|
||||||
|
_lines_text(sub.get("lines", [])) for sub in _sub_blocks(block) if _is_body(sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_text(subs: list[dict]) -> str:
|
||||||
|
"""table 块 → 其 `table_body` 子块的 HTML(或 latex);没有表体即畸形。"""
|
||||||
|
bodies = [sub for sub in subs if sub["type"] == "table_body"]
|
||||||
|
if not bodies:
|
||||||
|
raise ValueError("table 块缺少 table_body 子块")
|
||||||
|
return "\n".join(_lines_text(body.get("lines"), _table_span_text) for body in bodies)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_span_text(span: dict) -> str:
|
||||||
|
"""表体 span 取 html;上游另有 latex 分支。皆无则响亮失败——表格"无内容"与
|
||||||
|
合法空串在结果里无法区分,静默补空会让下游把缺失当成空表。"""
|
||||||
|
for key in ("html", "latex"):
|
||||||
|
value = span.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
raise ValueError("table span 缺少 html/latex")
|
||||||
|
|
||||||
|
|
||||||
|
def _lines_text(lines: object, span_text: Callable[[dict], str] | None = None) -> str:
|
||||||
|
"""行内 span 按 "" 拼、行间按 "\\n" 拼。
|
||||||
|
|
||||||
|
样本全是 1 行 1 span;多行/多 span 路径无真实样本,拼接规则由构造 fixture 钉住,
|
||||||
|
文档注明"未经实测"。
|
||||||
|
"""
|
||||||
|
if not isinstance(lines, list):
|
||||||
|
raise ValueError("lines 无效")
|
||||||
|
return "\n".join(_line_text(line, span_text or _span_text) for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _line_text(line: object, span_text: Callable[[dict], str]) -> str:
|
||||||
|
"""一行 → 文字:该行全部 span 的文字直接相接。"""
|
||||||
|
if not isinstance(line, dict) or not isinstance(line.get("spans"), list):
|
||||||
|
raise ValueError("行结构无效")
|
||||||
|
parts = []
|
||||||
|
for span in line["spans"]:
|
||||||
|
if not isinstance(span, dict):
|
||||||
|
raise ValueError("span 结构无效")
|
||||||
|
parts.append(span_text(span))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _span_text(span: dict) -> str:
|
||||||
|
"""文本 span 取 `content`(必须是 str);`inline_equation` 包 `$…$`(上游惯例)。"""
|
||||||
|
content = span.get("content")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise ValueError("span 缺少字符串 content")
|
||||||
|
return f"${content}$" if span.get("type") == "inline_equation" else content
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。
|
||||||
|
|
||||||
|
1.3.10(#30): null 一律视为缺省;已知字段沿用 1.3.9 语义;未知字段按 OpenAI 流式
|
||||||
|
累加惯例无损合并,冲突删键并登记路径——方言是确定性的,拒绝只会让重试重演同一失败。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from polygateway.errors import TransientError
|
||||||
|
from polygateway.types import OpenAIChatCompletion, ResponseMode
|
||||||
|
|
||||||
|
_DELTA_KNOWN = frozenset({"role", "content", "reasoning_content", "tool_calls"})
|
||||||
|
_TOOL_KNOWN = frozenset({"index", "id", "type", "function"})
|
||||||
|
_FUNCTION_KNOWN = frozenset({"name", "arguments"})
|
||||||
|
# 标签键: 每帧可能重复下发的身份/格式标记,同值幂等,绝不拼接(拼成 "x-v1x-v1"
|
||||||
|
# 回传上游会被拒);也不算生成进展
|
||||||
|
_LABEL_KEYS = frozenset({"index", "type", "id", "format"})
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid(reason: str) -> TransientError:
|
||||||
|
"""协议形态失败,不回显可能敏感的工具参数。"""
|
||||||
|
return TransientError(f"OpenAI 协议失败: {reason}", operation="chat")
|
||||||
|
|
||||||
|
|
||||||
|
def _object(value: Any, name: str) -> dict[str, Any]:
|
||||||
|
"""外部对象先校验再读取。"""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise _invalid(f"{name} 必须为对象")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(target: dict[str, Any], key: str, value: Any) -> bool:
|
||||||
|
"""身份字段只接受同值重复,不把重复完整 ID 当分片拼接。"""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise _invalid(f"{key} 必须为字符串")
|
||||||
|
if key in target and target[key] != value:
|
||||||
|
raise _invalid(f"{key} 冲突")
|
||||||
|
changed = key not in target and bool(value)
|
||||||
|
target[key] = value
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _append(target: dict[str, Any], key: str, value: Any) -> str:
|
||||||
|
"""字符串片段逐字拼接,保留缺省/null/空串区别。"""
|
||||||
|
if value is None:
|
||||||
|
target.setdefault(key, None)
|
||||||
|
return ""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise _invalid(f"{key} 必须为字符串或 null")
|
||||||
|
target[key] = (target.get(key) or "") + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble(message: dict[str, Any], tools: dict[int, dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""按 index 排序组装工具;`finish()` 与 `raw_message()` 共用,判定与快照同源。"""
|
||||||
|
assembled = copy.deepcopy(message)
|
||||||
|
if tools:
|
||||||
|
assembled["tool_calls"] = [copy.deepcopy(tools[index]) for index in sorted(tools)]
|
||||||
|
return assembled
|
||||||
|
|
||||||
|
|
||||||
|
def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion:
|
||||||
|
"""复用内核最终快照校验并翻译领域错误。"""
|
||||||
|
try:
|
||||||
|
return OpenAIChatCompletion(
|
||||||
|
message, metadata.get("finish_reason"), metadata.get("id"), metadata.get("usage")
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise _invalid(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# —— 未知字段合并(设计 §5.2) ——
|
||||||
|
|
||||||
|
|
||||||
|
class _MergeConflictError(Exception):
|
||||||
|
"""合并冲突(内部控制流): 由 `_ExtensionMerger.merge_key` 就地收口为删键。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_indexed(items: list[Any]) -> bool:
|
||||||
|
"""数组元素(忽略 null)均为带整数 index 的对象。"""
|
||||||
|
present = [item for item in items if item is not None]
|
||||||
|
return bool(present) and all(
|
||||||
|
isinstance(item, dict) and type(item.get("index")) is int for item in present
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fragment_progress(key: str, value: Any) -> bool:
|
||||||
|
"""不落盘的活性判据(文本模式): 非标签键上的非空字符串片段。"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return key not in _LABEL_KEYS and value != ""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(_fragment_progress(k, v) for k, v in value.items())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(_fragment_progress(key, item) for item in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _ExtensionMerger:
|
||||||
|
"""每尝试私有的未知字段合并器;冲突路径即封禁,后续同路径值丢弃。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.conflicts: set[str] = set()
|
||||||
|
self._array_modes: dict[str, bool] = {}
|
||||||
|
|
||||||
|
def merge_key(self, target: dict[str, Any], key: str, value: Any, path: str) -> bool:
|
||||||
|
"""合并一个键;返回是否并入了非空字符串片段(活性)。"""
|
||||||
|
if path in self.conflicts:
|
||||||
|
return False
|
||||||
|
if value is None:
|
||||||
|
target.setdefault(key, None)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
merged, grew = self._merge(target.get(key), value, key, path)
|
||||||
|
except _MergeConflictError:
|
||||||
|
target.pop(key, None)
|
||||||
|
self.conflicts.add(path)
|
||||||
|
return False
|
||||||
|
target[key] = merged
|
||||||
|
return grew
|
||||||
|
|
||||||
|
def _merge(self, old: Any, new: Any, key: str, path: str) -> tuple[Any, bool]:
|
||||||
|
if isinstance(new, dict):
|
||||||
|
return self._merge_dict(old, new, path)
|
||||||
|
if isinstance(new, list):
|
||||||
|
return self._merge_list(old, new, key, path)
|
||||||
|
if key in _LABEL_KEYS or not isinstance(new, str):
|
||||||
|
return self._merge_scalar(old, new)
|
||||||
|
if old is not None and not isinstance(old, str):
|
||||||
|
raise _MergeConflictError
|
||||||
|
return (old or "") + new, new != ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_scalar(old: Any, new: Any) -> tuple[Any, bool]:
|
||||||
|
"""标签键与数字/布尔: 同值幂等,不同值冲突(不学 SDK 相加,计数相加会造假)。"""
|
||||||
|
if old is None:
|
||||||
|
return new, False
|
||||||
|
if type(old) is type(new) and old == new:
|
||||||
|
return old, False
|
||||||
|
raise _MergeConflictError
|
||||||
|
|
||||||
|
def _merge_dict(self, old: Any, new: dict[str, Any], path: str) -> tuple[Any, bool]:
|
||||||
|
if old is not None and not isinstance(old, dict):
|
||||||
|
raise _MergeConflictError
|
||||||
|
target = old if old is not None else {}
|
||||||
|
grew = False
|
||||||
|
for sub_key, sub_value in new.items():
|
||||||
|
grew |= self.merge_key(target, sub_key, sub_value, f"{path}.{sub_key}")
|
||||||
|
return target, grew
|
||||||
|
|
||||||
|
def _merge_list(self, old: Any, new: list[Any], key: str, path: str) -> tuple[Any, bool]:
|
||||||
|
if old is not None and not isinstance(old, list):
|
||||||
|
raise _MergeConflictError
|
||||||
|
target = old if old is not None else []
|
||||||
|
items = [item for item in new if item is not None]
|
||||||
|
if not items:
|
||||||
|
return target, False
|
||||||
|
indexed = _is_indexed(items)
|
||||||
|
if self._array_modes.setdefault(path, indexed) != indexed:
|
||||||
|
raise _MergeConflictError # 形态改变: 首次出现决定合并策略
|
||||||
|
if indexed:
|
||||||
|
return target, self._merge_indexed(target, items, f"{path}[]")
|
||||||
|
return target, self._merge_appended(target, items, key, f"{path}[]")
|
||||||
|
|
||||||
|
def _merge_appended(self, target: list[Any], items: list[Any], key: str, path: str) -> bool:
|
||||||
|
grew = False
|
||||||
|
for item in items:
|
||||||
|
merged, item_grew = self._merge(None, item, key, path)
|
||||||
|
target.append(merged)
|
||||||
|
grew |= item_grew
|
||||||
|
return grew
|
||||||
|
|
||||||
|
def _merge_indexed(self, target: list[Any], items: list[Any], path: str) -> bool:
|
||||||
|
grew = False
|
||||||
|
for item in items:
|
||||||
|
slot = next((e for e in target if e.get("index") == item["index"]), None)
|
||||||
|
if slot is None:
|
||||||
|
slot = {}
|
||||||
|
target.append(slot)
|
||||||
|
grew |= self._merge_dict(slot, item, path)[1]
|
||||||
|
return grew
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatDelta:
|
||||||
|
"""一帧提取结果;progress 表示真实生成进展。
|
||||||
|
|
||||||
|
`reasoning` 是 `reasoning` 字段(vLLM/OpenRouter 新名)的片段,投影 thinking 时
|
||||||
|
作为 `reasoning_content` 缺席时的别名(设计 §4.4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
reasoning_content: str
|
||||||
|
progress: bool
|
||||||
|
reasoning: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionAccumulator:
|
||||||
|
"""每次 transport 尝试独享的聚合器,文本模式不严格校验工具产物。"""
|
||||||
|
|
||||||
|
def __init__(self, *, response_mode: ResponseMode) -> None:
|
||||||
|
"""初始化尝试私有状态。"""
|
||||||
|
self.mode = response_mode
|
||||||
|
self.message: dict[str, Any] = {}
|
||||||
|
self.metadata: dict[str, Any] = {}
|
||||||
|
self.tools: dict[int, dict[str, Any]] = {}
|
||||||
|
self._merger = _ExtensionMerger()
|
||||||
|
self._finish_seen = False
|
||||||
|
self._usage_after_finish = False
|
||||||
|
|
||||||
|
# —— 只读视图(transport 判定消费) ——
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finish_reason(self) -> str | None:
|
||||||
|
"""choice 0 首个非 null finish_reason;未见为 None。"""
|
||||||
|
return self.metadata.get("finish_reason")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finish_seen(self) -> bool:
|
||||||
|
"""是否已收到 finish_reason。"""
|
||||||
|
return self._finish_seen
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage_after_finish(self) -> bool:
|
||||||
|
"""finish 同帧或之后是否收到非 null usage(设计 §4.2)。"""
|
||||||
|
return self._usage_after_finish
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conflicts(self) -> frozenset[str]:
|
||||||
|
"""被删除的冲突键路径(数组下标已归一为 `[]`)。"""
|
||||||
|
return frozenset(self._merger.conflicts)
|
||||||
|
|
||||||
|
def raw_message(self) -> dict[str, Any]:
|
||||||
|
"""未校验的合并消息深拷贝(含排序工具),供构造快照前的结局判定。"""
|
||||||
|
return _assemble(self.message, self.tools)
|
||||||
|
|
||||||
|
# —— 聚合 ——
|
||||||
|
|
||||||
|
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta:
|
||||||
|
"""聚合一个已解码帧,一帧至多一个活性事件。"""
|
||||||
|
chunk = _object(chunk, "chunk")
|
||||||
|
if self.mode == "openai":
|
||||||
|
_identity(self.metadata, "id", chunk.get("id"))
|
||||||
|
choice = self._take_choice(chunk.get("choices"))
|
||||||
|
delta = self._take_delta(choice) if choice is not None else ChatDelta("", "", False)
|
||||||
|
self._take_usage(chunk.get("usage"))
|
||||||
|
return delta
|
||||||
|
|
||||||
|
def _take_usage(self, usage: Any) -> None:
|
||||||
|
"""usage 在 finish 处理之后登记,使同帧 finish+usage 算作"之后"。"""
|
||||||
|
if usage is None:
|
||||||
|
return
|
||||||
|
self.metadata["usage"] = _object(usage, "usage")
|
||||||
|
if self._finish_seen:
|
||||||
|
self._usage_after_finish = True
|
||||||
|
|
||||||
|
def _take_choice(self, choices: Any) -> dict[str, Any] | None:
|
||||||
|
"""选出本帧 choice 并登记 finish;usage-only 帧返回 None。"""
|
||||||
|
if choices is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(choices, list):
|
||||||
|
raise _invalid("choices 必须为数组")
|
||||||
|
if not choices:
|
||||||
|
return None
|
||||||
|
if self.mode == "openai":
|
||||||
|
if len(choices) != 1:
|
||||||
|
raise _invalid("仅支持单 choice")
|
||||||
|
choice = _object(choices[0], "choice")
|
||||||
|
if type(choice.get("index")) is not int or choice["index"] != 0:
|
||||||
|
raise _invalid("choice index 必须为 0")
|
||||||
|
_identity(self.metadata, "finish_reason", choice.get("finish_reason"))
|
||||||
|
else:
|
||||||
|
choice = self._text_choice(choices)
|
||||||
|
if choice is None:
|
||||||
|
return None
|
||||||
|
self._finish_seen = self._finish_seen or self.finish_reason is not None
|
||||||
|
return choice
|
||||||
|
|
||||||
|
def _text_choice(self, choices: list[Any]) -> dict[str, Any] | None:
|
||||||
|
"""文本模式取 index 缺省或为 0 的首个 choice,不混入其他 choice(n>1)。"""
|
||||||
|
for raw in choices:
|
||||||
|
choice = _object(raw, "choice")
|
||||||
|
index = choice.get("index")
|
||||||
|
# null 视缺省(=0);bool 不得冒充 0(`False == 0` 为真)
|
||||||
|
if index is None or (type(index) is int and index == 0):
|
||||||
|
finish = choice.get("finish_reason")
|
||||||
|
if isinstance(finish, str) and "finish_reason" not in self.metadata:
|
||||||
|
self.metadata["finish_reason"] = finish
|
||||||
|
return choice
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _take_delta(self, choice: dict[str, Any]) -> ChatDelta:
|
||||||
|
"""拆分已知与未知字段;已知字段语义逐字沿用 1.3.9。"""
|
||||||
|
raw_delta = choice.get("delta")
|
||||||
|
delta = _object(raw_delta if raw_delta is not None else {}, "delta")
|
||||||
|
if self.mode == "openai" and "role" in delta:
|
||||||
|
self._take_role(delta["role"])
|
||||||
|
content = self._take_text(delta, "content")
|
||||||
|
reasoning_content = self._take_text(delta, "reasoning_content")
|
||||||
|
alias = delta.get("reasoning") if isinstance(delta.get("reasoning"), str) else ""
|
||||||
|
progress = bool(content or reasoning_content)
|
||||||
|
if "tool_calls" in delta:
|
||||||
|
progress |= self._take_tools(delta["tool_calls"])
|
||||||
|
progress |= self._take_unknown(delta)
|
||||||
|
return ChatDelta(content, reasoning_content, progress, alias)
|
||||||
|
|
||||||
|
def _take_role(self, role: Any) -> None:
|
||||||
|
if role is None:
|
||||||
|
self.message.setdefault("role", None)
|
||||||
|
return
|
||||||
|
if role != "assistant":
|
||||||
|
raise _invalid("role 必须为 assistant")
|
||||||
|
self.message["role"] = role
|
||||||
|
|
||||||
|
def _take_text(self, delta: dict[str, Any], key: str) -> str:
|
||||||
|
if key not in delta:
|
||||||
|
return ""
|
||||||
|
if self.mode == "openai" or delta[key] is not None:
|
||||||
|
return _append(self.message, key, delta[key])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _take_unknown(self, delta: dict[str, Any]) -> bool:
|
||||||
|
"""未知 delta 键: 保真合并进 message;文本只算活性。"""
|
||||||
|
grew = False
|
||||||
|
for key, value in delta.items():
|
||||||
|
if key in _DELTA_KNOWN:
|
||||||
|
continue
|
||||||
|
if self.mode == "openai":
|
||||||
|
grew |= self._merger.merge_key(self.message, key, value, f"delta.{key}")
|
||||||
|
else:
|
||||||
|
grew |= _fragment_progress(key, value)
|
||||||
|
return grew
|
||||||
|
|
||||||
|
def _take_tools(self, tools: Any) -> bool:
|
||||||
|
if self.mode != "openai":
|
||||||
|
return isinstance(tools, list) and self._each_tool(tools, self._text_tool_progress)
|
||||||
|
if tools is None:
|
||||||
|
self.message.setdefault("tool_calls", None)
|
||||||
|
return False
|
||||||
|
if not isinstance(tools, list):
|
||||||
|
raise _invalid("tool_calls 必须为数组")
|
||||||
|
present = [tool for tool in tools if tool is not None]
|
||||||
|
if self.message.get("tool_calls") is None and (present or not tools):
|
||||||
|
# 显式 [] 原样保留;[null] 不造空数组(回传空 tool_calls 会被上游拒)
|
||||||
|
self.message["tool_calls"] = []
|
||||||
|
return self._each_tool(present, lambda raw: self._take_tool(_object(raw, "tool")))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _each_tool(tools: list[Any], take: Any) -> bool:
|
||||||
|
"""逐个非 null 工具增量聚合,返回本帧是否有进展。"""
|
||||||
|
progress = False
|
||||||
|
for raw in tools:
|
||||||
|
if raw is not None:
|
||||||
|
progress |= take(raw)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _text_tool_progress(self, raw: Any) -> bool:
|
||||||
|
"""文本只消费可识别的工具进展,不承诺工具完整性。"""
|
||||||
|
if not isinstance(raw, dict) or type(raw.get("index")) is not int:
|
||||||
|
return False
|
||||||
|
tool = self.tools.setdefault(raw["index"], {})
|
||||||
|
progress = False
|
||||||
|
for key in ("id", "type"):
|
||||||
|
if isinstance(raw.get(key), str) and raw[key] and key not in tool:
|
||||||
|
tool[key] = raw[key]
|
||||||
|
progress = True
|
||||||
|
fn = raw.get("function")
|
||||||
|
if isinstance(fn, dict):
|
||||||
|
progress |= any(
|
||||||
|
isinstance(fn.get(k), str) and bool(fn[k]) for k in ("name", "arguments")
|
||||||
|
)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _take_tool(self, raw: dict[str, Any]) -> bool:
|
||||||
|
index = raw.get("index")
|
||||||
|
if type(index) is not int or index < 0:
|
||||||
|
raise _invalid("tool index 必须为非负整数")
|
||||||
|
tool = self.tools.setdefault(index, {})
|
||||||
|
progress = _identity(tool, "id", raw.get("id"))
|
||||||
|
progress |= _identity(tool, "type", raw.get("type"))
|
||||||
|
if raw.get("function") is not None:
|
||||||
|
progress |= self._take_function(tool, _object(raw["function"], "function"))
|
||||||
|
for key, value in raw.items():
|
||||||
|
if key not in _TOOL_KNOWN:
|
||||||
|
progress |= self._merger.merge_key(tool, key, value, f"tool_calls[].{key}")
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _take_function(self, tool: dict[str, Any], fn: dict[str, Any]) -> bool:
|
||||||
|
target = tool.setdefault("function", {})
|
||||||
|
progress = False
|
||||||
|
for key, value in fn.items():
|
||||||
|
if key in _FUNCTION_KNOWN:
|
||||||
|
progress |= bool(_append(target, key, value))
|
||||||
|
else:
|
||||||
|
path = f"tool_calls[].function.{key}"
|
||||||
|
progress |= self._merger.merge_key(target, key, value, path)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def finish(self) -> OpenAIChatCompletion:
|
||||||
|
"""线路完整性由调用者保证;此处只校验最终消息。"""
|
||||||
|
return _completion(self.raw_message(), self.metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_null_tools(message: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""null 工具元素剔除: 与流式跳过同口径,原样回传 [null] 会被上游拒。"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
if isinstance(tools, list) and None in tools:
|
||||||
|
kept = [tool for tool in tools if tool is not None]
|
||||||
|
# 全为 null 视同 tool_calls:null,不造会被上游拒收的空数组
|
||||||
|
return {**message, "tool_calls": kept or None}
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _single_choice(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""恰好一个 choice,index 缺省或为 0;拒绝静默截取多 choice。"""
|
||||||
|
choices = body.get("choices")
|
||||||
|
if not isinstance(choices, list) or len(choices) != 1:
|
||||||
|
raise _invalid("仅支持单 choice")
|
||||||
|
choice = _object(choices[0], "choice")
|
||||||
|
if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0):
|
||||||
|
raise _invalid("choice index 必须为 0")
|
||||||
|
return choice
|
||||||
|
|
||||||
|
|
||||||
|
def check_completion_body(body: Any) -> tuple[dict[str, Any], str | None]:
|
||||||
|
"""非流式保真形态校验(不构造快照): 单 choice、index 为 0、finish 为字符串或 null。
|
||||||
|
|
||||||
|
与快照构造拆开: 结局判定要先于构造(截断的半截工具须判 ResultInvalid,
|
||||||
|
构造时抛 Transient 就到不了那一行,设计 §4.3 行 2)。
|
||||||
|
"""
|
||||||
|
choice = _single_choice(_object(body, "body"))
|
||||||
|
finish = choice.get("finish_reason")
|
||||||
|
if finish is not None and not isinstance(finish, str):
|
||||||
|
raise _invalid("finish_reason 必须为字符串或 null")
|
||||||
|
message = _object(choice.get("message"), "message")
|
||||||
|
if message.get("role") != "assistant":
|
||||||
|
# 形态失败先于结局判定,否则缺 role 的截断工具会被归成 ResultInvalid
|
||||||
|
raise _invalid("role 必须为 assistant")
|
||||||
|
return _strip_null_tools(message), finish
|
||||||
|
|
||||||
|
|
||||||
|
def build_completion(
|
||||||
|
message: dict[str, Any], body: dict[str, Any], finish: str | None
|
||||||
|
) -> OpenAIChatCompletion:
|
||||||
|
"""非流式快照构造;保留消息扩展字段。"""
|
||||||
|
return _completion(message, {**body, "finish_reason": finish})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion:
|
||||||
|
"""非流式完整消息保留扩展字段,拒绝静默截取多 choice。"""
|
||||||
|
message, finish = check_completion_body(body)
|
||||||
|
return build_completion(message, body, finish)
|
||||||
@@ -10,11 +10,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -29,7 +28,13 @@ from polygateway.transports._http_errors import (
|
|||||||
response_body,
|
response_body,
|
||||||
summarize_body,
|
summarize_body,
|
||||||
)
|
)
|
||||||
|
from polygateway.transports._monkey_document import (
|
||||||
|
finite_number,
|
||||||
|
parse_document_package,
|
||||||
|
parse_page_size,
|
||||||
|
)
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
|
OcrDocumentTransportResult,
|
||||||
OcrLayoutElement,
|
OcrLayoutElement,
|
||||||
OcrLayoutTransportResult,
|
OcrLayoutTransportResult,
|
||||||
OcrTextTransportResult,
|
OcrTextTransportResult,
|
||||||
@@ -42,6 +47,88 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_HEALTH_TIMEOUT_S = 5.0 # 探测常量(同 jitter 系数先例,不进配置)
|
_HEALTH_TIMEOUT_S = 5.0 # 探测常量(同 jitter 系数先例,不进配置)
|
||||||
|
|
||||||
|
_MediaKind = Literal["pdf", "image", "unknown"]
|
||||||
|
|
||||||
|
# 已知图片魔数。起始命中即判图片,且**优先于** PDF 判定: 含 XMP 元数据的图片
|
||||||
|
# 可能在前 1KB 内带 `%PDF-` 字样,不能因此被当成 PDF。
|
||||||
|
_IMAGE_MAGICS: tuple[bytes, ...] = (
|
||||||
|
b"\xff\xd8\xff", # JPEG
|
||||||
|
b"\x89PNG\r\n\x1a\n", # PNG
|
||||||
|
b"II*\x00", # TIFF 小端
|
||||||
|
b"MM\x00*", # TIFF 大端
|
||||||
|
b"GIF87a",
|
||||||
|
b"GIF89a",
|
||||||
|
)
|
||||||
|
_PDF_HEADER = b"%PDF-"
|
||||||
|
# BMP 的 "BM" 只有两字节,任何以 BM 开头的文本都会撞上。再要求偏移 14 处的 DIB 头大小字段合法
|
||||||
|
_BMP_DIB_HEADER_SIZES = frozenset({12, 40, 52, 56, 64, 108, 124})
|
||||||
|
|
||||||
|
# 文档路径把这些「确定性坏输入」的 500 改归 RequestRejected(#34 设计 §6)。小写子串匹配,
|
||||||
|
# 取 2026-10-21 实测(截断 PDF / 伪 PDF 头 / 乱码 / 空文件 / 扩展名与内容不符)。
|
||||||
|
# **不含** `unsupported file type`: 嗅探后它只可能由服务契约变化或我方标签 bug 引起,
|
||||||
|
# 应响亮失败并计熔断。标记是 PyMuPDF 的界面文案,随服务版本漂移,故可经构造参数覆盖。
|
||||||
|
_DEFAULT_REJECTED_MARKERS: tuple[str, ...] = (
|
||||||
|
"failed to open stream",
|
||||||
|
"cannot open empty stream",
|
||||||
|
"cannot save with zero pages",
|
||||||
|
"is no pdf",
|
||||||
|
)
|
||||||
|
# 服务把一切异常(含它自己抛的 400、`Model not initialized` 这类真故障)都包成同一前缀,
|
||||||
|
# 所以前缀只是必要条件,必须同时命中具体标记
|
||||||
|
_PARSING_FAILED_PREFIX = "parsing failed:"
|
||||||
|
|
||||||
|
_ERROR_BODY_READ_CAP = 64 * 1024
|
||||||
|
"""文档路径读取错误响应体的字节上限。摘要只取几 KiB(`summarize_body`),服务或中间人回一个
|
||||||
|
巨大的错误体不该被整个读进内存(独立审查实测 600 MiB 的 500 让 RSS 涨到 1.8 GiB)。"""
|
||||||
|
|
||||||
|
_MAX_PACKAGE_BYTES = 256 * 1024 * 1024
|
||||||
|
"""整包(含 `images/*.jpg`)体积上限,仅文档路径。4 页含 4 图约 598KB,数百页含图的文档
|
||||||
|
也远低于此;超限即拒,防止畸形/恶意响应把内存吃满。httpx 非流式 `get` 会先整包 `aread()`,
|
||||||
|
这正是文档路径改用流式下载并逐块累计的原因。"""
|
||||||
|
# Adobe 阅读器容忍头部前有垃圾字节(惯例而非 ISO 32000 正文);服务端实测可解析
|
||||||
|
# 前缀 200 字节垃圾的 PDF(#34 设计 E3)
|
||||||
|
_PDF_HEADER_WINDOW = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _is_webp(data: bytes) -> bool:
|
||||||
|
"""RIFF 容器且类型码为 WEBP。"""
|
||||||
|
return data[:4] == b"RIFF" and data[8:12] == b"WEBP"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bmp(data: bytes) -> bool:
|
||||||
|
"""`BM` 开头且 DIB 头大小字段合法(单靠两字节魔数会误判以 BM 开头的文本)。"""
|
||||||
|
return (
|
||||||
|
data.startswith(b"BM")
|
||||||
|
and len(data) >= 18
|
||||||
|
and int.from_bytes(data[14:18], "little") in _BMP_DIB_HEADER_SIZES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sniff_media(data: bytes) -> _MediaKind:
|
||||||
|
"""按内容判定上传类型: 图片魔数 → 前 1KB 内 `%PDF-` → 未知。
|
||||||
|
|
||||||
|
服务端按**文件名扩展名**分派(`.pdf` 走 PDF 数据集,其余走图片数据集),而标签
|
||||||
|
若与内容不符会得到 HTTP 500(`is no PDF`)或——在 `/ocr/text` 上——`success:true`
|
||||||
|
的幻觉文本(#34 设计 E2/E4)。所以标签只能由库按内容定,不开放给调用方声明。
|
||||||
|
"""
|
||||||
|
if data.startswith(_IMAGE_MAGICS) or _is_webp(data) or _is_bmp(data):
|
||||||
|
return "image"
|
||||||
|
if _PDF_HEADER in data[:_PDF_HEADER_WINDOW]:
|
||||||
|
return "pdf"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_part(data: bytes) -> tuple[str, bytes, str]:
|
||||||
|
"""multipart 的 (filename, content, content_type)。
|
||||||
|
|
||||||
|
只有 PDF 改标 `doc.pdf`;**其余(含未知)一律沿用历史的 `image.jpg`**——服务对
|
||||||
|
PNG/TIFF/BMP 字节标 `image.jpg` 照样按内容解码(2026-10-21 实测),任何"更精确"
|
||||||
|
的改标都只会把现在能成功的路径变成失败(webp/gif 没有对应的服务端扩展名)。
|
||||||
|
"""
|
||||||
|
if _sniff_media(data) == "pdf":
|
||||||
|
return ("doc.pdf", data, "application/pdf")
|
||||||
|
return ("image.jpg", data, "image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient:
|
def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient:
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
@@ -52,11 +139,14 @@ def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient:
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
def _translate_http_errors(
|
||||||
|
source_name: str, operation: str, *, rejected_markers: tuple[str, ...] = ()
|
||||||
|
) -> Iterator[None]:
|
||||||
"""httpx 异常 → 错误四分类(CHS `_translate_http_errors` 语义主体保真)。
|
"""httpx 异常 → 错误四分类(CHS `_translate_http_errors` 语义主体保真)。
|
||||||
|
|
||||||
429 细分(insufficient_quota/Retry-After)有意放弃——MonkeyOCR 无鉴权
|
429 细分(insufficient_quota/Retry-After)有意放弃——MonkeyOCR 无鉴权
|
||||||
无计费(设计 §10.2);`from exc` 保留 __cause__ 供 RetryMW 归类 timeout。
|
无计费(设计 §10.2);`from exc` 保留 __cause__ 供 RetryMW 归类 timeout。
|
||||||
|
`rejected_markers` 非空时启用确定性坏输入窄化(仅文档路径传入,见 `_classify_status`)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -67,7 +157,7 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
|||||||
operation=operation,
|
operation=operation,
|
||||||
) from exc
|
) from exc
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
raise _classify_status(exc, source_name, operation) from exc
|
raise _classify_status(exc, source_name, operation, rejected_markers) from exc
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise TransientError(
|
raise TransientError(
|
||||||
f"{source_name} OCR {operation} 网络错误: {exc}",
|
f"{source_name} OCR {operation} 网络错误: {exc}",
|
||||||
@@ -76,16 +166,31 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _is_deterministic_failure(body: str, markers: tuple[str, ...]) -> bool:
|
||||||
|
"""响应体同时含 `Parsing failed:` 前缀与任一已知确定性标记。"""
|
||||||
|
text = body.lower()
|
||||||
|
return _PARSING_FAILED_PREFIX in text and any(marker in text for marker in markers)
|
||||||
|
|
||||||
|
|
||||||
def _classify_status(
|
def _classify_status(
|
||||||
exc: httpx.HTTPStatusError, source_name: str, operation: str
|
exc: httpx.HTTPStatusError,
|
||||||
|
source_name: str,
|
||||||
|
operation: str,
|
||||||
|
rejected_markers: tuple[str, ...] = (),
|
||||||
) -> TransientError | SourceDeadError | RequestRejectedError:
|
) -> TransientError | SourceDeadError | RequestRejectedError:
|
||||||
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
|
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
|
||||||
|
|
||||||
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份——
|
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份——
|
||||||
"只有一个分支用了响应体"正是 issue #10 的成因。
|
"只有一个分支用了响应体"正是 issue #10 的成因。
|
||||||
|
|
||||||
|
唯一的可选窄化: `rejected_markers` 非空且为 500 且命中确定性坏输入标记时,归
|
||||||
|
`RequestRejectedError`(**保留 `status_code=500`**: 服务有响应,熔断按「响应即健康」
|
||||||
|
记成功且不计窗口)。默认空元组 = 既有行为字节级不变——既有调用方对坏图的处置属公共行为,
|
||||||
|
推广须另行设计(issue #35)。
|
||||||
"""
|
"""
|
||||||
status = exc.response.status_code
|
status = exc.response.status_code
|
||||||
summary = summarize_body(response_body(exc.response))
|
body = response_body(exc.response)
|
||||||
|
summary = summarize_body(body)
|
||||||
ctx: dict[str, Any] = {
|
ctx: dict[str, Any] = {
|
||||||
"source_name": source_name,
|
"source_name": source_name,
|
||||||
"status_code": status,
|
"status_code": status,
|
||||||
@@ -93,6 +198,8 @@ def _classify_status(
|
|||||||
"body_text": summary,
|
"body_text": summary,
|
||||||
}
|
}
|
||||||
message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
|
message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
|
||||||
|
if status == 500 and rejected_markers and _is_deterministic_failure(body, rejected_markers):
|
||||||
|
return RequestRejectedError(message, **ctx)
|
||||||
if status >= 500 or status == 429:
|
if status >= 500 or status == 429:
|
||||||
return TransientError(message, **ctx)
|
return TransientError(message, **ctx)
|
||||||
if status in (401, 403):
|
if status in (401, 403):
|
||||||
@@ -100,30 +207,10 @@ def _classify_status(
|
|||||||
return RequestRejectedError(message, **ctx)
|
return RequestRejectedError(message, **ctx)
|
||||||
|
|
||||||
|
|
||||||
def _finite_number(value: object) -> float:
|
|
||||||
"""JSON 数值 → 有限浮点;布尔与非有限数拒绝(CHS invokers.py:427 逐字)。"""
|
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
||||||
raise ValueError("字段不是数值")
|
|
||||||
number = float(value)
|
|
||||||
if not math.isfinite(number):
|
|
||||||
raise ValueError("字段不是有限数值")
|
|
||||||
return number
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_page_size(page: dict) -> tuple[float, float]:
|
|
||||||
page_size = page.get("page_size")
|
|
||||||
if not isinstance(page_size, list) or len(page_size) != 2:
|
|
||||||
raise ValueError("page_size 无效")
|
|
||||||
width, height = (_finite_number(item) for item in page_size)
|
|
||||||
if width <= 0 or height <= 0:
|
|
||||||
raise ValueError("page_size 必须为正数")
|
|
||||||
return (width, height)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_bbox(bbox: object) -> tuple[float, float, float, float]:
|
def _parse_bbox(bbox: object) -> tuple[float, float, float, float]:
|
||||||
if not isinstance(bbox, list) or len(bbox) != 4:
|
if not isinstance(bbox, list) or len(bbox) != 4:
|
||||||
raise ValueError("bbox 无效")
|
raise ValueError("bbox 无效")
|
||||||
x1, y1, x2, y2 = (_finite_number(item) for item in bbox)
|
x1, y1, x2, y2 = (finite_number(item) for item in bbox)
|
||||||
if x2 <= x1 or y2 <= y1:
|
if x2 <= x1 or y2 <= y1:
|
||||||
raise ValueError("bbox 边界顺序无效")
|
raise ValueError("bbox 边界顺序无效")
|
||||||
# 库返回 float 原生 bbox;整数化退化校验专为 CHS shim 的 int() 裁剪路径
|
# 库返回 float 原生 bbox;整数化退化校验专为 CHS shim 的 int() 裁剪路径
|
||||||
@@ -152,9 +239,9 @@ def _parse_pages(payload: object) -> tuple[list[OcrLayoutElement], list[tuple[fl
|
|||||||
for fallback_index, page in enumerate(payload["pdf_info"]):
|
for fallback_index, page in enumerate(payload["pdf_info"]):
|
||||||
if not isinstance(page, dict):
|
if not isinstance(page, dict):
|
||||||
raise ValueError("页面结构无效")
|
raise ValueError("页面结构无效")
|
||||||
page_sizes.append(_parse_page_size(page))
|
page_sizes.append(parse_page_size(page))
|
||||||
raw_index = page.get("page_idx", fallback_index)
|
raw_index = page.get("page_idx", fallback_index)
|
||||||
# bool 是 int 子类、负页号无意义——与 _finite_number 拒 bool 的口径一致
|
# bool 是 int 子类、负页号无意义——与 finite_number 拒 bool 的口径一致
|
||||||
valid_idx = (
|
valid_idx = (
|
||||||
isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0
|
isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0
|
||||||
)
|
)
|
||||||
@@ -186,6 +273,64 @@ def _parse_middle_json(
|
|||||||
raise ResultInvalidError(f"OCR 结果包内容无效: {exc}") from exc
|
raise ResultInvalidError(f"OCR 结果包内容无效: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _check_download_url(download_url: str, source: SourceConfig) -> None:
|
||||||
|
"""`download_url` 来自服务端响应,属外部输入:畸形值先于第二段请求拒绝。
|
||||||
|
|
||||||
|
控制字符、坏方括号会让 httpx 抛 `InvalidURL`(不属 `HTTPError` 体系),越界端口会在连接阶段
|
||||||
|
抛 ExceptionGroup;二者都会从 `_translate_http_errors` 下漏出四分类,`OcrClient._call` 只接
|
||||||
|
`PolyGatewayError`,于是不写终态遥测行、持有的半开探针也不归还。服务返回畸形 URL 是协议故障,
|
||||||
|
与"响应缺少 download_url"同归瞬时错误。**不限制主机**(基线接受绝对地址,测试钉住)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = httpx.URL(download_url)
|
||||||
|
except httpx.InvalidURL as exc:
|
||||||
|
raise TransientError(
|
||||||
|
f"{source.name} /parse 响应的 download_url 非法: {exc}",
|
||||||
|
source_name=source.name,
|
||||||
|
operation="parse",
|
||||||
|
) from exc
|
||||||
|
if url.port is not None and not 0 < url.port <= 65535:
|
||||||
|
raise TransientError(
|
||||||
|
f"{source.name} /parse 响应的 download_url 端口越界: {url.port}",
|
||||||
|
source_name=source.name,
|
||||||
|
operation="parse",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _package_too_large(source: SourceConfig, size: int) -> ResultInvalidError:
|
||||||
|
return ResultInvalidError(
|
||||||
|
f"{source.name} OCR 结果包体积超过上限 {_MAX_PACKAGE_BYTES}(已读 {size})",
|
||||||
|
source_name=source.name,
|
||||||
|
operation="download_result",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _bounded_error_response(resp: httpx.Response) -> httpx.Response:
|
||||||
|
"""读错误响应体的前 `_ERROR_BODY_READ_CAP` 字节,复制成已缓冲的响应。"""
|
||||||
|
data = bytearray()
|
||||||
|
async for chunk in resp.aiter_bytes():
|
||||||
|
data += chunk
|
||||||
|
if len(data) >= _ERROR_BODY_READ_CAP:
|
||||||
|
break
|
||||||
|
headers = {k: v for k, v in resp.headers.items() if k.lower() == "content-type"}
|
||||||
|
return httpx.Response(
|
||||||
|
resp.status_code,
|
||||||
|
headers=headers,
|
||||||
|
content=bytes(data[:_ERROR_BODY_READ_CAP]),
|
||||||
|
request=resp.request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_declared_length(resp: httpx.Response, source: SourceConfig) -> None:
|
||||||
|
"""声明长度超限就不必读了;缺失/非法的头部不作判据(靠累计字节兜底)。"""
|
||||||
|
try:
|
||||||
|
declared = int(resp.headers.get("content-length", ""))
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
if declared > _MAX_PACKAGE_BYTES:
|
||||||
|
raise _package_too_large(source, declared)
|
||||||
|
|
||||||
|
|
||||||
class MonkeyOcrTransport:
|
class MonkeyOcrTransport:
|
||||||
"""MonkeyOCR transport: 每源一个预配 httpx client,懒创建,aclose 统一释放。"""
|
"""MonkeyOCR transport: 每源一个预配 httpx client,懒创建,aclose 统一释放。"""
|
||||||
|
|
||||||
@@ -193,8 +338,11 @@ class MonkeyOcrTransport:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
|
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
|
||||||
|
deterministic_failure_markers: tuple[str, ...] = _DEFAULT_REJECTED_MARKERS,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._client_factory = client_factory or _default_client_factory
|
self._client_factory = client_factory or _default_client_factory
|
||||||
|
# 仅文档路径使用(见 `_classify_status`);recognize_text / parse_layout 不传
|
||||||
|
self._rejected_markers = deterministic_failure_markers
|
||||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||||
|
|
||||||
def _client_for(self, source: SourceConfig) -> httpx.AsyncClient:
|
def _client_for(self, source: SourceConfig) -> httpx.AsyncClient:
|
||||||
@@ -205,9 +353,10 @@ class MonkeyOcrTransport:
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _files(image: bytes) -> dict[str, tuple[str, bytes, str]]:
|
def _files(data: bytes) -> dict[str, tuple[str, bytes, str]]:
|
||||||
# 固定文件名/类型(CHS invokers.py:496 原样;服务按内容处理,35 样本实测)
|
# 标签按内容判定(#34): 非 PDF 沿用 CHS invokers.py:496 的 image.jpg,
|
||||||
return {"file": ("image.jpg", image, "image/jpeg")}
|
# PDF 改标 doc.pdf——服务按扩展名分派,固定名对 PDF 是错的
|
||||||
|
return {"file": _upload_part(data)}
|
||||||
|
|
||||||
def _response_json(self, resp: httpx.Response, source: SourceConfig, operation: str) -> dict:
|
def _response_json(self, resp: httpx.Response, source: SourceConfig, operation: str) -> dict:
|
||||||
"""响应体 JSON 化;坏 JSON/非 dict → Transient(设计 §4: 服务侧瞬时异常)。"""
|
"""响应体 JSON 化;坏 JSON/非 dict → Transient(设计 §4: 服务侧瞬时异常)。"""
|
||||||
@@ -249,32 +398,48 @@ class MonkeyOcrTransport:
|
|||||||
text=content, raw={k: v for k, v in data.items() if k != "content"}
|
text=content, raw={k: v for k, v in data.items() if k != "content"}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def parse_layout(
|
async def _post_parse(
|
||||||
self, *, image: bytes, source: SourceConfig, call_id: str
|
self,
|
||||||
) -> OcrLayoutTransportResult:
|
client: httpx.AsyncClient,
|
||||||
"""两段协议: POST /parse → GET download_url → ZIP → 全元素提取。"""
|
data: bytes,
|
||||||
del call_id
|
source: SourceConfig,
|
||||||
client = self._client_for(source)
|
rejected_markers: tuple[str, ...],
|
||||||
with _translate_http_errors(source.name, "parse"):
|
) -> str:
|
||||||
resp = await client.post("/parse", files=self._files(image))
|
"""两段协议的第一段: POST /parse → 校验 `success` → 返回 download_url。
|
||||||
|
|
||||||
|
layout 与 document 两条路径共用,保证"success 为假 → 附 status_code=200"等
|
||||||
|
设计 §10.2 的有意修复只有一份。
|
||||||
|
"""
|
||||||
|
with _translate_http_errors(source.name, "parse", rejected_markers=rejected_markers):
|
||||||
|
resp = await client.post("/parse", files=self._files(data))
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = self._response_json(resp, source, "parse")
|
body = self._response_json(resp, source, "parse")
|
||||||
if data.get("success") is not True:
|
if body.get("success") is not True:
|
||||||
# 设计 §10.2 有意修复: 200 响应确证服务活着,附 status_code
|
# 设计 §10.2 有意修复: 200 响应确证服务活着,附 status_code
|
||||||
# 让治理侧按"响应即健康"记熔断成功(CHS 原样会误走本地拒绝分支)
|
# 让治理侧按"响应即健康"记熔断成功(CHS 原样会误走本地拒绝分支)
|
||||||
raise RequestRejectedError(
|
raise RequestRejectedError(
|
||||||
f"{source.name} OCR 解析失败: {data.get('message')}",
|
f"{source.name} OCR 解析失败: {body.get('message')}",
|
||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
operation="parse",
|
operation="parse",
|
||||||
)
|
)
|
||||||
download_url = data.get("download_url")
|
download_url = body.get("download_url")
|
||||||
if not isinstance(download_url, str) or not download_url.strip():
|
if not isinstance(download_url, str) or not download_url.strip():
|
||||||
raise TransientError(
|
raise TransientError(
|
||||||
f"{source.name} /parse 响应缺少 download_url",
|
f"{source.name} /parse 响应缺少 download_url",
|
||||||
source_name=source.name,
|
source_name=source.name,
|
||||||
operation="parse",
|
operation="parse",
|
||||||
)
|
)
|
||||||
|
_check_download_url(download_url, source)
|
||||||
|
return download_url
|
||||||
|
|
||||||
|
async def parse_layout(
|
||||||
|
self, *, image: bytes, source: SourceConfig, call_id: str
|
||||||
|
) -> OcrLayoutTransportResult:
|
||||||
|
"""两段协议: POST /parse → GET download_url → ZIP → 全元素提取。"""
|
||||||
|
del call_id
|
||||||
|
client = self._client_for(source)
|
||||||
|
download_url = await self._post_parse(client, image, source, ())
|
||||||
with _translate_http_errors(source.name, "download_result"):
|
with _translate_http_errors(source.name, "download_result"):
|
||||||
# 实测 download_url 为相对路径(/static/*.zip),相对 base_url 解析;
|
# 实测 download_url 为相对路径(/static/*.zip),相对 base_url 解析;
|
||||||
# httpx 对绝对 URL 会忽略 base_url,两种形态天然兼容
|
# httpx 对绝对 URL 会忽略 base_url,两种形态天然兼容
|
||||||
@@ -287,6 +452,59 @@ class MonkeyOcrTransport:
|
|||||||
raw={"success": True, "download_url": download_url},
|
raw={"success": True, "download_url": download_url},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def parse_document(
|
||||||
|
self, *, document: bytes, source: SourceConfig, call_id: str
|
||||||
|
) -> OcrDocumentTransportResult:
|
||||||
|
"""整份文档: 本地预检 → POST /parse → 流式 GET → 线程内解析(#34 设计 §5)。"""
|
||||||
|
del call_id
|
||||||
|
if _sniff_media(document) == "unknown":
|
||||||
|
# 服务从未被打扰: HTML 付费墙页面、乱码、空串在批处理语料里最常见。不带
|
||||||
|
# status_code(服务没响应),治理侧只归还探针、不计熔断成败
|
||||||
|
raise RequestRejectedError(
|
||||||
|
f"{source.name} OCR 无法识别文档格式(既非 PDF 也非已知图片),拒绝发送",
|
||||||
|
source_name=source.name,
|
||||||
|
operation="parse",
|
||||||
|
)
|
||||||
|
client = self._client_for(source)
|
||||||
|
download_url = await self._post_parse(client, document, source, self._rejected_markers)
|
||||||
|
package = await self._download_package(client, download_url, source)
|
||||||
|
# 纯函数、可能处理大对象: 放线程里,取消与 call_deadline_s 才能及时生效
|
||||||
|
elements, page_sizes, markdown = await asyncio.to_thread(parse_document_package, package)
|
||||||
|
return OcrDocumentTransportResult(
|
||||||
|
elements=elements,
|
||||||
|
page_sizes=page_sizes,
|
||||||
|
markdown=markdown,
|
||||||
|
raw={"success": True, "download_url": download_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _download_package(
|
||||||
|
self, client: httpx.AsyncClient, download_url: str, source: SourceConfig
|
||||||
|
) -> bytes:
|
||||||
|
"""流式下载结果包并限制体积。
|
||||||
|
|
||||||
|
**先处理错误状态,再检查体积**: 结果包的资源检查不得改变 HTTP 错误分类
|
||||||
|
(一个声明超限的 401 仍是 SourceDead,响应体摘要也要保留——issue #10)。
|
||||||
|
"""
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
with _translate_http_errors(source.name, "download_result"):
|
||||||
|
async with client.stream("GET", download_url) as resp:
|
||||||
|
if not resp.is_success:
|
||||||
|
# 条件是「非 2xx」而不是 `is_error`:1xx/3xx 同样需要保留摘要。错误体只读
|
||||||
|
# 有界的前缀并复制成已缓冲的响应(摘要口径只认已缓冲的响应体)
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
f"HTTP {resp.status_code}",
|
||||||
|
request=resp.request,
|
||||||
|
response=await _bounded_error_response(resp),
|
||||||
|
)
|
||||||
|
_check_declared_length(resp, source)
|
||||||
|
async for chunk in resp.aiter_bytes():
|
||||||
|
total += len(chunk)
|
||||||
|
if total > _MAX_PACKAGE_BYTES:
|
||||||
|
raise _package_too_large(source, total)
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
async def check_health(self, *, source: SourceConfig) -> bool:
|
async def check_health(self, *, source: SourceConfig) -> bool:
|
||||||
"""GET /health 探测: 2xx → True,失败 → False,取消穿透(R10)。"""
|
"""GET /health 探测: 2xx → True,失败 → False,取消穿透(R10)。"""
|
||||||
client = self._client_for(source)
|
client = self._client_for(source)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import json
|
|||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import replace
|
from dataclasses import dataclass, field, replace
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -38,11 +38,24 @@ from polygateway.thinking import (
|
|||||||
validate_thinking_raw,
|
validate_thinking_raw,
|
||||||
)
|
)
|
||||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||||
|
from polygateway.transports._openai_completion import (
|
||||||
|
ChatCompletionAccumulator,
|
||||||
|
ChatDelta,
|
||||||
|
build_completion,
|
||||||
|
check_completion_body,
|
||||||
|
)
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
Effort,
|
Effort,
|
||||||
EmbeddingTransportResult,
|
EmbeddingTransportResult,
|
||||||
|
ResponseMode,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
StreamEnd,
|
||||||
TransportResult,
|
TransportResult,
|
||||||
|
_classify_finish,
|
||||||
|
_judge_outcome,
|
||||||
|
_stream_is_complete,
|
||||||
|
_summarize_message,
|
||||||
|
_summarize_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -50,6 +63,10 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import AsyncIterator, Callable, Mapping
|
from collections.abc import AsyncIterator, Callable, Mapping
|
||||||
|
|
||||||
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
||||||
|
# 方言告警节流上限与 finish 值截断(设计 §5.4): 键由上游决定,必须有界
|
||||||
|
_WARN_CAP = 1024
|
||||||
|
_FINISH_WARN_CHARS = 64
|
||||||
|
_PATH_WARN_CHARS = 128
|
||||||
|
|
||||||
# —— SSE 纯函数(VT llm.py 同款)——
|
# —— SSE 纯函数(VT llm.py 同款)——
|
||||||
|
|
||||||
@@ -62,29 +79,6 @@ def _sse_data_payload(raw: str) -> str | None:
|
|||||||
return line[len("data:") :].strip()
|
return line[len("data:") :].strip()
|
||||||
|
|
||||||
|
|
||||||
def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool, str] | None:
|
|
||||||
"""从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。"""
|
|
||||||
if chunk.get("usage"):
|
|
||||||
usage_sink["usage"] = chunk["usage"]
|
|
||||||
if "model" not in usage_sink:
|
|
||||||
# 首个**有效**值即固定: 末帧的异常值不得覆盖它;但首帧报空串也不能锁死
|
|
||||||
# sink——否则后续真实版本会丢(issue #3)
|
|
||||||
reported = _coerce_model_reported(chunk.get("model"))
|
|
||||||
if reported is not None:
|
|
||||||
usage_sink["model"] = reported
|
|
||||||
choices = chunk.get("choices") or []
|
|
||||||
if not choices:
|
|
||||||
return None
|
|
||||||
delta = choices[0].get("delta") or {}
|
|
||||||
content = delta.get("content")
|
|
||||||
if content:
|
|
||||||
return (True, content)
|
|
||||||
reasoning = delta.get("reasoning_content")
|
|
||||||
if reasoning:
|
|
||||||
return (False, reasoning)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _stall_from_chunk_times(
|
def _stall_from_chunk_times(
|
||||||
times: list[float], threshold_ms: int
|
times: list[float], threshold_ms: int
|
||||||
) -> tuple[float | None, float | None]:
|
) -> tuple[float | None, float | None]:
|
||||||
@@ -108,10 +102,25 @@ def _stall_from_chunk_times(
|
|||||||
return (total, float(count)) if count else (0.0, 0.0)
|
return (total, float(count)) if count else (0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_chat_error(chunk: Any) -> None:
|
||||||
|
"""所有模式拒绝顶层 error;诊断复用有界摘要。"""
|
||||||
|
if not isinstance(chunk, dict):
|
||||||
|
raise TransientError("chat 响应必须为 JSON 对象", operation="chat")
|
||||||
|
if "error" in chunk:
|
||||||
|
summary = summarize_body(json.dumps({"error": chunk["error"]}, ensure_ascii=False))
|
||||||
|
raise TransientError(
|
||||||
|
compose_message("上游 chat error 帧", summary), operation="chat", body_text=summary
|
||||||
|
)
|
||||||
|
if chunk.get("usage") is not None and not isinstance(chunk["usage"], dict):
|
||||||
|
raise TransientError("chat usage 必须为对象或 null", operation="chat")
|
||||||
|
|
||||||
|
|
||||||
async def _iter_sse_deltas(
|
async def _iter_sse_deltas(
|
||||||
lines: AsyncIterator[str], usage_sink: dict[str, Any]
|
lines: AsyncIterator[str],
|
||||||
) -> AsyncIterator[tuple[bool, str]]:
|
usage_sink: dict[str, Any],
|
||||||
"""逐行解析 SSE 流;[DONE] 置 sink["done"];畸形 JSON 帧 → 瞬时错误(可重试)。"""
|
accumulator: ChatCompletionAccumulator,
|
||||||
|
) -> AsyncIterator[ChatDelta]:
|
||||||
|
"""解析 SSE,一帧只产生一次进展事件。"""
|
||||||
async for raw in lines:
|
async for raw in lines:
|
||||||
data = _sse_data_payload(raw)
|
data = _sse_data_payload(raw)
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -122,14 +131,138 @@ async def _iter_sse_deltas(
|
|||||||
try:
|
try:
|
||||||
chunk = json.loads(data)
|
chunk = json.loads(data)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise TransientError(
|
raise TransientError("SSE 帧畸形(malformed_json)", operation="chat") from exc
|
||||||
f"SSE 帧畸形(malformed_json): {data[:80]!r}", operation="chat"
|
_check_chat_error(chunk)
|
||||||
) from exc
|
if chunk.get("usage") is not None:
|
||||||
delta = _sse_delta(chunk, usage_sink)
|
usage_sink["usage"] = chunk["usage"]
|
||||||
if delta is not None:
|
if "model" not in usage_sink:
|
||||||
|
model = _coerce_model_reported(chunk.get("model"))
|
||||||
|
if model is not None:
|
||||||
|
usage_sink["model"] = model
|
||||||
|
delta = accumulator.add_chunk(chunk)
|
||||||
|
if delta.progress:
|
||||||
yield delta
|
yield delta
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StreamTiming:
|
||||||
|
"""一次流式尝试的计时与文本片段(每尝试新建,不跨重试/对冲共享)。"""
|
||||||
|
|
||||||
|
content_parts: list[str] = field(default_factory=list)
|
||||||
|
reasoning_parts: list[str] = field(default_factory=list)
|
||||||
|
alias_parts: list[str] = field(default_factory=list)
|
||||||
|
chunk_times: list[float] = field(default_factory=list)
|
||||||
|
ttft_ms: float | None = None
|
||||||
|
max_gap: float = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_reasoning(self) -> bool:
|
||||||
|
"""出现过非空 reasoning_content: 别名 `reasoning` 让位(设计 §4.4)。"""
|
||||||
|
return bool(self.reasoning_parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _consume_deltas(
|
||||||
|
guarded: AsyncIterator[ChatDelta],
|
||||||
|
started: float,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
) -> _StreamTiming:
|
||||||
|
"""消费进展事件: 计时、置位对冲事件、收集文本片段。"""
|
||||||
|
timing = _StreamTiming()
|
||||||
|
last = started
|
||||||
|
async for delta in guarded:
|
||||||
|
now = time.monotonic()
|
||||||
|
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
|
||||||
|
# 空 chunk/usage 帧/SSE keepalive 已在聚合器被过滤
|
||||||
|
timing.chunk_times.append(now)
|
||||||
|
if content_event is not None:
|
||||||
|
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
|
||||||
|
content_event.set()
|
||||||
|
if timing.ttft_ms is None:
|
||||||
|
timing.ttft_ms = (now - started) * 1000
|
||||||
|
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
|
||||||
|
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
|
||||||
|
if first_token_event is not None:
|
||||||
|
first_token_event.set()
|
||||||
|
else:
|
||||||
|
timing.max_gap = max(timing.max_gap, (now - last) * 1000)
|
||||||
|
last = now
|
||||||
|
for parts, text in (
|
||||||
|
(timing.content_parts, delta.content),
|
||||||
|
(timing.reasoning_parts, delta.reasoning_content),
|
||||||
|
(timing.alias_parts, delta.reasoning),
|
||||||
|
):
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
return timing
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_unless_deliverable(
|
||||||
|
mode: ResponseMode,
|
||||||
|
message: dict[str, Any] | None,
|
||||||
|
content: str,
|
||||||
|
thinking: str,
|
||||||
|
finish_reason: str | None,
|
||||||
|
source: SourceConfig,
|
||||||
|
) -> None:
|
||||||
|
"""结局判定(设计 §4.3)的唯一翻译点: 判定值 → 领域错误。
|
||||||
|
|
||||||
|
原因码以固定格式进 message(遥测 error 列可检索);不回显推理/工具内容。
|
||||||
|
"""
|
||||||
|
if mode == "openai" and message is not None:
|
||||||
|
summary = _summarize_message(message, content)
|
||||||
|
else:
|
||||||
|
summary = _summarize_text(content, thinking)
|
||||||
|
verdict = _judge_outcome(mode=mode, summary=summary, finish=_classify_finish(finish_reason))
|
||||||
|
if verdict.deliver:
|
||||||
|
return
|
||||||
|
text = (
|
||||||
|
f"{source.name} {verdict.code}: finish_reason={finish_reason!r}, "
|
||||||
|
f"thinking_len={len(thinking)}"
|
||||||
|
)
|
||||||
|
if verdict.error_kind == "result_invalid":
|
||||||
|
raise ResultInvalidError(text, raw_text="", source_name=source.name, operation="chat")
|
||||||
|
raise TransientError(text, source_name=source.name, operation="chat")
|
||||||
|
|
||||||
|
|
||||||
|
def _text_choice(body: dict[str, Any], source: SourceConfig) -> tuple[Any, str | None]:
|
||||||
|
"""文本模式取首个 choice 的 message 与 finish(非字符串 finish 记 None)。"""
|
||||||
|
choices = body.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
raise TransientError(
|
||||||
|
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
|
||||||
|
)
|
||||||
|
if not isinstance(choices, list) or not isinstance(choices[0], dict):
|
||||||
|
raise TransientError("chat choices 形态非法", operation="chat")
|
||||||
|
finish = choices[0].get("finish_reason")
|
||||||
|
return choices[0].get("message", {}), finish if isinstance(finish, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _nonstream_message(
|
||||||
|
body: dict[str, Any], mode: ResponseMode, source: SourceConfig
|
||||||
|
) -> tuple[dict[str, Any], str | None]:
|
||||||
|
"""非流式取单 choice 的原始 message 与 finish;只校验形态,不构造快照。"""
|
||||||
|
if mode == "openai":
|
||||||
|
message, finish = check_completion_body(body)
|
||||||
|
else:
|
||||||
|
message, finish = _text_choice(body, source)
|
||||||
|
if not isinstance(message, dict) or any(
|
||||||
|
message.get(key) is not None and not isinstance(message[key], str)
|
||||||
|
for key in ("content", "reasoning_content")
|
||||||
|
):
|
||||||
|
raise TransientError("chat message 形态非法", operation="chat")
|
||||||
|
return message, finish
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_of(message: dict[str, Any]) -> str:
|
||||||
|
"""非流式思考投影: 非空 reasoning_content 优先,否则 `reasoning` 别名。"""
|
||||||
|
primary = message.get("reasoning_content")
|
||||||
|
if isinstance(primary, str) and primary:
|
||||||
|
return primary
|
||||||
|
alias = message.get("reasoning")
|
||||||
|
return alias if isinstance(alias, str) else ""
|
||||||
|
|
||||||
|
|
||||||
# —— 错误翻译(CHS invokers.py 同款)——
|
# —— 错误翻译(CHS invokers.py 同款)——
|
||||||
|
|
||||||
|
|
||||||
@@ -387,6 +520,9 @@ class OpenAICompatTransport:
|
|||||||
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
|
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
|
||||||
# 分开维护的理由是语义,不是碰撞)
|
# 分开维护的理由是语义,不是碰撞)
|
||||||
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
|
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
|
||||||
|
# 方言告警(1.3.10)另立集合: 冲突删键/未知 finish 与推理对账语义无关
|
||||||
|
self._warned_fields: set[tuple[str, str]] = set()
|
||||||
|
self._warn_saturated = False
|
||||||
self._client_factory = client_factory or _default_client_factory
|
self._client_factory = client_factory or _default_client_factory
|
||||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||||
|
|
||||||
@@ -458,6 +594,7 @@ class OpenAICompatTransport:
|
|||||||
reasoning_effort: Effort | None,
|
reasoning_effort: Effort | None,
|
||||||
first_token_event: asyncio.Event | None,
|
first_token_event: asyncio.Event | None,
|
||||||
content_event: asyncio.Event | None,
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
|
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
|
||||||
|
|
||||||
@@ -468,6 +605,16 @@ class OpenAICompatTransport:
|
|||||||
`content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个**
|
`content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个**
|
||||||
增量 chunk 置位一次,非流式路径收它但永不置位。
|
增量 chunk 置位一次,非流式路径收它但永不置位。
|
||||||
"""
|
"""
|
||||||
|
if response_mode not in ("text", "openai"):
|
||||||
|
raise RequestRejectedError(
|
||||||
|
"非法 response_mode", source_name=source.name, operation="chat"
|
||||||
|
)
|
||||||
|
if response_mode == "openai":
|
||||||
|
for layer in (source.extra_body, overlay):
|
||||||
|
if "n" in layer and (type(layer["n"]) is not int or layer["n"] != 1):
|
||||||
|
raise RequestRejectedError(
|
||||||
|
"openai 模式仅支持 n=1", source_name=source.name, operation="chat"
|
||||||
|
)
|
||||||
profile = get_provider(source.provider, registry=self._registry)
|
profile = get_provider(source.provider, registry=self._registry)
|
||||||
try:
|
try:
|
||||||
payload, applied_effort = self._build_payload(
|
payload, applied_effort = self._build_payload(
|
||||||
@@ -492,12 +639,30 @@ class OpenAICompatTransport:
|
|||||||
try:
|
try:
|
||||||
if stream:
|
if stream:
|
||||||
result = await self._complete_stream(
|
result = await self._complete_stream(
|
||||||
client, url, payload, source, profile, first_token_event, content_event
|
client,
|
||||||
|
url,
|
||||||
|
payload,
|
||||||
|
source,
|
||||||
|
profile,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = await self._complete_once(
|
result = await self._complete_once(
|
||||||
client, url, payload, source, profile, first_token_event, content_event
|
client,
|
||||||
|
url,
|
||||||
|
payload,
|
||||||
|
source,
|
||||||
|
profile,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
)
|
)
|
||||||
|
except TransientError as exc:
|
||||||
|
if exc.source_name is None:
|
||||||
|
exc.source_name = source.name
|
||||||
|
raise
|
||||||
except StreamLivenessTimeout as exc:
|
except StreamLivenessTimeout as exc:
|
||||||
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
@@ -585,6 +750,7 @@ class OpenAICompatTransport:
|
|||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
first_token_event: asyncio.Event | None,
|
first_token_event: asyncio.Event | None,
|
||||||
content_event: asyncio.Event | None,
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
async with client.stream("POST", url, json=payload) as resp:
|
async with client.stream("POST", url, json=payload) as resp:
|
||||||
@@ -594,55 +760,59 @@ class OpenAICompatTransport:
|
|||||||
source, resp.status_code, body, resp.headers, operation="chat"
|
source, resp.status_code, body, resp.headers, operation="chat"
|
||||||
)
|
)
|
||||||
sink: dict[str, Any] = {}
|
sink: dict[str, Any] = {}
|
||||||
|
accumulator = ChatCompletionAccumulator(response_mode=response_mode)
|
||||||
guarded = stream_with_liveness_timeouts(
|
guarded = stream_with_liveness_timeouts(
|
||||||
_iter_sse_deltas(resp.aiter_lines(), sink),
|
_iter_sse_deltas(resp.aiter_lines(), sink, accumulator),
|
||||||
ttft_s=source.ttft_timeout_s or source.timeout_s,
|
ttft_s=source.ttft_timeout_s or source.timeout_s,
|
||||||
inter_token_s=source.inter_token_timeout_s or source.timeout_s,
|
inter_token_s=source.inter_token_timeout_s or source.timeout_s,
|
||||||
total_s=source.timeout_s,
|
total_s=source.timeout_s,
|
||||||
)
|
)
|
||||||
content_parts: list[str] = []
|
timing = await _consume_deltas(guarded, started, first_token_event, content_event)
|
||||||
thinking_parts: list[str] = []
|
return self._stream_result(sink, accumulator, timing, source, profile, response_mode)
|
||||||
ttft_ms: float | None = None
|
|
||||||
last = started
|
def _stream_result(
|
||||||
max_gap = 0.0
|
self,
|
||||||
chunk_times: list[float] = []
|
sink: dict[str, Any],
|
||||||
async for is_content, text in guarded:
|
accumulator: ChatCompletionAccumulator,
|
||||||
now = time.monotonic()
|
timing: _StreamTiming,
|
||||||
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
|
source: SourceConfig,
|
||||||
# 空 chunk/usage 帧/SSE keepalive 已在 _sse_data_payload/_sse_delta
|
profile: ProviderProfile,
|
||||||
# 被过滤,这里逐字追加即可;阈值判定不在热路径,派生收敛在
|
response_mode: ResponseMode,
|
||||||
# _stall_from_chunk_times(设计 §3 派生原则)
|
) -> TransportResult:
|
||||||
chunk_times.append(now)
|
"""流结束后的统一判定(设计 §4.2-§4.3): 完整性 → 结局 → 组装。"""
|
||||||
if content_event is not None:
|
self._warn_dialect(source, accumulator.conflicts, accumulator.finish_reason)
|
||||||
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
|
thinking_parts = timing.reasoning_parts if timing.has_reasoning else timing.alias_parts
|
||||||
content_event.set()
|
content, thinking = self._finalize_text(timing.content_parts, thinking_parts, profile)
|
||||||
if ttft_ms is None:
|
stream_end = _stream_is_complete(
|
||||||
ttft_ms = (now - started) * 1000
|
done=bool(sink.get("done")),
|
||||||
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
|
finish_reason=accumulator.finish_reason,
|
||||||
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
|
usage_after_finish=accumulator.usage_after_finish,
|
||||||
if first_token_event is not None:
|
usage_measured=_resolve_usage(sink.get("usage") or {})[2] == "measured",
|
||||||
first_token_event.set()
|
)
|
||||||
else:
|
if stream_end is None:
|
||||||
max_gap = max(max_gap, (now - last) * 1000)
|
stream_end = self._truncated(timing, thinking_parts, source, response_mode)
|
||||||
last = now
|
message = accumulator.raw_message() if response_mode == "openai" else None
|
||||||
(content_parts if is_content else thinking_parts).append(text)
|
_raise_unless_deliverable(
|
||||||
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
|
response_mode, message, content, thinking, accumulator.finish_reason, source
|
||||||
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
)
|
||||||
self._reject_empty_completion(content, source)
|
completion_result = accumulator.finish() if response_mode == "openai" else None
|
||||||
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
prompt, completion, usage_source = _resolve_stream_usage(sink, stream_end == "salvaged")
|
||||||
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
||||||
stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms)
|
stall_ms, stall_count = _stall_from_chunk_times(
|
||||||
|
timing.chunk_times, source.stall_threshold_ms
|
||||||
|
)
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
prompt_tokens=prompt,
|
prompt_tokens=prompt,
|
||||||
completion_tokens=completion,
|
completion_tokens=completion,
|
||||||
usage_source=usage_source,
|
usage_source=usage_source,
|
||||||
ttft_ms=ttft_ms,
|
ttft_ms=timing.ttft_ms,
|
||||||
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
|
max_inter_token_ms=(timing.max_gap if timing.ttft_ms is not None else None),
|
||||||
stall_ms=stall_ms,
|
stall_ms=stall_ms,
|
||||||
stall_count=stall_count,
|
stall_count=stall_count,
|
||||||
raw={"usage": sink.get("usage")},
|
raw={"usage": sink.get("usage")},
|
||||||
|
completion=completion_result,
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||||
model_reported=_coerce_model_reported(sink.get("model")),
|
model_reported=_coerce_model_reported(sink.get("model")),
|
||||||
reasoning_tokens=reasoning_tokens,
|
reasoning_tokens=reasoning_tokens,
|
||||||
@@ -651,38 +821,56 @@ class OpenAICompatTransport:
|
|||||||
thinking_observation=observe_thinking(
|
thinking_observation=observe_thinking(
|
||||||
thinking=thinking, reasoning_tokens=reasoning_tokens
|
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||||
),
|
),
|
||||||
|
finish_reason=accumulator.finish_reason,
|
||||||
|
stream_end=stream_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_done(
|
def _truncated(
|
||||||
self,
|
self,
|
||||||
sink: dict[str, Any],
|
timing: _StreamTiming,
|
||||||
content_parts: list[str],
|
|
||||||
thinking_parts: list[str],
|
thinking_parts: list[str],
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
) -> bool:
|
response_mode: ResponseMode,
|
||||||
"""缺 [DONE] 语义(设计 §6): 零内容恒 retry;有内容按 missing_done 策略。"""
|
) -> StreamEnd:
|
||||||
if sink.get("done"):
|
"""截断流(设计 §4.2): 保真不打捞;文本按 missing_done,零产物恒 retry。"""
|
||||||
return False
|
|
||||||
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
||||||
if not content_parts and not thinking_parts:
|
if response_mode == "openai":
|
||||||
|
raise TransientError(f"{source.name} missing_done: 保真响应截断,禁止打捞", **ctx)
|
||||||
|
if not any(timing.content_parts) and not any(thinking_parts):
|
||||||
raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx)
|
raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx)
|
||||||
if source.missing_done == "retry":
|
if source.missing_done == "retry":
|
||||||
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
|
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
|
||||||
return True
|
return "salvaged"
|
||||||
|
|
||||||
def _reject_empty_completion(self, content: str, source: SourceConfig) -> None:
|
def _warn_dialect(
|
||||||
"""空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。
|
self, source: SourceConfig, conflicts: frozenset[str], finish_reason: str | None
|
||||||
|
) -> None:
|
||||||
服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等
|
"""方言告警: 冲突删键与未登记 finish 各按 (源, 路径/值) 只喊一次。"""
|
||||||
网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**;
|
for raw_path in sorted(conflicts):
|
||||||
承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。
|
path = raw_path[:_PATH_WARN_CHARS] # 键名由上游决定,长度同样须有界
|
||||||
"""
|
self._warn_once(
|
||||||
if not content.strip():
|
source.name, f"field:{path}", f"字段 {path} 合并冲突已删除,保真结果缺该字段"
|
||||||
raise TransientError(
|
|
||||||
f"{source.name} 空补全(empty_completion): 流程完整但零内容",
|
|
||||||
source_name=source.name,
|
|
||||||
operation="chat",
|
|
||||||
)
|
)
|
||||||
|
if finish_reason is not None and _classify_finish(finish_reason) == "unknown":
|
||||||
|
value = finish_reason[:_FINISH_WARN_CHARS]
|
||||||
|
self._warn_once(
|
||||||
|
source.name,
|
||||||
|
f"finish:{value}",
|
||||||
|
f"未登记的 finish_reason {value!r},按正常结束交付,该值的结果不缓存",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _warn_once(self, source_name: str, key: str, text: str) -> None:
|
||||||
|
"""有界节流: 键由上游决定,长跑 worker 不能让集合无限增长(设计 §5.4)。"""
|
||||||
|
if (source_name, key) in self._warned_fields:
|
||||||
|
return
|
||||||
|
if len(self._warned_fields) >= _WARN_CAP:
|
||||||
|
if not self._warn_saturated:
|
||||||
|
self._warn_saturated = True
|
||||||
|
logger.warning("方言告警节流集合已饱和({}),后续同类告警省略", _WARN_CAP)
|
||||||
|
return
|
||||||
|
self._warned_fields.add((source_name, key))
|
||||||
|
# 单参数传入 loguru: 文案可能含上游给的花括号(同 `_warn_on_thinking_mismatch`)
|
||||||
|
logger.warning("源 {} —— {}", source_name, text)
|
||||||
|
|
||||||
def _finalize_text(
|
def _finalize_text(
|
||||||
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
|
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
|
||||||
@@ -704,6 +892,7 @@ class OpenAICompatTransport:
|
|||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
first_token_event: asyncio.Event | None,
|
first_token_event: asyncio.Event | None,
|
||||||
content_event: asyncio.Event | None,
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。
|
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。
|
||||||
|
|
||||||
@@ -711,6 +900,9 @@ class OpenAICompatTransport:
|
|||||||
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
|
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
|
||||||
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
|
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
|
||||||
TransportResult 默认 None(排除条款)。
|
TransportResult 默认 None(排除条款)。
|
||||||
|
|
||||||
|
无流完整性可言,直接以 body 的 finish_reason 走结局判定;保真模式**先判定
|
||||||
|
后构造快照**,否则半截工具在构造时就抛 Transient,到不了 `tool_truncated`。
|
||||||
"""
|
"""
|
||||||
resp = await client.post(url, json=payload)
|
resp = await client.post(url, json=payload)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
@@ -723,16 +915,17 @@ class OpenAICompatTransport:
|
|||||||
raise TransientError(
|
raise TransientError(
|
||||||
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
|
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
|
||||||
) from exc
|
) from exc
|
||||||
choices = body.get("choices") or []
|
_check_chat_error(body)
|
||||||
if not choices:
|
message, finish = _nonstream_message(body, response_mode, source)
|
||||||
raise TransientError(
|
|
||||||
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
|
|
||||||
)
|
|
||||||
message = choices[0].get("message") or {}
|
|
||||||
content, thinking = self._finalize_text(
|
content, thinking = self._finalize_text(
|
||||||
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
|
[message.get("content") or ""], [_reasoning_of(message)], profile
|
||||||
|
)
|
||||||
|
self._warn_dialect(source, frozenset(), finish)
|
||||||
|
raw_message = message if response_mode == "openai" else None
|
||||||
|
_raise_unless_deliverable(response_mode, raw_message, content, thinking, finish, source)
|
||||||
|
completion_result = (
|
||||||
|
build_completion(message, body, finish) if response_mode == "openai" else None
|
||||||
)
|
)
|
||||||
self._reject_empty_completion(content, source)
|
|
||||||
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
||||||
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
@@ -744,6 +937,7 @@ class OpenAICompatTransport:
|
|||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
raw={"usage": body.get("usage")},
|
raw={"usage": body.get("usage")},
|
||||||
|
completion=completion_result,
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||||
model_reported=_coerce_model_reported(body.get("model")),
|
model_reported=_coerce_model_reported(body.get("model")),
|
||||||
reasoning_tokens=reasoning_tokens,
|
reasoning_tokens=reasoning_tokens,
|
||||||
@@ -752,6 +946,7 @@ class OpenAICompatTransport:
|
|||||||
thinking_observation=observe_thinking(
|
thinking_observation=observe_thinking(
|
||||||
thinking=thinking, reasoning_tokens=reasoning_tokens
|
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||||
),
|
),
|
||||||
|
finish_reason=finish,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
+359
-3
@@ -4,6 +4,7 @@
|
|||||||
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@@ -275,13 +276,19 @@ class ThinkingObservation(StrEnum):
|
|||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"]
|
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout", "parse_document"]
|
||||||
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。
|
"""遥测 `operation` 列的值域: **公开方法**五值,由调用点给定。
|
||||||
|
|
||||||
与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**,
|
与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**,
|
||||||
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。"""
|
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。"""
|
||||||
|
|
||||||
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout")
|
CALL_OPERATIONS: tuple[CallOperation, ...] = (
|
||||||
|
"chat",
|
||||||
|
"embed",
|
||||||
|
"recognize_text",
|
||||||
|
"parse_layout",
|
||||||
|
"parse_document",
|
||||||
|
)
|
||||||
|
|
||||||
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
|
EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
|
||||||
"""一行遥测描述的事件形态;旧行 NULL,不回填。
|
"""一行遥测描述的事件形态;旧行 NULL,不回填。
|
||||||
@@ -422,6 +429,280 @@ class _CallContext:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
ResponseMode = Literal["text", "openai"]
|
||||||
|
|
||||||
|
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
|
||||||
|
"""流式结束方式(1.3.10, #31): `done` = 收到 `[DONE]`;`eof_after_finish` = 未发
|
||||||
|
`[DONE]` 但 finish_reason 与其后的实测 usage 齐全、连接干净关闭;`salvaged` = 文本
|
||||||
|
模式按 `missing_done=salvage` 打捞的截断结果。非流式/缓存命中/失败行为 `None`。"""
|
||||||
|
|
||||||
|
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenAIChatCompletion:
|
||||||
|
"""单 choice 协议结果,构造时快照隔离;嵌套 JSON 仍可变。"""
|
||||||
|
|
||||||
|
message: dict[str, Any]
|
||||||
|
finish_reason: str | None
|
||||||
|
response_id: str | None
|
||||||
|
usage: dict[str, Any] | None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""校验协议快照;transport 翻译错误,缓存坏条目按 miss。"""
|
||||||
|
message = self.message
|
||||||
|
_check_completion_shape(message, self.finish_reason, self.response_id, self.usage)
|
||||||
|
json.dumps({"message": message, "usage": self.usage}, allow_nan=False)
|
||||||
|
object.__setattr__(self, "message", copy.deepcopy(message))
|
||||||
|
object.__setattr__(self, "usage", copy.deepcopy(self.usage))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_completion_shape(message: Any, finish_reason: Any, response_id: Any, usage: Any) -> None:
|
||||||
|
"""快照字段形态校验;违约抛 ValueError(由 transport/缓存各自翻译)。"""
|
||||||
|
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||||||
|
raise ValueError("completion message 必须含 assistant role")
|
||||||
|
if not all(_str_or_none(message.get(key)) for key in ("content", "reasoning_content")):
|
||||||
|
raise ValueError("completion content/reasoning_content 必须为字符串或 null")
|
||||||
|
if not (_str_or_none(finish_reason) and _str_or_none(response_id)):
|
||||||
|
raise ValueError("completion 身份/结束原因必须为字符串或 null")
|
||||||
|
if not isinstance(usage, dict | None):
|
||||||
|
raise ValueError("completion usage 必须为对象或 null")
|
||||||
|
if not isinstance(message.get("tool_calls", []), list | None):
|
||||||
|
raise ValueError("completion tool_calls 必须为数组或 null")
|
||||||
|
if not _tool_snapshot_ok(message):
|
||||||
|
raise ValueError("completion tool 身份/函数字段缺失、冲突或非法")
|
||||||
|
|
||||||
|
|
||||||
|
def _str_or_none(value: Any) -> bool:
|
||||||
|
"""协议字符串字段的形态判据: 字符串或 null。"""
|
||||||
|
return value is None or isinstance(value, str)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_ok(tool: Any, seen: set[str]) -> bool:
|
||||||
|
"""单个工具条目是否满足快照不变式;成立时登记其 id 供查重。"""
|
||||||
|
if not isinstance(tool, dict):
|
||||||
|
return False
|
||||||
|
identity, fn = tool.get("id"), tool.get("function")
|
||||||
|
ok = (
|
||||||
|
isinstance(identity, str)
|
||||||
|
and bool(identity)
|
||||||
|
and identity not in seen
|
||||||
|
and tool.get("type") == "function"
|
||||||
|
and isinstance(fn, dict)
|
||||||
|
and isinstance(fn.get("name"), str)
|
||||||
|
and bool(fn["name"])
|
||||||
|
and isinstance(fn.get("arguments"), str)
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
seen.add(identity)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool:
|
||||||
|
"""工具快照不变式(1.3.9 规则): 缺省/null 视无工具;null 元素跳过。
|
||||||
|
|
||||||
|
抽成纯判据而非留在快照构造里: 结局判定(#30)要在构造**之前**知道工具是否
|
||||||
|
成立——截断的半截工具归 ResultInvalid,构造时抛 Transient 就到不了那一行。
|
||||||
|
"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
if tools is None:
|
||||||
|
return True
|
||||||
|
if not isinstance(tools, list):
|
||||||
|
return False
|
||||||
|
seen: set[str] = set()
|
||||||
|
return all(tool is None or _tool_ok(tool, seen) for tool in tools)
|
||||||
|
|
||||||
|
|
||||||
|
_FINISH_CLASSES: Mapping[str, FinishClass] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"stop": "natural",
|
||||||
|
"tool_calls": "natural",
|
||||||
|
"function_call": "natural",
|
||||||
|
"length": "truncated",
|
||||||
|
"content_filter": "filtered",
|
||||||
|
"sensitive": "filtered", # GLM: 安全审核拦截
|
||||||
|
"network_error": "upstream_error", # GLM: 模型推理异常
|
||||||
|
"error": "upstream_error",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_finish(value: str | None) -> FinishClass:
|
||||||
|
"""finish_reason → 类别;大小写敏感(上游原值),未登记非空值为 unknown。"""
|
||||||
|
if value is None:
|
||||||
|
return "absent"
|
||||||
|
return _FINISH_CLASSES.get(value, "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_is_complete(
|
||||||
|
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
|
||||||
|
) -> StreamEnd | None:
|
||||||
|
"""流完整性(设计 §4.2): None = 截断。
|
||||||
|
|
||||||
|
缺 `[DONE]` 时只认"上游声明结束 + 其后到达的实测 usage": 前者证明生成结束,
|
||||||
|
后者证明流走到了 include_usage 的自然末尾(finish 之前的累计 usage 不算)。
|
||||||
|
"""
|
||||||
|
if done:
|
||||||
|
return "done"
|
||||||
|
if finish_reason is not None and usage_after_finish and usage_measured:
|
||||||
|
return "eof_after_finish"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _OutputSummary:
|
||||||
|
"""一次结果的产物摘要(设计 §4.3 定义),判定的唯一输入形态。"""
|
||||||
|
|
||||||
|
any_output: bool
|
||||||
|
body_output: bool
|
||||||
|
tools_present: bool
|
||||||
|
tools_ok: bool
|
||||||
|
null_arguments: bool = False
|
||||||
|
"""工具快照仅因 `function.arguments` 全程 null 而不成立(无参函数方言,审查 I2)。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_text(content: str, thinking: str) -> _OutputSummary:
|
||||||
|
"""文本模式: 产物只看投影正文与思考;空白也算产物,正文须非空白。"""
|
||||||
|
return _OutputSummary(
|
||||||
|
any_output=content != "" or thinking != "",
|
||||||
|
body_output=content.strip() != "",
|
||||||
|
tools_present=False,
|
||||||
|
tools_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_string(value: Any) -> bool:
|
||||||
|
"""递归查找任一非空字符串片段。"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value != ""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(_has_string(item) for item in value.values())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(_has_string(item) for item in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary:
|
||||||
|
"""保真模式: 任一非空字符串字段(role 除外)或工具条目即产物。"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
tools_present = isinstance(tools, list) and any(tool is not None for tool in tools)
|
||||||
|
tools_ok = _tool_snapshot_ok(message)
|
||||||
|
fields = {k: v for k, v in message.items() if k not in ("role", "tool_calls")}
|
||||||
|
return _OutputSummary(
|
||||||
|
any_output=tools_present or _has_string(fields),
|
||||||
|
body_output=projected_content.strip() != "" or (tools_present and tools_ok),
|
||||||
|
tools_present=tools_present,
|
||||||
|
tools_ok=tools_ok,
|
||||||
|
null_arguments=not tools_ok and _tool_snapshot_ok(_fill_null_arguments(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_null_arguments(message: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""仅供判定: 把 null arguments 视作空串后再验快照,区分"只缺参数"与其他缺陷。
|
||||||
|
|
||||||
|
不改写交付值——保真结果原样;此函数的产物只用于分类。
|
||||||
|
"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
if not isinstance(tools, list):
|
||||||
|
return dict(message)
|
||||||
|
filled = []
|
||||||
|
for tool in tools:
|
||||||
|
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||||
|
if isinstance(fn, dict) and "arguments" in fn and fn["arguments"] is None:
|
||||||
|
tool = {**tool, "function": {**fn, "arguments": ""}}
|
||||||
|
filled.append(tool)
|
||||||
|
return {**message, "tool_calls": filled}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Verdict:
|
||||||
|
"""结局判定;`deliver` 为真时另两字段为 None。"""
|
||||||
|
|
||||||
|
deliver: bool
|
||||||
|
error_kind: Literal["transient", "result_invalid"] | None
|
||||||
|
code: str | None
|
||||||
|
|
||||||
|
|
||||||
|
_DELIVER = _Verdict(True, None, None)
|
||||||
|
_EMPTY = _Verdict(False, "transient", "empty_completion")
|
||||||
|
_WITHOUT_CONTENT: Mapping[str, str] = MappingProxyType(
|
||||||
|
{"truncated": "length_without_content", "filtered": "filtered_without_content"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _judge_broken(
|
||||||
|
*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass
|
||||||
|
) -> _Verdict | None:
|
||||||
|
"""行 1-2: 上游自报失败、坏工具——先于产物判断,正文不得掩盖。"""
|
||||||
|
if finish == "upstream_error":
|
||||||
|
return _Verdict(False, "transient", "upstream_finish_error")
|
||||||
|
if mode == "openai" and summary.tools_present and not summary.tools_ok:
|
||||||
|
if finish == "truncated":
|
||||||
|
return _Verdict(False, "result_invalid", "tool_truncated")
|
||||||
|
if summary.null_arguments:
|
||||||
|
# 无参函数只发 arguments:null 是确定性方言: 重拉同样结果,不重试也不改写
|
||||||
|
return _Verdict(False, "result_invalid", "tool_arguments_null")
|
||||||
|
return _Verdict(False, "transient", "tool_incomplete")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict:
|
||||||
|
"""结局分类(设计 §4.3),自上而下首个匹配行生效。
|
||||||
|
|
||||||
|
确定性结局(length/过滤)在文本模式归 ResultInvalid 而非 Transient: 重抽只会
|
||||||
|
再花一份钱,还会把健康源推向熔断;保真模式原样交回,由下游按 finish 决定。
|
||||||
|
"""
|
||||||
|
broken = _judge_broken(mode=mode, summary=summary, finish=finish)
|
||||||
|
if broken is not None:
|
||||||
|
return broken
|
||||||
|
if summary.body_output:
|
||||||
|
return _DELIVER
|
||||||
|
if finish in _WITHOUT_CONTENT: # 行 3/6/7: 确定性结局
|
||||||
|
if mode == "openai":
|
||||||
|
return _DELIVER
|
||||||
|
return _Verdict(False, "result_invalid", _WITHOUT_CONTENT[finish])
|
||||||
|
# 行 4/8/9: 零产物恒为抖动;有产物无正文时,只有保真且上游明确声明结束才交回
|
||||||
|
if summary.any_output and mode == "openai" and finish != "absent":
|
||||||
|
return _DELIVER
|
||||||
|
return _EMPTY
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None:
|
||||||
|
"""缓存读写共用的 finish 口径: 顶层优先,缺省回退协议快照(1.3.9 条目无顶层)。"""
|
||||||
|
if finish_reason is None and completion is not None:
|
||||||
|
return completion.finish_reason
|
||||||
|
return finish_reason
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cacheable(
|
||||||
|
*,
|
||||||
|
mode: ResponseMode,
|
||||||
|
content: str,
|
||||||
|
finish_reason: str | None,
|
||||||
|
stream_end: str | None,
|
||||||
|
completion: OpenAIChatCompletion | None,
|
||||||
|
) -> bool:
|
||||||
|
"""可缓存 ⊂ 可交付(设计 §6): 截断、空正文、非自然结束一律不进 TTL。
|
||||||
|
|
||||||
|
缓存回放的是"同一请求的确定答案";length/过滤/未知结束带随机性或不可证明
|
||||||
|
是自然结束,固化进 TTL 会把一次偶然变成必然(无缓存毒化铁律)。
|
||||||
|
"""
|
||||||
|
if stream_end not in (None, "done", "eof_after_finish"):
|
||||||
|
return False
|
||||||
|
if _classify_finish(finish_reason) not in ("natural", "absent"):
|
||||||
|
return False
|
||||||
|
if mode == "openai":
|
||||||
|
# 判原文而非投影: 协议原文为空时不信任缓存里的旧投影(1.3.9 同口径)
|
||||||
|
if completion is None:
|
||||||
|
return False
|
||||||
|
raw = completion.message.get("content")
|
||||||
|
summary = _summarize_message(completion.message, raw if isinstance(raw, str) else "")
|
||||||
|
# 原文与投影都须有正文: 原文空不信任旧投影;投影空(think 标签剥净)不固化空回答
|
||||||
|
has_tools = summary.tools_present and summary.tools_ok
|
||||||
|
return summary.body_output and (content.strip() != "" or has_tools)
|
||||||
|
return content.strip() != ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LLMResponse:
|
class LLMResponse:
|
||||||
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
||||||
@@ -487,6 +768,15 @@ class LLMResponse:
|
|||||||
call_stats: CallStats | None = None
|
call_stats: CallStats | None = None
|
||||||
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
|
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
|
||||||
|
|
||||||
|
completion: OpenAIChatCompletion | None = None
|
||||||
|
|
||||||
|
finish_reason: str | None = None
|
||||||
|
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
|
||||||
|
|
||||||
|
stream_end: StreamEnd | None = None
|
||||||
|
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
|
||||||
|
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ChatRequest:
|
class ChatRequest:
|
||||||
@@ -543,6 +833,8 @@ class ChatRequest:
|
|||||||
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
|
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
|
||||||
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
|
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
|
||||||
|
|
||||||
|
response_mode: ResponseMode = "text"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Usage:
|
class Usage:
|
||||||
@@ -627,6 +919,15 @@ class TransportResult:
|
|||||||
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则);值域与 `None` 语义同
|
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则);值域与 `None` 语义同
|
||||||
`stall_ms`。"""
|
`stall_ms`。"""
|
||||||
|
|
||||||
|
completion: OpenAIChatCompletion | None = None
|
||||||
|
|
||||||
|
finish_reason: str | None = None
|
||||||
|
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
|
||||||
|
|
||||||
|
stream_end: StreamEnd | None = None
|
||||||
|
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
|
||||||
|
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SourceConfig:
|
class SourceConfig:
|
||||||
@@ -894,6 +1195,10 @@ class OcrLayoutElement:
|
|||||||
|
|
||||||
type 为开放字符串(实测 table/image/text,不枚举锁死——零业务假设);
|
type 为开放字符串(实测 table/image/text,不枚举锁死——零业务假设);
|
||||||
bbox 为 OCR 原生页面坐标 (x1, y1, x2, y2),几何映射留业务侧(D9)。
|
bbox 为 OCR 原生页面坐标 (x1, y1, x2, y2),几何映射留业务侧(D9)。
|
||||||
|
|
||||||
|
注意: `OcrDocumentElement` 继承本类并追加**无默认值**的 `text`。dataclass 要求
|
||||||
|
无默认字段不得排在带默认字段之后,故日后若给本类追加带默认值的字段,必须先评估
|
||||||
|
子类——否则 `OcrDocumentElement` 会在导入期抛 `TypeError`(#34 设计 §4.1)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
@@ -901,6 +1206,22 @@ class OcrLayoutElement:
|
|||||||
page_index: int
|
page_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OcrDocumentElement(OcrLayoutElement):
|
||||||
|
"""文档解析的逐块结果(#34 设计 §4.1): 版面元素 + 该块的文字内容。
|
||||||
|
|
||||||
|
`text` 的含义随 `type`,由 `parse_document` 的解析规则固定:
|
||||||
|
`text`/`title` 为纯文本(行内公式保留 `$…$`);`interline_equation` 为裸 LaTeX
|
||||||
|
(无 `$$` 定界);`table` 为 HTML 表体;`image` 为空串。服务将来若给出带 caption
|
||||||
|
/footnote 的嵌套子块,它们作为独立元素紧随父块输出,`type` 取子块类型。
|
||||||
|
|
||||||
|
继承而非组合: 文档元素"是"版面元素加内容,按 bbox 裁剪的下游函数可同时处理两者。
|
||||||
|
代价是 dataclass 的 `__eq__` 要求同类,`OcrDocumentElement != OcrLayoutElement`。
|
||||||
|
"""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OcrTextResult:
|
class OcrTextResult:
|
||||||
"""一次治理 OCR 文本转录的统一响应(/ocr/text;M3 设计 §3.1)。
|
"""一次治理 OCR 文本转录的统一响应(/ocr/text;M3 设计 §3.1)。
|
||||||
@@ -937,6 +1258,31 @@ class OcrLayoutResult:
|
|||||||
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
|
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OcrDocumentResult:
|
||||||
|
"""一次治理整份文档解析的统一响应(/parse → ZIP;#34 设计 §4.1)。
|
||||||
|
|
||||||
|
**独立定义,不继承 `OcrLayoutResult`**: 父类末尾的 `call_stats` 带默认值,
|
||||||
|
子类追加无默认字段 `markdown` 会在导入期 `TypeError`。
|
||||||
|
|
||||||
|
elements 按服务阅读序排列,页内按页序;page_sizes 按 page_index 索引(零基)。
|
||||||
|
`markdown` 是服务生成的整篇 Markdown 原文,**含悬空的 ``
|
||||||
|
图片引用**——库不外传图片资源。服务端会丢弃脚注、页眉页脚与页码,这些内容
|
||||||
|
既不在 elements 也不在 markdown 里,库无从补偿。
|
||||||
|
"""
|
||||||
|
|
||||||
|
elements: list[OcrDocumentElement]
|
||||||
|
page_sizes: list[tuple[float, float]]
|
||||||
|
markdown: str
|
||||||
|
source_name: str
|
||||||
|
usage: Usage
|
||||||
|
latency_ms: int
|
||||||
|
call_id: str
|
||||||
|
raw: dict[str, Any]
|
||||||
|
call_stats: CallStats | None = None
|
||||||
|
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OcrTextTransportResult:
|
class OcrTextTransportResult:
|
||||||
"""transport 单次 /ocr/text 调用产物;治理字段由 OcrClient 补齐。"""
|
"""transport 单次 /ocr/text 调用产物;治理字段由 OcrClient 补齐。"""
|
||||||
@@ -954,6 +1300,16 @@ class OcrLayoutTransportResult:
|
|||||||
raw: dict[str, Any]
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OcrDocumentTransportResult:
|
||||||
|
"""transport 单次整份文档两段调用的产物;治理字段由 OcrClient 补齐。"""
|
||||||
|
|
||||||
|
elements: list[OcrDocumentElement]
|
||||||
|
page_sizes: list[tuple[float, float]]
|
||||||
|
markdown: str
|
||||||
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EmbeddingTransportResult:
|
class EmbeddingTransportResult:
|
||||||
"""一次原始 embedding 调用的解析结果(M2 设计 §7.2;transport → client)。"""
|
"""一次原始 embedding 调用的解析结果(M2 设计 §7.2;transport → client)。"""
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ from polygateway.client import (
|
|||||||
)
|
)
|
||||||
from polygateway.providers import get_provider
|
from polygateway.providers import get_provider
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult
|
from polygateway.types import (
|
||||||
|
Effort,
|
||||||
|
EmbeddingTransportResult,
|
||||||
|
ResponseMode,
|
||||||
|
SourceConfig,
|
||||||
|
TransportResult,
|
||||||
|
)
|
||||||
from tests.live_evidence import (
|
from tests.live_evidence import (
|
||||||
AttemptEvidence,
|
AttemptEvidence,
|
||||||
HttpEvidence,
|
HttpEvidence,
|
||||||
@@ -293,6 +299,7 @@ class ObservedTransport:
|
|||||||
reasoning_effort: Effort | None,
|
reasoning_effort: Effort | None,
|
||||||
first_token_event: asyncio.Event | None,
|
first_token_event: asyncio.Event | None,
|
||||||
content_event: asyncio.Event | None,
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""与生产端口逐参数同签名。"""
|
"""与生产端口逐参数同签名。"""
|
||||||
with self._capture.attempt_context(call_id):
|
with self._capture.attempt_context(call_id):
|
||||||
@@ -306,6 +313,7 @@ class ObservedTransport:
|
|||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
first_token_event=first_token_event,
|
first_token_event=first_token_event,
|
||||||
content_event=content_event,
|
content_event=content_event,
|
||||||
|
response_mode=response_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def embed(
|
async def embed(
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""真实网关工具双轮冒烟;复用安全 Markdown 报告,不运行工具副作用。"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
from polygateway import GatewayClient, GatewaySettings
|
||||||
|
from tests.live_evidence import write_live_round
|
||||||
|
|
||||||
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
|
pytestmark = [pytest.mark.slow, pytest.mark.requires_llm]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stream", [True, False])
|
||||||
|
async def test_tools_two_rounds(stream):
|
||||||
|
"""已配置源中选首源,只改变测试隔离与 raw-only 意图,不收紧生产超时。"""
|
||||||
|
if not any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV):
|
||||||
|
pytest.skip("未配置真实源,未覆盖")
|
||||||
|
settings = GatewaySettings.from_env("LLM", env=_ENV)
|
||||||
|
source = dataclasses.replace(settings.sources[0], enable_thinking=None, reasoning_effort=None)
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
settings,
|
||||||
|
sources=(source,),
|
||||||
|
cache_backend="none",
|
||||||
|
cache_namespace=None,
|
||||||
|
cache_ttl_s=None,
|
||||||
|
telemetry_backend="none",
|
||||||
|
telemetry_auto_migrate=False,
|
||||||
|
limiter_backend="memory",
|
||||||
|
breaker_backend="memory",
|
||||||
|
hedge_after_s=None,
|
||||||
|
hedge_stall_after_s=None,
|
||||||
|
)
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "echo_value",
|
||||||
|
"description": "Return the supplied value",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
"required": ["value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Call echo_value with value hello. After receiving the tool result, reply with hello.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
status, error_type, counts = "FAIL", None, {}
|
||||||
|
try:
|
||||||
|
async with GatewayClient.from_settings(settings) as client:
|
||||||
|
first = await client.chat(
|
||||||
|
messages,
|
||||||
|
stream=stream,
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={
|
||||||
|
"tools": tools,
|
||||||
|
"tool_choice": {"type": "function", "function": {"name": "echo_value"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
completion = first.completion
|
||||||
|
assert completion is not None and completion.message["role"] == "assistant"
|
||||||
|
assert completion.response_id and completion.finish_reason == "tool_calls"
|
||||||
|
calls = completion.message["tool_calls"]
|
||||||
|
counts["tools"] = len(calls)
|
||||||
|
assert calls
|
||||||
|
messages.append(completion.message)
|
||||||
|
for call in calls:
|
||||||
|
assert call["function"]["name"] == "echo_value"
|
||||||
|
arguments = json.loads(call["function"]["arguments"])
|
||||||
|
assert arguments["value"] == "hello"
|
||||||
|
messages.append(
|
||||||
|
{"role": "tool", "tool_call_id": call["id"], "content": arguments["value"]}
|
||||||
|
)
|
||||||
|
second = await client.chat(
|
||||||
|
messages,
|
||||||
|
stream=stream,
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={"tools": tools, "tool_choice": "none"},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
second.completion is not None and second.completion.message["role"] == "assistant"
|
||||||
|
)
|
||||||
|
assert "hello" in (second.completion.message.get("content") or "").lower()
|
||||||
|
counts["attempts"] = first.call_stats.attempts + second.call_stats.attempts
|
||||||
|
status = "PASS"
|
||||||
|
except Exception as exc:
|
||||||
|
error_type = type(exc).__name__
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
write_live_round(
|
||||||
|
Path("tests/outputs/issue29/live"),
|
||||||
|
run_id=uuid4().hex,
|
||||||
|
matrix_id="tools",
|
||||||
|
round_index=1,
|
||||||
|
safe_fields={
|
||||||
|
"status": status,
|
||||||
|
"stream": stream,
|
||||||
|
"error_type": error_type,
|
||||||
|
"counts": counts,
|
||||||
|
"planned_rounds": 2,
|
||||||
|
"completed_rounds": 2 if status == "PASS" else 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""MonkeyOCR 整份文档活体验收(#34 计划 T8)。
|
||||||
|
|
||||||
|
**成败取决于外部服务当下状态**,按 CLAUDE.md §4.6 一律标 `slow`: 默认套件不收集,发布清单
|
||||||
|
第 4 步按 diff 交集选跑(`pytest tests/integration/test_monkey_ocr_document_live.py -m slow`)。
|
||||||
|
服务与其它业务线共用——所以每条用例只发**单份极小文档**、串行、不压测。
|
||||||
|
|
||||||
|
文档字节由本文件手写构造(最小合法 PDF,无第三方内容、无新依赖)。
|
||||||
|
不依赖 `data/soak` 图片语料,故**不**并入带语料 `skipif` 的 `test_monkey_ocr_live.py`。
|
||||||
|
服务不可达即 FAIL——验收必打真实,不 skip。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.errors import RequestRejectedError
|
||||||
|
from polygateway.ocr import OcrClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.slow
|
||||||
|
|
||||||
|
_PRIMARY = "http://10.77.0.20:7866"
|
||||||
|
_MARKER = "POLYGATEWAY SYNTHETIC SAMPLE"
|
||||||
|
_OUTPUTS = Path("tests/outputs/monkey_ocr_document")
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_pdf(text: str) -> bytes:
|
||||||
|
"""5 个对象的最小合法单页 PDF;xref 偏移按实际字节计算。`text` 不得含括号。"""
|
||||||
|
stream = f"BT /F1 28 Tf 40 700 Td ({text}) Tj ET".encode()
|
||||||
|
objects = [
|
||||||
|
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||||
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
|
||||||
|
b"/Resources << /Font << /F1 5 0 R >> >> >>",
|
||||||
|
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
|
||||||
|
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||||
|
]
|
||||||
|
out = bytearray(b"%PDF-1.4\n")
|
||||||
|
offsets = []
|
||||||
|
for number, body in enumerate(objects, start=1):
|
||||||
|
offsets.append(len(out))
|
||||||
|
out += b"%d 0 obj\n" % number + body + b"\nendobj\n"
|
||||||
|
xref_at = len(out)
|
||||||
|
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
|
||||||
|
for offset in offsets:
|
||||||
|
out += b"%010d 00000 n \n" % offset
|
||||||
|
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
|
||||||
|
len(objects) + 1,
|
||||||
|
xref_at,
|
||||||
|
)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _env(**extra: str) -> dict[str, str]:
|
||||||
|
env = {
|
||||||
|
"LLM_MAX_RETRIES": "3",
|
||||||
|
"LLM_RETRY_BASE_DELAY": "1.0",
|
||||||
|
"LLM_RETRY_MAX_DELAY": "5.0",
|
||||||
|
# 阈值 2: 坏文档若被误记成源故障,两份就足以开路——下面第三条用例据此判别
|
||||||
|
"LLM_CIRCUIT_BREAKER_THRESHOLD": "2",
|
||||||
|
"LLM_CIRCUIT_BREAKER_COOLDOWN": "30",
|
||||||
|
"PGW_CACHE_BACKEND": "none",
|
||||||
|
"PGW_TELEMETRY_BACKEND": "none",
|
||||||
|
"OCR__MONKEY__1__BASE_URL": _PRIMARY,
|
||||||
|
"OCR__MONKEY__1__API_KEY": "none",
|
||||||
|
"OCR__MONKEY__1__MODEL": "monkey-ocr",
|
||||||
|
"OCR__MONKEY__1__TIMEOUT_S": "300",
|
||||||
|
"OCR__MONKEY__1__MAX_CONCURRENCY": "1", # 与其它业务线共用,串行
|
||||||
|
# 服务在 LAN,开发机的系统代理会对内网地址回 403
|
||||||
|
"OCR__MONKEY__1__TRUST_ENV": "false",
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _record(name: str, lines: list[str]) -> None:
|
||||||
|
"""结构化 Markdown 证据(CLAUDE.md §4.6);tests/outputs 不提交。"""
|
||||||
|
_OUTPUTS.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
(_OUTPUTS / f"{name}_{stamp}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveDocument:
|
||||||
|
async def test_parse_document_returns_pages_and_text(self):
|
||||||
|
async with OcrClient.from_env("OCR", env=_env()) as client:
|
||||||
|
result = await client.parse_document(_minimal_pdf(_MARKER))
|
||||||
|
assert len(result.page_sizes) >= 1 and all(w > 0 and h > 0 for w, h in result.page_sizes)
|
||||||
|
assert result.elements, "合成 PDF 应至少检出一个块"
|
||||||
|
# 正向断言: 空串不能通过。OCR 对大号印刷体应稳定识别出标记词
|
||||||
|
assert "SYNTHETIC" in result.markdown.upper()
|
||||||
|
assert any("SYNTHETIC" in e.text.upper() for e in result.elements)
|
||||||
|
assert all(e.page_index == 0 for e in result.elements)
|
||||||
|
_record(
|
||||||
|
"parse_document",
|
||||||
|
[
|
||||||
|
"# parse_document 活体",
|
||||||
|
f"- source: {result.source_name} latency_ms: {result.latency_ms}",
|
||||||
|
f"- pages: {len(result.page_sizes)} elements: {len(result.elements)}",
|
||||||
|
f"- types: {sorted({e.type for e in result.elements})}",
|
||||||
|
f"- markdown: {result.markdown[:200]!r}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_recognize_text_on_pdf_is_not_the_hallucination(self):
|
||||||
|
"""E4: PDF 以 image.jpg 上传时 `/ocr/text` 返回 success 的幻觉文本。标签修正后应返回全文。"""
|
||||||
|
async with OcrClient.from_env("OCR", env=_env()) as client:
|
||||||
|
result = await client.recognize_text(_minimal_pdf(_MARKER))
|
||||||
|
assert result.text.strip()
|
||||||
|
assert "single character" not in result.text.lower()
|
||||||
|
assert "SYNTHETIC" in result.text.upper()
|
||||||
|
_record(
|
||||||
|
"recognize_text_pdf", ["# recognize_text(PDF) 活体", f"- text: {result.text[:200]!r}"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_corrupt_documents_do_not_open_the_breaker(self):
|
||||||
|
"""确定性坏输入归「请求被拒」,熔断记成功不计窗口。
|
||||||
|
|
||||||
|
阈值 2: 若坏文档被误记成源故障(旧行为: 500 → Transient → 重试并计熔断),
|
||||||
|
连续两份就会开路,第三次的有效文档会被 `fail_fast` 拒绝。
|
||||||
|
"""
|
||||||
|
corrupt = [b"%PDF-1.4\nnot really a pdf\n", b"%PDF-1.4\n" + b"garbage" * 50]
|
||||||
|
async with OcrClient.from_env("OCR", env=_env()) as client:
|
||||||
|
for bad in corrupt:
|
||||||
|
with pytest.raises(RequestRejectedError) as ei:
|
||||||
|
await client.parse_document(bad)
|
||||||
|
assert ei.value.status_code == 500 # 服务有响应: 按「响应即健康」记成功
|
||||||
|
result = await client.parse_document(_minimal_pdf(_MARKER))
|
||||||
|
assert result.elements
|
||||||
|
_record(
|
||||||
|
"corrupt_documents",
|
||||||
|
[
|
||||||
|
"# 坏文档不开熔断 活体",
|
||||||
|
f"- 两份坏文档均 RequestRejected(500); 随后有效文档成功, elements={len(result.elements)}",
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -72,6 +72,8 @@ _EXPECTED_COLUMNS = [
|
|||||||
# —— 1.3.8 沉默归因两列(issue #27)——
|
# —— 1.3.8 沉默归因两列(issue #27)——
|
||||||
"stall_ms",
|
"stall_ms",
|
||||||
"stall_count",
|
"stall_count",
|
||||||
|
# —— 1.3.10 结束方式(#31)——
|
||||||
|
"stream_end",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
|
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
|
||||||
@@ -173,6 +175,8 @@ def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object
|
|||||||
# —— 1.3.8 两列: 沉默归因只属流式 chat 成功 attempt 行(issue #27),普通行 NULL ——
|
# —— 1.3.8 两列: 沉默归因只属流式 chat 成功 attempt 行(issue #27),普通行 NULL ——
|
||||||
"stall_ms": None,
|
"stall_ms": None,
|
||||||
"stall_count": None,
|
"stall_count": None,
|
||||||
|
# —— 1.3.10: 结束方式只属流式成功 attempt 行,普通行 NULL ——
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
return fields
|
return fields
|
||||||
@@ -775,7 +779,7 @@ class TestCallerDimensionsAcceptance:
|
|||||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||||
schema,
|
schema,
|
||||||
)
|
)
|
||||||
# 22 → 38 个 recorder 字段(加 created_at 共 39 个物理列),且新列追加在末尾
|
# 22 → 39 个 recorder 字段(加 created_at 共 40 个物理列),且新列追加在末尾
|
||||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||||
rows = await _fetch(
|
rows = await _fetch(
|
||||||
schema_dsn,
|
schema_dsn,
|
||||||
@@ -1106,7 +1110,7 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
|
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
|
||||||
"""27 列旧表 auto 补齐到 39 列(1.3.5 十列 + 1.3.8 两列),新行两类取值读得回,**历史行新增列一律 NULL**。
|
"""27 列旧表 auto 补齐到 40 列(1.3.5 十列 + 1.3.8 两列 + 1.3.10 一列),新行两类取值读得回,**历史行新增列一律 NULL**。
|
||||||
|
|
||||||
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
|
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
|
||||||
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
|
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
|
||||||
@@ -1148,7 +1152,7 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||||
schema,
|
schema,
|
||||||
)
|
)
|
||||||
# 26 → 38 个 recorder 字段(加 created_at 共 39 个物理列),新列追加在末尾:
|
# 26 → 39 个 recorder 字段(加 created_at 共 40 个物理列),新列追加在末尾:
|
||||||
# 列序与新建库一致才不会让两条升级路径分叉
|
# 列序与新建库一致才不会让两条升级路径分叉
|
||||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||||
|
|
||||||
@@ -1176,6 +1180,8 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
# 1.3.8 两列: 普通成功 attempt 行无沉默观测 → NULL
|
# 1.3.8 两列: 普通成功 attempt 行无沉默观测 → NULL
|
||||||
"stall_ms": None,
|
"stall_ms": None,
|
||||||
"stall_count": None,
|
"stall_count": None,
|
||||||
|
# 1.3.10: 该行未传结束方式 → NULL
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
assert dict(by_id["term"]) == {
|
assert dict(by_id["term"]) == {
|
||||||
"call_id": "term",
|
"call_id": "term",
|
||||||
@@ -1192,6 +1198,8 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
# 1.3.8 两列: 终态行无 transport 观测 → NULL
|
# 1.3.8 两列: 终态行无 transport 观测 → NULL
|
||||||
"stall_ms": None,
|
"stall_ms": None,
|
||||||
"stall_count": None,
|
"stall_count": None,
|
||||||
|
# 1.3.10: 终态行无流可言 → NULL
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
|
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
|
||||||
assert dict(by_id["pre135"]) == {
|
assert dict(by_id["pre135"]) == {
|
||||||
@@ -1349,7 +1357,7 @@ class TestPublishedSchemaScript:
|
|||||||
|
|
||||||
await _execute_script(fresh_dsn, script)
|
await _execute_script(fresh_dsn, script)
|
||||||
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
||||||
# 物理列 = 38 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
# 物理列 = 39 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
||||||
assert set(actual) == set(COLUMNS) | {"created_at"}
|
assert set(actual) == set(COLUMNS) | {"created_at"}
|
||||||
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
|
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
|
||||||
assert actual == _EXPECTED_COLUMNS
|
assert actual == _EXPECTED_COLUMNS
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ def _resp(content="cached"):
|
|||||||
return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig")
|
return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig")
|
||||||
|
|
||||||
|
|
||||||
|
def _const(response):
|
||||||
|
async def terminal(request):
|
||||||
|
return response
|
||||||
|
|
||||||
|
return terminal
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def redis_cache():
|
async def redis_cache():
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
@@ -82,6 +89,23 @@ class TestRealRedisRoundtrip:
|
|||||||
assert first.cache_hit is False and second.cache_hit is True
|
assert first.cache_hit is False and second.cache_hit is True
|
||||||
assert calls["n"] == 1
|
assert calls["n"] == 1
|
||||||
|
|
||||||
|
async def test_completion_verdict_fields_roundtrip(self, redis_cache, namespace):
|
||||||
|
"""1.3.10: finish_reason/stream_end 经真实 Redis 往返;不可缓存结果不落库。"""
|
||||||
|
mw = CacheMW(
|
||||||
|
backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60
|
||||||
|
)
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
ok = replace(_resp(), finish_reason="stop", stream_end="eof_after_finish")
|
||||||
|
first = await mw(ChatRequest(messages=_MSGS), _const(ok))
|
||||||
|
second = await mw(ChatRequest(messages=_MSGS), _const(ok))
|
||||||
|
assert first.cache_hit is False and second.cache_hit is True
|
||||||
|
assert second.finish_reason == "stop" and second.stream_end is None
|
||||||
|
truncated = replace(_resp(), stream_end="salvaged")
|
||||||
|
other = [{"role": "user", "content": "integration-salvaged"}]
|
||||||
|
await mw(ChatRequest(messages=other), _const(truncated))
|
||||||
|
assert await redis_cache.get(build_cache_key("m", other, namespace, None)) is None
|
||||||
|
|
||||||
async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace):
|
async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace):
|
||||||
"""多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。"""
|
"""多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。"""
|
||||||
other = f"pgw:test:{uuid.uuid4().hex}"
|
other = f"pgw:test:{uuid.uuid4().hex}"
|
||||||
@@ -172,3 +196,22 @@ class TestClientWithRealRedis:
|
|||||||
second = await client.chat(_MSGS)
|
second = await client.chat(_MSGS)
|
||||||
assert first.cache_hit is False and second.cache_hit is True
|
assert first.cache_hit is False and second.cache_hit is True
|
||||||
assert second.content == "hello"
|
assert second.content == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_completion_roundtrip(redis_cache, namespace):
|
||||||
|
"""真实 Redis 保存完整嵌套协议对象,命中仍为值类型。"""
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from tests.unit.test_openai_completion import accumulate
|
||||||
|
|
||||||
|
completion = accumulate().finish()
|
||||||
|
mw = CacheMW(backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60)
|
||||||
|
|
||||||
|
async def terminal(_):
|
||||||
|
return replace(_resp(), completion=completion)
|
||||||
|
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode="openai")
|
||||||
|
first = await mw(request, terminal)
|
||||||
|
second = await mw(request, terminal)
|
||||||
|
assert second.cache_hit and second.completion == first.completion
|
||||||
|
assert second.completion is not first.completion
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class ScriptedTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
self.calls.append(source.name)
|
self.calls.append(source.name)
|
||||||
if self.hang:
|
if self.hang:
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""OCR 文档结果包的合成 fixtures(#34)。
|
||||||
|
|
||||||
|
全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 块类型、table/image 的嵌套
|
||||||
|
`blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——第三方文档内容许可不明、
|
||||||
|
敏感数据不入库(零业务假设 + P5),真实 ZIP 不提交。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
_BBOX = (10.0, 20.0, 110.0, 40.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _span(content="SYNTH-TEXT", type_="text"):
|
||||||
|
return {"type": type_, "content": content, "bbox": list(_BBOX), "score": 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_block(content="SYNTH-TEXT", *, type_="text", bbox=_BBOX):
|
||||||
|
return {
|
||||||
|
"type": type_,
|
||||||
|
"bbox": list(bbox),
|
||||||
|
"index": 0,
|
||||||
|
"lines": [{"bbox": list(bbox), "spans": [_span(content)]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _body(type_, spans, bbox=_BBOX):
|
||||||
|
return {"type": type_, "bbox": list(bbox), "lines": [{"bbox": list(bbox), "spans": spans}]}
|
||||||
|
|
||||||
|
|
||||||
|
def _table_block(html="<table><tr><td>SYNTH-CELL</td></tr></table>", extra_subs=()):
|
||||||
|
body = _body("table_body", [{"type": "table", "html": html, "image_path": "synth.jpg"}])
|
||||||
|
return {"type": "table", "bbox": list(_BBOX), "index": 1, "blocks": [*extra_subs, body]}
|
||||||
|
|
||||||
|
|
||||||
|
def _image_block():
|
||||||
|
body = _body("image_body", [{"type": "image", "image_path": "synth.jpg"}])
|
||||||
|
return {"type": "image", "bbox": list(_BBOX), "index": 2, "blocks": [body]}
|
||||||
|
|
||||||
|
|
||||||
|
def _page(blocks=None, *, page_idx=0, page_size=(612.0, 792.0), **extra):
|
||||||
|
page = {"page_idx": page_idx, "page_size": list(page_size), "discarded_blocks": []}
|
||||||
|
if blocks is not None:
|
||||||
|
page["para_blocks"] = blocks
|
||||||
|
page.update(extra)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def _package(
|
||||||
|
pages=None,
|
||||||
|
*,
|
||||||
|
md="# synth\n",
|
||||||
|
middle=None,
|
||||||
|
middle_name="doc_middle.json",
|
||||||
|
md_name=None,
|
||||||
|
extra=(),
|
||||||
|
):
|
||||||
|
"""组装结果包。`middle` 给原始 bytes 时原样写入;`md_name` 默认由 middle 名配对。"""
|
||||||
|
if middle is None:
|
||||||
|
middle = json.dumps({"pdf_info": pages if pages is not None else [_page([])]}).encode()
|
||||||
|
if md_name is None:
|
||||||
|
md_name = middle_name[: -len("_middle.json")] + ".md"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr(middle_name, middle)
|
||||||
|
if md is not None:
|
||||||
|
zf.writestr(md_name, md if isinstance(md, bytes) else md.encode())
|
||||||
|
for name, data in extra:
|
||||||
|
zf.writestr(name, data)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypted_package() -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
info = zipfile.ZipInfo("doc_middle.json")
|
||||||
|
info.flag_bits |= 0x1 # 加密位:读取时 zipfile 要求密码
|
||||||
|
zf.writestr(info, b'{"pdf_info": []}')
|
||||||
|
zf.writestr("doc.md", "x")
|
||||||
|
return buf.getvalue()
|
||||||
@@ -221,6 +221,7 @@ class ClockAdvancingTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
self.calls.append((source.name, call_id))
|
self.calls.append((source.name, call_id))
|
||||||
advance, action = self.script.pop(0)
|
advance, action = self.script.pop(0)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from polygateway.types import (
|
|||||||
ChatRequest,
|
ChatRequest,
|
||||||
Effort,
|
Effort,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
OpenAIChatCompletion,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
ThinkingObservation,
|
ThinkingObservation,
|
||||||
)
|
)
|
||||||
@@ -891,3 +892,238 @@ class TestCallStatsNotPoisoned:
|
|||||||
without = ChatRequest(messages=_MSGS)
|
without = ChatRequest(messages=_MSGS)
|
||||||
assert with_ctx.cache_namespace == without.cache_namespace
|
assert with_ctx.cache_namespace == without.cache_namespace
|
||||||
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
|
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenAICompletionCache:
|
||||||
|
"""协议缓存不得混模式或把字典冒充公共值对象。"""
|
||||||
|
|
||||||
|
def test_mode_isolation_preserves_text_key(self):
|
||||||
|
assert build_cache_key("m", _MSGS, "proj", None) == build_cache_key(
|
||||||
|
"m", _MSGS, "proj", None, response_mode="text"
|
||||||
|
)
|
||||||
|
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
|
||||||
|
"m", _MSGS, "proj", None, response_mode="openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"completion",
|
||||||
|
[
|
||||||
|
None,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"message": {"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"response_id": None,
|
||||||
|
"usage": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_missing_or_bad_completion_misses(self, completion):
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = CacheMW(backend=backend, model_fingerprint="m", default_namespace="proj", ttl_s=60)
|
||||||
|
raw = json.loads(mw._serialize(_resp()))
|
||||||
|
raw["completion"] = completion
|
||||||
|
assert (
|
||||||
|
mw._rehydrate(json.dumps(raw), ChatRequest(messages=_MSGS, response_mode="openai"))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_serialization_failure_does_not_fail_call(self):
|
||||||
|
mw = CacheMW(
|
||||||
|
backend=InMemoryCache(), model_fingerprint="m", default_namespace="proj", ttl_s=60
|
||||||
|
)
|
||||||
|
response = _resp(cost=object())
|
||||||
|
|
||||||
|
async def terminal(_):
|
||||||
|
return response
|
||||||
|
|
||||||
|
assert await mw(ChatRequest(messages=_MSGS), terminal) is response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"message",
|
||||||
|
[
|
||||||
|
{"role": "assistant"},
|
||||||
|
{"role": "assistant", "content": None, "tool_calls": []},
|
||||||
|
{"role": "assistant", "content": "", "tool_calls": []},
|
||||||
|
{"role": "assistant", "content": " \n", "tool_calls": []},
|
||||||
|
{"role": "assistant", "reasoning_content": "reason only"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("cached_content", ["", "stale projection"])
|
||||||
|
async def test_openai_cache_without_output_misses_and_refills(message, cached_content):
|
||||||
|
"""空协议产物不能靠旧投影正文伪装成功;miss 后回源并覆写。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode="openai")
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
|
||||||
|
bad = _resp(
|
||||||
|
content=cached_content,
|
||||||
|
completion=OpenAIChatCompletion(message, "stop", "bad", None),
|
||||||
|
)
|
||||||
|
await backend.set(key, mw._serialize(bad), ttl_s=60)
|
||||||
|
fresh = _resp(
|
||||||
|
completion=OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "content": "cached"}, "stop", "fresh", None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
terminal = _Terminal(fresh)
|
||||||
|
result = await mw(request, terminal)
|
||||||
|
assert terminal.calls == 1 and result is fresh and not result.cache_hit
|
||||||
|
hit = await mw(request, terminal)
|
||||||
|
assert terminal.calls == 1 and hit.cache_hit
|
||||||
|
assert hit.completion.response_id == "fresh"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_tool_only_cache_hits_without_origin():
|
||||||
|
"""有效工具本身是产物,空文本投影不妨碍命中。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
completion = OpenAIChatCompletion(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "echo", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"tool_calls",
|
||||||
|
"tool-response",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode="openai")
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
|
||||||
|
await backend.set(key, mw._serialize(_resp(content="", completion=completion)), ttl_s=60)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
hit = await mw(request, terminal)
|
||||||
|
assert hit.cache_hit and terminal.calls == 0
|
||||||
|
assert hit.completion == completion
|
||||||
|
|
||||||
|
|
||||||
|
# —— 1.3.10(#30/#31): 可缓存 ⊂ 可交付,读写共用同一判据(设计 §6)——
|
||||||
|
|
||||||
|
_OK_COMPLETION = OpenAIChatCompletion({"role": "assistant", "content": "ok"}, "stop", "up", None)
|
||||||
|
_THINK_LENGTH = OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "reasoning_content": "t"}, "length", "up", None
|
||||||
|
)
|
||||||
|
_THINK_STOP = OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "reasoning_content": "t"}, "stop", "up", None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "response"),
|
||||||
|
[
|
||||||
|
("text", _resp(stream_end="salvaged")),
|
||||||
|
("text", _resp(finish_reason="length")),
|
||||||
|
("text", _resp(finish_reason="content_filter")),
|
||||||
|
("text", _resp(finish_reason="end_turn")),
|
||||||
|
("openai", _resp(content="", finish_reason="length", completion=_THINK_LENGTH)),
|
||||||
|
("openai", _resp(content="", finish_reason="stop", completion=_THINK_STOP)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_uncacheable_results_are_delivered_but_not_written(mode, response):
|
||||||
|
"""截断/空正文/非自然结束: 照常交回,但不写缓存,下次回源。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(response)
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode=mode)
|
||||||
|
assert (await mw(request, terminal)) is response
|
||||||
|
assert (await mw(request, terminal)).cache_hit is False
|
||||||
|
assert terminal.calls == 2 and not backend._store
|
||||||
|
|
||||||
|
|
||||||
|
def _stored(mode, **fields):
|
||||||
|
"""直接注入一条缓存原文(模拟旧版本或别的进程写入)。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
data = dataclasses.asdict(_resp(**fields))
|
||||||
|
data.pop("structured_data", None)
|
||||||
|
data.pop("call_stats", None)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None, response_mode=mode)
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
return backend, key
|
||||||
|
|
||||||
|
|
||||||
|
async def _hit(backend, mode):
|
||||||
|
terminal = _Terminal(_resp(content="fresh", completion=_OK_COMPLETION, finish_reason="stop"))
|
||||||
|
response = await _mw(backend)(ChatRequest(messages=_MSGS, response_mode=mode), terminal)
|
||||||
|
return response, terminal.calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "fields"),
|
||||||
|
[
|
||||||
|
("text", {"stream_end": "salvaged"}),
|
||||||
|
("text", {"stream_end": "bogus"}),
|
||||||
|
("text", {"finish_reason": "length"}),
|
||||||
|
("text", {"finish_reason": 5}),
|
||||||
|
("openai", {"content": "", "completion": _THINK_LENGTH, "finish_reason": "length"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_injected_uncacheable_entries_read_as_miss(mode, fields):
|
||||||
|
"""读路径对存储原值判定(覆盖之前): 不可缓存即 miss 回源。"""
|
||||||
|
backend, _ = _stored(mode, **fields)
|
||||||
|
response, calls = await _hit(backend, mode)
|
||||||
|
assert response.cache_hit is False and calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_fidelity_length_entry_falls_back_to_completion_finish():
|
||||||
|
"""1.3.9 保真条目无顶层 finish: 回退 completion.finish_reason 识别为不可缓存。"""
|
||||||
|
completion = OpenAIChatCompletion({"role": "assistant", "content": "a"}, "length", None, None)
|
||||||
|
backend, key = _stored("openai", completion=completion)
|
||||||
|
data = json.loads(backend._store[key][1])
|
||||||
|
for name in ("finish_reason", "stream_end"):
|
||||||
|
data.pop(name)
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
response, calls = await _hit(backend, "openai")
|
||||||
|
assert response.cache_hit is False and calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_text_entry_without_new_fields_still_hits():
|
||||||
|
"""1.3.9 文本条目无新键: 仍命中;命中 stream_end=None。"""
|
||||||
|
backend, key = _stored("text")
|
||||||
|
data = json.loads(backend._store[key][1])
|
||||||
|
for name in ("finish_reason", "stream_end"):
|
||||||
|
data.pop(name)
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
response, calls = await _hit(backend, "text")
|
||||||
|
assert response.cache_hit is True and calls == 0
|
||||||
|
assert response.stream_end is None and response.finish_reason is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hit_resets_stream_end_and_keeps_effective_finish():
|
||||||
|
"""命中: stream_end 置 None;finish_reason 取判定所用值(顶层缺省时回退)。"""
|
||||||
|
backend, key = _stored("openai", completion=_OK_COMPLETION, stream_end="eof_after_finish")
|
||||||
|
data = json.loads(backend._store[key][1])
|
||||||
|
data.pop("finish_reason")
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
response, calls = await _hit(backend, "openai")
|
||||||
|
assert response.cache_hit is True and calls == 0
|
||||||
|
assert response.stream_end is None and response.finish_reason == "stop"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_new_fields_roundtrip_through_cache():
|
||||||
|
"""两新字段随序列化往返;命中只重置 stream_end。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(_resp(finish_reason="stop", stream_end="eof_after_finish"))
|
||||||
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
stored = json.loads(next(iter(backend._store.values()))[1])
|
||||||
|
assert stored["finish_reason"] == "stop" and stored["stream_end"] == "eof_after_finish"
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.cache_hit is True and hit.finish_reason == "stop" and hit.stream_end is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fidelity_think_only_projection_not_cached():
|
||||||
|
"""审查 I1: qwen think 标签剥离后投影为空的保真结果不写缓存。"""
|
||||||
|
completion = OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "content": "<think>x</think>"}, "stop", "up", None
|
||||||
|
)
|
||||||
|
response = _resp(content="", thinking="x", finish_reason="stop", completion=completion)
|
||||||
|
backend = InMemoryCache()
|
||||||
|
await _mw(backend)(ChatRequest(messages=_MSGS, response_mode="openai"), _Terminal(response))
|
||||||
|
assert not backend._store
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ class _ScriptedGenClockTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
self.calls.append(call_id)
|
self.calls.append(call_id)
|
||||||
result = self._results.pop(0)
|
result = self._results.pop(0)
|
||||||
@@ -1844,6 +1845,7 @@ class _ClockJumpTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
self.calls.append(call_id)
|
self.calls.append(call_id)
|
||||||
self._clock.advance(self._jump)
|
self._clock.advance(self._jump)
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""统一完成判定在 transport 两条路径上的落点(#30/#31,设计 §4.2-§4.4、§5.4)。
|
||||||
|
|
||||||
|
假 SSE 按 magpie/glm 实测形态二次构造: 末块 finish_reason + usage 后干净关闭、
|
||||||
|
不发 [DONE];以及只有思考的 length 截断、null/未知字段等方言。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.errors import AllSourcesExhausted, ResultInvalidError, TransientError
|
||||||
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
|
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
|
||||||
|
|
||||||
|
_USAGE = {"prompt_tokens": 11, "completion_tokens": 7}
|
||||||
|
_TOOL = {
|
||||||
|
"index": 0,
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "lookup", "arguments": '{"q": 1}'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _f(delta=None, finish=None, usage=None, choices=True):
|
||||||
|
"""单 choice 帧;`choices=False` 构造 usage-only 帧。"""
|
||||||
|
body = {"id": "up-1"}
|
||||||
|
if choices:
|
||||||
|
choice = {"index": 0, "delta": delta or {}}
|
||||||
|
if finish is not None:
|
||||||
|
choice["finish_reason"] = finish
|
||||||
|
body["choices"] = [choice]
|
||||||
|
else:
|
||||||
|
body["choices"] = []
|
||||||
|
if usage is not None:
|
||||||
|
body["usage"] = usage
|
||||||
|
return f"data: {json.dumps(body)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _assistant(**delta):
|
||||||
|
return _f({"role": "assistant", **delta})
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(frames, *, done, mode="text", **source_kw):
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*frames, done=done))
|
||||||
|
try:
|
||||||
|
return await _complete(transport, _source(**source_kw), response_mode=mode)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_once(body, *, mode="text"):
|
||||||
|
transport = _transport_for(lambda _: httpx.Response(200, json=body))
|
||||||
|
try:
|
||||||
|
return await _complete(transport, _source(), stream=False, response_mode=mode)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# —— #31: 缺 [DONE] 但 finish + 其后实测 usage 完整 ——
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
@pytest.mark.parametrize("finish", ["stop", "length"])
|
||||||
|
async def test_eof_after_finish_with_usage_succeeds(mode, finish):
|
||||||
|
"""验收 1: 末块 finish + usage 后干净关闭 → 成功、measured、eof_after_finish。"""
|
||||||
|
frames = [_assistant(content="hel"), _f({"content": "lo"}, finish=finish, usage=_USAGE)]
|
||||||
|
result = await _run(frames, done=False, mode=mode)
|
||||||
|
assert result.content == "hello" and result.stream_end == "eof_after_finish"
|
||||||
|
assert result.usage_source == "measured" and result.finish_reason == finish
|
||||||
|
assert (result.prompt_tokens, result.completion_tokens) == (11, 7)
|
||||||
|
if mode == "openai":
|
||||||
|
assert result.completion.finish_reason == finish
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eof_after_finish_usage_only_frame_and_tools():
|
||||||
|
"""验收 2: tool_calls 结束、usage 在其后的 usage-only 帧 → 工具完整交回。"""
|
||||||
|
frames = [
|
||||||
|
_assistant(content=None, tool_calls=[_TOOL]),
|
||||||
|
_f({}, finish="tool_calls"),
|
||||||
|
_f(choices=False, usage=_USAGE),
|
||||||
|
]
|
||||||
|
result = await _run(frames, done=False, mode="openai")
|
||||||
|
tool = result.completion.message["tool_calls"][0]
|
||||||
|
assert tool["function"] == {"name": "lookup", "arguments": '{"q": 1}'}
|
||||||
|
assert result.stream_end == "eof_after_finish" and result.usage_source == "measured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"frames",
|
||||||
|
[
|
||||||
|
[_assistant(content="partial"), _f(choices=False, usage=_USAGE)], # 验收 3: 无 finish
|
||||||
|
[_assistant(content="partial"), _f({}, finish="stop")], # 验收 4: 无 usage
|
||||||
|
[_assistant(content="a", **{}), _f({}, usage=_USAGE), _f({}, finish="stop")], # usage 早
|
||||||
|
[_assistant(content="a"), _f({}, finish="stop", usage={})], # usage 不可用
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_incomplete_eof_still_fails(frames):
|
||||||
|
"""截断不放宽: 保真仍不打捞,文本 retry 档仍抛。"""
|
||||||
|
with pytest.raises(TransientError, match="missing_done"):
|
||||||
|
await _run(frames, done=False, mode="openai", missing_done="salvage")
|
||||||
|
with pytest.raises(TransientError, match="missing_done"):
|
||||||
|
await _run(frames, done=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_text_salvage_marks_stream_end():
|
||||||
|
"""文本 salvage 打捞 → stream_end=salvaged,usage 降为 estimated。"""
|
||||||
|
frames = [_assistant(content="partial"), _f(choices=False, usage=_USAGE)]
|
||||||
|
result = await _run(frames, done=False, missing_done="salvage")
|
||||||
|
assert result.stream_end == "salvaged" and result.usage_source == "estimated"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_error_mid_stream_fails():
|
||||||
|
"""验收 5: 读取中途 RemoteProtocolError → 失败,不当干净 EOF。"""
|
||||||
|
|
||||||
|
class Broken(httpx.AsyncByteStream):
|
||||||
|
async def __aiter__(self):
|
||||||
|
yield _f({"role": "assistant", "content": "a"}, finish="stop", usage=_USAGE).encode()
|
||||||
|
raise httpx.RemoteProtocolError("peer closed")
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
transport = _transport_for(lambda _: httpx.Response(200, stream=Broken()))
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError, match="网络错误"):
|
||||||
|
await _complete(transport, _source(), response_mode="openai")
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
async def test_done_stream_marks_done(mode):
|
||||||
|
"""验收 6: 带 [DONE] 的正常流行为不变,stream_end=done。"""
|
||||||
|
result = await _run([_assistant(content="ok"), _f({}, "stop", _USAGE)], done=True, mode=mode)
|
||||||
|
assert result.stream_end == "done" and result.finish_reason == "stop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("done", [True, False])
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
async def test_upstream_error_finish_is_transient(done, mode):
|
||||||
|
"""GLM network_error: 上游自报失败,有正文也不交付。"""
|
||||||
|
frames = [_assistant(content="half"), _f({}, finish="network_error", usage=_USAGE)]
|
||||||
|
with pytest.raises(TransientError, match="upstream_finish_error"):
|
||||||
|
await _run(frames, done=done, mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
# —— #30 与结局表(设计 §4.3)——
|
||||||
|
|
||||||
|
_THINK = [_assistant(reasoning_content="long thought")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("done", [True, False])
|
||||||
|
async def test_openai_reasoning_only_length_delivered(done):
|
||||||
|
"""#30 验收: 只有思考 + length → 交回,content 空,思考完整。"""
|
||||||
|
result = await _run([*_THINK, _f({}, "length", _USAGE)], done=done, mode="openai")
|
||||||
|
message = result.completion.message
|
||||||
|
assert message.get("content") in (None, "") and message["reasoning_content"] == "long thought"
|
||||||
|
assert result.finish_reason == "length" and result.thinking == "long thought"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("frames", "finish", "openai_ok", "text_error"),
|
||||||
|
[
|
||||||
|
(_THINK, "length", True, (ResultInvalidError, "length_without_content")),
|
||||||
|
([_assistant()], "length", True, (ResultInvalidError, "length_without_content")),
|
||||||
|
(_THINK, "content_filter", True, (ResultInvalidError, "filtered_without_content")),
|
||||||
|
([_assistant()], "sensitive", True, (ResultInvalidError, "filtered_without_content")),
|
||||||
|
(_THINK, "stop", True, (TransientError, "empty_completion")),
|
||||||
|
([_assistant(refusal="no")], "stop", True, (TransientError, "empty_completion")),
|
||||||
|
([_assistant()], "stop", False, (TransientError, "empty_completion")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_outcome_rows_stream(frames, finish, openai_ok, text_error):
|
||||||
|
"""行 3/4/6/7/8: 两模式的分叉。"""
|
||||||
|
full = [*frames, _f({}, finish, _USAGE)]
|
||||||
|
if openai_ok:
|
||||||
|
assert (await _run(full, done=True, mode="openai")).finish_reason == finish
|
||||||
|
else:
|
||||||
|
with pytest.raises(TransientError, match="empty_completion"):
|
||||||
|
await _run(full, done=True, mode="openai")
|
||||||
|
error, code = text_error
|
||||||
|
with pytest.raises(error, match=code) as caught:
|
||||||
|
await _run(full, done=True)
|
||||||
|
assert caught.type is error # ResultInvalid 不得被 Transient 子类掩盖
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
async def test_reasoning_only_without_finish_is_transient(mode):
|
||||||
|
"""行 9: 只靠 DONE 收尾、无 finish → 无法区分抖动与结局,维持瞬时。"""
|
||||||
|
with pytest.raises(TransientError, match="empty_completion"):
|
||||||
|
await _run([*_THINK, _f(choices=False, usage=_USAGE)], done=True, mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_whitespace_content_stop_delivered():
|
||||||
|
"""正文只有空白 + stop: 保真交回(#30 一-2)。"""
|
||||||
|
result = await _run(
|
||||||
|
[_assistant(content=" "), _f({}, "stop", _USAGE)], done=True, mode="openai"
|
||||||
|
)
|
||||||
|
assert result.completion.message["content"] == " "
|
||||||
|
|
||||||
|
|
||||||
|
async def test_truncated_tool_is_result_invalid():
|
||||||
|
"""行 2: length 截断的半截工具(缺 name)→ ResultInvalid tool_truncated。"""
|
||||||
|
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": '{"q'}}
|
||||||
|
frames = [_assistant(tool_calls=[broken]), _f({}, "length", _USAGE)]
|
||||||
|
with pytest.raises(ResultInvalidError, match="tool_truncated"):
|
||||||
|
await _run(frames, done=True, mode="openai")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_content_cannot_mask_broken_tool():
|
||||||
|
"""行 2: 正文非空 + 坏工具 + stop → Transient tool_incomplete。"""
|
||||||
|
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": "{}"}}
|
||||||
|
frames = [_assistant(content="text", tool_calls=[broken]), _f({}, "stop", _USAGE)]
|
||||||
|
with pytest.raises(TransientError, match="tool_incomplete"):
|
||||||
|
await _run(frames, done=True, mode="openai")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_null_refusal_and_tool_calls_stream():
|
||||||
|
"""#30 验收二: delta 带 refusal:null 与 tool_calls:null → 正常交回。"""
|
||||||
|
frames = [_assistant(content="ok", refusal=None, tool_calls=None), _f({}, "stop", _USAGE)]
|
||||||
|
result = await _run(frames, done=True, mode="openai")
|
||||||
|
assert result.completion.message["content"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
# —— 非流式镜像 ——
|
||||||
|
|
||||||
|
|
||||||
|
def _body(message, finish):
|
||||||
|
choice = {"index": 0, "message": {"role": "assistant", **message}}
|
||||||
|
if finish is not None:
|
||||||
|
choice["finish_reason"] = finish
|
||||||
|
return {"id": "up", "choices": [choice], "usage": _USAGE}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nonstream_null_tool_calls_and_refusal():
|
||||||
|
"""非流式 message 带 tool_calls:null + refusal:null → 成功。"""
|
||||||
|
body = _body({"content": "hi", "tool_calls": None, "refusal": None}, "stop")
|
||||||
|
result = await _run_once(body, mode="openai")
|
||||||
|
assert result.completion.message["tool_calls"] is None and result.stream_end is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("message", "finish", "openai_expect", "text_expect"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"reasoning_content": "t"},
|
||||||
|
"length",
|
||||||
|
None,
|
||||||
|
(ResultInvalidError, "length_without_content"),
|
||||||
|
),
|
||||||
|
({"content": ""}, "content_filter", None, (ResultInvalidError, "filtered_without_content")),
|
||||||
|
({"reasoning_content": "t"}, "stop", None, (TransientError, "empty_completion")),
|
||||||
|
({"reasoning_content": "t"}, None, (TransientError, "empty_completion"), None),
|
||||||
|
({"content": "a"}, "network_error", (TransientError, "upstream_finish_error"), None),
|
||||||
|
({"content": "a"}, "length", None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_outcome_rows_nonstream(message, finish, openai_expect, text_expect):
|
||||||
|
"""非流式跳过流完整性,直接以 body finish 走结局表。"""
|
||||||
|
body = _body(message, finish)
|
||||||
|
for mode, expect in (("openai", openai_expect), ("text", text_expect or openai_expect)):
|
||||||
|
if expect is None:
|
||||||
|
result = await _run_once(body, mode=mode)
|
||||||
|
assert result.finish_reason == finish and result.stream_end is None
|
||||||
|
else:
|
||||||
|
with pytest.raises(expect[0], match=expect[1]) as caught:
|
||||||
|
await _run_once(body, mode=mode)
|
||||||
|
assert caught.type is expect[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nonstream_truncated_tool_is_result_invalid():
|
||||||
|
"""非流式先判定后构造快照: 缺 name + length → tool_truncated 而非构造期 Transient。"""
|
||||||
|
tool = {"id": "c", "type": "function", "function": {"arguments": "{"}}
|
||||||
|
with pytest.raises(ResultInvalidError, match="tool_truncated"):
|
||||||
|
await _run_once(_body({"tool_calls": [tool]}, "length"), mode="openai")
|
||||||
|
|
||||||
|
|
||||||
|
# —— reasoning 别名(设计 §4.4)——
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reasoning_alias_projects_thinking():
|
||||||
|
"""只有 reasoning 字段: 文本 thinking 取别名;非空 reasoning_content 优先。"""
|
||||||
|
alias = await _run(
|
||||||
|
[_assistant(reasoning="r1"), _f({"content": "a"}, "stop", _USAGE)], done=True
|
||||||
|
)
|
||||||
|
assert alias.thinking == "r1"
|
||||||
|
both = [
|
||||||
|
_assistant(reasoning="r1", reasoning_content="rc"),
|
||||||
|
_f({"content": "a"}, "stop", _USAGE),
|
||||||
|
]
|
||||||
|
assert (await _run(both, done=True)).thinking == "rc"
|
||||||
|
empty_rc = [_assistant(reasoning="r1", reasoning_content=""), _f({"content": "a"}, "stop")]
|
||||||
|
assert (await _run(empty_rc, done=True)).thinking == "r1"
|
||||||
|
nonstream = await _run_once(_body({"content": "a", "reasoning": "nr"}, "stop"))
|
||||||
|
assert nonstream.thinking == "nr"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reasoning_alias_keeps_ttft_alive():
|
||||||
|
"""只有 reasoning 的思考期刷新活性,不被 TTFT 看门狗误杀。"""
|
||||||
|
|
||||||
|
class Slow(httpx.AsyncByteStream):
|
||||||
|
async def __aiter__(self):
|
||||||
|
yield _assistant().encode()
|
||||||
|
for _ in range(5): # 0.5s 只有 reasoning,远超 ttft,但每帧间隔 < inter_token
|
||||||
|
yield _f({"reasoning": "t"}).encode()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
yield _f({"content": "answer"}, "stop", _USAGE).encode()
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
transport = _transport_for(lambda _: httpx.Response(200, stream=Slow()))
|
||||||
|
try:
|
||||||
|
source = _source(ttft_timeout_s=0.3, inter_token_timeout_s=0.25)
|
||||||
|
result = await _complete(transport, source)
|
||||||
|
assert result.content == "answer" and result.thinking == "ttttt"
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# —— 有界告警节流(设计 §5.4)——
|
||||||
|
|
||||||
|
|
||||||
|
def _sink():
|
||||||
|
records = []
|
||||||
|
handle = logger.add(lambda m: records.append(m.record["message"]), level="WARNING")
|
||||||
|
return records, handle
|
||||||
|
|
||||||
|
|
||||||
|
async def test_conflict_and_unknown_finish_warn_once():
|
||||||
|
"""同源同路径冲突、同一未知 finish 值各只告警一次,不回显字段值。"""
|
||||||
|
frames = [
|
||||||
|
_assistant(content="a", ext=1),
|
||||||
|
_f({"ext": 2}),
|
||||||
|
_f({}, "end_turn", _USAGE),
|
||||||
|
]
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*frames))
|
||||||
|
records, handle = _sink()
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
result = await _complete(transport, _source(), response_mode="openai")
|
||||||
|
assert "ext" not in result.completion.message
|
||||||
|
finally:
|
||||||
|
logger.remove(handle)
|
||||||
|
await transport.aclose()
|
||||||
|
conflict = [r for r in records if "delta.ext" in r]
|
||||||
|
unknown = [r for r in records if "end_turn" in r]
|
||||||
|
assert len(conflict) == 1 and len(unknown) == 1 and "不缓存" in unknown[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warning_set_is_bounded():
|
||||||
|
"""节流集合饱和后只记一次饱和告警,不再增长。"""
|
||||||
|
transport = OpenAICompatTransport()
|
||||||
|
transport._warned_fields.update((f"s{i}", "k") for i in range(1024))
|
||||||
|
records, handle = _sink()
|
||||||
|
try:
|
||||||
|
transport._warn_once("qwen_1", "field:x", "a")
|
||||||
|
transport._warn_once("qwen_1", "field:y", "b")
|
||||||
|
finally:
|
||||||
|
logger.remove(handle)
|
||||||
|
await transport.aclose()
|
||||||
|
assert len(transport._warned_fields) == 1024
|
||||||
|
assert sum("饱和" in r for r in records) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# —— 治理链: 确定性结局不重试、不计熔断 ——
|
||||||
|
|
||||||
|
|
||||||
|
async def test_text_length_without_content_not_retried_nor_breaker_failure():
|
||||||
|
"""文本 length 无正文 → ResultInvalid 直抛,attempts=1,熔断不记失败。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
calls.append(1)
|
||||||
|
return _sse_stream(*_THINK, _f({}, "length", _USAGE))
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
failures = []
|
||||||
|
original = client._breaker_backend.record_failure
|
||||||
|
|
||||||
|
async def spy(*args, **kwargs):
|
||||||
|
failures.append(args)
|
||||||
|
return await original(*args, **kwargs)
|
||||||
|
|
||||||
|
client._breaker_backend.record_failure = spy
|
||||||
|
try:
|
||||||
|
with pytest.raises(ResultInvalidError, match="length_without_content"):
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}])
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
assert len(calls) == 1 and failures == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_reasoning_length_single_attempt_via_client():
|
||||||
|
"""#30 验收经治理链: attempts=1;#31 形态经治理链不再重试到耗尽。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
calls.append(1)
|
||||||
|
return _sse_stream(*_THINK, _f({}, "length", _USAGE), done=False)
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
try:
|
||||||
|
response = await client.chat([{"role": "user", "content": "hi"}], response_mode="openai")
|
||||||
|
except AllSourcesExhausted: # pragma: no cover - 失败即回归
|
||||||
|
pytest.fail("reasoning-only length 不应重试到耗尽")
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
assert len(calls) == 1 and response.call_stats.attempts == 1
|
||||||
|
assert response.stream_end == "eof_after_finish" and response.finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eof_after_finish_stop_single_attempt_via_client():
|
||||||
|
"""#31 验收 1 经治理链: stop + usage 无 DONE,attempts=1、不重试到耗尽。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
calls.append(1)
|
||||||
|
return _sse_stream(_assistant(content="ok"), _f({}, "stop", _USAGE), done=False)
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
try:
|
||||||
|
response = await client.chat([{"role": "user", "content": "hi"}])
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
assert len(calls) == 1 and response.call_stats.attempts == 1
|
||||||
|
assert response.stream_end == "eof_after_finish" and response.usage_source == "measured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_text_salvage_with_length_partial_content_delivered():
|
||||||
|
"""文本 salvage + length + 部分正文(finish 后无 usage): 交付、salvaged。"""
|
||||||
|
frames = [_assistant(content="part"), _f({}, finish="length")]
|
||||||
|
result = await _run(frames, done=False, missing_done="salvage")
|
||||||
|
assert result.content == "part" and result.stream_end == "salvaged"
|
||||||
|
assert result.finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_null_arguments_tool_is_result_invalid_stream():
|
||||||
|
"""审查 I2: 无参函数 arguments 全程 null → ResultInvalid,不重试。"""
|
||||||
|
tool = {"index": 0, "id": "c", "type": "function", "function": {"name": "f", "arguments": None}}
|
||||||
|
frames = [_assistant(tool_calls=[tool]), _f({}, "tool_calls", _USAGE)]
|
||||||
|
with pytest.raises(ResultInvalidError, match="tool_arguments_null"):
|
||||||
|
await _run(frames, done=True, mode="openai")
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""统一完成判定(#30/#31): 内核纯函数真值表。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.types import (
|
||||||
|
LLMResponse,
|
||||||
|
OpenAIChatCompletion,
|
||||||
|
TransportResult,
|
||||||
|
_classify_finish,
|
||||||
|
_effective_finish,
|
||||||
|
_is_cacheable,
|
||||||
|
_judge_outcome,
|
||||||
|
_OutputSummary,
|
||||||
|
_stream_is_complete,
|
||||||
|
_summarize_message,
|
||||||
|
_summarize_text,
|
||||||
|
_tool_snapshot_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TOOL = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "expected"),
|
||||||
|
[
|
||||||
|
("stop", "natural"),
|
||||||
|
("tool_calls", "natural"),
|
||||||
|
("function_call", "natural"),
|
||||||
|
("length", "truncated"),
|
||||||
|
("content_filter", "filtered"),
|
||||||
|
("sensitive", "filtered"),
|
||||||
|
("network_error", "upstream_error"),
|
||||||
|
("error", "upstream_error"),
|
||||||
|
("end_turn", "unknown"),
|
||||||
|
("STOP", "unknown"),
|
||||||
|
(None, "absent"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_classify_finish(value, expected):
|
||||||
|
assert _classify_finish(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("done", "finish", "after", "measured", "expected"),
|
||||||
|
[
|
||||||
|
(True, None, False, False, "done"),
|
||||||
|
(True, "stop", True, True, "done"),
|
||||||
|
(False, "stop", True, True, "eof_after_finish"),
|
||||||
|
(False, "length", True, True, "eof_after_finish"),
|
||||||
|
(False, None, True, True, None), # 无 finish 即截断
|
||||||
|
(False, "stop", False, True, None), # usage 早于 finish(累计 usage)
|
||||||
|
(False, "stop", True, False, None), # finish 后 usage 不可用
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_stream_is_complete(done, finish, after, measured, expected):
|
||||||
|
got = _stream_is_complete(
|
||||||
|
done=done, finish_reason=finish, usage_after_finish=after, usage_measured=measured
|
||||||
|
)
|
||||||
|
assert got == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_snapshot_ok():
|
||||||
|
assert _tool_snapshot_ok({"role": "assistant"})
|
||||||
|
assert _tool_snapshot_ok({"tool_calls": None})
|
||||||
|
assert _tool_snapshot_ok({"tool_calls": [None, _TOOL]})
|
||||||
|
assert not _tool_snapshot_ok({"tool_calls": {}})
|
||||||
|
assert not _tool_snapshot_ok({"tool_calls": [{"id": "t1", "type": "function"}]})
|
||||||
|
assert not _tool_snapshot_ok({"tool_calls": [_TOOL, _TOOL]}) # 重复 id
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_text():
|
||||||
|
assert _summarize_text("", "") == _OutputSummary(False, False, False, True)
|
||||||
|
assert _summarize_text(" ", "") == _OutputSummary(True, False, False, True)
|
||||||
|
assert _summarize_text("", "think") == _OutputSummary(True, False, False, True)
|
||||||
|
assert _summarize_text("hi", "") == _OutputSummary(True, True, False, True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("message", "projected", "expected"),
|
||||||
|
[
|
||||||
|
({"role": "assistant"}, "", _OutputSummary(False, False, False, True)),
|
||||||
|
({"role": "assistant", "content": None}, "", _OutputSummary(False, False, False, True)),
|
||||||
|
({"role": "assistant", "refusal": "no"}, "", _OutputSummary(True, False, False, True)),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "reasoning_content": "r"},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, False, False, True),
|
||||||
|
),
|
||||||
|
({"role": "assistant", "content": " "}, " ", _OutputSummary(True, False, False, True)),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "x": {"y": ["", "z"]}},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, False, False, True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "tool_calls": [None]},
|
||||||
|
"",
|
||||||
|
_OutputSummary(False, False, False, True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "tool_calls": [_TOOL]},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, True, True, True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "tool_calls": [{"id": "t"}]},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, False, True, False),
|
||||||
|
),
|
||||||
|
({"role": "assistant", "content": "a"}, "a", _OutputSummary(True, True, False, True)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_summarize_message(message, projected, expected):
|
||||||
|
assert _summarize_message(message, projected) == expected
|
||||||
|
|
||||||
|
|
||||||
|
_NONE = _OutputSummary(False, False, False, True)
|
||||||
|
_THINK = _OutputSummary(True, False, False, True)
|
||||||
|
_BODY = _OutputSummary(True, True, False, True)
|
||||||
|
_BAD_TOOL = _OutputSummary(True, True, True, False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "summary", "finish", "expected"),
|
||||||
|
[
|
||||||
|
# 行 1: upstream_error 优先于一切
|
||||||
|
("openai", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
|
||||||
|
("text", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
|
||||||
|
# 行 2: 坏工具(仅保真),正文不得掩盖
|
||||||
|
("openai", _BAD_TOOL, "truncated", ("result_invalid", "tool_truncated")),
|
||||||
|
("openai", _BAD_TOOL, "natural", ("transient", "tool_incomplete")),
|
||||||
|
("text", _BAD_TOOL, "natural", None),
|
||||||
|
# 行 3: 无产物 + truncated/filtered
|
||||||
|
("openai", _NONE, "truncated", None),
|
||||||
|
("openai", _NONE, "filtered", None),
|
||||||
|
("text", _NONE, "truncated", ("result_invalid", "length_without_content")),
|
||||||
|
("text", _NONE, "filtered", ("result_invalid", "filtered_without_content")),
|
||||||
|
# 行 4: 零增量抖动(2026-07-20 裁决)
|
||||||
|
("openai", _NONE, "natural", ("transient", "empty_completion")),
|
||||||
|
("openai", _NONE, "absent", ("transient", "empty_completion")),
|
||||||
|
("openai", _NONE, "unknown", ("transient", "empty_completion")),
|
||||||
|
("text", _NONE, "absent", ("transient", "empty_completion")),
|
||||||
|
# 行 5: 有正文
|
||||||
|
("openai", _BODY, "truncated", None),
|
||||||
|
("text", _BODY, "filtered", None),
|
||||||
|
("text", _BODY, "absent", None),
|
||||||
|
# 行 6-9: 有产物无正文
|
||||||
|
("openai", _THINK, "truncated", None),
|
||||||
|
("text", _THINK, "truncated", ("result_invalid", "length_without_content")),
|
||||||
|
("openai", _THINK, "filtered", None),
|
||||||
|
("text", _THINK, "filtered", ("result_invalid", "filtered_without_content")),
|
||||||
|
("openai", _THINK, "natural", None),
|
||||||
|
("openai", _THINK, "unknown", None),
|
||||||
|
("text", _THINK, "natural", ("transient", "empty_completion")),
|
||||||
|
("openai", _THINK, "absent", ("transient", "empty_completion")),
|
||||||
|
("text", _THINK, "absent", ("transient", "empty_completion")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_judge_outcome_rows(mode, summary, finish, expected):
|
||||||
|
verdict = _judge_outcome(mode=mode, summary=summary, finish=finish)
|
||||||
|
if expected is None:
|
||||||
|
assert verdict.deliver and verdict.error_kind is None and verdict.code is None
|
||||||
|
else:
|
||||||
|
assert not verdict.deliver
|
||||||
|
assert (verdict.error_kind, verdict.code) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def _completion(content="a", finish="stop", tools=None):
|
||||||
|
message = {"role": "assistant", "content": content}
|
||||||
|
if tools is not None:
|
||||||
|
message["tool_calls"] = tools
|
||||||
|
return OpenAIChatCompletion(message, finish, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "expected"),
|
||||||
|
[
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "done"}, True),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": None}, True),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"mode": "text",
|
||||||
|
"content": "a",
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"stream_end": "eof_after_finish",
|
||||||
|
},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": "salvaged"}, False),
|
||||||
|
({"mode": "text", "content": " ", "finish_reason": "stop", "stream_end": "done"}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "length", "stream_end": None}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "sensitive", "stream_end": None}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "end_turn", "stream_end": None}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "bogus"}, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_cacheable_text(kwargs, expected):
|
||||||
|
assert _is_cacheable(completion=None, **kwargs) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_cacheable_openai():
|
||||||
|
ok = _completion()
|
||||||
|
assert _is_cacheable(
|
||||||
|
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=ok
|
||||||
|
)
|
||||||
|
assert not _is_cacheable(
|
||||||
|
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=None
|
||||||
|
)
|
||||||
|
# 原文为空且无工具: 不信任旧投影
|
||||||
|
assert not _is_cacheable(
|
||||||
|
mode="openai",
|
||||||
|
content="a",
|
||||||
|
finish_reason="stop",
|
||||||
|
stream_end="done",
|
||||||
|
completion=_completion(content=""),
|
||||||
|
)
|
||||||
|
tool_only = _completion(content=None, finish="tool_calls", tools=[_TOOL])
|
||||||
|
assert _is_cacheable(
|
||||||
|
mode="openai", content="", finish_reason="tool_calls", stream_end=None, completion=tool_only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_finish():
|
||||||
|
assert _effective_finish("stop", _completion(finish="length")) == "stop"
|
||||||
|
assert _effective_finish(None, _completion(finish="length")) == "length"
|
||||||
|
assert _effective_finish(None, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_accepts_null_tool_calls():
|
||||||
|
snap = OpenAIChatCompletion({"role": "assistant", "tool_calls": None}, "stop", None, None)
|
||||||
|
assert "tool_calls" in snap.message and snap.message["tool_calls"] is None
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
OpenAIChatCompletion({"role": "assistant", "tool_calls": {}}, "stop", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_fields_default_none():
|
||||||
|
result = TransportResult("a", "", 0, 0, "unavailable", None, None, {})
|
||||||
|
assert result.finish_reason is None and result.stream_end is None
|
||||||
|
response = LLMResponse("a", "", "m", "p", 0, 0, 0, None, None, False, "c")
|
||||||
|
assert response.finish_reason is None and response.stream_end is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_end_exported():
|
||||||
|
import polygateway
|
||||||
|
|
||||||
|
assert "StreamEnd" in polygateway.__all__
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_cacheable_requires_projected_body():
|
||||||
|
"""审查 I1: think 标签剥离后投影为空,即使原文非空也不可缓存。"""
|
||||||
|
think_only = _completion(content="<think>x</think>")
|
||||||
|
assert not _is_cacheable(
|
||||||
|
mode="openai", content="", finish_reason="stop", stream_end="done", completion=think_only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_arguments_only_tool_is_result_invalid():
|
||||||
|
"""审查 I2: 无参函数 arguments 全程 null(其余齐全)→ 确定性方言,ResultInvalid。"""
|
||||||
|
tool = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": None}}
|
||||||
|
summary = _summarize_message({"role": "assistant", "tool_calls": [tool]}, "")
|
||||||
|
assert summary.null_arguments and not summary.tools_ok
|
||||||
|
verdict = _judge_outcome(mode="openai", summary=summary, finish="natural")
|
||||||
|
assert (verdict.error_kind, verdict.code) == ("result_invalid", "tool_arguments_null")
|
||||||
|
broken = {"id": "t1", "type": "function", "function": {"arguments": None}}
|
||||||
|
assert not _summarize_message({"role": "assistant", "tool_calls": [broken]}, "").null_arguments
|
||||||
@@ -1397,9 +1397,7 @@ class TestStallConfig:
|
|||||||
def test_stall_after_s_single_source_warns(self):
|
def test_stall_after_s_single_source_warns(self):
|
||||||
"""单源 scope 设 stall 阈值: 装配期 warning 放行(1.3.7 §5 同款)。"""
|
"""单源 scope 设 stall 阈值: 装配期 warning 放行(1.3.7 §5 同款)。"""
|
||||||
with _captured_warnings() as warnings:
|
with _captured_warnings() as warnings:
|
||||||
s = GatewaySettings.from_env(
|
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"}))
|
||||||
"LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})
|
|
||||||
)
|
|
||||||
assert s.hedge_stall_after_s == 8.0
|
assert s.hedge_stall_after_s == 8.0
|
||||||
assert any("单源" in m for m in warnings)
|
assert any("单源" in m for m in warnings)
|
||||||
|
|
||||||
@@ -1422,9 +1420,7 @@ class TestStallConfig:
|
|||||||
"EMBED__BATCH_SIZE": "64",
|
"EMBED__BATCH_SIZE": "64",
|
||||||
}
|
}
|
||||||
with _captured_warnings() as warnings:
|
with _captured_warnings() as warnings:
|
||||||
EmbeddingSettings.from_env(
|
EmbeddingSettings.from_env("EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"})
|
||||||
"EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"}
|
|
||||||
)
|
|
||||||
assert any("仅对 chat scope 生效" in m for m in warnings)
|
assert any("仅对 chat scope 生效" in m for m in warnings)
|
||||||
with _captured_warnings() as warnings:
|
with _captured_warnings() as warnings:
|
||||||
EmbeddingSettings.from_env(
|
EmbeddingSettings.from_env(
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class HedgeTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
self.calls.append(source.name)
|
self.calls.append(source.name)
|
||||||
self.ft_events.append(first_token_event)
|
self.ft_events.append(first_token_event)
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ async def _complete(observed, *, call_id="a", stream=False):
|
|||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
first_token_event=None,
|
first_token_event=None,
|
||||||
content_event=None,
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1270,6 +1271,7 @@ async def test_structured_first_attempt_requires_exact_initial_messages():
|
|||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
first_token_event=None,
|
first_token_event=None,
|
||||||
content_event=None,
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
)
|
)
|
||||||
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
|
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
|
||||||
assert not request_is_valid(event)
|
assert not request_is_valid(event)
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
"""MonkeyOCR 文档结果包解析测试(#34 设计 §5/§5.1)。
|
||||||
|
|
||||||
|
fixtures 全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 的块类型、
|
||||||
|
table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——
|
||||||
|
第三方文档内容许可不明、敏感数据不入库(零业务假设 + P5),真实 ZIP 不提交。
|
||||||
|
|
||||||
|
契约表逐行一条,参数化列明各分支。解析函数是 bytes → 结果的纯函数,失败一律
|
||||||
|
`ResultInvalidError`,**不得漏出原生异常**(漏出会让 `OcrClient._call` 既不写终态遥测
|
||||||
|
行也不归还半开探针)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.errors import ResultInvalidError
|
||||||
|
from polygateway.transports import _monkey_document
|
||||||
|
from polygateway.transports._monkey_document import parse_document_package
|
||||||
|
from polygateway.types import OcrDocumentElement
|
||||||
|
from tests.unit._document_fixtures import (
|
||||||
|
_BBOX,
|
||||||
|
_body,
|
||||||
|
_encrypted_package,
|
||||||
|
_image_block,
|
||||||
|
_package,
|
||||||
|
_page,
|
||||||
|
_span,
|
||||||
|
_table_block,
|
||||||
|
_text_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(pages=None, **kwargs):
|
||||||
|
return parse_document_package(_package(pages, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_rejected_on_purpose(exc: ResultInvalidError) -> None:
|
||||||
|
"""拒绝必须出自显式校验,不能是 Python 自己绊倒后被外层边界吸收。
|
||||||
|
|
||||||
|
外层边界会把 TypeError/KeyError 也归为 ResultInvalidError,所以只断言异常类型,
|
||||||
|
「显式校验」与「碰巧在别处失败」无法区分(变异检查证实过: 删掉校验后测试仍绿)。
|
||||||
|
原因链里出现 TypeError/KeyError 即说明有校验缺口。
|
||||||
|
"""
|
||||||
|
assert not isinstance(exc.__cause__, TypeError | KeyError), repr(exc.__cause__)
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid(pages=None, **kwargs):
|
||||||
|
with pytest.raises(ResultInvalidError) as ei:
|
||||||
|
_parse(pages, **kwargs)
|
||||||
|
_assert_rejected_on_purpose(ei.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHappyPath:
|
||||||
|
def test_every_block_type_yields_text_per_contract(self):
|
||||||
|
pages = [
|
||||||
|
_page(
|
||||||
|
[
|
||||||
|
_text_block("SYNTH-TITLE", type_="title"),
|
||||||
|
_text_block("SYNTH-BODY"),
|
||||||
|
_text_block("x^{2}", type_="interline_equation"),
|
||||||
|
_table_block("<table>SYNTH</table>"),
|
||||||
|
_image_block(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
elements, page_sizes, markdown = _parse(pages)
|
||||||
|
assert [(e.type, e.text) for e in elements] == [
|
||||||
|
("title", "SYNTH-TITLE"),
|
||||||
|
("text", "SYNTH-BODY"),
|
||||||
|
("interline_equation", "x^{2}"),
|
||||||
|
("table", "<table>SYNTH</table>"),
|
||||||
|
("image", ""),
|
||||||
|
]
|
||||||
|
assert all(isinstance(e, OcrDocumentElement) for e in elements)
|
||||||
|
assert all(e.bbox == _BBOX and e.page_index == 0 for e in elements)
|
||||||
|
assert page_sizes == [(612.0, 792.0)]
|
||||||
|
assert markdown == "# synth\n"
|
||||||
|
|
||||||
|
def test_reading_order_and_page_index_across_pages(self):
|
||||||
|
pages = [
|
||||||
|
_page([_text_block("A")], page_idx=0),
|
||||||
|
_page([_text_block("B"), _text_block("C")], page_idx=1, page_size=(595.0, 842.0)),
|
||||||
|
]
|
||||||
|
elements, page_sizes, _ = _parse(pages)
|
||||||
|
assert [(e.page_index, e.text) for e in elements] == [(0, "A"), (1, "B"), (1, "C")]
|
||||||
|
assert page_sizes == [(612.0, 792.0), (595.0, 842.0)]
|
||||||
|
|
||||||
|
def test_content_list_and_images_are_ignored(self):
|
||||||
|
"""内容只来自 middle: content_list 把 `$` 转义成 `\\$`(展示格式),取它会污染 LaTeX。"""
|
||||||
|
garbage = json.dumps([{"type": "text", "text": "GARBAGE \\$"}]).encode()
|
||||||
|
elements, _, _ = parse_document_package(
|
||||||
|
_package(
|
||||||
|
[_page([_text_block("SYNTH $x$")])],
|
||||||
|
extra=[("doc_content_list.json", garbage), ("images/x.jpg", b"\xff\xd8")],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert [e.text for e in elements] == ["SYNTH $x$"]
|
||||||
|
|
||||||
|
def test_discarded_blocks_are_not_in_the_result(self):
|
||||||
|
pages = [_page([_text_block("KEEP")], discarded_blocks=[_text_block("DROPPED")])]
|
||||||
|
elements, _, _ = _parse(pages)
|
||||||
|
assert [e.text for e in elements] == ["KEEP"]
|
||||||
|
|
||||||
|
def test_need_drop_pages_are_neither_read_nor_rejected(self):
|
||||||
|
pages = [_page([_text_block("KEEP")], need_drop=True, drop_reason=["synthetic"])]
|
||||||
|
elements, _, _ = _parse(pages)
|
||||||
|
assert [e.text for e in elements] == ["KEEP"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextAssembly:
|
||||||
|
def test_multiple_lines_and_spans_are_joined(self):
|
||||||
|
"""样本全是 1 行 1 span;多行/多 span 路径未经实测,只由构造 fixture 钉住拼接规则。"""
|
||||||
|
block = {
|
||||||
|
"type": "text",
|
||||||
|
"bbox": list(_BBOX),
|
||||||
|
"lines": [
|
||||||
|
{"spans": [_span("A"), _span("B")]},
|
||||||
|
{"spans": [_span("C")]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
elements, _, _ = _parse([_page([block])])
|
||||||
|
assert elements[0].text == "AB\nC"
|
||||||
|
|
||||||
|
def test_inline_equation_spans_are_wrapped_in_dollars(self):
|
||||||
|
"""上游惯例;样本中从未出现该 span 类型,未经实测。"""
|
||||||
|
block = {
|
||||||
|
"type": "text",
|
||||||
|
"bbox": list(_BBOX),
|
||||||
|
"lines": [{"spans": [_span("see "), _span("a+b", "inline_equation"), _span(".")]}],
|
||||||
|
}
|
||||||
|
elements, _, _ = _parse([_page([block])])
|
||||||
|
assert elements[0].text == "see $a+b$."
|
||||||
|
|
||||||
|
def test_empty_lines_list_is_empty_text(self):
|
||||||
|
block = {"type": "text", "bbox": list(_BBOX), "lines": []}
|
||||||
|
elements, _, _ = _parse([_page([block])])
|
||||||
|
assert elements[0].text == ""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mutate",
|
||||||
|
[
|
||||||
|
pytest.param(lambda b: b.update(lines="not-a-list"), id="lines-not-list"),
|
||||||
|
pytest.param(lambda b: b.update(lines=["not-a-dict"]), id="line-not-dict"),
|
||||||
|
pytest.param(lambda b: b["lines"][0].update(spans="x"), id="spans-not-list"),
|
||||||
|
pytest.param(lambda b: b["lines"][0].update(spans=["x"]), id="span-not-dict"),
|
||||||
|
pytest.param(lambda b: b["lines"][0]["spans"][0].pop("content"), id="content-missing"),
|
||||||
|
pytest.param(lambda b: b["lines"][0]["spans"][0].update(content=7), id="content-int"),
|
||||||
|
pytest.param(
|
||||||
|
lambda b: b["lines"][0]["spans"][0].update(content=None), id="content-null"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_malformed_text_structure_is_rejected_not_silently_empty(self, mutate):
|
||||||
|
block = _text_block()
|
||||||
|
mutate(block)
|
||||||
|
_invalid([_page([block])])
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableAndImage:
|
||||||
|
def test_table_falls_back_to_latex_when_no_html(self):
|
||||||
|
body = _body("table_body", [{"type": "table", "latex": "\\begin{tabular}SYNTH"}])
|
||||||
|
block = {"type": "table", "bbox": list(_BBOX), "blocks": [body]}
|
||||||
|
elements, _, _ = _parse([_page([block])])
|
||||||
|
assert elements[0].text == "\\begin{tabular}SYNTH"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"block",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"bbox": list(_BBOX),
|
||||||
|
"blocks": [_body("table_body", [{"type": "table"}])],
|
||||||
|
},
|
||||||
|
id="span-has-neither-html-nor-latex",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"type": "table", "bbox": list(_BBOX), "blocks": []}, id="no-body-sub-block"
|
||||||
|
),
|
||||||
|
pytest.param({"type": "table", "bbox": list(_BBOX)}, id="no-blocks-key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_table_without_content_is_rejected_not_silently_empty(self, block):
|
||||||
|
"""表格无内容与"合法空串"无法在结果里区分,故响亮失败。"""
|
||||||
|
_invalid([_page([block])])
|
||||||
|
|
||||||
|
def test_image_text_is_empty_even_with_path_only_span(self):
|
||||||
|
elements, _, _ = _parse([_page([_image_block()])])
|
||||||
|
assert (elements[0].type, elements[0].text) == ("image", "")
|
||||||
|
|
||||||
|
def test_non_body_sub_blocks_become_independent_elements_after_their_parent(self):
|
||||||
|
"""默认部署不产生 caption/footnote 子块(未经实测);出现时不得静默丢弃。"""
|
||||||
|
caption = _body("table_caption", [_span("SYNTH-CAPTION")], bbox=(1.0, 2.0, 3.0, 4.0))
|
||||||
|
footnote = _body("table_footnote", [_span("SYNTH-NOTE")], bbox=(5.0, 6.0, 7.0, 8.0))
|
||||||
|
block = _table_block(extra_subs=[caption])
|
||||||
|
block["blocks"].append(footnote)
|
||||||
|
elements, _, _ = _parse([_page([block])])
|
||||||
|
assert [(e.type, e.text) for e in elements] == [
|
||||||
|
("table", "<table><tr><td>SYNTH-CELL</td></tr></table>"),
|
||||||
|
("table_caption", "SYNTH-CAPTION"),
|
||||||
|
("table_footnote", "SYNTH-NOTE"),
|
||||||
|
]
|
||||||
|
assert elements[1].bbox == (1.0, 2.0, 3.0, 4.0)
|
||||||
|
assert all(e.page_index == 0 for e in elements)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("blocks", "reason"),
|
||||||
|
[
|
||||||
|
pytest.param("not-a-list", "blocks 无效", id="blocks-not-list"),
|
||||||
|
pytest.param(["not-a-dict"], "块结构无效", id="sub-block-not-dict"),
|
||||||
|
pytest.param([{"bbox": list(_BBOX)}], "块缺少 type", id="sub-block-without-type"),
|
||||||
|
pytest.param([{"type": " ", "bbox": list(_BBOX)}], "块缺少 type", id="blank-type"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_malformed_nested_blocks_are_rejected_with_their_own_reason(self, blocks, reason):
|
||||||
|
"""外层边界会把 TypeError 等也归为 ResultInvalid,所以必须断言**原因**,否则"显式校验"
|
||||||
|
与"碰巧在别处失败"无法区分(变异检查发现仅断言类型时删掉校验测试仍绿)。"""
|
||||||
|
block = {"type": "table", "bbox": list(_BBOX), "blocks": blocks}
|
||||||
|
with pytest.raises(ResultInvalidError, match=reason):
|
||||||
|
_parse([_page([block])])
|
||||||
|
|
||||||
|
def test_empty_non_body_sub_block_is_not_emitted(self):
|
||||||
|
caption = _body("table_caption", [_span("")])
|
||||||
|
elements, _, _ = _parse([_page([_table_block(extra_subs=[caption])])])
|
||||||
|
assert [e.type for e in elements] == ["table"]
|
||||||
|
|
||||||
|
def test_unknown_type_without_lines_or_blocks_keeps_type_and_empty_text(self):
|
||||||
|
block = {"type": "synthetic_widget", "bbox": list(_BBOX)}
|
||||||
|
elements, _, _ = _parse([_page([block])])
|
||||||
|
assert (elements[0].type, elements[0].text) == ("synthetic_widget", "")
|
||||||
|
|
||||||
|
def test_unknown_container_keeps_its_body_text(self):
|
||||||
|
"""未知容器类型的 `*_body` 子块文字并入父元素,不被静默丢弃。"""
|
||||||
|
block = {
|
||||||
|
"type": "synthetic_code",
|
||||||
|
"bbox": list(_BBOX),
|
||||||
|
"blocks": [_body("synthetic_code_body", [_span("SYNTH-CODE")])],
|
||||||
|
}
|
||||||
|
elements, _, _ = _parse([_page([block])])
|
||||||
|
assert (elements[0].type, elements[0].text) == ("synthetic_code", "SYNTH-CODE")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPageStructure:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
pytest.param({}, id="pdf_info-missing"),
|
||||||
|
pytest.param({"pdf_info": "x"}, id="pdf_info-not-list"),
|
||||||
|
pytest.param({"pdf_info": []}, id="zero-pages"),
|
||||||
|
pytest.param([], id="payload-not-dict"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_document_level_shape(self, payload):
|
||||||
|
"""零页被拒: 成功解析的文档至少 1 页(与 layout 路径"elements 空合法"不同)。"""
|
||||||
|
_invalid(middle=json.dumps(payload).encode())
|
||||||
|
|
||||||
|
def test_page_not_a_dict(self):
|
||||||
|
_invalid(["not-a-dict"])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"page_size",
|
||||||
|
[
|
||||||
|
pytest.param(None, id="missing"),
|
||||||
|
pytest.param([612.0], id="one-element"),
|
||||||
|
pytest.param([612.0, 792.0, 1.0], id="three-elements"),
|
||||||
|
pytest.param([0, 792.0], id="zero-width"),
|
||||||
|
pytest.param([612.0, -1], id="negative-height"),
|
||||||
|
pytest.param([True, 792.0], id="bool"),
|
||||||
|
pytest.param(["612", 792.0], id="string"),
|
||||||
|
pytest.param([10**400, 792.0], id="huge-int"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_page_size_invalid(self, page_size):
|
||||||
|
page = _page([])
|
||||||
|
if page_size is None:
|
||||||
|
del page["page_size"]
|
||||||
|
else:
|
||||||
|
page["page_size"] = page_size
|
||||||
|
_invalid([page])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"page_idx",
|
||||||
|
[
|
||||||
|
pytest.param("missing", id="missing"),
|
||||||
|
pytest.param("0", id="string"),
|
||||||
|
pytest.param(0.0, id="float"),
|
||||||
|
pytest.param(True, id="bool-true-off-position"),
|
||||||
|
pytest.param(-1, id="negative"),
|
||||||
|
pytest.param(3, id="out-of-position"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_page_idx_must_equal_position(self, page_idx):
|
||||||
|
"""layout 路径对非法 `page_idx` 回退枚举下标,会让 page_sizes 按 page_index 的索引契约静默错位。"""
|
||||||
|
page = _page([])
|
||||||
|
if page_idx == "missing":
|
||||||
|
del page["page_idx"]
|
||||||
|
else:
|
||||||
|
page["page_idx"] = page_idx
|
||||||
|
_invalid([page])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"pages",
|
||||||
|
[
|
||||||
|
pytest.param([_page([], page_idx=False)], id="false-equals-0-on-first-page"),
|
||||||
|
pytest.param(
|
||||||
|
[_page([], page_idx=0), _page([], page_idx=True)], id="true-equals-1-on-second-page"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bool_page_idx_is_rejected_even_when_it_equals_the_position(self, pages):
|
||||||
|
"""`False == 0`、`True == 1`: 没有显式的 bool 守卫时它们恰好等于页序而被放行。"""
|
||||||
|
with pytest.raises(ResultInvalidError, match="page_idx"):
|
||||||
|
_parse(pages)
|
||||||
|
|
||||||
|
def test_second_page_must_not_claim_the_first_index(self):
|
||||||
|
_invalid([_page([], page_idx=0), _page([], page_idx=0)])
|
||||||
|
|
||||||
|
def test_para_blocks_missing_is_a_legal_empty_page(self):
|
||||||
|
elements, page_sizes, _ = _parse([_page()])
|
||||||
|
assert elements == [] and page_sizes == [(612.0, 792.0)]
|
||||||
|
|
||||||
|
def test_para_blocks_not_a_list_is_rejected(self):
|
||||||
|
_invalid([_page("x")])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlockStructure:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"block",
|
||||||
|
[
|
||||||
|
pytest.param("not-a-dict", id="block-not-dict"),
|
||||||
|
pytest.param({"bbox": list(_BBOX)}, id="type-missing"),
|
||||||
|
pytest.param({"type": 7, "bbox": list(_BBOX)}, id="type-not-str"),
|
||||||
|
pytest.param({"type": "", "bbox": list(_BBOX)}, id="type-empty"),
|
||||||
|
pytest.param({"type": " ", "bbox": list(_BBOX)}, id="type-whitespace"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_block_shape(self, block):
|
||||||
|
_invalid([_page([block])])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bbox",
|
||||||
|
[
|
||||||
|
pytest.param(None, id="missing"),
|
||||||
|
pytest.param("1,2,3,4", id="string"),
|
||||||
|
pytest.param([1, 2, 3], id="length-3"),
|
||||||
|
pytest.param([1, 2, 3, 4, 5], id="length-5"),
|
||||||
|
pytest.param([1, 2, "3", 4], id="string-element"),
|
||||||
|
pytest.param([1, 2, True, 4], id="bool-element"),
|
||||||
|
pytest.param([1, 2, float("nan"), 4], id="nan"),
|
||||||
|
pytest.param([1, 2, float("inf"), 4], id="inf"),
|
||||||
|
pytest.param([10, 2, 5, 4], id="x-reversed"),
|
||||||
|
pytest.param([1, 20, 5, 4], id="y-reversed"),
|
||||||
|
pytest.param([0, 0, 10**400, 1], id="huge-int"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bbox_invalid(self, bbox):
|
||||||
|
block = _text_block()
|
||||||
|
if bbox is None:
|
||||||
|
del block["bbox"]
|
||||||
|
else:
|
||||||
|
block["bbox"] = bbox
|
||||||
|
# NaN/Infinity 不是合法 JSON,但 Python 的 json 模块默认接受并往返——真实服务可能发出
|
||||||
|
_invalid([_page([block])])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bbox", [(5, 5, 5, 20), (5, 5, 50, 5), (5, 5, 5, 5)])
|
||||||
|
def test_zero_width_or_height_boxes_are_legal(self, bbox):
|
||||||
|
"""整数化退化校验专为 CHS shim 的 int() 裁剪;文档消费者不裁剪,一个退化框不该让整份失败。"""
|
||||||
|
elements, _, _ = _parse([_page([_text_block(bbox=bbox)])])
|
||||||
|
assert elements[0].bbox == tuple(float(v) for v in bbox)
|
||||||
|
|
||||||
|
def test_out_of_page_boxes_are_returned_as_is(self):
|
||||||
|
"""越界与否是图内语义,归调用方(ARCHITECTURE §5.1 边界);库不裁剪。"""
|
||||||
|
elements, _, _ = _parse([_page([_text_block(bbox=(0, 0, 9999, 9999))])])
|
||||||
|
assert elements[0].bbox == (0.0, 0.0, 9999.0, 9999.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMembers:
|
||||||
|
def test_missing_middle_json(self):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr("doc.md", "x")
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(buf.getvalue())
|
||||||
|
|
||||||
|
def test_missing_paired_markdown(self):
|
||||||
|
_invalid(md=None)
|
||||||
|
|
||||||
|
def test_markdown_is_paired_by_name_not_first_match(self):
|
||||||
|
elements, _, markdown = parse_document_package(
|
||||||
|
_package(
|
||||||
|
[_page([_text_block("A")])],
|
||||||
|
md="PAIRED",
|
||||||
|
extra=[("aaa_other.md", "DISTRACTOR")],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert markdown == "PAIRED"
|
||||||
|
|
||||||
|
def test_unpaired_markdown_alone_is_not_accepted(self):
|
||||||
|
_invalid(md=None, extra=[("other.md", "DISTRACTOR")])
|
||||||
|
|
||||||
|
def test_first_middle_json_wins_and_pairs_with_its_own_markdown(self):
|
||||||
|
pages_a = json.dumps({"pdf_info": [_page([_text_block("A")])]}).encode()
|
||||||
|
pages_b = json.dumps({"pdf_info": [_page([_text_block("B")])]}).encode()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
|
zf.writestr("a_middle.json", pages_a)
|
||||||
|
zf.writestr("b_middle.json", pages_b)
|
||||||
|
zf.writestr("a.md", "MD-A")
|
||||||
|
zf.writestr("b.md", "MD-B")
|
||||||
|
elements, _, markdown = parse_document_package(buf.getvalue())
|
||||||
|
assert [e.text for e in elements] == ["A"] and markdown == "MD-A"
|
||||||
|
|
||||||
|
def test_markdown_bom_is_stripped(self):
|
||||||
|
_, _, markdown = _parse(md=b"\xef\xbb\xbf# synth")
|
||||||
|
assert markdown == "# synth"
|
||||||
|
|
||||||
|
def test_empty_markdown_is_legal(self):
|
||||||
|
_, _, markdown = _parse(md="")
|
||||||
|
assert markdown == ""
|
||||||
|
|
||||||
|
def test_non_utf8_markdown_is_rejected(self):
|
||||||
|
_invalid(md=b"\xff\xfe\x00bad")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"middle",
|
||||||
|
[
|
||||||
|
pytest.param(b"not json at all", id="not-json"),
|
||||||
|
pytest.param(b"", id="empty"),
|
||||||
|
pytest.param(b"\xff\xfe", id="not-utf8"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_middle_json_unparseable(self, middle):
|
||||||
|
_invalid(middle=middle)
|
||||||
|
|
||||||
|
def test_not_a_zip(self):
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(b"definitely not a zip")
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_first_member_compression(zip_bytes: bytes, method: int) -> bytes:
|
||||||
|
"""把第一个成员的压缩方法号改成 `method`(本地头偏移 8,中央目录偏移 10)。"""
|
||||||
|
data = bytearray(zip_bytes)
|
||||||
|
local = data.index(b"PK\x03\x04")
|
||||||
|
central = data.index(b"PK\x01\x02")
|
||||||
|
struct.pack_into("<H", data, local + 8, method)
|
||||||
|
struct.pack_into("<H", data, central + 10, method)
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExternalDataExceptionBoundary:
|
||||||
|
"""外部字节引起的一切已知异常都归 `ResultInvalidError`,绝不漏出原生异常。
|
||||||
|
|
||||||
|
漏出的后果(`OcrClient._call` 只接 PolyGatewayError): 不写终态遥测行,持有的半开探针
|
||||||
|
也不归还。边界必须同时盖住「读取」与「全部结构解析」两段。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_encrypted_member(self):
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(_encrypted_package())
|
||||||
|
|
||||||
|
def test_unknown_compression_method(self):
|
||||||
|
patched = _patch_first_member_compression(_package([_page([])]), 99)
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(patched)
|
||||||
|
|
||||||
|
def test_crc_corruption(self):
|
||||||
|
corrupted = _package([_page([])]).replace(b'"pdf_info"', b'"pdf_infq"', 1)
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(corrupted)
|
||||||
|
|
||||||
|
def test_huge_integer_inside_page_structure_is_not_an_overflow_error(self):
|
||||||
|
"""`float(10**400)` 抛 OverflowError——发生在结构解析段,不在读取段。"""
|
||||||
|
block = _text_block()
|
||||||
|
block["bbox"] = [0, 0, 10**400, 1]
|
||||||
|
_invalid([_page([block])])
|
||||||
|
|
||||||
|
def test_deeply_nested_json_is_not_a_recursion_error(self):
|
||||||
|
deep = b'{"pdf_info": ' + b"[" * 100_000 + b"]" * 100_000 + b"}"
|
||||||
|
_invalid(middle=deep)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("cap_name", "member"),
|
||||||
|
[("MAX_MIDDLE_BYTES", "doc_middle.json"), ("MAX_MARKDOWN_BYTES", "doc.md")],
|
||||||
|
)
|
||||||
|
def test_declared_size_over_cap_names_the_offending_member(self, cap_name, member, monkeypatch):
|
||||||
|
"""断言消息里的成员名: 两个上限互换时,只断言异常类型的旧用例仍会通过。"""
|
||||||
|
monkeypatch.setattr(_monkey_document, cap_name, 8)
|
||||||
|
with pytest.raises(ResultInvalidError, match=member):
|
||||||
|
_parse([_page([_text_block("A")])], md="# a markdown longer than eight bytes")
|
||||||
|
|
||||||
|
def test_existing_result_invalid_messages_pass_through_unchanged(self):
|
||||||
|
with pytest.raises(ResultInvalidError, match="至少应有 1 页"):
|
||||||
|
_parse([])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayoutPathUntouched:
|
||||||
|
"""文档路径更严/更宽的口径不得外溢到既有调用方在用的 layout 解析。"""
|
||||||
|
|
||||||
|
def test_layout_parser_still_rejects_degenerate_int_boxes(self):
|
||||||
|
from polygateway.transports.monkey_ocr import _parse_middle_json
|
||||||
|
|
||||||
|
block = {"type": "text", "bbox": [5, 5, 5.4, 20], "lines": []}
|
||||||
|
zip_bytes = _package([_page([block])])
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
_parse_middle_json(zip_bytes)
|
||||||
|
|
||||||
|
def test_layout_parser_keeps_its_lenient_page_idx_handling(self):
|
||||||
|
"""layout 路径: 合法的非负整数 `page_idx` 原样接受(哪怕与页序不符),非法值才回退
|
||||||
|
枚举下标——这正是文档路径改为严格的原因,但既有调用方的行为不得被连带改动。"""
|
||||||
|
from polygateway.transports.monkey_ocr import _parse_middle_json
|
||||||
|
|
||||||
|
accepted, _ = _parse_middle_json(_package([_page([_text_block()], page_idx=7)]))
|
||||||
|
assert accepted[0].page_index == 7
|
||||||
|
fallback, _ = _parse_middle_json(_package([_page([_text_block()], page_idx="x")]))
|
||||||
|
assert fallback[0].page_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestZipLayerNeverLeaksNativeExceptions:
|
||||||
|
"""独立验证发现: BZIP2/LZMA 成员数据损坏会漏出 `OSError`/`LZMAError`,中央目录版本字段损坏会在
|
||||||
|
`ZipFile` 构造阶段漏出 `NotImplementedError`——它们都不在外层边界里。漏出的后果与设计 §5 一致:
|
||||||
|
`OcrClient._call` 只接 `PolyGatewayError`,既不写终态遥测行,也不归还半开探针。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _zip(method: int) -> bytes:
|
||||||
|
payload = json.dumps({"pdf_info": [_page([_text_block("SYNTH")])]}).encode() * 20
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", compression=method) as zf:
|
||||||
|
zf.writestr("doc_middle.json", payload)
|
||||||
|
zf.writestr("doc.md", "# synth " * 50)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method",
|
||||||
|
[
|
||||||
|
pytest.param(zipfile.ZIP_DEFLATED, id="deflated"),
|
||||||
|
pytest.param(zipfile.ZIP_BZIP2, id="bzip2"),
|
||||||
|
pytest.param(zipfile.ZIP_LZMA, id="lzma"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_single_bit_flips_never_escape_as_native_exceptions(self, method):
|
||||||
|
"""确定性字节翻转(固定种子): 每个结果只能是成功或 `ResultInvalidError`。"""
|
||||||
|
base = self._zip(method)
|
||||||
|
rng = random.Random(34)
|
||||||
|
for _ in range(600):
|
||||||
|
data = bytearray(base)
|
||||||
|
position = rng.randrange(len(data))
|
||||||
|
data[position] ^= 1 << rng.randrange(8)
|
||||||
|
try:
|
||||||
|
parse_document_package(bytes(data))
|
||||||
|
except ResultInvalidError:
|
||||||
|
continue
|
||||||
|
except Exception as exc: # noqa: BLE001 — 测试要抓的正是任何漏出的原生异常
|
||||||
|
pytest.fail(
|
||||||
|
f"字节 {position} 翻转后漏出 {type(exc).__module__}.{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA])
|
||||||
|
def test_corrupt_non_deflate_member_data(self, method):
|
||||||
|
"""压缩流内部损坏(而非 CRC 失败): 标准库抛 OSError / LZMAError。"""
|
||||||
|
data = bytearray(self._zip(method))
|
||||||
|
local = data.index(b"PK\x03\x04")
|
||||||
|
name_len, extra_len = struct.unpack_from("<HH", data, local + 26)
|
||||||
|
body = local + 30 + name_len + extra_len
|
||||||
|
for offset in range(body, body + 12):
|
||||||
|
data[offset] ^= 0xFF
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(bytes(data))
|
||||||
|
|
||||||
|
def test_zipfile_construction_stage_is_inside_the_boundary(self):
|
||||||
|
"""中央目录的 extract_version 损坏: `ZipFile(...)` 构造阶段抛 NotImplementedError。"""
|
||||||
|
data = bytearray(self._zip(zipfile.ZIP_STORED))
|
||||||
|
central = data.index(b"PK\x01\x02")
|
||||||
|
data[central + 6] = 99 # extract_version = 9.9 → "zip file version 9.9"
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(bytes(data))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextBearingBlocksRequireLines:
|
||||||
|
"""text/title/interline_equation 缺 `lines` 不是「空文本」而是畸形: 与 table 缺内容就报错同口径,
|
||||||
|
否则下游分不清「服务识别出空块」和「服务给了坏结构」。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("block_type", ["text", "title", "interline_equation"])
|
||||||
|
def test_missing_lines_is_rejected_with_its_reason(self, block_type):
|
||||||
|
block = {"type": block_type, "bbox": list(_BBOX)}
|
||||||
|
with pytest.raises(ResultInvalidError, match="缺少 lines"):
|
||||||
|
_parse([_page([block])])
|
||||||
@@ -8,6 +8,7 @@ P5 医疗数据不入库)。
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -19,12 +20,20 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
|
from polygateway.transports import monkey_ocr
|
||||||
from polygateway.transports.monkey_ocr import (
|
from polygateway.transports.monkey_ocr import (
|
||||||
|
_DEFAULT_REJECTED_MARKERS,
|
||||||
MonkeyOcrTransport,
|
MonkeyOcrTransport,
|
||||||
_classify_status,
|
_classify_status,
|
||||||
_parse_middle_json,
|
_parse_middle_json,
|
||||||
|
_sniff_media,
|
||||||
|
_translate_http_errors,
|
||||||
|
_upload_part,
|
||||||
)
|
)
|
||||||
from polygateway.types import SourceConfig
|
from polygateway.types import OcrDocumentElement, OcrDocumentTransportResult, SourceConfig
|
||||||
|
from tests.unit._document_fixtures import _package as _doc_package
|
||||||
|
from tests.unit._document_fixtures import _page as _doc_page
|
||||||
|
from tests.unit._document_fixtures import _table_block, _text_block
|
||||||
|
|
||||||
|
|
||||||
def _source(**overrides):
|
def _source(**overrides):
|
||||||
@@ -167,6 +176,96 @@ class TestRecognizeText:
|
|||||||
assert b'filename="image.jpg"' in seen["body"] # CHS invokers.py:496 固定名
|
assert b'filename="image.jpg"' in seen["body"] # CHS invokers.py:496 固定名
|
||||||
|
|
||||||
|
|
||||||
|
_PDF_BODY = b"%PDF-1.4\n%synthetic body"
|
||||||
|
# BMP: "BM" + 文件大小(4) + 保留(4) + 像素偏移(4) + DIB 头大小(4,常见 12/40/52/56/64/108/124)
|
||||||
|
_BMP_HEADER = b"BM" + b"\x00" * 12 + (40).to_bytes(4, "little") + b"\x00" * 20
|
||||||
|
_JUNK = b"x" * 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestSniffMedia:
|
||||||
|
"""上传类型按内容判定(#34 设计 §3 Q2): 图片魔数优先 → 前 1KB 内 `%PDF-` → 未知。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("data", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(_PDF_BODY, "pdf", id="pdf"),
|
||||||
|
pytest.param(_JUNK + _PDF_BODY, "pdf", id="pdf-with-junk-prefix"),
|
||||||
|
pytest.param(b"\xff\xd8\xff\xe0" + b"0" * 20, "image", id="jpeg"),
|
||||||
|
pytest.param(b"\x89PNG\r\n\x1a\n" + b"0" * 20, "image", id="png"),
|
||||||
|
pytest.param(b"II*\x00" + b"0" * 20, "image", id="tiff-le"),
|
||||||
|
pytest.param(b"MM\x00*" + b"0" * 20, "image", id="tiff-be"),
|
||||||
|
pytest.param(_BMP_HEADER, "image", id="bmp"),
|
||||||
|
pytest.param(
|
||||||
|
b"BMW is a car maker, not a bitmap", "unknown", id="text-starting-with-BM"
|
||||||
|
),
|
||||||
|
pytest.param(b"BM" + b"\x00" * 40, "unknown", id="BM-with-bad-dib-header"),
|
||||||
|
pytest.param(b"GIF89a" + b"0" * 20, "image", id="gif89a"),
|
||||||
|
pytest.param(b"GIF87a" + b"0" * 20, "image", id="gif87a"),
|
||||||
|
pytest.param(b"RIFF\x00\x00\x00\x00WEBPVP8 ", "image", id="webp"),
|
||||||
|
# 图片魔数优先: 含 XMP 元数据的图片可能在 1KB 内带 `%PDF-` 字样
|
||||||
|
pytest.param(
|
||||||
|
b"\xff\xd8\xff\xe0" + b"<xmp>%PDF-</xmp>", "image", id="jpeg-mentions-pdf"
|
||||||
|
),
|
||||||
|
pytest.param(b"<html><body>paywall</body></html>", "unknown", id="html"),
|
||||||
|
pytest.param(b"GARBAGE-NOT-AN-IMAGE", "unknown", id="garbage"),
|
||||||
|
pytest.param(b"", "unknown", id="empty"),
|
||||||
|
# 1KB 窗口之外的 `%PDF-` 不算
|
||||||
|
pytest.param(b"x" * 1100 + b"%PDF-", "unknown", id="header-beyond-window"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sniff(self, data, expected):
|
||||||
|
assert _sniff_media(data) == expected
|
||||||
|
|
||||||
|
def test_upload_part_labels_pdf_only(self):
|
||||||
|
assert _upload_part(_PDF_BODY) == ("doc.pdf", _PDF_BODY, "application/pdf")
|
||||||
|
assert _upload_part(_JUNK + _PDF_BODY)[0] == "doc.pdf"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"data", [b"\xff\xd8\xff\xe0abc", b"\x89PNG\r\n\x1a\nabc", b"junk", b""]
|
||||||
|
)
|
||||||
|
def test_everything_else_keeps_the_legacy_label(self, data):
|
||||||
|
"""非 PDF 路径字节级不变: 服务对图片按内容解码(2026-10-21 实测 PNG/TIFF/BMP 标 image.jpg 成功)。"""
|
||||||
|
assert _upload_part(data) == ("image.jpg", data, "image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUploadLabelPerMethod:
|
||||||
|
"""三个调用点共用同一个标签点: 漏掉任何一个,E4 的静默错误结果就在那里复活。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _capturing_transport(seen):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == "/ocr/text":
|
||||||
|
seen["body"] = request.read()
|
||||||
|
return httpx.Response(200, json=_text_body())
|
||||||
|
if request.url.path == "/parse":
|
||||||
|
seen["body"] = request.read()
|
||||||
|
return httpx.Response(200, json=_parse_body())
|
||||||
|
return httpx.Response(200, content=_zip_bytes())
|
||||||
|
|
||||||
|
return _transport_for(handler)
|
||||||
|
|
||||||
|
async def test_recognize_text_labels_pdf_as_pdf(self):
|
||||||
|
seen = {}
|
||||||
|
t = self._capturing_transport(seen)
|
||||||
|
await t.recognize_text(image=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert b'filename="doc.pdf"' in seen["body"]
|
||||||
|
assert b"Content-Type: application/pdf" in seen["body"]
|
||||||
|
|
||||||
|
async def test_parse_layout_labels_pdf_as_pdf(self):
|
||||||
|
seen = {}
|
||||||
|
t = self._capturing_transport(seen)
|
||||||
|
await t.parse_layout(image=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert b'filename="doc.pdf"' in seen["body"]
|
||||||
|
assert b"Content-Type: application/pdf" in seen["body"]
|
||||||
|
|
||||||
|
async def test_parse_layout_keeps_image_label_for_images(self):
|
||||||
|
seen = {}
|
||||||
|
t = self._capturing_transport(seen)
|
||||||
|
await t.parse_layout(image=b"\xff\xd8\xff\xe0abc", source=_source(), call_id="c1")
|
||||||
|
assert b'filename="image.jpg"' in seen["body"]
|
||||||
|
assert b"Content-Type: image/jpeg" in seen["body"]
|
||||||
|
|
||||||
|
|
||||||
class TestParseLayout:
|
class TestParseLayout:
|
||||||
async def test_full_chain_relative_url(self):
|
async def test_full_chain_relative_url(self):
|
||||||
t = _transport_for(
|
t = _transport_for(
|
||||||
@@ -369,6 +468,424 @@ class TestErrorTranslation:
|
|||||||
assert isinstance(ei.value.__cause__, httpx.TimeoutException)
|
assert isinstance(ei.value.__cause__, httpx.TimeoutException)
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(status, body):
|
||||||
|
request = httpx.Request("POST", "http://ocr.example/parse")
|
||||||
|
return httpx.HTTPStatusError(
|
||||||
|
str(status),
|
||||||
|
request=request,
|
||||||
|
response=httpx.Response(status, content=body.encode(), request=request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parsing_failed(reason):
|
||||||
|
# 真实形态(2026-10-21 实测): 服务把一切异常包成 500 + {"detail": "Parsing failed: …"}
|
||||||
|
return json.dumps({"detail": f"Parsing failed: {reason}"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestRejectedMarkers:
|
||||||
|
"""#34 设计 §6: 确定性坏输入的 500 在文档路径归「请求被拒」,其余分类一概不动。
|
||||||
|
|
||||||
|
共享的 `_classify_status` 默认行为字节级不变——既有调用方对坏图的重试/熔断行为属公共行为,
|
||||||
|
推广须另行设计(issue #35)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("marker", _DEFAULT_REJECTED_MARKERS)
|
||||||
|
def test_known_deterministic_failures_become_rejected_with_status_500(self, marker):
|
||||||
|
exc = _http_error(500, _parsing_failed(marker.capitalize()))
|
||||||
|
err = _classify_status(exc, "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS)
|
||||||
|
assert isinstance(err, RequestRejectedError)
|
||||||
|
assert err.status_code == 500 # 服务有响应: 熔断按「响应即健康」记成功
|
||||||
|
assert marker.capitalize() in err.body_text # 响应体摘要仍随该分支
|
||||||
|
|
||||||
|
def test_marker_matching_is_case_insensitive(self):
|
||||||
|
exc = _http_error(500, '{"detail": "PARSING FAILED: FAILED TO OPEN STREAM"}')
|
||||||
|
err = _classify_status(exc, "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS)
|
||||||
|
assert isinstance(err, RequestRejectedError)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"body",
|
||||||
|
[
|
||||||
|
pytest.param('{"detail": "Failed to open stream"}', id="no-parsing-failed-prefix"),
|
||||||
|
pytest.param(
|
||||||
|
_parsing_failed("500: Model not initialized"), id="real-fault-same-prefix"
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
_parsing_failed("400: Unsupported file type: .webp"), id="unsupported-type"
|
||||||
|
),
|
||||||
|
pytest.param(_parsing_failed("CUDA out of memory"), id="oom"),
|
||||||
|
pytest.param("", id="empty-body"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_everything_else_stays_transient(self, body):
|
||||||
|
"""`Parsing failed:` 前缀不能单独当判据: 服务把真故障也包成同一前缀。
|
||||||
|
`Unsupported file type` 有意不在白名单——嗅探后它只可能是契约变化或我方标签 bug。"""
|
||||||
|
err = _classify_status(
|
||||||
|
_http_error(500, body), "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS
|
||||||
|
)
|
||||||
|
assert isinstance(err, TransientError)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [_parsing_failed(m) for m in _DEFAULT_REJECTED_MARKERS])
|
||||||
|
def test_default_call_without_markers_is_unchanged(self, body):
|
||||||
|
err = _classify_status(_http_error(500, body), "monkey_1", "parse")
|
||||||
|
assert isinstance(err, TransientError)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "exc_type"),
|
||||||
|
[
|
||||||
|
(429, TransientError),
|
||||||
|
(502, TransientError),
|
||||||
|
(503, TransientError),
|
||||||
|
(401, SourceDeadError),
|
||||||
|
(403, SourceDeadError),
|
||||||
|
(404, RequestRejectedError),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_other_statuses_are_never_narrowed(self, status, exc_type):
|
||||||
|
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0])
|
||||||
|
err = _classify_status(_http_error(status, body), "m", "parse", _DEFAULT_REJECTED_MARKERS)
|
||||||
|
assert type(err) is exc_type
|
||||||
|
|
||||||
|
def test_translate_http_errors_passes_markers_through(self):
|
||||||
|
with (
|
||||||
|
pytest.raises(RequestRejectedError),
|
||||||
|
_translate_http_errors("monkey_1", "parse", rejected_markers=_DEFAULT_REJECTED_MARKERS),
|
||||||
|
):
|
||||||
|
raise _http_error(500, _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]))
|
||||||
|
# 默认不传: 行为不变
|
||||||
|
with pytest.raises(TransientError), _translate_http_errors("monkey_1", "parse"):
|
||||||
|
raise _http_error(500, _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]))
|
||||||
|
|
||||||
|
def test_markers_are_configurable_per_transport(self):
|
||||||
|
assert MonkeyOcrTransport()._rejected_markers == _DEFAULT_REJECTED_MARKERS
|
||||||
|
custom = ("synthetic marker",)
|
||||||
|
assert MonkeyOcrTransport(deterministic_failure_markers=custom)._rejected_markers == custom
|
||||||
|
|
||||||
|
async def test_layout_and_text_paths_do_not_apply_the_markers(self):
|
||||||
|
"""传输实例持有标记,但只有文档路径使用;既有的两个方法行为不变。"""
|
||||||
|
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]).encode()
|
||||||
|
t = _transport_for(
|
||||||
|
_routes(
|
||||||
|
text_resp=httpx.Response(500, content=body),
|
||||||
|
parse_resp=httpx.Response(500, content=body),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMalformedDownloadUrl:
|
||||||
|
"""`download_url` 来自服务端响应,属外部输入。畸形值(控制字符、坏方括号、越界端口)会让 httpx
|
||||||
|
抛 `InvalidURL`(不属 `HTTPError` 体系)或在连接阶段抛 ExceptionGroup,从 `_translate_http_errors`
|
||||||
|
下漏出四分类——`OcrClient._call` 只接 `PolyGatewayError`,于是不写终态遥测行、半开探针不归还。
|
||||||
|
(独立审查发现;`parse_layout` 的基线同样如此,共用的 `_post_parse` 一处修。)"""
|
||||||
|
|
||||||
|
_BAD_URLS = [
|
||||||
|
pytest.param("http://[::1", id="broken-bracket"),
|
||||||
|
pytest.param("/static/a\r\nb.zip", id="crlf"),
|
||||||
|
pytest.param("/static/a\x00.zip", id="nul"),
|
||||||
|
pytest.param("/static/a\tb.zip", id="tab"),
|
||||||
|
pytest.param("http://127.0.0.1:99999/x", id="port-out-of-range"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transport_with(download_url, seen):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append((request.method, request.url.path))
|
||||||
|
if request.url.path == "/parse":
|
||||||
|
return httpx.Response(200, json=_parse_body(download_url))
|
||||||
|
return httpx.Response(200, content=b"never fetched")
|
||||||
|
|
||||||
|
return _transport_for(handler)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("download_url", _BAD_URLS)
|
||||||
|
@pytest.mark.parametrize("method", ["parse_layout", "parse_document"])
|
||||||
|
async def test_rejected_as_transient_without_fetching(self, download_url, method):
|
||||||
|
seen = []
|
||||||
|
t = self._transport_with(download_url, seen)
|
||||||
|
kwargs = {"image": b"jpg"} if method == "parse_layout" else {"document": _PDF_BODY}
|
||||||
|
with pytest.raises(TransientError, match="download_url"):
|
||||||
|
await getattr(t, method)(**kwargs, source=_source(), call_id="c1")
|
||||||
|
assert seen == [("POST", "/parse")] # 从未发出第二段请求
|
||||||
|
|
||||||
|
async def test_well_formed_urls_are_untouched(self):
|
||||||
|
for url in ("/static/ok.zip", "http://10.77.0.20:7866/static/abs.zip"):
|
||||||
|
seen = []
|
||||||
|
t = self._transport_with(url, seen)
|
||||||
|
with pytest.raises(ResultInvalidError): # 响应体不是 ZIP: 说明确实走到了第二段
|
||||||
|
await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
|
||||||
|
assert [m for m, _ in seen] == ["POST", "GET"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDocument:
|
||||||
|
"""#34 设计 §5: 本地预检 → POST /parse → 流式 GET(体积上限)→ 线程内解析。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _doc_zip():
|
||||||
|
return _doc_package(
|
||||||
|
[_doc_page([_text_block("SYNTH-A"), _table_block("<table>SYNTH</table>")])]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _transport(self, seen=None, *, zip_resp=None, parse_resp=None):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if seen is not None:
|
||||||
|
seen.append((request.method, request.url.path, request.read()))
|
||||||
|
if request.url.path == "/parse":
|
||||||
|
return parse_resp or httpx.Response(200, json=_parse_body())
|
||||||
|
if request.url.path.startswith("/static/"):
|
||||||
|
return zip_resp or httpx.Response(200, content=self._doc_zip())
|
||||||
|
return httpx.Response(500)
|
||||||
|
|
||||||
|
return _transport_for(handler)
|
||||||
|
|
||||||
|
async def test_full_chain_relative_download_url(self):
|
||||||
|
t = self._transport()
|
||||||
|
result = await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert isinstance(result, OcrDocumentTransportResult)
|
||||||
|
assert [(e.type, e.text) for e in result.elements] == [
|
||||||
|
("text", "SYNTH-A"),
|
||||||
|
("table", "<table>SYNTH</table>"),
|
||||||
|
]
|
||||||
|
assert all(isinstance(e, OcrDocumentElement) for e in result.elements)
|
||||||
|
assert result.page_sizes == [(612.0, 792.0)]
|
||||||
|
assert result.markdown == "# synth\n"
|
||||||
|
assert result.raw == {"success": True, "download_url": "/static/sample_parsed_1.zip"}
|
||||||
|
|
||||||
|
async def test_absolute_download_url(self):
|
||||||
|
url = "http://10.77.0.20:7866/static/abs.zip"
|
||||||
|
t = self._transport(parse_resp=httpx.Response(200, json=_parse_body(url)))
|
||||||
|
result = await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert result.raw["download_url"] == url
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("document", "filename", "content_type"),
|
||||||
|
[
|
||||||
|
pytest.param(_PDF_BODY, b"doc.pdf", b"application/pdf", id="pdf"),
|
||||||
|
pytest.param(_JUNK + _PDF_BODY, b"doc.pdf", b"application/pdf", id="pdf-junk-prefix"),
|
||||||
|
pytest.param(b"\xff\xd8\xff\xe0abc", b"image.jpg", b"image/jpeg", id="jpeg"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_upload_label_matrix_for_the_document_entry(
|
||||||
|
self, document, filename, content_type
|
||||||
|
):
|
||||||
|
seen = []
|
||||||
|
await self._transport(seen).parse_document(
|
||||||
|
document=document, source=_source(), call_id="c1"
|
||||||
|
)
|
||||||
|
post_body = next(body for method, path, body in seen if path == "/parse")
|
||||||
|
assert b'filename="' + filename + b'"' in post_body
|
||||||
|
assert b"Content-Type: " + content_type in post_body
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"document",
|
||||||
|
[b"<html>paywall</html>", b"GARBAGE-NOT-AN-IMAGE", b""],
|
||||||
|
ids=["html", "garbage", "empty"],
|
||||||
|
)
|
||||||
|
async def test_unrecognizable_bytes_are_rejected_locally_without_any_request(self, document):
|
||||||
|
seen = []
|
||||||
|
with pytest.raises(RequestRejectedError) as ei:
|
||||||
|
await self._transport(seen).parse_document(
|
||||||
|
document=document, source=_source(), call_id="c1"
|
||||||
|
)
|
||||||
|
assert seen == [] # 服务从未被打扰
|
||||||
|
assert ei.value.status_code is None # 服务未响应: 熔断侧只归还探针,不计成败
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("marker", _DEFAULT_REJECTED_MARKERS)
|
||||||
|
async def test_known_deterministic_post_failures_are_rejected_with_status_500(self, marker):
|
||||||
|
body = _parsing_failed(marker).encode()
|
||||||
|
t = self._transport(parse_resp=httpx.Response(500, content=body))
|
||||||
|
with pytest.raises(RequestRejectedError) as ei:
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert ei.value.status_code == 500 and ei.value.body_text
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reason",
|
||||||
|
["CUDA out of memory", "400: Unsupported file type: .webp", "500: Model not initialized"],
|
||||||
|
)
|
||||||
|
async def test_unknown_post_500_stays_transient(self, reason):
|
||||||
|
t = self._transport(
|
||||||
|
parse_resp=httpx.Response(500, content=_parsing_failed(reason).encode())
|
||||||
|
)
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
|
||||||
|
async def test_success_false_is_rejected_with_status_200(self):
|
||||||
|
t = self._transport(
|
||||||
|
parse_resp=httpx.Response(200, json={"success": False, "message": "no"})
|
||||||
|
)
|
||||||
|
with pytest.raises(RequestRejectedError) as ei:
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert ei.value.status_code == 200
|
||||||
|
|
||||||
|
async def test_missing_download_url_and_non_json_are_transient(self):
|
||||||
|
body = {"success": True, "message": "ok"}
|
||||||
|
for resp in (httpx.Response(200, json=body), httpx.Response(200, content=b"not json")):
|
||||||
|
t = self._transport(parse_resp=resp)
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "exc_type"),
|
||||||
|
[
|
||||||
|
(401, SourceDeadError),
|
||||||
|
(429, TransientError),
|
||||||
|
(500, TransientError),
|
||||||
|
(404, RequestRejectedError),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_download_errors_keep_their_class_and_body(self, status, exc_type):
|
||||||
|
"""GET 阶段不适用确定性窄化,且响应体摘要随每个分支(issue #10)。"""
|
||||||
|
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0])
|
||||||
|
t = self._transport(zip_resp=httpx.Response(status, content=body.encode()))
|
||||||
|
with pytest.raises(exc_type) as ei:
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert ei.value.status_code == status and ei.value.body_text == body
|
||||||
|
|
||||||
|
async def test_huge_error_body_is_read_only_up_to_a_bound(self):
|
||||||
|
"""摘要只需要几 KiB;服务(或中间人)回一个巨大的错误体不该被整个读进内存。
|
||||||
|
|
||||||
|
(独立审查实测:600 MiB 的 500 响应让进程 RSS 涨到 1.8 GiB——新代码声称"流式 + 体积上限",
|
||||||
|
错误分支却是例外。)
|
||||||
|
"""
|
||||||
|
consumed = []
|
||||||
|
|
||||||
|
async def endless_error_body():
|
||||||
|
for _ in range(100_000):
|
||||||
|
consumed.append(1)
|
||||||
|
yield b"e" * 1024
|
||||||
|
|
||||||
|
t = self._transport(zip_resp=httpx.Response(500, content=endless_error_body()))
|
||||||
|
with pytest.raises(TransientError) as ei:
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert ei.value.status_code == 500
|
||||||
|
assert len(consumed) <= monkey_ocr._ERROR_BODY_READ_CAP // 1024 + 2 # 只读了上限那么多
|
||||||
|
assert ei.value.body_text.startswith("e" * 100) # 摘要仍在
|
||||||
|
|
||||||
|
async def test_error_status_with_oversized_content_length_is_still_classified_by_http(
|
||||||
|
self, monkeypatch
|
||||||
|
):
|
||||||
|
"""结果包资源检查不得改变错误分类: 超限的 Content-Length 出现在 401 上,仍是 SourceDead。"""
|
||||||
|
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 100)
|
||||||
|
resp = httpx.Response(401, content=b"denied", headers={"content-length": "101"})
|
||||||
|
with pytest.raises(SourceDeadError):
|
||||||
|
await self._transport(zip_resp=resp).parse_document(
|
||||||
|
document=_PDF_BODY, source=_source(), call_id="c1"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _oversized_valid_package():
|
||||||
|
"""**合法**的 ZIP 但整体超过上限: 去掉上限检查时它能被正常解析,用例才会转红。
|
||||||
|
|
||||||
|
(独立验证发现: 旧用例用非 ZIP 垃圾字节,去掉上限后同样抛 ResultInvalidError——
|
||||||
|
走的是 BadZipFile 路径,上限从未被测到。)
|
||||||
|
"""
|
||||||
|
return _doc_package(
|
||||||
|
[_doc_page([_text_block("SYNTH-A")])], extra=[("images/big.jpg", b"x" * 5000)]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _assert_cap_rejects_before_parsing(self, monkeypatch, resp):
|
||||||
|
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 2000)
|
||||||
|
parsed = []
|
||||||
|
|
||||||
|
def spy(package):
|
||||||
|
parsed.append(len(package))
|
||||||
|
return [], [(1.0, 1.0)], ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(monkey_ocr, "parse_document_package", spy)
|
||||||
|
with pytest.raises(ResultInvalidError, match="体积超过上限"):
|
||||||
|
await self._transport(zip_resp=resp).parse_document(
|
||||||
|
document=_PDF_BODY, source=_source(), call_id="c1"
|
||||||
|
)
|
||||||
|
assert parsed == [] # 解析函数根本没被调用
|
||||||
|
|
||||||
|
async def test_oversized_valid_package_is_rejected_by_declared_content_length(
|
||||||
|
self, monkeypatch
|
||||||
|
):
|
||||||
|
resp = httpx.Response(200, content=self._oversized_valid_package()) # 自动带 Content-Length
|
||||||
|
await self._assert_cap_rejects_before_parsing(monkeypatch, resp)
|
||||||
|
|
||||||
|
async def test_oversized_valid_package_is_rejected_by_cumulative_count(self, monkeypatch):
|
||||||
|
"""没有 Content-Length(分块传输)时靠累计字节兜底。"""
|
||||||
|
package = self._oversized_valid_package()
|
||||||
|
|
||||||
|
async def chunks():
|
||||||
|
for offset in range(0, len(package), 700):
|
||||||
|
yield package[offset : offset + 700]
|
||||||
|
|
||||||
|
await self._assert_cap_rejects_before_parsing(
|
||||||
|
monkeypatch, httpx.Response(200, content=chunks())
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_package_under_the_cap_still_parses(self, monkeypatch):
|
||||||
|
"""对照: 上限只拦超限的,不误伤正常包。"""
|
||||||
|
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 10_000)
|
||||||
|
result = await self._transport(
|
||||||
|
zip_resp=httpx.Response(200, content=self._oversized_valid_package())
|
||||||
|
).parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert [e.text for e in result.elements] == ["SYNTH-A"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "exc_type"),
|
||||||
|
[
|
||||||
|
(401, SourceDeadError),
|
||||||
|
(404, RequestRejectedError),
|
||||||
|
(500, TransientError),
|
||||||
|
(302, RequestRejectedError), # 3xx 不是 `is_error`,但 raise_for_status 会抛
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_streamed_error_body_is_read_before_classification(self, status, exc_type):
|
||||||
|
"""真正的流式响应(异步迭代器 body)不会被预缓冲: 不先 `aread()`,摘要读到的是空串。
|
||||||
|
|
||||||
|
(独立验证发现: `httpx.Response(content=bytes)` 已预缓冲,删掉 `aread()` 后旧用例仍绿;
|
||||||
|
且旧条件 `is_error` 漏掉 3xx。)
|
||||||
|
"""
|
||||||
|
body = '{"detail": "synthetic upstream reason"}'
|
||||||
|
|
||||||
|
async def streamed():
|
||||||
|
yield body.encode()
|
||||||
|
|
||||||
|
t = self._transport(zip_resp=httpx.Response(status, content=streamed()))
|
||||||
|
with pytest.raises(exc_type) as ei:
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert ei.value.status_code == status
|
||||||
|
assert ei.value.body_text == body
|
||||||
|
|
||||||
|
async def test_bad_package_is_result_invalid(self):
|
||||||
|
t = self._transport(zip_resp=httpx.Response(200, content=b"not a zip"))
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
|
||||||
|
async def test_cancellation_during_parse_does_not_block_the_event_loop(self, monkeypatch):
|
||||||
|
"""解析在线程里跑: 同步解析大对象会占住事件循环,取消与 call_deadline_s 都会失效。
|
||||||
|
|
||||||
|
线程内用 threading.Event 阻塞,并用 call_soon_threadsafe 通知(线程里不能直接用
|
||||||
|
asyncio.Event)。若实现退回同步解析,桩会把循环占满 1 秒后正常返回,
|
||||||
|
`task.cancel()` 就取不到 CancelledError——用例转红。
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
entered = asyncio.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
|
def blocking_parse(zip_bytes):
|
||||||
|
loop.call_soon_threadsafe(entered.set)
|
||||||
|
release.wait(timeout=1)
|
||||||
|
return [], [(1.0, 1.0)], ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(monkey_ocr, "parse_document_package", blocking_parse)
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._transport().parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(entered.wait(), timeout=2)
|
||||||
|
await asyncio.sleep(0.01) # 线程还卡着,循环必须仍能调度别的协程
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
finally:
|
||||||
|
release.set() # 让线程收尾,不泄漏
|
||||||
|
|
||||||
|
|
||||||
class TestCheckHealth:
|
class TestCheckHealth:
|
||||||
async def test_healthy(self):
|
async def test_healthy(self):
|
||||||
t = _transport_for(_routes(health_resp=httpx.Response(200, json={"status": "healthy"})))
|
t = _transport_for(_routes(health_resp=httpx.Response(200, json={"status": "healthy"})))
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ retry_exhausted/circuit_open/stalled 三组断言即设计 §6 ③ 的 G1 契约
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -21,20 +23,34 @@ from polygateway.errors import (
|
|||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
from polygateway.ocr import OcrClient
|
from polygateway.ocr import OcrClient
|
||||||
|
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
|
OcrDocumentElement,
|
||||||
|
OcrDocumentTransportResult,
|
||||||
OcrLayoutElement,
|
OcrLayoutElement,
|
||||||
OcrLayoutTransportResult,
|
OcrLayoutTransportResult,
|
||||||
OcrTextTransportResult,
|
OcrTextTransportResult,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
from tests.unit import _document_fixtures as fx
|
||||||
|
|
||||||
_BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
|
_BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
|
||||||
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
|
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
|
||||||
_TEXT_OK = OcrTextTransportResult(text="LINE-1", raw={"task_type": "text"})
|
_TEXT_OK = OcrTextTransportResult(text="LINE-1", raw={"task_type": "text"})
|
||||||
|
_DOC_OK = OcrDocumentTransportResult(
|
||||||
|
elements=[
|
||||||
|
OcrDocumentElement(
|
||||||
|
type="text", bbox=(1.0, 2.0, 3.0, 4.0), page_index=0, text="SYNTH-DOC-BODY"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
page_sizes=[(612.0, 792.0)],
|
||||||
|
markdown="# SYNTH-MD",
|
||||||
|
raw={"success": True},
|
||||||
|
)
|
||||||
_LAYOUT_OK = OcrLayoutTransportResult(
|
_LAYOUT_OK = OcrLayoutTransportResult(
|
||||||
elements=[OcrLayoutElement(type="table", bbox=(41.0, 48.0, 218.0, 282.0), page_index=0)],
|
elements=[OcrLayoutElement(type="table", bbox=(41.0, 48.0, 218.0, 282.0), page_index=0)],
|
||||||
page_sizes=[(759.0, 540.0)],
|
page_sizes=[(759.0, 540.0)],
|
||||||
@@ -72,7 +88,11 @@ class ScriptedOcrTransport:
|
|||||||
if action == "hang":
|
if action == "hang":
|
||||||
self.entered.set()
|
self.entered.set()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
return _TEXT_OK if action == "text" else _LAYOUT_OK
|
if action == "text":
|
||||||
|
return _TEXT_OK
|
||||||
|
if action == "document":
|
||||||
|
return _DOC_OK
|
||||||
|
return _LAYOUT_OK
|
||||||
|
|
||||||
async def recognize_text(self, *, image, source, call_id):
|
async def recognize_text(self, *, image, source, call_id):
|
||||||
return await self._next("text", source, call_id)
|
return await self._next("text", source, call_id)
|
||||||
@@ -80,6 +100,9 @@ class ScriptedOcrTransport:
|
|||||||
async def parse_layout(self, *, image, source, call_id):
|
async def parse_layout(self, *, image, source, call_id):
|
||||||
return await self._next("layout", source, call_id)
|
return await self._next("layout", source, call_id)
|
||||||
|
|
||||||
|
async def parse_document(self, *, document, source, call_id):
|
||||||
|
return await self._next("document", source, call_id)
|
||||||
|
|
||||||
async def check_health(self, *, source):
|
async def check_health(self, *, source):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -548,7 +571,7 @@ class TestOcrCallerDimensions:
|
|||||||
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
|
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
|
||||||
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
|
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout", "parse_document"])
|
||||||
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
|
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
|
||||||
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
|
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
|
||||||
recorder = _MemoryRecorder()
|
recorder = _MemoryRecorder()
|
||||||
@@ -558,6 +581,14 @@ class TestOcrCallerDimensions:
|
|||||||
assert recorder.rows == []
|
assert recorder.rows == []
|
||||||
assert client._transport.calls == [] # 连调用都没发出
|
assert client._transport.calls == [] # 连调用都没发出
|
||||||
|
|
||||||
|
async def test_parse_document_row_carries_dimensions(self):
|
||||||
|
"""第三个入口: 只测前两个会漏掉它的透传缺口。"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, _ = _client([_src()], ["document"], telemetry=recorder)
|
||||||
|
await client.parse_document(b"%PDF-1.4", tenant_id="t3", meta={"batch": "b-44"})
|
||||||
|
assert recorder.rows[0]["tenant_id"] == "t3"
|
||||||
|
assert recorder.rows[0]["meta"] == '{"batch": "b-44"}'
|
||||||
|
|
||||||
async def test_defaults_land_as_sentinels(self):
|
async def test_defaults_land_as_sentinels(self):
|
||||||
recorder = _MemoryRecorder()
|
recorder = _MemoryRecorder()
|
||||||
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
||||||
@@ -566,6 +597,408 @@ class TestOcrCallerDimensions:
|
|||||||
assert recorder.rows[0]["meta"] == "{}"
|
assert recorder.rows[0]["meta"] == "{}"
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyOcrTransport:
|
||||||
|
"""只实现旧 `OcrTransport` 三方法的下游 fake: 不满足 `OcrDocumentTransport`。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def recognize_text(self, *, image, source, call_id):
|
||||||
|
self.calls.append("text")
|
||||||
|
return _TEXT_OK
|
||||||
|
|
||||||
|
async def parse_layout(self, *, image, source, call_id):
|
||||||
|
self.calls.append("layout")
|
||||||
|
return _LAYOUT_OK
|
||||||
|
|
||||||
|
async def check_health(self, *, source):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDocument:
|
||||||
|
"""#34 设计 §4.2/§6: 与 parse_layout 对称的整份文档入口,治理循环原样复用。"""
|
||||||
|
|
||||||
|
async def test_result_fields(self):
|
||||||
|
client, _, gate = _client([_src()], ["document"])
|
||||||
|
r = await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert [e.text for e in r.elements] == ["SYNTH-DOC-BODY"]
|
||||||
|
assert r.markdown == "# SYNTH-MD" and r.page_sizes == [(612.0, 792.0)]
|
||||||
|
assert r.source_name == "m1"
|
||||||
|
assert r.usage.prompt_tokens == 0 and r.usage.completion_tokens == 0
|
||||||
|
assert r.latency_ms >= 0 and r.call_id and r.raw == {"success": True}
|
||||||
|
assert r.call_stats is not None and r.call_stats.attempts == 1
|
||||||
|
assert gate.successes == [("m1", True)]
|
||||||
|
assert client._selector.outcomes == [("m1", True)]
|
||||||
|
|
||||||
|
async def test_input_validation_names_the_document(self):
|
||||||
|
client, _, _ = _client([_src()], [])
|
||||||
|
with pytest.raises(TypeError, match="document 必须是 bytes"):
|
||||||
|
await client.parse_document("not-bytes")
|
||||||
|
with pytest.raises(ValueError, match="document 不能为空"):
|
||||||
|
await client.parse_document(b"")
|
||||||
|
|
||||||
|
async def test_legacy_entrances_keep_their_error_text(self):
|
||||||
|
"""私有形参改名为 payload 不得改变旧入口的报错文案(含 D9 的业务边界提示)。"""
|
||||||
|
client, _, _ = _client([_src()], [])
|
||||||
|
with pytest.raises(TypeError, match=r"image 必须是 bytes\(路径读取/批量拼帧留业务侧,D9\)"):
|
||||||
|
await client.recognize_text("not-bytes")
|
||||||
|
with pytest.raises(ValueError, match="image 不能为空"):
|
||||||
|
await client.parse_layout(b"")
|
||||||
|
|
||||||
|
async def test_transport_without_document_support_is_an_assembly_error(self):
|
||||||
|
"""装配错误当场报,且早于统计边界: 没有遥测行、没有任何 transport 调用。"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
legacy = _LegacyOcrTransport()
|
||||||
|
client, limiter, _ = _client([_src(rpm=100)], [], transport=legacy, telemetry=recorder)
|
||||||
|
with pytest.raises(TypeError, match="OcrDocumentTransport"):
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert recorder.rows == [] and legacy.calls == []
|
||||||
|
# 早于准入: 从未向限流后端申请过许可(否则 rpm 已被占用一次)
|
||||||
|
assert (await limiter.source_stats("m1")).rpm_used == 0
|
||||||
|
|
||||||
|
async def test_invalid_call_deadline_is_rejected_like_the_other_entrances(self):
|
||||||
|
client, _, _ = _client([_src()], ["document"])
|
||||||
|
with pytest.raises(ValueError, match="call_deadline_s"):
|
||||||
|
await client.parse_document(b"%PDF-1.4", call_deadline_s=-1)
|
||||||
|
|
||||||
|
async def test_transient_retries_then_succeeds(self):
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src()],
|
||||||
|
[TransientError("boom", status_code=500), "document"],
|
||||||
|
sleep=lambda _d: asyncio.sleep(0),
|
||||||
|
)
|
||||||
|
r = await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert r.markdown == "# SYNTH-MD"
|
||||||
|
assert client._selector.outcomes == [("m1", False), ("m1", True)]
|
||||||
|
|
||||||
|
async def test_source_dead_switches_to_the_second_source_immediately(self):
|
||||||
|
s1, s2 = _src(name="m1"), _src(name="m2", base_url="http://10.77.0.20:7867")
|
||||||
|
client, _, _ = _client(
|
||||||
|
[s1, s2], [SourceDeadError("401", status_code=401), "document"], sleep=_no_sleep
|
||||||
|
)
|
||||||
|
r = await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert r.source_name == "m2"
|
||||||
|
assert client._selector.outcomes == [("m1", False), ("m2", True)]
|
||||||
|
|
||||||
|
async def test_result_invalid_counts_no_attempt_and_no_feed(self):
|
||||||
|
client, _, gate = _client([_src()], [ResultInvalidError("bad package")])
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert gate.successes == [("m1", False)]
|
||||||
|
assert client._selector.outcomes == []
|
||||||
|
|
||||||
|
async def test_deterministic_rejection_with_status_500_counts_as_a_live_service(self):
|
||||||
|
"""服务有响应(500 + 确定性坏输入标记)= 服务活着: 记成功不入窗口,不喂选源器。"""
|
||||||
|
client, _, gate = _client(
|
||||||
|
[_src()], [RequestRejectedError("Parsing failed: is no PDF", status_code=500)]
|
||||||
|
)
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert gate.successes == [("m1", False)]
|
||||||
|
assert client._selector.outcomes == []
|
||||||
|
|
||||||
|
async def test_local_rejection_does_not_touch_the_breaker(self):
|
||||||
|
client, _, gate = _client([_src()], [RequestRejectedError("unrecognizable")])
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert gate.successes == []
|
||||||
|
|
||||||
|
async def test_rate_limited_responses_spend_retry_budget_but_not_breaker_failures(self):
|
||||||
|
"""429 是背压不是故障(熔断两通道均不计): 三次 429 耗尽重试后,熔断仍是 CLOSED。"""
|
||||||
|
client, _, gate = _client(
|
||||||
|
[_src()],
|
||||||
|
[TransientError("busy", status_code=429)] * 3,
|
||||||
|
sleep=lambda _d: asyncio.sleep(0),
|
||||||
|
)
|
||||||
|
with pytest.raises(AllSourcesExhausted):
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
entry = await gate._inner.try_enter("m1", "check")
|
||||||
|
assert entry.allowed and not entry.is_probe
|
||||||
|
|
||||||
|
async def test_read_timeouts_are_counted_as_source_failures(self):
|
||||||
|
"""对照: 文档路径的 httpx 超时仍是瞬时故障、计熔断(`timeout_s` 不是免责额度)。"""
|
||||||
|
|
||||||
|
def timeout_exc():
|
||||||
|
exc = TransientError("timed out", operation="parse")
|
||||||
|
exc.__cause__ = httpx.ReadTimeout("read timed out")
|
||||||
|
return exc
|
||||||
|
|
||||||
|
client, _, gate = _client(
|
||||||
|
[_src()], [timeout_exc() for _ in range(3)], sleep=lambda _d: asyncio.sleep(0)
|
||||||
|
)
|
||||||
|
with pytest.raises(AllSourcesExhausted) as ei:
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert ei.value.per_source_reasons == {"m1": "timeout"}
|
||||||
|
entry = await gate._inner.try_enter("m1", "check")
|
||||||
|
assert not entry.allowed # 阈值 3 的熔断已开路
|
||||||
|
|
||||||
|
async def test_cancel_releases_permit_and_probe(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
inner = InMemoryGate(config=_BREAKER, now=clock)
|
||||||
|
gate = RecordingGate(inner)
|
||||||
|
transport = ScriptedOcrTransport(["hang"])
|
||||||
|
client, limiter, _ = _client(
|
||||||
|
[_src(max_concurrency=1)], [], now=clock, breaker=gate, transport=transport
|
||||||
|
)
|
||||||
|
entry = await inner.try_enter("m1", "setup")
|
||||||
|
await inner.record_failure(entry, "source_dead", True)
|
||||||
|
clock.advance(61.0) # 越过冷却 → 半开探针
|
||||||
|
task = asyncio.create_task(client.parse_document(b"%PDF-1.4"))
|
||||||
|
await transport.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert gate.probe_releases >= 1
|
||||||
|
assert (await limiter.source_stats("m1")).inflight == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _no_sleep(_delay):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoLatencyJudgement:
|
||||||
|
"""长文档不会被判慢(设计 §7): 库内没有任何基于延迟的熔断/选源判定。
|
||||||
|
|
||||||
|
时钟只在 transport 内前进(`ClockAdvancingOcrTransport`),门与 client 共用同一注入钟。
|
||||||
|
probe_ttl_s 取极大值,避免把探针租约接管混进"慢"的断言。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=1e12)
|
||||||
|
|
||||||
|
async def test_slow_success_leaves_breaker_closed_with_no_failures(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
gate = RecordingGate(InMemoryGate(config=self._CFG, now=clock))
|
||||||
|
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src(timeout_s=120.0)], [], now=clock, breaker=gate, transport=transport
|
||||||
|
)
|
||||||
|
r = await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert r.call_stats.attempts == 1 and r.call_stats.generation_ms >= 100_000_000
|
||||||
|
assert gate._inner._gates["m1"].fails == 0
|
||||||
|
assert gate.successes == [("m1", True)]
|
||||||
|
|
||||||
|
async def test_slow_success_clears_pre_existing_failure_count(self):
|
||||||
|
"""前置状态不是初始 CLOSED: 已有 2 次失败(阈值 3),一次很慢的成功也只会清零。"""
|
||||||
|
clock = FakeClock()
|
||||||
|
inner = InMemoryGate(config=self._CFG, now=clock)
|
||||||
|
for _ in range(2):
|
||||||
|
entry = await inner.try_enter("m1", "setup")
|
||||||
|
await inner.record_failure(entry, "network_error", False)
|
||||||
|
assert inner._gates["m1"].fails == 2
|
||||||
|
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src()], [], now=clock, breaker=RecordingGate(inner), transport=transport
|
||||||
|
)
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert inner._gates["m1"].fails == 0
|
||||||
|
|
||||||
|
async def test_slow_success_on_the_half_open_probe_closes_the_breaker(self):
|
||||||
|
clock = FakeClock()
|
||||||
|
inner = InMemoryGate(config=self._CFG, now=clock)
|
||||||
|
entry = await inner.try_enter("m1", "setup")
|
||||||
|
await inner.record_failure(entry, "source_dead", True) # OPEN
|
||||||
|
clock.advance(61.0) # → HALF_OPEN
|
||||||
|
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src()], [], now=clock, breaker=RecordingGate(inner), transport=transport
|
||||||
|
)
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
after = await inner.try_enter("m1", "check")
|
||||||
|
assert after.allowed and not after.is_probe # 探针成功 → CLOSED,而不是被慢耗尽
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentTelemetry:
|
||||||
|
async def test_success_row(self):
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, _ = _client([_src()], ["document"], telemetry=recorder)
|
||||||
|
await client.parse_document(b"%PDF-RAW-BYTES")
|
||||||
|
(row,) = recorder.rows
|
||||||
|
assert row["operation"] == "parse_document"
|
||||||
|
assert "<ocr:document image_bytes=14>" in row["messages"]
|
||||||
|
assert "RAW-BYTES" not in row["messages"] # 文档字节绝不入库
|
||||||
|
assert row["response"] == "<document elements=1 pages=1 md_chars=10>"
|
||||||
|
assert "SYNTH-MD" not in row["response"] and "SYNTH-DOC-BODY" not in row["response"]
|
||||||
|
assert row["reasoning_effort"] is None # `reasoning_applies=False` 的落库形态
|
||||||
|
assert row["error"] is None and row["prompt_tokens"] == 0
|
||||||
|
|
||||||
|
async def test_summary_distinguishes_the_three_result_kinds(self):
|
||||||
|
"""新结果类型必须在 `_summarize` 里显式区分,否则会被静默套成 layout 摘要而漏记 md_chars。"""
|
||||||
|
assert OcrClient._summarize(_DOC_OK) == "<document elements=1 pages=1 md_chars=10>"
|
||||||
|
assert OcrClient._summarize(_LAYOUT_OK) == "<elements n=1 pages=1>"
|
||||||
|
assert OcrClient._summarize(_TEXT_OK) == "LINE-1"
|
||||||
|
|
||||||
|
def test_summary_keeps_structural_dispatch_for_duck_typed_results(self):
|
||||||
|
"""旧实现按结构分派(`hasattr(result, "text")`)。下游测试替身可能返回非库类型的鸭子对象,
|
||||||
|
改成 isinstance 会让它们在开启遥测后以原生 AttributeError 漏出——无登记的回归。"""
|
||||||
|
assert OcrClient._summarize(SimpleNamespace(text="hello", raw={})) == "hello"
|
||||||
|
assert (
|
||||||
|
OcrClient._summarize(SimpleNamespace(elements=[1, 2], page_sizes=[(1.0, 1.0)]))
|
||||||
|
== "<elements n=2 pages=1>"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
OcrClient._summarize(
|
||||||
|
SimpleNamespace(elements=[1], page_sizes=[(1.0, 1.0)], markdown="abc")
|
||||||
|
)
|
||||||
|
== "<document elements=1 pages=1 md_chars=3>"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_failed_attempt_row_carries_the_class_prefix(self):
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, _ = _client([_src()], [ResultInvalidError("bad package")], telemetry=recorder)
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert any(r["error"].startswith("ResultInvalidError:") for r in recorder.rows)
|
||||||
|
assert {r["operation"] for r in recorder.rows} == {"parse_document"}
|
||||||
|
|
||||||
|
|
||||||
|
def _real_transport(handler):
|
||||||
|
return MonkeyOcrTransport(
|
||||||
|
client_factory=lambda src: httpx.AsyncClient(
|
||||||
|
base_url=src.base_url, transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_handler(package: bytes | None = None, *, download=None):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == "/parse":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"success": True, "message": "ok", "download_url": "/static/doc.zip"},
|
||||||
|
)
|
||||||
|
if download is not None:
|
||||||
|
return download()
|
||||||
|
return httpx.Response(200, content=package)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentThroughTheRealTransport:
|
||||||
|
"""真实 `MonkeyOcrTransport` + `MockTransport` + 真实 `OcrClient`: 证明异常/期限贯穿整条链。
|
||||||
|
|
||||||
|
MockTransport 不执行 httpcore 的逐次读超时,所以本组只证明**治理期限与异常分类**,
|
||||||
|
不证明 read timeout 的真实行为(后者留给活体用例)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_happy_path_end_to_end(self):
|
||||||
|
package = fx._package([fx._page([fx._text_block("SYNTH-E2E")])])
|
||||||
|
client, _, _ = _client([_src()], [], transport=_real_transport(_doc_handler(package)))
|
||||||
|
r = await client.parse_document(b"%PDF-1.4 synthetic")
|
||||||
|
assert [e.text for e in r.elements] == ["SYNTH-E2E"]
|
||||||
|
assert r.markdown == "# synth\n"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("package_kind", ["huge-int", "encrypted"])
|
||||||
|
async def test_parser_exceptions_become_result_invalid_with_full_bookkeeping(
|
||||||
|
self, package_kind
|
||||||
|
):
|
||||||
|
"""真实解析器抛出的异常(OverflowError / RuntimeError)经真实客户端: 终态遥测行已写、
|
||||||
|
半开探针已归还、熔断窗口不变——不能只用桩直接抛预制的 `ResultInvalidError`。"""
|
||||||
|
if package_kind == "huge-int":
|
||||||
|
block = fx._text_block()
|
||||||
|
block["bbox"] = [0, 0, 10**400, 1]
|
||||||
|
package = fx._package([fx._page([block])])
|
||||||
|
else:
|
||||||
|
package = fx._encrypted_package()
|
||||||
|
clock = FakeClock()
|
||||||
|
inner = InMemoryGate(
|
||||||
|
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0), now=clock
|
||||||
|
)
|
||||||
|
entry = await inner.try_enter("m1", "setup")
|
||||||
|
await inner.record_failure(entry, "source_dead", True)
|
||||||
|
clock.advance(61.0) # 半开: 本次调用持有探针
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, gate = _client(
|
||||||
|
[_src()],
|
||||||
|
[],
|
||||||
|
now=clock,
|
||||||
|
breaker=RecordingGate(inner),
|
||||||
|
telemetry=recorder,
|
||||||
|
transport=_real_transport(_doc_handler(package)),
|
||||||
|
)
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
await client.parse_document(b"%PDF-1.4 synthetic")
|
||||||
|
assert any(r["error"].startswith("ResultInvalidError:") for r in recorder.rows)
|
||||||
|
after = await inner.try_enter("m1", "check")
|
||||||
|
assert after.allowed and not after.is_probe # 探针已按"坏结果≠坏服务"结算,没有悬挂
|
||||||
|
assert inner._gates["m1"].fails == 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _slow_download_handler():
|
||||||
|
async def slow_body():
|
||||||
|
for _ in range(200):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield b"x" * 8
|
||||||
|
|
||||||
|
return _doc_handler(download=lambda: httpx.Response(200, content=slow_body()))
|
||||||
|
|
||||||
|
async def test_per_call_deadline_overrides_the_assembly_value(self):
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src(timeout_s=120.0)], [], transport=_real_transport(self._slow_download_handler())
|
||||||
|
) # 装配值未设期限
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.parse_document(b"%PDF-1.4 synthetic", call_deadline_s=0.1)
|
||||||
|
|
||||||
|
async def test_deadline_while_holding_the_half_open_probe_returns_it_and_writes_a_terminal_row(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
"""期限到期取消在飞请求时: 探针必须归还,且终态遥测行必须写出(遥测必录)。"""
|
||||||
|
clock = FakeClock()
|
||||||
|
inner = InMemoryGate(
|
||||||
|
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0), now=clock
|
||||||
|
)
|
||||||
|
entry = await inner.try_enter("m1", "setup")
|
||||||
|
await inner.record_failure(entry, "source_dead", True)
|
||||||
|
clock.advance(61.0) # 半开: 本次调用持有探针
|
||||||
|
gate = RecordingGate(inner)
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src(timeout_s=120.0)],
|
||||||
|
[],
|
||||||
|
now=clock,
|
||||||
|
breaker=gate,
|
||||||
|
telemetry=recorder,
|
||||||
|
transport=_real_transport(self._slow_download_handler()),
|
||||||
|
call_deadline_s=0.1,
|
||||||
|
)
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.parse_document(b"%PDF-1.4 synthetic")
|
||||||
|
assert gate.probe_releases >= 1
|
||||||
|
again = await inner.try_enter("m1", "check")
|
||||||
|
assert again.allowed and again.is_probe # 探针没有悬挂,下一位调用方可以再探
|
||||||
|
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
|
||||||
|
assert len(terminals) == 1 and terminals[0]["operation"] == "parse_document"
|
||||||
|
|
||||||
|
async def test_sustained_slow_download_hits_the_call_deadline_not_a_transient_error(self):
|
||||||
|
"""每个分块都在 read 超时内、总时长超过 call_deadline_s: 是期限,不是瞬时故障。"""
|
||||||
|
closed = []
|
||||||
|
produced = []
|
||||||
|
|
||||||
|
async def slow_body():
|
||||||
|
try:
|
||||||
|
for _ in range(200):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
produced.append(1)
|
||||||
|
yield b"x" * 8
|
||||||
|
finally:
|
||||||
|
closed.append(True)
|
||||||
|
|
||||||
|
handler = _doc_handler(download=lambda: httpx.Response(200, content=slow_body()))
|
||||||
|
client, limiter, gate = _client(
|
||||||
|
[_src(max_concurrency=1, timeout_s=120.0)],
|
||||||
|
[],
|
||||||
|
transport=_real_transport(handler),
|
||||||
|
call_deadline_s=0.1,
|
||||||
|
)
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.parse_document(b"%PDF-1.4 synthetic")
|
||||||
|
assert len(produced) >= 2 # 确实进入了多块下载,而不是一开始就超时
|
||||||
|
assert closed == [True] # 流已关闭
|
||||||
|
assert (await limiter.source_stats("m1")).inflight == 0 # permit 释放
|
||||||
|
assert gate.successes == [] # 期限不是服务故障也不是成功
|
||||||
|
entry = await gate._inner.try_enter("m1", "check")
|
||||||
|
assert entry.allowed and not entry.is_probe # 熔断窗口不变
|
||||||
|
|
||||||
|
|
||||||
class TestAssembly:
|
class TestAssembly:
|
||||||
_ENV = {
|
_ENV = {
|
||||||
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
||||||
@@ -600,10 +1033,14 @@ class TestAssembly:
|
|||||||
|
|
||||||
|
|
||||||
class TestReasonlessTelemetryContract:
|
class TestReasonlessTelemetryContract:
|
||||||
"""text/layout 两个入口分别验证错误行不受源级推理配置污染。"""
|
"""text/layout/document 三个入口分别验证错误行不受源级推理配置污染。
|
||||||
|
|
||||||
|
独立验证发现文档入口原先不在矩阵里: 让 OCR 一律按 `reasoning_applies=True` 记账,
|
||||||
|
15 个旧用例转红,但没有一个是 `parse_document` 的。"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"method,action", [("recognize_text", "text"), ("parse_layout", "layout")]
|
"method,action",
|
||||||
|
[("recognize_text", "text"), ("parse_layout", "layout"), ("parse_document", "document")],
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
|
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
|
||||||
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
|
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
|
||||||
@@ -636,7 +1073,7 @@ class TestReasonlessTelemetryContract:
|
|||||||
if backend == "sqlite":
|
if backend == "sqlite":
|
||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout", "parse_document"])
|
||||||
@pytest.mark.parametrize("exhausted", [False, True])
|
@pytest.mark.parametrize("exhausted", [False, True])
|
||||||
async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
|
async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
|
||||||
script = (
|
script = (
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ async def _complete(
|
|||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
first_token_event=None,
|
first_token_event=None,
|
||||||
content_event=None,
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
):
|
):
|
||||||
return await transport.complete(
|
return await transport.complete(
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
@@ -100,9 +101,19 @@ async def _complete(
|
|||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
first_token_event=first_token_event,
|
first_token_event=first_token_event,
|
||||||
content_event=content_event,
|
content_event=content_event,
|
||||||
|
response_mode=response_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_helper_forwards_response_mode():
|
||||||
|
"""测试封装不得将显式保真模式静默退化成文本模式。"""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
transport = AsyncMock()
|
||||||
|
await _complete(transport, _source(), response_mode="openai")
|
||||||
|
assert transport.complete.call_args.kwargs["response_mode"] == "openai"
|
||||||
|
|
||||||
|
|
||||||
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
|
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
|
||||||
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
|
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
|
||||||
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
|
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
|
||||||
@@ -161,6 +172,8 @@ class TestSsePureFunctions:
|
|||||||
assert _sse_data_payload("data: [DONE]") == "[DONE]"
|
assert _sse_data_payload("data: [DONE]") == "[DONE]"
|
||||||
|
|
||||||
async def test_iter_deltas_yields_and_flags_done(self):
|
async def test_iter_deltas_yields_and_flags_done(self):
|
||||||
|
from polygateway.transports._openai_completion import ChatCompletionAccumulator, ChatDelta
|
||||||
|
|
||||||
async def lines():
|
async def lines():
|
||||||
for raw in [
|
for raw in [
|
||||||
_chunk(content="he"),
|
_chunk(content="he"),
|
||||||
@@ -173,16 +186,28 @@ class TestSsePureFunctions:
|
|||||||
yield line
|
yield line
|
||||||
|
|
||||||
sink = {}
|
sink = {}
|
||||||
out = [d async for d in _iter_sse_deltas(lines(), sink)]
|
out = [
|
||||||
assert out == [(True, "he"), (False, "think")]
|
d
|
||||||
|
async for d in _iter_sse_deltas(
|
||||||
|
lines(), sink, ChatCompletionAccumulator(response_mode="text")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert out == [ChatDelta("he", "", True), ChatDelta("", "think", True)]
|
||||||
assert sink["done"] is True and sink["usage"] == _USAGE
|
assert sink["done"] is True and sink["usage"] == _USAGE
|
||||||
|
|
||||||
async def test_malformed_frame_raises_transient(self):
|
async def test_malformed_frame_raises_transient(self):
|
||||||
|
from polygateway.transports._openai_completion import ChatCompletionAccumulator
|
||||||
|
|
||||||
async def lines():
|
async def lines():
|
||||||
yield "data: {not-json"
|
yield "data: {not-json"
|
||||||
|
|
||||||
with pytest.raises(TransientError, match="malformed"):
|
with pytest.raises(TransientError, match="malformed"):
|
||||||
_ = [d async for d in _iter_sse_deltas(lines(), {})]
|
_ = [
|
||||||
|
d
|
||||||
|
async for d in _iter_sse_deltas(
|
||||||
|
lines(), {}, ChatCompletionAccumulator(response_mode="text")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestStreamHappyPath:
|
class TestStreamHappyPath:
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"""协议聚合器的分片、防御与快照契约。"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.errors import TransientError
|
||||||
|
from polygateway.transports._openai_completion import (
|
||||||
|
ChatCompletionAccumulator,
|
||||||
|
parse_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chunk(delta, **extra):
|
||||||
|
"""构造单 choice 协议帧。"""
|
||||||
|
return (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps({"id": "upstream-1", "choices": [{"index": 0, "delta": delta, **extra}]})
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tool_frames():
|
||||||
|
"""两个交错分片工具,保留空白及 Unicode。"""
|
||||||
|
return [
|
||||||
|
chunk({"role": "assistant", "content": None}),
|
||||||
|
chunk(
|
||||||
|
{
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"id": "b",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "echo", "arguments": '{"v":'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"id": "a",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "echo", "arguments": "{"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
chunk(
|
||||||
|
{
|
||||||
|
"tool_calls": [
|
||||||
|
{"index": 0, "function": {"arguments": '"v": "中" }'}},
|
||||||
|
{"index": 1, "function": {"arguments": "2}"}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
finish_reason="tool_calls",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def accumulate():
|
||||||
|
"""复用双工具网关帧样本。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
for frame in tool_frames():
|
||||||
|
state.add_chunk(json.loads(frame.removeprefix("data: ")))
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [None, "user", "tool"])
|
||||||
|
def test_role_is_required_everywhere(role):
|
||||||
|
"""缺角色不能被协议成功掩盖。"""
|
||||||
|
state = accumulate()
|
||||||
|
state.message.pop("role")
|
||||||
|
if role:
|
||||||
|
state.message["role"] = role
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
state.finish()
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
parse_chat_completion({"choices": [{"message": state.message}]})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"change",
|
||||||
|
[
|
||||||
|
lambda t: t.pop("id"),
|
||||||
|
lambda t: t.update(type="custom"),
|
||||||
|
lambda t: t["function"].pop("name"),
|
||||||
|
lambda t: t["function"].pop("arguments"),
|
||||||
|
lambda t: t["function"].update(arguments=12),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_incomplete_tool_is_not_success(change):
|
||||||
|
"""工具必需字段不能靠正文兜底。"""
|
||||||
|
state = accumulate()
|
||||||
|
state.message["content"] = "text"
|
||||||
|
change(state.tools[0])
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
state.finish()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"delta",
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
{"tool_calls": {}},
|
||||||
|
{"content": []},
|
||||||
|
{"role": "user"},
|
||||||
|
{"tool_calls": [{"index": True}]},
|
||||||
|
{"tool_calls": [{"index": -1}]},
|
||||||
|
{"tool_calls": [{"index": None}]},
|
||||||
|
{"tool_calls": [{"index": 0, "function": {"arguments": 12}}]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_delta_is_domain_error(delta):
|
||||||
|
"""畸形输入不泄漏裸类型错误。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
state.add_chunk({"choices": [{"index": 0, "delta": delta}]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_repeat_does_not_refresh_progress():
|
||||||
|
"""身份只初次生效算进展;同值重复不能续命。"""
|
||||||
|
state = accumulate()
|
||||||
|
repeated = {
|
||||||
|
"choices": [
|
||||||
|
{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "a", "type": "function"}]}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert not state.add_chunk(repeated).progress
|
||||||
|
repeated["choices"][0]["delta"]["tool_calls"][0]["id"] = "other"
|
||||||
|
with pytest.raises(TransientError, match="冲突"):
|
||||||
|
state.add_chunk(repeated)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("delta", [{"role": "assistant"}, {}, {"content": ""}])
|
||||||
|
def test_metadata_not_progress(delta):
|
||||||
|
"""空帧和角色不算 token。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
assert not state.add_chunk({"choices": [{"index": 0, "delta": delta}]}).progress
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonstream_snapshot_and_length_preserved():
|
||||||
|
"""非流式扩展字段与参数原文独立保存,不修复 length 产物。"""
|
||||||
|
message = accumulate().finish().message
|
||||||
|
message["extension"] = {"nested": [1]}
|
||||||
|
message["tool_calls"][0]["function"]["arguments"] = '{"unfinished":'
|
||||||
|
original = copy.deepcopy(message)
|
||||||
|
result = parse_chat_completion(
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"choices": [{"message": message, "finish_reason": "length"}],
|
||||||
|
"usage": {"extra": 3},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
message["extension"]["nested"].append(2)
|
||||||
|
assert result.message == original
|
||||||
|
assert result.finish_reason == "length" and result.usage == {"extra": 3}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("choices", [[], [{"index": 1, "message": {}}], [{}, {}]])
|
||||||
|
def test_nonstream_single_choice(choices):
|
||||||
|
"""不会静默取第一个 choice。"""
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
parse_chat_completion({"choices": choices})
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_empty_tools_remains_present():
|
||||||
|
"""显式空工具列表与缺省不同,不删除已接收字段。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
state.add_chunk(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"index": 0, "delta": {"role": "assistant", "content": "ok", "tool_calls": []}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fragmented_name_and_arguments_with_usage():
|
||||||
|
"""名称/参数分片逐字拼接,usage 扩展原样快照。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
state.add_chunk(
|
||||||
|
{
|
||||||
|
"id": "response",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"id": "call",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "ec", "arguments": " "},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
progress = state.add_chunk(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"content": "text",
|
||||||
|
"reasoning_content": "reason",
|
||||||
|
"tool_calls": [
|
||||||
|
{"index": 3, "function": {"name": "ho", "arguments": "{} "}}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"finish_reason": "length",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
progress.content == "text" and progress.reasoning_content == "reason" and progress.progress
|
||||||
|
)
|
||||||
|
usage = {"prompt_tokens": 7, "completion_tokens": 2, "extra": {"nested": 9}}
|
||||||
|
assert not state.add_chunk({"choices": [], "usage": usage}).progress
|
||||||
|
result = state.finish()
|
||||||
|
usage["extra"]["nested"] = 10
|
||||||
|
assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "}
|
||||||
|
assert result.usage["extra"]["nested"] == 9
|
||||||
|
assert result.finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
|
# —— 1.3.10(#30): null 优先、未知字段无损合并、finish/usage 位置 ——
|
||||||
|
|
||||||
|
|
||||||
|
def _feed(state, *chunks):
|
||||||
|
"""依次喂入已解码帧,返回每帧的 ChatDelta。"""
|
||||||
|
return [state.add_chunk(c) for c in chunks]
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(delta, finish=None, **top):
|
||||||
|
"""单 choice 已解码帧。"""
|
||||||
|
choice = {"index": 0, "delta": delta}
|
||||||
|
if finish is not None:
|
||||||
|
choice["finish_reason"] = finish
|
||||||
|
return {"choices": [choice], **top}
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_known_and_unknown_fields_are_absent():
|
||||||
|
"""refusal/tool_calls/role/arguments 为 null 不判失败,键按缺省保留。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": None, "refusal": None, "tool_calls": None, "content": None}),
|
||||||
|
_frame({"role": "assistant", "content": "hi"}, finish="stop"),
|
||||||
|
)
|
||||||
|
message = state.finish().message
|
||||||
|
assert message["refusal"] is None and message["content"] == "hi"
|
||||||
|
assert message["role"] == "assistant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_only_null_fails_at_finish():
|
||||||
|
"""全程只有 role:null 即缺 role,照旧失败。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(state, _frame({"role": None, "content": "x"}, finish="stop"))
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
state.finish()
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_arguments_and_null_tool_elements():
|
||||||
|
"""arguments:null 视缺省;tool_calls 中 null 元素跳过。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f"}}
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "tool_calls": [None, tool]}),
|
||||||
|
_frame({"tool_calls": [{"index": 0, "function": {"arguments": None}}]}),
|
||||||
|
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}"}}]}, "tool_calls"),
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"][0]["function"] == {"name": "f", "arguments": "{}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_choices_and_delta_are_absent():
|
||||||
|
"""chunk 层 choices:null 与 choice 层 delta:null 视缺省。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
assert not state.add_chunk({"choices": None}).progress
|
||||||
|
assert not state.add_chunk({"choices": [{"index": 0, "delta": None}]}).progress
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_alias_is_progress_and_preserved():
|
||||||
|
"""只有 reasoning 字段: 计活性、交别名片段、保真原键保留。"""
|
||||||
|
for mode in ("openai", "text"):
|
||||||
|
state = ChatCompletionAccumulator(response_mode=mode)
|
||||||
|
delta = state.add_chunk(_frame({"role": "assistant", "reasoning": "think"}))
|
||||||
|
assert delta.progress and delta.reasoning == "think" and delta.reasoning_content == ""
|
||||||
|
state.add_chunk(_frame({"reasoning": " more"}))
|
||||||
|
openai = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(openai, _frame({"role": "assistant", "reasoning": "a"}), _frame({"reasoning": "b"}))
|
||||||
|
assert openai.raw_message()["reasoning"] == "ab"
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(text, **extra):
|
||||||
|
return {"type": "reasoning.text", "index": 0, "format": "x-v1", "text": text, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_details_merge_by_index_without_label_concat():
|
||||||
|
"""OpenRouter reasoning_details 分片: index 合并,format/id/type 不拼接。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "reasoning_details": [_detail("he", id="r1")]}),
|
||||||
|
_frame({"reasoning_details": [_detail("llo", id="r1")]}),
|
||||||
|
_frame({"content": "ok"}, finish="stop"),
|
||||||
|
)
|
||||||
|
details = state.finish().message["reasoning_details"]
|
||||||
|
assert details == [
|
||||||
|
{"type": "reasoning.text", "index": 0, "format": "x-v1", "text": "hello", "id": "r1"}
|
||||||
|
]
|
||||||
|
assert not state.conflicts
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_level_nested_extension_preserved():
|
||||||
|
"""工具层未知嵌套对象(如 thought signature)原样保留以便回传。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
extra = {"google": {"thought_signature": "sig"}}
|
||||||
|
tool = {"index": 0, "id": "a", "type": "function", "extra_content": extra}
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "tool_calls": [{**tool, "function": {"name": "f"}}]}),
|
||||||
|
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}", "strict": True}}]}),
|
||||||
|
)
|
||||||
|
snapshot = state.finish().message["tool_calls"][0]
|
||||||
|
assert snapshot["extra_content"] == extra and snapshot["function"]["strict"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("first", "second"),
|
||||||
|
[(1, 2), (True, False), ("s", {"o": 1}), ([{"index": 0, "t": "a"}], ["plain"])],
|
||||||
|
)
|
||||||
|
def test_conflict_drops_key_and_bans_path(first, second):
|
||||||
|
"""数值/布尔/形态冲突: 删键、登记路径、后续同路径丢弃、调用成功。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "content": "a", "ext": first}),
|
||||||
|
_frame({"ext": second}),
|
||||||
|
_frame({"ext": first}, finish="stop"),
|
||||||
|
)
|
||||||
|
assert "ext" not in state.finish().message
|
||||||
|
assert state.conflicts == frozenset({"delta.ext"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_arrays_append_and_same_scalar_idempotent():
|
||||||
|
"""普通数组追加;同值标量幂等;追加数组中的字符串算活性。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
deltas = _feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "notes": ["a"], "n": 1}),
|
||||||
|
_frame({"notes": ["b", None], "n": 1}),
|
||||||
|
)
|
||||||
|
message = state.raw_message()
|
||||||
|
assert message["notes"] == ["a", "b"] and message["n"] == 1
|
||||||
|
assert all(d.progress for d in deltas[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_progress_per_frame_with_many_fragments():
|
||||||
|
"""同帧多个字段只产生一次进展事件(ChatDelta 单值)。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
delta = state.add_chunk(_frame({"content": "a", "reasoning_content": "b", "x": "c"}))
|
||||||
|
assert delta.progress is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_only_unknown_is_not_progress():
|
||||||
|
"""未知层仅标签键(type/format/id/index)不刷新活性。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
assert not state.add_chunk(_frame({"meta": {"type": "x", "format": "y"}})).progress
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("frames", "after"),
|
||||||
|
[
|
||||||
|
([_frame({"content": "a"}, usage={"x": 1}), _frame({}, finish="stop")], False),
|
||||||
|
([_frame({"content": "a"}, finish="stop", usage={"x": 1})], True),
|
||||||
|
([_frame({"content": "a"}, finish="stop"), {"choices": [], "usage": {"x": 1}}], True),
|
||||||
|
([_frame({"content": "a"}, finish="stop")], False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_usage_after_finish_tracking(frames, after):
|
||||||
|
"""usage 必须在 finish 同帧或之后到达才算流走到自然末尾。"""
|
||||||
|
for mode in ("openai", "text"):
|
||||||
|
state = ChatCompletionAccumulator(response_mode=mode)
|
||||||
|
_feed(state, *frames)
|
||||||
|
assert state.finish_seen and state.finish_reason == "stop"
|
||||||
|
assert state.usage_after_finish is after
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_mode_ignores_other_choice_index():
|
||||||
|
"""文本模式只取 index 缺省或为 0 的 choice,不混入 index=1。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
delta = state.add_chunk(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"index": 1, "delta": {"content": "B"}, "finish_reason": "length"},
|
||||||
|
{"index": 0, "delta": {"content": "A"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert delta.content == "A" and state.finish_reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_mode_finish_non_string_ignored():
|
||||||
|
"""文本模式 finish 非字符串记 None,不判失败。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
state.add_chunk(_frame({"content": "a"}, finish=5))
|
||||||
|
assert state.finish_reason is None and not state.finish_seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonstream_null_tool_elements_stripped():
|
||||||
|
"""非流式 tool_calls 中 null 元素剔除(回传 [null] 会被上游拒),null 列表原样保留。"""
|
||||||
|
tool = {"id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||||
|
body = {
|
||||||
|
"choices": [{"message": {"role": "assistant", "tool_calls": [None, tool], "refusal": None}}]
|
||||||
|
}
|
||||||
|
assert parse_chat_completion(body).message["tool_calls"] == [tool]
|
||||||
|
body["choices"][0]["message"]["tool_calls"] = None
|
||||||
|
assert parse_chat_completion(body).message["tool_calls"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("index", "taken"), [(None, True), (0, True), (False, False), (1, False)])
|
||||||
|
def test_text_mode_choice_index_null_is_zero(index, taken):
|
||||||
|
"""审查 I3: 文本模式 index:null 视缺省(=0);bool 不冒充 0。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
delta = state.add_chunk(
|
||||||
|
{"choices": [{"index": index, "delta": {"content": "hi"}, "finish_reason": "stop"}]}
|
||||||
|
)
|
||||||
|
assert (delta.content == "hi") is taken and (state.finish_reason == "stop") is taken
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_only_tool_elements_do_not_create_empty_list():
|
||||||
|
"""审查 Minor1: tool_calls null 后 [null] 不得产出会被上游拒收的空数组。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "content": "a", "tool_calls": None}),
|
||||||
|
_frame({"tool_calls": [None]}, finish="stop"),
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"] is None
|
||||||
|
body = {"choices": [{"message": {"role": "assistant", "content": "a", "tool_calls": [None]}}]}
|
||||||
|
assert parse_chat_completion(body).message["tool_calls"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_calls_null_then_list_merges():
|
||||||
|
"""tool_calls 先 null 后列表: 正常聚合工具。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "tool_calls": None}),
|
||||||
|
_frame({"tool_calls": [tool]}, finish="tool_calls"),
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"][0]["id"] == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonstream_role_checked_before_verdict():
|
||||||
|
"""审查 Minor3: 非流式缺 role 是形态失败,先于结局判定。"""
|
||||||
|
from polygateway.transports._openai_completion import check_completion_body
|
||||||
|
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
check_completion_body(
|
||||||
|
{"choices": [{"message": {"content": "a"}, "finish_reason": "length"}]}
|
||||||
|
)
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""OpenAI 消息保真回归:按网关 Chat Completions 形态构造。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.errors import AllSourcesExhausted, TransientError
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
|
||||||
|
from tests.unit.test_openai_completion import accumulate, chunk, tool_frames
|
||||||
|
|
||||||
|
|
||||||
|
async def test_error_before_done_is_failure():
|
||||||
|
"""文本模式也不能接受 error 后的 DONE。"""
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: _sse_stream(chunk({"content": "partial"}), 'data: {"error": null}\n\n')
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await _complete(transport, _source())
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mixed_content_reasoning_survive():
|
||||||
|
"""同一帧双文本不互斥。"""
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: _sse_stream(chunk({"content": "answer", "reasoning_content": "reason"}))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await _complete(transport, _source())
|
||||||
|
assert (result.content, result.thinking) == ("answer", "reason")
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tools_roundtrip_and_retry():
|
||||||
|
"""真实 transport 经过治理,失败重试不污染工具,消息可直接往返。"""
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
requests.append(json.loads(request.content))
|
||||||
|
if len(requests) == 1:
|
||||||
|
return _sse_stream(
|
||||||
|
chunk({"content": "partial"}),
|
||||||
|
'data: {"error": {"code": "upstream_stream_read_error"}}\n\n',
|
||||||
|
)
|
||||||
|
return _sse_stream(*tool_frames())
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
try:
|
||||||
|
response = await client.chat(
|
||||||
|
[{"role": "user", "content": "hi"}],
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={"reasoning_effort": "max"},
|
||||||
|
)
|
||||||
|
assert response.call_stats.attempts == 2
|
||||||
|
message = response.completion.message
|
||||||
|
assert message["role"] == "assistant" and message["content"] is None
|
||||||
|
assert [t["id"] for t in message["tool_calls"]] == ["a", "b"]
|
||||||
|
assert message["tool_calls"][0]["function"]["arguments"] == '{"v": "中" }'
|
||||||
|
assert response.completion.response_id == "upstream-1"
|
||||||
|
assert response.completion.finish_reason == "tool_calls"
|
||||||
|
messages = [message, {"role": "tool", "tool_call_id": "a", "content": "ok"}]
|
||||||
|
await client.chat(messages, response_mode="openai")
|
||||||
|
assert requests[-1]["messages"] == messages
|
||||||
|
assert requests[0]["reasoning_effort"] == "max"
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [None, "user"])
|
||||||
|
async def test_final_role_required(role):
|
||||||
|
"""非流式缺失或错误 role 必须协议失败。"""
|
||||||
|
message = {"content": "ok"}
|
||||||
|
if role:
|
||||||
|
message["role"] = role
|
||||||
|
client = _client(
|
||||||
|
handler=lambda _: httpx.Response(200, json={"choices": [{"message": message}]})
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(AllSourcesExhausted):
|
||||||
|
await client.chat(
|
||||||
|
[{"role": "user", "content": "hi"}], stream=False, response_mode="openai"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stream", [True, False])
|
||||||
|
async def test_completion_cache_roundtrip(stream):
|
||||||
|
"""完整结果回放,修改返回对象不毒化缓存;模式隔离。"""
|
||||||
|
from polygateway.backends.memory.cache import InMemoryCache
|
||||||
|
|
||||||
|
completion = accumulate().finish()
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
if stream:
|
||||||
|
return _sse_stream(*tool_frames())
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "upstream-1",
|
||||||
|
"choices": [{"message": completion.message, "finish_reason": "tool_calls"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = _client(
|
||||||
|
handler=handler, cache=InMemoryCache(), cache_namespace="tools", cache_ttl_s=60
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
first = await client.chat([], stream=stream, response_mode="openai")
|
||||||
|
second = await client.chat([], stream=stream, response_mode="openai")
|
||||||
|
assert not first.cache_hit and second.cache_hit
|
||||||
|
assert second.completion == first.completion
|
||||||
|
assert second.call_id != first.call_id and second.call_stats.attempts == 0
|
||||||
|
second.completion.message["tool_calls"].clear()
|
||||||
|
third = await client.chat([], stream=stream, response_mode="openai")
|
||||||
|
assert len(third.completion.message["tool_calls"]) == 2
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kwargs",
|
||||||
|
[
|
||||||
|
{"response_mode": "bad"},
|
||||||
|
{"response_mode": "openai", "structured": "json"},
|
||||||
|
{"response_mode": "openai", "overlay": {"n": 2}},
|
||||||
|
{"response_mode": "openai", "overlay": {"n": True}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_invalid_mode_combinations_fail_before_http(kwargs):
|
||||||
|
"""组合错误入口拒绝。"""
|
||||||
|
|
||||||
|
def unexpected(_):
|
||||||
|
pytest.fail("不应发送 HTTP")
|
||||||
|
|
||||||
|
client = _client(handler=unexpected)
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await client.chat([], **kwargs)
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_never_salvages():
|
||||||
|
"""无 finish 后 usage 的截断流即使收全工具也不打捞(1.3.10: 判据由 DONE 改为完成判定)。"""
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False))
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError, match="missing_done"):
|
||||||
|
await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(missing_done="salvage"),
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_finish_with_trailing_usage_is_complete_without_done():
|
||||||
|
"""对照: 同一工具流补上 finish 之后的 usage 帧,即使无 DONE 也完整交回(#31)。"""
|
||||||
|
usage = 'data: {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}}\n\n'
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), usage, done=False))
|
||||||
|
try:
|
||||||
|
result = await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(),
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
assert result.stream_end == "eof_after_finish"
|
||||||
|
assert [t["id"] for t in result.completion.message["tool_calls"]] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_progress_sets_events():
|
||||||
|
"""工具首进展驱动两个事件与时间观测。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
first, progress = asyncio.Event(), asyncio.Event()
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*tool_frames()))
|
||||||
|
try:
|
||||||
|
result = await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(),
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=first,
|
||||||
|
content_event=progress,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
assert first.is_set() and progress.is_set()
|
||||||
|
assert result.ttft_ms is not None and result.stall_count == 0
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_transport_stall_hedge_cancels_loser():
|
||||||
|
"""真实工具流沉默触发异源对冲,连接关闭且赢家不混入半成品。"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from polygateway.sources import RoundRobinSelector
|
||||||
|
from polygateway.types import ChatRequest, _CallContext
|
||||||
|
from tests.unit.test_retry import _src
|
||||||
|
from tests.unit.test_stall_hedge import _harness
|
||||||
|
|
||||||
|
entered, closed = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
class Hanging(httpx.AsyncByteStream):
|
||||||
|
"""工具首帧后阻塞,取消由真实 httpx 流处理。"""
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
yield tool_frames()[0].encode()
|
||||||
|
yield tool_frames()[1].encode()
|
||||||
|
entered.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
nonlocal count
|
||||||
|
count += 1
|
||||||
|
if count == 1:
|
||||||
|
return httpx.Response(200, stream=Hanging())
|
||||||
|
return _sse_stream(*tool_frames())
|
||||||
|
|
||||||
|
transport = _transport_for(handler)
|
||||||
|
sources = [_src("s1"), _src("s2")]
|
||||||
|
mw, limiter, *_ = _harness(
|
||||||
|
sources, transport, stall_after_s=0.05, selector=RoundRobinSelector()
|
||||||
|
)
|
||||||
|
context = _CallContext(now=time.monotonic)
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
mw(ChatRequest(messages=[], response_mode="openai", call_context=context)), 3
|
||||||
|
)
|
||||||
|
assert entered.is_set() and closed.is_set()
|
||||||
|
assert response.source_name == "s2" and len(response.completion.message["tool_calls"]) == 2
|
||||||
|
assert context.snapshot().hedges == 1 and context.snapshot().attempts == 2
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stream", [True, False])
|
||||||
|
async def test_bad_usage_is_domain_failure(stream):
|
||||||
|
"""坏 usage 不能穿透成裸 AttributeError,包括默认文本模式。"""
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: (
|
||||||
|
_sse_stream(chunk({"content": "ok"}), 'data: {"choices": [], "usage": [1]}\n\n')
|
||||||
|
if stream
|
||||||
|
else httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "ok"}}], "usage": [1]}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await _complete(transport, _source(), stream=stream)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nonstream_tool_result_has_no_liveness_events():
|
||||||
|
"""非流式工具不制造流式观测。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
completion = accumulate().finish()
|
||||||
|
first, progress = asyncio.Event(), asyncio.Event()
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: httpx.Response(
|
||||||
|
200, json={"choices": [{"message": completion.message, "finish_reason": "tool_calls"}]}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(),
|
||||||
|
stream=False,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=first,
|
||||||
|
content_event=progress,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
assert result.completion.message == completion.message
|
||||||
|
assert not first.is_set() and not progress.is_set()
|
||||||
|
assert result.ttft_ms is None and result.stall_ms is None
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_does_not_bypass_managed_conflict():
|
||||||
|
"""保真模式不静默撤销源级受管意图。"""
|
||||||
|
from polygateway.errors import RequestRejectedError
|
||||||
|
|
||||||
|
def unexpected(_):
|
||||||
|
pytest.fail("冲突请求不应发送 HTTP")
|
||||||
|
|
||||||
|
transport = _transport_for(unexpected)
|
||||||
|
try:
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(enable_thinking=True),
|
||||||
|
stream=True,
|
||||||
|
overlay={"reasoning_effort": "max"},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_tool_stream_releases_resources():
|
||||||
|
"""显式取消真实工具流,连接关闭且配额归还。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
|
from polygateway.types import GlobalLimits
|
||||||
|
|
||||||
|
entered, closed = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
class Hanging(httpx.AsyncByteStream):
|
||||||
|
"""可确定取消窗口的 HTTP 流。"""
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
for frame in tool_frames()[:2]:
|
||||||
|
yield frame.encode()
|
||||||
|
entered.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
source = _source(max_concurrency=1)
|
||||||
|
limiter = InMemoryLimiter(
|
||||||
|
scope="llm", sources={source.name: source}, global_limits=GlobalLimits(1, 0, 0)
|
||||||
|
)
|
||||||
|
client = _client(
|
||||||
|
sources=[source], limiter=limiter, handler=lambda _: httpx.Response(200, stream=Hanging())
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(client.chat([], response_mode="openai"))
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(entered.wait(), 2)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert closed.is_set()
|
||||||
|
assert (await limiter.source_stats(source.name)).inflight == 0
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
await client._transport.aclose()
|
||||||
@@ -22,6 +22,8 @@ def test_ocr_public_surface_exported():
|
|||||||
"OcrTextResult",
|
"OcrTextResult",
|
||||||
"OcrLayoutResult",
|
"OcrLayoutResult",
|
||||||
"OcrLayoutElement",
|
"OcrLayoutElement",
|
||||||
|
"OcrDocumentResult",
|
||||||
|
"OcrDocumentElement",
|
||||||
):
|
):
|
||||||
assert hasattr(polygateway, name), name
|
assert hasattr(polygateway, name), name
|
||||||
assert name in polygateway.__all__, name
|
assert name in polygateway.__all__, name
|
||||||
@@ -88,3 +90,15 @@ def test_every_promised_export_is_actually_importable():
|
|||||||
"""
|
"""
|
||||||
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
|
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
|
||||||
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
|
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apache_license_metadata():
|
||||||
|
"""许可正文和包元数据同时存在,不以版本说明替代许可。"""
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
assert "Apache License" in (root / "LICENSE").read_text()
|
||||||
|
assert "Version 2.0" in (root / "LICENSE").read_text()
|
||||||
|
project = tomllib.loads((root / "pyproject.toml").read_text())["project"]
|
||||||
|
assert project["license"] == {"file": "LICENSE"}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from polygateway.ports import (
|
|||||||
GateState,
|
GateState,
|
||||||
GateUpdate,
|
GateUpdate,
|
||||||
Middleware,
|
Middleware,
|
||||||
|
OcrDocumentTransport,
|
||||||
OcrTransport,
|
OcrTransport,
|
||||||
Permit,
|
Permit,
|
||||||
ProviderGate,
|
ProviderGate,
|
||||||
@@ -83,6 +84,7 @@ class _DummyTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -149,6 +151,7 @@ class _DummyRecorder:
|
|||||||
total_latency_ms,
|
total_latency_ms,
|
||||||
stall_ms,
|
stall_ms,
|
||||||
stall_count,
|
stall_count,
|
||||||
|
stream_end,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -191,6 +194,7 @@ class TestReasoningTierIsOnlyOnTheChatPort:
|
|||||||
(EmbeddingTransport, "embed"),
|
(EmbeddingTransport, "embed"),
|
||||||
(OcrTransport, "recognize_text"),
|
(OcrTransport, "recognize_text"),
|
||||||
(OcrTransport, "parse_layout"),
|
(OcrTransport, "parse_layout"),
|
||||||
|
(OcrDocumentTransport, "parse_document"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_other_transports_have_no_reasoning_tier(self, protocol, method):
|
def test_other_transports_have_no_reasoning_tier(self, protocol, method):
|
||||||
@@ -321,7 +325,7 @@ class TestTelemetryRecorderSignature:
|
|||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
} <= set(params)
|
} <= set(params)
|
||||||
assert len(params) - 1 == 38 # 减掉 self
|
assert len(params) - 1 == 39 # 减掉 self
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"name",
|
"name",
|
||||||
@@ -340,6 +344,7 @@ class TestTelemetryRecorderSignature:
|
|||||||
"error_body",
|
"error_body",
|
||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
|
"stream_end",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_caller_dimensions_have_no_default(self, name):
|
def test_caller_dimensions_have_no_default(self, name):
|
||||||
@@ -369,6 +374,34 @@ class TestOcrPorts:
|
|||||||
assert isinstance(GoodClient(), OcrLayoutPort)
|
assert isinstance(GoodClient(), OcrLayoutPort)
|
||||||
assert isinstance(GoodTransport(), OcrTransport)
|
assert isinstance(GoodTransport(), OcrTransport)
|
||||||
|
|
||||||
|
def test_document_ports_are_runtime_checkable(self):
|
||||||
|
from polygateway.ports import OcrDocumentPort, OcrDocumentTransport
|
||||||
|
|
||||||
|
class GoodClient:
|
||||||
|
async def parse_document(self, document): ...
|
||||||
|
|
||||||
|
class GoodTransport:
|
||||||
|
async def parse_document(self, *, document, source, call_id): ...
|
||||||
|
|
||||||
|
class Empty: ...
|
||||||
|
|
||||||
|
assert isinstance(GoodClient(), OcrDocumentPort)
|
||||||
|
assert isinstance(GoodTransport(), OcrDocumentTransport)
|
||||||
|
assert not isinstance(Empty(), OcrDocumentPort)
|
||||||
|
assert not isinstance(Empty(), OcrDocumentTransport)
|
||||||
|
|
||||||
|
def test_ocr_transport_members_are_unchanged(self):
|
||||||
|
"""新能力走独立 Protocol: 往旧 runtime_checkable Protocol 加成员,会让所有
|
||||||
|
现有实现(测试替身、下游自带 fake)在 isinstance 上一夜之间不合格。"""
|
||||||
|
from polygateway.ports import OcrTransport
|
||||||
|
|
||||||
|
members = {
|
||||||
|
name
|
||||||
|
for name in vars(OcrTransport)
|
||||||
|
if not name.startswith("_") and callable(getattr(OcrTransport, name))
|
||||||
|
}
|
||||||
|
assert members == {"recognize_text", "parse_layout", "check_health"}
|
||||||
|
|
||||||
def test_missing_method_rejected(self):
|
def test_missing_method_rejected(self):
|
||||||
from polygateway.ports import OcrTransport
|
from polygateway.ports import OcrTransport
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class FakeTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
self.calls.append((source.name, call_id))
|
self.calls.append((source.name, call_id))
|
||||||
self.efforts.append(reasoning_effort)
|
self.efforts.append(reasoning_effort)
|
||||||
@@ -132,6 +133,7 @@ class _GenClockTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
advance = self._advances.pop(0)
|
advance = self._advances.pop(0)
|
||||||
stall = self._stalls.pop(0) if self._stalls is not None else None
|
stall = self._stalls.pop(0) if self._stalls is not None else None
|
||||||
@@ -144,6 +146,7 @@ class _GenClockTransport:
|
|||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
first_token_event=first_token_event,
|
first_token_event=first_token_event,
|
||||||
content_event=content_event,
|
content_event=content_event,
|
||||||
|
response_mode=response_mode,
|
||||||
)
|
)
|
||||||
if stall is not None:
|
if stall is not None:
|
||||||
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
|
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class StallHedgeTransport:
|
|||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
first_token_event,
|
first_token_event,
|
||||||
content_event,
|
content_event,
|
||||||
|
response_mode,
|
||||||
):
|
):
|
||||||
self.calls.append(source.name)
|
self.calls.append(source.name)
|
||||||
self.ft_events.append(first_token_event)
|
self.ft_events.append(first_token_event)
|
||||||
@@ -271,9 +272,7 @@ class TestStallHedgeTrigger:
|
|||||||
"""参数化四格(设计 §5.2 逐字): 两阈值独立生效,各格触发语义不串。"""
|
"""参数化四格(设计 §5.2 逐字): 两阈值独立生效,各格触发语义不串。"""
|
||||||
# 格 1: 仅 AFTER_S + 流式 pre-TTFT 挂起 → AFTER_S 触发(1.3.7 语义不变)
|
# 格 1: 仅 AFTER_S + 流式 pre-TTFT 挂起 → AFTER_S 触发(1.3.7 语义不变)
|
||||||
s1, s2 = _src("s1"), _src("s2")
|
s1, s2 = _src("s1"), _src("s2")
|
||||||
transport = StallHedgeTransport(
|
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]})
|
||||||
{"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
|
|
||||||
)
|
|
||||||
mw, _, _ = _harness([s1, s2], transport, hedge_after_s=_STALL_AFTER_S)
|
mw, _, _ = _harness([s1, s2], transport, hedge_after_s=_STALL_AFTER_S)
|
||||||
ctx = _ctx()
|
ctx = _ctx()
|
||||||
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
|
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
|
||||||
@@ -312,9 +311,7 @@ class TestStallHedgeTrigger:
|
|||||||
|
|
||||||
# 格 3b: 两者皆设 + pre-TTFT 挂起 → 阶段 1 AFTER_S 触发(两阶段各管一段)
|
# 格 3b: 两者皆设 + pre-TTFT 挂起 → 阶段 1 AFTER_S 触发(两阶段各管一段)
|
||||||
s1, s2 = _src("s1"), _src("s2")
|
s1, s2 = _src("s1"), _src("s2")
|
||||||
transport = StallHedgeTransport(
|
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]})
|
||||||
{"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
|
|
||||||
)
|
|
||||||
mw, _, _ = _harness(
|
mw, _, _ = _harness(
|
||||||
[s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S
|
[s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S
|
||||||
)
|
)
|
||||||
@@ -340,9 +337,7 @@ class TestStallHedgeTrigger:
|
|||||||
async def test_stall_only_non_stream_degrades_to_single_leg(self):
|
async def test_stall_only_non_stream_degrades_to_single_leg(self):
|
||||||
"""仅 stall 已设 + `stream=False` → 逐字单路(中段沉默无非流式概念,入口门)。"""
|
"""仅 stall 已设 + `stream=False` → 逐字单路(中段沉默无非流式概念,入口门)。"""
|
||||||
s1, s2 = _src("s1"), _src("s2")
|
s1, s2 = _src("s1"), _src("s2")
|
||||||
transport = StallHedgeTransport(
|
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]})
|
||||||
{"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]}
|
|
||||||
)
|
|
||||||
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
|
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
|
||||||
ctx = _ctx()
|
ctx = _ctx()
|
||||||
task = asyncio.ensure_future(mw(_req(stream=False, ctx=ctx)))
|
task = asyncio.ensure_future(mw(_req(stream=False, ctx=ctx)))
|
||||||
|
|||||||
+140
-14
@@ -37,6 +37,8 @@ from polygateway.types import (
|
|||||||
EmbeddingTransportResult,
|
EmbeddingTransportResult,
|
||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
OcrDocumentElement,
|
||||||
|
OcrDocumentTransportResult,
|
||||||
OcrTextTransportResult,
|
OcrTextTransportResult,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
@@ -92,6 +94,8 @@ _EXPECTED_COLUMNS = [
|
|||||||
# —— 1.3.8 沉默归因两列(issue #27)——
|
# —— 1.3.8 沉默归因两列(issue #27)——
|
||||||
"stall_ms",
|
"stall_ms",
|
||||||
"stall_count",
|
"stall_count",
|
||||||
|
# —— 1.3.10 结束方式(#31)——
|
||||||
|
"stream_end",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用
|
# 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用
|
||||||
@@ -187,6 +191,8 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
|||||||
# —— 1.3.8 沉默归因两列: 默认 None = 非流式/无沉默观测(合法事实)——
|
# —— 1.3.8 沉默归因两列: 默认 None = 非流式/无沉默观测(合法事实)——
|
||||||
"stall_ms": None,
|
"stall_ms": None,
|
||||||
"stall_count": None,
|
"stall_count": None,
|
||||||
|
# —— 1.3.10 结束方式: 默认 None = 非流式/缓存命中/失败行 ——
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
@@ -268,7 +274,7 @@ class TestSchemaModule:
|
|||||||
|
|
||||||
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
|
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
|
||||||
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
|
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
|
||||||
assert len(COLUMNS) == 38
|
assert len(COLUMNS) == 39
|
||||||
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
|
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
|
||||||
for ddl in (SQLITE_DDL, PG_DDL):
|
for ddl in (SQLITE_DDL, PG_DDL):
|
||||||
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
|
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
|
||||||
@@ -282,8 +288,8 @@ class TestSchemaModule:
|
|||||||
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
|
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
|
||||||
)
|
)
|
||||||
assert PG_BACKFILL[-1] == (
|
assert PG_BACKFILL[-1] == (
|
||||||
"stall_count",
|
"stream_end",
|
||||||
"ALTER TABLE llm_calls ADD COLUMN stall_count INTEGER",
|
"ALTER TABLE llm_calls ADD COLUMN stream_end TEXT",
|
||||||
)
|
)
|
||||||
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
|
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
|
||||||
|
|
||||||
@@ -297,10 +303,10 @@ class TestSchemaModule:
|
|||||||
|
|
||||||
assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT
|
assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT
|
||||||
assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT
|
assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT
|
||||||
# 全量 38 列: 占位符随列数增长,且不留空洞
|
# 全量 39 列: 占位符随列数增长,且不留空洞
|
||||||
assert insert_sql("postgres", COLUMNS).count("$") == 38
|
assert insert_sql("postgres", COLUMNS).count("$") == 39
|
||||||
assert "$38)" in insert_sql("postgres", COLUMNS)
|
assert "$39)" in insert_sql("postgres", COLUMNS)
|
||||||
assert insert_sql("sqlite", COLUMNS).count("?") == 38
|
assert insert_sql("sqlite", COLUMNS).count("?") == 39
|
||||||
# 裁剪列表按位置占位符重新编号,不留空洞
|
# 裁剪列表按位置占位符重新编号,不留空洞
|
||||||
assert insert_sql("postgres", ["call_id", "model"]) == (
|
assert insert_sql("postgres", ["call_id", "model"]) == (
|
||||||
"INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING"
|
"INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING"
|
||||||
@@ -377,13 +383,14 @@ class TestBackendColumnParity:
|
|||||||
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
|
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
|
||||||
from polygateway.telemetry.schema import COLUMNS
|
from polygateway.telemetry.schema import COLUMNS
|
||||||
|
|
||||||
assert COLUMNS[-6:] == (
|
assert COLUMNS[-7:] == (
|
||||||
"cause_type",
|
"cause_type",
|
||||||
"error_body",
|
"error_body",
|
||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
"stall_ms",
|
"stall_ms",
|
||||||
"stall_count",
|
"stall_count",
|
||||||
|
"stream_end",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -776,7 +783,7 @@ class TestSQLiteCallerDimensionsAcceptance:
|
|||||||
|
|
||||||
conn = sqlite3.connect(db)
|
conn = sqlite3.connect(db)
|
||||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||||
assert cols == _EXPECTED_COLUMNS # 22 → 38 个 recorder 字段(+ created_at 共 39 物理列)
|
assert cols == _EXPECTED_COLUMNS # 22 → 39 个 recorder 字段(+ created_at 共 40 物理列)
|
||||||
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
|
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
|
||||||
assert rows["new-row"] == "tenant-a"
|
assert rows["new-row"] == "tenant-a"
|
||||||
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||||
@@ -826,7 +833,7 @@ class TestSQLiteSchemaMode:
|
|||||||
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
|
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
|
||||||
|
|
||||||
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
|
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
|
||||||
补齐后 38 + `created_at` = 39。混用 INSERT 字段数与物理列数是本处最易错的地方。
|
补齐后 39 + `created_at` = 40。混用 INSERT 字段数与物理列数是本处最易错的地方。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _physical_columns(self, db: Path) -> list[str]:
|
def _physical_columns(self, db: Path) -> list[str]:
|
||||||
@@ -865,7 +872,7 @@ class TestSQLiteSchemaMode:
|
|||||||
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
|
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
|
||||||
|
|
||||||
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
|
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
|
||||||
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 39。"""
|
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 40。"""
|
||||||
db = tmp_path / "auto_legacy.db"
|
db = tmp_path / "auto_legacy.db"
|
||||||
_make_pre_tenant_db(db)
|
_make_pre_tenant_db(db)
|
||||||
|
|
||||||
@@ -874,10 +881,10 @@ class TestSQLiteSchemaMode:
|
|||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
assert self._physical_columns(db) == _EXPECTED_COLUMNS
|
assert self._physical_columns(db) == _EXPECTED_COLUMNS
|
||||||
assert len(self._physical_columns(db)) == 39
|
assert len(self._physical_columns(db)) == 40
|
||||||
|
|
||||||
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
|
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
|
||||||
"""manual 只管 ALTER,不管 CREATE: 全新库照建,39 个物理列齐全(设计 §4.2)。"""
|
"""manual 只管 ALTER,不管 CREATE: 全新库照建,40 个物理列齐全(设计 §4.2)。"""
|
||||||
db = tmp_path / "manual_fresh.db"
|
db = tmp_path / "manual_fresh.db"
|
||||||
recorder = SQLiteRecorder(db, auto_migrate=False)
|
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||||
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
|
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
|
||||||
@@ -1117,6 +1124,8 @@ class TestPostgresBackfillDiscipline:
|
|||||||
# 1.3.8 两列: 稳态定义随补列前移,否则每进程首写都去抢锁
|
# 1.3.8 两列: 稳态定义随补列前移,否则每进程首写都去抢锁
|
||||||
"stall_ms",
|
"stall_ms",
|
||||||
"stall_count",
|
"stall_count",
|
||||||
|
# 1.3.10 一列,理由同上
|
||||||
|
"stream_end",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _recorder(self, conn):
|
def _recorder(self, conn):
|
||||||
@@ -2415,6 +2424,18 @@ class _StubOcrTransport:
|
|||||||
async def parse_layout(self, *, image, source, call_id):
|
async def parse_layout(self, *, image, source, call_id):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def parse_document(self, *, document, source, call_id):
|
||||||
|
return OcrDocumentTransportResult(
|
||||||
|
elements=[
|
||||||
|
OcrDocumentElement(
|
||||||
|
type="text", bbox=(0.0, 0.0, 1.0, 1.0), page_index=0, text="SYNTH-BODY"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
page_sizes=[(612.0, 792.0)],
|
||||||
|
markdown="# SYNTH-MD",
|
||||||
|
raw={"success": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _governance(scope, sources):
|
def _governance(scope, sources):
|
||||||
"""embed/OCR 两条链路共用的最小治理装配(真实内存后端,不 mock)。"""
|
"""embed/OCR 两条链路共用的最小治理装配(真实内存后端,不 mock)。"""
|
||||||
@@ -2471,6 +2492,20 @@ class TestTextCapCoversEmbedAndOcrChains:
|
|||||||
assert json.loads(row["messages"])[0]["content"] == "<ocr:tex…(略 16 字)"
|
assert json.loads(row["messages"])[0]["content"] == "<ocr:tex…(略 16 字)"
|
||||||
assert row["response"] == "识别结果识别结果…(略 32 字)" # 先经 OCR 自有的 200 字上限
|
assert row["response"] == "识别结果识别结果…(略 32 字)" # 先经 OCR 自有的 200 字上限
|
||||||
|
|
||||||
|
async def test_ocr_document_rows_are_capped(self):
|
||||||
|
"""第三个 OCR 入口的占位串同样受 text_cap 约束(单一遥测出口)。"""
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
client = OcrClient(
|
||||||
|
**_governance("ocr", [_source(name="m1", model="monkey-ocr")]),
|
||||||
|
transport=_StubOcrTransport(),
|
||||||
|
telemetry=rec,
|
||||||
|
text_cap=8,
|
||||||
|
)
|
||||||
|
await client.parse_document(b"%PDF-1.4")
|
||||||
|
row = rec.rows[0]
|
||||||
|
assert json.loads(row["messages"])[0]["content"] == "<ocr:doc…(略 20 字)"
|
||||||
|
assert row["operation"] == "parse_document"
|
||||||
|
|
||||||
|
|
||||||
# —— 遥测降级状态(issue #15 C 组): 共用 tracker + 只读快照 + 节流日志 ——
|
# —— 遥测降级状态(issue #15 C 组): 共用 tracker + 只读快照 + 节流日志 ——
|
||||||
|
|
||||||
@@ -3429,7 +3464,9 @@ class TestRowLevelObservability:
|
|||||||
assert error.count("v" * 200) == 3
|
assert error.count("v" * 200) == 3
|
||||||
assert len(error) < 1200
|
assert len(error) < 1200
|
||||||
|
|
||||||
@pytest.mark.parametrize("operation", ["chat", "embed", "recognize_text", "parse_layout"])
|
@pytest.mark.parametrize(
|
||||||
|
"operation", ["chat", "embed", "recognize_text", "parse_layout", "parse_document"]
|
||||||
|
)
|
||||||
async def test_operation_is_given_by_the_call_site_not_the_exception(self, operation):
|
async def test_operation_is_given_by_the_call_site_not_the_exception(self, operation):
|
||||||
"""新列 `operation` 与 `exc.operation` 是两个语义(设计 §5 I1/I2)。"""
|
"""新列 `operation` 与 `exc.operation` 是两个语义(设计 §5 I1/I2)。"""
|
||||||
exc = TransientError("boom", status_code=500, operation="download_result")
|
exc = TransientError("boom", status_code=500, operation="download_result")
|
||||||
@@ -3586,3 +3623,92 @@ class TestTerminalRowSqlSemantics:
|
|||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
== 0
|
== 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 1.3.8 形态(1.3.10 补列前)的表: 38 个 INSERT 字段 + created_at = 39 物理列。
|
||||||
|
# 冻结字面量(由 _PRE_138_DDL 末行补两列),不从常量切片派生,理由同 _PRE_138_DDL。
|
||||||
|
_PRE_1310_DDL = _PRE_138_DDL.replace(
|
||||||
|
" total_latency_ms INTEGER\n);",
|
||||||
|
" total_latency_ms INTEGER,\n stall_ms INTEGER,\n"
|
||||||
|
" stall_count INTEGER\n);",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamEndColumn:
|
||||||
|
"""1.3.10 结束方式列(#31): 落行、缺省 NULL、补列幂等、manual 告警、各入口取值。"""
|
||||||
|
|
||||||
|
async def test_value_round_trips_and_default_is_null(self, tmp_path):
|
||||||
|
db = tmp_path / "stream_end.db"
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder, call_id="c-eof", stream_end="eof_after_finish")
|
||||||
|
await _record_minimal(recorder, call_id="c-none")
|
||||||
|
recorder.close()
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
rows = dict(conn.execute("SELECT call_id, stream_end FROM llm_calls").fetchall())
|
||||||
|
conn.close()
|
||||||
|
assert rows == {"c-eof": "eof_after_finish", "c-none": None}
|
||||||
|
|
||||||
|
async def test_auto_migrate_backfills_from_138_table(self, tmp_path):
|
||||||
|
db = tmp_path / "pre1310.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute(_PRE_1310_DDL)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
for call_id in ("c1", "c2"): # 二次初始化幂等
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder, call_id=call_id, stream_end="done")
|
||||||
|
recorder.close()
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async def test_manual_mode_names_the_new_alter(self, tmp_path, captured_warnings):
|
||||||
|
db = tmp_path / "manual1310.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute(_PRE_1310_DDL)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||||
|
await _record_minimal(recorder, call_id="c-manual", stream_end="done")
|
||||||
|
recorder.close()
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert len([r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]) == 39
|
||||||
|
assert conn.execute("SELECT call_id FROM llm_calls").fetchone() == ("c-manual",)
|
||||||
|
conn.close()
|
||||||
|
assert "ALTER TABLE llm_calls ADD COLUMN stream_end TEXT" in captured_warnings[0]
|
||||||
|
|
||||||
|
async def test_attempt_takes_stream_end_from_response(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, text_cap=None, scope="LLM").emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(stream_end="eof_after_finish"),
|
||||||
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["stream_end"] == "eof_after_finish"
|
||||||
|
|
||||||
|
async def test_failed_hit_and_terminal_rows_are_null(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=None,
|
||||||
|
error="boom",
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
|
)
|
||||||
|
# 命中行显式不信任 response 上的值(即使上游误带)
|
||||||
|
await emitter.emit_cache_hit(
|
||||||
|
request=_REQ, response=_resp(cache_hit=True, stream_end="done"), operation="chat"
|
||||||
|
)
|
||||||
|
await emitter.emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="c", error="dead", operation="chat", stats=_MIGRATED_STATS
|
||||||
|
)
|
||||||
|
assert [row["stream_end"] for row in rec.rows] == [None, None, None]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import inspect
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
|
CALL_OPERATIONS,
|
||||||
USAGE_SOURCES,
|
USAGE_SOURCES,
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
@@ -13,6 +14,10 @@ from polygateway.types import (
|
|||||||
Effort,
|
Effort,
|
||||||
GlobalLimits,
|
GlobalLimits,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
OcrDocumentElement,
|
||||||
|
OcrDocumentResult,
|
||||||
|
OcrDocumentTransportResult,
|
||||||
|
OcrLayoutElement,
|
||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
ThinkingObservation,
|
ThinkingObservation,
|
||||||
@@ -849,3 +854,61 @@ class _FakeMonotonic:
|
|||||||
|
|
||||||
def advance(self, seconds: float) -> None:
|
def advance(self, seconds: float) -> None:
|
||||||
self.t += seconds
|
self.t += seconds
|
||||||
|
|
||||||
|
|
||||||
|
class TestOcrDocumentTypes:
|
||||||
|
"""#34 设计 §4.1: 文档元素是版面元素 + 内容;结果独立定义。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _element(**overrides):
|
||||||
|
base = {"type": "text", "bbox": (0.0, 0.0, 1.0, 1.0), "page_index": 0, "text": "x"}
|
||||||
|
base.update(overrides)
|
||||||
|
return OcrDocumentElement(**base)
|
||||||
|
|
||||||
|
def test_document_element_is_a_layout_element(self):
|
||||||
|
"""继承的价值: 按 bbox 裁剪的下游函数可以同时吃两种元素。"""
|
||||||
|
assert isinstance(self._element(), OcrLayoutElement)
|
||||||
|
|
||||||
|
def test_document_element_never_equals_a_layout_element(self):
|
||||||
|
"""dataclass 的 `__eq__` 要求同类——已知语义,钉死以免下游拿版面元素去比对文档结果。"""
|
||||||
|
layout = OcrLayoutElement(type="text", bbox=(0.0, 0.0, 1.0, 1.0), page_index=0)
|
||||||
|
assert self._element() != layout
|
||||||
|
|
||||||
|
def test_new_types_are_frozen(self):
|
||||||
|
element = self._element()
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
element.text = "y" # type: ignore[misc]
|
||||||
|
transport = OcrDocumentTransportResult(elements=[], page_sizes=[], markdown="", raw={})
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
transport.markdown = "y" # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_result_is_not_a_subclass_of_layout_result(self):
|
||||||
|
"""父类末尾 `call_stats` 带默认值,子类无默认字段 `markdown` 会在导入期 TypeError。"""
|
||||||
|
from polygateway.types import OcrLayoutResult
|
||||||
|
|
||||||
|
assert not issubclass(OcrDocumentResult, OcrLayoutResult)
|
||||||
|
|
||||||
|
def test_result_carries_provenance_five_plus_markdown(self):
|
||||||
|
fields = [f.name for f in dataclasses.fields(OcrDocumentResult)]
|
||||||
|
assert fields == [
|
||||||
|
"elements",
|
||||||
|
"page_sizes",
|
||||||
|
"markdown",
|
||||||
|
"source_name",
|
||||||
|
"usage",
|
||||||
|
"latency_ms",
|
||||||
|
"call_id",
|
||||||
|
"raw",
|
||||||
|
"call_stats",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallOperations:
|
||||||
|
def test_call_operations_matches_the_literal(self):
|
||||||
|
"""`CALL_OPERATIONS` 此前没有任何读者,与 `CallOperation` 漂移也无人发现。"""
|
||||||
|
from typing import get_args
|
||||||
|
|
||||||
|
from polygateway.types import CallOperation
|
||||||
|
|
||||||
|
assert get_args(CallOperation) == CALL_OPERATIONS
|
||||||
|
assert "parse_document" in CALL_OPERATIONS
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ async def test_salvage_override_stays_in_domain(usage):
|
|||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
first_token_event=None,
|
first_token_event=None,
|
||||||
content_event=None,
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
)
|
)
|
||||||
assert result.usage_source in USAGE_SOURCES
|
assert result.usage_source in USAGE_SOURCES
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ _SIGNATURE_TARGETS = [
|
|||||||
("EmbeddingClient.embed", EmbeddingClient.embed),
|
("EmbeddingClient.embed", EmbeddingClient.embed),
|
||||||
("OcrClient.from_env", OcrClient.from_env),
|
("OcrClient.from_env", OcrClient.from_env),
|
||||||
("OcrClient.recognize_text", OcrClient.recognize_text),
|
("OcrClient.recognize_text", OcrClient.recognize_text),
|
||||||
|
("OcrClient.parse_document", OcrClient.parse_document),
|
||||||
("gather_bounded", polygateway.gather_bounded),
|
("gather_bounded", polygateway.gather_bounded),
|
||||||
("register_provider", register_provider),
|
("register_provider", register_provider),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user