runapprentice 0.1.0__tar.gz
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.
- runapprentice-0.1.0/.gitignore +38 -0
- runapprentice-0.1.0/LICENSE +21 -0
- runapprentice-0.1.0/PKG-INFO +219 -0
- runapprentice-0.1.0/README.md +194 -0
- runapprentice-0.1.0/pyproject.toml +61 -0
- runapprentice-0.1.0/src/runapprentice/__init__.py +26 -0
- runapprentice-0.1.0/src/runapprentice/_capture.py +117 -0
- runapprentice-0.1.0/src/runapprentice/client.py +391 -0
- runapprentice-0.1.0/src/runapprentice/langchain.py +403 -0
- runapprentice-0.1.0/src/runapprentice/models.py +62 -0
- runapprentice-0.1.0/src/runapprentice/openai.py +126 -0
- runapprentice-0.1.0/src/runapprentice/py.typed +0 -0
- runapprentice-0.1.0/tests/test_capture.py +213 -0
- runapprentice-0.1.0/tests/test_client.py +175 -0
- runapprentice-0.1.0/tests/test_dispatcher.py +81 -0
- runapprentice-0.1.0/tests/test_failopen.py +57 -0
- runapprentice-0.1.0/tests/test_langchain_prompts.py +63 -0
- runapprentice-0.1.0/tests/test_openai.py +91 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.mypy_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
|
|
10
|
+
# Node
|
|
11
|
+
node_modules/
|
|
12
|
+
.next/
|
|
13
|
+
out/
|
|
14
|
+
|
|
15
|
+
# Env / secrets — never commit (AGENTS.md)
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.example
|
|
19
|
+
!**/.env.example
|
|
20
|
+
*.key
|
|
21
|
+
|
|
22
|
+
# Data & model artifacts — never commit (AGENTS.md)
|
|
23
|
+
*.csv
|
|
24
|
+
*.jsonl
|
|
25
|
+
datasets/
|
|
26
|
+
apprentice-api/spike/.cache/
|
|
27
|
+
checkpoints/
|
|
28
|
+
optimized_program.json
|
|
29
|
+
|
|
30
|
+
# Tooling
|
|
31
|
+
.codegraph/
|
|
32
|
+
.DS_Store
|
|
33
|
+
*.log
|
|
34
|
+
apprentice-ui/codex_working.md
|
|
35
|
+
.codex/
|
|
36
|
+
|
|
37
|
+
# Local dev databases
|
|
38
|
+
*.db
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abhishek Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runapprentice
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts.
|
|
5
|
+
Project-URL: Homepage, https://runapprentice.com
|
|
6
|
+
Project-URL: Documentation, https://docs.runapprentice.com
|
|
7
|
+
Author: Abhishek Singh
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: dspy,evaluation,gepa,langchain,llm,prompt-optimization,rag
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Provides-Extra: langchain
|
|
23
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# runapprentice
|
|
27
|
+
|
|
28
|
+
Capture your LLM traffic, build a golden dataset, and optimize your prompt against it. Training a small model to take over from the frontier model is planned for a later release.
|
|
29
|
+
|
|
30
|
+
This README documents the **implemented surface** (v0.1). Anything not shown here does not exist yet.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv add runapprentice[langchain] # [langchain] extra enables zero-code capture
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quickstart (the whole trial journey)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from runapprentice import Apprentice
|
|
42
|
+
|
|
43
|
+
client = Apprentice(
|
|
44
|
+
api_key="ap_live_...", # create once in the console, or env APPRENTICE_API_KEY
|
|
45
|
+
) # talks to the hosted API; set base_url only to self-host
|
|
46
|
+
|
|
47
|
+
# 1. Create a task (a "task" = one repeatable LLM job you want to make cheaper)
|
|
48
|
+
client.tasks.create("ticket-triage")
|
|
49
|
+
|
|
50
|
+
# 2. Point us at the data you already have (CSV with input/output columns)
|
|
51
|
+
client.datasets.upload(
|
|
52
|
+
"ticket-triage",
|
|
53
|
+
path="golden.csv",
|
|
54
|
+
input_col="input",
|
|
55
|
+
output_col="output",
|
|
56
|
+
prompt="...your current system prompt...", # the baseline to beat
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 3. Optimize the prompt against your data (GEPA, held-out eval)
|
|
60
|
+
job = client.optimize("ticket-triage")
|
|
61
|
+
report = job.wait().report()
|
|
62
|
+
print(report.baseline_score, "->", report.optimized_score)
|
|
63
|
+
|
|
64
|
+
# 4. Pull the optimized prompt back into your code (versioned)
|
|
65
|
+
best = client.prompts.get("ticket-triage")
|
|
66
|
+
print(best.version, best.text)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Live capture: LangChain (one line, zero code changes)
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# init_chat_model comes from your LangChain model package (for example
|
|
73
|
+
# langchain[openai]); runapprentice[langchain] only adds langchain-core for
|
|
74
|
+
# the callback.
|
|
75
|
+
from langchain.chat_models import init_chat_model
|
|
76
|
+
from runapprentice.langchain import ApprenticeCallback
|
|
77
|
+
|
|
78
|
+
model = init_chat_model(
|
|
79
|
+
"gpt-5.5", # whatever YOU already use. We observe, we don't choose
|
|
80
|
+
callbacks=[ApprenticeCallback("ticket-triage", client)],
|
|
81
|
+
)
|
|
82
|
+
# Every call now logs input/output/model/latency/tokens to the task's dataset (raw tier).
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Fail-open guarantee:** the capture path can NEVER break your app. If the
|
|
86
|
+
Apprentice backend is down, unreachable, or slow, your LLM calls proceed
|
|
87
|
+
untouched. The only loss is the trace. First drop logs a WARNING, repeats log
|
|
88
|
+
at DEBUG.
|
|
89
|
+
|
|
90
|
+
**PII redaction** runs in your process, before anything is transmitted:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
ApprenticeCallback("ticket-triage", client, redact=lambda s: my_scrubber(s))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## RAG quickstart (grounding + refusal-aware)
|
|
97
|
+
|
|
98
|
+
For retrieval-augmented tasks, each row carries the **exact context the model saw**
|
|
99
|
+
(the retrieved passages), not just the question. Pick the `rag_composite` metric so
|
|
100
|
+
optimization rewards grounding and correct refusals, not just answer overlap.
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from runapprentice import Apprentice
|
|
104
|
+
client = Apprentice(api_key="ap_live_...")
|
|
105
|
+
|
|
106
|
+
# rag_composite scores answer correctness + faithfulness to context + refusal
|
|
107
|
+
# correctness (it should say "not enough information" when the context lacks it).
|
|
108
|
+
client.tasks.create("support-rag", metric="rag_composite")
|
|
109
|
+
|
|
110
|
+
client.datasets.upload("support-rag", rows=[
|
|
111
|
+
{"inputs": {"question": q, "context": retrieved_passages}, "output": gold_answer}
|
|
112
|
+
for q, retrieved_passages, gold_answer in your_golden_set
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
client.prompts.register("support-rag", {
|
|
116
|
+
"format": "f-string",
|
|
117
|
+
"messages": [
|
|
118
|
+
{"role": "system", "template":
|
|
119
|
+
"Answer using only the context. If the context does not contain the "
|
|
120
|
+
"answer, say you do not have enough information.\n{context}"},
|
|
121
|
+
{"role": "human", "template": "{question}"},
|
|
122
|
+
],
|
|
123
|
+
"input_variables": ["context", "question"],
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
report = client.optimize("support-rag").wait().report()
|
|
127
|
+
print(report.baseline_score, "->", report.optimized_score)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**RAG capture for simple chains.** For a standard LangChain RAG chain, the
|
|
131
|
+
`ApprenticeCallback` records the retrieved context from `on_retriever_end`, capturing
|
|
132
|
+
the `{question, context}` shape. For custom formatting, multiple retrievers, or
|
|
133
|
+
non-standard chains, call `client.capture(..., inputs={"question": ..., "context": ...})`
|
|
134
|
+
explicitly.
|
|
135
|
+
|
|
136
|
+
### Register a LangChain prompt directly
|
|
137
|
+
|
|
138
|
+
`prompts.register` also accepts a LangChain `ChatPromptTemplate`, no raw dict needed:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
142
|
+
client.prompts.register("support-rag", ChatPromptTemplate.from_messages([
|
|
143
|
+
("system", "Answer using only the context. If it lacks the answer, refuse.\n{context}"),
|
|
144
|
+
("human", "{question}"),
|
|
145
|
+
]))
|
|
146
|
+
# round-trip the optimized prompt back into LangChain:
|
|
147
|
+
optimized = client.prompts.to_langchain("support-rag")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Metric menu
|
|
151
|
+
|
|
152
|
+
| `metric=` | Use for | Scored by |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `auto` (default) | let the backend infer from your rows | inferred |
|
|
155
|
+
| `json_f1` | JSON / structured extraction | deterministic |
|
|
156
|
+
| `text_f1` | short free-text answers | deterministic |
|
|
157
|
+
| `semantic_f1` | free-text / RAG answers (default for RAG) | LLM judge |
|
|
158
|
+
| `rag_faithfulness` | is every claim supported by the context | LLM judge |
|
|
159
|
+
| `rag_composite` | RAG grounding **and** refusal correctness | LLM judge |
|
|
160
|
+
|
|
161
|
+
RAG rows auto-route to `semantic_f1`; pass `metric="rag_composite"` explicitly when you
|
|
162
|
+
want grounding + refusal optimized together.
|
|
163
|
+
|
|
164
|
+
## Feedback (your end-users' signal)
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
client.feedback(trace_id, good=True) # thumbs
|
|
168
|
+
client.feedback(trace_id, score=0.5, note="partial") # graded
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Debugging
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
import runapprentice
|
|
175
|
+
runapprentice.enable_debug_logging() # or env APPRENTICE_DEBUG=1
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Shows every API call with status + latency, every capture attempt, every job
|
|
179
|
+
poll. Control-plane errors are designed to tell you what to do (e.g. unreachable
|
|
180
|
+
backend includes the URL it tried; optimize with too few rows tells you the
|
|
181
|
+
count and the minimum).
|
|
182
|
+
|
|
183
|
+
## API reference (implemented surface)
|
|
184
|
+
|
|
185
|
+
| Call | Returns | Raises |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `Apprentice(api_key=, base_url=, timeout=)` | client | `ApprenticeError` on bad config |
|
|
188
|
+
| `client.tasks.create(name, metric="auto")` | dict (`created` bool) | `ApprenticeError` on HTTP failure |
|
|
189
|
+
| `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` | same; also if both/neither of path/rows |
|
|
190
|
+
| `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
|
|
191
|
+
| `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
|
|
192
|
+
| `client.prompts.to_langchain(task= or artifact=)` | LangChain prompt | `ImportError` without the `[langchain]` extra |
|
|
193
|
+
| `client.optimize(task)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
|
|
194
|
+
| `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
|
|
195
|
+
| `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
|
|
196
|
+
| `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
|
|
197
|
+
| `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
|
|
198
|
+
| `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace |
|
|
199
|
+
| `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
|
|
200
|
+
| `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
|
|
201
|
+
|
|
202
|
+
## Data tiers (how your rows are treated)
|
|
203
|
+
|
|
204
|
+
- **raw**: captured from live traffic, unverified
|
|
205
|
+
- **silver**: uploaded by you (curated) or passed deterministic checks
|
|
206
|
+
- **gold**: human-verified
|
|
207
|
+
- Optimization uses gold + silver; eval gates will use **gold only**.
|
|
208
|
+
|
|
209
|
+
## For AI coding tools
|
|
210
|
+
|
|
211
|
+
Module docstrings and this README are the source of truth. Key invariants an
|
|
212
|
+
agent must preserve when editing this package:
|
|
213
|
+
|
|
214
|
+
1. `post_trace_failopen` and everything in `runapprentice/langchain.py` must never
|
|
215
|
+
raise into the caller. Capture is fail-open by contract (see `tests/test_failopen.py`).
|
|
216
|
+
2. Control-plane methods must raise `ApprenticeError` with actionable messages.
|
|
217
|
+
3. Dependencies stay minimal: `httpx` + `pydantic`; LangChain only via the
|
|
218
|
+
`[langchain]` extra; never import from `apprentice-api`.
|
|
219
|
+
4. Required tests for any new method: add a row to `../needed_test.md`.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# runapprentice
|
|
2
|
+
|
|
3
|
+
Capture your LLM traffic, build a golden dataset, and optimize your prompt against it. Training a small model to take over from the frontier model is planned for a later release.
|
|
4
|
+
|
|
5
|
+
This README documents the **implemented surface** (v0.1). Anything not shown here does not exist yet.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv add runapprentice[langchain] # [langchain] extra enables zero-code capture
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart (the whole trial journey)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from runapprentice import Apprentice
|
|
17
|
+
|
|
18
|
+
client = Apprentice(
|
|
19
|
+
api_key="ap_live_...", # create once in the console, or env APPRENTICE_API_KEY
|
|
20
|
+
) # talks to the hosted API; set base_url only to self-host
|
|
21
|
+
|
|
22
|
+
# 1. Create a task (a "task" = one repeatable LLM job you want to make cheaper)
|
|
23
|
+
client.tasks.create("ticket-triage")
|
|
24
|
+
|
|
25
|
+
# 2. Point us at the data you already have (CSV with input/output columns)
|
|
26
|
+
client.datasets.upload(
|
|
27
|
+
"ticket-triage",
|
|
28
|
+
path="golden.csv",
|
|
29
|
+
input_col="input",
|
|
30
|
+
output_col="output",
|
|
31
|
+
prompt="...your current system prompt...", # the baseline to beat
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# 3. Optimize the prompt against your data (GEPA, held-out eval)
|
|
35
|
+
job = client.optimize("ticket-triage")
|
|
36
|
+
report = job.wait().report()
|
|
37
|
+
print(report.baseline_score, "->", report.optimized_score)
|
|
38
|
+
|
|
39
|
+
# 4. Pull the optimized prompt back into your code (versioned)
|
|
40
|
+
best = client.prompts.get("ticket-triage")
|
|
41
|
+
print(best.version, best.text)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Live capture: LangChain (one line, zero code changes)
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
# init_chat_model comes from your LangChain model package (for example
|
|
48
|
+
# langchain[openai]); runapprentice[langchain] only adds langchain-core for
|
|
49
|
+
# the callback.
|
|
50
|
+
from langchain.chat_models import init_chat_model
|
|
51
|
+
from runapprentice.langchain import ApprenticeCallback
|
|
52
|
+
|
|
53
|
+
model = init_chat_model(
|
|
54
|
+
"gpt-5.5", # whatever YOU already use. We observe, we don't choose
|
|
55
|
+
callbacks=[ApprenticeCallback("ticket-triage", client)],
|
|
56
|
+
)
|
|
57
|
+
# Every call now logs input/output/model/latency/tokens to the task's dataset (raw tier).
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Fail-open guarantee:** the capture path can NEVER break your app. If the
|
|
61
|
+
Apprentice backend is down, unreachable, or slow, your LLM calls proceed
|
|
62
|
+
untouched. The only loss is the trace. First drop logs a WARNING, repeats log
|
|
63
|
+
at DEBUG.
|
|
64
|
+
|
|
65
|
+
**PII redaction** runs in your process, before anything is transmitted:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
ApprenticeCallback("ticket-triage", client, redact=lambda s: my_scrubber(s))
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## RAG quickstart (grounding + refusal-aware)
|
|
72
|
+
|
|
73
|
+
For retrieval-augmented tasks, each row carries the **exact context the model saw**
|
|
74
|
+
(the retrieved passages), not just the question. Pick the `rag_composite` metric so
|
|
75
|
+
optimization rewards grounding and correct refusals, not just answer overlap.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from runapprentice import Apprentice
|
|
79
|
+
client = Apprentice(api_key="ap_live_...")
|
|
80
|
+
|
|
81
|
+
# rag_composite scores answer correctness + faithfulness to context + refusal
|
|
82
|
+
# correctness (it should say "not enough information" when the context lacks it).
|
|
83
|
+
client.tasks.create("support-rag", metric="rag_composite")
|
|
84
|
+
|
|
85
|
+
client.datasets.upload("support-rag", rows=[
|
|
86
|
+
{"inputs": {"question": q, "context": retrieved_passages}, "output": gold_answer}
|
|
87
|
+
for q, retrieved_passages, gold_answer in your_golden_set
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
client.prompts.register("support-rag", {
|
|
91
|
+
"format": "f-string",
|
|
92
|
+
"messages": [
|
|
93
|
+
{"role": "system", "template":
|
|
94
|
+
"Answer using only the context. If the context does not contain the "
|
|
95
|
+
"answer, say you do not have enough information.\n{context}"},
|
|
96
|
+
{"role": "human", "template": "{question}"},
|
|
97
|
+
],
|
|
98
|
+
"input_variables": ["context", "question"],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
report = client.optimize("support-rag").wait().report()
|
|
102
|
+
print(report.baseline_score, "->", report.optimized_score)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**RAG capture for simple chains.** For a standard LangChain RAG chain, the
|
|
106
|
+
`ApprenticeCallback` records the retrieved context from `on_retriever_end`, capturing
|
|
107
|
+
the `{question, context}` shape. For custom formatting, multiple retrievers, or
|
|
108
|
+
non-standard chains, call `client.capture(..., inputs={"question": ..., "context": ...})`
|
|
109
|
+
explicitly.
|
|
110
|
+
|
|
111
|
+
### Register a LangChain prompt directly
|
|
112
|
+
|
|
113
|
+
`prompts.register` also accepts a LangChain `ChatPromptTemplate`, no raw dict needed:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
117
|
+
client.prompts.register("support-rag", ChatPromptTemplate.from_messages([
|
|
118
|
+
("system", "Answer using only the context. If it lacks the answer, refuse.\n{context}"),
|
|
119
|
+
("human", "{question}"),
|
|
120
|
+
]))
|
|
121
|
+
# round-trip the optimized prompt back into LangChain:
|
|
122
|
+
optimized = client.prompts.to_langchain("support-rag")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Metric menu
|
|
126
|
+
|
|
127
|
+
| `metric=` | Use for | Scored by |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `auto` (default) | let the backend infer from your rows | inferred |
|
|
130
|
+
| `json_f1` | JSON / structured extraction | deterministic |
|
|
131
|
+
| `text_f1` | short free-text answers | deterministic |
|
|
132
|
+
| `semantic_f1` | free-text / RAG answers (default for RAG) | LLM judge |
|
|
133
|
+
| `rag_faithfulness` | is every claim supported by the context | LLM judge |
|
|
134
|
+
| `rag_composite` | RAG grounding **and** refusal correctness | LLM judge |
|
|
135
|
+
|
|
136
|
+
RAG rows auto-route to `semantic_f1`; pass `metric="rag_composite"` explicitly when you
|
|
137
|
+
want grounding + refusal optimized together.
|
|
138
|
+
|
|
139
|
+
## Feedback (your end-users' signal)
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
client.feedback(trace_id, good=True) # thumbs
|
|
143
|
+
client.feedback(trace_id, score=0.5, note="partial") # graded
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Debugging
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import runapprentice
|
|
150
|
+
runapprentice.enable_debug_logging() # or env APPRENTICE_DEBUG=1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Shows every API call with status + latency, every capture attempt, every job
|
|
154
|
+
poll. Control-plane errors are designed to tell you what to do (e.g. unreachable
|
|
155
|
+
backend includes the URL it tried; optimize with too few rows tells you the
|
|
156
|
+
count and the minimum).
|
|
157
|
+
|
|
158
|
+
## API reference (implemented surface)
|
|
159
|
+
|
|
160
|
+
| Call | Returns | Raises |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `Apprentice(api_key=, base_url=, timeout=)` | client | `ApprenticeError` on bad config |
|
|
163
|
+
| `client.tasks.create(name, metric="auto")` | dict (`created` bool) | `ApprenticeError` on HTTP failure |
|
|
164
|
+
| `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` | same; also if both/neither of path/rows |
|
|
165
|
+
| `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
|
|
166
|
+
| `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
|
|
167
|
+
| `client.prompts.to_langchain(task= or artifact=)` | LangChain prompt | `ImportError` without the `[langchain]` extra |
|
|
168
|
+
| `client.optimize(task)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
|
|
169
|
+
| `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
|
|
170
|
+
| `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
|
|
171
|
+
| `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
|
|
172
|
+
| `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
|
|
173
|
+
| `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace |
|
|
174
|
+
| `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
|
|
175
|
+
| `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
|
|
176
|
+
|
|
177
|
+
## Data tiers (how your rows are treated)
|
|
178
|
+
|
|
179
|
+
- **raw**: captured from live traffic, unverified
|
|
180
|
+
- **silver**: uploaded by you (curated) or passed deterministic checks
|
|
181
|
+
- **gold**: human-verified
|
|
182
|
+
- Optimization uses gold + silver; eval gates will use **gold only**.
|
|
183
|
+
|
|
184
|
+
## For AI coding tools
|
|
185
|
+
|
|
186
|
+
Module docstrings and this README are the source of truth. Key invariants an
|
|
187
|
+
agent must preserve when editing this package:
|
|
188
|
+
|
|
189
|
+
1. `post_trace_failopen` and everything in `runapprentice/langchain.py` must never
|
|
190
|
+
raise into the caller. Capture is fail-open by contract (see `tests/test_failopen.py`).
|
|
191
|
+
2. Control-plane methods must raise `ApprenticeError` with actionable messages.
|
|
192
|
+
3. Dependencies stay minimal: `httpx` + `pydantic`; LangChain only via the
|
|
193
|
+
`[langchain]` extra; never import from `apprentice-api`.
|
|
194
|
+
4. Required tests for any new method: add a row to `../needed_test.md`.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "runapprentice"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Abhishek Singh" }]
|
|
10
|
+
keywords = ["llm", "prompt-optimization", "dspy", "gepa", "rag", "evaluation", "langchain"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.27",
|
|
23
|
+
"pydantic>=2.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://runapprentice.com"
|
|
28
|
+
Documentation = "https://docs.runapprentice.com"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
langchain = ["langchain-core>=0.3"]
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"mypy>=1.10",
|
|
37
|
+
"ruff>=0.5",
|
|
38
|
+
"langchain-core>=0.3",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling>=1.27.0"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/runapprentice"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
only-include = ["src/runapprentice", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
src = ["src", "tests"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
57
|
+
mypy_path = "src"
|
|
58
|
+
ignore_missing_imports = true
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Apprentice SDK: capture LLM traffic, build golden datasets, and optimize prompts."""
|
|
2
|
+
|
|
3
|
+
from runapprentice.client import Apprentice, ApprenticeError, Job, enable_debug_logging
|
|
4
|
+
from runapprentice.models import (
|
|
5
|
+
DatasetRow,
|
|
6
|
+
DatasetStatus,
|
|
7
|
+
JobInfo,
|
|
8
|
+
OptimizationReport,
|
|
9
|
+
PromptVersion,
|
|
10
|
+
TraceRecord,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Apprentice",
|
|
15
|
+
"ApprenticeError",
|
|
16
|
+
"enable_debug_logging",
|
|
17
|
+
"Job",
|
|
18
|
+
"DatasetRow",
|
|
19
|
+
"DatasetStatus",
|
|
20
|
+
"JobInfo",
|
|
21
|
+
"OptimizationReport",
|
|
22
|
+
"PromptVersion",
|
|
23
|
+
"TraceRecord",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Background capture dispatcher.
|
|
2
|
+
|
|
3
|
+
Capture runs inside the customer's production request path, so it must be both
|
|
4
|
+
NON-BLOCKING (never add latency to their call) and RESILIENT (a transient network
|
|
5
|
+
blip must not silently drop a trace on the first try). This dispatcher gives both:
|
|
6
|
+
|
|
7
|
+
- `submit()` enqueues and returns immediately (the customer's thread never waits on
|
|
8
|
+
Apprentice's network).
|
|
9
|
+
- A single daemon worker drains the queue and delivers each trace with bounded
|
|
10
|
+
retry + exponential backoff.
|
|
11
|
+
- The queue is bounded; if Apprentice's backend is down long enough to fill it, the
|
|
12
|
+
oldest pending traces are dropped (fail-open — the customer app is never affected).
|
|
13
|
+
- `flush()` (also run at interpreter exit) drains in-flight traces so short scripts
|
|
14
|
+
don't lose their last captures.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import logging
|
|
21
|
+
import queue
|
|
22
|
+
import random
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
from runapprentice.models import TraceRecord
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("runapprentice")
|
|
30
|
+
|
|
31
|
+
# send(record) -> True if delivered, False if permanently rejected (do not retry);
|
|
32
|
+
# raises on a transient failure (network/5xx) that should be retried.
|
|
33
|
+
SendFn = Callable[[TraceRecord], bool]
|
|
34
|
+
_SHUTDOWN = object()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CaptureDispatcher:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
send: SendFn,
|
|
41
|
+
on_exhausted: Callable[[str], None],
|
|
42
|
+
max_queue: int = 1000,
|
|
43
|
+
retries: int = 2,
|
|
44
|
+
backoff_base: float = 0.5,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._send = send
|
|
47
|
+
self._on_exhausted = on_exhausted
|
|
48
|
+
self._retries = retries
|
|
49
|
+
self._backoff_base = backoff_base
|
|
50
|
+
self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
|
|
51
|
+
self._dropped = 0
|
|
52
|
+
self._worker = threading.Thread(
|
|
53
|
+
target=self._run, name="apprentice-capture", daemon=True
|
|
54
|
+
)
|
|
55
|
+
self._worker.start()
|
|
56
|
+
atexit.register(self.flush)
|
|
57
|
+
|
|
58
|
+
def submit(self, record: TraceRecord) -> None:
|
|
59
|
+
"""Enqueue a trace without blocking. Drops the oldest if the queue is full."""
|
|
60
|
+
try:
|
|
61
|
+
self._queue.put_nowait(record)
|
|
62
|
+
except queue.Full:
|
|
63
|
+
# Backend is unreachable and the buffer is full — drop the oldest pending
|
|
64
|
+
# trace to make room for the newest, so we keep the freshest signal.
|
|
65
|
+
try:
|
|
66
|
+
self._queue.get_nowait()
|
|
67
|
+
self._queue.task_done()
|
|
68
|
+
except queue.Empty:
|
|
69
|
+
pass
|
|
70
|
+
self._dropped += 1
|
|
71
|
+
if self._dropped == 1 or self._dropped % 100 == 0:
|
|
72
|
+
logger.warning(
|
|
73
|
+
"runapprentice: capture buffer full, dropped %d trace(s) "
|
|
74
|
+
"(backend unreachable; app unaffected)",
|
|
75
|
+
self._dropped,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
self._queue.put_nowait(record)
|
|
79
|
+
except queue.Full:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def _run(self) -> None:
|
|
83
|
+
while True:
|
|
84
|
+
item = self._queue.get()
|
|
85
|
+
try:
|
|
86
|
+
if item is _SHUTDOWN:
|
|
87
|
+
return
|
|
88
|
+
assert isinstance(item, TraceRecord)
|
|
89
|
+
self._deliver(item)
|
|
90
|
+
finally:
|
|
91
|
+
self._queue.task_done()
|
|
92
|
+
|
|
93
|
+
def _deliver(self, record: TraceRecord) -> None:
|
|
94
|
+
last_error: Exception | None = None
|
|
95
|
+
for attempt in range(self._retries + 1):
|
|
96
|
+
try:
|
|
97
|
+
self._send(record) # True=delivered, False=permanent reject
|
|
98
|
+
return
|
|
99
|
+
except Exception as exc: # noqa: BLE001 — transient; retry then give up
|
|
100
|
+
last_error = exc
|
|
101
|
+
if attempt < self._retries:
|
|
102
|
+
time.sleep(self._backoff_base * (2**attempt) + random.uniform(0, 0.1))
|
|
103
|
+
if last_error is not None:
|
|
104
|
+
self._on_exhausted(f"capture failed after {self._retries + 1} attempts: {last_error}")
|
|
105
|
+
|
|
106
|
+
def flush(self, timeout: float = 2.0) -> None:
|
|
107
|
+
"""Wait up to `timeout` seconds for in-flight traces to finish delivering.
|
|
108
|
+
|
|
109
|
+
Counts items as done only after the worker calls `task_done()` (i.e. after
|
|
110
|
+
delivery + any retries complete), not merely when they leave the queue.
|
|
111
|
+
"""
|
|
112
|
+
deadline = time.monotonic() + timeout
|
|
113
|
+
while self._queue.unfinished_tasks > 0 and time.monotonic() < deadline:
|
|
114
|
+
time.sleep(0.02)
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
self._queue.put(_SHUTDOWN)
|