Jev dans Temporal : une activité, une retry policy et un workflow déterministe
Ce guide montre comment router des tickets avec Jev, le modèle de décision de TypeSafe AI, dans un workflow Temporal écrit en Python. L’appel à Jev vit dans une activité, avec une retry policy adaptée à ses erreurs. Le workflow branche sur la confiance et replie vers votre LLM actuel.
Ce que vous allez construire
Un workflow qui route un ticket de support vers la facturation, la technique ou le commercial. C’est le même scénario que notre guide n8n, avec les garanties de Temporal en plus.
- Une activité
ask_jevqui pose deux questions à Jev : quel service, et est-ce urgent. - Une retry policy qui retente les codes 429 et 529, mais jamais les codes 401 et 422.
- Un workflow
RouteTicketqui compare la confiance au seuil reçu en entrée. - Une activité de repli,
ask_current_llm, qui reprend votre routeur LLM actuel. - Un historique Temporal qui garde la version du modèle, les probabilités, la confiance et le chemin suivi.
Pourquoi l’appel à Jev doit vivre dans une activité
Un workflow Temporal doit être déterministe. Temporal enregistre chaque événement dans un historique. Après un redémarrage du worker, il rejoue le code du workflow depuis le début et vérifie qu’il prend les mêmes décisions.
Un appel réseau casse cette règle. Rejoué, il pourrait renvoyer une autre réponse, échouer ou prendre dix secondes. Le workflow suivrait alors un autre chemin, et Temporal lèverait une erreur de non-déterminisme.
Une activité règle le problème. Elle s’exécute une fois par tentative, et son résultat est écrit dans l’historique. Au rejeu, Temporal lit la réponse de Jev dans l’historique au lieu de rappeler l’API.
- Jev n’est appelé qu’une fois par tentative, même si le workflow est rejoué dix fois.
- Chaque décision est reproductible : la réponse exacte de Jev est dans l’historique.
Le SDK Python ajoute un bac à sable, qui réimporte le fichier du workflow à chaque exécution et bloque les appels non déterministes. Le bloc imports_passed_through importe typesafe_sdk et anthropic une seule fois, hors du bac à sable. Seules les activités s’en servent.
Avant de commencer
- Python 3.10 ou plus récent, et la CLI Temporal pour un serveur de développement local.
- Une clé API Jev, créée dans la console TypeSafe, dans la variable
TYPESAFE_API_KEY. Les inscriptions à Jev sont suspendues temporairement : vérifiez votre accès. - La clé de votre LLM actuel. L’exemple utilise Claude Haiku 4.5 via
ANTHROPIC_API_KEY. Remplacez-le par le modèle que vous utilisez déjà.
pip install temporalio typesafe-sdk anthropic
temporal server start-dev
Le code complet
Activités, workflow et worker tiennent en moins de 80 lignes. Copiez-le tel quel, puis lisez les sections suivantes pour l’adapter.
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from temporalio import activity, workflow
from temporalio.client import Client
from temporalio.common import RetryPolicy
from temporalio.exceptions import ActivityError
from temporalio.worker import Worker
with workflow.unsafe.imports_passed_through():
from anthropic import AsyncAnthropic
from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul, RetryPolicy as SdkRetryPolicy
JEV_MODEL = "jev-1.13.0" # pinned: a threshold is calibrated for one model version
TEAMS = {"billing": "Payments, invoices, refunds", "technical": "Bugs, outages, integrations",
"sales": "Pricing, quotes, new accounts"}
QUESTIONS = {
"department": Choice(instructions="Which team should handle this ticket?", criteria=TEAMS),
"urgent": Noul(instructions="Does the message express urgency?")}
PROMPT = "Which team should handle this ticket: billing, technical or sales? One word only.\n\n"
JEV_RETRY = RetryPolicy( # 429 and 529: retried after 1 s, 2 s, 4 s, 8 s. 401 and 422: never.
initial_interval=timedelta(seconds=1), backoff_coefficient=2.0, maximum_attempts=5,
non_retryable_error_types=["TypeSafeAuthenticationError", "TypeSafeUnprocessableEntityError"])
@dataclass
class Ticket:
text: str
threshold: float = 0.6 # workflow input: every run records the threshold it used
shadow: bool = True # start here: Jev is logged, your current LLM still decides
class TicketActivities:
def __init__(self): # SDK retries off: Temporal owns them, and shows each attempt
self.jev = AsyncTypeSafeClient(model=JEV_MODEL, retry=SdkRetryPolicy(max_retries=0))
self.llm = AsyncAnthropic(max_retries=0)
@activity.defn
async def ask_jev(self, text: str) -> dict:
res = await self.jev.system_one(state=text, questions=QUESTIONS)
return {"model": res.model, **res.choices["department"].model_dump(),
"urgent": res.nouls["urgent"].noul}
@activity.defn
async def ask_current_llm(self, text: str) -> str: # your current LLM router, unchanged
msg = await self.llm.messages.create(model="claude-haiku-4-5", max_tokens=256,
messages=[{"role": "user", "content": PROMPT + text}])
return msg.content[0].text.strip().lower() # billing, technical or sales
@workflow.defn
class RouteTicket:
@workflow.run
async def run(self, ticket: Ticket) -> dict:
jev, error = None, None
try:
jev = await workflow.execute_activity_method(
TicketActivities.ask_jev, ticket.text, retry_policy=JEV_RETRY,
start_to_close_timeout=timedelta(seconds=10))
except ActivityError as err: # retries spent, or a non-retryable error
error = str(err.cause)
if jev and not ticket.shadow and jev["confidence"] >= ticket.threshold:
department, decided_by = jev["choice"], "jev"
else:
department = await workflow.execute_activity_method(
TicketActivities.ask_current_llm, ticket.text,
schedule_to_close_timeout=timedelta(minutes=2))
decided_by = "llm"
return {"department": department, "decided_by": decided_by, "jev": jev, "error": error}
async def main():
client = await Client.connect("localhost:7233")
acts = TicketActivities()
async with Worker(client, task_queue="tickets", workflows=[RouteTicket],
activities=[acts.ask_jev, acts.ask_current_llm]):
ticket = Ticket("Hi, my September invoice shows up twice. Can you refund the duplicate?")
print(await client.execute_workflow(RouteTicket.run, ticket, id="ticket-1042",
task_queue="tickets"))
if __name__ == "__main__":
asyncio.run(main())
1. L’activité qui appelle Jev
Le client épingle la version du modèle. AsyncTypeSafeClient(model="jev-1.13.0") fixe le modèle pour tous les appels. L’alias jev-latest change à chaque nouvelle version, et vos seuils avec lui.
Les activités sont des méthodes d’une classe. Le worker crée les clients une fois, et toutes les exécutions les partagent.
Les nouvelles tentatives du SDK sont coupées. RetryPolicy(max_retries=0) laisse Temporal seul maître des retries. Sinon, trois essais du SDK multipliés par cinq essais Temporal feraient quinze appels.
2. Une retry policy adaptée à 429 et 529
Jev renvoie 429 au-delà de sa limite de débit, et 529 quand il est surchargé. TypeSafe recommande de retenter ces deux codes avec un délai croissant. JEV_RETRY fait exactement cela.
initial_intervaletbackoff_coefficient: attentes de 1, 2, 4 puis 8 secondes.maximum_attempts=5: cinq appels au plus, puis le workflow passe au repli.start_to_close_timeout: 10 secondes par tentative. Un appel bloqué compte comme un échec, et il est retenté.non_retryable_error_types: une clé invalide (401) ou une question mal formée (422) ne se corrige pas seule.
Cette liste contient des noms de classes Python. Quand une activité lève une exception ordinaire, Temporal prend le nom de sa classe comme type d’erreur. Nous l’avons vérifié : 401 et 422 donnent un seul appel, 429 et 529 en donnent cinq.
Vous voulez respecter l’en-tête Retry-After ? Capturez TypeSafeRateLimitError, lisez retry_after_ms, puis relevez une ApplicationError avec next_retry_delay.
3. Le workflow déterministe qui branche sur la confiance
Le workflow ne lit que son entrée et les résultats d’activités. Il ne consulte ni l’horloge, ni une variable d’environnement, ni un fichier. C’est ce qui le rend rejouable.
Le seuil et le mode shadow arrivent dans l’entrée, la classe Ticket. Si le seuil était une constante et que vous la changiez pendant qu’un workflow tourne, son rejeu pourrait prendre l’autre branche. Temporal le signalerait comme une erreur de non-déterminisme.
Avec le seuil en entrée, chaque exécution garde le sien. L’historique enregistre aussi le seuil appliqué, ticket par ticket.
Si l’activité Jev échoue après ses tentatives, le workflow reçoit une ActivityError. Il note la cause et passe au repli. Le ticket est routé quand même.
4. L’activité de repli
ask_current_llm reprend votre appel LLM actuel, déplacé dans une activité. Elle ne tourne que si Jev hésite, échoue, ou si le mode shadow est actif. Dans les autres cas, votre LLM n’est pas appelé.
Son schedule_to_close_timeout de deux minutes borne ses nouvelles tentatives. Si votre LLM échoue lui aussi, le workflow échoue de façon visible dans l’interface, au lieu de router le ticket au hasard.
5. Commencer en mode shadow
Démarrez avec shadow=True, la valeur par défaut. Jev répond sur chaque ticket, sa réponse est enregistrée, mais votre LLM décide. Quand l’accord entre les deux vous convient, lancez les nouveaux workflows avec shadow=False. Les workflows en cours restent intacts.
6. Le journal de décision
L’historique Temporal sert de journal. Il contient l’entrée, chaque tentative de Jev, sa réponse et le résultat final. Voici le résultat d’un ticket de facturation traité par Jev, avec les valeurs d’exemple de la documentation :
{"decided_by": "jev", "department": "billing", "error": null, "jev": {"choice": "billing", "confidence": 0.81, "model": "jev-1.13.0", "probabilities": {"billing": 0.88, "sales": 0.0, "technical": 0.12}, "type": "choice", "urgent": 0.12}}
Attention : Temporal supprime l’historique des workflows terminés après la période de rétention du namespace. Pour un audit sur plusieurs mois, copiez ce résultat dans votre propre base.
Comment nous avons testé ce code
Nous avons exécuté ce fichier tel quel, avec temporalio 1.33.0 et typesafe-sdk 0.7.1. Le test utilise WorkflowEnvironment.start_time_skipping(), qui saute les attentes entre tentatives. Les appels de Jev partaient vers une fausse API aux réponses documentées par TypeSafe. Le LLM était remplacé par une doublure.
- Confiance de 0,81 : Jev décide, une seule tentative, pas d’appel au LLM.
- Confiance de 0,10 : le repli choisit le service.
- Erreurs 429 et 529 répétées : cinq tentatives, puis le repli.
- Erreurs 401 et 422 : une seule tentative, puis le repli.
- Seuil de 0,05 passé en entrée : Jev décide avec 0,10.
- Mode shadow : le LLM décide, la réponse de Jev reste dans le résultat.
Chaque historique a ensuite été rejoué avec Replayer, sans erreur de non-déterminisme. Faites de même dans votre CI avant chaque déploiement. Nous avons aussi exécuté la partie Jev de ce code contre la vraie API le 23/09/2026 : jev-1.13.0 a répondu billing, confiance 1, en 374 ms depuis la France, pour 365 tokens d’entrée.
Choisir le bon seuil
Un seuil ne se devine pas. Il se mesure sur vos données, dans votre langue. Lancez un workflow par ticket passé, en mode shadow, puis comparez pour chaque seuil la part traitée par Jev et son taux d’erreur. Le guide n8n détaille la méthode.
Jev est d’abord entraîné en anglais. TypeSafe recommande de tester sur vos propres contenus avant de l’utiliser dans une autre langue.
Avant la production
- Données : Jev est hébergé aux États-Unis. Pseudonymisez les données personnelles avant l’envoi, et faites valider le transfert par votre DPO. L’historique Temporal contient aussi le texte du ticket.
- Version : gardez
jev-1.13.0épinglé. Recalibrez avant de passer à la suivante. - Identifiants : utilisez l’identifiant du ticket comme identifiant du workflow. Temporal refuse alors de router deux fois le même ticket en parallèle.
- Décisions sensibles : une confiance élevée ne vaut pas autorisation. Toute issue défavorable à une personne doit passer par un humain.
Vous voulez voir Jev décider avant d’écrire du code ? Essayez la démo avec vos propres exemples de tickets.
Jev et TypeSafe sont des marques de TypeSafe AI, Inc. Cet article est indépendant et n’est pas affilié à TypeSafe AI.