Skip to content

Commit 7ede3d3

Browse files
Render rerank observability (0060b)
Render the typed rerank events in both bundled observers, replacing the safe-skip guards: the OTel openarmature.rerank.complete span (observability §5.5.13) and the Langfuse Retriever observation (§8.4.7, a new retriever() client/adapter surface + the searchUnits usageDetails convention on LangfuseUsage). Usage figures emit conditionally per proposal 0093; the span renders the scored results (RerankEvent.output_results per 0089) and is not gated by disable_llm_spans. Un-defer conformance fixtures 099-108 / 138 / 141 / 142 (109 metrics rides 0067). conformance.toml 0060 and 0089 move to implemented, completing the rerank capability and the 0089 output-rendering surface.
1 parent 256ab2a commit 7ede3d3

6 files changed

Lines changed: 692 additions & 59 deletions

File tree

conformance.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -612,9 +612,9 @@ note = "Retrieval-provider embedding capability. The EmbeddingProvider protocol
612612
# variants + OTel ``openarmature.rerank.complete`` span / Langfuse Retriever
613613
# observation / rerank metrics. Sibling to the embedding surface (0059).
614614
[proposals."0060"]
615-
status = "partial"
615+
status = "implemented"
616616
since = "0.16.0"
617-
note = "Retrieval-provider rerank protocol. The RerankProvider protocol (retrieval-provider §5) + the RerankResponse / ScoredDocument / RerankUsage / RerankRuntimeConfig response types (§6) + the RerankRuntimeConfig runtime config (§2) ship, alongside the CohereRerankProvider reference reranker against POST /v2/rerank (§8.4, Cohere-shape): the request body is {model, query, documents (string array), top_n from top_k}, return_documents is a silent no-op (the Cohere wire has no such field), and max_tokens_per_doc rides the extras pass-through bag. The provider parses {id, model?, results: [{index, relevance_score, document?}], meta.billed_units.{search_units, input_tokens}}, sorts results by relevance_score descending, reads the document echo only when present (never auto-filled), builds a RerankUsage record only when the provider surfaces >= 1 usage figure (else usage = null, never fabricated), and enforces the §6 invariants -- valid index into the input documents, no duplicate index, len(results) <= top_k when supplied -- raising provider_invalid_response otherwise. Pre-send validation raises provider_invalid_request on an empty query, an empty documents list, or top_k <= 0 (top_k MAY exceed len(documents)). The provider dispatches the typed RerankEvent / RerankFailedEvent (graph-engine §6) per rerank() call, mutually exclusive, alongside the §7-category exception on failure; RerankEvent.output_results is populated unconditionally on success (proposal 0089). The §7 categories reuse the shared llm-provider taxonomy. Protocol fixtures 006-012 pass. partial because the observability rendering (OTel §5.5.13 openarmature.rerank.complete span + Langfuse §8.4.7 Retriever observation + the rerank-metrics surface; fixtures 099-109 / 138 / 141 / 142) lands in the 0060b follow-on -- the bundled OTel + Langfuse observers safely skip the rerank events for now. Wire-format details (0090 Cohere rerank fixtures 028-031) are a separate proposal."
617+
note = "Retrieval-provider rerank protocol. The RerankProvider protocol (retrieval-provider §5) + the RerankResponse / ScoredDocument / RerankUsage / RerankRuntimeConfig response types (§6) + the RerankRuntimeConfig runtime config (§2) ship, alongside the CohereRerankProvider reference reranker against POST /v2/rerank (§8.4, Cohere-shape): the request body is {model, query, documents (string array), top_n from top_k}, return_documents is a silent no-op (the Cohere wire has no such field), and max_tokens_per_doc rides the extras pass-through bag. The provider parses {id, model?, results: [{index, relevance_score, document?}], meta.billed_units.{search_units, input_tokens}}, sorts results by relevance_score descending, reads the document echo only when present (never auto-filled), builds a RerankUsage record only when the provider surfaces >= 1 usage figure (else usage = null, never fabricated), and enforces the §6 invariants -- valid index into the input documents, no duplicate index, len(results) <= top_k when supplied -- raising provider_invalid_response otherwise. Pre-send validation raises provider_invalid_request on an empty query, an empty documents list, or top_k <= 0 (top_k MAY exceed len(documents)). The provider dispatches the typed RerankEvent / RerankFailedEvent (graph-engine §6) per rerank() call, mutually exclusive, alongside the §7-category exception on failure; RerankEvent.output_results is populated unconditionally on success (proposal 0089). The §7 categories reuse the shared llm-provider taxonomy. Both bundled observers render the rerank events (0060b): the OTelObserver emits an openarmature.rerank.complete span (observability §5.5.13, SpanKind.CLIENT) parented lineage-aware under the calling node, carrying the GenAI semconv subset (gen_ai.system / request.model / response.model / response.id + the conditional usage.input_tokens, gated by disable_genai_semconv) + the OA openarmature.rerank.query_length (UTF-8 bytes) / .document_count / .top_k (omitted when null) / .result_count identity attrs + the conditional .search_units + the payload-gated (disable_provider_payload) .query / .documents / .results (the results sourced from RerankEvent.output_results per proposal 0089) -- no gen_ai.operation.name (deferred, no upstream rerank coverage), not gated by disable_llm_spans; failure renders ERROR status + error.type + an exception event + openarmature.error.category. The LangfuseObserver renders a dedicated Retriever observation (asType retriever, §8.4.7) with model = response_model-or-model, usageDetails.{input, searchUnits} (empty map when usage = null), the openarmature_query_length / _document_count / _top_k / _result_count / _response_id metadata, and the payload-gated input {query, documents} + output results; failure renders an ERROR-level Retriever observation with error_category as statusMessage, error_type / error_message in metadata, and no output. Protocol fixtures 006-012 + observability fixtures 099-108 / 138 / 141 / 142 pass. The §11 rerank-metrics path (fixture 109) is proposal 0067's metrics surface (one GenAI instrument across LLM + embedding + rerank calls), tracked and deferred under 0067 (cf. 089), not part of 0060's own observability surface. Wire-format details (0090 Cohere rerank fixtures 028-031) are a separate proposal."
618618

619619
# Spec v0.55.0 (proposal 0065; repo pins v0.55.1). Failure-isolation
620620
# cause fidelity at non-node placements (pipeline-utilities §6.3 /
@@ -856,9 +856,9 @@ status = "not-yet"
856856
# (graph-engine §6 EmbeddingEvent.output_vectors / RerankEvent.
857857
# output_results + observability output mappings).
858858
[proposals."0089"]
859-
status = "partial"
859+
status = "implemented"
860860
since = "0.16.0"
861-
note = "The embedding half shipped in 0059b (#198): EmbeddingEvent.output_vectors is populated by OpenAIEmbeddingProvider and rendered as the Langfuse Embedding observation output (observability §8.4.5), sourced event-side per 0089; observability fixtures 083 (re-sourced) + 137 pass. partial because the rerank half is unshipped -- RerankEvent.output_results and the rerank observation output rendering ride the rerank capability (proposal 0060, not-yet); observability fixtures 108 + 138 defer with it."
861+
note = "The embedding half shipped in 0059b (#198): EmbeddingEvent.output_vectors is populated by OpenAIEmbeddingProvider and rendered as the Langfuse Embedding observation output (observability §8.4.5), sourced event-side per 0089; observability fixtures 083 (re-sourced) + 137 pass. The rerank half now ships with the 0060b rerank observability: RerankEvent.output_results is populated unconditionally on success by CohereRerankProvider and rendered as both the OTel openarmature.rerank.results attribute (observability §5.5.13, payload-gated) and the Langfuse Retriever observation output (§8.4.7), sourced event-side per 0089; observability fixtures 108 + 138 pass. This completes 0089 across the embedding + rerank surfaces."
862862

863863
# Spec v0.85.0 (proposal 0090). Cohere rerank wire mapping
864864
# (retrieval-provider §8.4, POST /v2/rerank; backs 0060's Cohere-shaped

src/openarmature/observability/langfuse/adapter.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,65 @@ def embedding(
519519
)
520520
return _SpanHandle(obs)
521521

522+
def retriever(
523+
self,
524+
*,
525+
trace_id: str,
526+
name: str | None = None,
527+
model: str | None = None,
528+
usage: LangfuseUsage | None = None,
529+
metadata: dict[str, Any] | None = None,
530+
parent_observation_id: str | None = None,
531+
level: ObservationLevel = "DEFAULT",
532+
status_message: str | None = None,
533+
input: Any = None,
534+
output: Any = None,
535+
start_time: datetime | None = None,
536+
) -> LangfuseSpanHandle:
537+
# v4 unifies observations under start_observation(as_type=); a Retriever
538+
# observation routes through as_type="retriever" (proposal 0060), the
539+
# SDK's native LangfuseRetriever span. It carries model + usage like an
540+
# Embedding but is a distinct type; usage maps to v4's usage_details.
541+
# The rerank usageDetails convention (§8.4.7) exposes input tokens under
542+
# "input" and search-units under "searchUnits". Back-date via the
543+
# private OTel tracer when start_time is supplied, exactly as
544+
# generation()/tool()/embedding() do.
545+
extra_kwargs: dict[str, Any] = {"model": model, "input": input, "output": output}
546+
if usage is not None:
547+
usage_details: dict[str, int] = {}
548+
if usage.input is not None:
549+
usage_details["input"] = usage.input
550+
if usage.search_units is not None:
551+
usage_details["searchUnits"] = usage.search_units
552+
extra_kwargs["usage_details"] = usage_details
553+
present_extra = {k: v for k, v in extra_kwargs.items() if v is not None}
554+
if start_time is not None:
555+
from langfuse._client.span import LangfuseRetriever
556+
557+
obs = self._start_back_dated_observation(
558+
LangfuseRetriever,
559+
trace_id=trace_id,
560+
name=name,
561+
metadata=metadata,
562+
parent_observation_id=parent_observation_id,
563+
level=level,
564+
status_message=status_message,
565+
start_time=start_time,
566+
**present_extra,
567+
)
568+
else:
569+
obs = self._start_observation(
570+
as_type="retriever",
571+
trace_id=trace_id,
572+
name=name,
573+
metadata=metadata,
574+
parent_observation_id=parent_observation_id,
575+
level=level,
576+
status_message=status_message,
577+
**present_extra,
578+
)
579+
return _SpanHandle(obs)
580+
522581
def _start_back_dated_observation(
523582
self,
524583
observation_cls: type[Any],

src/openarmature/observability/langfuse/client.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from datetime import datetime
3434
from typing import Any, Literal, Protocol, runtime_checkable
3535

36-
ObservationType = Literal["span", "generation", "event", "tool", "embedding"]
36+
ObservationType = Literal["span", "generation", "event", "tool", "embedding", "retriever"]
3737

3838
# Langfuse-supported `level` values per spec §8.4.2 (statusMessage pair).
3939
ObservationLevel = Literal["DEFAULT", "DEBUG", "INFO", "WARNING", "ERROR"]
@@ -46,6 +46,12 @@ class LangfuseUsage:
4646
input: int | None = None
4747
output: int | None = None
4848
total: int | None = None
49+
# Rerank observability (proposal 0060, observability §8.4.7): Langfuse's
50+
# usageDetails is an open-shape map, and the OA convention adds a
51+
# `searchUnits` figure for the Retriever observation. Modeled here as a
52+
# snake_case field; the SDK adapter maps it to the camelCase `searchUnits`
53+
# usage_details key. None when the provider reported no search-units count.
54+
search_units: int | None = None
4955

5056

5157
@dataclass
@@ -297,6 +303,35 @@ def embedding(
297303
the caller ``.end()``s at outcome time."""
298304
...
299305

306+
# Proposal 0060 rerank observability: a dedicated Retriever observation
307+
# (Langfuse asType="retriever", spec observability §8.4.7), NOT a
308+
# Generation with a discriminator. Langfuse positions Retriever for data
309+
# retrieval steps (broad enough to cover reranking); the `usage` record's
310+
# `search_units` / `input` figures render into the open-shape usageDetails
311+
# map (the OA `searchUnits` key convention is defined in §8.4.7).
312+
def retriever(
313+
self,
314+
*,
315+
trace_id: str,
316+
name: str | None = None,
317+
model: str | None = None,
318+
usage: LangfuseUsage | None = None,
319+
metadata: dict[str, Any] | None = None,
320+
parent_observation_id: str | None = None,
321+
level: ObservationLevel = "DEFAULT",
322+
status_message: str | None = None,
323+
input: Any = None,
324+
output: Any = None,
325+
start_time: datetime | None = None,
326+
) -> LangfuseSpanHandle:
327+
"""Open a dedicated Retriever observation. Like :meth:`embedding`
328+
for the model / usage surface but a distinct observation type:
329+
carries ``model`` / ``usage`` (input-token + search-unit figures) /
330+
``input`` (the query + documents) / ``output`` (the scored results) /
331+
metadata / level. Returns a minimal handle the caller ``.end()``s at
332+
outcome time."""
333+
...
334+
300335
def force_flush(self, timeout_ms: int = 30_000) -> bool:
301336
"""Flush any pending outbound buffer in the underlying sink.
302337
@@ -591,6 +626,39 @@ def embedding(
591626
trace.observations.append(observation)
592627
return _InMemorySpanHandle(observation=observation)
593628

629+
def retriever(
630+
self,
631+
*,
632+
trace_id: str,
633+
name: str | None = None,
634+
model: str | None = None,
635+
usage: LangfuseUsage | None = None,
636+
metadata: dict[str, Any] | None = None,
637+
parent_observation_id: str | None = None,
638+
level: ObservationLevel = "DEFAULT",
639+
status_message: str | None = None,
640+
input: Any = None,
641+
output: Any = None,
642+
start_time: datetime | None = None,
643+
) -> LangfuseSpanHandle:
644+
trace = self._get_trace(trace_id)
645+
observation = LangfuseObservation(
646+
id=self._mint_observation_id(),
647+
type="retriever",
648+
name=name,
649+
metadata=dict(metadata) if metadata is not None else {},
650+
parent_observation_id=parent_observation_id,
651+
level=level,
652+
status_message=status_message,
653+
input=input,
654+
output=output,
655+
model=model,
656+
usage=usage,
657+
start_time=start_time,
658+
)
659+
trace.observations.append(observation)
660+
return _InMemorySpanHandle(observation=observation)
661+
594662
def force_flush(self, timeout_ms: int = 30_000) -> bool:
595663
# In-memory recorder has no outbound buffer; every observation
596664
# is captured synchronously on its create call. The ``timeout_ms``

0 commit comments

Comments
 (0)