Dokumentation

Zeilenweise Suche

Baue eine semantische Suche für GitHubs Nutzungsbedingungen. Bewerte in einer Anfrage 218 Zeilen-IDs mit einer Choice-Frage gegen eine alltagssprachliche Suchanfrage und prüfe mit einer Noul-Frage, ob das Dokument überhaupt eine Antwort enthält.

Du hast GitHubs Nutzungsbedingungen und eine alltagssprachliche Frage dazu. Du brauchst die Zeilen, die die Frage beantworten, und eine Möglichkeit zu erkennen, wann das Dokument keine Antwort hat. Die mitgelieferten Suchanfragen reihen Zeilen mit direkten Antworten zuerst ein. Die exists-Schwellenwerte ordnen die übrigen Fälle als fehlend oder teilweise ein. Am Ende hast du find(), das die exists-Wahrscheinlichkeit und einen Relevanz-Score pro Zeile zurückgibt.

Eine Suchanfrage durchsucht ein Dokument und legt eine Antwort frei, die an der
passenden Zeile hängt

Das Such-Backend besteht aus drei Teilen:

  1. Versehe jede Zeile mit einer ID, damit TypeSafe darauf verweisen kann.
  2. Nutze eine Choice-Frage, um diese Zeilen-IDs danach zu reihen, wie gut sie die Suche beantworten. Die Wahrscheinlichkeiten einer Choice-Frage addieren sich immer zu 1, also steht eine Zeile an erster Stelle, selbst wenn keine die Suchanfrage beantwortet.
  3. Nutze in derselben Anfrage eine Noul-Frage, um zu prüfen, ob das Dokument überhaupt eine Antwort enthält.

Einrichtung

Einen TypeSafe-API-Schlüssel bekommen

Erstelle einen Schlüssel in der TypeSafe-Konsole und exportiere ihn:

export TYPESAFE_API_KEY="your-key-here"

Abhängigkeiten installieren

pip install 'cooksafe>=0.2.0,<0.3.0'

JsonCache spielt die mitgelieferten API-Antworten erneut ab, sodass die folgenden Schritte ohne API-Schlüssel und ohne Kosten laufen. Um die Anfragen stattdessen live zu stellen, setze TYPESAFE_API_KEY und lösche json_cache.json.

Das Skript erstellen

Beginne semantic_search.py mit den Imports und dem Client:

import os
import urllib.request
from pathlib import Path

from cooksafe import JsonCache
from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient

TYPESAFE_MODEL = "jev-1.12"

client = TypeSafeClient(
    api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"), timeout=120.0
)
json_cache = JsonCache(Path("json_cache.json"))

Schritt 1: Jede Zeile mit einer ID versehen

Das Testdokument sind GitHubs Nutzungsbedingungen, in 218 Klauseln aufgeteilt, sodass jedes Suchergebnis auf eine zitierbare Zeile verweist.

Füge zu semantic_search.py hinzu:

GIST = (
    "https://gist.githubusercontent.com/eugene-shvarts/900632789a24983d5678ffd508dd01f6"
    "/raw/cf9c2ab422d568deade949ef0a06bed6896964b9/github-tos.txt"
)

@json_cache
def fetch_document(url: str) -> str:
    request = urllib.request.Request(
        url, headers={"User-Agent": "typesafe-cookbook/1.0"}
    )
    with urllib.request.urlopen(request) as response:
        return response.read().decode()

LINES = fetch_document(GIST).splitlines()

Der Cache verhindert wiederholte Downloads, und splitlines() hinterlässt eine Liste aus 218 Zeichenketten.

Stelle nun jeder Zeile eine kurze ID voran und füge die Zeilen wieder zu einem Dokument zusammen. Das Modell nutzt diese IDs, um auf seine Antwort zu verweisen.

def line_id(i: int) -> str:
    return f"L{i:03d}"

DOCUMENT = "\n".join(f"{line_id(i)}| {line}" for i, line in enumerate(LINES))

DOCUMENT sieht jetzt so aus:

L052| You own Your Content. If you post Content you did not create, you are responsible for...
L053| You grant us and other Users the licenses in Sections D.4–D.8. These licenses apply...
L054| 4. License Grant to Us

Schritt 2: Fragen, wo die Antwort steht

Eine Choice-Frage gibt für jede Option eine Wahrscheinlichkeit zurück. Nutze die Zeilen-IDs als Optionen, und aus „wähle eine Option“ wird „verweise auf eine Zeile“.

def where_question(query: str) -> Choice:
    return Choice(
        instructions=f'Which line of the document contains the answer to: "{query}"?',
        criteria={line_id(i): None for i in range(len(LINES))},
    )

Die Optionsbeschreibungen sind None, weil das Dokument den Text für jede ID bereits enthält. Die Suchanfrage kommt in instructions; der Zustand bleibt zwischen den Suchen unverändert.

Schritt 3: Prüfen, ob eine Antwort existiert

Die Wahrscheinlichkeiten einer Choice-Frage addieren sich immer zu 1, also steht irgendeine Zeile an erster Stelle, selbst wenn das Dokument die Frage nicht beantwortet. Die Reihung allein kann eine echte Antwort nicht von der nächstbesten irrelevanten Zeile unterscheiden.

Stelle also eine zweite Frage, in derselben Anfrage:

def exists_question(query: str) -> Noul:
    return Noul(
        instructions=f'Does any line of the document address or answer: "{query}"?',
        criteria=NoulCriteria(
            true="At least one line of the document states or directly implies the answer",
            false="No line of the document addresses this",
        ),
    )

Anders als die Choice-Wahrscheinlichkeiten hängt die Noul-Wahrscheinlichkeit nicht von den anderen Optionen ab, also kann sie nahe null fallen, wenn das Dokument keine Antwort hat.

Schritt 4: Beide Fragen in einer Anfrage senden

Die Methode system_one beantwortet beide Fragen in einem Durchgang. Der Zustand wird einmal gesendet, also erfordert die zusätzliche Existenzprüfung nur wenig zusätzliche Ausgabe.

Ein mit IDs versehenes Dokument und eine Nutzerfrage gehen in eine TypeSafe-Anfrage ein.
Eine Choice-Frage bewertet jede Zeile, während eine Noul-Frage prüft, ob eine Antwort existiert.
Lokaler Code reiht dann die Zeilen und wendet das Urteil des Dokuments an.
@json_cache
def _find(
    model: str,
    state: str,
    where: Choice,
    exists: Noul,
) -> dict:
    response = client.system_one(
        state=state,
        questions={"where": where, "exists": exists},
        model=model,
    )
    probabilities = response.answers["where"].probabilities
    return {
        "exists": response.answers["exists"].noul,
        "relevance": [probabilities.get(line_id(i), 0.0) for i in range(len(LINES))],
    }

def find(query: str) -> dict:
    return _find(
        TYPESAFE_MODEL,
        DOCUMENT,
        where_question(query),
        exists_question(query),
    )

Die Liste relevance hält einen Score pro Zeile, in Dokumentreihenfolge.

Schritt 5: Das Ergebnis lesen

Zwei Stücke lokalen Codes erledigen den Rest: verdict() verwandelt die rohe exists-Wahrscheinlichkeit in drei Zustände, mit einem mittleren für teilweise Antworten, und show() rendert relevance als Balkendiagramm, damit die Reihung im Terminal lesbar ist.

FOUND, ABSENT = 0.7, 0.35  # present answers typically read >=0.9, absent <=0.05

def verdict(exists: float) -> str:
    if exists >= FOUND:
        return "answered in this document"
    return "not in this document" if exists < ABSENT else "partially addressed"

def show(query: str, top: int = 4) -> dict:
    result = find(query)
    print(f'"{query}"')
    print(f"  exists {result['exists']:.2f} -> {verdict(result['exists'])}")
    ranked = sorted(
        range(len(LINES)), key=lambda i: result["relevance"][i], reverse=True
    )
    for i in ranked[:top]:
        bar = "#" * max(1, round(result["relevance"][i] * 12))
        preview = LINES[i][:58].rstrip()
        print(f"  {line_id(i)}  {result['relevance'][i]:.2f}  {bar:<12}  {preview}")
    return result

Diese Schwellenwerte trennen die Beispiele unten, aber stimme sie auf deine eigenen Dokumente ab, bevor du sie in Produktion einsetzt.

Schritt 6: Die Suche ausführen

Stelle zwei Fragen, die direkte Antworten haben, eine, die keine Antwort hat, und eine, die eine teilweise Antwort hat, insgesamt vier.

print(f"{len(LINES)} lines, {len(DOCUMENT):,} characters\n")
show("who owns the code I upload?")
print()
show("can GitHub kick me off the platform without warning?")
print()
show("do I have to take disputes to arbitration?", top=2)
print()
show("can minors use GitHub with parental permission?", top=2)
218 lines, 43,980 characters

"who owns the code I upload?"
  exists 0.98 -> answered in this document
  L052  0.95  ###########   You own Your Content. If you post Content you did not crea
  L046  0.02  #             Short version: You own content you create, but you allow u
  L051  0.02  #             3. Ownership and License Grants
  L217  0.01  #             Questions about the Terms of Service? Contact us through t

"can GitHub kick me off the platform without warning?"
  exists 0.97 -> answered in this document
  L168  0.97  ############  GitHub has the right to suspend or terminate your access t
  L167  0.03  #             3. GitHub May Terminate
  L000  0.00  #             Effective date: April 27, 2026 · A. Definitions
  L001  0.00  #             Short version: We use these basic terms throughout the agr

"do I have to take disputes to arbitration?"
  exists 0.14 -> not in this document
  L205  0.86  ##########    Except to the extent applicable law provides otherwise, th
  L168  0.02  #             GitHub has the right to suspend or terminate your access t

"can minors use GitHub with parental permission?"
  exists 0.46 -> partially addressed
  L029  0.90  ###########   You must be age 13 or older. While we are thrilled to see
  L012  0.07  #             “User,” “You,” and “Your” refer to the individual person,

Was die Scores bedeuten

Die ersten beiden Suchanfragen liefern direkte Antworten und die Quellzeilen, die man zum Überprüfen braucht.

Die anderen beiden zeigen, warum die Existenzprüfung wichtig ist:

  • Schiedsverfahren: Die Reihung gibt der nächstbesten Zeile einen Score von 0.86, aber exists ist nur 0.14. Die Antwort steht nicht im Dokument.
  • Elterliche Erlaubnis: Die Altersregel steht an erster Stelle, aber sie beantwortet nicht, ob eine elterliche Erlaubnis die Regel ändert. Das Ergebnis ist teilweise beantwortet.

Die Reihung sagt dir, wo du suchen sollst; der exists-Score sagt dir, ob das Ergebnis die Frage beantwortet.

Mit deinem eigenen Dokument ausprobieren

Öffne den mit Tags versehenen Vertrag im TypeSafe-Playground, um die Fragen gegen denselben Text zu bearbeiten. Um dein eigenes Dokument zu durchsuchen, tausche die URL in fetch_document() aus; jede andere Zeile des Skripts arbeitet mit LINES.