Questions parallèles
Exécute un briefing réglementaire de 13 questions sur l'article Wikipédia relatif au RGPD, démontrant que le regroupement de toutes les questions en un seul appel TypeSafe est 12,2 fois moins cher et 10,0 fois plus rapide, sans aucune modification des réponses.
Traduit automatiquement depuis le en, non relu. À utiliser comme référence rapide uniquement.

Vous avez un document et N questions à son sujet. Vous pouvez envoyer une seule requête contenant les N questions, ou N requêtes avec une question chacune. Avec TypeSafe, les réponses sont identiques dans les deux cas : chaque question est évaluée indépendamment par rapport au document, de sorte que sa réponse ne dépend pas des autres éléments présents dans la requête.
Pour vérifier cela, le livre de recettes pose chaque question plusieurs fois dans les deux sens — tout N dans une seule requête, et une question par requête — et compare l’écart-type inter-exécutions : la distance à laquelle une réponse s’écarte d’une répétition à l’autre. Quelque soit le bruit qu’une question présente, il est présent sous les deux stratégies de groupement. Le groupement n’en ajoute aucun. La plupart des réponses sont revenues identiques lors des 5 répétitions, dans les deux cas, avec la même valeur à chaque appel, un écart-type exactement de 0,0.
Le coût et la vitesse évoluent. Le document est au cœur de chaque requête. N appels individuels à une question le rémunèrent N fois, en N allers-retours ; l’appel groupé le rémunère une seule fois. Plus le document est volumineux, plus cette économie se rapproche d’un facteur Nx complet.
Le cas ici est une note de synthèse réglementaire. Le document est l’article Wikipédia sur le RGPD
(~54 000 caractères, une charge de travail dominée par le document où le document constitue la majeure partie de chaque
requête), et une équipe de conformité souhaite vérifier 13 éléments : 8 Noul questions, 2 Choice
questions, et 3 Score questions.
Installation
pip install ipython "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
alors définis TYPESAFE_API_KEY.
import json
import os
import urllib.request
from pathlib import Path
from statistics import mean, stdev
from time import perf_counter
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, ChoiceAnswer, Noul, NoulAnswer, Score, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
PRICE = (
0.042,
0.00,
) # $ per 1M tokens (input, output); TypeSafe jev-1.12 as of 2026-09, see README
RUNS = 5 # repeats per batching strategy, to estimate each answer's run-to-run std dev
client = TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], timeout=120.0)
json_cache = JsonCache(Path("json_cache.json"))
Le document : l’article Wikipédia sur le RGPD
Récupéré en texte brut depuis une révision épinglée de l’article et mis en cache dans json_cache.json
à côté des appels API, afin que le document et ses chiffres restent fixes même lorsque l’article
en direct est modifié.
WIKIPEDIA_REVISION = 1363040264 # "General Data Protection Regulation", as of 2026-07
@json_cache
def fetch_article(revision_id: int) -> str:
url = (
"https://en.wikipedia.org/w/api.php?action=query&format=json"
f"&prop=extracts&explaintext=1&revids={revision_id}"
)
request = urllib.request.Request(
url, headers={"User-Agent": "typesafe-cookbook/1.0"}
)
with urllib.request.urlopen(request) as response:
pages = json.loads(response.read())["query"]["pages"]
return next(iter(pages.values()))["extract"]
DOCUMENT = {
"source": f"https://en.wikipedia.org/?oldid={WIKIPEDIA_REVISION}",
"text": fetch_article(WIKIPEDIA_REVISION),
}
print(f"{len(DOCUMENT['text']):,} characters")
display(Markdown(f"📄 [Read the pinned Wikipedia revision]({DOCUMENT['source']})"))
53,777 characters
📄 Lire la révision épinglée de Wikipédia
Les questions : 8 nouls + 2 choices + 3 scores
Un nombre suivi par réponse, par type :
Noul: la probabilité de « oui ».Choice: la probabilité maximale, la probabilité sur l’étiquette choisie.criteriaassocie à chaque étiquette sa signification.Score: le score normalisé entre 0 et 1, le score divisé par le niveau supérieur.criterialiste les descriptions des niveaux, du niveau 0 vers le haut.
QUESTIONS = {
"breach_72h": Noul(
instructions="Must a personal data breach be reported to the supervisory authority within 72 hours?"
),
"applies_non_eu": Noul(
instructions="Does the regulation apply to organisations established outside the EU that offer goods or services to people in the EU?"
),
"dpo_all_orgs": Noul(
instructions="Must every organisation appoint a Data Protection Officer, regardless of what data it processes?"
),
"pre_ticked_consent": Noul(
instructions="Can valid consent be obtained through pre-ticked boxes or inactivity?"
),
"right_erasure": Noul(
instructions="Does the regulation grant individuals a right to erasure of their personal data?"
),
"data_portability": Noul(
instructions="Does the regulation include a right to data portability?"
),
"us_federal_law": Noul(instructions="Is the GDPR a United States federal law?"),
"criminal_penalties": Noul(
instructions="Does the GDPR itself impose criminal penalties such as imprisonment?"
),
"instrument_type": Choice(
instructions="What kind of EU legal instrument is the GDPR?",
criteria={
"Regulation": "Directly binding law in all member states, no national implementation needed.",
"Directive": "Sets goals that member states implement through national law.",
"Treaty": "An international treaty between states.",
"Recommendation": "Non-binding guidance.",
},
),
"max_fine": Choice(
instructions="What is the maximum administrative fine for the most serious infringements?",
criteria={
"TwentyM_or_4pct": "Up to EUR 20 million or 4% of annual worldwide turnover, whichever is greater.",
"TenM_or_2pct": "Up to EUR 10 million or 2% of annual worldwide turnover, whichever is greater.",
"FixedCap": "A fixed amount not tied to turnover.",
"NoFines": "The GDPR provides no administrative fines.",
},
),
"individual_rights": Score(
instructions="How strong are the rights the GDPR grants to individuals over their data?",
criteria=[
"None: individuals get no rights over their data.",
"Weak: a right to be informed, but little control.",
"Moderate: access and correction rights, but limited means to act on them.",
"Strong: access, erasure, portability, and objection rights, with enforcement behind them.",
],
),
"penalty_severity": Score(
instructions="How severe are the penalties the GDPR provides for non-compliance?",
criteria=[
"None: no penalties of any kind.",
"Symbolic: small fixed fines unlikely to change behavior.",
"Substantial: fines large enough to matter to most companies.",
"Severe: fines scaled to global revenue, material even to the largest companies.",
],
),
"compliance_burden": Score(
instructions="How heavy is the compliance burden the GDPR places on organisations?",
criteria=[
"Negligible: no meaningful obligations.",
"Light: a few notices and disclosures.",
"Moderate: documented processes and some dedicated roles for larger processors.",
"Heavy: records, impact assessments, officers, and breach procedures for many organisations.",
"Extreme: obligations so demanding that ordinary organisations cannot fully comply.",
],
),
}
N = len(QUESTIONS)
METRIC = { # question type -> the one number we track per answer
Noul: "p(yes)",
Choice: "max prob",
Score: "normalized score",
}
Demander de deux manières, 5 fois chacune
ask() envoie tout sous-ensemble de questions avec le document et réduit chaque réponse à son
numéro suivi. Le document est byte-identique dans chaque appel.
Les deux stratégies de regroupement s’exécutent RUNS = 5 fois, fournissant ainsi cinq réponses par question pour chaque stratégie,
ce qui permet de comparer la moyenne (les deux s’accordent-elles ?) et l’écart type (le regroupement ajoute-t-il du bruit ?).
Les appels sont mis en cache dans json_cache.json, qui est livré avec le cahier de recettes, donc le nouveau rendu est
gratuit ; supprimez-le pour relancer en direct.
@json_cache
def ask(keys: tuple[str, ...], run: int):
"""One TypeSafe call -> ({key: tracked metric}, input_tokens, output_tokens, latency_s);
``run`` only forces a distinct live call per repeat."""
started = perf_counter()
response = client.system_one(
state={"article": DOCUMENT},
questions={key: QUESTIONS[key] for key in keys},
model=TYPESAFE_MODEL,
)
values = {}
for key in keys:
answer = response.answers[key]
if isinstance(answer, NoulAnswer):
values[key] = answer.noul
elif isinstance(answer, ChoiceAnswer):
values[key] = max(answer.probabilities.values())
else:
values[key] = answer.score / (len(QUESTIONS[key].criteria) - 1)
return (
values,
response.usage.input_tokens,
response.usage.output_tokens,
perf_counter() - started,
)
def priced(result):
"""({key: metric}, in_tokens, out_tokens, latency) -> ({key: metric}, cost_usd, latency)."""
values, input_tokens, output_tokens, latency = result
return values, input_tokens / 1e6 * PRICE[0] + output_tokens / 1e6 * PRICE[1], latency
# Price after cache retrieval, so a price change needs no new calls.
batched = [
priced(ask(tuple(QUESTIONS), run)) for run in range(RUNS)
] # all N in one call, x RUNS
singles = [
{key: priced(ask((key,), run)) for key in QUESTIONS} for run in range(RUNS)
] # N x 1, x RUNS
Le regroupement ne modifie pas les réponses
Pour chaque question : la moyenne et l’écart-type de son nombre suivi sur les 5 exécutions, sous chaque stratégie de regroupement. Si le regroupement modifiait les réponses, les colonnes groupées différeraient des colonnes uniques. Une moyenne décalée est un biais. Un écart-type plus élevé est du bruit.
print(
f"{'question':<22}{'metric':<18}{'batched mean':>13}{'single mean':>12}"
f"{'batched std':>13}{'single std':>12}"
)
for key, question in QUESTIONS.items():
batched_values = [values[key] for values, _cost, _latency in batched]
single_values = [singles[run][key][0][key] for run in range(RUNS)]
print(
f"{key:<22}{METRIC[type(question)]:<18}{mean(batched_values):>13.3f}"
f"{mean(single_values):>12.3f}{stdev(batched_values):>13.4f}{stdev(single_values):>12.4f}"
)
question metric batched mean single mean batched std single std
breach_72h p(yes) 0.804 0.814 0.0055 0.0055
applies_non_eu p(yes) 0.990 0.990 0.0000 0.0000
dpo_all_orgs p(yes) 0.030 0.030 0.0000 0.0000
pre_ticked_consent p(yes) 0.040 0.040 0.0000 0.0000
right_erasure p(yes) 0.990 0.990 0.0000 0.0000
data_portability p(yes) 0.990 0.990 0.0000 0.0000
us_federal_law p(yes) 0.010 0.010 0.0000 0.0000
criminal_penalties p(yes) 0.108 0.108 0.0045 0.0084
instrument_type max prob 1.000 1.000 0.0000 0.0000
max_fine max prob 1.000 1.000 0.0000 0.0000
individual_rights normalized score 1.000 1.000 0.0000 0.0000
penalty_severity normalized score 1.000 1.000 0.0000 0.0000
compliance_burden normalized score 0.750 0.750 0.0000 0.0000
Lecture du tableau par type de question :
- Les choix, les scores et six des huit nouls reviennent identiques sur les 5 répétitions : l’écart-type est exactement de 0,0 dans les deux stratégies de regroupement, chaque appel groupé et individuel renvoyant le même nombre. Un appel avec N questions donne les mêmes réponses que N appels avec une question chacun.
breach_72hetcriminal_penaltiesprésentent un léger bruit d’échantillonnage d’une exécution à l’autre, et il est de la même taille dans les deux stratégies de regroupement, les moyennes coïncidant à hauteur de ce bruit. Le bruit est une propriété de la question, et non de la manière dont vous regroupez : le regroupement ne déplace pas la réponse ni n’ajoute de variance.
Quoi qu’il en soit, il n’y a aucun effet de lot : la réponse à une question ne dépend pas des 12 autres questions partageant sa requête.
La seule différence : le coût et la vitesse
Mêmes réponses, facture différente. L’article d’environ 54 000 caractères domine chaque demande, donc :
- Coût : les 13 appels individuels renvoie l’article 13 fois ; l’appel groupé l’envoie une seule fois. Cette économie s’applique quelle que soit la façon dont vous lancez les appels.
- Vitesse : le chiffre additionne les latences des 13 appels individuels, il suppose donc qu’ils s’exécutent les uns après les autres. Lancez-les en parallèle et l’écart se réduit, mais le coût en tokens x13 reste.
Les compteurs de tokens et les latences sont mis en cache avec les réponses ; le coût est appliqué ensuite, et les deux sont moyennés sur les 5 exécutions.
batched_cost = mean(cost for _values, cost, _latency in batched)
batched_latency = mean(latency for _values, _cost, latency in batched)
singles_cost = mean(
sum(singles[run][key][1] for key in QUESTIONS) for run in range(RUNS)
)
singles_latency = mean(
sum(singles[run][key][2] for key in QUESTIONS) for run in range(RUNS)
)
print(f"{'batching':<24}{'calls':>6}{'cost':>12}{'total time':>12}")
print(
f"{f'one call, all {N}':<24}{1:>6}{'$' + format(batched_cost, '.6f'):>12}{format(batched_latency, '.2f') + 's':>12}"
)
print(
f"{f'{N} calls, one each':<24}{N:>6}{'$' + format(singles_cost, '.6f'):>12}{format(singles_latency, '.2f') + 's':>12}"
)
print(
f"\nbatching: {singles_cost / batched_cost:.1f}x cheaper, {singles_latency / batched_latency:.1f}x faster"
)
batching calls cost total time
one call, all 13 1 $0.000497 0.27s
13 calls, one each 13 $0.006090 2.71s
batching: 12.2x cheaper, 10.0x faster
Ouvrez-le dans le playground TypeSafe
Le même article et les mêmes 13 questions, regroupés dans un lien de partage. Ouvrez-le pour relancer briefing en direct ; les mêmes chiffres reviennent.
playground_link = make_playground_link(
{"article": DOCUMENT}, QUESTIONS, models=[TYPESAFE_MODEL]
)
display(
Markdown(
f"🔗 [Open this article + questions in the TypeSafe playground]({playground_link})"
)
)
Ouvrir cet article + les questions dans le playground TypeSafe →