feat: carry caller dimensions through the OCR chain

OcrClient is the third telemetry path that skips the chat onion: _emit
builds its own ChatRequest purely to reuse the shared TelemetryEmitter,
so wiring chat() and embed() alone left every OCR row without a tenant
while those rows land in the same llm_calls table. Take the dimensions
at both public entries, validate them there (anything failing further
down is degraded to a warning), and thread them through _call ->
_attempt -> _emit so success, rejection, cancellation and retryable
failure rows all carry the same pair.
This commit is contained in:
2026-08-17 11:34:39 -04:00
parent 702040d1a3
commit 6ad58a6553
2 changed files with 135 additions and 10 deletions
+82 -10
View File
@@ -18,7 +18,7 @@ import random
import time
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Any, Literal
from loguru import logger
@@ -46,6 +46,7 @@ from polygateway.types import (
OcrTextResult,
Usage,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
if TYPE_CHECKING:
@@ -142,9 +143,21 @@ class OcrClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrTextResult:
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字""""
outcome = await self._call("text", image, session_id, parent_call_id)
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
)
outcome = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
return OcrTextResult(
text=result.text,
@@ -161,9 +174,20 @@ class OcrClient:
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
) -> OcrLayoutResult:
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素""""
outcome = await self._call("layout", image, session_id, parent_call_id)
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
"""
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
)
outcome = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
return OcrLayoutResult(
elements=result.elements,
@@ -195,6 +219,8 @@ class OcrClient:
image: bytes,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome:
if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
@@ -213,7 +239,7 @@ class OcrClient:
continue
async with clock.attempting():
outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
)
if isinstance(outcome, _AttemptOutcome):
return outcome
@@ -294,9 +320,13 @@ class OcrClient:
reasons: dict[str, str],
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4())
started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
try:
result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry))
@@ -304,20 +334,47 @@ class OcrClient:
self._feed_outcome(source.name, ok=True)
latency_ms = int((self._now() - started) * 1000)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, result
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
result,
)
return _AttemptOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc:
await self._gate_on_terminal(exc, entry)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
raise
except asyncio.CancelledError:
if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error="cancelled"
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error="cancelled",
)
raise
except (SourceDeadError, TransientError) as exc:
@@ -327,7 +384,16 @@ class OcrClient:
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
self._feed_outcome(source.name, ok=False)
await self._emit(
kind, image, source, call_id, started, session_id, parent_call_id, error=exc
kind,
image,
source,
call_id,
started,
session_id,
parent_call_id,
tenant_id,
meta,
error=exc,
)
return _FailedAttempt(exc, immediate=dead)
finally:
@@ -389,16 +455,22 @@ class OcrClient:
started: float,
session_id: str | None,
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None,
error: object | None = None,
) -> None:
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
if self._emitter is None:
return
# 这个 ChatRequest 只为复用同一个 Emitter 而现场构造(OCR 不走 chat 洋葱),
# 故调用方维度必须在这里显式填回,否则 OCR 行的维度恒为空
request = ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}],
session_id=session_id,
parent_call_id=parent_call_id,
tenant_id=tenant_id,
meta=meta,
)
latency_ms = int((self._now() - started) * 1000)
response = None