Joindre un fichier au chatbot — ignore, annote, ou vraiment vu

Onzieme notebook de la serie « AI Engine par son API ». Le precedent (donner-une-memoire-ephemere-au-chatbot) avait cartographie le stockage des pieces jointes : upload, fiche, TTL d’une heure, partition par utilisateur. Mais stocker n’est pas consommer. La question de ce grain est la suite logique : comment un fichier televerse entre-t-il dans une completion ? Un assistant qui recoit un manuscrit, une image de couverture ou un extrait audio doit bien les recevoir quelque part.

La reponse mesuree sur l’instance jetable est un drama en trois actes, et chaque acte est un etat different de la meme piece jointe :

  1. Ignoree — le parametre s’appelle newFileId, pas fileId. Envoyer le mauvais nom rend un 200 parfaitement silencieux : aucune erreur, aucun message, le fichier n’est meme pas regarde.
  2. Annotee puis jetee — avec le bon nom, la couche applicative traite le fichier (son purpose bascule a analysis, sa fiche gagne les metadonnees de session) mais le contenu d’un fichier texte n’entre jamais dans le prompt : le compte de tokens est identique a un tour sans fichier, et le modele le dit honnetement.
  3. Vraiment vue — une image, elle, traverse : encodee en base64 dans le format standard des modeles de vision, elle est reellement decrite par le moteur. La preuve sera un controle negatif : une image bicolore construite par le notebook, dont les couleurs sont connues par construction — une reponse correcte ne peut pas etre devinee.

La serie « AI Engine par son API »

Le projet Livres Agites a mis AI Engine au coeur d’une maison d’edition : bot d’accueil, agents d’ateliers, bibliothecaire documentee par RAG, formulaires dynamiques. Cette serie presente le plugin de maniere reproductible — sans jamais exposer de donnees client :

Notebook Face / objet
presenter-ai-engine-par-son-api socle : instance, API, catalogue, premiere completion
configurer-chatbots-par-l-api admin : chatbots comme documents JSON
administrer-les-formulaires-par-l-api admin : le formulaire comme contenu
piloter-wordpress-par-mcp agent : WordPress serveur MCP
brancher-plusieurs-providers-par-l-api admin : environnements, matrice d’usages
parler-au-chatbot-en-visiteur-par-l-api visiteur : session anonyme, nonce
obtenir-des-donnees-structurees-par-l-api donnees structurees : la case json de la matrice
autour-du-consent-oauth-du-serveur-mcp protocole : OAuth embarque du serveur MCP
interroger-lassistant-de-lediteur-par-l-api editeur : assistant de redaction
donner-une-memoire-ephemere-au-chatbot-par-l-api pieces jointes : stockage, TTL, partition
joindre-un-fichier-au-chatbot-par-l-api ce grain : la consommation — ignore, annote, vu

Prerequis

  • L’instance jetable « Maison Valmont » est demarree (dossier instance-jetable/) ;
  • le fichier instance-jetable/.env existe (jamais commite) ;
  • les uploads fonctionnent (si un 500 « Could not move the file. » apparait, voir la note de maintenance du README de l’instance) ;
  • ce notebook n’a pas besoin de compte : tout se joue sur la face visiteur (session anonyme du sixieme grain) ;
  • aucune donnee reelle : le fichier texte et l’image sont synthetiques, fabriques par le notebook, et detruits en fin de parcours.

L’appel au modele est reel : la description de l’image coute des tokens, et le compte rendu par l’API fait partie des mesures.

# Configuration et helpers. Aucune cle n'est stockee ici : tout vient
# de instance-jetable/.env.

import base64
import io
import json
import re
import struct
import zlib
from pathlib import Path

import requests
from dotenv import load_dotenv
import os

charges = []
for candidat in (Path("instance-jetable/.env"), Path(".env")):
    if candidat.exists():
        load_dotenv(candidat)
        charges.append(str(candidat))
print("Fichiers .env charges :", charges or "(aucun)")

BASE_URL = os.getenv("VALMONT_BASE_URL", "http://localhost:8093").rstrip("/")


def demarrer_session_visiteur():
    """Le seul endpoint public : delivre nonce frais + sessionId."""
    r = requests.post(BASE_URL + "/wp-json/mwai/v1/start_session", json={}, timeout=60)
    r.raise_for_status()
    donnees = r.json()
    return donnees["restNonce"], donnees["sessionId"]


NONCE, SESSION_ID = demarrer_session_visiteur()
print("session visiteur :", SESSION_ID[:8] + "...")


def api_files(action, payload=None, fichiers=None, timeout=120):
    """POST mwai-ui/v1/files/<action> au nonce visiteur."""
    if fichiers is not None:
        r = session.post(BASE_URL + "/wp-json/mwai-ui/v1/files/" + action,
                         headers={"X-WP-Nonce": NONCE}, files=fichiers,
                         data=payload or {}, timeout=timeout)
    else:
        r = session.post(BASE_URL + "/wp-json/mwai-ui/v1/files/" + action,
                         headers={"X-WP-Nonce": NONCE,
                                  "Content-Type": "application/json"},
                         json=payload or {}, timeout=timeout)
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, {"_brut": r.text[:200]}


def api_chat(payload, timeout=300):
    """POST mwai-ui/v1/chats/submit au nonce visiteur."""
    r = session.post(BASE_URL + "/wp-json/mwai-ui/v1/chats/submit",
                     headers={"X-WP-Nonce": NONCE,
                              "Content-Type": "application/json"},
                     json=payload, timeout=timeout)
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, {"_brut": r.text[:200]}


session = requests.Session()
statut, carte = api_files("list")
total_initial = carte.get("data", {}).get("total")
print("total initial :", total_initial)
Fichiers .env charges : ['instance-jetable\\.env']
session visiteur : 2aa021cc...
total initial : 0

Le point de depart est la face visiteur du sixieme grain : start_session delivre un nonce et un identifiant de session, et c’est avec cette identite anonyme que le visiteur possede sa partition de fichiers (le dixieme grain avait mesure cette partition, jusqu’aux sessions anonymes qui ont chacune la leur). La carte est vide au depart — le notebook va la remplir puis la vider, chaque etat verifie par le compteur.

Les deux helpers isolent tout ce qui est repetitif : api_files pour la famille mwai-ui/v1/files/* du grain precedent, api_chat pour chats/submit. Notez qu’ils ne portent aucune credential : le nonce visiteur suffit, il est anti-CSRF, pas une authentification — le controle d’acces reel vit dans la propriete des fichiers, pas dans le transport.

# Mesure 1 : le mauvais nom de parametre.
# On televerse un fichier texte contenant un canary (une phrase que le
# modele ne peut pas inventer), puis on le reference avec fileId — le nom
# qui semblerait naturel.

CANARY = "MON-SECRET-EXTERNE-7341 : le colibri de Valmont dort au nord du jardin."
fichiers = {"file": ("note_canary.txt", io.BytesIO(CANARY.encode("utf-8")), "text/plain")}
statut, rep = api_files("upload", payload={"purpose": "jointure-sondage"}, fichiers=fichiers)
print("upload :", statut)
REFID_TXT = rep.get("data", {}).get("id")
print("refId  :", REFID_TXT)
print("canary :", CANARY)


def fiche_de(refid):
    statut, liste = api_files("list")
    for f in liste.get("data", {}).get("files", []):
        if f.get("refId") == refid:
            return f
    return {}


print("purpose avant :", fiche_de(REFID_TXT).get("purpose"))

payload = {
    "botId": "valmont",
    "newMessage": ("Je te joins un fichier. Cite mot pour mot la premiere ligne "
                   "du fichier joint, sans inventer. Si tu ne disposes pas du "
                   "contenu du fichier, dis-le explicitement."),
    "fileId": REFID_TXT,  # <- le nom plausible... mais pas le bon
    "sessionId": SESSION_ID,
}
statut, rep = api_chat(payload)
print()
print("chats/submit avec {fileId: ...} :", statut)
print("reply :", str(rep.get("reply"))[:160])
print("canary dans la reponse ?", ("colibri" in str(rep.get("reply"))) or ("7341" in str(rep.get("reply"))))
print("prompt_tokens :", rep.get("usage", {}).get("prompt_tokens"))
print("purpose apres :", fiche_de(REFID_TXT).get("purpose"))
upload : 200
refId  : f70aeae66cb8280f7986e47892ac8ee9
canary : MON-SECRET-EXTERNE-7341 : le colibri de Valmont dort au nord du jardin.
purpose avant : jointure-sondage

chats/submit avec {fileId: ...} : 200
reply : Je ne dispose pas du contenu du fichier joint.
Veuillez copier et coller le texte du fichier dans votre message pour que je puisse en extraire la première ligne
canary dans la reponse ? False
prompt_tokens : 105
purpose apres : jointure-sondage

L’API a repondu 200, la completion a tourne, le modele a repondu quelque chose — et rien n’indique que la piece jointe a ete ignoree. C’est le refus le plus silencieux de la serie : les grains precedents decouvraient leurs contrats par des erreurs explicites (400 « Purpose is required. », 400 « Empty message. »), ici le mauvais nom de parametre est simplement… pas lu. Le contrat reel se constate a trois indices croises, tous dans les sorties ci-dessus :

  1. la reponse du modele — il dit ne pas disposer du contenu, ce qu’un modele honnete confirme quand rien ne lui a ete passe ;
  2. le purpose du fichier — il n’a pas bouge : la couche applicative qui traite les jointures ne s’est pas declenchee ;
  3. le compte de tokens — celui d’un tour ordinaire sans fichier.

La route lit newFileId (et sa forme plurielle newFileIds), pas fileId. Un client qui suppose le nom — motif classique d’integration — obtient un chat qui marche, une facture, et un fichier que personne n’a regarde. Aucune exception ne sera levee plus tard : le fichier attendra son TTL d’une heure dans sa partition, joint a rien. La lecon : pour une API muette, la sonde fiable n’est pas le code de statut, c’est l’effet de bord observable (ici la fiche du fichier, qui ne change que si le traitement a reellement eu lieu).

# Mesure 2 : le bon nom de parametre — newFileId.
# Meme fichier, meme question, seul le nom du champ change.

payload = {
    "botId": "valmont",
    "newMessage": ("Je te joins un fichier. Cite mot pour mot la premiere ligne "
                   "du fichier joint, sans inventer. Si tu ne disposes pas du "
                   "contenu du fichier, dis-le explicitement."),
    "newFileId": REFID_TXT,  # <- le nom que la route lit reellement
    "sessionId": SESSION_ID,
}
statut, rep = api_chat(payload)
print("chats/submit avec {newFileId: ...} :", statut)
print("reply :", str(rep.get("reply"))[:160])
print("canary dans la reponse ?", ("colibri" in str(rep.get("reply"))) or ("7341" in str(rep.get("reply"))))
print("prompt_tokens :", rep.get("usage", {}).get("prompt_tokens"))

f = fiche_de(REFID_TXT)
print()
print("fiche apres traitement :")
for cle in ("purpose", "status", "metadata"):
    print(" ", cle, ":", f.get(cle))
chats/submit avec {newFileId: ...} : 200
reply : Je ne dispose pas du contenu du fichier.
Je n'ai pas accès aux pièces jointes ou aux documents que vous pourriez mentionner.
Veuillez copier et coller le texte 
canary dans la reponse ? False
prompt_tokens : 105

fiche apres traitement :
  purpose : analysis
  status : uploaded
  metadata : {'query_envId': '', 'query_session': '862bcbf06463fdbc3293b737a43a9ac0'}

Le refus n’est pas un signal stable. Comparez les deux reponses : meme question, meme fichier, meme compteur de tokens — et pourtant deux formulations differentes. La premiere suggere de copier-coller le texte du fichier dans le message ; la seconde enonce ne pas avoir acces aux pieces jointes. Aucune des deux n’est un message d’erreur systeme : ce sont des generations du modele, et la generation varie. Deux consequences de methode :

  • Ne sondez pas le wording. Un client qui teste son integration en cherchant une phrase precise dans la reponse cassera sans raison apparente au tour suivant. Les champs machine (purpose, prompt_tokens, fiche) sont les seules sondes reproductibles ; le texte, lui, navigue.
  • Un meme refus masque deux etats systeme differents. Avant et apres la correction du nom de parametre, le modele dit a peu pres la meme chose — alors que le systeme a change d’etat entre les deux (la fiche du second tour porte les traces du traitement). Un audit qui ne lirait que les reponses conclurait « rien ne fonctionne » deux fois, et passerait a cote du fait que la seconde tentative a bien ete traitee, puis jetee au routeur de formats.

La variation elle-meme est une observation a deux echantillons : ce run ne mesure pas la variance, il constate qu’elle existe.

Cette fois la couche applicative a travaille — et c’est exactement ce qui rend la mesure interessante. Les sorties ci-dessus montrent une piece jointe dans un etat intermediaire que personne n’aurait invente :

  • le purpose a bascule a analysis : le plugin a bien resolu le refId, reconnu le fichier, marque qu’il sert a une analyse ;
  • la fiche porte des metadonnees de jointure (query_session lie le fichier a la conversation qui l’a reference) ;
  • et pourtant le compte de tokens est celui d’un tour sans fichier, le modele dit ne pas disposer du contenu, et le canary est absent.

Toutes les couches ne promettent pas la meme chose : la couche de stockage a annote, la couche moteur a jete. Le code du moteur chatml est explicite lorsqu’il construit le corps de la requete : seules les images deviennent des parties de contenu, les autres pieces jointes sont passees sous silence (« Skip non-images for Chat Completions API »). La famille est celle du null silencieux du septieme grain — mais la couche fautive change : la lecon precedente etait un parseur qui ne levait jamais d’erreur, celle-ci est un routeur de formats qui ne signale pas ce qu’il ne transmet pas.

Consequence pratique pour une maison d’edition : un chatbot qui recoit un manuscrit par cette route repond sans jamais l’avoir lu, poliment. Le verificateur n’est pas la reponse du modele (elle est honnete), c’est le compte de tokens — la seule mesure qui ne peut pas mentir sur ce qui est entre dans le prompt.

La fiche parle dans le namespace du moteur. La fiche rendue apres traitement porte query_session : 862bcbf0... — or le notebook avait soumis sessionId : 2aa021cc.... Deux identifiants differents pour ce qui semble etre « la » conversation : celui que le client pose dans le payload, et celui que le moteur enregistre dans la fiche. La jointure n’est donc pas tracee dans l’identite que vous manipulez : chats/submit opere la traduction vers sa session interne, et c’est cette derniere qui figure dans les metadonnees. Pour croiser un fichier avec une conversation dans un audit, il faut les deux namespaces — et aucun champ de cette surface ne les relie explicitement : la reponse d’upload rend le refId, la fiche rend la session interne.

Un second champ merite l’attention : query_envId est vide. Le schema prevoit le multi-environnement (les environnements du cinquieme grain), mais cette instance n’en enregistre aucun au moment de la jointure. Slot prevu, valeur absente — un vide informatif : la structure des metadonnees decrit une capacite de routage que ce parcours, instance de demonstration a environnement par defaut, n’exerce pas.

purpose : requis, libre, puis ecrase. Le contrat de ce champ se lit en trois temps dans les sorties. A l’upload, il est obligatoire (le grain precedent a croise le 400 « Purpose is required. »). Mais il n’est pas valide : la valeur posee ici, jointure-sondage, est un tag fabrique pour l’occasion — aucune enumeration ne l’attendait, l’API l’a accepte tel quel. Et au traitement, il est ecrase : le passage a analysis ne garde aucune trace de la valeur client.

Autrement dit : le client nomme, le serveur renomme. La valeur choisie n’a d’effet observable nulle part dans ce parcours — ni dans la reponse, ni dans les metadonnees de jointure. Des usages specialises attendent peut-etre des valeurs precises (les surfaces de consommation evoquees au dernier acte), mais rien dans ce run ne le mesure ; ce qui est prouve est la structure du contrat — champ present, contenu libre, semantique serveur. Conclusion pratique en une ligne : n’encodez pas d’information dans purpose, elle ne survit pas au premier tour qui consomme le fichier.

# Mesure 3 : une image bicolore construite par le notebook.
# Deux couleurs choisies par code, verifiees par un re-decodage independant :
# le contenu de l'image est connu PAR CONSTRUCTION, pas suppose.

LARG, HAUT = 16, 8
GAUCHE = (200, 30, 30)   # rouge profond
DROITE = (30, 60, 200)   # bleu profond


def chunk_png(typ, data):
    return (struct.pack(">I", len(data)) + typ + data
            + struct.pack(">I", zlib.crc32(typ + data) & 0xFFFFFFFF))


def ecrire_png_bicolore(chemin):
    lignes = b""
    for y in range(HAUT):
        ligne = b"\x00"  # filtre 0 : chaque ligne brut
        for x in range(LARG):
            ligne += bytes(GAUCHE if x < LARG // 2 else DROITE)
        lignes += ligne
    ihdr = struct.pack(">IIBBBBB", LARG, HAUT, 8, 2, 0, 0, 0)  # RGB
    png = (b"\x89PNG\r\n\x1a\n" + chunk_png(b"IHDR", ihdr)
           + chunk_png(b"IDAT", zlib.compress(lignes)) + chunk_png(b"IEND", b""))
    Path(chemin).write_bytes(png)


CHEMIN_PNG = "bicolore_valmont.png"
ecrire_png_bicolore(CHEMIN_PNG)

# preuve independante : re-decoder le fichier ecrit et echantillonner deux pixels
brut = Path(CHEMIN_PNG).read_bytes()
pos, idat, dims = 8, b"", None
while pos < len(brut):
    ln, typ = struct.unpack(">I4s", brut[pos:pos + 8])
    data = brut[pos + 8:pos + 8 + ln]
    if typ == b"IHDR":
        dims = struct.unpack(">II", data[:8])
    if typ == b"IDAT":
        idat += data
    pos += 12 + ln
pixels = zlib.decompress(idat)
stride = dims[0] * 3 + 1  # +1 : octet de filtre par ligne


def pixel_a(x, y):
    depart = y * stride + 1 + x * 3
    return tuple(pixels[depart:depart + 3])


print("dimensions :", dims)
print("pixel (2, 4)  attendu", GAUCHE, "->", pixel_a(2, 4))
print("pixel (13, 4) attendu", DROITE, "->", pixel_a(13, 4))

fichiers = {"file": ("bicolore.png", Path(CHEMIN_PNG).read_bytes(), "image/png")}
statut, rep = api_files("upload", payload={"purpose": "jointure-sondage"}, fichiers=fichiers)
print("upload png :", statut)
REFID_PNG = rep.get("data", {}).get("id")
print("refId png  :", REFID_PNG)
dimensions : (16, 8)
pixel (2, 4)  attendu (200, 30, 30) -> (200, 30, 30)
pixel (13, 4) attendu (30, 60, 200) -> (30, 60, 200)
upload png : 200
refId png  : 400d73b44d55797a810d097d44e4be90

Pourquoi construire une image bicolore plutot que de reutiliser un visuel quelconque ? Parce que la question « le modele voit-il vraiment l’image ? » ne se tranche pas avec une reponse plausible. Demandez « decris cette image » a un modele qui n’a rien recu : il decrit quand meme quelque chose de generique. Le sonde du jour l’a confirme par accident — la premiere image de test etait un unique pixel semi-transparent, et la reponse « surface verte claire et unie » etait exacte (le pixel etait vert a moitie opace). Plausible n’est donc pas preuve ; une reponse correcte sur un contenu impossible a deviner l’est.

L’image est fabriquee par le notebook en format PNG ecrit a la main — struct pour les blocs, zlib pour la compression : une image n’est qu’une matrice de pixels encodee, et la garder en stdlib evite toute dependance. Les deux couleurs sont fixees par le code, puis re-decodees depuis le fichier ecrit : deux echantillons, attendus et obtenus. La reponse du modele ne pourra donc pas etre une devinette : nommer les deux bonnes couleurs parmi toutes celles possibles, sans jamais les avoir vues ailleurs que dans l’image.

# Mesure 4 : l'image referencee par newFileId — le modele la decrit-il ?

payload = {
    "botId": "valmont",
    "newMessage": ("L'image jointe est composee d'exactement deux zones de "
                   "couleur. Nomme les deux couleurs dominantes en une phrase "
                   "tres courte."),
    "newFileId": REFID_PNG,
    "sessionId": SESSION_ID,
}
statut, rep = api_chat(payload)
print("chats/submit + newFileId png :", statut)
reponse = str(rep.get("reply"))
print("reply :", reponse[:220])
rouge = bool(re.search(r"rouge", reponse, re.IGNORECASE))
bleu = bool(re.search(r"bleu", reponse, re.IGNORECASE))
print("rouge detecte :", rouge, "| bleu detecte :", bleu)
print("prompt_tokens :", rep.get("usage", {}).get("prompt_tokens"))

f = fiche_de(REFID_PNG)
print("purpose png apres :", f.get("purpose"))
chats/submit + newFileId png : 200
reply : Les deux couleurs dominantes sont le rouge et le bleu.
rouge detecte : True | bleu detecte : True
prompt_tokens : 170
purpose png apres : analysis

105, 105, 170 : l’arithmetique de l’admission. Les trois tours texte mesurent le meme plancher — 105 tokens, quel que soit le destin du fichier (ignore, puis annote-jete). Le tour a l’image monte a 170 : +65 tokens pour 128 pixels. Cette difference se lit comme le prix du format de fil : l’image voyage encodee dans une partie image_url, et ce codage coute ce qu’il coute quelle que soit la scene representee. Deux deductions, etiquetees comme telles :

  • Le delta, pas le total, est la mesure. 105 est le plancher de tout tour (instructions du bot, message, habillage) ; il masque ce que vous voulez savoir. En soustrayant deux tours qui ne different que d’un element, vous isolez le prix de cet element — technique de differenciation reutilisable sur toute surface qui rend un compteur.
  • Etre vu coute plus cher que lire. Le canary texte fait 74 caracteres ; entre dans le prompt comme message, il couterait de l’ordre de sa propre longueur — une quinzaine de tokens, estimation non mesuree ici. transmis comme image, ces 128 pixels en ont coute 65 : le codage du format pese plus que le contenu literal. l’economie du multimodal n’est pas celle du texte, et la grille de facturation est visible dans usage a chaque tour.

L’image, elle, a traverse. La reponse nomme les deux couleurs posees par le code — un contenu impossible a deviner, puisque les valeurs exactes n’apparaissent nulle part dans la conversation. La vision est donc reelle sur cette installation, et c’est la decouverte qui deborde le cadre du plugin : le moteur n’est pas un provider officiel, c’est un endpoint compatible OpenAI auto-heberge. La frontiere du multimodal n’est pas le fournisseur, c’est le format de fil : l’image voyage comme une partie de contenu image_url encodee en base64, le format standard des modeles de vision — et tout serveur dont le modele sait lire ces parties les traitera.

Le compte de tokens raconte la meme histoire vu d’en bas : un tour avec image coute davantage qu’un tour texte, l’ecart etant le prix du codage de l’image. Et la fiche du fichier png confirme le chemin applicatif deja vu avec le texte : purpose bascule aussi — la difference entre les deux destins ne se joue pas dans le stockage (identique), mais dans le routeur de formats du moteur : les images passent, le reste est jete.

# Mesure 5 : le controle negatif du prompt — meme question, SANS fichier.
# Si le modele nommait rouge/bleu sans image, la mesure 4 ne prouverait rien.

payload = {
    "botId": "valmont",
    "newMessage": "L'image jointe est composee d'exactement deux zones de couleur. Nomme les deux couleurs dominantes en une phrase tres courte.",
    "sessionId": SESSION_ID,
}
statut, rep = api_chat(payload)
print("chats/submit SANS fichier :", statut)
reponse_sans = str(rep.get("reply"))
print("reply :", reponse_sans[:220])
print("rouge cite sans image ?", bool(re.search(r"rouge", reponse_sans, re.IGNORECASE)))

print()
print("recapitulatif des trois etats d'une piece jointe :")
lignes = [
    ("fileId (mauvais nom)", "requete acceptee, fichier jamais regarde"),
    ("newFileId + texte", "fichier annote (purpose, session) puis contenu jete au moteur"),
    ("newFileId + image", "fichier annote ET contenu transmis : le modele decrit ce qu'il voit"),
]
for cas, destin in lignes:
    print(" -", cas, "->", destin)
chats/submit SANS fichier : 200
reply : 
rouge cite sans image ? False

recapitulatif des trois etats d'une piece jointe :
 - fileId (mauvais nom) -> requete acceptee, fichier jamais regarde
 - newFileId + texte -> fichier annote (purpose, session) puis contenu jete au moteur
 - newFileId + image -> fichier annote ET contenu transmis : le modele decrit ce qu'il voit

Sans image jointe, la meme question ne produit pas les couleurs — la reponse revient meme completement vide : le moteur a raisonne et son budget s’est consume avant le texte final (comportement deja croise au sixieme grain). Peu importe la forme du silence, il fait le controle : aucune couleur n’est citee, et le controle negatif ferme la porte a l’objection « il aurait pu deviner » — la reponse de la mesure precedente ne pouvait venir que de l’image, et de nulle autre source.

Le tableau recapitulatif donne au grain sa lecon compacte : une piece jointe a trois destins possibles, et aucun ne se lit sur le code de statut de la requete. Le premier depend du nom du parametre (contrat de route), le deuxieme du format du fichier (contrat de moteur), le troisieme de la capacite reelle du modele a lire ce format. Trois couches, trois responsabilites — et pour l’integrateur, trois sondes differentes : la fiche du fichier pour la premiere, le compte de tokens pour la deuxieme, un contenu non devinable pour la troisieme.

Reste une question honnete : a quoi sert le stockage du grain precedent, si le texte joint n’entre pas dans le prompt ? Il sert aux surfaces qui savent le consommer — les assistants OpenAI (file search), la transcription audio, les usages a venir du miroir admin — et le TTL d’une heure garde tout ce qui n’est pas consomme de s’accumuler. Stocker d’abord, router ensuite : la partition et le TTL sont l’infrastructure du multimodal, pas sa promesse.

# Cleanup : les deux fichiers repartent, la carte revient a zero.

statut, rep = api_files("delete", payload={"files": [REFID_TXT, REFID_PNG]})
print("delete :", statut, "->", rep)
Path(CHEMIN_PNG).unlink(missing_ok=True)

statut, carte = api_files("list")
print("total final :", carte.get("data", {}).get("total"))
delete : 200 -> {'success': True, 'deleted': 2}
total final : 0

La carte est revenue a son etat initial — meme discipline de sortie que le grain precedent, et pour la meme raison : l’instance doit rester propre pour le lecteur suivant.

Ce grain ferme la boucle ouverte par le stockage : une piece jointe ne vaut que par ce qu’un moteur en fait. Le fil de la serie croise ici trois lecons anterieures — le contrat decouvert par refus (grains 3 et 9), le silence des couches qui ne signalent pas ce qu’elles droppent (grain 7), et la mesure par effet de bord observable plutot que par code de statut (grain 10). La nouveaute est la frontiere elle-meme : le multimodal d’une API compatible OpenAI est une question de format de fil, pas de provider — et la preuve honnete d’une capacite de vision n’est jamais une reponse plausible, c’est un contenu impossible a deviner. Pour l’autrice qui joint son manuscrit, cela veut dire deux choses tres concretes : verifiez le nom du champ avant d’ecrire un client, et ne concluez jamais « il a lu mon fichier » sans avoir croise le compte de tokens avec un contenu que le modele ne pouvait pas connaitre d’avance.

Exercices

Trois experiences restent a mener, chacune prolonge une mesure de ce notebook. Les squelettes ci-dessous s’executent sans rien casser ; a vous d’ecrire le corps.

# Exercice 1 — un newFileId inconnu.
# La mesure 1 a montre qu'un mauvais NOM de parametre est ignore
# silencieusement. Question : une VALEUR inconnue (un refId qui n'existe
# pas, ou le refId d'un fichier supprime) produit-elle une erreur, ou le
# meme silence ? Demandez au modele de citer le fichier, relevez le code
# de statut, la reponse et le prompt_tokens, et comparez aux deux
# premieres mesures.

def refid_inconnu():
    """Soumet un tour avec newFileId = 'refid-qui-n-existe-pas' et
    retourne (statut, reponse, prompt_tokens)."""
    pass


# statut, reponse, tokens = refid_inconnu()
# Exercice 2 — la jointure d'une autre session.
# Le grain precedent a montre que la partition fichiers est par
# utilisateur (sessions anonymes incluses). Question : si la session B
# reference le fichier de la session A via newFileId, que se passe-t-il ?
# Refus explicite, ou annote-jete de nouveau ? Ouvrez une deuxieme
# session (nouveau start_session), televersez depuis A, referencez
# depuis B, et croisez : reponse, prompt_tokens, purpose du fichier.

def jointure_cross_session():
    """Retourne le destin croise du fichier de A reference par B :
    (statut, reponse, purpose_apres)."""
    pass


# resultat = jointure_cross_session()
# Exercice 3 — l'image a deviner.
# Generalisez le controle negatif : construisez une image a TROIS bandes
# verticales de trois couleurs de votre choix (reutilisez
# ecrire_png_bicolore comme modele), televersez-la, demandez les couleurs
# nommees dans l'ordre gauche-droite, et verifiez par regex que la
# reponse cite les trois dans l'ordre. Que se passe-t-il si deux bandes
# ont des couleurs proches (bleu / bleu ciel) ?

def image_trois_bandes(couleurs, chemin):
    """Ecrit un PNG de trois bandes verticales et retourne le chemin."""
    pass


# image_trois_bandes([(200,30,30), (30,200,60), (30,60,200)], "tricolore.png")

Onzieme notebook de la serie « AI Engine par son API » — suite et fin du dossier pieces jointes ouvert par la memoire ephemere. Tour suivant : la veille des surfaces restantes du catalogue.

Retour au sommet