runapprentice 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ """LangChain integration: zero-code-change capture via callbacks.
2
+
3
+ Usage:
4
+ from runapprentice import Apprentice
5
+ from runapprentice.langchain import ApprenticeCallback
6
+
7
+ client = Apprentice(base_url=..., api_key=...)
8
+ model = init_chat_model("gpt-5.5", callbacks=[ApprenticeCallback("content_gap", client)])
9
+
10
+ Every LLM call then logs input/output/latency to the task's dataset (raw tier).
11
+
12
+ FAIL-OPEN CONTRACT: nothing in this module may ever raise into the customer's
13
+ call path. All capture work is wrapped; worst case is a missing trace.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ from collections.abc import Sequence
21
+ from typing import Any, Callable
22
+ from uuid import UUID
23
+
24
+ from runapprentice.client import Apprentice
25
+ from runapprentice.models import TraceRecord
26
+
27
+ try:
28
+ from langchain_core.callbacks import BaseCallbackHandler
29
+ except ImportError: # pragma: no cover - exercised only without the optional extra
30
+ BaseCallbackHandler = object # type: ignore[assignment, misc]
31
+
32
+ logger = logging.getLogger("runapprentice.langchain")
33
+
34
+ RedactFn = Callable[[str], str]
35
+
36
+ _SUPPORTED_FORMAT = "f-string"
37
+ _ROLE_BY_CLASS = {
38
+ "SystemMessagePromptTemplate": "system",
39
+ "HumanMessagePromptTemplate": "human",
40
+ "AIMessagePromptTemplate": "ai",
41
+ }
42
+
43
+
44
+ class ApprenticeCallback(BaseCallbackHandler):
45
+ """Captures chat-model calls for one task. Streaming/async safe: LangChain
46
+ invokes `on_llm_end` after the final chunk for streamed runs."""
47
+
48
+ # never let a callback error abort the customer's chain
49
+ raise_error = False
50
+
51
+ def __init__(
52
+ self,
53
+ task: str,
54
+ client: Apprentice,
55
+ redact: RedactFn | None = None,
56
+ ) -> None:
57
+ _require_langchain()
58
+ self.task = task
59
+ self._client = client
60
+ self._redact = redact
61
+ self._starts: dict[UUID, tuple[str, float, str | None]] = {}
62
+ self._parents: dict[UUID, UUID | None] = {}
63
+ self._questions: dict[UUID, str] = {}
64
+ self._contexts: dict[UUID, str] = {}
65
+
66
+ # -- chain / retriever lifecycle -------------------------------------------
67
+
68
+ def on_chain_start(
69
+ self,
70
+ serialized: dict[str, Any],
71
+ inputs: dict[str, Any] | Any,
72
+ *,
73
+ run_id: UUID,
74
+ parent_run_id: UUID | None = None,
75
+ **kwargs: Any,
76
+ ) -> None:
77
+ try:
78
+ self._parents[run_id] = parent_run_id
79
+ question = _question_text(inputs)
80
+ if question is not None:
81
+ self._questions[run_id] = question
82
+ except Exception as exc: # noqa: BLE001 — fail-open contract
83
+ logger.debug("chain capture start failed: %s", exc)
84
+
85
+ def on_chain_end(
86
+ self,
87
+ outputs: dict[str, Any] | Any,
88
+ *,
89
+ run_id: UUID,
90
+ **kwargs: Any,
91
+ ) -> None:
92
+ try:
93
+ self._questions.pop(run_id, None)
94
+ self._contexts.pop(run_id, None)
95
+ self._parents.pop(run_id, None)
96
+ except Exception as exc: # noqa: BLE001
97
+ logger.debug("chain capture end failed: %s", exc)
98
+
99
+ def on_retriever_start(
100
+ self,
101
+ serialized: dict[str, Any],
102
+ query: str,
103
+ *,
104
+ run_id: UUID,
105
+ parent_run_id: UUID | None = None,
106
+ **kwargs: Any,
107
+ ) -> None:
108
+ try:
109
+ self._parents[run_id] = parent_run_id
110
+ if parent_run_id is None:
111
+ self._questions[run_id] = query
112
+ except Exception as exc: # noqa: BLE001
113
+ logger.debug("retriever capture start failed: %s", exc)
114
+
115
+ def on_retriever_end(
116
+ self,
117
+ documents: Sequence[Any],
118
+ *,
119
+ run_id: UUID,
120
+ parent_run_id: UUID | None = None,
121
+ **kwargs: Any,
122
+ ) -> None:
123
+ try:
124
+ context = _documents_context(documents)
125
+ if context is None:
126
+ return
127
+ # Store context on the lineage ROOT, not the immediate parent: in the
128
+ # canonical LCEL shape ({"context": retriever | format, "question": ...}
129
+ # | prompt | llm) the retriever sits under a RunnableParallel sibling, so
130
+ # only the shared root run is also an ancestor of the model call. The
131
+ # model's on_llm_end walks its lineage to that root to pair context with
132
+ # question. ponytail: one retriever per simple RAG chain; use explicit
133
+ # capture() when custom formatting or multiple retrievers matter.
134
+ root = _lineage(run_id, self._parents)[-1]
135
+ self._contexts[root] = context
136
+ # The retriever run gets no on_chain_end, so drop its own transient
137
+ # entries here — otherwise they leak for the life of the process.
138
+ self._parents.pop(run_id, None)
139
+ if root != run_id:
140
+ self._questions.pop(run_id, None)
141
+ except Exception as exc: # noqa: BLE001
142
+ logger.debug("retriever capture end failed: %s", exc)
143
+
144
+ # -- chat model lifecycle ---------------------------------------------------
145
+
146
+ def on_chat_model_start(
147
+ self,
148
+ serialized: dict[str, Any],
149
+ messages: list[list[Any]],
150
+ *,
151
+ run_id: UUID,
152
+ parent_run_id: UUID | None = None,
153
+ **kwargs: Any,
154
+ ) -> None:
155
+ try:
156
+ self._parents[run_id] = parent_run_id
157
+ text = "\n".join(f"{m.type}: {_message_text(m)}" for batch in messages for m in batch)
158
+ model_name = _model_name(serialized, kwargs)
159
+ self._starts[run_id] = (text, time.monotonic(), model_name)
160
+ except Exception as exc: # noqa: BLE001 — fail-open contract
161
+ logger.debug("capture start failed: %s", exc)
162
+
163
+ def on_llm_start(
164
+ self,
165
+ serialized: dict[str, Any],
166
+ prompts: list[str],
167
+ *,
168
+ run_id: UUID,
169
+ parent_run_id: UUID | None = None,
170
+ **kwargs: Any,
171
+ ) -> None:
172
+ try:
173
+ self._parents[run_id] = parent_run_id
174
+ self._starts[run_id] = (
175
+ "\n".join(prompts),
176
+ time.monotonic(),
177
+ _model_name(serialized, kwargs),
178
+ )
179
+ except Exception as exc: # noqa: BLE001
180
+ logger.debug("capture start failed: %s", exc)
181
+
182
+ def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
183
+ try:
184
+ start = self._starts.pop(run_id, None)
185
+ input_text, t0, model_name = start if start else ("", None, None)
186
+ output_text = "\n".join(gen.text for batch in response.generations for gen in batch)
187
+ usage = (response.llm_output or {}).get("token_usage", {}) or {}
188
+ if self._redact is not None:
189
+ input_text = self._redact(input_text)
190
+ output_text = self._redact(output_text)
191
+ inputs = self._structured_inputs(run_id)
192
+ if inputs is not None and self._redact is not None:
193
+ inputs = {key: self._redact(value) for key, value in inputs.items()}
194
+ record = TraceRecord(
195
+ task=self.task,
196
+ input=input_text,
197
+ inputs=inputs,
198
+ output=output_text,
199
+ model=model_name,
200
+ latency_ms=(time.monotonic() - t0) * 1000 if t0 is not None else None,
201
+ prompt_tokens=usage.get("prompt_tokens"),
202
+ completion_tokens=usage.get("completion_tokens"),
203
+ metadata={"structured_capture": inputs is not None},
204
+ )
205
+ self._client.post_trace_failopen(record)
206
+ self._parents.pop(run_id, None)
207
+ except Exception as exc: # noqa: BLE001 — fail-open contract
208
+ logger.debug("capture end failed: %s", exc)
209
+
210
+ def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
211
+ try:
212
+ start = self._starts.pop(run_id, None)
213
+ input_text, t0, model_name = start if start else ("", None, None)
214
+ if self._redact is not None:
215
+ input_text = self._redact(input_text)
216
+ self._client.post_trace_failopen(
217
+ TraceRecord(
218
+ task=self.task,
219
+ input=input_text,
220
+ output="",
221
+ model=model_name,
222
+ latency_ms=(time.monotonic() - t0) * 1000 if t0 is not None else None,
223
+ error=str(error)[:2000],
224
+ )
225
+ )
226
+ self._parents.pop(run_id, None)
227
+ except Exception as exc: # noqa: BLE001
228
+ logger.debug("capture error-path failed: %s", exc)
229
+
230
+ def _structured_inputs(self, run_id: UUID) -> dict[str, str] | None:
231
+ question: str | None = None
232
+ context: str | None = None
233
+ for current in _lineage(run_id, self._parents):
234
+ question = question or self._questions.get(current)
235
+ context = context or self._contexts.get(current)
236
+ if question is not None and context is not None:
237
+ return {"question": question, "context": context}
238
+ return None
239
+
240
+
241
+ def _message_text(m: Any) -> str:
242
+ """Message text across LangChain versions: `.text` is a property in current
243
+ releases (was a method before; deprecated). Prefer the property, fall back
244
+ to the callable form, then to `.content`."""
245
+ text_attr = getattr(type(m), "text", None)
246
+ if isinstance(text_attr, property):
247
+ text = text_attr.__get__(m, type(m))
248
+ if text is not None:
249
+ return str(text)
250
+ text = getattr(m, "text", None)
251
+ if callable(text): # pre-property LangChain
252
+ try:
253
+ return str(text())
254
+ except Exception:
255
+ return str(getattr(m, "content", ""))
256
+ if text is not None:
257
+ return str(text)
258
+ return str(getattr(m, "content", ""))
259
+
260
+
261
+ def _question_text(inputs: Any) -> str | None:
262
+ if isinstance(inputs, str):
263
+ return inputs
264
+ if not isinstance(inputs, dict):
265
+ return None
266
+ for key in ("question", "input", "query"):
267
+ value = inputs.get(key)
268
+ if isinstance(value, str):
269
+ return value
270
+ strings = [value for value in inputs.values() if isinstance(value, str)]
271
+ return strings[0] if len(strings) == 1 else None
272
+
273
+
274
+ def _documents_context(documents: Sequence[Any]) -> str | None:
275
+ parts = [str(content) for doc in documents if (content := getattr(doc, "page_content", None))]
276
+ return "\n\n".join(parts) if parts else None
277
+
278
+
279
+ def _lineage(run_id: UUID, parents: dict[UUID, UUID | None]) -> list[UUID]:
280
+ lineage = [run_id]
281
+ current = run_id
282
+ seen = {run_id}
283
+ while (parent := parents.get(current)) is not None and parent not in seen:
284
+ lineage.append(parent)
285
+ seen.add(parent)
286
+ current = parent
287
+ return lineage
288
+
289
+
290
+ def _model_name(serialized: dict[str, Any], kwargs: dict[str, Any]) -> str | None:
291
+ invocation = kwargs.get("invocation_params") or {}
292
+ name = invocation.get("model") or invocation.get("model_name")
293
+ if name:
294
+ return str(name)
295
+ ids = serialized.get("id")
296
+ return str(ids[-1]) if isinstance(ids, list) and ids else None
297
+
298
+
299
+ def serialize_prompt(template: Any) -> dict[str, Any]:
300
+ """Serialize LangChain prompt templates to Apprentice Contract 2.
301
+
302
+ Plain `PromptTemplate` becomes one human message because it has no chat role.
303
+ """
304
+ ChatPromptTemplate, PromptTemplate, MessagesPlaceholder = _prompt_classes()
305
+ if isinstance(template, ChatPromptTemplate):
306
+ messages = [_serialize_message(m, MessagesPlaceholder) for m in template.messages]
307
+ formats = {_message_format(m, MessagesPlaceholder) for m in template.messages}
308
+ template_format = formats.pop() if formats else _SUPPORTED_FORMAT
309
+ if formats:
310
+ raise ValueError("mixed template formats are not supported; use one f-string format")
311
+ _reject_format(template_format, messages)
312
+ return {
313
+ "format": template_format,
314
+ "messages": messages,
315
+ "input_variables": list(template.input_variables),
316
+ "partial_variables": dict(getattr(template, "partial_variables", {}) or {}),
317
+ }
318
+ if isinstance(template, PromptTemplate):
319
+ template_format = str(getattr(template, "template_format", _SUPPORTED_FORMAT))
320
+ messages = [{"role": "human", "template": str(template.template)}]
321
+ _reject_format(template_format, messages)
322
+ return {
323
+ "format": template_format,
324
+ "messages": messages,
325
+ "input_variables": list(template.input_variables),
326
+ "partial_variables": dict(getattr(template, "partial_variables", {}) or {}),
327
+ }
328
+ raise ValueError("unsupported LangChain prompt type; use ChatPromptTemplate or PromptTemplate")
329
+
330
+
331
+ def artifact_to_langchain(artifact: dict[str, Any]) -> Any:
332
+ ChatPromptTemplate, _, _ = _prompt_classes()
333
+ template_format = str(artifact.get("format", _SUPPORTED_FORMAT))
334
+ messages = artifact.get("messages")
335
+ if not isinstance(messages, list):
336
+ raise ValueError("template artifact must include messages list")
337
+ _reject_format(template_format, messages)
338
+ lc_messages = []
339
+ for message in messages:
340
+ if not isinstance(message, dict):
341
+ raise ValueError("template artifact messages must be objects")
342
+ role = str(message.get("role", ""))
343
+ if role not in {"system", "human", "ai"}:
344
+ raise ValueError(f"unsupported message role {role!r}; use system, human, or ai")
345
+ lc_messages.append((role, str(message.get("template", ""))))
346
+ return ChatPromptTemplate.from_messages(lc_messages, template_format=template_format)
347
+
348
+
349
+ def _serialize_message(message: Any, MessagesPlaceholder: type[Any]) -> dict[str, str]:
350
+ if isinstance(message, MessagesPlaceholder):
351
+ raise ValueError(
352
+ "MessagesPlaceholder is not supported yet; upload flattened scalar inputs instead"
353
+ )
354
+ prompt = getattr(message, "prompt", None)
355
+ template = getattr(prompt, "template", None)
356
+ if template is None:
357
+ raise ValueError("only message prompt templates are supported; use tuple/string templates")
358
+ role = _ROLE_BY_CLASS.get(type(message).__name__)
359
+ if role is None:
360
+ raise ValueError("unsupported chat message role; use system, human, or ai message templates")
361
+ return {"role": role, "template": str(template)}
362
+
363
+
364
+ def _message_format(message: Any, MessagesPlaceholder: type[Any]) -> str:
365
+ if isinstance(message, MessagesPlaceholder):
366
+ return _SUPPORTED_FORMAT
367
+ prompt = getattr(message, "prompt", None)
368
+ return str(getattr(prompt, "template_format", _SUPPORTED_FORMAT))
369
+
370
+
371
+ def _reject_format(template_format: str, messages: list[dict[str, Any]]) -> None:
372
+ if any(_has_template_loop(template_format, str(m.get("template", ""))) for m in messages):
373
+ raise ValueError(
374
+ "jinja2/mustache loops are not supported yet; use flat f-string variables"
375
+ )
376
+ if template_format != _SUPPORTED_FORMAT:
377
+ raise ValueError(
378
+ f"template_format={template_format!r} is not supported yet; "
379
+ "use f-string templates for Apprentice dynamic prompts"
380
+ )
381
+
382
+
383
+ def _has_template_loop(template_format: str, template: str) -> bool:
384
+ if template_format == "jinja2":
385
+ return "{% for " in template or "{% endfor" in template
386
+ if template_format == "mustache":
387
+ return "{{#" in template or "{{/" in template
388
+ return False
389
+
390
+
391
+ def _prompt_classes() -> tuple[type[Any], type[Any], type[Any]]:
392
+ try:
393
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, PromptTemplate
394
+ except ImportError as exc:
395
+ raise ImportError(
396
+ "LangChain support requires the optional extra: "
397
+ "`uv add 'runapprentice[langchain]'` or install `langchain-core>=0.3`."
398
+ ) from exc
399
+ return ChatPromptTemplate, PromptTemplate, MessagesPlaceholder
400
+
401
+
402
+ def _require_langchain() -> None:
403
+ _prompt_classes()
@@ -0,0 +1,62 @@
1
+ """Pydantic models for the Apprentice SDK. Mirrors the control-plane API contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class DatasetRow(BaseModel):
12
+ input: str
13
+ output: str
14
+ metadata: dict[str, Any] = Field(default_factory=dict)
15
+
16
+
17
+ class DatasetStatus(BaseModel):
18
+ task: str
19
+ gold: int = 0
20
+ silver: int = 0
21
+ raw: int = 0
22
+ ready_for_optimization: bool = False
23
+
24
+
25
+ class TraceRecord(BaseModel):
26
+ task: str
27
+ input: str
28
+ inputs: dict[str, str] | None = None
29
+ output: str
30
+ model: str | None = None
31
+ latency_ms: float | None = None
32
+ prompt_tokens: int | None = None
33
+ completion_tokens: int | None = None
34
+ error: str | None = None
35
+ metadata: dict[str, Any] = Field(default_factory=dict)
36
+
37
+
38
+ class OptimizationReport(BaseModel):
39
+ task: str
40
+ baseline_score: float | None = None
41
+ optimized_score: float | None = None
42
+ examples_used: int | None = None
43
+ optimized_prompt: str | None = None
44
+ optimized_template: dict[str, Any] | None = None
45
+ detail: dict[str, Any] = Field(default_factory=dict)
46
+
47
+
48
+ class JobInfo(BaseModel):
49
+ job_id: str
50
+ task: str
51
+ kind: str
52
+ status: str # queued | running | succeeded | failed
53
+ created_at: datetime | None = None
54
+ error: str | None = None
55
+ report: OptimizationReport | None = None
56
+
57
+
58
+ class PromptVersion(BaseModel):
59
+ task: str
60
+ version: int
61
+ text: str
62
+ score: float | None = None
@@ -0,0 +1,126 @@
1
+ """OpenAI SDK integration: zero-config capture by wrapping the client.
2
+
3
+ Usage:
4
+ from openai import OpenAI # or AsyncOpenAI
5
+ from runapprentice import Apprentice
6
+ from runapprentice.openai import wrap
7
+
8
+ client = wrap(OpenAI(), Apprentice(api_key="..."), task="ticket-triage")
9
+ # every client.chat.completions.create(...) call is captured, unchanged.
10
+
11
+ Works with both the sync `OpenAI` and async `AsyncOpenAI` clients (the wrapper
12
+ matches whichever `create` it finds).
13
+
14
+ This captures traces (it does NOT proxy or route your calls — your request goes
15
+ straight to OpenAI as before). Routing/replacement is a later, opt-in stage.
16
+
17
+ FAIL-OPEN CONTRACT: capture never raises into the customer's call path, and never
18
+ blocks it (delivery is buffered off-thread). The real OpenAI call happens first and
19
+ its result is always returned; worst case is a missing trace. Real OpenAI errors
20
+ propagate unchanged.
21
+
22
+ STREAMING: when `stream=True`, the response is an iterator that must not be consumed
23
+ by the capture layer (that would break the caller's stream). Such calls pass through
24
+ uncaptured — breaking streaming is never worth a trace.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import functools
30
+ import inspect
31
+ import logging
32
+ import time
33
+ from typing import Any, Callable
34
+
35
+ from runapprentice.client import Apprentice
36
+ from runapprentice.models import TraceRecord
37
+
38
+ logger = logging.getLogger("runapprentice.openai")
39
+
40
+ RedactFn = Callable[[str], str]
41
+
42
+
43
+ def wrap(openai_client: Any, client: Apprentice, task: str, redact: RedactFn | None = None) -> Any:
44
+ """Wrap an OpenAI client so chat.completions.create calls are captured.
45
+
46
+ Returns the same client (mutated in place) for convenient one-line use.
47
+ Idempotent: wrapping an already-wrapped client is a no-op. Supports both sync
48
+ (`OpenAI`) and async (`AsyncOpenAI`) clients.
49
+ """
50
+ completions = openai_client.chat.completions
51
+ if getattr(completions, "_apprentice_wrapped", False):
52
+ return openai_client
53
+ original = completions.create
54
+
55
+ def _capture(response: Any, kwargs: dict[str, Any], args: tuple[Any, ...], t0: float) -> None:
56
+ try:
57
+ input_text = _messages_to_text(kwargs.get("messages") or (args[0] if args else []))
58
+ output_text = _response_to_text(response)
59
+ if redact is not None:
60
+ input_text = redact(input_text)
61
+ output_text = redact(output_text)
62
+ usage = getattr(response, "usage", None)
63
+ client.post_trace_failopen(
64
+ TraceRecord(
65
+ task=task,
66
+ input=input_text,
67
+ output=output_text,
68
+ model=getattr(response, "model", None) or kwargs.get("model"),
69
+ latency_ms=(time.monotonic() - t0) * 1000,
70
+ prompt_tokens=getattr(usage, "prompt_tokens", None),
71
+ completion_tokens=getattr(usage, "completion_tokens", None),
72
+ )
73
+ )
74
+ except Exception as exc: # noqa: BLE001 — fail-open contract
75
+ logger.debug("capture failed: %s", exc)
76
+
77
+ if inspect.iscoroutinefunction(original):
78
+
79
+ @functools.wraps(original)
80
+ async def wrapped(*args: Any, **kwargs: Any) -> Any:
81
+ t0 = time.monotonic()
82
+ response = await original(*args, **kwargs) # real call — never intercepted
83
+ if not kwargs.get("stream"):
84
+ _capture(response, kwargs, args, t0)
85
+ return response
86
+
87
+ else:
88
+
89
+ @functools.wraps(original)
90
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
91
+ t0 = time.monotonic()
92
+ response = original(*args, **kwargs) # real call — never intercepted
93
+ if not kwargs.get("stream"):
94
+ _capture(response, kwargs, args, t0)
95
+ return response
96
+
97
+ wrapped._apprentice_wrapped = True # type: ignore[attr-defined]
98
+ completions.create = wrapped
99
+ completions._apprentice_wrapped = True
100
+ return openai_client
101
+
102
+
103
+ def _messages_to_text(messages: Any) -> str:
104
+ if isinstance(messages, str):
105
+ return messages
106
+ parts = []
107
+ for m in messages or []:
108
+ role = m.get("role", "") if isinstance(m, dict) else getattr(m, "role", "")
109
+ content = m.get("content", "") if isinstance(m, dict) else getattr(m, "content", "")
110
+ if isinstance(content, list): # multimodal: keep the text parts
111
+ content = "\n".join(
112
+ p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
113
+ )
114
+ parts.append(f"{role}: {content}".strip(": ").strip() if role else str(content))
115
+ return "\n".join(parts)
116
+
117
+
118
+ def _response_to_text(response: Any) -> str:
119
+ choices = getattr(response, "choices", None) or []
120
+ out = []
121
+ for c in choices:
122
+ msg = getattr(c, "message", None)
123
+ text = getattr(msg, "content", None) if msg is not None else getattr(c, "text", None)
124
+ if text:
125
+ out.append(text)
126
+ return "\n".join(out)
runapprentice/py.typed ADDED
File without changes