Extraction de valeurs préanalysées
Utilise des expressions régulières pour trouver des e-mails, numéros de téléphone et montants candidats, puis fait sélectionner par TypeSafe le fragment demandé pour que le code normalise une valeur littérale.
Une expression régulière trouve les valeurs candidates, TypeSafe choisit celle que demande la question, et le code la copie telle quelle.
Le couple find et pick présenté ici peut être pointé vers tes propres documents, et trois cas traités en montrent l’usage l’adresse à laquelle un expéditeur veut que son reçu soit envoyé, un numéro de téléphone sous la forme +14155550177, et le total d’une facture sous la forme 1315.50 USD marqué comme un débit.
TypeSafe choisit l’une des options que tu lui donnes, donc il faut d’abord trouver les candidats. Une expression régulière les trouve, TypeSafe en choisit un, et le code copie le choix, en trois étapes
- Une expression régulière trouve les valeurs candidates dans le texte. Règle-la pour qu’elle trouve trop.
- TypeSafe choisit le candidat que demande la question et lit tout attribut dont le code a besoin en aval (devise, pays, si un montant est un crédit ou un débit).
- Le code copie la valeur choisie et la normalise.
Comme TypeSafe ne choisit jamais que parmi les fragments trouvés par l’expression régulière, la valeur que tu récupères est l’un de ces fragments, copié sans modification. Il ne peut ni inventer une valeur ni transposer un chiffre.
L’expression régulière trouve des valeurs candidates dans le document, TypeSafe en choisit une, et le code en aval la normalise et agit dessus.
Configuration
pip install ipython phonenumbers 'cooksafe>=0.2.0,<0.3.0'
puis définis TYPESAFE_API_KEY.
import os
import re
from decimal import Decimal
from pathlib import Path
import phonenumbers
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, Noul, TypeSafeClient
TYPESAFE_MODEL = "jev-1.12"
NONE = "none" # the escape hatch on every selection: "none of the candidates fits"
# base_url defaults to https://api.typesafe.ai/ ; the env override points at another deployment.
ts = TypeSafeClient(
api_key=os.environ.get(
"TYPESAFE_API_KEY", "cache-only"
), # cached re-renders need no key
base_url=os.environ.get("TYPESAFE_BASE_URL"),
timeout=30.0,
)
json_cache = JsonCache(Path("json_cache.json"))
Auxiliaires
find exécute une expression régulière réglée pour trop trouver et dédoublonne les correspondances. pick est une question Choice dont les options sont les fragments que renvoie find, donc sa réponse est l’un de ces fragments copié exactement, ou none quand aucun candidat ne convient. classify est une question Choice sur un ensemble fixe d’étiquettes, utilisée ici pour la devise et le pays.
is_true est un Noul, utilisé ici pour demander si un montant est un crédit.
Chaque appel est mis en cache dans json_cache.json, donc un nouveau rendu ne fait aucun appel API.
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
PHONE_RE = re.compile(r"\(?\+?\d[\d\s()\-.]{6,}\d")
MONEY_RE = re.compile(r"[$€£¥]\s?\d[\d,]*(?:\.\d{2})?")
def find(pattern: re.Pattern, text: str) -> list[str]:
"""Code-side candidate finder: recall-tuned regex, deduped, in document order."""
seen: set[str] = set()
out: list[str] = []
for match in pattern.findall(text):
span = match.strip()
if span and span not in seen:
seen.add(span)
out.append(span)
return out
@json_cache
def pick(document: str, candidates: list[str], question: str) -> dict:
"""TypeSafe selects which found span plays the role. Returns {choice, confidence}.
The options ARE the candidate spans, so ``choice`` is a verbatim copy of one of them (or the
``none`` hatch) - the model chooses, code owns the string."""
criteria = {c: None for c in candidates} | {
NONE: "None of these is the requested value."
}
answer = ts.system_one(
state=document,
questions={"pick": Choice(instructions=question, criteria=criteria)},
model=TYPESAFE_MODEL,
).answers["pick"]
return {"choice": answer.choice, "confidence": answer.confidence}
@json_cache
def classify(document: str, question: str, options: list[str]) -> dict:
"""A small Choice over a fixed label set (currency, country, ...). Returns {choice, confidence}."""
answer = ts.system_one(
state=document,
questions={
"q": Choice(instructions=question, criteria={o: None for o in options})
},
model=TYPESAFE_MODEL,
).answers["q"]
return {"choice": answer.choice, "confidence": answer.confidence}
@json_cache
def is_true(document: str, question: str) -> float:
"""A yes/no Noul. Returns P(yes)."""
return (
ts.system_one(
state=document,
questions={"q": Noul(instructions=question)},
model=TYPESAFE_MODEL,
)
.answers["q"]
.noul
)
E-mail choisis la bonne adresse selon le rôle
Quatre adresses dans les en-têtes. Le corps demande que le reçu aille à une adresse personnelle plutôt qu’à l’alias de facturation To:, donc la réponse dépend de la lecture du corps. Deux questions ici quelle adresse reçoit le reçu, et laquelle a envoyé le message.
EMAIL_DOC = """From: Dana Whit <dana.whit@acme-corp.com>
To: billing@acme-corp.com
Cc: orders@acme-corp.com
Reply-To: dana.personal@gmail.com
Hi team - please don't use the billing alias for this one. Send my receipt to my
personal address instead. Thanks, Dana."""
emails = find(EMAIL_RE, EMAIL_DOC)
receipt = pick(
EMAIL_DOC, emails, "Which email address does the sender want their receipt sent to?"
)
sender = pick(
EMAIL_DOC, emails, "Which email address did this message come from (the From line)?"
)
print("candidates :", emails)
# code copies the picked value verbatim and normalizes (lowercase); it never re-types it
print(
f"receipt -> : {receipt['choice'].lower():<28} (conf {receipt['confidence']:.2f})"
)
print(f"sender -> : {sender['choice'].lower():<28} (conf {sender['confidence']:.2f})")
candidates : ['dana.whit@acme-corp.com', 'billing@acme-corp.com', 'orders@acme-corp.com', 'dana.personal@gmail.com']
receipt -> : dana.personal@gmail.com (conf 0.98)
sender -> : dana.whit@acme-corp.com (conf 1.00)
receipt est l’adresse Gmail personnelle de la ligne Reply-To:, ce que demande le corps sender est celle de la ligne From. Les deux sont des copies de correspondances de l’expression régulière, passées en minuscules dans le code.
Téléphone choisis le mobile et normalise-le en E.164
Trois numéros, aucun ne portant d’indicatif de pays. TypeSafe choisit le mobile et lit le pays dans le texte phonenumbers combine ces deux réponses en E.164, le format international qui commence par un + et l’indicatif du pays.
PHONE_DOC = """Reach our San Francisco office at these numbers: main desk (415) 555-0199,
billing fax (415) 555-0142, and my direct cell (415) 555-0177. Call the cell if it's urgent."""
phones = find(PHONE_RE, PHONE_DOC)
mobile = pick(PHONE_DOC, phones, "Which of these is the direct mobile / cell number?")
region = classify(
PHONE_DOC,
"In what country is this office located?",
["US", "GB", "DE", "FR", "CA", "AU"],
)
# code copies the picked value and normalizes it with the model-supplied country
parsed = phonenumbers.parse(mobile["choice"], region["choice"])
e164 = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
print("candidates :", phones)
print(f"mobile -> : {mobile['choice']} (conf {mobile['confidence']:.2f})")
print(f"country -> : {region['choice']} (conf {region['confidence']:.2f})")
print(f"E.164 -> : {e164}")
candidates : ['(415) 555-0199', '(415) 555-0142', '(415) 555-0177']
mobile -> : (415) 555-0177 (conf 1.00)
country -> : US (conf 0.90)
E.164 -> : +14155550177
Rien dans les chiffres ne dit quel numéro est le mobile ni dans quel pays il se trouve ce sont les mots autour qui le disent. TypeSafe lit ces mots, et phonenumbers met en forme le numéro choisi sous la forme +14155550177.
Argent choisis le montant, classe la devise et marque crédit ou débit
Une facture portant quatre montants. TypeSafe choisit le total dû et le crédit, lit la devise et marque chaque montant choisi comme un débit ou un crédit. Le code copie chaque chaîne choisie et la convertit en Decimal.
MONEY_DOC = """Invoice INV-2087.
Subtotal: $1,200.00
Sales tax: $115.50
Total due: $1,315.50
A $50.00 courtesy credit from last month has already been applied."""
amounts = find(MONEY_RE, MONEY_DOC)
currency = classify(
MONEY_DOC,
"What currency are these amounts in?",
["USD", "EUR", "GBP", "JPY", "CAD"],
)
total = pick(MONEY_DOC, amounts, "Which amount is the total the customer must pay?")
credit = pick(
MONEY_DOC, amounts, "Which amount is the courtesy credit that was applied?"
)
def to_decimal(value: str) -> Decimal:
"""Copy the picked value and parse the number in code (US grouping/decimal here)."""
return Decimal(re.sub(r"[^\d.]", "", value))
for label, chosen in [("total due", total), ("credit", credit)]:
is_credit = is_true(
MONEY_DOC,
f"Is the amount {chosen['choice']} a credit or refund to the customer, not a charge?",
)
kind = "credit" if is_credit > 0.5 else "charge"
print(
f"{label:<10}: {chosen['choice']:<10} -> {to_decimal(chosen['choice'])} {currency['choice']} "
f"({kind}, P(credit)={is_credit:.2f})"
)
print("\ncandidates :", amounts)
total due : $1,315.50 -> 1315.50 USD (charge, P(credit)=0.01)
credit : $50.00 -> 50.00 USD (credit, P(credit)=0.99)
candidates : ['$1,200.00', '$115.50', '$1,315.50', '$50.00']
Le total dû est de $1,315.50 et le crédit de $50.00, tous deux en USD. Le Noul crédit ou débit répond 0.01 sur le total et 0.99 sur le crédit, donc le code connaît le signe de chaque Decimal qu’il convertit.
to_decimalsuppose que la virgule sépare les milliers et que le point est le séparateur décimal. Cela vaut pour$1,315.50dans€1.315,50c’est l’inverse. Pose une questionNoulpour savoir quelle convention utilise le document, et branche dessus dans le code.
Ouvre-le dans le playground TypeSafe
Un lien de partage qui ouvre le fil d’e-mails dans le navigateur, avec la question du reçu et les quatre adresses trouvées par l’expression régulière parmi ses options.
receipt_criteria = {e: None for e in emails} | {
NONE: "None of these is the requested value."
}
playground_link = make_playground_link(
EMAIL_DOC,
{
"receipt": Choice(
instructions="Which email address does the sender want their receipt sent to?",
criteria=receipt_criteria,
)
},
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 [Open this thread + selection in the TypeSafe playground]({playground_link})"
)
)
Ouvre ce fil + la sélection dans le playground TypeSafe →
Deux limites
- Une question
Choiceaccepte au plus 255 options. Avec plus de candidats que cela, resserre en deux étapes choisis d’abord la section, puis le fragment à l’intérieur. - Trouver les candidats est la partie qui demande du travail. Les e-mails, les numéros de téléphone et les montants ont des expressions régulières qui les couvrent un nom n’en a pas, donc ses candidats doivent venir d’une liste que tu possèdes déjà, ou d’un reconnaisseur d’entités nommées ou d’un LLM qui les propose. TypeSafe choisit ensuite celui que demande la question.