Skip to content

teff.node.ask

teff.node.ask

Ask — declarative validation strategy for interrupt answers.

interrupt_loop / interrupt with an Ask stop being hard-wired to a single expected word: the strategy decides whether the operator's answer passes, and can capture an arbitrary value (a discount code, a date, …) alongside the pass/fail decision.

Strategies:

  • equals — exact match on a normalized (strip + lowercase) value.
  • any_of — match any of several normalized values.
  • regex — match a regular expression; the first capture group (or the whole match) is extracted.
  • check — a callable fn(value) -> bool or fn(value) -> (bool, extracted).
  • llm — an :class:~teff.node.LLM turns the free-form answer into a structured verdict ({ok: bool, ...}); value_field names the verdict field to capture.

A llm strategy can also declare a third outcome — "unclear, re-ask". When clear_field names a verdict boolean (e.g. clear) that is False, :class:Validate writes clarify_value instead of a pass/fail decision; :meth:~teff.flow.Flow.interrupt_loop then routes that value back to the interrupt (re-ask the operator) without re-running the body chain. This is how a free-form reply like "ghskdlsjdkls" gets re-asked while "yes" / "sure" approve and "no" re-plans.

The strategy is executed by the :class:Validate node, which decodes the verdict / raw answer into a flow.loop decider value (like :class:~teff.node.Gate) and optionally writes the extracted value.

Classes:

Name Description
Ask

Declarative validation strategy for an interrupt answer.

Validate

Decode an interrupt answer into a flow.loop decider value.

Ask

Declarative validation strategy for an interrupt answer.

Use the classmethod constructors to pick a strategy::

Ask.equals("yes")
Ask.any_of("yes", "ok", "sure")
Ask.regex(r"^[A-Z0-9]{4,12}$", value_key="discount_code")
Ask.check(lambda v: v.lower() in {"yes", "ok"})
Ask.llm(system=..., user=..., schema=..., model=..., provider=...)

The strategy is auto-detected from the constructor kwargs, so plain Ask(equals="yes", value_key="code") also works.

Methods:

Name Description
classifier

Build the verdict classifier LLM for the "llm" strategy.

from_mapping

Build an :class:Ask from a declarative strategy mapping.

validate_node

Build the :class:Validate node wired to input_key.

Source code in teff/node/ask.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
class Ask:
    """Declarative validation strategy for an interrupt answer.

    Use the classmethod constructors to pick a strategy::

        Ask.equals("yes")
        Ask.any_of("yes", "ok", "sure")
        Ask.regex(r"^[A-Z0-9]{4,12}$", value_key="discount_code")
        Ask.check(lambda v: v.lower() in {"yes", "ok"})
        Ask.llm(system=..., user=..., schema=..., model=..., provider=...)

    The strategy is auto-detected from the constructor kwargs, so plain
    ``Ask(equals="yes", value_key="code")`` also works.
    """

    def __init__(
        self,
        *,
        equals: Optional[str] = None,
        any_of: Optional[list] = None,
        regex: Optional[str] = None,
        check: Optional[Callable] = None,
        system: str = "",
        user: str = "",
        schema: Optional[dict] = None,
        model: str = "",
        provider: str = "",
        verdict_key: str = "verdict",
        ok_field: str = "ok",
        value_key: str = "",
        value_field: str = "",
        decision_key: str = "decision",
        pass_value: str = "да",
        fail_value: str = "нет",
        clear_field: str = "",
        clarify_value: str = "",
        rounds_key: str = "rounds",
        max_rounds: int = 100,
    ):
        # Internal names avoid colliding with the classmethod constructors.
        self._expected = equals
        self._allowed = list(any_of) if any_of else None
        self._pattern = regex
        self._predicate = check
        self.system = system
        self.user = user
        self.schema = schema
        self.model_name = model
        self.provider = provider
        self.verdict_key = verdict_key
        self.ok_field = ok_field
        self.value_key = value_key
        self.value_field = value_field
        self.decision_key = decision_key
        self.pass_value = pass_value
        self.fail_value = fail_value
        self.clear_field = clear_field
        self.clarify_value = clarify_value
        self.rounds_key = rounds_key
        self.max_rounds = max_rounds

    @property
    def strategy(self) -> str:
        if self._predicate is not None:
            return "check"
        if self._pattern:
            return "regex"
        if self._allowed:
            return "any_of"
        if self._expected is not None:
            return "equals"
        if self.system or self.schema:
            return "llm"
        return ""

    def needs_classifier(self) -> bool:
        return self.strategy == "llm"

    @classmethod
    def equals(cls, value, **kwargs) -> "Ask":
        return cls(equals=value, **kwargs)

    @classmethod
    def any_of(cls, *values, **kwargs) -> "Ask":
        return cls(any_of=list(values), **kwargs)

    @classmethod
    def regex(cls, pattern: str, **kwargs) -> "Ask":
        return cls(regex=pattern, **kwargs)

    @classmethod
    def check(cls, fn: Callable, **kwargs) -> "Ask":
        return cls(check=fn, **kwargs)

    @classmethod
    def llm(
        cls,
        *,
        system: str,
        user: str,
        schema: dict,
        model: str,
        provider: str,
        **kwargs,
    ) -> "Ask":
        return cls(
            system=system,
            user=user,
            schema=schema,
            model=model,
            provider=provider,
            **kwargs,
        )

    @classmethod
    def from_mapping(cls, mapping: dict) -> "Ask":
        """Build an :class:`Ask` from a declarative strategy mapping.

        Mirrors the YAML shorthand on an ``interrupt`` step::

            strategy:
              equals: yes
            # or: any_of: [yes, ok]  |  regex: "^[A-Z0-9]{4}$"
            # or: llm: {system, user, schema, model, provider}

        The mapping's other keys (``value_key``, ``decision_key``,
        ``pass_value``, ``fail_value``, ``verdict_key``, ``ok_field``,
        ``clear_field``, ``clarify_value``, ``rounds_key``, ``max_rounds``)
        are passed through to the chosen strategy constructor.

        Raises:
            ValueError: When no known strategy key is present.
        """
        if "equals" in mapping:
            spec = {k: v for k, v in mapping.items() if k != "equals"}
            return cls(equals=mapping["equals"], **spec)
        if "any_of" in mapping:
            spec = {k: v for k, v in mapping.items() if k != "any_of"}
            return cls(any_of=list(mapping["any_of"]), **spec)
        if "regex" in mapping:
            spec = {k: v for k, v in mapping.items() if k != "regex"}
            return cls(regex=mapping["regex"], **spec)
        if isinstance(mapping.get("llm"), dict):
            llm_cfg = mapping["llm"]
            spec = {k: v for k, v in mapping.items() if k != "llm"}
            return cls(
                system=llm_cfg.get("system", ""),
                user=llm_cfg.get("user", ""),
                schema=llm_cfg.get("schema"),
                model=llm_cfg.get("model", ""),
                provider=llm_cfg.get("provider", ""),
                **spec,
            )
        raise ValueError(
            "strategy requires one of equals / any_of / regex / llm, "
            f"got {sorted(mapping)}"
        )

    def classifier(self) -> LLM:
        """Build the verdict classifier ``LLM`` for the ``"llm"`` strategy."""
        return LLM(
            system=self.system,
            prompt=self.user,
            output_key=self.verdict_key,
            json_schema=self.schema or {},
            model=self.model_name,
            provider=self.provider,
        )

    def validate_node(self, input_key: str) -> "Validate":
        """Build the :class:`Validate` node wired to *input_key*."""
        return Validate(
            input_key=input_key,
            verdict_key=self.verdict_key,
            ok_field=self.ok_field,
            output_key=self.decision_key,
            pass_value=self.pass_value,
            fail_value=self.fail_value,
            clear_field=self.clear_field,
            clarify_value=self.clarify_value,
            value_key=self.value_key,
            value_field=self.value_field,
            rounds_key=self.rounds_key,
            max_rounds=self.max_rounds,
            strategy=self.strategy,
            equals=self._expected,
            any_of=self._allowed,
            regex=self._pattern,
            check=self._predicate,
        )

classifier

classifier()

Build the verdict classifier LLM for the "llm" strategy.

Source code in teff/node/ask.py
204
205
206
207
208
209
210
211
212
213
def classifier(self) -> LLM:
    """Build the verdict classifier ``LLM`` for the ``"llm"`` strategy."""
    return LLM(
        system=self.system,
        prompt=self.user,
        output_key=self.verdict_key,
        json_schema=self.schema or {},
        model=self.model_name,
        provider=self.provider,
    )

from_mapping classmethod

from_mapping(mapping)

Build an :class:Ask from a declarative strategy mapping.

Mirrors the YAML shorthand on an interrupt step::

strategy:
  equals: yes
# or: any_of: [yes, ok]  |  regex: "^[A-Z0-9]{4}$"
# or: llm: {system, user, schema, model, provider}

The mapping's other keys (value_key, decision_key, pass_value, fail_value, verdict_key, ok_field, clear_field, clarify_value, rounds_key, max_rounds) are passed through to the chosen strategy constructor.

Raises:

Type Description
ValueError

When no known strategy key is present.

Source code in teff/node/ask.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
@classmethod
def from_mapping(cls, mapping: dict) -> "Ask":
    """Build an :class:`Ask` from a declarative strategy mapping.

    Mirrors the YAML shorthand on an ``interrupt`` step::

        strategy:
          equals: yes
        # or: any_of: [yes, ok]  |  regex: "^[A-Z0-9]{4}$"
        # or: llm: {system, user, schema, model, provider}

    The mapping's other keys (``value_key``, ``decision_key``,
    ``pass_value``, ``fail_value``, ``verdict_key``, ``ok_field``,
    ``clear_field``, ``clarify_value``, ``rounds_key``, ``max_rounds``)
    are passed through to the chosen strategy constructor.

    Raises:
        ValueError: When no known strategy key is present.
    """
    if "equals" in mapping:
        spec = {k: v for k, v in mapping.items() if k != "equals"}
        return cls(equals=mapping["equals"], **spec)
    if "any_of" in mapping:
        spec = {k: v for k, v in mapping.items() if k != "any_of"}
        return cls(any_of=list(mapping["any_of"]), **spec)
    if "regex" in mapping:
        spec = {k: v for k, v in mapping.items() if k != "regex"}
        return cls(regex=mapping["regex"], **spec)
    if isinstance(mapping.get("llm"), dict):
        llm_cfg = mapping["llm"]
        spec = {k: v for k, v in mapping.items() if k != "llm"}
        return cls(
            system=llm_cfg.get("system", ""),
            user=llm_cfg.get("user", ""),
            schema=llm_cfg.get("schema"),
            model=llm_cfg.get("model", ""),
            provider=llm_cfg.get("provider", ""),
            **spec,
        )
    raise ValueError(
        "strategy requires one of equals / any_of / regex / llm, "
        f"got {sorted(mapping)}"
    )

validate_node

validate_node(input_key)

Build the :class:Validate node wired to input_key.

Source code in teff/node/ask.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def validate_node(self, input_key: str) -> "Validate":
    """Build the :class:`Validate` node wired to *input_key*."""
    return Validate(
        input_key=input_key,
        verdict_key=self.verdict_key,
        ok_field=self.ok_field,
        output_key=self.decision_key,
        pass_value=self.pass_value,
        fail_value=self.fail_value,
        clear_field=self.clear_field,
        clarify_value=self.clarify_value,
        value_key=self.value_key,
        value_field=self.value_field,
        rounds_key=self.rounds_key,
        max_rounds=self.max_rounds,
        strategy=self.strategy,
        equals=self._expected,
        any_of=self._allowed,
        regex=self._pattern,
        check=self._predicate,
    )

Validate

Bases: Node

Decode an interrupt answer into a flow.loop decider value.

Works on two kinds of input:

  • a raw answer (a string from the interrupt resume) matched by the equals / any_of / regex / check strategies;
  • a verdict dict (from an LLM classifier) read via ok_field, with value_field captured into value_key.

Each evaluation increments rounds_key; once it reaches max_rounds the node is forced to pass_value so the enclosing loop terminates deterministically instead of spinning forever.

Config

input_key: State key holding the raw answer or verdict object. strategy: Matching strategy for raw answers. equals/any_of/regex/check: Strategy parameters (raw answers). verdict_key: State key holding the classifier's verdict object. ok_field: Pass-flag field of the verdict object. output_key: State key receiving pass_value / fail_value. pass_value/fail_value: Decision values written on pass / fail. clear_field: Optional verdict boolean naming "is this answer decipherable". When it is False the node writes clarify_value instead of pass/fail (re-ask, no body). clarify_value: Decision value written when clear_field is False (falls back to fail_value when empty). value_key: State key receiving the extracted value (cleared on a fail). Empty to skip. value_field: Verdict field captured into value_key. rounds_key: State key with the evaluation counter (incremented). max_rounds: After this many evaluations the node is forced to pass. missing_is_ok: Treat a missing / non-dict input as a pass.

Source code in teff/node/ask.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
class Validate(Node):
    """Decode an interrupt answer into a ``flow.loop`` decider value.

    Works on two kinds of input:

    * a **raw answer** (a string from the interrupt resume) matched by the
      ``equals`` / ``any_of`` / ``regex`` / ``check`` strategies;
    * a **verdict dict** (from an ``LLM`` classifier) read via *ok_field*,
      with *value_field* captured into *value_key*.

    Each evaluation increments ``rounds_key``; once it reaches
    ``max_rounds`` the node is forced to ``pass_value`` so the enclosing
    loop terminates deterministically instead of spinning forever.

    Config:
        input_key: State key holding the raw answer or verdict object.
        strategy: Matching strategy for raw answers.
        equals/any_of/regex/check: Strategy parameters (raw answers).
        verdict_key: State key holding the classifier's verdict object.
        ok_field: Pass-flag field of the verdict object.
        output_key: State key receiving ``pass_value`` / ``fail_value``.
        pass_value/fail_value: Decision values written on pass / fail.
        clear_field: Optional verdict boolean naming "is this answer
            decipherable".  When it is ``False`` the node writes
            ``clarify_value`` instead of pass/fail (re-ask, no body).
        clarify_value: Decision value written when *clear_field* is
            ``False`` (falls back to *fail_value* when empty).
        value_key: State key receiving the extracted value (cleared on a
            fail).  Empty to skip.
        value_field: Verdict field captured into *value_key*.
        rounds_key: State key with the evaluation counter (incremented).
        max_rounds: After this many evaluations the node is forced to pass.
        missing_is_ok: Treat a missing / non-dict input as a pass.
    """

    type = "validate"

    def __init__(
        self,
        config: dict | None = None,
        *,
        input_key: str = "answer",
        strategy: str = "",
        equals: Optional[str] = None,
        any_of: Optional[list] = None,
        regex: Optional[str] = None,
        check: Optional[Callable] = None,
        verdict_key: str = "verdict",
        ok_field: str = "ok",
        output_key: str = "decision",
        pass_value: str = "да",
        fail_value: str = "нет",
        clear_field: str = "",
        clarify_value: str = "",
        value_key: str = "",
        value_field: str = "",
        rounds_key: str = "rounds",
        max_rounds: int = 100,
        missing_is_ok: bool = False,
        **kwargs,
    ):
        merged = {
            "input_key": input_key,
            "strategy": strategy,
            "equals": equals,
            "any_of": any_of,
            "regex": regex,
            "check": check,
            "verdict_key": verdict_key,
            "ok_field": ok_field,
            "output_key": output_key,
            "pass_value": pass_value,
            "fail_value": fail_value,
            "clear_field": clear_field,
            "clarify_value": clarify_value,
            "value_key": value_key,
            "value_field": value_field,
            "rounds_key": rounds_key,
            "max_rounds": max_rounds,
            "missing_is_ok": missing_is_ok,
            **(config or {}),
            **kwargs,
        }
        super().__init__(**merged)

    def _match(self, raw):
        """Return ``(ok, extracted)`` for a raw answer."""
        cfg = self.config
        strategy = cfg["strategy"]
        if strategy == "equals":
            ok = _norm(raw) == _norm(cfg["equals"])
            return ok, (raw if ok else None)
        if strategy == "any_of":
            ok = _norm(raw) in {_norm(v) for v in cfg["any_of"]}
            return ok, (raw if ok else None)
        if strategy == "regex":
            m = re.search(cfg["regex"], str(raw or ""))
            ok = m is not None
            value = None
            if m:
                value = m.group(1) if m.groups() else m.group(0)
            return ok, value
        if strategy == "check":
            res = cfg["check"](raw)
            if isinstance(res, tuple):
                ok, value = res
                return bool(ok), value
            ok = bool(res)
            return ok, (raw if ok else None)
        if isinstance(raw, dict):
            ok = bool(raw.get(cfg["ok_field"], cfg["missing_is_ok"]))
            value = raw.get(cfg["value_field"]) if cfg["value_field"] else None
            return ok, value
        return bool(cfg["missing_is_ok"]), None

    async def execute(self, ctx, state: dict) -> dict:
        cfg = self.config
        rounds = int(state.get(cfg["rounds_key"], 0) or 0) + 1

        data = state.get(cfg["input_key"])
        if isinstance(data, dict):
            ok = bool(data.get(cfg["ok_field"], cfg["missing_is_ok"]))
            value = data.get(cfg["value_field"]) if cfg["value_field"] else None
            clear = cfg["clear_field"] == "" or bool(
                data.get(cfg["clear_field"], False)
            )
        else:
            ok, value = self._match(data)
            clear = True

        forced = rounds >= int(cfg["max_rounds"])
        if not forced and not clear:
            # the verdict is unclear — route to the "re-ask" branch (no body)
            decision = cfg["clarify_value"] or cfg["fail_value"]
            passed = False
        else:
            passed = bool(ok or forced)
            decision = cfg["pass_value"] if passed else cfg["fail_value"]

        out: dict = {
            cfg["rounds_key"]: rounds,
            cfg["output_key"]: decision,
        }
        if cfg["value_key"]:
            out[cfg["value_key"]] = value if passed else ""
        return out