문서

사전 파싱 값 추출

정규식으로 후보 이메일, 전화번호, 금액을 찾은 뒤, TypeSafe가 요청된 스팬을 선택하게 하여 코드가 원문 그대로의 값을 정규화할 수 있게 합니다.

정규식이 후보 값을 찾고, TypeSafe가 질문이 요구하는 값을 고르면, 코드가 그것을 원문 그대로 복사합니다.

여기서의 find와 pick 조합은 여러분의 문서에도 적용할 수 있으며, 세 가지 예제가 사용법을 보여줍니다. 발신자가 영수증을 받고 싶어 하는 주소, +14155550177 형태의 전화번호, 그리고 청구(charge)로 표시된 1315.50 USD 금액의 인보이스 합계입니다.

TypeSafe는 여러분이 넘긴 선택지 중 하나를 고르므로, 후보를 먼저 찾아야 합니다. 정규식이 후보를 찾고, TypeSafe가 하나를 고르고, 코드가 그 선택을 복사하는 세 단계입니다.

  1. 정규식이 텍스트에서 후보 값을 찾습니다. 과다 검출되도록 조정하십시오.
  2. TypeSafe가 질문이 요구하는 후보가 무엇인지 고르고, 코드가 이후에 필요로 하는 속성(통화, 국가, 금액이 입금인지 청구인지)을 읽어냅니다.
  3. 코드가 선택된 값을 복사하고 정규화합니다.

TypeSafe는 정규식이 찾은 스팬 중에서만 고르므로, 돌려받는 값은 그 스팬 중 하나를 그대로 복사한 것입니다. 값을 지어내거나 숫자를 뒤바꿀 수 없습니다.

Overview diagram

정규식이 문서에서 후보 값을 찾고, TypeSafe가 하나를 고르면, 이후 코드가 그것을 정규화하여 처리합니다.

준비

pip install ipython phonenumbers 'cooksafe>=0.2.0,<0.3.0'

그다음 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"))

헬퍼

find는 과다 검출되도록 조정된 정규식을 실행하고 매칭을 중복 제거합니다. pick은 선택지가 find가 반환한 스팬들인 Choice 질문이므로, 그 답은 그 스팬 중 하나를 정확히 복사한 것이거나, 맞는 후보가 없을 때는 none입니다. classify는 고정된 레이블 집합에 대한 Choice 질문으로, 여기서는 통화와 국가에 사용합니다. is_true는 Noul이며, 여기서는 금액이 입금(credit)인지 묻는 데 사용합니다.

모든 호출은 json_cache.json에 캐시되므로, 다시 렌더링해도 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
    )

이메일: 역할에 맞는 주소 고르기

헤더에 주소가 네 개 있습니다. 본문에서는 영수증을 To: 청구 별칭이 아니라 개인 주소로 보내 달라고 요청하므로, 답은 본문을 읽는 데 달려 있습니다. 여기서는 두 가지를 묻습니다. 어느 주소로 영수증을 받는지, 그리고 어느 주소가 메시지를 보냈는지입니다.

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는 본문이 요청한 대로 Reply-To: 줄에 있는 개인 Gmail 주소이고, sender는 From 줄에 있는 주소입니다. 둘 다 정규식 매칭 결과를 복사한 뒤 코드에서 소문자로 바꾼 것입니다.

전화: 휴대폰을 골라 E.164로 정규화하기

번호가 세 개 있고, 어느 것도 국가 코드를 달고 있지 않습니다. TypeSafe가 휴대폰을 고르고 텍스트에서 국가를 읽으며, phonenumbers가 그 두 답을 합쳐 +와 국가 코드로 시작하는 국제 형식인 E.164로 만듭니다.

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

숫자만으로는 어느 번호가 휴대폰인지, 어느 나라 것인지 알 수 없습니다. 그 주변의 단어들이 알려줍니다. TypeSafe가 그 단어들을 읽고, phonenumbers가 고른 번호를 +14155550177로 포맷합니다.

금액: 금액을 고르고, 통화를 분류하고, 입금과 청구를 구분하기

금액이 네 개 적힌 인보이스입니다. TypeSafe가 청구 총액과 입금(credit)을 고르고, 통화를 읽으며, 고른 각 금액을 청구(charge) 또는 입금(credit)으로 표시합니다. 코드는 고른 각 문자열을 복사해 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']

청구 총액은 $1,315.50이고 입금은 $50.00이며, 둘 다 USD입니다. 입금-또는-청구 Noul은 총액에 대해 0.01, 입금에 대해 0.99를 답하므로, 코드는 자신이 파싱하는 각 Decimal의 부호를 알 수 있습니다.

to_decimal은 쉼표가 천 단위를 묶고 점이 소수점이라고 가정합니다. 이는 $1,315.50에는 성립하지만, €1.315,50에서는 반대입니다. 문서가 어느 관례를 쓰는지 Noul 질문으로 물어보고, 코드에서 그것에 따라 분기하십시오.

TypeSafe playground에서 열기

이메일 스레드를 브라우저에서 열어 주는 공유 링크로, 영수증 질문과 정규식이 찾은 네 주소가 그 선택지에 담겨 있습니다.

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})"
    )
)
TypeSafe playground에서 이 스레드 + 선택 열기 →

두 가지 한계

  • Choice 질문은 선택지를 최대 255개까지 허용합니다. 후보가 그보다 많으면 두 단계로 좁히십시오. 먼저 절을 고르고, 그 안에서 스팬을 고릅니다.
  • 후보를 찾는 것이 일이 많이 드는 부분입니다. 이메일, 전화번호, 금액에는 그것을 포괄하는 정규식이 있습니다. 이름에는 없으므로, 그 후보는 이미 가지고 있는 명부나, 개체명 인식기, 혹은 후보를 제안하는 LLM에서 나와야 합니다. 그런 다음 TypeSafe가 질문이 요구하는 하나를 고릅니다.