Skip to content

Commit 4d16551

Browse files
Lift shared retrieval event-builders to a module (#210)
The four retrieval reference providers (openai, tei, cohere, jina) built identical EmbeddingEvent / RerankEvent and their failed variants from the same ContextVar identity snapshot, differing only in the provider id, the bound model, and the response fields. Lift the four builders into retrieval/_events.py as free functions; each provider passes its identity and response in. Also fold the duplicated /v1 and /v2 base_url guard into a shared normalize_base_url helper (openai, cohere, jina). TEI has no version prefix and keeps its inline rstrip. Pure refactor with no behavior change: the full test suite passes unchanged, and the conformance fixtures that assert every event field are the safety net.
1 parent 96ca62c commit 4d16551

5 files changed

Lines changed: 365 additions & 681 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
# Shared observability-event builders for the retrieval reference providers.
2+
# The four bundled providers (openai / tei / cohere / jina) construct the same
3+
# typed EmbeddingEvent / EmbeddingFailedEvent / RerankEvent / RerankFailedEvent
4+
# from identical ContextVar identity snapshots; the only per-provider variation
5+
# is the provider identifier (gen_ai.system), the bound model, and the response
6+
# fields. These free functions carry that construction once so a provider passes
7+
# its identity + response in rather than duplicating the body.
8+
9+
"""Shared event builders for the retrieval reference providers.
10+
11+
Free functions that construct the typed embedding / rerank observer events
12+
from a provider's identity (``provider`` / ``model`` /
13+
``populate_caller_metadata``), the parsed response (or raised error), and the
14+
per-call request context. The identity / scoping fields are sourced from the
15+
calling-node correlation ContextVars at build time; the outcome fields come
16+
from the response or exception.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from collections.abc import Mapping
22+
from typing import Any
23+
24+
from openarmature.graph.events import (
25+
EmbeddingEvent,
26+
EmbeddingFailedEvent,
27+
RerankEvent,
28+
RerankFailedEvent,
29+
)
30+
from openarmature.llm.errors import LlmProviderError
31+
from openarmature.observability.correlation import (
32+
current_attempt_index,
33+
current_branch_name,
34+
current_correlation_id,
35+
current_fan_out_index,
36+
current_invocation_id,
37+
current_namespace_prefix,
38+
)
39+
from openarmature.observability.metadata import AttributeValue, current_invocation_metadata
40+
41+
from .response import EmbeddingResponse, RerankResponse
42+
43+
44+
def normalize_base_url(base_url: str, *, guard_prefix: str) -> str:
45+
"""Strip a trailing slash from ``base_url`` and reject a doubled version prefix.
46+
47+
Returns the trailing-slash-stripped host root. Raises :class:`ValueError`
48+
when the stripped value already ends in ``guard_prefix`` (e.g. ``"/v1"`` /
49+
``"/v2"``).
50+
"""
51+
# base_url is the host root; the provider appends the versioned routes
52+
# itself (e.g. /v1/embeddings, /v2/rerank), so a base_url that already ends
53+
# in the version prefix would produce a doubled /v1/v1 (or /v2/v2) path that
54+
# 404s. Guard the footgun at construction; trailing slashes are stripped.
55+
normalized = base_url.rstrip("/")
56+
if normalized.endswith(guard_prefix):
57+
raise ValueError(
58+
f"base_url should be the host root; the provider appends the "
59+
f"{guard_prefix} routes itself, so a trailing {guard_prefix} would "
60+
f"produce a doubled {guard_prefix}{guard_prefix} path. Got {base_url!r}."
61+
)
62+
return normalized
63+
64+
65+
def build_embedding_event(
66+
response: EmbeddingResponse,
67+
latency_ms: float,
68+
*,
69+
provider: str,
70+
model: str,
71+
populate_caller_metadata: bool,
72+
call_id: str,
73+
input_strings: list[str],
74+
request_params: dict[str, Any],
75+
request_extras: dict[str, Any],
76+
active_prompt: Any,
77+
active_prompt_group: Any,
78+
) -> EmbeddingEvent:
79+
"""Construct the typed EmbeddingEvent for the success path.
80+
81+
Sources identity / scoping from the calling-node ContextVars and outcome
82+
fields from the response.
83+
"""
84+
namespace = current_namespace_prefix()
85+
node_name = namespace[-1] if namespace else ""
86+
invocation_id = current_invocation_id() or ""
87+
caller_metadata: Mapping[str, AttributeValue] | None = None
88+
if populate_caller_metadata:
89+
caller_metadata = dict(current_invocation_metadata())
90+
return EmbeddingEvent(
91+
invocation_id=invocation_id,
92+
correlation_id=current_correlation_id(),
93+
node_name=node_name,
94+
namespace=namespace,
95+
attempt_index=current_attempt_index(),
96+
fan_out_index=current_fan_out_index(),
97+
branch_name=current_branch_name(),
98+
provider=provider,
99+
model=model,
100+
response_id=response.response_id,
101+
response_model=response.model,
102+
usage=response.usage,
103+
latency_ms=latency_ms,
104+
input_strings=input_strings,
105+
input_count=len(input_strings),
106+
dimensions=response.dimensions,
107+
# Populated unconditionally on success per observability §5.5.9;
108+
# privacy gating is observer-side at rendering (symmetric with
109+
# input_strings). Sources EmbeddingEvent.output_vectors from the
110+
# parsed response vectors for the §8.4.5 embedding.output mapping.
111+
output_vectors=response.vectors,
112+
request_params=request_params,
113+
request_extras=request_extras,
114+
active_prompt=active_prompt,
115+
active_prompt_group=active_prompt_group,
116+
call_id=call_id,
117+
caller_invocation_metadata=caller_metadata,
118+
)
119+
120+
121+
def build_embedding_failed_event(
122+
exc: LlmProviderError,
123+
latency_ms: float,
124+
*,
125+
provider: str,
126+
model: str,
127+
populate_caller_metadata: bool,
128+
call_id: str,
129+
input_strings: list[str],
130+
request_params: dict[str, Any],
131+
request_extras: dict[str, Any],
132+
active_prompt: Any,
133+
active_prompt_group: Any,
134+
) -> EmbeddingFailedEvent:
135+
"""Construct the typed EmbeddingFailedEvent for the failure path.
136+
137+
``error_type`` defaults to the exception class name (the "upstream
138+
exception class name" style).
139+
"""
140+
namespace = current_namespace_prefix()
141+
node_name = namespace[-1] if namespace else ""
142+
invocation_id = current_invocation_id() or ""
143+
caller_metadata: Mapping[str, AttributeValue] | None = None
144+
if populate_caller_metadata:
145+
caller_metadata = dict(current_invocation_metadata())
146+
return EmbeddingFailedEvent(
147+
invocation_id=invocation_id,
148+
correlation_id=current_correlation_id(),
149+
node_name=node_name,
150+
namespace=namespace,
151+
attempt_index=current_attempt_index(),
152+
fan_out_index=current_fan_out_index(),
153+
branch_name=current_branch_name(),
154+
provider=provider,
155+
model=model,
156+
latency_ms=latency_ms,
157+
input_strings=input_strings,
158+
request_params=request_params,
159+
request_extras=request_extras,
160+
active_prompt=active_prompt,
161+
active_prompt_group=active_prompt_group,
162+
call_id=call_id,
163+
error_category=exc.category,
164+
error_type=type(exc).__name__,
165+
error_message=str(exc),
166+
caller_invocation_metadata=caller_metadata,
167+
)
168+
169+
170+
def build_rerank_event(
171+
response: RerankResponse,
172+
latency_ms: float,
173+
*,
174+
provider: str,
175+
model: str,
176+
populate_caller_metadata: bool,
177+
call_id: str,
178+
query: str,
179+
documents: list[str],
180+
top_k: int | None,
181+
request_params: dict[str, Any],
182+
request_extras: dict[str, Any],
183+
active_prompt: Any,
184+
active_prompt_group: Any,
185+
) -> RerankEvent:
186+
"""Construct the typed RerankEvent for the success path.
187+
188+
One event per rerank() call: ``documents`` is the full input and the
189+
results are the stitched output, not per-chunk. Sources identity / scoping
190+
from the calling-node ContextVars and outcome fields from the response.
191+
"""
192+
namespace = current_namespace_prefix()
193+
node_name = namespace[-1] if namespace else ""
194+
invocation_id = current_invocation_id() or ""
195+
caller_metadata: Mapping[str, AttributeValue] | None = None
196+
if populate_caller_metadata:
197+
caller_metadata = dict(current_invocation_metadata())
198+
return RerankEvent(
199+
invocation_id=invocation_id,
200+
correlation_id=current_correlation_id(),
201+
node_name=node_name,
202+
namespace=namespace,
203+
attempt_index=current_attempt_index(),
204+
fan_out_index=current_fan_out_index(),
205+
branch_name=current_branch_name(),
206+
provider=provider,
207+
model=model,
208+
response_id=response.response_id,
209+
response_model=response.model,
210+
usage=response.usage,
211+
latency_ms=latency_ms,
212+
query=query,
213+
documents=documents,
214+
document_count=len(documents),
215+
top_k=top_k,
216+
result_count=len(response.results),
217+
# Populated unconditionally on success per proposal 0089; privacy
218+
# gating is observer-side at rendering (symmetric with query /
219+
# documents). Sources output_results from the parsed response.
220+
output_results=list(response.results),
221+
request_params=request_params,
222+
request_extras=request_extras,
223+
active_prompt=active_prompt,
224+
active_prompt_group=active_prompt_group,
225+
call_id=call_id,
226+
caller_invocation_metadata=caller_metadata,
227+
)
228+
229+
230+
def build_rerank_failed_event(
231+
exc: LlmProviderError,
232+
latency_ms: float,
233+
*,
234+
provider: str,
235+
model: str,
236+
populate_caller_metadata: bool,
237+
call_id: str,
238+
query: str,
239+
documents: list[str],
240+
top_k: int | None,
241+
request_params: dict[str, Any],
242+
request_extras: dict[str, Any],
243+
active_prompt: Any,
244+
active_prompt_group: Any,
245+
) -> RerankFailedEvent:
246+
"""Construct the typed RerankFailedEvent for the failure path.
247+
248+
``error_type`` defaults to the exception class name (the "upstream
249+
exception class name" style).
250+
"""
251+
namespace = current_namespace_prefix()
252+
node_name = namespace[-1] if namespace else ""
253+
invocation_id = current_invocation_id() or ""
254+
caller_metadata: Mapping[str, AttributeValue] | None = None
255+
if populate_caller_metadata:
256+
caller_metadata = dict(current_invocation_metadata())
257+
return RerankFailedEvent(
258+
invocation_id=invocation_id,
259+
correlation_id=current_correlation_id(),
260+
node_name=node_name,
261+
namespace=namespace,
262+
attempt_index=current_attempt_index(),
263+
fan_out_index=current_fan_out_index(),
264+
branch_name=current_branch_name(),
265+
provider=provider,
266+
model=model,
267+
latency_ms=latency_ms,
268+
query=query,
269+
documents=documents,
270+
document_count=len(documents),
271+
top_k=top_k,
272+
request_params=request_params,
273+
request_extras=request_extras,
274+
active_prompt=active_prompt,
275+
active_prompt_group=active_prompt_group,
276+
call_id=call_id,
277+
error_category=exc.category,
278+
error_type=type(exc).__name__,
279+
error_message=str(exc),
280+
caller_invocation_metadata=caller_metadata,
281+
)
282+
283+
284+
__all__ = [
285+
"build_embedding_event",
286+
"build_embedding_failed_event",
287+
"build_rerank_event",
288+
"build_rerank_failed_event",
289+
"normalize_base_url",
290+
]

0 commit comments

Comments
 (0)