flowspec2 1.0.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.
- flowspec2/__init__.py +105 -0
- flowspec2/authoring/__init__.py +275 -0
- flowspec2/authoring/authoring-evidence-signature.schema.json +34 -0
- flowspec2/authoring/authoring-evidence.schema.json +455 -0
- flowspec2/authoring/authoring-operational-evidence.schema.json +160 -0
- flowspec2/authoring/benchmark.py +1409 -0
- flowspec2/authoring/corpus/await_correction.case.json +220 -0
- flowspec2/authoring/corpus/flowspec2.ctk.json +769 -0
- flowspec2/authoring/corpus/gated_derive.case.json +100 -0
- flowspec2/authoring/corpus/linear.case.json +55 -0
- flowspec2/authoring/corpus/manifest.json +31 -0
- flowspec2/authoring/corpus/subflow.case.json +66 -0
- flowspec2/authoring/corpus/terminal.case.json +94 -0
- flowspec2/authoring/corpus.py +307 -0
- flowspec2/authoring/ctk.py +836 -0
- flowspec2/authoring/detached_signature.py +120 -0
- flowspec2/authoring/evidence.py +311 -0
- flowspec2/authoring/evidence_signature.py +187 -0
- flowspec2/authoring/evidence_verification.py +229 -0
- flowspec2/authoring/gemini.py +215 -0
- flowspec2/authoring/operational-corpus.json +61 -0
- flowspec2/authoring/operational.py +956 -0
- flowspec2/authoring/operational_providers.py +167 -0
- flowspec2/authoring/presentation_review.py +1013 -0
- flowspec2/authoring/presentation_review_signature.py +305 -0
- flowspec2/authoring/projection.py +299 -0
- flowspec2/authoring/provider_prompt.py +83 -0
- flowspec2/backends/__init__.py +82 -0
- flowspec2/backends/http.py +189 -0
- flowspec2/checker.py +238 -0
- flowspec2/cli.py +789 -0
- flowspec2/cli_parser.py +345 -0
- flowspec2/clock.py +23 -0
- flowspec2/compat/__init__.py +47 -0
- flowspec2/compat/models.py +82 -0
- flowspec2/compat/open_workflow.py +616 -0
- flowspec2/compat/rasa.py +19 -0
- flowspec2/compat/rasa_export.py +876 -0
- flowspec2/compat/rasa_import.py +992 -0
- flowspec2/compat/rasa_shared.py +270 -0
- flowspec2/compat/schemas/open-workflow-conversation-1.schema.json +138 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.LICENSE +201 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.provenance.json +13 -0
- flowspec2/compat/schemas/vendor/open-workflow-1.0.3.workflow.yaml +1956 -0
- flowspec2/compat/tool_profiles.py +149 -0
- flowspec2/compat/yaml.py +147 -0
- flowspec2/compiler.py +957 -0
- flowspec2/compiler_contracts.py +17 -0
- flowspec2/compiler_resume_contracts.py +594 -0
- flowspec2/compiler_schema_relations.py +1830 -0
- flowspec2/compiler_tool_contracts.py +245 -0
- flowspec2/compiler_value_contracts.py +447 -0
- flowspec2/derive.py +32 -0
- flowspec2/diagnostics.py +94 -0
- flowspec2/domains.py +489 -0
- flowspec2/experimental/__init__.py +57 -0
- flowspec2/experimental/flowspec-3-draft.schema.json +923 -0
- flowspec2/experimental/v3-preview-loss-policy.json +161 -0
- flowspec2/experimental/v3_lowering.py +928 -0
- flowspec2/experimental/v3_preview.py +2460 -0
- flowspec2/flowspec-2.schema.json +635 -0
- flowspec2/interactive.py +300 -0
- flowspec2/ir.py +1459 -0
- flowspec2/json_codec.py +120 -0
- flowspec2/llm.py +366 -0
- flowspec2/models.py +121 -0
- flowspec2/nodes.py +1537 -0
- flowspec2/observability.py +151 -0
- flowspec2/predicates.py +89 -0
- flowspec2/profiles.py +118 -0
- flowspec2/py.typed +0 -0
- flowspec2/runtime.py +1059 -0
- flowspec2/schema.py +54 -0
- flowspec2/schema_contracts.py +203 -0
- flowspec2/semantic_derive_contracts.py +530 -0
- flowspec2/semantic_path_contracts.py +528 -0
- flowspec2/semantic_predicate_contracts.py +355 -0
- flowspec2/semantic_schema_contracts.py +137 -0
- flowspec2/semantic_source_contracts.py +21 -0
- flowspec2/semantic_state_contracts.py +450 -0
- flowspec2/semantic_support.py +40 -0
- flowspec2/semantics.py +410 -0
- flowspec2/state_migration.py +1034 -0
- flowspec2/subflows/__init__.py +1117 -0
- flowspec2/subflows/address.py +328 -0
- flowspec2/subflows/identification.py +1031 -0
- flowspec2/tools.py +1154 -0
- flowspec2-1.0.0.dist-info/METADATA +482 -0
- flowspec2-1.0.0.dist-info/RECORD +92 -0
- flowspec2-1.0.0.dist-info/WHEEL +4 -0
- flowspec2-1.0.0.dist-info/entry_points.txt +2 -0
- flowspec2-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
"""identification@2 — method select → gov.br (await_external) | Brazilian tax ID → email → name.
|
|
2
|
+
|
|
3
|
+
Exposes the ``brazilian_tax_id``/``email``/``name`` slots. gov.br is modelled as an
|
|
4
|
+
out-of-band suspend/resume: the first visit emits the login affordance and
|
|
5
|
+
pauses; a later turn carrying a ``govbr_token`` resumes and populates the slots.
|
|
6
|
+
Refusal/abort (when identification is optional) routes to anonymous. Every
|
|
7
|
+
collection leg caps re-asks at ``max_attempts`` and dispatches the configured
|
|
8
|
+
``on_exhaust`` policy.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from typing import Any, Final, Literal, Optional, cast
|
|
17
|
+
|
|
18
|
+
from langgraph.graph import END as LANGGRAPH_END
|
|
19
|
+
|
|
20
|
+
from ..domains import normalize_text
|
|
21
|
+
from ..models import CORRECTION_REQUESTED_INTERNAL_KEY, AgentResponse, ServiceState
|
|
22
|
+
from ..nodes import (
|
|
23
|
+
NEXT,
|
|
24
|
+
FlowContext,
|
|
25
|
+
NodeDesc,
|
|
26
|
+
clear_cascade,
|
|
27
|
+
inc_attempts,
|
|
28
|
+
make_await_external_node,
|
|
29
|
+
mark_flow_finished,
|
|
30
|
+
reset_attempts,
|
|
31
|
+
)
|
|
32
|
+
from ..observability import log_event
|
|
33
|
+
from . import SubflowBuild
|
|
34
|
+
|
|
35
|
+
_REFUSAL = (
|
|
36
|
+
"anonymous",
|
|
37
|
+
"without identification",
|
|
38
|
+
"do not identify",
|
|
39
|
+
"refuse",
|
|
40
|
+
"none",
|
|
41
|
+
"without brazilian_tax_id",
|
|
42
|
+
"skip",
|
|
43
|
+
)
|
|
44
|
+
END: Final[str] = LANGGRAPH_END
|
|
45
|
+
_EXHAUSTION_MODES = frozenset({"reask", "skip", "default", "handoff", "END"})
|
|
46
|
+
_EXHAUSTED_ERROR = "maximum attempts reached; let us try again"
|
|
47
|
+
_HANDOFF_DESCRIPTION = "I will transfer you to a support agent."
|
|
48
|
+
_END_DESCRIPTION = "I could not complete identification. Try again later."
|
|
49
|
+
_BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS: Final[dict[str, str]] = {
|
|
50
|
+
"email": "_brazilian_tax_id_lookup_derived:email",
|
|
51
|
+
"name": "_brazilian_tax_id_lookup_derived:name",
|
|
52
|
+
}
|
|
53
|
+
_IdentificationStage = Literal["method", "brazilian_tax_id", "email", "name"]
|
|
54
|
+
_IdentificationMethod = Literal["brazilian_tax_id", "govbr", "anonymous"]
|
|
55
|
+
_ExhaustionMode = Literal["reask", "skip", "default", "handoff", "END"]
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_refusal(text: str) -> bool:
|
|
61
|
+
norm = normalize_text(text)
|
|
62
|
+
return bool(norm) and any(tok in norm for tok in _REFUSAL)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Exact-token skip for the optional contact slots (email/name/brazilian_tax_id). Exact match —
|
|
66
|
+
# not substring — so a real value like "skipper@x.com" is never read as a skip.
|
|
67
|
+
_SKIP_TOKENS = {"skip", "no", "none", "pass", "next", "later"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_skip(text: str) -> bool:
|
|
71
|
+
return normalize_text(text) in _SKIP_TOKENS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _clear_brazilian_tax_id_lookup_derived_contacts(state: ServiceState, ctx: FlowContext) -> None:
|
|
75
|
+
for contact_slot, marker_key in _BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS.items():
|
|
76
|
+
if state.internal.pop(marker_key, False):
|
|
77
|
+
clear_cascade(state, contact_slot, ctx)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _mark_brazilian_tax_id_lookup_derived_contact(state: ServiceState, contact_slot: str) -> None:
|
|
81
|
+
state.internal[_BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS[contact_slot]] = True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _clear_brazilian_tax_id_lookup_provenance(state: ServiceState, contact_slot: str) -> None:
|
|
85
|
+
state.internal.pop(_BRAZILIAN_TAX_ID_LOOKUP_DERIVED_MARKERS[contact_slot], None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _govbr_validation_failure_response(
|
|
89
|
+
ctx: FlowContext,
|
|
90
|
+
state: ServiceState,
|
|
91
|
+
*,
|
|
92
|
+
tax_id_fallback: bool = True,
|
|
93
|
+
) -> AgentResponse:
|
|
94
|
+
log_id = log_event(
|
|
95
|
+
logger,
|
|
96
|
+
logging.WARNING,
|
|
97
|
+
"gov.br authentication response could not be validated",
|
|
98
|
+
operation="authenticate_govbr",
|
|
99
|
+
log_id_generator=ctx.log_id_generator,
|
|
100
|
+
context={"flow": state.service_name},
|
|
101
|
+
)
|
|
102
|
+
return AgentResponse(
|
|
103
|
+
description=(
|
|
104
|
+
"I could not validate gov.br. Shall we try the Brazilian tax ID instead?"
|
|
105
|
+
if tax_id_fallback
|
|
106
|
+
else "I could not validate gov.br. Try again."
|
|
107
|
+
),
|
|
108
|
+
log_id=log_id,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _normalize_method(raw: str) -> Optional[_IdentificationMethod]:
|
|
113
|
+
norm = normalize_text(raw)
|
|
114
|
+
if "govbr" in norm or "gov.br" in norm or norm == "gov" or "gov br" in norm:
|
|
115
|
+
return "govbr"
|
|
116
|
+
if "brazilian_tax_id" in norm:
|
|
117
|
+
return "brazilian_tax_id"
|
|
118
|
+
if _is_refusal(norm) or norm == "anonymous":
|
|
119
|
+
return "anonymous"
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _call_optional_identification_tool(
|
|
124
|
+
ctx: FlowContext,
|
|
125
|
+
tool_name: str,
|
|
126
|
+
**tool_inputs: Any,
|
|
127
|
+
) -> Optional[dict[str, Any]]:
|
|
128
|
+
"""Call a subflow enrichment whose contract is explicitly best-effort."""
|
|
129
|
+
try:
|
|
130
|
+
tool_result = await ctx.tools.call(tool_name, **tool_inputs)
|
|
131
|
+
if not isinstance(tool_result, Mapping):
|
|
132
|
+
raise TypeError(f"tool {tool_name!r} returned a non-object result")
|
|
133
|
+
return dict(tool_result)
|
|
134
|
+
except Exception:
|
|
135
|
+
log_event(
|
|
136
|
+
logger,
|
|
137
|
+
logging.WARNING,
|
|
138
|
+
"Optional identification enrichment failed",
|
|
139
|
+
operation="identification",
|
|
140
|
+
log_id_generator=ctx.log_id_generator,
|
|
141
|
+
context={"tool": tool_name},
|
|
142
|
+
exc_info=True,
|
|
143
|
+
)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class IdentificationSubflow:
|
|
148
|
+
name = "identification"
|
|
149
|
+
major = 2
|
|
150
|
+
|
|
151
|
+
def build(self, ctx: FlowContext, with_cfg: dict[str, Any]) -> SubflowBuild:
|
|
152
|
+
required = bool(with_cfg.get("required", ctx.config.get("identification_required", False)))
|
|
153
|
+
methods: tuple[_IdentificationMethod, ...] = tuple(
|
|
154
|
+
cast(
|
|
155
|
+
list[_IdentificationMethod],
|
|
156
|
+
with_cfg.get("methods", ["brazilian_tax_id", "govbr", "anonymous"]),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
eligible_methods: tuple[_IdentificationMethod, ...] = tuple(
|
|
160
|
+
method for method in methods if method != "anonymous" or not required
|
|
161
|
+
)
|
|
162
|
+
if not eligible_methods:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
"identification@2 requires an eligible method; required identification "
|
|
165
|
+
"must configure brazilian_tax_id or govbr"
|
|
166
|
+
)
|
|
167
|
+
eligible_method_set: frozenset[_IdentificationMethod] = frozenset(eligible_methods)
|
|
168
|
+
max_attempts = int(with_cfg.get("max_attempts", ctx.max_attempts))
|
|
169
|
+
configured_on_exhaust = str(with_cfg.get("on_exhaust", "reask"))
|
|
170
|
+
if max_attempts < 1:
|
|
171
|
+
raise ValueError("identification@2 max_attempts must be positive")
|
|
172
|
+
if configured_on_exhaust not in _EXHAUSTION_MODES:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"identification@2 has unsupported on_exhaust mode: {configured_on_exhaust!r}"
|
|
175
|
+
)
|
|
176
|
+
on_exhaust = cast(_ExhaustionMode, configured_on_exhaust)
|
|
177
|
+
|
|
178
|
+
# register the slots this subflow contributes
|
|
179
|
+
ctx.domains.setdefault("BrazilianTaxId", {"type": "brazilian_tax_id"})
|
|
180
|
+
ctx.domains.setdefault("Email", {"type": "email"})
|
|
181
|
+
ctx.domains.setdefault("Name", {"type": "name"})
|
|
182
|
+
ctx.slots.setdefault("brazilian_tax_id", {"domain": "BrazilianTaxId"})
|
|
183
|
+
ctx.slots.setdefault("email", {"domain": "Email"})
|
|
184
|
+
ctx.slots.setdefault("name", {"domain": "Name"})
|
|
185
|
+
ctx.slot_aux["brazilian_tax_id"] = ["identity_verified", "identification_skipped"]
|
|
186
|
+
ctx.slot_aux["email"] = ["email_processed"]
|
|
187
|
+
ctx.slot_aux["name"] = ["name_processed"]
|
|
188
|
+
tax_id_model = ctx.model_for("brazilian_tax_id")
|
|
189
|
+
email_model = ctx.model_for("email")
|
|
190
|
+
name_model = ctx.model_for("name")
|
|
191
|
+
|
|
192
|
+
def set_identification_method(
|
|
193
|
+
state: ServiceState,
|
|
194
|
+
identification_method: _IdentificationMethod,
|
|
195
|
+
) -> None:
|
|
196
|
+
if identification_method not in eligible_method_set:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"identification@2 attempted to select disabled method "
|
|
199
|
+
f"{identification_method!r}"
|
|
200
|
+
)
|
|
201
|
+
state.data["identification_method"] = identification_method
|
|
202
|
+
if identification_method == "anonymous":
|
|
203
|
+
state.data["identification_skipped"] = True
|
|
204
|
+
else:
|
|
205
|
+
state.data.pop("identification_skipped", None)
|
|
206
|
+
|
|
207
|
+
def alternative_method(
|
|
208
|
+
state: ServiceState,
|
|
209
|
+
*,
|
|
210
|
+
prefer_anonymous: bool,
|
|
211
|
+
) -> _IdentificationMethod | None:
|
|
212
|
+
current_method = state.data.get("identification_method")
|
|
213
|
+
if prefer_anonymous and "anonymous" in eligible_method_set:
|
|
214
|
+
return "anonymous"
|
|
215
|
+
return next(
|
|
216
|
+
(method for method in eligible_methods if method != current_method),
|
|
217
|
+
None,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def validate_optional_lookup_contact(
|
|
221
|
+
state: ServiceState,
|
|
222
|
+
contact_slot: Literal["email", "name"],
|
|
223
|
+
raw_contact: Any,
|
|
224
|
+
) -> str | None:
|
|
225
|
+
try:
|
|
226
|
+
validated_contact_model = ctx.model_for(contact_slot).model_validate(
|
|
227
|
+
{contact_slot: raw_contact}
|
|
228
|
+
)
|
|
229
|
+
except Exception: # noqa: BLE001
|
|
230
|
+
log_event(
|
|
231
|
+
logger,
|
|
232
|
+
logging.WARNING,
|
|
233
|
+
"Brazilian tax ID lookup contact failed slot validation",
|
|
234
|
+
operation="brazilian_tax_id_lookup",
|
|
235
|
+
log_id_generator=ctx.log_id_generator,
|
|
236
|
+
context={
|
|
237
|
+
"flow": state.service_name,
|
|
238
|
+
"slot": contact_slot,
|
|
239
|
+
},
|
|
240
|
+
exc_info=True,
|
|
241
|
+
)
|
|
242
|
+
return None
|
|
243
|
+
return cast(str, getattr(validated_contact_model, contact_slot))
|
|
244
|
+
|
|
245
|
+
def complete_without_value(
|
|
246
|
+
state: ServiceState,
|
|
247
|
+
identification_stage: _IdentificationStage,
|
|
248
|
+
*,
|
|
249
|
+
reask_response: AgentResponse,
|
|
250
|
+
) -> ServiceState:
|
|
251
|
+
reset_attempts(state, identification_stage)
|
|
252
|
+
if identification_stage in {"email", "name"}:
|
|
253
|
+
_clear_brazilian_tax_id_lookup_provenance(state, identification_stage)
|
|
254
|
+
state.data.pop(identification_stage, None)
|
|
255
|
+
state.data[f"{identification_stage}_processed"] = True
|
|
256
|
+
state.agent_response = None
|
|
257
|
+
return state
|
|
258
|
+
|
|
259
|
+
if identification_stage == "brazilian_tax_id":
|
|
260
|
+
_clear_brazilian_tax_id_lookup_derived_contacts(state, ctx)
|
|
261
|
+
state.data.pop("brazilian_tax_id", None)
|
|
262
|
+
fallback_method = alternative_method(state, prefer_anonymous=True)
|
|
263
|
+
if fallback_method is None:
|
|
264
|
+
state.agent_response = reask_response
|
|
265
|
+
return state
|
|
266
|
+
set_identification_method(state, fallback_method)
|
|
267
|
+
state.agent_response = None
|
|
268
|
+
return state
|
|
269
|
+
|
|
270
|
+
def complete_with_default(
|
|
271
|
+
state: ServiceState,
|
|
272
|
+
identification_stage: _IdentificationStage,
|
|
273
|
+
*,
|
|
274
|
+
reask_response: AgentResponse,
|
|
275
|
+
) -> ServiceState:
|
|
276
|
+
if identification_stage in {"email", "name"}:
|
|
277
|
+
return complete_without_value(
|
|
278
|
+
state,
|
|
279
|
+
identification_stage,
|
|
280
|
+
reask_response=reask_response,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
reset_attempts(state, identification_stage)
|
|
284
|
+
current_method = state.data.get("identification_method")
|
|
285
|
+
default_method = (
|
|
286
|
+
"anonymous"
|
|
287
|
+
if identification_stage == "brazilian_tax_id" and "anonymous" in eligible_method_set
|
|
288
|
+
else eligible_methods[0]
|
|
289
|
+
)
|
|
290
|
+
if identification_stage == "brazilian_tax_id":
|
|
291
|
+
_clear_brazilian_tax_id_lookup_derived_contacts(state, ctx)
|
|
292
|
+
state.data.pop("brazilian_tax_id", None)
|
|
293
|
+
set_identification_method(state, default_method)
|
|
294
|
+
state.agent_response = reask_response if default_method == current_method else None
|
|
295
|
+
return state
|
|
296
|
+
|
|
297
|
+
def exhaust(
|
|
298
|
+
state: ServiceState,
|
|
299
|
+
*,
|
|
300
|
+
stage: _IdentificationStage,
|
|
301
|
+
reask_response: AgentResponse,
|
|
302
|
+
) -> ServiceState:
|
|
303
|
+
if on_exhaust == "reask":
|
|
304
|
+
reset_attempts(state, stage)
|
|
305
|
+
state.agent_response = reask_response
|
|
306
|
+
elif on_exhaust == "skip":
|
|
307
|
+
complete_without_value(
|
|
308
|
+
state,
|
|
309
|
+
stage,
|
|
310
|
+
reask_response=reask_response,
|
|
311
|
+
)
|
|
312
|
+
elif on_exhaust == "default":
|
|
313
|
+
complete_with_default(
|
|
314
|
+
state,
|
|
315
|
+
stage,
|
|
316
|
+
reask_response=reask_response,
|
|
317
|
+
)
|
|
318
|
+
elif on_exhaust == "handoff":
|
|
319
|
+
state.agent_response = AgentResponse(description=_HANDOFF_DESCRIPTION)
|
|
320
|
+
else:
|
|
321
|
+
mark_flow_finished(state, reset_next=True)
|
|
322
|
+
state.status = "completed"
|
|
323
|
+
log_id = log_event(
|
|
324
|
+
logger,
|
|
325
|
+
logging.WARNING,
|
|
326
|
+
"Flow stopped after identification attempts were exhausted",
|
|
327
|
+
operation=f"identification:{stage}",
|
|
328
|
+
log_id_generator=ctx.log_id_generator,
|
|
329
|
+
context={"flow": state.service_name, "stage": stage},
|
|
330
|
+
)
|
|
331
|
+
state.agent_response = AgentResponse(
|
|
332
|
+
description=_END_DESCRIPTION,
|
|
333
|
+
log_id=log_id,
|
|
334
|
+
)
|
|
335
|
+
return state
|
|
336
|
+
|
|
337
|
+
def method_response(error_message: str | None = None) -> AgentResponse:
|
|
338
|
+
buttons = [
|
|
339
|
+
{
|
|
340
|
+
"id": method,
|
|
341
|
+
"title": {
|
|
342
|
+
"brazilian_tax_id": "Brazilian tax ID",
|
|
343
|
+
"govbr": "Gov.br",
|
|
344
|
+
"anonymous": "Continue anonymously",
|
|
345
|
+
}[method],
|
|
346
|
+
}
|
|
347
|
+
for method in eligible_methods
|
|
348
|
+
]
|
|
349
|
+
return AgentResponse(
|
|
350
|
+
description="How would you like to identify yourself?",
|
|
351
|
+
error_message=error_message,
|
|
352
|
+
interactive={
|
|
353
|
+
"body": "How would you like to identify yourself?",
|
|
354
|
+
"field": "identification_method",
|
|
355
|
+
"buttons": buttons,
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def recover_from_govbr_validation_failure(state: ServiceState) -> ServiceState:
|
|
360
|
+
state.data.pop("govbr_auth_sent", None)
|
|
361
|
+
if "brazilian_tax_id" in eligible_method_set:
|
|
362
|
+
set_identification_method(state, "brazilian_tax_id")
|
|
363
|
+
state.agent_response = _govbr_validation_failure_response(ctx, state)
|
|
364
|
+
return state
|
|
365
|
+
|
|
366
|
+
validation_failure_response = _govbr_validation_failure_response(
|
|
367
|
+
ctx,
|
|
368
|
+
state,
|
|
369
|
+
tax_id_fallback=False,
|
|
370
|
+
)
|
|
371
|
+
if inc_attempts(state, "method") >= max_attempts:
|
|
372
|
+
return exhaust(
|
|
373
|
+
state,
|
|
374
|
+
stage="method",
|
|
375
|
+
reask_response=validation_failure_response,
|
|
376
|
+
)
|
|
377
|
+
state.agent_response = validation_failure_response
|
|
378
|
+
return state
|
|
379
|
+
|
|
380
|
+
def reject_disabled_method(
|
|
381
|
+
state: ServiceState,
|
|
382
|
+
requested_method: _IdentificationMethod,
|
|
383
|
+
) -> ServiceState:
|
|
384
|
+
state.data.pop("govbr_auth_sent", None)
|
|
385
|
+
unavailable_method_response = method_response(
|
|
386
|
+
f"the method {requested_method!r} is unavailable for this service session"
|
|
387
|
+
)
|
|
388
|
+
if inc_attempts(state, "method") >= max_attempts:
|
|
389
|
+
return exhaust(
|
|
390
|
+
state,
|
|
391
|
+
stage="method",
|
|
392
|
+
reask_response=unavailable_method_response,
|
|
393
|
+
)
|
|
394
|
+
state.agent_response = unavailable_method_response
|
|
395
|
+
return state
|
|
396
|
+
|
|
397
|
+
def reject_unavailable_anonymous_brazilian_tax_id_skip(state: ServiceState) -> ServiceState:
|
|
398
|
+
unavailable_skip_response = AgentResponse(
|
|
399
|
+
description="This service session requires an available identification method.",
|
|
400
|
+
payload_schema=tax_id_model.model_json_schema(),
|
|
401
|
+
error_message="anonymous continuation is not configured",
|
|
402
|
+
)
|
|
403
|
+
if inc_attempts(state, "brazilian_tax_id") >= max_attempts:
|
|
404
|
+
return exhaust(
|
|
405
|
+
state,
|
|
406
|
+
stage="brazilian_tax_id",
|
|
407
|
+
reask_response=unavailable_skip_response,
|
|
408
|
+
)
|
|
409
|
+
state.agent_response = unavailable_skip_response
|
|
410
|
+
return state
|
|
411
|
+
|
|
412
|
+
# ── select method ────────────────────────────────────────────────
|
|
413
|
+
async def select(state: ServiceState) -> ServiceState:
|
|
414
|
+
if (
|
|
415
|
+
state.data.get("identification_method")
|
|
416
|
+
or state.data.get("brazilian_tax_id")
|
|
417
|
+
or state.data.get("govbr_authenticated")
|
|
418
|
+
):
|
|
419
|
+
state.agent_response = None
|
|
420
|
+
return state
|
|
421
|
+
pay = state.payload or {}
|
|
422
|
+
raw = (
|
|
423
|
+
str(pay.get("identification_method", "")) if "identification_method" in pay else ""
|
|
424
|
+
)
|
|
425
|
+
if not required and not pay and "anonymous" in eligible_method_set:
|
|
426
|
+
set_identification_method(state, "anonymous")
|
|
427
|
+
state.agent_response = None
|
|
428
|
+
return state
|
|
429
|
+
if "identification_method" in pay:
|
|
430
|
+
method = _normalize_method(raw)
|
|
431
|
+
if method in eligible_method_set:
|
|
432
|
+
set_identification_method(state, method)
|
|
433
|
+
reset_attempts(state, "method")
|
|
434
|
+
state.agent_response = None
|
|
435
|
+
return state
|
|
436
|
+
if inc_attempts(state, "method") >= max_attempts:
|
|
437
|
+
return exhaust(
|
|
438
|
+
state,
|
|
439
|
+
stage="method",
|
|
440
|
+
reask_response=method_response(_EXHAUSTED_ERROR),
|
|
441
|
+
)
|
|
442
|
+
state.agent_response = method_response()
|
|
443
|
+
return state
|
|
444
|
+
|
|
445
|
+
def select_router(state: ServiceState) -> str:
|
|
446
|
+
if state.agent_response is not None:
|
|
447
|
+
return END
|
|
448
|
+
method = state.data.get("identification_method")
|
|
449
|
+
if method == "govbr":
|
|
450
|
+
return "authenticate_govbr"
|
|
451
|
+
if method == "brazilian_tax_id":
|
|
452
|
+
return "collect_tax_id"
|
|
453
|
+
return "identification_done" # anonymous / pulada
|
|
454
|
+
|
|
455
|
+
# ── gov.br (await_external) ───────────────────────────────────────
|
|
456
|
+
async def authenticate_legacy(state: ServiceState) -> ServiceState:
|
|
457
|
+
if state.data.get("identification_method") != "govbr" or state.data.get(
|
|
458
|
+
"govbr_authenticated"
|
|
459
|
+
):
|
|
460
|
+
state.agent_response = None
|
|
461
|
+
return state
|
|
462
|
+
pay = state.payload or {}
|
|
463
|
+
token = pay.get("govbr_token")
|
|
464
|
+
if "govbr_token" in pay:
|
|
465
|
+
try:
|
|
466
|
+
if not isinstance(token, Mapping):
|
|
467
|
+
raise TypeError("gov.br token must be an object")
|
|
468
|
+
if "brazilian_tax_id" not in token:
|
|
469
|
+
raise ValueError("gov.br token did not contain Brazilian tax ID")
|
|
470
|
+
validated_tax_id = cast(
|
|
471
|
+
str,
|
|
472
|
+
tax_id_model.model_validate(
|
|
473
|
+
{"brazilian_tax_id": token["brazilian_tax_id"]}
|
|
474
|
+
).model_dump()["brazilian_tax_id"],
|
|
475
|
+
)
|
|
476
|
+
identity_writes: dict[str, Any] = {"brazilian_tax_id": validated_tax_id}
|
|
477
|
+
if "name" in token:
|
|
478
|
+
identity_writes["name"] = cast(
|
|
479
|
+
str,
|
|
480
|
+
name_model.model_validate({"name": token["name"]}).model_dump()["name"],
|
|
481
|
+
)
|
|
482
|
+
if "email" in token:
|
|
483
|
+
identity_writes["email"] = cast(
|
|
484
|
+
str,
|
|
485
|
+
email_model.model_validate({"email": token["email"]}).model_dump()[
|
|
486
|
+
"email"
|
|
487
|
+
],
|
|
488
|
+
)
|
|
489
|
+
except Exception: # noqa: BLE001
|
|
490
|
+
return recover_from_govbr_validation_failure(state)
|
|
491
|
+
|
|
492
|
+
enrich = await _call_optional_identification_tool(
|
|
493
|
+
ctx,
|
|
494
|
+
"get_user_info",
|
|
495
|
+
brazilian_tax_id=validated_tax_id,
|
|
496
|
+
)
|
|
497
|
+
if enrich and enrich.get("phones"):
|
|
498
|
+
identity_writes["phone"] = str(enrich["phones"][0])
|
|
499
|
+
if "name" in identity_writes:
|
|
500
|
+
identity_writes["name_processed"] = True
|
|
501
|
+
if "email" in identity_writes:
|
|
502
|
+
identity_writes["email_processed"] = True
|
|
503
|
+
identity_writes["govbr_authenticated"] = True
|
|
504
|
+
identity_writes["identity_verified"] = True
|
|
505
|
+
state.data.update(identity_writes)
|
|
506
|
+
if "name" in identity_writes:
|
|
507
|
+
_clear_brazilian_tax_id_lookup_provenance(state, "name")
|
|
508
|
+
if "email" in identity_writes:
|
|
509
|
+
_clear_brazilian_tax_id_lookup_provenance(state, "email")
|
|
510
|
+
state.data.pop("govbr_auth_sent", None)
|
|
511
|
+
state.agent_response = None
|
|
512
|
+
return state
|
|
513
|
+
if state.data.get("govbr_auth_sent"):
|
|
514
|
+
text = normalize_text(
|
|
515
|
+
f"{pay.get('message', '')} {pay.get('identification_method', '')}"
|
|
516
|
+
)
|
|
517
|
+
if not required and _is_refusal(text):
|
|
518
|
+
if "anonymous" not in eligible_method_set:
|
|
519
|
+
return reject_disabled_method(state, "anonymous")
|
|
520
|
+
set_identification_method(state, "anonymous")
|
|
521
|
+
state.data.pop("govbr_auth_sent", None)
|
|
522
|
+
state.agent_response = None
|
|
523
|
+
return state
|
|
524
|
+
if "brazilian_tax_id" in text:
|
|
525
|
+
if "brazilian_tax_id" not in eligible_method_set:
|
|
526
|
+
return reject_disabled_method(state, "brazilian_tax_id")
|
|
527
|
+
set_identification_method(state, "brazilian_tax_id")
|
|
528
|
+
state.data.pop("govbr_auth_sent", None)
|
|
529
|
+
state.agent_response = None
|
|
530
|
+
return state
|
|
531
|
+
state.agent_response = AgentResponse(
|
|
532
|
+
description="I am waiting for you to complete the gov.br login. Let me know when you finish. 🙂"
|
|
533
|
+
)
|
|
534
|
+
return state
|
|
535
|
+
state.data["govbr_auth_sent"] = True
|
|
536
|
+
state.agent_response = AgentResponse(
|
|
537
|
+
description="To identify through gov.br, use the login button I sent. 🔐",
|
|
538
|
+
interactive={"out_of_band_sent": True, "next_step": "await_govbr_auth"},
|
|
539
|
+
)
|
|
540
|
+
return state
|
|
541
|
+
|
|
542
|
+
def authenticate_router(state: ServiceState) -> str:
|
|
543
|
+
if state.data.get("govbr_authenticated"):
|
|
544
|
+
if not state.data.get("email") and not state.data.get("email_processed"):
|
|
545
|
+
return "collect_email"
|
|
546
|
+
if not state.data.get("name") and not state.data.get("name_processed"):
|
|
547
|
+
return "collect_name"
|
|
548
|
+
return "identification_done"
|
|
549
|
+
if state.agent_response is not None:
|
|
550
|
+
return END
|
|
551
|
+
method = state.data.get("identification_method")
|
|
552
|
+
if method == "brazilian_tax_id":
|
|
553
|
+
return "collect_tax_id"
|
|
554
|
+
if method == "anonymous" or state.data.get("identification_skipped"):
|
|
555
|
+
return "identification_done"
|
|
556
|
+
return "identification_done"
|
|
557
|
+
|
|
558
|
+
# ── collect Brazilian tax ID ──────────────────────────────────────────────────
|
|
559
|
+
async def collect_tax_id(state: ServiceState) -> ServiceState:
|
|
560
|
+
if state.data.get("govbr_authenticated"):
|
|
561
|
+
state.agent_response = None
|
|
562
|
+
return state
|
|
563
|
+
if state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == "brazilian_tax_id":
|
|
564
|
+
_clear_brazilian_tax_id_lookup_derived_contacts(state, ctx)
|
|
565
|
+
clear_cascade(state, "brazilian_tax_id", ctx)
|
|
566
|
+
state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
|
|
567
|
+
if "brazilian_tax_id" not in eligible_method_set:
|
|
568
|
+
return reject_disabled_method(state, "brazilian_tax_id")
|
|
569
|
+
# Persist the method so the NEXT turn's select node re-enters the
|
|
570
|
+
# subflow instead of short-circuiting to done (the citizen may have
|
|
571
|
+
# been "anonymous" before correcting). correction_requested is consumed
|
|
572
|
+
# this turn, so the flip must outlive it.
|
|
573
|
+
set_identification_method(state, "brazilian_tax_id")
|
|
574
|
+
if state.data.get("brazilian_tax_id") or state.data.get("identification_skipped"):
|
|
575
|
+
state.agent_response = None
|
|
576
|
+
return state
|
|
577
|
+
pay = state.payload or {}
|
|
578
|
+
if "brazilian_tax_id" in pay:
|
|
579
|
+
raw = pay["brazilian_tax_id"]
|
|
580
|
+
if (raw is None or str(raw).strip() == "" or _is_skip(str(raw))) and not required:
|
|
581
|
+
if "anonymous" not in eligible_method_set:
|
|
582
|
+
return reject_unavailable_anonymous_brazilian_tax_id_skip(state)
|
|
583
|
+
return complete_without_value(
|
|
584
|
+
state,
|
|
585
|
+
"brazilian_tax_id",
|
|
586
|
+
reask_response=AgentResponse(
|
|
587
|
+
description="What is your Brazilian tax ID?",
|
|
588
|
+
payload_schema=tax_id_model.model_json_schema(),
|
|
589
|
+
),
|
|
590
|
+
)
|
|
591
|
+
try:
|
|
592
|
+
validated = tax_id_model.model_validate({"brazilian_tax_id": raw})
|
|
593
|
+
validated_tax_id = cast(str, validated.model_dump()["brazilian_tax_id"])
|
|
594
|
+
except Exception as exc:
|
|
595
|
+
if inc_attempts(state, "brazilian_tax_id") >= max_attempts:
|
|
596
|
+
return exhaust(
|
|
597
|
+
state,
|
|
598
|
+
stage="brazilian_tax_id",
|
|
599
|
+
reask_response=AgentResponse(
|
|
600
|
+
description="I could not validate your Brazilian tax ID. Try again.",
|
|
601
|
+
payload_schema=tax_id_model.model_json_schema(),
|
|
602
|
+
error_message=_EXHAUSTED_ERROR,
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
state.agent_response = AgentResponse(
|
|
606
|
+
description="Invalid Brazilian tax ID. Check and resubmit all 11 digits.",
|
|
607
|
+
payload_schema=tax_id_model.model_json_schema(),
|
|
608
|
+
error_message=str(exc),
|
|
609
|
+
)
|
|
610
|
+
return state
|
|
611
|
+
|
|
612
|
+
state.data["brazilian_tax_id"] = validated_tax_id
|
|
613
|
+
reset_attempts(state, "brazilian_tax_id")
|
|
614
|
+
info = await _call_optional_identification_tool(
|
|
615
|
+
ctx, "brazilian_tax_id_lookup", brazilian_tax_id=validated_tax_id
|
|
616
|
+
)
|
|
617
|
+
if info and info.get("status") == "ok":
|
|
618
|
+
if info.get("email") and not state.data.get("email_processed"):
|
|
619
|
+
validated_email = validate_optional_lookup_contact(
|
|
620
|
+
state,
|
|
621
|
+
"email",
|
|
622
|
+
info["email"],
|
|
623
|
+
)
|
|
624
|
+
if validated_email is not None:
|
|
625
|
+
state.data["email"] = validated_email
|
|
626
|
+
state.data["email_processed"] = True
|
|
627
|
+
_mark_brazilian_tax_id_lookup_derived_contact(state, "email")
|
|
628
|
+
if info.get("name") and not state.data.get("name_processed"):
|
|
629
|
+
validated_name = validate_optional_lookup_contact(
|
|
630
|
+
state,
|
|
631
|
+
"name",
|
|
632
|
+
info["name"],
|
|
633
|
+
)
|
|
634
|
+
if validated_name is not None:
|
|
635
|
+
state.data["name"] = validated_name
|
|
636
|
+
state.data["name_processed"] = True
|
|
637
|
+
_mark_brazilian_tax_id_lookup_derived_contact(state, "name")
|
|
638
|
+
state.data["identity_verified"] = True
|
|
639
|
+
state.agent_response = None
|
|
640
|
+
return state
|
|
641
|
+
if not required and _is_refusal(str(pay.get("message", ""))):
|
|
642
|
+
if "anonymous" not in eligible_method_set:
|
|
643
|
+
return reject_unavailable_anonymous_brazilian_tax_id_skip(state)
|
|
644
|
+
return complete_without_value(
|
|
645
|
+
state,
|
|
646
|
+
"brazilian_tax_id",
|
|
647
|
+
reask_response=AgentResponse(
|
|
648
|
+
description="What is your Brazilian tax ID?",
|
|
649
|
+
payload_schema=tax_id_model.model_json_schema(),
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
state.agent_response = AgentResponse(
|
|
653
|
+
description=(
|
|
654
|
+
"What is your Brazilian tax ID?"
|
|
655
|
+
if required
|
|
656
|
+
else "What is your Brazilian tax ID? (or say 'skip' to continue anonymously)"
|
|
657
|
+
),
|
|
658
|
+
payload_schema=tax_id_model.model_json_schema(),
|
|
659
|
+
)
|
|
660
|
+
return state
|
|
661
|
+
|
|
662
|
+
def tax_id_router(state: ServiceState) -> str:
|
|
663
|
+
if state.agent_response is not None:
|
|
664
|
+
return END
|
|
665
|
+
if state.data.get("identification_method") == "govbr":
|
|
666
|
+
return "authenticate_govbr"
|
|
667
|
+
if state.data.get("identification_skipped"):
|
|
668
|
+
return "identification_done"
|
|
669
|
+
if not state.data.get("email") and not state.data.get("email_processed"):
|
|
670
|
+
return "collect_email"
|
|
671
|
+
if not state.data.get("name") and not state.data.get("name_processed"):
|
|
672
|
+
return "collect_name"
|
|
673
|
+
return "identification_done"
|
|
674
|
+
|
|
675
|
+
# ── collect email (optional) ─────────────────────────────────────
|
|
676
|
+
async def collect_email(state: ServiceState) -> ServiceState:
|
|
677
|
+
if state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == "email":
|
|
678
|
+
_clear_brazilian_tax_id_lookup_provenance(state, "email")
|
|
679
|
+
clear_cascade(state, "email", ctx)
|
|
680
|
+
state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
|
|
681
|
+
if state.data.get("email_processed"):
|
|
682
|
+
state.agent_response = None
|
|
683
|
+
return state
|
|
684
|
+
pay = state.payload or {}
|
|
685
|
+
if "email" in pay:
|
|
686
|
+
raw = pay["email"]
|
|
687
|
+
if raw is None or str(raw).strip() == "" or _is_skip(str(raw)):
|
|
688
|
+
return complete_without_value(
|
|
689
|
+
state,
|
|
690
|
+
"email",
|
|
691
|
+
reask_response=AgentResponse(
|
|
692
|
+
description="What is your email address? (or say 'skip')",
|
|
693
|
+
payload_schema=email_model.model_json_schema(),
|
|
694
|
+
),
|
|
695
|
+
)
|
|
696
|
+
try:
|
|
697
|
+
validated = email_model.model_validate({"email": raw})
|
|
698
|
+
state.data["email"] = cast(str, validated.model_dump()["email"])
|
|
699
|
+
state.data["email_processed"] = True
|
|
700
|
+
_clear_brazilian_tax_id_lookup_provenance(state, "email")
|
|
701
|
+
reset_attempts(state, "email")
|
|
702
|
+
state.agent_response = None
|
|
703
|
+
return state
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
if inc_attempts(state, "email") >= max_attempts:
|
|
706
|
+
return exhaust(
|
|
707
|
+
state,
|
|
708
|
+
stage="email",
|
|
709
|
+
reask_response=AgentResponse(
|
|
710
|
+
description="Invalid email address. Resubmit it or say 'skip'.",
|
|
711
|
+
payload_schema=email_model.model_json_schema(),
|
|
712
|
+
error_message=_EXHAUSTED_ERROR,
|
|
713
|
+
),
|
|
714
|
+
)
|
|
715
|
+
state.agent_response = AgentResponse(
|
|
716
|
+
description="Invalid email address. Resubmit it or say 'skip'.",
|
|
717
|
+
payload_schema=email_model.model_json_schema(),
|
|
718
|
+
error_message=str(exc),
|
|
719
|
+
)
|
|
720
|
+
return state
|
|
721
|
+
state.agent_response = AgentResponse(
|
|
722
|
+
description="What is your email address? (or say 'skip')",
|
|
723
|
+
payload_schema=email_model.model_json_schema(),
|
|
724
|
+
)
|
|
725
|
+
return state
|
|
726
|
+
|
|
727
|
+
def email_router(state: ServiceState) -> str:
|
|
728
|
+
if state.agent_response is not None:
|
|
729
|
+
return END
|
|
730
|
+
if not state.data.get("name") and not state.data.get("name_processed"):
|
|
731
|
+
return "collect_name"
|
|
732
|
+
return "identification_done"
|
|
733
|
+
|
|
734
|
+
# ── collect name (optional) ──────────────────────────────────────
|
|
735
|
+
async def collect_name(state: ServiceState) -> ServiceState:
|
|
736
|
+
if state.internal.get(CORRECTION_REQUESTED_INTERNAL_KEY) == "name":
|
|
737
|
+
_clear_brazilian_tax_id_lookup_provenance(state, "name")
|
|
738
|
+
clear_cascade(state, "name", ctx)
|
|
739
|
+
state.internal.pop(CORRECTION_REQUESTED_INTERNAL_KEY, None)
|
|
740
|
+
if state.data.get("name_processed"):
|
|
741
|
+
state.agent_response = None
|
|
742
|
+
return state
|
|
743
|
+
pay = state.payload or {}
|
|
744
|
+
if "name" in pay:
|
|
745
|
+
raw = pay["name"]
|
|
746
|
+
if raw is None or str(raw).strip() == "" or _is_skip(str(raw)):
|
|
747
|
+
return complete_without_value(
|
|
748
|
+
state,
|
|
749
|
+
"name",
|
|
750
|
+
reask_response=AgentResponse(
|
|
751
|
+
description="What is your full name? (or say 'skip')",
|
|
752
|
+
payload_schema=name_model.model_json_schema(),
|
|
753
|
+
),
|
|
754
|
+
)
|
|
755
|
+
try:
|
|
756
|
+
validated = name_model.model_validate({"name": raw})
|
|
757
|
+
state.data["name"] = cast(str, validated.model_dump()["name"])
|
|
758
|
+
state.data["name_processed"] = True
|
|
759
|
+
_clear_brazilian_tax_id_lookup_provenance(state, "name")
|
|
760
|
+
reset_attempts(state, "name")
|
|
761
|
+
state.agent_response = None
|
|
762
|
+
return state
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
if inc_attempts(state, "name") >= max_attempts:
|
|
765
|
+
return exhaust(
|
|
766
|
+
state,
|
|
767
|
+
stage="name",
|
|
768
|
+
reask_response=AgentResponse(
|
|
769
|
+
description="Invalid name. Resubmit it or say 'skip'.",
|
|
770
|
+
payload_schema=name_model.model_json_schema(),
|
|
771
|
+
error_message=_EXHAUSTED_ERROR,
|
|
772
|
+
),
|
|
773
|
+
)
|
|
774
|
+
state.agent_response = AgentResponse(
|
|
775
|
+
description="Invalid name. Resubmit it or say 'skip'.",
|
|
776
|
+
payload_schema=name_model.model_json_schema(),
|
|
777
|
+
error_message=str(exc),
|
|
778
|
+
)
|
|
779
|
+
return state
|
|
780
|
+
state.agent_response = AgentResponse(
|
|
781
|
+
description="What is your full name? (or say 'skip')",
|
|
782
|
+
payload_schema=name_model.model_json_schema(),
|
|
783
|
+
)
|
|
784
|
+
return state
|
|
785
|
+
|
|
786
|
+
def name_router(state: ServiceState) -> str:
|
|
787
|
+
return END if state.agent_response is not None else "identification_done"
|
|
788
|
+
|
|
789
|
+
async def done(state: ServiceState) -> ServiceState:
|
|
790
|
+
state.agent_response = None
|
|
791
|
+
return state
|
|
792
|
+
|
|
793
|
+
authenticate_descriptor = NodeDesc(
|
|
794
|
+
"authenticate_govbr",
|
|
795
|
+
authenticate_legacy,
|
|
796
|
+
authenticate_router,
|
|
797
|
+
targets=[
|
|
798
|
+
"collect_tax_id",
|
|
799
|
+
"collect_email",
|
|
800
|
+
"collect_name",
|
|
801
|
+
"identification_done",
|
|
802
|
+
],
|
|
803
|
+
)
|
|
804
|
+
await_external = ctx.await_external
|
|
805
|
+
if await_external and await_external.get("step") == "authenticate_govbr":
|
|
806
|
+
if "govbr" not in eligible_method_set:
|
|
807
|
+
raise ValueError("identification@2 await_external targets disabled method 'govbr'")
|
|
808
|
+
configured_capability = copy.deepcopy(await_external)
|
|
809
|
+
configured_transitions = {
|
|
810
|
+
"timeout": configured_capability.get("timeout"),
|
|
811
|
+
**(configured_capability.get("recovery") or {}),
|
|
812
|
+
}
|
|
813
|
+
transition_methods: dict[str, _IdentificationMethod | None] = {}
|
|
814
|
+
method_for_target: dict[str, _IdentificationMethod] = {
|
|
815
|
+
"collect_tax_id": "brazilian_tax_id",
|
|
816
|
+
"authenticate_govbr": "govbr",
|
|
817
|
+
"identification_done": "anonymous",
|
|
818
|
+
}
|
|
819
|
+
for transition_name, transition in configured_transitions.items():
|
|
820
|
+
if transition is None:
|
|
821
|
+
transition_methods[transition_name] = None
|
|
822
|
+
continue
|
|
823
|
+
transition_target = transition["goto"]
|
|
824
|
+
target_method = method_for_target.get(transition_target)
|
|
825
|
+
transition_set = transition.get("set") or {}
|
|
826
|
+
configured_method = transition_set.get("identification_method")
|
|
827
|
+
if configured_method is not None and configured_method not in {
|
|
828
|
+
"brazilian_tax_id",
|
|
829
|
+
"govbr",
|
|
830
|
+
"anonymous",
|
|
831
|
+
}:
|
|
832
|
+
raise ValueError(
|
|
833
|
+
"identification@2 await_external transition "
|
|
834
|
+
f"{transition_name!r} sets invalid identification method "
|
|
835
|
+
f"{configured_method!r}"
|
|
836
|
+
)
|
|
837
|
+
if (
|
|
838
|
+
target_method is not None
|
|
839
|
+
and configured_method is not None
|
|
840
|
+
and target_method != configured_method
|
|
841
|
+
):
|
|
842
|
+
raise ValueError(
|
|
843
|
+
"identification@2 await_external transition "
|
|
844
|
+
f"{transition_name!r} has conflicting method and target"
|
|
845
|
+
)
|
|
846
|
+
implied_method = cast(
|
|
847
|
+
_IdentificationMethod | None,
|
|
848
|
+
configured_method or target_method,
|
|
849
|
+
)
|
|
850
|
+
if implied_method is not None and implied_method not in eligible_method_set:
|
|
851
|
+
raise ValueError(
|
|
852
|
+
"identification@2 await_external transition "
|
|
853
|
+
f"{transition_name!r} routes to disabled method {implied_method!r}"
|
|
854
|
+
)
|
|
855
|
+
transition_methods[transition_name] = implied_method
|
|
856
|
+
configured_on_resume = configured_capability.setdefault("on_resume", {})
|
|
857
|
+
if isinstance(configured_on_resume.get("enrich"), str):
|
|
858
|
+
configured_on_resume["enrich"] = {
|
|
859
|
+
"tool": configured_on_resume["enrich"],
|
|
860
|
+
"optional": True,
|
|
861
|
+
"input": {"brazilian_tax_id": "$token.brazilian_tax_id"},
|
|
862
|
+
"set": {"phone": "$result.phones.0"},
|
|
863
|
+
}
|
|
864
|
+
configured_step: dict[str, Any] = {"step": "authenticate_govbr"}
|
|
865
|
+
if not configured_capability.get("interactive"):
|
|
866
|
+
configured_step["interactive"] = {
|
|
867
|
+
"kind": "cta_url",
|
|
868
|
+
"field": configured_capability["resume_on"],
|
|
869
|
+
"out_of_band": True,
|
|
870
|
+
"next_step": "await_govbr_auth",
|
|
871
|
+
}
|
|
872
|
+
generic_descriptor = make_await_external_node(
|
|
873
|
+
ctx,
|
|
874
|
+
configured_capability,
|
|
875
|
+
configured_step,
|
|
876
|
+
default_router=authenticate_router,
|
|
877
|
+
additional_targets=[
|
|
878
|
+
"collect_tax_id",
|
|
879
|
+
"collect_email",
|
|
880
|
+
"collect_name",
|
|
881
|
+
"identification_done",
|
|
882
|
+
],
|
|
883
|
+
legacy_sent_data_key="govbr_auth_sent",
|
|
884
|
+
waiting_description=(
|
|
885
|
+
"I am waiting for you to complete the gov.br login. "
|
|
886
|
+
"Let me know when you finish. 🙂"
|
|
887
|
+
),
|
|
888
|
+
)
|
|
889
|
+
await_sent_internal_key = f"_await_external_sent:{generic_descriptor.id}"
|
|
890
|
+
await_completed_internal_key = f"_await_external_completed:{generic_descriptor.id}"
|
|
891
|
+
|
|
892
|
+
def clear_configured_await_markers(state: ServiceState) -> None:
|
|
893
|
+
state.data.pop("govbr_auth_sent", None)
|
|
894
|
+
state.internal.pop(await_sent_internal_key, None)
|
|
895
|
+
state.internal.pop(await_completed_internal_key, None)
|
|
896
|
+
|
|
897
|
+
async def authenticate_configured(state: ServiceState) -> ServiceState:
|
|
898
|
+
if state.data.get("identification_method") != "govbr" or state.data.get(
|
|
899
|
+
"govbr_authenticated"
|
|
900
|
+
):
|
|
901
|
+
state.agent_response = None
|
|
902
|
+
return state
|
|
903
|
+
|
|
904
|
+
payload = state.payload or {}
|
|
905
|
+
resume_on = configured_capability["resume_on"]
|
|
906
|
+
recovery = configured_capability.get("recovery") or {}
|
|
907
|
+
if (
|
|
908
|
+
state.data.get("govbr_auth_sent")
|
|
909
|
+
and resume_on not in payload
|
|
910
|
+
and "_external_event" not in payload
|
|
911
|
+
):
|
|
912
|
+
recovery_text = normalize_text(
|
|
913
|
+
f"{payload.get('message', '')} {payload.get('identification_method', '')}"
|
|
914
|
+
)
|
|
915
|
+
if not required and _is_refusal(recovery_text):
|
|
916
|
+
if "abort" in recovery:
|
|
917
|
+
payload["_external_event"] = "abort"
|
|
918
|
+
elif "anonymous" not in eligible_method_set:
|
|
919
|
+
clear_configured_await_markers(state)
|
|
920
|
+
return reject_disabled_method(state, "anonymous")
|
|
921
|
+
else:
|
|
922
|
+
set_identification_method(state, "anonymous")
|
|
923
|
+
clear_configured_await_markers(state)
|
|
924
|
+
state.status = "progress"
|
|
925
|
+
state.agent_response = None
|
|
926
|
+
return state
|
|
927
|
+
elif "brazilian_tax_id" in recovery_text:
|
|
928
|
+
if "brazilian_tax_id" not in eligible_method_set:
|
|
929
|
+
clear_configured_await_markers(state)
|
|
930
|
+
return reject_disabled_method(state, "brazilian_tax_id")
|
|
931
|
+
if "switch" in recovery:
|
|
932
|
+
payload["_external_event"] = "switch"
|
|
933
|
+
else:
|
|
934
|
+
set_identification_method(state, "brazilian_tax_id")
|
|
935
|
+
clear_configured_await_markers(state)
|
|
936
|
+
state.status = "progress"
|
|
937
|
+
state.agent_response = None
|
|
938
|
+
return state
|
|
939
|
+
|
|
940
|
+
resume_token = payload.get(resume_on)
|
|
941
|
+
if resume_on in payload and (
|
|
942
|
+
not isinstance(resume_token, Mapping)
|
|
943
|
+
or not resume_token.get("brazilian_tax_id")
|
|
944
|
+
):
|
|
945
|
+
if "switch" in recovery:
|
|
946
|
+
payload.pop(resume_on, None)
|
|
947
|
+
payload["_external_event"] = "switch"
|
|
948
|
+
else:
|
|
949
|
+
clear_configured_await_markers(state)
|
|
950
|
+
state.status = "progress"
|
|
951
|
+
return recover_from_govbr_validation_failure(state)
|
|
952
|
+
|
|
953
|
+
external_event = payload.get("_external_event")
|
|
954
|
+
has_resume_token = resume_on in payload
|
|
955
|
+
updated_state = await generic_descriptor.fn(state)
|
|
956
|
+
if updated_state.agent_response is not None:
|
|
957
|
+
return updated_state
|
|
958
|
+
|
|
959
|
+
transition = configured_transitions.get(cast(str, external_event))
|
|
960
|
+
if external_event is not None and transition is not None:
|
|
961
|
+
if transition_method := transition_methods.get(cast(str, external_event)):
|
|
962
|
+
set_identification_method(updated_state, transition_method)
|
|
963
|
+
if transition_method == "govbr":
|
|
964
|
+
updated_state.internal.pop(await_completed_internal_key, None)
|
|
965
|
+
if transition["goto"] == "select_identification_method":
|
|
966
|
+
if transition_method is None:
|
|
967
|
+
updated_state.data.pop("identification_method", None)
|
|
968
|
+
updated_state.data.pop("identification_skipped", None)
|
|
969
|
+
updated_state.internal.pop(await_completed_internal_key, None)
|
|
970
|
+
|
|
971
|
+
if not has_resume_token:
|
|
972
|
+
return updated_state
|
|
973
|
+
if not updated_state.data.get("brazilian_tax_id"):
|
|
974
|
+
updated_state.data.pop("govbr_authenticated", None)
|
|
975
|
+
updated_state.data.pop("identity_verified", None)
|
|
976
|
+
clear_configured_await_markers(updated_state)
|
|
977
|
+
return recover_from_govbr_validation_failure(updated_state)
|
|
978
|
+
if updated_state.data.get("name"):
|
|
979
|
+
updated_state.data["name_processed"] = True
|
|
980
|
+
_clear_brazilian_tax_id_lookup_provenance(updated_state, "name")
|
|
981
|
+
if updated_state.data.get("email"):
|
|
982
|
+
updated_state.data["email_processed"] = True
|
|
983
|
+
_clear_brazilian_tax_id_lookup_provenance(updated_state, "email")
|
|
984
|
+
updated_state.data["govbr_authenticated"] = True
|
|
985
|
+
updated_state.data["identity_verified"] = True
|
|
986
|
+
return updated_state
|
|
987
|
+
|
|
988
|
+
authenticate_descriptor = NodeDesc(
|
|
989
|
+
generic_descriptor.id,
|
|
990
|
+
authenticate_configured,
|
|
991
|
+
generic_descriptor.router,
|
|
992
|
+
generic_descriptor.targets,
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
descriptors = [
|
|
996
|
+
NodeDesc(
|
|
997
|
+
"select_identification_method",
|
|
998
|
+
select,
|
|
999
|
+
select_router,
|
|
1000
|
+
targets=["authenticate_govbr", "collect_tax_id", "identification_done"],
|
|
1001
|
+
),
|
|
1002
|
+
authenticate_descriptor,
|
|
1003
|
+
NodeDesc(
|
|
1004
|
+
"collect_tax_id",
|
|
1005
|
+
collect_tax_id,
|
|
1006
|
+
tax_id_router,
|
|
1007
|
+
targets=[
|
|
1008
|
+
"authenticate_govbr",
|
|
1009
|
+
"collect_email",
|
|
1010
|
+
"collect_name",
|
|
1011
|
+
"identification_done",
|
|
1012
|
+
],
|
|
1013
|
+
),
|
|
1014
|
+
NodeDesc(
|
|
1015
|
+
"collect_email",
|
|
1016
|
+
collect_email,
|
|
1017
|
+
email_router,
|
|
1018
|
+
targets=["collect_name", "identification_done"],
|
|
1019
|
+
),
|
|
1020
|
+
NodeDesc("collect_name", collect_name, name_router, targets=["identification_done"]),
|
|
1021
|
+
NodeDesc("identification_done", done, lambda s: NEXT),
|
|
1022
|
+
]
|
|
1023
|
+
return SubflowBuild(
|
|
1024
|
+
descriptors=descriptors,
|
|
1025
|
+
entry_id="select_identification_method",
|
|
1026
|
+
node_for_slot={
|
|
1027
|
+
"brazilian_tax_id": "collect_tax_id",
|
|
1028
|
+
"email": "collect_email",
|
|
1029
|
+
"name": "collect_name",
|
|
1030
|
+
},
|
|
1031
|
+
)
|