refactor(fase2a): konsolider fire skriptede klienter til én kanonisk (S2.5)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 07:37:43 +02:00
commit 620d5cfb83
3 changed files with 136 additions and 108 deletions

View file

@ -67,21 +67,48 @@ _CHECKER_APPROVE = "Tallene er innenfor feasibelt område og resonnementet holde
class ScriptedChatClient(OpenAIChatCompletionClient):
"""A network-free, SCRIPTED chat client: it returns a fixed ``reply`` and records every prompt
it receives into a shared ``sink`` (the observation probe). It is a stand-in for a real model
it proves the loop's plumbing, NOT model behaviour.
"""The ONE canonical network-free scripted chat client (S2.5 consolidation): a single
``_inner_get_response`` body shared by the simulation's fixed-reply client AND conftest's three
test doubles (which subclass it). Parametrized by a ``reply_selector`` over
``(prompt_blob, role)`` plus an optional ``sink`` recording every prompt the four
previously-divergent ``_inner_get_response`` bodies collapse to this one.
Subclasses the LAYERED ``OpenAIChatCompletionClient`` (not the minimal ``BaseChatClient``) so the
always-attached ``BudgetMiddleware`` is not silently no-op'd (verified). Construction is offline
(loopback ``base_url`` + dummy key); ``_inner_get_response`` intercepts before any HTTP."""
(loopback ``base_url`` + dummy key); ``_inner_get_response`` intercepts before any HTTP.
Back-compat constructors are preserved (divergent PUBLIC surfaces the external test call-sites
depend on): ``ScriptedChatClient(reply, sink)`` (POSITIONAL used by ``scripted_factory``) is
sugar for a constant selector; the ``call_count`` attribute + ``model``/OTEL ``"synthetic"`` are
always present; subclasses pass ``reply_selector=`` / ``default_reply=`` for scripted-list,
prompt-scan, or record-only behaviour."""
OTEL_PROVIDER_NAME = "synthetic"
def __init__(self, reply: str, sink: list[str], *, tokens_per_reply: int = 8) -> None:
def __init__(
self,
reply: str | None = None,
sink: list[str] | None = None,
*,
reply_selector: Callable[[str, str], str] | None = None,
role: str = "",
default_reply: str = "ok",
tokens_per_reply: int = 8,
) -> None:
super().__init__(model="synthetic", api_key="synthetic", base_url="http://127.0.0.1:9/v1")
self._reply = reply
self._sink = sink
self._role = role
self._default = default_reply
# The reply-selector over (prompt_blob, role). A positional ``reply`` is sugar for a constant
# selector (scripted_factory back-compat); with neither, the constant is ``default_reply``.
if reply_selector is not None:
self._select: Callable[[str, str], str] = reply_selector
elif reply is not None:
self._select = lambda _prompt, _role: reply
else:
self._select = lambda _prompt, _role: self._default
self._tokens = tokens_per_reply
self.call_count = 0
def _inner_get_response(
self,
@ -91,7 +118,11 @@ class ScriptedChatClient(OpenAIChatCompletionClient):
stream: bool = False,
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
self._sink.append(" ".join(getattr(m, "text", "") or "" for m in messages))
blob = " ".join(getattr(m, "text", "") or "" for m in messages)
if self._sink is not None:
self._sink.append(blob)
self.call_count += 1
reply = self._select(blob, self._role)
usage = UsageDetails(total_token_count=self._tokens)
if stream:
@ -99,14 +130,14 @@ class ScriptedChatClient(OpenAIChatCompletionClient):
# The framework accepts a {"type": "text", ...} dict here (its types under-specify it).
yield ChatResponseUpdate(
role="assistant",
contents=[{"type": "text", "text": self._reply}], # type: ignore[list-item]
contents=[{"type": "text", "text": reply}], # type: ignore[list-item]
)
return self._build_response_stream(_agen())
async def _coro() -> ChatResponse:
return ChatResponse(
messages=[Message(role="assistant", contents=[self._reply])],
messages=[Message(role="assistant", contents=[reply])],
response_id="synthetic",
usage_details=usage,
)
@ -121,7 +152,7 @@ def scripted_factory(replies: dict[str, str], sink: list[str]) -> Callable[[str]
per-turn counter); the shared ``sink`` spans the debate turns and the generation call."""
def factory(role: str) -> BaseChatClient:
return ScriptedChatClient(replies[role], sink)
return ScriptedChatClient(replies[role], sink, role=role)
return factory