Documentation

Recherche ligne par ligne

Construis une recherche sémantique pour les Conditions d’utilisation de GitHub. En une seule requête, note 218 ids de ligne face à une question en langage naturel avec une question Choice, et utilise une question Noul pour vérifier si le document contient une réponse.

Tu disposes des Conditions d’utilisation de GitHub et d’une question en langage naturel à leur sujet. Il te faut les lignes qui répondent à la question et un moyen de détecter quand le document n’a pas de réponse. Les requêtes incluses classent d’abord les lignes qui contiennent une réponse directe. Les seuils d’exists classent les autres cas comme absents ou partiels. Tu obtiens au final find(), qui renvoie la probabilité d’exists et un score de pertinence par ligne.

Une requête parcourt un document et révèle une réponse attachée à la ligne correspondante

Le backend de recherche s’assemble en trois parties :

  1. Étiquette chaque ligne avec un ID pour que TypeSafe puisse la désigner.
  2. Utilise une question Choice pour classer ces ids de ligne selon la qualité de leur réponse à la requête. Les probabilités d’une question Choice totalisent toujours 1, donc une ligne arrive en tête même si aucune ne répond à la requête.
  3. Dans la même requête, utilise une question Noul pour vérifier si le document contient une réponse, quelle qu’elle soit.

Configuration

Obtenir une clé d’API TypeSafe

Crée une clé dans la console TypeSafe et exporte-la :

export TYPESAFE_API_KEY="your-key-here"

Installer les dépendances

pip install 'cooksafe>=0.2.0,<0.3.0'

JsonCache rejoue les réponses d’API incluses, donc les étapes ci-dessous s’exécutent sans clé d’API ni aucune dépense. Pour rendre les requêtes réelles, définis TYPESAFE_API_KEY et supprime json_cache.json.

Créer le script

Commence semantic_search.py par les imports et le 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"))

Étape 1 : étiqueter chaque ligne avec un ID

Le document de test est les Conditions d’utilisation de GitHub, découpées en 218 clauses, de sorte que chaque résultat de recherche pointe vers une ligne citable.

Ajoute à semantic_search.py :

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()

Le cache évite les téléchargements répétés, et splitlines() laisse une liste de 218 chaînes.

Maintenant, préfixe chaque ligne par un ID court et rassemble les lignes en un seul document. Le modèle utilise ces IDs pour désigner sa réponse.

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 ressemble maintenant à ceci :

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

Étape 2 : demander où se trouve la réponse

Une question Choice renvoie une probabilité pour chaque option. Utilise les ids de ligne comme options, et « choisir une option » devient « désigner une ligne ».

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))},
    )

Les descriptions des options sont None parce que le document contient déjà le texte de chaque ID. La requête va dans instructions ; l’état reste inchangé entre les recherches.

Étape 3 : vérifier si une réponse existe

Les probabilités de Choice totalisent toujours 1, donc une ligne arrive en tête même si le document ne répond pas à la question. Le classement à lui seul ne peut pas distinguer une vraie réponse de la ligne non pertinente la plus proche.

Alors pose une seconde question, dans la même requête :

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",
        ),
    )

Contrairement aux probabilités de Choice, la probabilité d’un Noul ne dépend pas des autres options, donc elle peut tomber près de zéro quand le document n’a pas de réponse.

Étape 4 : envoyer les deux questions en une seule requête

La méthode system_one répond aux deux questions en une seule passe. L’état est envoyé une fois, donc ajouter la vérification d’existence ne demande qu’un petit supplément de sortie.

Un document étiqueté et la question d'un utilisateur entrent dans une seule requête TypeSafe. Une question Choice note chaque ligne tandis qu'une question Noul vérifie si une réponse existe. Le code local classe ensuite les lignes et applique le verdict du document.
@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),
    )

La liste relevance conserve un score par ligne, dans l’ordre du document.

Étape 5 : lire le résultat

Deux bouts de code local terminent le travail : verdict() transforme la probabilité brute d’exists en trois états, avec un état intermédiaire pour les réponses partielles, et show() rend relevance sous forme de diagramme à barres pour que le classement soit lisible dans un terminal.

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

Ces seuils séparent les exemples ci-dessous, mais ajuste-les sur tes propres documents avant de les utiliser en production.

Étape 6 : lancer la recherche

Pose deux questions qui ont une réponse directe, une qui n’a pas de réponse et une qui a une réponse partielle : quatre en tout.

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,

Ce que les scores signifient

Les deux premières requêtes renvoient des réponses directes et les lignes sources nécessaires pour les vérifier.

Les deux autres montrent pourquoi la vérification d’existence compte :

  • Arbitrage : Le classement donne à la ligne la plus proche un score de 0.86, mais exists n’est que de 0.14. La réponse n’est pas dans le document.
  • Autorisation parentale : La règle d’âge arrive en tête, mais elle ne répond pas à la question de savoir si l’autorisation parentale change la règle. Le résultat est partiellement traité.

Le classement te dit où regarder ; le score d’exists te dit si le résultat répond à la question.

Essaie-le sur ton propre document

Ouvre le contrat étiqueté dans le playground TypeSafe pour modifier les questions sur le même texte. Pour chercher dans le tien, remplace l’URL dans fetch_document() ; toutes les autres lignes du script partent de LINES.