Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
+97
-65
@@ -2,12 +2,14 @@
|
||||
UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base :
|
||||
tout ce qu'un tour de chat produit est donc TOUJOURS relisable/
|
||||
modifiable dans l'éditeur normal (même garantie que la Phase 1)."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
from .client import get_client, MODEL
|
||||
from .client import MODEL, get_client
|
||||
from .tools import TOOLS, dispatch_tool
|
||||
|
||||
# Borne dure : jamais une boucle sans fin qui dépenserait sans fin si
|
||||
@@ -25,10 +27,10 @@ _SYSTEM_PROMPT = (
|
||||
"Réutilise une variable globale déjà existante UNIQUEMENT si elle "
|
||||
"représente EXACTEMENT la même information (ex. un score total "
|
||||
"partagé par tout le jeu) — les variables sont globales à TOUT le "
|
||||
"jeu, visibles depuis n'importe quel écran, donc un drapeau \"terminé\" "
|
||||
'jeu, visibles depuis n\'importe quel écran, donc un drapeau "terminé" '
|
||||
"propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un "
|
||||
"autre dialogue/quiz, même similaire, même sur un autre écran (bug "
|
||||
"corrigé : un quiz réutilisait par erreur le drapeau \"terminé\" d'un "
|
||||
'corrigé : un quiz réutilisait par erreur le drapeau "terminé" d\'un '
|
||||
"AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à "
|
||||
"chaque nouveau drapeau un nom qui identifie clairement CE qu'il "
|
||||
"suit (ex. préfixé par le nom du personnage/objet concerné). "
|
||||
@@ -40,10 +42,10 @@ _SYSTEM_PROMPT = (
|
||||
"CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique "
|
||||
"\"déclencheur -> action\" d'un objet de scène OU de l'écran entier "
|
||||
"(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE "
|
||||
"système, séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, "
|
||||
'système, séparé, de "flow" (nœuds/liaisons) pour changer d\'écran, '
|
||||
"les minuteurs récurrents, une animation de sprite — tu n'as AUCUN "
|
||||
"outil pour ça. Si une demande a besoin de cette partie-là (\"passer "
|
||||
"à l'écran suivant\", \"après 5 secondes\"...), fais quand même tout "
|
||||
'outil pour ça. Si une demande a besoin de cette partie-là ("passer '
|
||||
'à l\'écran suivant", "après 5 secondes"...), fais quand même tout '
|
||||
"ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse "
|
||||
"texte ce que tu n'as pas pu faire et pourquoi (cette partie doit "
|
||||
"être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS "
|
||||
@@ -51,29 +53,29 @@ _SYSTEM_PROMPT = (
|
||||
"permet, ça n'aboutira jamais."
|
||||
"\n\n"
|
||||
"DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : "
|
||||
"\"collision\" (contact avec le personnage \"joueur\" — a besoin d'un "
|
||||
"joueur, voir JOUEUR ET PNJ), \"clic\" (l'objet est cliqué/touché, "
|
||||
'"collision" (contact avec le personnage "joueur" — a besoin d\'un '
|
||||
'joueur, voir JOUEUR ET PNJ), "clic" (l\'objet est cliqué/touché, '
|
||||
"aucun joueur requis — typique d'un panneau, un bouton, un objet "
|
||||
"d'interface statique), \"survol\" (le pointeur survole l'objet, "
|
||||
"aucun joueur requis — typique d'une info contextuelle affichée sans "
|
||||
"action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS "
|
||||
"objet requis) : \"affichage\" — se déclenche dès que l'écran "
|
||||
'objet requis) : "affichage" — se déclenche dès que l\'écran '
|
||||
"apparaît, pour une narration/cinématique d'ouverture. "
|
||||
"RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel "
|
||||
"déclencheur utiliser pour un élément donné (ex. \"ajoute un panneau "
|
||||
'déclencheur utiliser pour un élément donné (ex. "ajoute un panneau '
|
||||
"d'information\" sans dire si c'est au clic, au survol, ou dès "
|
||||
"l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta "
|
||||
"réponse texte avant d'agir. "
|
||||
"NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, "
|
||||
"chaînables via \"then\" comme dialogue/variable) : \"surbrillance\" "
|
||||
'chaînables via "then" comme dialogue/variable) : "surbrillance" '
|
||||
"(met un objet en valeur, ex. pour guider l'attention du joueur vers "
|
||||
"la suite), \"visibilite\" (affiche/masque un objet, ex. débloquer un "
|
||||
"élément), \"son\" (effet sonore ponctuel), \"video\" (joue une vidéo "
|
||||
"de \"Mes assets\", en plein écran ou en incrustation — bloque la "
|
||||
"suite de la chaîne jusqu'à la fin, comme un dialogue), \"indication\" "
|
||||
'la suite), "visibilite" (affiche/masque un objet, ex. débloquer un '
|
||||
'élément), "son" (effet sonore ponctuel), "video" (joue une vidéo '
|
||||
'de "Mes assets", en plein écran ou en incrustation — bloque la '
|
||||
'suite de la chaîne jusqu\'à la fin, comme un dialogue), "indication" '
|
||||
"(bulle de texte courte près d'un objet, pour un conseil ponctuel — "
|
||||
"PAS pour une réplique de personnage, utilise \"dialogue\" pour ça), "
|
||||
"\"attendre\" (suspend la chaîne \"then\" pendant data_value SECONDES "
|
||||
'PAS pour une réplique de personnage, utilise "dialogue" pour ça), '
|
||||
'"attendre" (suspend la chaîne "then" pendant data_value SECONDES '
|
||||
"avant de continuer — utile pour laisser un temps de lecture après "
|
||||
"une narration à l'affichage de l'écran, avant d'enchaîner sur un "
|
||||
"dialogue/une autre action)."
|
||||
@@ -97,13 +99,13 @@ _SYSTEM_PROMPT = (
|
||||
"VISIBLE (coin haut-gauche à (0,0)) — place tout objet important "
|
||||
"(personnages, widgets d'interface) DANS cette zone par défaut. Le "
|
||||
"moteur RAMÈNE automatiquement dans le cadre toute position qui en "
|
||||
"sortirait (voir un éventuel champ \"note\" dans le résultat de "
|
||||
'sortirait (voir un éventuel champ "note" dans le résultat de '
|
||||
"set_object_geometry/add_scene_object — c'est déjà corrigé, rien à "
|
||||
"refaire), donc vise une position raisonnable sans stresser sur le "
|
||||
"pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées "
|
||||
"— espace-les clairement. Un personnage fraîchement posé fait "
|
||||
"128x128 px par défaut — garde des tailles cohérentes entre "
|
||||
"personnages sauf besoin explicite. RÈGLE FIXE pour un \"fond\" : "
|
||||
'personnages sauf besoin explicite. RÈGLE FIXE pour un "fond" : '
|
||||
"redimensionne-le TOUJOURS automatiquement en 2000x1000 px "
|
||||
"(set_object_geometry, position (0,0)) juste après l'avoir posé, "
|
||||
"SANS que le créateur ait besoin de le demander à chaque fois — "
|
||||
@@ -112,37 +114,37 @@ _SYSTEM_PROMPT = (
|
||||
"grand."
|
||||
"\n\n"
|
||||
"JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle "
|
||||
"\"pnj\" par défaut, JAMAIS \"joueur\". Un déclencheur de type "
|
||||
"\"collision\" ne se déclenche QUE par le contact du personnage au "
|
||||
"rôle \"joueur\" — sans lui, la collision ne se déclenche jamais ET "
|
||||
'"pnj" par défaut, JAMAIS "joueur". Un déclencheur de type '
|
||||
'"collision" ne se déclenche QUE par le contact du personnage au '
|
||||
'rôle "joueur" — sans lui, la collision ne se déclenche jamais ET '
|
||||
"la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un "
|
||||
"déclencheur de collision (quiz, dialogue déclenché en marchant "
|
||||
"vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN "
|
||||
"personnage avec role=\"joueur\" (set_object_role) — jamais deux, "
|
||||
'personnage avec role="joueur" (set_object_role) — jamais deux, '
|
||||
"et ne pose jamais deux PNJ identiques sans que le créateur l'ait "
|
||||
"demandé. "
|
||||
"RÈGLE IMPORTANTE : si le créateur ne précise pas comment un "
|
||||
"déclencheur \"collision\" doit se comporter, demande-lui si "
|
||||
'déclencheur "collision" doit se comporter, demande-lui si '
|
||||
"l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le "
|
||||
"joueur doit d'abord APPUYER SUR UNE TOUCHE (action \"interagir\", "
|
||||
"qui affiche \"Appuie sur [touche]\" tant que le contact dure) avant "
|
||||
'joueur doit d\'abord APPUYER SUR UNE TOUCHE (action "interagir", '
|
||||
'qui affiche "Appuie sur [touche]" tant que le contact dure) avant '
|
||||
"que l'action ne se déclenche — ne suppose jamais l'un ou l'autre "
|
||||
"toi-même sur une scène avec joueur+collision."
|
||||
"\n\n"
|
||||
"QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une "
|
||||
"donnée consultée plus tard (progression, un choix du joueur, un "
|
||||
"drapeau \"terminé\") — JAMAIS pour un score de quiz (voir "
|
||||
'drapeau "terminé") — JAMAIS pour un score de quiz (voir '
|
||||
"reward_amount plus haut, déjà automatique). Modifie une variable "
|
||||
"(action \"variable\") quand un événement doit changer durablement "
|
||||
"cet état (ex. marquer un drapeau \"xxx_termine\" à vrai une fois un "
|
||||
'(action "variable") quand un événement doit changer durablement '
|
||||
'cet état (ex. marquer un drapeau "xxx_termine" à vrai une fois un '
|
||||
"quiz fini). Utilise une CONDITION quand le comportement doit "
|
||||
"VRAIMENT différer selon l'état actuel d'une variable — un simple "
|
||||
"enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais "
|
||||
"d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à "
|
||||
"USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être "
|
||||
"protégé par une CONDITION qui vérifie D'ABORD le drapeau "
|
||||
"\"xxx_termine\" — si faux (pas encore fait), lance le dialogue/quiz "
|
||||
"normal (branche si_faux) PUIS termine par une action \"variable\" "
|
||||
'"xxx_termine" — si faux (pas encore fait), lance le dialogue/quiz '
|
||||
'normal (branche si_faux) PUIS termine par une action "variable" '
|
||||
"qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une "
|
||||
"réplique courte différente (branche si_vrai) au lieu de rejouer "
|
||||
"tout le quiz à chaque collision. Utilise un DIALOGUE pour toute "
|
||||
@@ -152,31 +154,31 @@ _SYSTEM_PROMPT = (
|
||||
"JEU : il faut TOUJOURS poser en plus le widget d'interface "
|
||||
"correspondant sur l'écran via add_scene_object, sinon rien n'apparaît "
|
||||
"à l'écran même si le déclencheur est correctement configuré. Une "
|
||||
"action \"dialogue\" dont les lignes sont de type \"replique\" a besoin "
|
||||
"d'un objet kind=\"dialogue_box\" ; une action \"dialogue\" dont les "
|
||||
"lignes sont de type \"question\" (quiz) a besoin d'un objet "
|
||||
"kind=\"quiz_box\" (pas dialogue_box) ; si un score/des points sont "
|
||||
"utilisés, ajoute aussi un objet kind=\"score_widget\" pour qu'il soit "
|
||||
'action "dialogue" dont les lignes sont de type "replique" a besoin '
|
||||
'd\'un objet kind="dialogue_box" ; une action "dialogue" dont les '
|
||||
'lignes sont de type "question" (quiz) a besoin d\'un objet '
|
||||
'kind="quiz_box" (pas dialogue_box) ; si un score/des points sont '
|
||||
'utilisés, ajoute aussi un objet kind="score_widget" pour qu\'il soit '
|
||||
"visible en continu. Positionne ces widgets à un endroit raisonnable "
|
||||
"de l'écran (ex. centré, ou en haut) via set_object_geometry après "
|
||||
"les avoir posés."
|
||||
"\n\n"
|
||||
"QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit "
|
||||
"un besoin de quiz \"tout seul\" (pas un personnage/dialogue de jeu), "
|
||||
"utilise set_quiz_box_config sur l'objet kind=\"quiz_box\" pour régler "
|
||||
'un besoin de quiz "tout seul" (pas un personnage/dialogue de jeu), '
|
||||
'utilise set_quiz_box_config sur l\'objet kind="quiz_box" pour régler '
|
||||
"le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : "
|
||||
"jamais imposé par défaut — demande TOUJOURS si le créateur en veut un "
|
||||
"avant d'en activer un, \"question\" redémarre à chaque question, "
|
||||
"\"quiz\" est un seul compte à rebours pour tout le quiz, timer_seconds "
|
||||
'avant d\'en activer un, "question" redémarre à chaque question, '
|
||||
'"quiz" est un seul compte à rebours pour tout le quiz, timer_seconds '
|
||||
"sa durée)."
|
||||
"\n\n"
|
||||
"MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : "
|
||||
"dialog_template (modèles \"boîte de dialogue\" : \"defaut\" — Classique — "
|
||||
"ou \"manga_dialogue\", le pendant en petite carte du thème manga) ne "
|
||||
'dialog_template (modèles "boîte de dialogue" : "defaut" — Classique — '
|
||||
'ou "manga_dialogue", le pendant en petite carte du thème manga) ne '
|
||||
"s'affiche QUE si fullscreen=false. "
|
||||
"page_template (modèles \"page de quiz\" : \"classique\" — sobre/"
|
||||
"professionnel, couleurs reprises du modèle \"boîte de dialogue\" de "
|
||||
"base — ou \"manga\", un thème dessiné/typographié entièrement à part) "
|
||||
'page_template (modèles "page de quiz" : "classique" — sobre/'
|
||||
'professionnel, couleurs reprises du modèle "boîte de dialogue" de '
|
||||
'base — ou "manga", un thème dessiné/typographié entièrement à part) '
|
||||
"ne s'affiche QUE si fullscreen=true — chacun un thème complet et "
|
||||
"autonome (police, formes, couleurs ET structure entièrement propres à "
|
||||
"ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux "
|
||||
@@ -194,13 +196,26 @@ _SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def _describe_scene_state(slug, screen_id):
|
||||
class ScreenDeletedError(Exception):
|
||||
"""Garde-fou défensif pour screen_id introuvable : en usage normal,
|
||||
supprimer un écran (screens/screens_repo/delete_screen.py) supprime
|
||||
déjà EN CASCADE ses conversations IA (ON DELETE CASCADE, voir
|
||||
screens/ia/ensure_ia_chat_schema.py) — cette voie n'est donc pas
|
||||
censée être atteignable via l'appli. get_screen() renvoyant
|
||||
dict | None, ce garde évite quand même un crash cru (attribut sur
|
||||
None) si jamais screen_id était invalide pour une autre raison,
|
||||
plutôt qu'un message lisible par un humain (voir run_chat_turn)."""
|
||||
|
||||
|
||||
def _describe_scene_state(slug: str, screen_id: int) -> str:
|
||||
"""Contexte dynamique (jamais mémorisé côté Claude entre les tours,
|
||||
voir run_chat_turn — seul le texte final est persisté) : sans ça,
|
||||
Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des
|
||||
objets au lieu de les corriger (bug observé : 2 PNJ identiques créés
|
||||
à la place d'un seul joueur + un pnj)."""
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
if screen is None:
|
||||
raise ScreenDeletedError("L'écran de cette conversation a été supprimé.")
|
||||
width, height = screen["scene_width"], screen["scene_height"]
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
# Répété ICI (pas seulement dans les instructions générales) avec les
|
||||
@@ -251,11 +266,11 @@ def _describe_scene_state(slug, screen_id):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _history_to_messages(history):
|
||||
def _history_to_messages(history: list[dict[str, Any]]) -> list[Any]:
|
||||
return [{"role": m["role"], "content": m["content"]} for m in history]
|
||||
|
||||
|
||||
def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
|
||||
def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int, user_message: str) -> str:
|
||||
"""Un tour complet : reprend l'historique persisté de CETTE
|
||||
conversation, ajoute le message du créateur, boucle tant que Claude
|
||||
appelle des outils, et renvoie le texte final. `screen_id` reste
|
||||
@@ -264,40 +279,57 @@ def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
|
||||
d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py,
|
||||
seul appelant, qui décide de ce qui est sauvegardé (même séparation
|
||||
que le reste du moteur : cette fonction ne fait que la logique IA)."""
|
||||
try:
|
||||
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
|
||||
# Claude lui-même) — voir _describe_scene_state. Vérifié AVANT de
|
||||
# construire le client Claude : pas la peine d'appeler l'API si
|
||||
# l'écran de cette conversation n'existe plus.
|
||||
scene_state = _describe_scene_state(slug, screen_id)
|
||||
except ScreenDeletedError:
|
||||
return "Cet écran a été supprimé — cette conversation n'est plus utilisable."
|
||||
client = get_client() # AnthropicNotConfiguredError si pas de clé
|
||||
messages = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
|
||||
messages: list[Any] = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par
|
||||
# Claude lui-même) — voir _describe_scene_state.
|
||||
system_prompt = _SYSTEM_PROMPT + "\n\n" + _describe_scene_state(slug, screen_id)
|
||||
system_prompt = _SYSTEM_PROMPT + "\n\n" + scene_state
|
||||
|
||||
response = None
|
||||
for _ in range(_MAX_TOOL_ITERATIONS):
|
||||
response = client.messages.create(
|
||||
# 4096 était trop bas (bug corrigé) : la réflexion adaptative
|
||||
# partage le même budget que la réponse — sur une demande
|
||||
# riche (plusieurs objets + logique + texte), Claude pouvait
|
||||
# être coupé EN PLEINE RÉFLEXION, avant le moindre appel
|
||||
# d'outil (symptôme observé : aucune progression du tout,
|
||||
# "(pas de réponse textuelle)" dès le premier tour).
|
||||
model=MODEL, max_tokens=16000, system=system_prompt,
|
||||
tools=TOOLS, thinking={"type": "adaptive"}, messages=messages,
|
||||
# 4096 était trop bas (bug corrigé) : la réflexion adaptative
|
||||
# partage le même budget que la réponse — sur une demande riche
|
||||
# (plusieurs objets + logique + texte), Claude pouvait être coupé
|
||||
# EN PLEINE RÉFLEXION, avant le moindre appel d'outil (symptôme
|
||||
# observé : aucune progression du tout, "(pas de réponse
|
||||
# textuelle)" dès le premier tour).
|
||||
response = client.messages.create( # type: ignore[call-overload] # TOOLS/messages sont des dict Python bruts, pas les TypedDict exacts du SDK anthropic
|
||||
model=MODEL,
|
||||
max_tokens=16000,
|
||||
system=system_prompt,
|
||||
tools=TOOLS,
|
||||
thinking={"type": "adaptive"},
|
||||
messages=messages,
|
||||
)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
tool_results = []
|
||||
tool_results: list[Any] = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
try:
|
||||
result = dispatch_tool(slug, screen_id, user_id, block.name, block.input)
|
||||
except Exception as e:
|
||||
result = {"error": str(e)}
|
||||
tool_results.append({
|
||||
"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result),
|
||||
})
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": json.dumps(result),
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
||||
"_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois"
|
||||
)
|
||||
text = next((b.text for b in response.content if b.type == "text"), "")
|
||||
if text:
|
||||
return text
|
||||
|
||||
Reference in New Issue
Block a user