Skip to content

Platform predictors

semantic_evaluation.platforms holds a ready-made predictor for agents that run on a chat platform and emit no Langfuse trace the evaluation can read: Open WebUI and Copilot Studio. The predictor holds real conversations with a candidate copy of the agent, reads the platform's own transcript of each one back, and turns it into PredictionRecords with the measurements the platform can give. A project writes only the part that knows its agent: what to ask, how to reply, and what the answer predicts.

See 04-offline-evaluation.md for the records and traces such a run produces, and 04-run-config.md for how a run file names the predictor.

The three parts

Part Who writes it What it does
Platform The SDK: OpenWebUIPlatform, CopilotStudioPlatform Describes the candidate, runs every conversation of a round, reads each transcript back
ConversationHook The project Turns items into conversations and a conversation's outcome into predictions
PlatformPredictor The SDK Joins the two for the runner and implements PredictionRunner

A ConversationHook has four methods:

Method Returns
conversations(items) The Conversations of the run. One conversation may answer several items: it carries them in items, with the first message and an optional document
next_message(conversation, reply, turn) The next user message, given the agent's last reply and the turn number, or None to end the conversation
predictions(conversation, outcome) One prediction dict per item of conversation.items, in the same order, read from outcome.transcript; outcome.tool_calls holds its parsed tool calls
describe(run_config) Run-config values the project adds to the candidate's description; may return {}

predictions is called only for an outcome without an error; an errored conversation gets an empty prediction for each of its items, see Errored conversations. The predictor fills the rest of each record: the item's identifiers, source_transcript (a reference to the platform's transcript), measurements and error. It leaves trace_id empty, so the run file sets prediction_traces: create_when_missing.

A conversation has a key that groups items into it, and a title that names it in logs, chat titles and the files a failed run leaves behind.

Configuring a run

A platform is configured by one frozen dataclass, defined in the project and named from the run file. Open WebUI:

Field Default Meaning
platform required The platform value written to the run config, for example support_assistant
candidate_prefix required Only assistants whose id starts with this are run
default_candidate required The assistant used when the run config names no candidate
live_runs required Folder for transcripts and candidate snapshots; files go under live_runs/open_webui/
instance "STAGING" The only OPENWEBUI_INSTANCE a round may run on
max_turns 5 Most turns one conversation takes
measure_from "spend_log" Where a conversation's tokens, cost and tool calls come from: "spend_log" or "traces", see Measurements and cost
parallel 4 Conversations run at once; used only with measure_from="traces"
pause 30 Spend log only: seconds between conversations
settle 60 Spend log only: seconds after the last conversation before the spend log is read
spend_rates None Spend log only: USD per million tokens by model name, as SpendRates; None prices at LiteLLM's own spend

Copilot Studio:

Field Default Meaning
platform required The platform value written to the run config
org required The Dataverse organisation URL
publisher required The schema-name prefix of the publisher
candidate_prefix required The agent name prefix a candidate must carry
default_candidate required The agent name used when the run config names no candidate
workflow required The HTTP workflow that calls the candidate
live_runs required Folder for transcripts, snapshots and failed runs; files go under live_runs/copilot_studio/
max_turns 5 Most turns one conversation takes
pause 30 Seconds between conversations
poll_from 29 minutes When transcripts are first read, counted from the latest conversation's last turn
poll_until 75 minutes When the search for transcripts stops, counted the same way
poll_every 60 Seconds between transcript reads
first_turn_attempts 2 Attempts at a first turn that fails on the connection

measure_from and parallel are checked before the round: an unknown source or a parallel that is not a positive integer raises ValueError.

A minimal project module defines the configuration and the hook:

# evaluation/platform.py
from pathlib import Path

from semantic_evaluation.platforms import OpenWebUIConfig

SUPPORT_STAGING = OpenWebUIConfig(
    platform="support_assistant",
    candidate_prefix="eval-",
    default_candidate="eval-support",
    live_runs=Path("evaluation/live_runs"),
)
# evaluation/hook.py
from semantic_evaluation.contracts import EvaluationItem
from semantic_evaluation.platforms import Conversation, ConversationOutcome


class SupportConversations:
    def conversations(self, items: list[EvaluationItem]) -> list[Conversation]:
        return [
            Conversation(
                key=item.item_id,
                title=item.item_id,
                items=[item],
                message=item.input["question"],
                document=None,
            )
            for item in items
        ]

    def next_message(self, conversation, reply, turn):
        return None  # one question, one answer

    def predictions(
        self, conversation: Conversation, outcome: ConversationOutcome
    ) -> list[dict]:
        history = outcome.transcript["chat"]["chat"]["history"]
        # read the final answer from the platform's chat JSON
        return [{"response": final_answer(history)}]

    def describe(self, run_config):
        return {}

The run file names both, by module:attr:

predictor_adapter: semantic_evaluation.platforms:PlatformPredictor
prediction_traces: create_when_missing
run_config:
  platform_config: evaluation.platform:SUPPORT_STAGING
  conversation_hook: evaluation.hook:SupportConversations
  candidate: eval-support
  max_conversations: 40

To measure from traces, set measure_from="traces" in the configuration, and parallel to the number of conversations to run at once.

platform_config names an OpenWebUIConfig or a CopilotStudioConfig instance; conversation_hook names a class the predictor builds with no arguments. The runner builds the predictor with the run's run_config and repeats, so the same file runs from the pod CLI unchanged.

A project whose hook needs arguments builds the predictor itself. Passed to the runner, the predictor is not asked to describe the candidate, so the project calls describe_candidate() first and passes the merged mapping as run_config:

from semantic_evaluation.platforms import PlatformPredictor

predictor = PlatformPredictor(
    run_config, repeats, platform=OpenWebUIPlatform(SUPPORT_STAGING), hook=hook
)
run_config = predictor.describe_candidate()
result = await execute_offline_evaluation(
    manifest=manifest, ..., run_config=run_config, repeats=repeats, predictor=predictor
)

A project's own predictor that wraps PlatformPredictor and is named in predictor_adapter is built by the runner, which describes it through its describe_candidate(). The wrapper must define that method and return the wrapped predictor's describe_candidate(). Without it the runner has nothing to call, the candidate goes undescribed, and the platform's guards never run. A wrapper the caller builds and passes in is described by the caller, as above.

Size cap

A run holds conversations * repeats conversations, and each one costs a platform run. run_config.max_conversations is required, as a positive integer. A run that needs more is refused before any conversation starts; --config max_conversations=N raises the cap.

Run-config keys

Each platform reads these keys from the run config:

Key Platform Meaning
max_conversations both The size cap above, read by the predictor
candidate both The candidate to run; the configuration's default_candidate when absent
upload_mode Open WebUI default (the platform injects retrieved chunks of an uploaded document) or whole (the "Using Entire Document" toggle); default default
workflow Copilot Studio The HTTP workflow, replacing the configuration's workflow for this run

Before the round, the predictor's describe_candidate() merges what the platform and then the hook record about the candidate into the run config. That description labels the run's artifacts, dataset runs and traces, as in Keys that describe the candidate:

Key Open WebUI Copilot Studio
platform the configuration's platform the configuration's platform
candidate the assistant id the agent's schema name
model the assistant's base model, left out when the export names none the model series the agent's configuration names, left out when it names none
delivery attachment text
content_sha256 hash of the saved candidate snapshot hash of the saved candidate snapshot
upload_mode default or whole not written
measured_from spend_log or traces, from measure_from not written
file_context whether uploaded documents are injected into the context not written
bot_id not written the agent's Dataverse bot id
workflow not written the workflow that calls the candidate

The three reserved keys, instructions_version, knowledge_version and release_id, are never written here. The snapshot behind content_sha256 is saved as live_runs/<store>/candidate-<first 12 characters of the hash>.json, so two runs on the same content carry the same hash and the content can be read afterwards.

Environment and access

Open WebUI Copilot Studio
Variables OPENWEBUI_INSTANCE (default DEV), OPENWEBUI_URL_<INSTANCE>, OPENWEBUI_API_KEY_<INSTANCE>; for the spend log also LITELLM_URL_<INSTANCE>, LITELLM_API_KEY_<INSTANCE>; for traces OPENWEBUI_LANGFUSE_BASE_URL_<INSTANCE>, OPENWEBUI_LANGFUSE_PUBLIC_KEY_<INSTANCE>, OPENWEBUI_LANGFUSE_SECRET_KEY_<INSTANCE> COPILOT_WORKFLOW_URL, the HTTP URL of the workflow that calls the candidate
Login The API key's account owns the candidate az login: the Flow and Dataverse tokens come from az account get-access-token, with no app registration
Guard on the instance A round runs only when OPENWEBUI_INSTANCE equals the configuration's instance Not applicable
Guard on the candidate The assistant id must start with candidate_prefix, and the assistant must exist The agent's schema name must start with the prefix, the agent must exist, and the workflow's agent node must call it

With measure_from="traces" no LiteLLM variable is needed. The three OPENWEBUI_LANGFUSE_* variables name the Langfuse project the instance writes its traces to; either key may be a Key Vault secret URI. With none of them set, the traces are read from the evaluation's own Langfuse project (the LANGFUSE_* variables). describe_candidate() checks these credentials before the round, and raises ValueError when only some of the three are set.

Both guards raise ValueError from describe_candidate(), before any conversation, so a round never runs on the live agent or on the wrong instance. Create the candidate as a copy of the live assistant or agent under the prefix first. On Open WebUI the copy includes its skills and belongs to the API key's account, because the server offers a conversation only the skills its caller can read.

How a round runs

The predictor runs one round for the whole run: the first predict_items call runs every conversation of every repeat, and each repeat is served from the result.

Open WebUI's round depends on measure_from. Background tasks (title, tags, follow-ups) are off in both modes, so the measured work is the conversation's own model calls. Each chat is read back and saved with the driver's turn log (each turn records its message_id) as live_runs/open_webui/<chat id>.json; then the chat and its upload are deleted.

measure_from="spend_log" measure_from="traces"
Conversations One after another, repeat by repeat, with pause seconds between them parallel at a time, with no pause
Why The spend log names no chat, so a conversation's calls are matched to it by time, and overlapping conversations could not be told apart Every trace names its chat, so conversations do not need to be kept apart
After the round Waits settle seconds, reads the spend log for the round's time window, matches rows to each conversation's turns Reads each chat's traces and waits until they stop changing, because Langfuse ingests spans and computes cost a while after a turn ends

With measure_from="traces" and parallel above 1, the hook's next_message is called from several threads at once, so it must be thread-safe: keep no state shared between conversations that a call changes, or guard it with a lock. conversations, predictions and describe are called from the main thread.

Copilot Studio also runs the conversations one after another with pause between them, but writes a conversation's Dataverse conversationtranscripts row only when its session times out, long after the last turn. The round drives every conversation first. From poll_from after the latest conversation's last turn it reads the candidate's transcripts every poll_every seconds until each is found or poll_until has passed. A conversation's transcript is the row whose name is the workflow's conversation id, an underscore and the agent's bot id. A first turn that fails on the connection (no HTTP status, or a 5xx) opened no conversation, so it is made again, after pause, up to first_turn_attempts attempts in all. Copilot Studio keeps transcripts for 30 days by default, so read them within that window. Each transcript is saved with the driver's turns as live_runs/copilot_studio/<transcript id>.json.

Measurements and cost

Open WebUI measures a conversation from one of two sources, chosen by measure_from:

Measurement From traces From the spend log
wall_time_s the sum of the conversation's turn times, timed by the runner the same
Tokens summed over the chat's trace observations, cached input included summed over the spend rows matched to the conversation's turns by time
Cached input counted from the usage details, under any of the names the evaluation recognises counted from the rows' cache details; some rows carry none, and the notes say so
cost_usd Langfuse's computed cost; the notes name a model Langfuse has no price for spend_rates when set, else the rows' spend, priced at LiteLLM's own rates
tool_calls, tool_calls_by_name observations of type TOOL and RETRIEVER from the chat's assistant messages
Notes a turn with no trace, and a model with no price no matching spend row, a model with no rate, and a spend log that cannot be read

Copilot Studio measures only wall time (the sum of the conversation's turn times) and tool calls, from the transcript's ToolCallTrace activities.

From traces, a conversation's turns are matched to traces by message_id, the id of the assistant message a turn produced, read from the trace metadata. The deployment must write one Langfuse trace per user message, with the chat id as the session id and message_id in the trace metadata. A turn with no trace leaves its share of the measurements out, and the notes name it. An instance that traces only a share of its chats leaves the other conversations unmeasured, so use the spend log there; the spend log works on any instance.

With spend_rates set, each row is priced at the rates of the first entry whose key is a substring of the row's model name; a row with no match is left unpriced, and the notes name its model. With spend_rates=None, the cost is the sum of the rows' spend column, priced at LiteLLM's own rates, and the notes say so. When no spend row matches a conversation, the measurements carry no tokens and the notes say that. A spend log that cannot be read leaves the conversations' transcripts intact and says so in the notes.

Copilot Studio reports no tokens and no credits per conversation to a program: credits are read by hand from the agent's Activity page, and every outcome's notes say so.

Errored conversations

A conversation is errored when the platform left no transcript to read:

  • no conversation was opened, for example the first turn failed or the workflow returned no conversation id;
  • no transcript appeared within poll_until, or an Open WebUI run raised;
  • the transcript cannot be read.

An answer that was read but is wrong, empty or invalid is not an error; it is a prediction, and the evaluators score it. An errored conversation does not stop the round. Its outcome carries the reason as error and no transcript, its notes repeat the reason, and the failure is logged as a warning. Each of its items becomes a PredictionRecord with an empty prediction and that error. Errored predictions are not scored: they are left out of the scoring request, the trace metrics, the group aggregates and the agreement. The run file's max_error_fraction (default 0) is the largest share of a scope's predictions that may be errored before the gate fails, and the run report prints errored: n of N. See Errored predictions for what the runner and the publication do with them.

Delivering a document

A conversation's document reaches the agent in the way its platform takes files.

Delivery
Open WebUI Uploaded as an attachment and sent with the first message; optional. Deleted after the run
Copilot Studio Sent as text after the first message: a blank line, then the text of the .docx or the PDF. Any other type, and a PDF without a text layer (a scan), is refused before the round

A .docx is read with the standard library: a line per paragraph, a line per table row with the cells joined by |; headers and footers are not read. Reading a PDF needs the pdf extra:

uv add "semantic-evaluation[pdf]"

Reading transcripts, and what is public

The readers below take a platform's own transcript, live from ConversationOutcome.transcript or stored (a chat export, a Dataverse row saved earlier). All are exported from semantic_evaluation.platforms.

Name Returns
ToolCall One tool call: name, target, status (completed, failed or no result), duration_ms, error, result_text
open_webui_active_path(history) The messages from the first user message to the active leaf; history is transcript["chat"]["history"] of a chat as Open WebUI returns or exports it
open_webui_tool_calls(message) The tool calls of one assistant message
open_webui_whole_file_sources(message) {file name: characters} of the whole files the platform put in the model's context, not retrieved chunks
copilot_tool_calls(activities) The tool calls of a Copilot Studio transcript; activities is the list in the content JSON of a Dataverse transcript row
delivered_text(path) The text a .docx or PDF is sent as

ConversationOutcome.tool_calls holds the tool calls the platform read from the transcript, the same ones measurements.tool_calls counts. It is empty for an errored outcome. active_path and whole_file_sources in semantic_evaluation.platforms.transcripts are the older names of the first and third readers; they stay importable until 4.0.0 but are not public.

The modules below are internal. They stay importable at their current path and name until 4.0.0, and their behaviour may change in any minor release. Use the replacement:

Module Replacement
platforms.open_webui.client None in the SDK: a script that calls Open WebUI routes the platform does not use keeps its own REST client
platforms.open_webui.chat OpenWebUIPlatform through PlatformPredictor: it runs the conversation, uploads the document and reads the chat back
platforms.open_webui.litellm_spend, except SpendRates, which is public ConversationOutcome.measurements: tokens and cost_usd per conversation from OpenWebUIPlatform with measure_from="spend_log"
platforms.open_webui.chat_traces ConversationOutcome.measurements: tokens, cost_usd and tool calls per conversation from OpenWebUIPlatform with measure_from="traces"
platforms.copilot_studio.workflow, platforms.copilot_studio.dataverse CopilotStudioPlatform through PlatformPredictor
platforms.documents.docx_text, pdf_text delivered_text, which returns the same text
Other helpers in platforms.transcripts (parameters, copilot_target, save_transcript) The readers above