rote-cli 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,341 @@
1
+ # LLM Judge Extraction
2
+
3
+ Phase 4 of the graduator turns every `llm_judge` node's fuzzy prose
4
+ rubric into a typed signature with bounded inputs and outputs. This
5
+ file is the reference for that extraction.
6
+
7
+ The premise is simple: if the source skill's rubric defines a
8
+ classification (red flags, tiers, decision categories), that
9
+ classification has a natural schema — and *running it as an unbounded
10
+ LLM prompt throws away all the structure*. A typed signature recovers
11
+ the structure, makes the step regression-testable, and lets downstream
12
+ nodes consume a predictable shape instead of parsing free text.
13
+
14
+ ## What a typed signature looks like
15
+
16
+ A typed signature is a Python class (or BAML function, or DSPy
17
+ Signature) with three parts:
18
+
19
+ 1. **Input model** — a Pydantic BaseModel with every field the LLM
20
+ needs, sourced from upstream nodes.
21
+ 2. **Output model** — a Pydantic BaseModel with enum-bounded fields
22
+ wherever the rubric implies a discrete choice, plus any structured
23
+ rationale.
24
+ 3. **Forward method** — the call site that dispatches to the LLM. For
25
+ v0 this is a stub; downstream work fills it in with DSPy or BAML.
26
+
27
+ The BDR example `signatures/vet_contact.py` is the canonical reference
28
+ implementation.
29
+
30
+ ## The extraction procedure
31
+
32
+ ### Step 1 — Read the source rubric
33
+
34
+ Find every piece of prose in the source skill that describes how to
35
+ classify the input. For BDR's `vet_contact`, this is
36
+ `references/quality-and-vetting.md`:
37
+
38
+ - High-signal indicators (boosts)
39
+ - Red flags (discard)
40
+ - The core test ("would this person commission an RWE study?")
41
+ - Tier definitions (ideal / strong / good)
42
+ - Numeric thresholds (accuracy ≥ 85)
43
+
44
+ ### Step 2 — Enumerate the decision space
45
+
46
+ Ask: what are the possible outputs? For `vet_contact`:
47
+
48
+ | Dimension | Values |
49
+ |---|---|
50
+ | Decision | `keep`, `discard` |
51
+ | Tier (if keep) | `ideal`, `strong`, `good` |
52
+ | Reason (if discard) | `indication_mismatch`, `msl_role`, `biomarker_discovery`, `translational`, `sales_commercial`, `ops_strategy`, `program_management`, `low_accuracy`, `no_valid_email`, `other` |
53
+ | Evidence | free-form string (1-2 sentences) |
54
+
55
+ Anything with a small enumerable value space becomes an **enum**. Free
56
+ text is only used for the *evidence* or *explanation* field, never for
57
+ the core decision.
58
+
59
+ **Rule of thumb:** if you can write the values in a table like the one
60
+ above, it's an enum. If the values would span a paragraph, it's free
61
+ text.
62
+
63
+ ### Step 3 — Design the input model
64
+
65
+ Walk the IR backward from the node. The input model must contain every
66
+ field the LLM needs to make the decision. For `vet_contact`:
67
+
68
+ ```python
69
+ class VetContactInput(BaseModel):
70
+ model_config = ConfigDict(extra="forbid")
71
+
72
+ contact: EnrichedContact # from enrich_contact_batch upstream
73
+ brief: CampaignBrief # from pipeline input
74
+ intel: IntelBrief # from target_research upstream
75
+ ```
76
+
77
+ **`extra="forbid"`** prevents accidental field additions that would
78
+ silently break downstream reads. Strict-by-default.
79
+
80
+ ### Step 4 — Design the output model
81
+
82
+ ```python
83
+ class VetDecision(str, Enum):
84
+ KEEP = "keep"
85
+ DISCARD = "discard"
86
+
87
+ class ContactTier(str, Enum):
88
+ IDEAL = "ideal"
89
+ STRONG = "strong"
90
+ GOOD = "good"
91
+
92
+ class DiscardReason(str, Enum):
93
+ INDICATION_MISMATCH = "indication_mismatch"
94
+ MSL_ROLE = "msl_role"
95
+ BIOMARKER_DISCOVERY = "biomarker_discovery"
96
+ # ... one per rubric category
97
+
98
+ class VetContactOutput(BaseModel):
99
+ model_config = ConfigDict(extra="forbid")
100
+
101
+ decision: VetDecision
102
+ tier: ContactTier | None = None # only when decision == keep
103
+ discard_reason: DiscardReason | None = None # only when decision == discard
104
+ relevance_evidence: str
105
+ ```
106
+
107
+ **Optional fields with `None` defaults** are how you express "this
108
+ field only applies in some decision branches" without making the
109
+ schema conditional. Downstream consumers handle the None case.
110
+
111
+ ### Step 5 — Lift hard thresholds into a pre-filter
112
+
113
+ If the rubric contains any numeric threshold or enum check that can be
114
+ evaluated without the LLM, put it in the `forward()` method as a
115
+ pre-filter that short-circuits before calling the model.
116
+
117
+ From `signatures/vet_contact.py`:
118
+
119
+ ```python
120
+ MIN_ACCURACY_SCORE: int = 85
121
+
122
+ class VetContact:
123
+ async def forward(self, inputs: VetContactInput) -> VetContactOutput:
124
+ if inputs.contact.accuracy_score < MIN_ACCURACY_SCORE:
125
+ return VetContactOutput(
126
+ decision=VetDecision.DISCARD,
127
+ discard_reason=DiscardReason.LOW_ACCURACY,
128
+ relevance_evidence=(
129
+ f"Accuracy score {inputs.contact.accuracy_score} "
130
+ f"below threshold {MIN_ACCURACY_SCORE}."
131
+ ),
132
+ )
133
+ # ... dispatch to LLM for fuzzy cases
134
+ ```
135
+
136
+ **Why this matters:** the pre-filter saves tokens on the obvious cases
137
+ (probably 20-40% of real contacts) and guarantees the hard rule cannot
138
+ drift. The LLM can never "forget" the accuracy threshold because it
139
+ never sees those contacts.
140
+
141
+ ### Step 6 — Scaffold a seed eval set
142
+
143
+ For every `llm_judge` node, create a seed evals file at the path
144
+ referenced by the node's `eval_set:` field. Each line is one test case.
145
+
146
+ Harvest examples directly from the source rubric. For every
147
+ discard_reason enum value, construct at least one input that should
148
+ produce that reason.
149
+
150
+ For BDR `evals/vet_contact.jsonl`:
151
+
152
+ ```jsonl
153
+ {"name": "msl_role_discard", "input": {"contact": {"job_title": "Medical Science Liaison, Respiratory", "employment_history": [{"title": "MSL, Oncology"}], "accuracy_score": 95}, "brief": {"therapeutic_area": "respiratory"}}, "expected": {"decision": "discard", "discard_reason": "msl_role"}}
154
+ {"name": "biomarker_discard", "input": {"contact": {"job_title": "Director, Biomarker Sciences", "accuracy_score": 92}}, "expected": {"decision": "discard", "discard_reason": "biomarker_discovery"}}
155
+ {"name": "low_accuracy_discard", "input": {"contact": {"job_title": "Sr. Director RWE", "accuracy_score": 70}}, "expected": {"decision": "discard", "discard_reason": "low_accuracy"}}
156
+ {"name": "ideal_coe", "input": {"contact": {"job_title": "Sr. Director, Real World Evidence CoE", "employment_history": [...]}}, "expected": {"decision": "keep", "tier": "ideal"}}
157
+ ```
158
+
159
+ These seed examples are not a full eval suite — they're the starting
160
+ point a human can expand as they encounter real cases. But having them
161
+ in the repo means the downstream DSPy/BAML compile step has a regression
162
+ baseline to optimize against.
163
+
164
+ ## Common patterns
165
+
166
+ ### Pattern: "discard categories" rubric
167
+
168
+ The source skill has an explicit list of categories to reject. Each
169
+ becomes an enum member of a `Reason` enum, and the eval set has one
170
+ example per category.
171
+
172
+ ### Pattern: "tier the keepers" rubric
173
+
174
+ The source skill defines tiers (ideal / strong / good) with criteria
175
+ for each. Each tier becomes an enum member of a `Tier` field, which is
176
+ optional (only set when the decision is to keep).
177
+
178
+ ### Pattern: "one judge, fan out"
179
+
180
+ If the source skill applies the same judgment to a list of inputs
181
+ (vet 50 contacts, personalize 10 emails), set `fan_out: true` on the
182
+ node in the IR. The adapter will invoke the signature once per input
183
+ element in parallel. The signature itself handles one input at a time.
184
+
185
+ ### Pattern: "cheap pre-filter + expensive LLM"
186
+
187
+ Almost every `llm_judge` has at least one numeric or enum constraint
188
+ hiding in the rubric. Always check.
189
+
190
+ ## What does NOT belong in a signature
191
+
192
+ - **Exploratory work.** If the "classification" requires the agent to
193
+ decide which external sources to consult, it's an `agent_loop`, not
194
+ an `llm_judge`.
195
+ - **Unbounded generation.** If the output is a paragraph of free prose
196
+ with no schema, reconsider whether it's the right kind. Most
197
+ "generate a paragraph" tasks can be narrowed to a few structured
198
+ fields (opening line + TA callout + CTA, for example).
199
+ - **Multi-step reasoning across many inputs.** Signatures take one
200
+ bounded input and return one bounded output. If the step needs to
201
+ reason across a batch, use `fan_out: true` so each input is its
202
+ own invocation.
203
+
204
+ ## The output of Phase 4
205
+
206
+ For each `llm_judge` node:
207
+
208
+ 1. A file at `signatures/<node_name>.py` with:
209
+ - The input model
210
+ - The output model
211
+ - The enum definitions
212
+ - The signature class with a stub `forward()` method (pre-filter
213
+ logic included; LLM dispatch raises `NotImplementedError`)
214
+ 2. A file at `evals/<node_name>.jsonl` with 3–10 seed examples, one
215
+ per distinct decision path.
216
+ 3. A `signature_spec:` block embedded directly in the
217
+ `pipeline.yaml` node — see "Cross-runtime signature_spec" below.
218
+
219
+ Record every signature extracted in the Phase 7 graduation report with
220
+ the source rubric location and a one-line summary of the output schema.
221
+ This is the audit trail for the human reviewer.
222
+
223
+ ## Cross-runtime signature_spec
224
+
225
+ Python signature files (item 1 above) work for the Temporal adapter,
226
+ which emits Python and can `import` them directly. They do **not**
227
+ work for runtimes that emit a different language — the Cloudflare
228
+ adapter emits TypeScript, can't read Python, and has no way to call
229
+ into a Python BAML/DSPy client (those have native Rust binaries that
230
+ don't run on Cloudflare Workers' V8 isolate).
231
+
232
+ The IR carries a runtime-agnostic structured form alongside the path:
233
+ `signature_spec`. Every `llm_judge` node should populate it. The
234
+ adapter that consumes the IR converts the schemas to whatever native
235
+ shape its target language expects — Pydantic for Python, Zod for
236
+ TypeScript, etc.
237
+
238
+ ### Field shape
239
+
240
+ ```yaml
241
+ signature_spec:
242
+ input_schema: { ... } # JSON Schema for the input model
243
+ output_schema: { ... } # JSON Schema for the output model
244
+ prompt: | # Jinja-style {{ var }} interpolation
245
+ <multi-line prompt template>
246
+ client: anthropic # 'anthropic' | 'openai'
247
+ model: claude-sonnet-4-6 # optional; adapter chooses default
248
+ temperature: 0.0 # optional
249
+ ```
250
+
251
+ ### Deriving the JSON Schemas
252
+
253
+ The Pydantic models you wrote in step 3 (input model) and step 4
254
+ (output model) already know how to emit JSON Schema:
255
+
256
+ ```python
257
+ VetContactInput.model_json_schema()
258
+ VetContactOutput.model_json_schema()
259
+ ```
260
+
261
+ Embed those dictionaries verbatim under `input_schema` and
262
+ `output_schema`. The `$defs` block stays inline — adapters resolve
263
+ references at emit time.
264
+
265
+ If you can't run Python, derive the JSON Schema by hand from the
266
+ Pydantic source:
267
+
268
+ | Pydantic | JSON Schema |
269
+ | --- | --- |
270
+ | `field: str` (required) | `{"type": "string"}` in `properties`, name in `required` |
271
+ | `field: int` | `{"type": "integer"}` |
272
+ | `field: float` | `{"type": "number"}` |
273
+ | `field: bool` | `{"type": "boolean"}` |
274
+ | `field: list[X]` | `{"type": "array", "items": <X>}` |
275
+ | `field: SomeEnum` | `{"enum": [<member values>]}` (or `$ref` to a `$defs` entry) |
276
+ | `field: X \| None = None` | `{"anyOf": [<X>, {"type": "null"}], "default": null}`, optional in `required` |
277
+ | `model_config = ConfigDict(extra="forbid")` | `"additionalProperties": false` on the object |
278
+
279
+ For nested Pydantic models, hoist the inner model into a `$defs`
280
+ entry and use `{"$ref": "#/$defs/InnerModelName"}` at the use site.
281
+ This matches Pydantic's own emission shape and lets adapters resolve
282
+ references with a single helper.
283
+
284
+ ### Designing the prompt template
285
+
286
+ The prompt is a Jinja-style template. Variables are addressed by the
287
+ input model's field names (top-level only — adapters use simple
288
+ `{{ contact }}` substitution that JSON-stringifies non-string
289
+ values). Three rules:
290
+
291
+ 1. **Always end with a directive that names the structured-output
292
+ tool** — the adapter wraps the call in tool-use mode and the LLM
293
+ needs the cue to invoke it. Example: *"Return your decision via
294
+ the structured output tool."*
295
+ 2. **Reproduce the discard-categories table inline.** The schema
296
+ already constrains the output enum, but the prompt should still
297
+ describe each category in prose so the LLM has the rubric.
298
+ 3. **Don't paste the source skill's entire reference file** — just
299
+ the rubric. The skill bundle has plenty of context that's
300
+ irrelevant at decision time and inflates token cost per call.
301
+
302
+ ### Worked example: vet_contact prompt
303
+
304
+ ```yaml
305
+ prompt: |
306
+ You are vetting a contact for a BDR outreach campaign.
307
+
308
+ Apply this rubric:
309
+ - Discard if job title indicates MSL, Biomarker/Discovery,
310
+ Translational Research, Sales/Commercial, Operations/Strategy,
311
+ or Program Management.
312
+ - Discard on indication mismatch with the campaign therapeutic
313
+ area.
314
+ - Tier surviving contacts: ideal / strong / good based on RWE/HEOR
315
+ signal density.
316
+
317
+ Core test: would this person commission, design, or approve a
318
+ real-world evidence study?
319
+
320
+ Contact: {{ contact }}
321
+ Campaign brief: {{ brief }}
322
+ Intel brief: {{ intel }}
323
+
324
+ Return your decision via the structured output tool.
325
+ ```
326
+
327
+ ### Pre-filter logic and signature_spec
328
+
329
+ Hard thresholds (the Step 5 pre-filter) live in **Python**, not in the
330
+ prompt. Cross-language emission is the responsibility of the runtime
331
+ adapter. The Temporal adapter calls the Python signature class, which
332
+ runs the pre-filter then dispatches to the LLM. The Cloudflare adapter
333
+ emits a TS function that calls the LLM directly — a future iteration
334
+ will model the pre-filter as a separate `pure_function` node so it
335
+ runs cross-runtime, but for v0.2 the signature_spec is "schema +
336
+ prompt only" and the pre-filter only short-circuits in the Python
337
+ runtime.
338
+
339
+ If you want a hard rule to apply on every runtime today, model it as
340
+ a separate `pure_function` node *before* the `llm_judge` and route
341
+ short-circuited inputs around the LLM via an explicit edge.
@@ -0,0 +1,223 @@
1
+ # Node Kinds — Classification Rubric
2
+
3
+ Every step in a graduated pipeline is exactly **one** of five kinds. Phase 2
4
+ of the graduator's job is assigning the right kind to every step in the
5
+ source skill. This file is the reference for that classification.
6
+
7
+ ## The five kinds
8
+
9
+ ### `pure_function`
10
+
11
+ A step whose logic is fully deterministic: same input, same output, no LLM
12
+ reasoning required, no external API calls.
13
+
14
+ **Signals to recognize one:**
15
+ - The skill's prose describes a fixed transformation (e.g., "format as
16
+ markdown with these sections", "group contacts by company").
17
+ - The skill includes literal Python, pseudocode, or a concrete formula.
18
+ - The step produces a report, a formatted output, or a structured
19
+ summary from already-structured inputs.
20
+ - The step is a loop with a clear termination condition over
21
+ already-gathered data.
22
+
23
+ **Anti-signals (not a `pure_function`):**
24
+ - The step calls an external service → `external_call`.
25
+ - The step makes a fuzzy judgment → `llm_judge`.
26
+ - The logic varies based on context in a way that can't be enumerated
27
+ → `agent_loop`.
28
+
29
+ **BDR example:** `pre_enrollment_report` — takes counts of vetted, passed,
30
+ and excluded contacts and renders a fixed markdown template. No LLM, no
31
+ APIs, just string formatting.
32
+
33
+ **Common mistake:** don't miss steps where the LLM was being used to
34
+ generate obvious string templates. If the skill's prose *already shows you
35
+ the exact output format*, the LLM was doing pure formatting and the step
36
+ is a `pure_function`.
37
+
38
+ ---
39
+
40
+ ### `external_call`
41
+
42
+ A step that makes a deterministic call to an external service (HTTP API,
43
+ database, file system) with retry and timeout semantics.
44
+
45
+ **Signals to recognize one:**
46
+ - The skill uses a tool in a fixed, repeatable way — not exploratory.
47
+ - The call has well-known limits (batch size, rate limits) that appear
48
+ in the skill's prose.
49
+ - The step is "fetch X from service Y given params Z" with no LLM
50
+ reasoning about the response shape.
51
+
52
+ **Anti-signals (not an `external_call`):**
53
+ - The response requires LLM interpretation before being useful →
54
+ that's a pair of nodes: `external_call` followed by `llm_judge`.
55
+ - The tool is being used exploratorily with variable inputs →
56
+ `agent_loop`.
57
+ - The step is a pure in-memory computation → `pure_function`.
58
+
59
+ **BDR example:** `enrich_contact_batch` — calls
60
+ `zoominfo_enrich_contacts` with a fixed output field set and a hard batch
61
+ size of 10. The parameters don't vary run-over-run.
62
+
63
+ **Common mistake:** treating every tool call as an `external_call`. A tool
64
+ call is only an `external_call` if the *semantics* of calling it are
65
+ deterministic. If the agent decides which tool to call and what to pass
66
+ each iteration, it's an `agent_loop`.
67
+
68
+ **MCP → deterministic API:** this is the kind that most often originates
69
+ as an MCP tool call in the source skill. The graduation step is to record
70
+ the underlying vendor API the tool wraps (e.g.,
71
+ `POST /crm/v3/objects/contacts/batch/upsert`) in the `impl`'s docstring so
72
+ the trace from skill → MCP tool → REST endpoint is visible in the code.
73
+
74
+ ---
75
+
76
+ ### `llm_judge`
77
+
78
+ A step that asks an LLM to make a classification or generation against a
79
+ rubric, with typed input and typed output. The fuzzy part is bounded —
80
+ the output space is enumerable or naturally small.
81
+
82
+ **Signals to recognize one:**
83
+ - The skill describes "apply these rules to decide X" where X is a
84
+ bounded decision (keep/discard, tier A/B/C, a short piece of
85
+ generated text).
86
+ - There's an explicit rubric in the skill — red flags list, core test,
87
+ tier definitions, discard categories.
88
+ - The output has a natural schema: decision + reason + evidence.
89
+ - The input is bounded (a single contact, a single row of data) rather
90
+ than exploratory.
91
+
92
+ **Anti-signals (not an `llm_judge`):**
93
+ - The output is free-form prose with no schema → `agent_loop`.
94
+ - The input is exploratory (search queries, research topics) → `agent_loop`.
95
+ - The "judgment" has hard numerical thresholds that could be encoded
96
+ in Python — lift those into a pre-filter in the signature, but the
97
+ step itself can still be an `llm_judge`.
98
+
99
+ **BDR example:** `vet_contact` — takes an enriched contact plus the
100
+ campaign brief, applies the BDR red-flags rubric, returns
101
+ `{decision: keep|discard, tier: ideal|strong|good, discard_reason: ...,
102
+ relevance_evidence: str}`. The decision is fuzzy (reading employment
103
+ history) but the output schema is tight.
104
+
105
+ **Common mistake:** running an `llm_judge` when the classification is
106
+ actually deterministic. If every dimension of the decision maps to a
107
+ boolean check on the input, it's a `pure_function`.
108
+
109
+ ---
110
+
111
+ ### `agent_loop`
112
+
113
+ A step that requires genuine LLM orchestration — the agent decides what
114
+ to do next, which tools to call, and when to terminate, based on
115
+ intermediate results. Reserved for genuinely exploratory work.
116
+
117
+ **Signals to recognize one:**
118
+ - The skill's prose says things like "iterate until...", "try different
119
+ searches", "backfill when gaps appear".
120
+ - The tool choices vary run-over-run.
121
+ - The termination condition depends on intermediate results, not a
122
+ fixed iteration count.
123
+ - The step produces a summary or brief from external research where
124
+ the sources aren't known in advance.
125
+
126
+ **Anti-signals (not an `agent_loop`):**
127
+ - The step is a fixed sequence of API calls with known inputs → chain
128
+ of `external_call` nodes.
129
+ - The step is an LLM classification with bounded output → `llm_judge`.
130
+ - Every "iteration" does the same thing on different inputs → a
131
+ `pure_function` or `external_call` with `fan_out: true`.
132
+
133
+ **BDR example:** `lead_generation_loop` — starts with three parallel
134
+ ZoomInfo searches, enriches in batches, discards contacts that fail
135
+ vetting, backfills with new targeted searches until the quota is met.
136
+ Both the number of iterations and the specific queries vary per campaign.
137
+
138
+ **Required fields:** `tools` must be set on every `agent_loop` node (the
139
+ tools the agent may call). `loop_body` is optional and lists the IDs of
140
+ sub-nodes the loop invokes each iteration.
141
+
142
+ **Common mistake:** leaving things as `agent_loop` when they could be
143
+ crystallized. Most skills over-use this kind on their first pass because
144
+ "the LLM was doing it" is the easiest classification. Fight the urge —
145
+ prefer any other kind when the data supports it. Every step you keep as
146
+ `agent_loop` is a run-time cost and a reliability risk.
147
+
148
+ ---
149
+
150
+ ### `hitl_gate`
151
+
152
+ A step where the workflow pauses waiting for a human signal before
153
+ continuing. Survives worker restarts; resumes when the signal arrives.
154
+
155
+ **Signals to recognize one:**
156
+ - The skill explicitly says "present to the user", "wait for approval",
157
+ "the user may add or remove".
158
+ - The next step is conditional on explicit human decision.
159
+ - The skill says something "must be done manually in the UI" — the
160
+ gate is the thing that confirms it happened.
161
+
162
+ **Anti-signals (not a `hitl_gate`):**
163
+ - The step prints output but doesn't wait for a response → that's the
164
+ end of a pipeline or a terminal `pure_function`.
165
+ - The "human" is conceptual — there's no actual signal → still a
166
+ `pure_function` that produces a report.
167
+
168
+ **BDR examples:**
169
+ 1. `contact_review_gate` — Phase 3 pause where the BDR reviews the
170
+ vetted contact table before CRM upload. Signal:
171
+ `contact_review_approved`.
172
+ 2. `manual_enrollment_handoff` — Phase 7 pause where the BDR manually
173
+ enrolls contacts in HubSpot's UI. Signal: `bdr_enrollment_complete`.
174
+
175
+ **Required field:** every `hitl_gate` MUST have a `signal`. The adapter
176
+ uses this to generate the corresponding signal handler in the workflow.
177
+
178
+ **Optional field:** `notify` tells the adapter how to alert the human
179
+ reviewer when the workflow reaches the gate (Slack channel, email, etc.).
180
+
181
+ ---
182
+
183
+ ## Decision rules for ambiguous cases
184
+
185
+ **When a step could be two kinds, prefer the more deterministic one.** In
186
+ descending order of determinism:
187
+
188
+ ```
189
+ pure_function > external_call > llm_judge > agent_loop
190
+ ```
191
+
192
+ The north star is "keep the LLM at points where the input is unbounded or
193
+ ambiguous, and codify everything else." Every step you move leftward on
194
+ this ladder is tokens saved and reliability gained.
195
+
196
+ **`agent_loop` vs `external_call`:** could the agent do this with a fixed
197
+ sequence of calls? If yes, it's `external_call`s (possibly chained via
198
+ edges or fan-out). If the sequence itself varies based on intermediate
199
+ results, it's `agent_loop`.
200
+
201
+ **`llm_judge` vs `pure_function`:** does the decision depend on reading
202
+ prose (job titles, descriptions, employment histories, free text)? If
203
+ yes, it's `llm_judge`. If it depends only on numeric thresholds or enum
204
+ matching, it's `pure_function`.
205
+
206
+ **`llm_judge` vs `agent_loop`:** does the output have a schema? If yes,
207
+ it's `llm_judge`. Free-form output usually indicates the agent is
208
+ orchestrating something and you haven't found its real boundaries yet.
209
+
210
+ **`hitl_gate` vs report-producing `pure_function`:** is there an explicit
211
+ signal or approval the workflow waits for? If yes, `hitl_gate`. If the
212
+ step just prints a summary and the pipeline ends or continues
213
+ unconditionally, it's a `pure_function`.
214
+
215
+ ## Cheat sheet
216
+
217
+ | Question | Kind |
218
+ |---|---|
219
+ | Is there an explicit human signal? | `hitl_gate` |
220
+ | Is it a vendor API call with fixed semantics? | `external_call` |
221
+ | Is it fully deterministic Python? | `pure_function` |
222
+ | Is it a fuzzy classification with typed output? | `llm_judge` |
223
+ | Does the agent genuinely need to decide? | `agent_loop` |