Update app.py
Browse files
app.py
CHANGED
|
@@ -57,19 +57,21 @@ def auth_callback(username: str, password: str) -> Optional[cl.User]:
|
|
| 57 |
"""
|
| 58 |
# Exemple simple (à remplacer par votre logique)
|
| 59 |
auth = json.loads(os.environ.get("CHAINLIT_AUTH_LOGIN"))
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
@cl.data_layer
|
| 75 |
def get_data_layer():
|
|
@@ -189,7 +191,6 @@ async def display_similar_info(similar_info: List[Dict[str, Any]]):
|
|
| 189 |
|
| 190 |
# Créer un élément Chainlit
|
| 191 |
element = cl.Text(
|
| 192 |
-
name=f"similar_{db_name}",
|
| 193 |
content="\n".join(content_parts),
|
| 194 |
display="side"
|
| 195 |
)
|
|
@@ -201,13 +202,55 @@ async def display_similar_info(similar_info: List[Dict[str, Any]]):
|
|
| 201 |
elements=elements
|
| 202 |
).send()
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
# =============================================================================
|
| 205 |
# FONCTION PRINCIPALE TRACÉE AVEC LANGSMITH
|
| 206 |
# =============================================================================
|
| 207 |
|
| 208 |
@traceable(name="agent_collaboratif_query", project_name=LANGSMITH_PROJECT)
|
| 209 |
async def process_query_with_tracing(query: str, thread_id: str) -> Dict[str, Any]:
|
| 210 |
-
"""Traite la requête avec traçage LangSmith."""
|
| 211 |
|
| 212 |
# Import du workflow
|
| 213 |
from agent_collaboratif_avid import AgentState, create_agent_workflow
|
|
@@ -224,30 +267,58 @@ async def process_query_with_tracing(query: str, thread_id: str) -> Dict[str, An
|
|
| 224 |
"final_response": "",
|
| 225 |
"iteration_count": 0,
|
| 226 |
"errors": [],
|
| 227 |
-
"additional_information": []
|
|
|
|
| 228 |
}
|
| 229 |
|
| 230 |
-
#
|
| 231 |
-
await send_cot_step("🔄 Démarrage", "Initialisation du workflow...", "
|
| 232 |
|
| 233 |
-
|
|
|
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
| 247 |
|
| 248 |
-
#
|
| 249 |
-
if final_state
|
| 250 |
-
|
| 251 |
|
| 252 |
result = {
|
| 253 |
"query": query,
|
|
@@ -258,6 +329,7 @@ async def process_query_with_tracing(query: str, thread_id: str) -> Dict[str, An
|
|
| 258 |
"iteration_count": final_state.get("iteration_count", 0),
|
| 259 |
"errors": final_state.get("errors", []),
|
| 260 |
"additional_information": final_state.get("additional_information", []),
|
|
|
|
| 261 |
"sources_used": [
|
| 262 |
info["database"]
|
| 263 |
for info in final_state.get("collected_information", [])
|
|
@@ -271,41 +343,14 @@ async def process_query_with_tracing(query: str, thread_id: str) -> Dict[str, An
|
|
| 271 |
# CALLBACKS CHAINLIT
|
| 272 |
# =============================================================================
|
| 273 |
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
# """Initialisation de la session chat."""
|
| 277 |
-
|
| 278 |
-
# Message de bienvenue avec style
|
| 279 |
-
# welcome_msg = f"""# 🎓 Agent Collaboratif - Université Gustave Eiffel
|
| 280 |
-
|
| 281 |
-
#Bienvenue ! Je suis votre assistant spécialisé en **Ville Durable**.
|
| 282 |
-
|
| 283 |
-
## 🔧 Configuration
|
| 284 |
-
#- **Index Pinecone:** `{PINECONE_INDEX_NAME}`
|
| 285 |
-
#- **Modèle:** `{OPENAI_MODEL_NAME}`
|
| 286 |
-
#- **Top K résultats:** `{SIMILARITY_TOP_K}`
|
| 287 |
-
#- **Max validations:** `{MAX_VALIDATION_LOOPS}`
|
| 288 |
-
|
| 289 |
-
## 💡 Fonctionnalités
|
| 290 |
-
#✅ Recherche multi-bases vectorielles
|
| 291 |
-
#✅ Validation anti-hallucination
|
| 292 |
-
#✅ Suggestions d'informations connexes
|
| 293 |
-
#✅ Traçage LangSmith actif
|
| 294 |
-
|
| 295 |
-
#**Choisissez un starter ou posez votre question !**
|
| 296 |
-
#"""
|
| 297 |
-
|
| 298 |
-
# await cl.Message(content=welcome_msg).send()
|
| 299 |
-
|
| 300 |
-
# Sauvegarder les métadonnées de session
|
| 301 |
-
# cl.user_session.set("session_started", True)
|
| 302 |
-
# cl.user_session.set("query_count", 0)
|
| 303 |
-
|
| 304 |
-
@cl.set_starters
|
| 305 |
-
async def set_starters():
|
| 306 |
-
"""Configure les starters avec icônes."""
|
| 307 |
-
#return [cl.Starter(label=s["label"], message=s["message"], icon=s["icon"]) for s in STARTERS]
|
| 308 |
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
cl.Starter(
|
| 310 |
label= "🔬 Laboratoires & Mobilité",
|
| 311 |
message= "Quels sont les laboratoires de l'université Gustave Eiffel travaillant sur la mobilité urbaine durable?",
|
|
@@ -357,6 +402,49 @@ async def set_starters():
|
|
| 357 |
#icon= "/public/icons/circular.svg"
|
| 358 |
)
|
| 359 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
|
| 361 |
@cl.on_message
|
| 362 |
async def main(message: cl.Message):
|
|
@@ -377,9 +465,29 @@ async def main(message: cl.Message):
|
|
| 377 |
# Traitement avec affichage du COT
|
| 378 |
result = await process_query_with_tracing(query, thread_id)
|
| 379 |
|
| 380 |
-
# Réponse finale
|
| 381 |
final_response = result["final_response"]
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
# Métadonnées
|
| 384 |
metadata_parts = [
|
| 385 |
f"\n\n---\n### 📊 Métadonnées du traitement",
|
|
@@ -393,10 +501,12 @@ async def main(message: cl.Message):
|
|
| 393 |
|
| 394 |
metadata_parts.append(f"**Requête n°:** {query_count}")
|
| 395 |
|
| 396 |
-
|
|
|
|
|
|
|
| 397 |
|
| 398 |
-
#
|
| 399 |
-
processing_msg.content =
|
| 400 |
await processing_msg.update()
|
| 401 |
|
| 402 |
# Sauvegarder dans l'historique de session
|
|
@@ -420,6 +530,10 @@ async def main(message: cl.Message):
|
|
| 420 |
# comment=str(e)
|
| 421 |
# )
|
| 422 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
@cl.on_chat_resume
|
| 424 |
async def on_chat_resume(thread: ThreadDict):
|
| 425 |
"""Reprise d'une conversation existante."""
|
|
|
|
| 57 |
"""
|
| 58 |
# Exemple simple (à remplacer par votre logique)
|
| 59 |
auth = json.loads(os.environ.get("CHAINLIT_AUTH_LOGIN"))
|
| 60 |
+
|
| 61 |
+
auth_iter = iter(auth)
|
| 62 |
+
|
| 63 |
+
while True:
|
| 64 |
+
# item will be "end" if iteration is complete
|
| 65 |
+
connexion = next(auth_iter, "end")
|
| 66 |
+
if bcrypt.checkpw(username.encode('utf-8'), bcrypt.hashpw(connexion['ident'].encode('utf-8'), bcrypt.gensalt())) == True and bcrypt.checkpw(password.encode('utf-8'), bcrypt.hashpw(connexion['pwd'].encode('utf-8'), bcrypt.gensalt())) == True:
|
| 67 |
+
print("OK")
|
| 68 |
+
return cl.User(
|
| 69 |
+
identifier=connexion['ident'],
|
| 70 |
+
metadata={"role": connexion['role'], "provider": "credentials"}
|
| 71 |
+
)
|
| 72 |
+
if connexion == "end":
|
| 73 |
+
break
|
| 74 |
+
return None
|
| 75 |
|
| 76 |
@cl.data_layer
|
| 77 |
def get_data_layer():
|
|
|
|
| 191 |
|
| 192 |
# Créer un élément Chainlit
|
| 193 |
element = cl.Text(
|
|
|
|
| 194 |
content="\n".join(content_parts),
|
| 195 |
display="side"
|
| 196 |
)
|
|
|
|
| 202 |
elements=elements
|
| 203 |
).send()
|
| 204 |
|
| 205 |
+
# =============================================================================
|
| 206 |
+
# FONCTIONS D'AFFICHAGE STREAMING PAR NŒUD
|
| 207 |
+
# =============================================================================
|
| 208 |
+
|
| 209 |
+
async def stream_response(content: str, msg: cl.Message, chunk_size: int = 50):
|
| 210 |
+
"""Stream du contenu progressivement dans un message."""
|
| 211 |
+
for i in range(0, len(content), chunk_size):
|
| 212 |
+
chunk = content[i:i + chunk_size]
|
| 213 |
+
msg.content += chunk
|
| 214 |
+
await msg.update()
|
| 215 |
+
await asyncio.sleep(0.25) # Petit délai pour un effet visuel
|
| 216 |
+
|
| 217 |
+
async def display_node_update(node_name: str, state: Dict[str, Any]):
|
| 218 |
+
"""Affiche les mises à jour d'état après l'exécution d'un nœud."""
|
| 219 |
+
|
| 220 |
+
if node_name == "analyze_query":
|
| 221 |
+
if state.get("query_analysis"):
|
| 222 |
+
await display_query_analysis(state["query_analysis"])
|
| 223 |
+
|
| 224 |
+
elif node_name == "collect_information":
|
| 225 |
+
if state.get("collected_information"):
|
| 226 |
+
await display_collection(state["collected_information"])
|
| 227 |
+
|
| 228 |
+
elif node_name == "generate_response":
|
| 229 |
+
if state.get("final_response"):
|
| 230 |
+
content = f"**Réponse générée** ({len(state['final_response'])} caractères)\n\nLa réponse complète sera affichée à la fin du workflow."
|
| 231 |
+
await send_cot_step("✏️ Génération de la réponse", content, "done")
|
| 232 |
+
|
| 233 |
+
elif node_name == "validate_response":
|
| 234 |
+
if state.get("validation_results"):
|
| 235 |
+
iteration = state.get("iteration_count", len(state["validation_results"]))
|
| 236 |
+
last_validation = state["validation_results"][-1]
|
| 237 |
+
await display_validation(last_validation, iteration)
|
| 238 |
+
|
| 239 |
+
elif node_name == "refine_response":
|
| 240 |
+
content = f"**Itération:** {state.get('iteration_count', 0)}\n**Correction en cours...**"
|
| 241 |
+
await send_cot_step("⚙️ Refinement", content, "done")
|
| 242 |
+
|
| 243 |
+
elif node_name == "collect_similar_information":
|
| 244 |
+
if state.get("additional_information"):
|
| 245 |
+
await display_similar_info(state["additional_information"])
|
| 246 |
+
|
| 247 |
# =============================================================================
|
| 248 |
# FONCTION PRINCIPALE TRACÉE AVEC LANGSMITH
|
| 249 |
# =============================================================================
|
| 250 |
|
| 251 |
@traceable(name="agent_collaboratif_query", project_name=LANGSMITH_PROJECT)
|
| 252 |
async def process_query_with_tracing(query: str, thread_id: str) -> Dict[str, Any]:
|
| 253 |
+
"""Traite la requête avec traçage LangSmith et streaming en temps réel."""
|
| 254 |
|
| 255 |
# Import du workflow
|
| 256 |
from agent_collaboratif_avid import AgentState, create_agent_workflow
|
|
|
|
| 267 |
"final_response": "",
|
| 268 |
"iteration_count": 0,
|
| 269 |
"errors": [],
|
| 270 |
+
"additional_information": [],
|
| 271 |
+
"similar_info_response":""
|
| 272 |
}
|
| 273 |
|
| 274 |
+
# Message de démarrage
|
| 275 |
+
await send_cot_step("🔄 Démarrage", "Initialisation du workflow LangGraph...", "done")
|
| 276 |
|
| 277 |
+
# Variables pour suivre l'état
|
| 278 |
+
final_state = None
|
| 279 |
|
| 280 |
+
# STREAMING: Utilisation de app.astream() pour obtenir les mises à jour après chaque nœud
|
| 281 |
+
try:
|
| 282 |
+
async for event in app.astream(initial_state):
|
| 283 |
+
# event est un dictionnaire avec les nœuds comme clés
|
| 284 |
+
for node_name, node_state in event.items():
|
| 285 |
+
# Ignorer le nœud spécial __start__
|
| 286 |
+
if node_name == "__start__":
|
| 287 |
+
continue
|
| 288 |
+
|
| 289 |
+
# Afficher un message de progression pour le nœud actuel
|
| 290 |
+
node_display_names = {
|
| 291 |
+
"analyze_query": "🔍 Analyse de la requête",
|
| 292 |
+
"collect_information": "📊 Collecte d'informations",
|
| 293 |
+
"generate_response": "✏️ Génération de la réponse",
|
| 294 |
+
"validate_response": "✅ Validation anti-hallucination",
|
| 295 |
+
"refine_response": "⚙️ Refinement de la réponse",
|
| 296 |
+
"collect_similar_information": "🔗 Collecte d'informations similaires"
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
display_name = node_display_names.get(node_name, f"⚙️ {node_name}")
|
| 300 |
+
|
| 301 |
+
# Message de progression
|
| 302 |
+
await send_cot_step(
|
| 303 |
+
f"🔄 {display_name}",
|
| 304 |
+
f"Nœud exécuté avec succès",
|
| 305 |
+
"done"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# Afficher les détails spécifiques du nœud
|
| 309 |
+
await display_node_update(node_name, node_state)
|
| 310 |
+
|
| 311 |
+
# Sauvegarder l'état final
|
| 312 |
+
final_state = node_state
|
| 313 |
|
| 314 |
+
except Exception as e:
|
| 315 |
+
error_msg = f"Erreur lors du streaming: {str(e)}"
|
| 316 |
+
await send_cot_step("❌ Erreur", error_msg, "error")
|
| 317 |
+
raise
|
| 318 |
|
| 319 |
+
# Si le streaming n'a pas retourné d'état final, utiliser la méthode classique
|
| 320 |
+
if final_state is None:
|
| 321 |
+
final_state = initial_state
|
| 322 |
|
| 323 |
result = {
|
| 324 |
"query": query,
|
|
|
|
| 329 |
"iteration_count": final_state.get("iteration_count", 0),
|
| 330 |
"errors": final_state.get("errors", []),
|
| 331 |
"additional_information": final_state.get("additional_information", []),
|
| 332 |
+
"similar_info_response": final_state.get("similar_info_response", ""),
|
| 333 |
"sources_used": [
|
| 334 |
info["database"]
|
| 335 |
for info in final_state.get("collected_information", [])
|
|
|
|
| 343 |
# CALLBACKS CHAINLIT
|
| 344 |
# =============================================================================
|
| 345 |
|
| 346 |
+
@cl.set_chat_profiles
|
| 347 |
+
async def chat_profile(current_user: cl.User):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
return [
|
| 349 |
+
cl.ChatProfile(
|
| 350 |
+
name="Avid Agent",
|
| 351 |
+
markdown_description="🎓 Avid Agent permet de converser avec un agent collaboratif entre 4 bases de données pour extraire les informations pertinentes afin de générer une réponse en réduisant les hallucations, par relecture et redéfinition des éléments.",
|
| 352 |
+
icon="/public/sparkles-gustaveia.png",
|
| 353 |
+
starters=[
|
| 354 |
cl.Starter(
|
| 355 |
label= "🔬 Laboratoires & Mobilité",
|
| 356 |
message= "Quels sont les laboratoires de l'université Gustave Eiffel travaillant sur la mobilité urbaine durable?",
|
|
|
|
| 402 |
#icon= "/public/icons/circular.svg"
|
| 403 |
)
|
| 404 |
]
|
| 405 |
+
),cl.ChatProfile(
|
| 406 |
+
name="Avid Dataviz",
|
| 407 |
+
markdown_description="💡 Avid Dataviz permet d'avoir recours à des éléments statistiques et de corrélation entre les données laboratoires et les thématiques Ville Durable",
|
| 408 |
+
)
|
| 409 |
+
]
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
@cl.on_chat_start
|
| 413 |
+
async def start():
|
| 414 |
+
"""Initialisation de la session chat."""
|
| 415 |
+
user = cl.user_session.get("user")
|
| 416 |
+
chat_profile = cl.user_session.get("chat_profile")
|
| 417 |
+
if chat_profile == "Avid Dataviz":
|
| 418 |
+
await cl.Message(
|
| 419 |
+
content=f"Bienvenue {user.identifier}!\n\nL'environnement {chat_profile} vous restitue les données sous forme d'objets statistiques."
|
| 420 |
+
).send()
|
| 421 |
+
|
| 422 |
+
# Message de bienvenue avec style
|
| 423 |
+
# welcome_msg = f"""# 🎓 Agent Collaboratif - Université Gustave Eiffel
|
| 424 |
+
|
| 425 |
+
#Bienvenue ! Je suis votre assistant spécialisé en **Ville Durable**.
|
| 426 |
+
|
| 427 |
+
## 🔧 Configuration
|
| 428 |
+
#- **Index Pinecone:** `{PINECONE_INDEX_NAME}`
|
| 429 |
+
#- **Modèle:** `{OPENAI_MODEL_NAME}`
|
| 430 |
+
#- **Top K résultats:** `{SIMILARITY_TOP_K}`
|
| 431 |
+
#- **Max validations:** `{MAX_VALIDATION_LOOPS}`
|
| 432 |
+
|
| 433 |
+
## 💡 Fonctionnalités
|
| 434 |
+
#✅ Recherche multi-bases vectorielles
|
| 435 |
+
#✅ Validation anti-hallucination
|
| 436 |
+
#✅ Suggestions d'informations connexes
|
| 437 |
+
#✅ Traçage LangSmith actif
|
| 438 |
+
|
| 439 |
+
#**Choisissez un starter ou posez votre question !**
|
| 440 |
+
#"""
|
| 441 |
+
|
| 442 |
+
# await cl.Message(content=welcome_msg).send()
|
| 443 |
+
|
| 444 |
+
# Sauvegarder les métadonnées de session
|
| 445 |
+
# cl.user_session.set("session_started", True)
|
| 446 |
+
# cl.user_session.set("query_count", 0)
|
| 447 |
+
|
| 448 |
|
| 449 |
@cl.on_message
|
| 450 |
async def main(message: cl.Message):
|
|
|
|
| 465 |
# Traitement avec affichage du COT
|
| 466 |
result = await process_query_with_tracing(query, thread_id)
|
| 467 |
|
| 468 |
+
# Réponse finale en streaming
|
| 469 |
final_response = result["final_response"]
|
| 470 |
|
| 471 |
+
# Afficher un séparateur
|
| 472 |
+
await send_cot_step("📝 Réponse finale", "Affichage de la réponse complète en streaming...", "done")
|
| 473 |
+
|
| 474 |
+
# Créer un nouveau message pour la réponse finale
|
| 475 |
+
response_msg = cl.Message(content="")
|
| 476 |
+
await response_msg.send()
|
| 477 |
+
|
| 478 |
+
# Streamer la réponse complète
|
| 479 |
+
await stream_response(final_response, response_msg, chunk_size=50)
|
| 480 |
+
|
| 481 |
+
# Afficher les informations similaires collectées par le nœud 6
|
| 482 |
+
if result.get("similar_info_response"):
|
| 483 |
+
similar_msg = cl.Message(content="")
|
| 484 |
+
await similar_msg.send()
|
| 485 |
+
|
| 486 |
+
# Streamer la réponse similaire
|
| 487 |
+
await stream_response(result["similar_info_response"], similar_msg, chunk_size=50)
|
| 488 |
+
|
| 489 |
+
#await display_similar_info(result["similar_info_response"])
|
| 490 |
+
|
| 491 |
# Métadonnées
|
| 492 |
metadata_parts = [
|
| 493 |
f"\n\n---\n### 📊 Métadonnées du traitement",
|
|
|
|
| 501 |
|
| 502 |
metadata_parts.append(f"**Requête n°:** {query_count}")
|
| 503 |
|
| 504 |
+
# Ajouter les métadonnées en streaming
|
| 505 |
+
metadata_text = "\n".join(metadata_parts)
|
| 506 |
+
await stream_response(metadata_text, response_msg, chunk_size=100)
|
| 507 |
|
| 508 |
+
# Supprimer le message de traitement initial vide
|
| 509 |
+
processing_msg.content = "✅ Traitement terminé"
|
| 510 |
await processing_msg.update()
|
| 511 |
|
| 512 |
# Sauvegarder dans l'historique de session
|
|
|
|
| 530 |
# comment=str(e)
|
| 531 |
# )
|
| 532 |
|
| 533 |
+
@cl.on_shared_thread_view
|
| 534 |
+
async def on_shared_thread_view(thread: ThreadDict, viewer: Optional[cl.User]) -> bool:
|
| 535 |
+
return True
|
| 536 |
+
|
| 537 |
@cl.on_chat_resume
|
| 538 |
async def on_chat_resume(thread: ThreadDict):
|
| 539 |
"""Reprise d'une conversation existante."""
|