Documentación

Búsqueda línea por línea

Construye búsqueda semántica para los Términos de Servicio de GitHub. En una sola solicitud, puntúa 218 ids de línea contra una consulta en lenguaje natural con una pregunta de Choice, y usa una pregunta de Noul para comprobar si el documento contiene una respuesta.

Tienes los Términos de Servicio de GitHub y una pregunta en lenguaje natural sobre ellos. Necesitas las líneas que responden a la pregunta y una forma de detectar cuándo el documento no tiene respuesta. Las consultas incluidas ordenan primero las líneas con respuestas directas. Los umbrales de exists clasifican el resto de los casos como ausentes o parciales. Terminas con find(), que devuelve la probabilidad de exists y una puntuación de relevancia por línea.

Una consulta recorre un documento y revela una respuesta adjunta a la línea
que coincide

El backend de búsqueda se arma en tres partes:

  1. Etiqueta cada línea con un ID para que TypeSafe pueda señalarla.
  2. Usa una pregunta Choice para ordenar esos ids de línea según lo bien que responden a la consulta. Las probabilidades de una pregunta Choice siempre suman 1, así que una línea queda primera aunque ninguna responda a la consulta.
  3. En la misma solicitud, usa una pregunta Noul para comprobar si el documento contiene alguna respuesta.

Preparación

Obtener una clave de API de TypeSafe

Crea una clave en la consola de TypeSafe y expórtala:

export TYPESAFE_API_KEY="your-key-here"

Instalar las dependencias

pip install 'cooksafe>=0.2.0,<0.3.0'

JsonCache reproduce las respuestas de API incluidas, así que los pasos de abajo se ejecutan sin clave de API ni gasto alguno. Para hacer las solicitudes en vivo, define TYPESAFE_API_KEY y elimina json_cache.json.

Crear el script

Empieza semantic_search.py con las importaciones y el cliente:

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

Paso 1: etiquetar cada línea con un ID

El documento de prueba son los Términos de Servicio de GitHub, divididos en 218 cláusulas, para que cada resultado de búsqueda apunte a una línea citable.

Añade a 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()

La caché evita descargas repetidas, y splitlines() deja una lista de 218 cadenas.

Ahora antepón a cada línea un ID corto y vuelve a unir las líneas en un solo documento. El modelo usa estos IDs para señalar su respuesta.

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 ahora se ve así:

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

Paso 2: preguntar dónde está la respuesta

Una pregunta Choice devuelve una probabilidad para cada opción. Usa los ids de línea como opciones, y «elegir una opción» se convierte en «señalar una línea».

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

Las descripciones de las opciones son None porque el documento ya contiene el texto de cada ID. La consulta va en instructions; el estado no cambia entre búsquedas.

Paso 3: comprobar si existe una respuesta

Las probabilidades de Choice siempre suman 1, así que alguna línea queda primera aunque el documento no responda a la pregunta. Solo con el orden no se puede distinguir una respuesta real de la línea irrelevante más cercana.

Así que haz una segunda pregunta, en la misma solicitud:

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

A diferencia de las probabilidades de Choice, la probabilidad de Noul no depende de las demás opciones, así que puede caer cerca de cero cuando el documento no tiene respuesta.

Paso 4: enviar ambas preguntas en una sola solicitud

El método system_one responde a ambas preguntas en una sola pasada. El estado se envía una vez, así que añadir la comprobación de existencia solo requiere una pequeña cantidad de salida extra.

Un documento etiquetado y una pregunta del usuario entran en una sola solicitud de TypeSafe. Una pregunta Choice puntúa
cada línea mientras una pregunta Noul comprueba si existe una respuesta. Luego el código local ordena
las líneas y aplica el veredicto del documento.
@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 lista relevance guarda una puntuación por línea, en el orden del documento.

Paso 5: leer el resultado

Dos fragmentos de código local terminan el trabajo: verdict() convierte la probabilidad bruta de exists en tres estados, con uno intermedio para respuestas parciales, y show() representa relevance como un gráfico de barras para que el orden sea legible en una 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

Estos umbrales separan los ejemplos de abajo, pero ajústalos con tus propios documentos antes de usarlos en producción.

Paso 6: ejecutar la búsqueda

Haz dos preguntas que tienen respuesta directa, una que no tiene respuesta y una que tiene una respuesta parcial: cuatro en total.

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,

Qué significan las puntuaciones

Las dos primeras consultas devuelven respuestas directas y las líneas de origen necesarias para verificarlas.

Las otras dos muestran por qué importa la comprobación de existencia:

  • Arbitraje: El orden da a la línea más cercana una puntuación de 0.86, pero exists es solo 0.14. La respuesta no está en el documento.
  • Permiso parental: La regla de edad queda primera, pero no responde si el permiso parental cambia la regla. El resultado es abordado parcialmente.

El orden te dice dónde mirar; la puntuación de exists te dice si el resultado responde a la pregunta.

Pruébalo con tu propio documento

Abre el contrato etiquetado en el playground de TypeSafe para editar las preguntas sobre el mismo texto. Para buscar en el tuyo, cambia la URL en fetch_document(); todas las demás líneas del script parten de LINES.