Fins ara, tot el que DocuBot "ha respost" ho hem escrit nosaltres sobre el paper: prompts dissenyats al mòdul 2 i respostes esperades contra les quals avaluar. En aquesta lliçó el sistema pren vida: prepararem un entorn Python net, farem la primera crida real a l'API d'un LLM enviant els rols system/user que vam dissenyar a 02-01, disseccionarem la resposta que retorna el proveïdor (contingut, motiu d'aturada, consum de tokens) i connectarem el parser JSON defensiu de 02-03 a una resposta de debò. Ho exemplificarem amb l'SDK d'Anthropic, però els conceptes —missatges amb rols, límit de sortida, objecte d'ús— són equivalents en qualsevol proveïdor, i tancarem amb una taula que ho demostra.

Contingut

  1. Del paper a l'API: què construirem
  2. Preparar l'entorn: venv, SDK i clau d'API
  3. La primera crida: system + user amb els artefactes de prompts.py
  4. Anatomia de la resposta: contingut, stop_reason i usage
  5. Paràmetres principals: model, max_tokens i temperature
  6. Connectar el parser JSON de 02-03 a una resposta real
  7. Equivalència de conceptes entre proveïdors

Del paper a l'API: què construirem

A 01-03 vam prendre una decisió d'arquitectura que ara es materialitza: API gestionada, model de gamma mitjana i temperature baixa. Això vol dir que no instal·larem ni servirem cap model; enviarem peticions HTTP a un proveïdor i rebrem respostes. L'SDK del proveïdor s'encarrega del transport, l'autenticació i la serialització, i nosaltres ens concentrem en el que ja tenim: SYSTEM_DOCUBOT, les plantilles de prompts.py (versió 1.2) i el contracte JSON amb la seva validació defensiva.

L'objectiu d'aquesta lliçó és un mòdul client.py que, donada una pregunta i un bloc de documentació, retorni el diccionari validat {"categoria", "resposta", "fonts", "confianca"}. És la primera peça "viva" de DocuBot.

Preparar l'entorn: venv, SDK i clau d'API

Treballarem en un entorn virtual per aïllar dependències de la resta del sistema:

# Crear i activar l'entorn virtual
python -m venv .venv

# Linux / macOS
source .venv/bin/activate
# Windows (PowerShell)
.venv\Scripts\Activate.ps1

# Instal·lar l'SDK del proveïdor (exemple: Anthropic)
pip install anthropic

# Congelar dependències per a reproduïbilitat
pip freeze > requirements.txt

La clau d'API mai no s'escriu al codi ni es puja al repositori. Es defineix com a variable d'entorn, que és on l'SDK la busca automàticament:

# Linux / macOS
export ANTHROPIC_API_KEY="la-teva-clau-aqui"

# Windows (PowerShell)
$env:ANTHROPIC_API_KEY = "la-teva-clau-aqui"

Bones pràctiques des del primer dia:

  • Variable d'entorn o gestor de secrets, mai una cadena al codi. Una clau filtrada en un commit és un incident de seguretat (i de facturació: qui la tingui gasta la teva quota).
  • Afegeix .venv/ i qualsevol fitxer .env al .gitignore.
  • Fes servir una clau diferent per a desenvolupament i per a producció; així en pots revocar una sense afectar l'altra.

La primera crida: system + user

Creem client.py reutilitzant els artefactes del mòdul 2 tal qual. Fixa't que el system prompt viatja en un paràmetre propi, separat de la llista de missatges — exactament la separació de rols que vam estudiar a 02-01:

# client.py
import os
import anthropic

from prompts import SYSTEM_DOCUBOT, prompt_resposta, PROMPT_VERSION

# L'identificador exacte del model depèn del proveïdor i del moment;
# el llegim d'una variable d'entorn amb un placeholder com a valor per defecte.
MODEL = os.environ.get("DOCUBOT_MODELO", "claude-...")

# El client llegeix ANTHROPIC_API_KEY de l'entorn: no passem la clau a mà.
client = anthropic.Anthropic()

def preguntar_docubot_brut(pregunta: str, documentacio: str):
    """Envia una pregunta a l'API i retorna la resposta completa (sense processar)."""
    return client.messages.create(
        model=MODEL,
        max_tokens=1024,
        temperature=0.2,          # baixa: respostes consistents (decisió de 01-03)
        system=SYSTEM_DOCUBOT,    # el system prompt v1.2 dissenyat a 02-01
        messages=[
            {
                "role": "user",
                "content": prompt_resposta(pregunta, documentacio),
            }
        ],
    )

Desglossament línia a línia:

  • anthropic.Anthropic() crea el client. No li passem la clau: la resol des d'ANTHROPIC_API_KEY. Si no existeix, la crida fallarà amb un error d'autenticació — millor això que una clau escrita al fitxer.
  • model=MODEL indica quin model respon. Fem servir un placeholder tipus "claude-..." perquè els identificadors concrets canvien amb el temps; al teu projecte fixa el que vas triar a 01-03.
  • system=SYSTEM_DOCUBOT envia la identitat, les regles anti-al·lucinació i el to de DocuBot. En aquest proveïdor és un paràmetre de primer nivell; en d'altres va com un missatge més amb rol system (ho veurem a la taula final).
  • messages és la llista de torns de conversa. De moment un únic torn user, el contingut del qual genera prompt_resposta(): la pregunta de l'empleat més el bloc <documentacio> que exigeix la regla 2 del system prompt.
  • max_tokens=1024 és el topall de tokens de sortida: la resposta mai no serà més llarga que això. És obligatori en aquesta API i és el teu primer fre de cost.

Ho provem amb dades fictícies de Nubelia:

if __name__ == "__main__":
    documentacio = """## Exportar un projecte
Per exportar un projecte a CSV: Projecte > Configuració > Exportar > CSV.
L'exportació inclou tasques, responsables i dates. Disponible als
plans Pro i Business."""

    pregunta = "Com exporto el meu projecte a CSV?"

    resposta = preguntar_docubot_brut(pregunta, documentacio)
    print(resposta)

Si tot està ben configurat, en executar python client.py veuràs per primera vegada una resposta generada pel model amb el teu system prompt. Val la pena aturar-s'hi un segon: les regles que vas escriure a 02-01 estan governant, ara mateix, la sortida d'un model real.

Anatomia de la resposta

L'objecte que retorna l'API conté molt més que el text. Els seus camps importants:

resposta = preguntar_docubot_brut(pregunta, documentacio)

# 1. El contingut: una LLISTA de blocs, no un string directe
for bloc in resposta.content:
    if bloc.type == "text":
        print(bloc.text)

# 2. Per què va deixar de generar
print(resposta.stop_reason)      # p. ex. "end_turn"

# 3. Quants tokens ha consumit (això és la factura!)
print(resposta.usage.input_tokens)   # tokens d'entrada (system + user)
print(resposta.usage.output_tokens)  # tokens generats

# 4. Metadades
print(resposta.model)            # el model que ha respost realment
print(resposta.id)               # identificador únic de la petició

Tres detalls que convé interioritzar:

  • content és una llista de blocs. Avui només conté blocs de text, però el mateix camp transporta altres tipus (crides a eines al mòdul 5, per exemple). Acostuma't des d'ara a filtrar per bloc.type == "text" en comptes d'accedir a content[0].text a cegues.
  • stop_reason et diu per què va acabar la generació. Els valors principals:
stop_reason Significat Què fer
end_turn El model ha acabat de manera natural Cas normal, processar la resposta
max_tokens S'ha esgotat el topall de sortida La resposta està tallada: puja'l o demana sortides més breus
stop_sequence Ha aparegut una seqüència d'aturada definida per tu Processar fins a aquell punt
tool_use El model vol cridar una eina Ho veurem al mòdul 5

Per a DocuBot, max_tokens és especialment perillós: un JSON tallat a mitges mai no validarà. Comprovar stop_reason abans de parsejar evita perdre temps depurant "JSON invàlid" quan el problema real era el topall de sortida.

  • usage és el teu comptador de cost. Entrada i sortida es facturen per separat i a preus diferents. A 03-04 construirem l'estimació de costos sobre aquests dos números; de moment, imprimeix-los a cada prova per anar desenvolupant intuïció de quant "pesa" cada crida.

Una funció auxiliar que farem servir durant tot el curs:

def extreure_text(resposta) -> str:
    """Concatena els blocs de text d'una resposta de l'API."""
    return "".join(
        bloc.text for bloc in resposta.content if bloc.type == "text"
    )

Paràmetres principals

Els tres paràmetres que ajustaràs constantment:

Paràmetre Què controla Guia per a DocuBot
model Quin model respon (capacitat, velocitat, preu) Gamma mitjana per a respostes; a 03-04 veurem que el classificador pot fer servir un de menor
max_tokens Topall dur de tokens de sortida 1024 sobra per a respostes de <150 paraules + JSON; és fre de cost i de descontrol
temperature Aleatorietat del mostreig (0 = molt determinista, 1 = molt variat) Baixa (0.0–0.3): DocuBot ha de ser consistent, no creatiu

Sobre temperature, dos matisos honestos:

  • Temperature baixa no és determinisme total. Com vam veure a 01-04 (no determinisme), dues crides idèntiques poden diferir lleugerament fins i tot a temperature 0. Redueix la variància; no l'elimina.
  • Alguns models recents d'alguns proveïdors fixen el mostreig internament i rebutgen el paràmetre temperature amb un error de petició invàlida. Consulta la documentació del model concret que facis servir; si el rebutja, simplement omet el paràmetre i controla l'estil mitjançant el prompt.

Connectar el parser JSON de 02-03 a una resposta real

A 02-03 vam dissenyar el contracte {"categoria", "resposta", "fonts", "confianca"} i la validació defensiva (netejar_json, json.loads amb try/except, defaults conservadors, RESPOSTA_FALLBACK). Tot allò es va escriure contra respostes hipotètiques; ara ho endollem a la sortida real:

import json
from prompts import RESPOSTA_FALLBACK
from validacio import netejar_json, validar_contracte  # el mòdul escrit a 02-03

def preguntar_docubot(pregunta: str, documentacio: str) -> dict:
    """Crida completa: API -> text -> JSON validat amb el contracte de 02-03."""
    resposta = preguntar_docubot_brut(pregunta, documentacio)

    # Un JSON tallat per max_tokens mai no parsejarà: detectar-ho aviat i clar.
    if resposta.stop_reason == "max_tokens":
        return RESPOSTA_FALLBACK

    text = extreure_text(resposta)
    try:
        dades = json.loads(netejar_json(text))   # treu ```json ... ``` i soroll
    except (json.JSONDecodeError, TypeError):
        return RESPOSTA_FALLBACK

    # Defaults conservadors per a camps absents o invàlids (02-03)
    return validar_contracte(dades)

La primera vegada que executis això contra el model real és probable que descobreixis coses que el paper no mostrava: respostes embolcallades amb ```json, un camp confianca amb un valor fora de l'enumerat, una llista fonts que arriba com a string. Exactament per a això vam construir la validació defensiva — i per això netejar_json i els defaults no eren paranoia, sinó enginyeria.

Sortida estructurada nativa. Com vam anunciar a 02-03, els proveïdors ofereixen modes que garanteixen que la sortida compleix un esquema JSON: a Anthropic es declara un esquema a la petició (output_config amb json_schema), a OpenAI existeix l'equivalent amb response_format/esquemes estrictes i a Gemini amb response_schema. Quan el teu proveïdor i model ho suportin, activa-ho: converteix el "gairebé sempre és JSON vàlid" en "sempre ho és". Tot i així, mantén la validació defensiva: l'esquema garanteix la forma, no el contingut (una confianca: "alta" en una resposta inventada continua sent un problema d'avaluació, no de format), i el teu codi continuarà sent portable entre proveïdors.

Equivalència de conceptes entre proveïdors

Tot l'anterior existeix, amb altres noms, en qualsevol proveïdor seriós. A nivell de conceptes (no de xifres ni d'identificadors concrets):

Concepte Anthropic OpenAI Google Gemini
SDK Python anthropic openai google-genai
Clau d'API ANTHROPIC_API_KEY (env) OPENAI_API_KEY (env) GOOGLE_API_KEY (env)
System prompt Paràmetre system separat Missatge amb rol system/developer a la llista system_instruction a la configuració
Torns de conversa messages amb rols user/assistant messages amb rols user/assistant contents amb rols user/model
Topall de sortida max_tokens Paràmetre de límit de tokens de sortida max_output_tokens
Consum de tokens usage.input_tokens / usage.output_tokens Objecte usage (prompt / completion) usage_metadata
Motiu d'aturada stop_reason finish_reason finish_reason
Sortida estructurada Esquema JSON a la petició response_format / esquemes estrictes response_schema

La lliçó d'aquesta taula: aprèn els conceptes, no els noms. Si demà Nubelia canvia de proveïdor, el 90% del teu codi (plantilles, validació, avaluació, gestió d'errors) no canvia; només la fina capa que parla amb l'SDK. Per això concentrem tota la interacció amb l'API a client.py — un únic fitxer per reescriure si arriba aquell dia.

El que no hem tocat avui, deliberadament: la resposta arriba de cop al cap d'uns quants segons (streaming, 03-02), si la xarxa falla l'excepció explota sense control (errors i reintents, 03-02), cada crida comença de zero sense recordar res (converses, 03-03) i no sabem quant costa això al mes (costos, 03-04).

Errors Comuns i Consells

  • Hardcodejar la clau d'API "només per provar". Aquell "només per provar" acaba en un commit. Variable d'entorn des del minut u, sense excepcions.
  • Accedir a resposta.content[0].text sense comprovar el tipus de bloc. Funciona avui; es trenca tan bon punt el model retorni un altre tipus de bloc. Fes servir sempre el filtratge per bloc.type.
  • Ignorar stop_reason. El símptoma típic: "el parser JSON falla aleatòriament". La causa real: respostes tallades per max_tokens. Comprova el motiu d'aturada abans de parsejar.
  • Oblidar que max_tokens limita la sortida, no l'entrada. L'entrada està limitada per la finestra de context del model (01-04); són límits diferents amb errors diferents.
  • Reinventar el parser a cada script. Els artefactes de 02-03 (netejar_json, validar_contracte, RESPOSTA_FALLBACK) són mòduls importables. Un únic punt de validació, un únic lloc per corregir.
  • Consell: imprimeix usage a cada prova durant el desenvolupament. En dos dies tindràs una intuïció sòlida de quants tokens consumeix cada tipus de pregunta — i 03-04 et resultarà molt més natural.
  • Consell: executa contra l'API el conjunt d'avaluació de 02-04 (o una mostra de 10 preguntes) tan bon punt tinguis preguntar_docubot() funcionant. És la teva primera avaluació amb respostes reals, i la base de comparació per a tot el que optimitzis després.

Exercicis

Exercici 1

Escriu una funció diagnostic_crida(resposta) que rebi l'objecte de resposta de l'API i retorni un diccionari amb: text (contingut concatenat), tallada (booleà: True si stop_reason == "max_tokens"), tokens_entrada, tokens_sortida i model. Prova-la amb una crida real i amb max_tokens=20 per forçar el tall.

Exercici 2

Modifica preguntar_docubot_brut() perquè accepti un paràmetre opcional temperature (per defecte 0.2) i fes 3 crides amb la mateixa pregunta a temperature 0.0 i 3 més a 0.9. Compara les sortides: es compleix el que vam estudiar a 02-04 sobre la necessitat de diverses execucions per cas en comparar?

Exercici 3

El classificador de suport de 02-02 (prompt_classificacio() amb EXEMPLES_CLASSIFICACIO) encara no ha tocat l'API. Escriu classificar_consulta(text: str) -> str que l'enviï amb max_tokens=20 i temperature 0.0, i retorni una de les 6 categories (facturacio, permisos, integracions, api, incidencies, altres). Si la sortida del model no coincideix exactament amb cap categoria, retorna "altres" (default conservador, com a 02-03).

Solucions

Solució 1:

def diagnostic_crida(resposta) -> dict:
    return {
        "text": "".join(
            b.text for b in resposta.content if b.type == "text"
        ),
        "tallada": resposta.stop_reason == "max_tokens",
        "tokens_entrada": resposta.usage.input_tokens,
        "tokens_sortida": resposta.usage.output_tokens,
        "model": resposta.model,
    }

Amb max_tokens=20 veuràs tallada: True i un text interromput a mitja frase: exactament el cas que preguntar_docubot() intercepta abans de parsejar.

Solució 2:

def preguntar_docubot_brut(pregunta, documentacio, temperature=0.2):
    return client.messages.create(
        model=MODEL,
        max_tokens=1024,
        temperature=temperature,
        system=SYSTEM_DOCUBOT,
        messages=[{"role": "user", "content": prompt_resposta(pregunta, documentacio)}],
    )

for t in (0.0, 0.9):
    print(f"--- temperature={t} ---")
    for i in range(3):
        r = preguntar_docubot_brut(pregunta, documentacio, temperature=t)
        print(f"[{i+1}]", extreure_text(r)[:120])

A 0.0 les tres sortides seran gairebé idèntiques (gairebé: no determinisme de 01-04); a 0.9 variaran visiblement. Conclusió pràctica: comparar prompts amb una sola execució per cas és soroll, no mesura — per això 02-04 va fixar 3 execucions per cas.

Solució 3:

from prompts import prompt_classificacio

CATEGORIES = {"facturacio", "permisos", "integracions", "api", "incidencies", "altres"}

def classificar_consulta(text: str) -> str:
    resposta = client.messages.create(
        model=MODEL,
        max_tokens=20,
        temperature=0.0,
        messages=[{"role": "user", "content": prompt_classificacio(text)}],
    )
    sortida = extreure_text(resposta).strip().lower()
    return sortida if sortida in CATEGORIES else "altres"

Dues decisions heretades del mòdul 2: temperature 0.0 perquè classificar exigeix consistència, i default "altres" perquè davant del dubte és millor una categoria genèrica que una d'equivocada amb aparença de segura.

Conclusió

DocuBot ja no és paper: tens un entorn reproduïble amb l'SDK instal·lat i la clau fora del codi, una primera crida real que envia el SYSTEM_DOCUBOT de 02-01 i les plantilles de prompts.py, i el criteri per llegir el que torna — el contingut com a llista de blocs, stop_reason com a sentinella (especialment max_tokens abans de parsejar) i usage com a comptador de la factura. El parser defensiu de 02-03 processa ara respostes de debò, reforçat amb els modes de sortida estructurada natius quan el proveïdor els ofereixi, i la taula d'equivalències et garanteix que res d'això no et lliga a un proveïdor concret. Però la nostra integració és ingènua en un aspecte que l'usuari nota a l'instant i en un altre que notarà el pitjor dia de l'any: la resposta triga uns quants segons a aparèixer de cop, i si l'API retorna un error 429 o un 500, DocuBot simplement explota. A la propera lliçó resolem tots dos: streaming perquè les paraules flueixin a mesura que es generen, i una estratègia seriosa d'errors i reintents perquè DocuBot es degradi amb elegància en comptes de trencar-se.

© Copyright 2026. Tots els drets reservats