first commit

This commit is contained in:
Sam
2025-08-27 16:54:36 +02:00
commit 779d68dba9
10 changed files with 1298 additions and 0 deletions

21
.env Normal file
View File

@@ -0,0 +1,21 @@
# Flask
SECRET_KEY=dev_secret
DATABASE_URL=sqlite:///treasure.db
# IA Perplexity
PERPLEXITY_API_KEY=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
# Matrix
MATRIX_HOMESERVER=https://conduit.blackdrop.fr
MATRIX_USER=@chatbot:conduit.blackdrop.fr
MATRIX_PASSWORD=">J?e3n7~c)Mc#xq"
MATRIX_ACCESS_TOKEN="TSKq7w3oygdgLuhTJ31LlpfDrtTO0fLI" # de préférence utiliser un token
MATRIX_ROOM_ID="!__cfA9pLoT-ar8Jpje1hrdt8n7ngzOYeg_dyuho3ytA"
# Sécurité API
FLASK_SHARED_SECRET=secret_flask_matrix
# Options
DEBUG=true
GOOGLE_SHEET_CSV_URL = "https://docs.google.com/spreadsheets/d/1H5rs2wR2Hb1GYhMwEjxOWtP5AnZgdIOX3Xmag_RLCBs/export?format=csv"

0
__init.py__ Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

801
app.py Normal file
View File

@@ -0,0 +1,801 @@
import os
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from dotenv import load_dotenv
import pandas as pd
from models import db, Player, Step, MessageLog
import requests
import logging
from io import StringIO
import re
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("flask")
# Charger .env
load_dotenv()
app = Flask(__name__)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev_secret")
app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv("DATABASE_URL", "sqlite:///treasure.db")
db.init_app(app)
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
FLASK_SHARED_SECRET = os.getenv("FLASK_SHARED_SECRET", "secret_flask_matrix")
CONTEXT_TURNS = int(os.getenv("CONTEXT_TURNS", 10)) # nbre d'échanges pris en compte
GOOGLE_SHEET_CSV_URL = os.getenv("GOOGLE_SHEET_CSV_URL", "")
def build_perplexity_history(player, current_user_message, step):
# récupère tout l'historique du joueur, ordonné
history = MessageLog.query.filter_by(player_id=player.id).order_by(MessageLog.timestamp.asc()).all()
messages = []
for msg in history:
if msg.sender == "Katniss":
messages.append({"role": "assistant", "content": msg.content})
else:
messages.append({"role": "user", "content": msg.content})
# On ajoute le message courant et le contexte de l'étape dans UN SEUL message user à la fin
context_prefix = (
f"[Contexte: Étape {step.step if step else '?'} - {step.location if step else ''}]\n"
f"Énigme: {step.location_enigma if step else ''}\n"
)
messages.append({
"role": "user",
"content": context_prefix + current_user_message.strip()
})
# Nettoie pour garantir alternance
cleaned = []
last_role = None
for m in messages:
if m["role"] == last_role:
# Fusionne le contenu avec le dernier du même role (précaution très utile)
cleaned[-1]["content"] += "\n\n" + m["content"]
else:
cleaned.append(m)
last_role = m["role"]
# Ancien prompt_system remplacé pour prendre en compte le model Step
prompt_system = (
"Tu es Katniss, guide d'aventure pour une chasse au trésor (rôle assistant). "
"Tu reçois toujours, en contexte, un 'step' qui contient les champs suivants :\n"
"- step : numéro de l'étape\n"
"- pre_text : texte d'introduction/context avant saisie du code (si présent)\n"
"- location : titre du lieu\n"
"- location_enigma : énigme principale ou description de l'énigme à créer\n"
"- location_hint : indice lié au code (à utiliser progressivement)\n"
"- code : la réponse/código (NE JAMAIS révéler au joueur)\n"
"- question : question additionnelle éventuelle\n"
"- question_hint : indice pour la question\n"
"- answer : réponse à la question (NE JAMAIS révéler)\n"
"- comments : commentaire pour le MJ (informations internes)\n"
"- success_text : texte à afficher après réussite\n"
"- image_path, audio_path : chemins vers médias liés à l'étape (mention possible)\n\n"
"Règles strictes :\n"
"1) Ne jamais fournir directement 'code' ni 'answer'. Si le joueur demande la réponse, refuse poliment et propose un indice.\n"
"2) Proposer des indices progressifs : commencer par des indices généraux (réutiliser location_hint ou question_hint), "
"puis, si le joueur insiste, donner des indices de plus en plus directs sans révéler la solution.\n"
"3) Utiliser pre_text pour contextualiser la demande si présent, et suggérer au joueur d'examiner image_path/audio_path si fournis.\n"
"4) Si la requête montre que le joueur a trouvé le code, encourager et rappeler qu'après validation le texte success_text sera affiché.\n"
"5) Ne pas inventer d'informations non présentes dans 'step'; se limiter aux champs et à l'historique des messages.\n"
"6) Répondre en français, ton amical et encourageant, bref si l'utilisateur demande un indice simple, plus explicite si l'utilisateur a tenté plusieurs fois.\n"
)
messages_final = [{"role": "system", "content": prompt_system}] + cleaned[-20:] # Limite à 20 messages récents
return messages_final
def get_ai_hint(player, current_user_message):
step = Step.query.filter_by(step=player.current_step).first()
messages = build_perplexity_history(player, current_user_message, step)
log.info(messages)
url = "https://api.perplexity.ai/chat/completions"
headers = {
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json"
}
payload = {
"model": "sonar",
"messages": messages,
"max_tokens": 500,
"temperature": 0.7,
}
try:
resp = requests.post(url, headers=headers, json=payload, timeout=20)
if resp.status_code == 400:
app.logger.error(f"Perplexity 400: {resp.text}")
return "(Katniss indisponible: historique de messages mal formé, regarde les logs serveur !)"
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return content.strip() if content else "(Katniss n'a rien répondu, elle ne capte probablement plus au fond du bunker)"
except Exception as e:
return f"(Katniss est en échec: {e})"
def call_perplexity(messages, max_tokens=400, temperature=0.7):
"""Send messages to Perplexity and return assistant content or error string."""
url = "https://api.perplexity.ai/chat/completions"
headers = {
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json"
}
payload = {
"model": "sonar",
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
try:
resp = requests.post(url, headers=headers, json=payload, timeout=20)
if resp.status_code == 400:
app.logger.error(f"Perplexity 400: {resp.text}")
return "(Katniss indisponible: historique de messages mal formé, regarde les logs serveur !)"
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
return content.strip() if content else ""
except Exception as e:
app.logger.exception("Perplexity error")
return f"(Katniss est en échec: {e})"
def is_affirmative(text: str) -> bool:
"""Return True if text looks like a positive/ready answer (oui/ok/yes/etc.)."""
if not text:
return False
normalized = re.sub(r"[^a-z0-9\s]", "", text.lower())
tokens = set(normalized.split())
affirmatives = {"oui", "ouais", "ok", "yes", "yeah", "yep", "go", "pret", "prêt", "daccord", "d'accord"}
return len(tokens & affirmatives) > 0
def matches_any(text: str, choices: str) -> bool:
"""Return True if text matches any of the semicolon-separated choices (case-insensitive).
Normalises by lowercasing, trimming and removing punctuation for a more forgiving match.
"""
if not text:
return False
if not choices:
return False
text_norm = re.sub(r"[^a-z0-9\s]", "", text.lower()).strip()
for part in str(choices).split(";"):
part_norm = re.sub(r"[^a-z0-9\s]", "", part.lower()).strip()
if part_norm == text_norm:
return True
return False
# def generate_step_intro(player, step):
# """Ask the AI to improve pre_text + location_enigma and return a short intro in French."""
# if not step:
# return "(Aucune étape trouvée)"
# system = (
# "Tu es Katniss, guide d'aventure et maître du jeu pour une chasse au trésor."
# "Améliore et synthétise en français le texte d'introduction fourni (pre_text) et l'énigme (location_enigma) "
# "pour qu'il soit engageant et clair pour le joueur. Ne révèle jamais le 'code' ni la 'answer'."
# "Parle à la première personne du singulier, comme si tu parlais directement au joueur."
# "Renvoie la réponse au format markdown."
# )
# user_content = f"Prétexte:\n{step.pre_text or ''}\n\nÉnigme:\n{step.location_enigma or ''}\n\n"
# messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
# return call_perplexity(messages, max_tokens=1500)
def generate_step_intro(player, step):
"""Ask the AI to improve pre_text + location_enigma and return a short intro in French."""
if not step:
return "(Aucune étape trouvée)"
system = (
"Tu es Katniss, guide d'aventure et maître du jeu pour une chasse au trésor."
"Améliore et synthétise en frle texte d'introduction fourni (pre_text) et l'énigme (location_enigma) "
"pour qu'il soit engageant et clair pour le joueur. Ne révèle jamais le 'code' ni la 'answer'."
"Parle à la première personne du singulier, comme si tu parlais directement au joueur."
"Renvoie la réponse au format markdown."
)
user_content = f"Prétexte:\n{step.pre_text or ''}\n\nÉnigme:\n{step.location_enigma or ''}\n\n"
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
return call_perplexity(messages, max_tokens=1500)
def generate_intro_text(player, step):
"""Generate only the intro (pre_text) improved by AI, without revealing the enigma.
Returns markdown string."""
if not step:
return "(Aucune étape trouvée)"
# return step.pre_text or ""
system = (
"Renvoi le texte d'introduction fourni (pre_text) au format markdown sans fautes mais ne le modifie pas. Ne rajoute pas d'autre informations. "
"")
user_content = f"pre_text:\n{step.pre_text or ''}\n\n"
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
return call_perplexity(messages, max_tokens=800)
def generate_enigma_text(player, step):
"""Generate only the enigma (location_enigma) improved by AI, ready to be presented to the player."""
if not step:
return "(Aucune étape trouvée)"
# return step.location_enigma or ""
system = (
"Renvoie l'énigme (location_enigma) au format markdown sans fautes mais ne la modifie pas. Ne rajoute pas d'autre informations."
""
)
user_content = f"Énigme brute:\n{step.location_enigma or ''}\n\n"
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
return call_perplexity(messages, max_tokens=1000)
def is_player_ready_ai(player, reply_text, step):
"""Ask the AI to judge whether the player's reply indicates readiness.
Returns (bool, katniss_message). The AI is instructed to output first line TRUE or FALSE, then a short encouraging message on the next line.
"""
system = (
"Tu es Katniss, guide d'aventure. Tu dois lire la réponse courte d'un joueur et décider s'il est prêt à recevoir l'énigme suivante. "
"Réponds STRICTEMENT sur deux lignes : première ligne TRUE si le joueur est prêt, FALSE sinon ; deuxième ligne un court message d'encouragement en français (1-2 phrases)."
)
user_content = f"Contexte étape {step.step if step else '?'} - lieu: {step.location if step else ''}\nRéponse du joueur:\n{reply_text}\n\nRenvoie exactement deux lignes : TRUE/FALSE, puis le message Katniss."
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
ai_text = call_perplexity(messages, max_tokens=200)
if not ai_text:
return False, "Prends ton temps, dis-moi quand tu seras prêt·e."
# Parse first token for true/false
first_line = ai_text.splitlines()[0].strip().lower() if ai_text else ""
kat_msg = "\n".join(ai_text.splitlines()[1:]).strip() or "Prends ton temps, dis-moi quand tu seras prêt·e."
ready = False
if "true" in first_line or "oui" in first_line or "vrai" in first_line:
ready = True
return ready, kat_msg
def generate_formatted_hint(player, step, hint_text, hint_kind="location", hint_index=0):
"""Ask the AI to present a single hint (already extracted) in a friendly way."""
system = (
"Tu es Katniss, guide d'aventure et maitre du jeu pour Magali. Renvoie l'indice (hint) SANS MODIFICATION, sans fautes, sans autres informations"
"Avant l'indice, encourage le joueur."
)
user_content = (
f"indice : {hint_text}"
""
)
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
return call_perplexity(messages, max_tokens=1000)
def generate_question_text(player:Player, step:Step):
"""Ask the AI to format the question to the player if present."""
if not step or not step.question:
return ""
system = (
"Tu es Katniss, guide d'aventure et maitre du jeu. Reformule la question fournie de façon claire et engageante en français. "
"Parle à la première personne du singulier, comme si tu parlais directement au joueur."
"Renvoie la réponse au format markdown."
"Les questions sont liés à des évenements entre Sam et Magali"
)
user_content = f"Question brute:\n{step.question}\n\nRenvoie la question après avoir donner un encouragement pour avoir réussi à trouver le code. indique en gras que c'est une question. redonne le numero de l'etape {step.step}"
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
return call_perplexity(messages, max_tokens=1000)
def generate_success_text(player, step):
"""Ask the AI to produce an encouraging success message (can use step.success_text as base)."""
base = step.success_text or "Bravo ! Tu as réussi cette étape."
# system = (
# "Tu es Katniss, guide d'aventure. Génère un court message d'encouragement en français à destination du joueur après réussite. "
# "Tu peux améliorer le texte de base sans révéler d'informations supplémentaires."
# "garde les élements entre crochets dans le texte de base"
# "Parle à la première personne du singulier, comme si tu parlais directement au joueur."
# "Renvoie la réponse au format markdown."
# )
system = (
"Renvoie le texte d'encouragement fourni (success_text) au format markdown sans fautes mais ne le modifie pas. Ne rajoute pas d'autre informations. "
"")
# Ask the AI to enhance the base text but ensure we keep the original base (with bracketed URLs)
# user_content = (
# f"Texte de base:\n{base}\n\n"
# "Améliore ce texte de base pour en faire un court message d'encouragement chaleureux en français."
# " NE MODIFIE PAS ni ne SUPPRIME les éléments entre crochets [] présents dans le texte de base."
# " Renvoie uniquement le texte d'encouragement (format markdown)."
# )
user_content = f"success_text:\n{base}\n\n"
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
ai_response = call_perplexity(messages, max_tokens=1000)
# Always return the original base first (to preserve bracketed URLs), then the AI enhancement
# If AI returned an empty string, fall back to base
if not ai_response or ai_response.strip() == "":
return base
return f"{ai_response.strip()}"
def fetch_steps_from_gsheet(csv_url=GOOGLE_SHEET_CSV_URL):
resp = requests.get(csv_url)
resp.raise_for_status()
csv_content = resp.text
df = pd.read_csv(StringIO(csv_content))
df = df.fillna("") # Remplace NaN par ""
steps = []
for _, row in df.iterrows():
# Only accept the exact CSV columns: step, pre_text, location, location_enigma,
# location_hint, code, question, question_hint, answer, comments, success_text
raw_step = row.get('step', "")
try:
number = int(float(raw_step)) if str(raw_step).strip() != "" else None
except Exception:
# skip invalid/empty step rows
continue
if number is None:
continue
pre_text = row.get('pre_text', "")
location = row.get('location', "")
location_enigma = row.get('location_enigma', "")
location_hint = row.get('location_hint', "")
code = str(row.get('code', ""))
question = row.get('question', "")
question_hint = row.get('question_hint', "")
answer = str(row.get('answer', ""))
comments = row.get('comments', "")
success_text = row.get('success_text', "")
# No media columns expected in this simplified mapping
image_path = ""
audio_path = ""
step = Step(
step=number,
pre_text=pre_text,
location=location,
location_enigma=location_enigma,
location_hint=location_hint,
code=code,
question=question,
question_hint=question_hint,
answer=answer,
comments=comments,
success_text=success_text,
image_path=image_path,
audio_path=audio_path,
)
steps.append(step)
return steps
@app.route("/api/matrix/incoming", methods=["POST"])
def matrix_incoming():
data = request.json
if data.get("secret") != FLASK_SHARED_SECRET:
return jsonify({"error": "Forbidden"}), 403
matrix_id = data.get("sender")
text = data.get("body", "").strip()
player = Player.query.filter_by(matrix_id=matrix_id).first()
is_new_player = False
if not player:
# initialize player with hint counters and stage (awaiting ready confirmation)
player = Player(
matrix_id=matrix_id,
current_step=1,
stage='awaiting_ready',
location_hint_index=0,
question_hint_index=0,
last_sent_step=None,
)
db.session.add(player)
db.session.commit()
is_new_player = True
# Log message reçu
db.session.add(MessageLog(player_id=player.id, sender="mag", content=text))
step = Step.query.filter_by(step=player.current_step).first()
# If no step found, respond accordingly
if not step:
reply = "(Aucune étape définie pour ce joueur pour le moment.)"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
db.session.commit()
return jsonify({"reply": reply})
# Ensure player has stage/hint counters attributes (in case models were not migrated)
if not hasattr(player, 'stage') or not player.stage:
player.stage = 'intro_needed'
if not hasattr(player, 'location_hint_index'):
player.location_hint_index = 0
if not hasattr(player, 'question_hint_index'):
player.question_hint_index = 0
reply = None
# If player is awaiting the "ready to play" confirmation
if player.stage == 'awaiting_ready':
# Use AI to judge whether the player's message indicates readiness and get a short Katniss reply
ready, kat_msg = is_player_ready_ai(player, text, step)
if ready:
# Prepare and send intro for the current step
player.stage = 'intro_needed'
db.session.commit()
if not step:
reply = "(Aucune étape définie pour le moment.)"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
db.session.commit()
return jsonify({"reply": reply})
# Instead of sending enigma now, send only the improved intro and ask readiness for enigma
intro = generate_intro_text(player, step)
player.stage = 'awaiting_ready_for_enigma'
player.location_hint_index = 0
player.question_hint_index = 0
player.last_sent_step = player.current_step
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=intro))
db.session.commit()
# Prompt the user to confirm when they're ready to see the enigma
ready_prompt = "T'es-tu prête pour l'énigme ? "
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
db.session.commit()
return jsonify({"reply": kat_msg + "\n\n" + intro + "\n\n" + ready_prompt})
else:
# AI says not ready: send Katniss encouraging message
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
db.session.commit()
return jsonify({"reply": kat_msg})
# If player answered the intro and we are waiting for them to confirm readiness for the enigma
if player.stage == 'awaiting_ready_for_enigma':
# Use AI to interpret readiness and get Katniss encouragement
ready, kat_msg = is_player_ready_ai(player, text, step)
if ready:
# send the enigma (improved by AI) and move to awaiting_code
enigma = generate_enigma_text(player, step)
player.stage = 'awaiting_code'
player.location_hint_index = 0
player.question_hint_index = 0
player.last_sent_step = player.current_step
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=enigma))
db.session.commit()
return jsonify({"reply": kat_msg + "\n\n" + enigma})
else:
# send encouraging message and remain in awaiting_ready_for_enigma
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
db.session.commit()
return jsonify({"reply": kat_msg})
# If player is awaiting confirmation after a success to continue to next step
if player.stage == 'awaiting_ready_after_success':
# The current_step was already incremented at success time; get the new step
next_step = Step.query.filter_by(step=player.current_step).first()
# Use AI to interpret readiness
ready, kat_msg = is_player_ready_ai(player, text, next_step)
if ready:
if not next_step:
reply = "(Aucune étape suivante définie.)"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
db.session.commit()
return jsonify({"reply": reply})
# send the intro for the next step (improved by AI) and move to awaiting_ready_for_enigma
next_intro = generate_intro_text(player, next_step)
player.stage = 'awaiting_ready_for_enigma'
player.location_hint_index = 0
player.question_hint_index = 0
player.last_sent_step = player.current_step
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=next_intro))
db.session.commit()
ready_prompt = "T'es-tu prête pour l'énigme ?"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
db.session.commit()
return jsonify({"reply": kat_msg + "\n\n" + next_intro + "\n\n" + ready_prompt})
else:
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
db.session.commit()
return jsonify({"reply": kat_msg})
# 1) If we need to send the intro for this step
if player.stage == 'intro_needed' or player.current_step != getattr(player, 'last_sent_step', None):
intro = generate_intro_text(player, step)
# store that we've sent the intro and reset hint counters, but wait for readiness before enigma
player.stage = 'awaiting_ready_for_enigma'
player.location_hint_index = 0
player.question_hint_index = 0
player.last_sent_step = player.current_step
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=intro))
db.session.commit()
ready_prompt = "T'es-tu prête pour l'énigme ?"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
db.session.commit()
return jsonify({"reply": intro + "\n\n" + ready_prompt})
# 2) We're waiting for the code
if player.stage == 'awaiting_code':
if matches_any(text, step.code or ""):
# Correct code
# If there's a question, send it (formatted by AI) and change stage
if step.question and step.question.strip() != "":
qtext = generate_question_text(player, step)
player.stage = 'awaiting_answer'
player.question_hint_index = 0
reply = qtext or step.question
else:
# No question: generate success, advance step and ask ready-to-continue
success = generate_success_text(player, step)
player.current_step += 1
# After success, wait for player's confirmation before sending next intro
player.stage = 'awaiting_ready_after_success'
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=success))
db.session.commit()
# Check if there's a next step; if so, prepare a ready prompt as followup
next_step = Step.query.filter_by(step=player.current_step).first()
if next_step:
# Optionally pre-generate and store the intro for debugging/history
next_intro = generate_intro_text(player, next_step)
player.last_sent_step = player.current_step
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=next_intro))
db.session.commit()
ready_prompt = "Bravo ! Veux-tu continuer vers l'étape suivante ?"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
db.session.commit()
return jsonify({"reply": success, "followup": ready_prompt})
else:
reply = success + "\n\nTu as terminé toutes les étapes. Félicitations !"
db.session.commit()
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
db.session.commit()
return jsonify({"reply": reply})
else:
# Incorrect code: send next location hint (one at a time)
raw_hints = (step.location_hint or "").split(";") if step.location_hint else []
idx = int(getattr(player, 'location_hint_index', 0))
if idx < len(raw_hints) and raw_hints[idx].strip() != "":
hint_raw = raw_hints[idx].strip()
hint_text = generate_formatted_hint(player, step, hint_raw, hint_kind='location', hint_index=idx)
player.location_hint_index = idx + 1
else:
# No more location hints available; ask AI to give a gentle generic hint
hint_text = generate_formatted_hint(player, step, step.location_hint or "", hint_kind='location', hint_index=idx)
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=hint_text))
db.session.commit()
return jsonify({"reply": hint_text})
# 3) We're waiting for the answer to the question
if player.stage == 'awaiting_answer':
if matches_any(text, step.answer or ""):
# Correct answer: generate success and advance
success = generate_success_text(player, step)
player.current_step += 1
# After success, wait for player's confirmation before sending next intro
player.stage = 'awaiting_ready_after_success'
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=success))
db.session.commit()
# send next intro later after confirmation; prepare ready prompt as followup if next exists
next_step = Step.query.filter_by(step=player.current_step).first()
if next_step:
next_intro = generate_step_intro(player, next_step)
player.last_sent_step = player.current_step
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=next_intro))
db.session.commit()
ready_prompt = "Bravo Mag ! T'es tu prête pour la suite ?"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
db.session.commit()
return jsonify({"reply": success, "followup": ready_prompt})
else:
reply = success + "\n\nTu as terminé toutes les étapes. Félicitations !"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
db.session.commit()
return jsonify({"reply": reply})
else:
# Incorrect answer: provide next question hint (one at a time)
raw_qhints = (step.question_hint or "").split(";") if step.question_hint else []
qidx = int(getattr(player, 'question_hint_index', 0))
if qidx < len(raw_qhints) and raw_qhints[qidx].strip() != "":
qhint_raw = raw_qhints[qidx].strip()
qhint_text = generate_formatted_hint(player, step, qhint_raw, hint_kind='question', hint_index=qidx)
player.question_hint_index = qidx + 1
else:
qhint_text = generate_formatted_hint(player, step, step.question_hint or "", hint_kind='question', hint_index=qidx)
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=qhint_text))
db.session.commit()
return jsonify({"reply": qhint_text})
# Log réponse Katniss
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
db.session.commit()
return jsonify({"reply": reply})
@app.route("/api/admin/reset_player", methods=["POST"])
def reset_player():
data = request.json or {}
secret = data.get("secret")
matrix_id = data.get("matrix_id")
if secret != FLASK_SHARED_SECRET:
return jsonify({"error": "Forbidden"}), 403
if not matrix_id:
return jsonify({"error": "matrix_id_required"}), 400
player = Player.query.filter_by(matrix_id=matrix_id).first()
if not player:
# Create player and set initial stage to awaiting_ready so we can prompt them
player = Player(
matrix_id=matrix_id,
current_step=1,
stage='awaiting_ready',
location_hint_index=0,
question_hint_index=0,
last_sent_step=None,
)
db.session.add(player)
db.session.commit()
# Prepare and log the ready prompt so the bot can forward it to the user
ready_prompt = "Partie initialisée. Es-tu prête à commencer les jeux ?"
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
db.session.commit()
return jsonify({"status": "created_and_reset", "current_step": player.current_step, "reply": ready_prompt})
# Efface lhistorique messages joueur seulement (pas global)
MessageLog.query.filter_by(player_id=player.id).delete()
player.current_step = 1
# After reset, ask the player if they're ready to start
player.stage = 'awaiting_ready'
player.location_hint_index = 0
player.question_hint_index = 0
player.last_sent_step = None
db.session.commit()
ready_prompt = "Partie réinitialisée. T'es-tu prête à recommencer les jeux ? "
# Log prompt
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
db.session.commit()
log.info(f"Player {matrix_id} reset to step 1 and awaiting ready confirmation.")
# Return the ready prompt to be sent to the player
return jsonify({"status": "reset_ok", "current_step": player.current_step, "reply": ready_prompt})
@app.route("/api/admin/push_message", methods=["POST"])
def admin_push():
data = request.json
matrix_id = data.get("matrix_id")
text = data.get("text")
player = Player.query.filter_by(matrix_id=matrix_id).first()
if not player:
return jsonify({"error": "player_not_found"}), 404
db.session.add(MessageLog(player_id=player.id, sender="admin", content=text))
db.session.commit()
return jsonify({"status": "ok"})
@app.route("/api/admin/gen_success", methods=["POST"])
def gen_success():
"""Generate the AI success text for the player's current step (debug endpoint).
POST JSON: { "secret": <shared_secret>, "matrix_id": "@user:server" }
Returns: { status: ok, reply: <text>, current_step: n }
"""
data = request.json or {}
secret = data.get("secret")
matrix_id = data.get("matrix_id")
if secret != FLASK_SHARED_SECRET:
return jsonify({"error": "Forbidden"}), 403
if not matrix_id:
return jsonify({"error": "matrix_id_required"}), 400
player = Player.query.filter_by(matrix_id=matrix_id).first()
if not player:
return jsonify({"error": "player_not_found"}), 404
step = Step.query.filter_by(step=player.current_step).first()
if not step:
return jsonify({"error": "step_not_found"}), 404
success = generate_success_text(player, step)
# Log the generated success text for debugging (do not advance the player)
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=success))
db.session.commit()
return jsonify({"status": "ok", "reply": success, "current_step": player.current_step})
@app.route("/api/admin/gen_question", methods=["POST"])
def gen_question():
"""Generate the AI question text for the player's current step (debug endpoint)."""
data = request.json or {}
secret = data.get("secret")
matrix_id = data.get("matrix_id")
if secret != FLASK_SHARED_SECRET:
return jsonify({"error": "Forbidden"}), 403
if not matrix_id:
return jsonify({"error": "matrix_id_required"}), 400
player = Player.query.filter_by(matrix_id=matrix_id).first()
if not player:
return jsonify({"error": "player_not_found"}), 404
step = Step.query.filter_by(step=player.current_step).first()
if not step:
return jsonify({"error": "step_not_found"}), 404
qtext = generate_question_text(player, step)
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=qtext))
db.session.commit()
return jsonify({"status": "ok", "reply": qtext, "current_step": player.current_step})
@app.route("/api/admin/gen_intro", methods=["POST"])
def gen_intro():
"""Generate the AI intro text for the player's current step (debug endpoint)."""
data = request.json or {}
secret = data.get("secret")
matrix_id = data.get("matrix_id")
if secret != FLASK_SHARED_SECRET:
return jsonify({"error": "Forbidden"}), 403
if not matrix_id:
return jsonify({"error": "matrix_id_required"}), 400
player = Player.query.filter_by(matrix_id=matrix_id).first()
if not player:
return jsonify({"error": "player_not_found"}), 404
step = Step.query.filter_by(step=player.current_step).first()
if not step:
return jsonify({"error": "step_not_found"}), 404
intro = generate_step_intro(player, step)
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=intro))
db.session.commit()
return jsonify({"status": "ok", "reply": intro, "current_step": player.current_step})
@app.route("/api/admin/gen_hint", methods=["POST"])
def gen_hint():
"""Generate a single formatted hint for the player's current step (debug endpoint).
POST JSON: { "secret": <shared_secret>, "matrix_id": "@user:server", "hint_kind": "location"|"question", "hint_index": 0, "hint_text": "optional raw hint" }
If hint_text is provided it is used directly. Otherwise the endpoint picks the hint at hint_index from the requested hint_kind field.
"""
data = request.json or {}
secret = data.get("secret")
matrix_id = data.get("matrix_id")
hint_kind = data.get("hint_kind", "location")
hint_index = int(data.get("hint_index", 0) or 0)
hint_text_override = data.get("hint_text")
if secret != FLASK_SHARED_SECRET:
return jsonify({"error": "Forbidden"}), 403
if not matrix_id:
return jsonify({"error": "matrix_id_required"}), 400
player = Player.query.filter_by(matrix_id=matrix_id).first()
if not player:
return jsonify({"error": "player_not_found"}), 404
step = Step.query.filter_by(step=player.current_step).first()
if not step:
return jsonify({"error": "step_not_found"}), 404
if hint_text_override and str(hint_text_override).strip() != "":
hint_raw = str(hint_text_override).strip()
else:
if hint_kind == 'question':
raw_list = (step.question_hint or "").split(";") if step.question_hint else []
else:
raw_list = (step.location_hint or "").split(";") if step.location_hint else []
if 0 <= hint_index < len(raw_list):
hint_raw = raw_list[hint_index].strip()
else:
# fallback to full hint field
hint_raw = (step.question_hint if hint_kind == 'question' else step.location_hint) or ""
hint_out = generate_formatted_hint(player, step, hint_raw, hint_kind=hint_kind, hint_index=hint_index)
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=hint_out))
db.session.commit()
return jsonify({"status": "ok", "reply": hint_out, "current_step": player.current_step})
# ... Commande init-db inchangée ...
# --------- Init DB ----------
@app.cli.command("init-db")
def init_db():
db.drop_all()
db.create_all()
steps = fetch_steps_from_gsheet()
for step in steps:
db.session.add(step)
db.session.commit()
print(f"{len(steps)} étapes importées avec succès depuis Google Sheet !")
if __name__ == "__main__":
with app.app_context():
db.create_all()
app.run(host="0.0.0.0", port=5000, debug=os.getenv("DEBUG", "false").lower() == "true")

365
bot.py Normal file
View File

@@ -0,0 +1,365 @@
import os
import asyncio
import time
import logging
import requests
from dotenv import load_dotenv
from nio import AsyncClient, LoginResponse, RoomMessageText
import markdown2
import re
import mimetypes
import io
from urllib.parse import urlparse
import functools
import coloredlogs
load_dotenv()
HOMESERVER = os.getenv("MATRIX_HOMESERVER")
USER = os.getenv("MATRIX_USER")
PASSWORD = os.getenv("MATRIX_PASSWORD")
ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN")
ROOM_ID = os.getenv("MATRIX_ROOM_ID")
STORE_PATH = os.getenv("MATRIX_STORE_PATH", ".matrixstore")
FLASK_INCOMING = os.getenv("FLASK_ENDPOINT_INCOMING", "http://localhost:5000/api/matrix/incoming")
FLASK_SHARED_SECRET = os.getenv("FLASK_SHARED_SECRET", "secret_flask_matrix")
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("matrix-bot")
# install coloredlogs for nicer colored output in terminals
try:
coloredlogs.install(level=logging.INFO, logger=log, fmt='%(asctime)s %(levelname)s %(message)s')
except Exception:
# coloredlogs optional; fallback silently
pass
class MatrixBot:
def __init__(self):
self.client = AsyncClient(HOMESERVER, USER, store_path=STORE_PATH)
if ACCESS_TOKEN:
self.client.access_token = ACCESS_TOKEN
self.client.user_id = USER
self.client.add_event_callback(self.message_callback, RoomMessageText)
# timestamp (ms) when the bot considers "connected"; events older than this are ignored
self.start_ts = 0
async def login(self):
if not ACCESS_TOKEN:
resp = await self.client.login(PASSWORD, device_name="LiaBot")
if isinstance(resp, LoginResponse):
log.info("Connecté en tant que %s", resp.user_id)
# mark connection time to discard past history (epoch ms)
self.start_ts = int(time.time() * 1000)
else:
raise RuntimeError(f"Login failed: {resp}")
else:
# if using ACCESS_TOKEN we still consider now as the connection time
self.start_ts = int(time.time() * 1000)
async def message_callback(self, room, event):
# ignore historical events that arrived before the bot connected
try:
evt_ts = None
for attr in ("server_timestamp", "server_ts", "origin_server_ts", "server_time", "ts"):
if hasattr(event, attr):
evt_ts = getattr(event, attr)
break
# Some nio events expose integer ms, some expose nested dicts; try to coerce
if evt_ts is None and hasattr(event, "__dict__"):
evt_ts = getattr(event, "__dict__", {}).get("server_timestamp")
if evt_ts and self.start_ts and int(evt_ts) < int(self.start_ts):
log.debug("Ignoring historical event (ts %s < start %s)", evt_ts, self.start_ts)
return
except Exception:
pass
log.error("received")
if event.sender != "@magali:conduit.blackdrop.fr":
return
body = (event.body or "").strip()
# Commande admin reset
if body.startswith("/reset"):
parts = body.split()
if len(parts) == 2:
target = parts[1]
else:
target = event.sender
try:
resp = requests.post(
os.getenv("FLASK_ENDPOINT_RESET_PLAYER", "http://localhost:5000/api/admin/reset_player"),
json={
"secret": FLASK_SHARED_SECRET,
"matrix_id": target
},
timeout=15
)
if resp.status_code == 200:
try:
body = resp.json()
reply = body.get("reply")
if reply:
await self.send_message(room.room_id, reply)
else:
await self.send_message(room.room_id, f"Reset de {target} effectué. Bon nouveau départ !")
except Exception:
await self.send_message(room.room_id, f"Reset de {target} effectué. Bon nouveau départ ! (Exception)")
else:
await self.send_message(room.room_id, f"Reset impossible ({resp.status_code}).")
except Exception as e:
await self.send_message(room.room_id, f"Erreur reset: {e}")
return
# Commande admin: regenere le texte de success pour debug
if body.startswith("/gen_success"):
parts = body.split()
target = parts[1] if len(parts) == 2 else event.sender
try:
resp = requests.post(
os.getenv("FLASK_ENDPOINT_GEN_SUCCESS", "http://localhost:5000/api/admin/gen_success"),
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
timeout=15,
)
if resp.status_code == 200:
try:
reply = resp.json().get("reply") or resp.text
except Exception:
reply = resp.text
else:
reply = f"gen_success failed ({resp.status_code})"
except Exception as e:
reply = f"Erreur gen_success: {e}"
await self.send_message(room.room_id, reply)
return
# Commande admin: regenere la question pour debug
if body.startswith("/gen_question"):
parts = body.split()
target = parts[1] if len(parts) == 2 else event.sender
try:
resp = requests.post(
os.getenv("FLASK_ENDPOINT_GEN_QUESTION", "http://localhost:5000/api/admin/gen_question"),
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
timeout=15,
)
if resp.status_code == 200:
try:
reply = resp.json().get("reply") or resp.text
except Exception:
reply = resp.text
else:
reply = f"gen_question failed ({resp.status_code})"
except Exception as e:
reply = f"Erreur gen_question: {e}"
await self.send_message(room.room_id, reply)
return
# Commande admin: regenere l'intro pour debug
if body.startswith("/gen_intro"):
parts = body.split()
target = parts[1] if len(parts) == 2 else event.sender
try:
resp = requests.post(
os.getenv("FLASK_ENDPOINT_GEN_INTRO", "http://localhost:5000/api/admin/gen_intro"),
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
timeout=15,
)
if resp.status_code == 200:
try:
reply = resp.json().get("reply") or resp.text
except Exception:
reply = resp.text
else:
reply = f"gen_intro failed ({resp.status_code})"
except Exception as e:
reply = f"Erreur gen_intro: {e}"
await self.send_message(room.room_id, reply)
return
# Commande admin: regenere un hint pour debug
if body.startswith("/gen_hint"):
parts = body.split()
target = parts[1] if len(parts) == 2 else event.sender
try:
resp = requests.post(
os.getenv("FLASK_ENDPOINT_GEN_HINT", "http://localhost:5000/api/admin/gen_hint"),
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
timeout=15,
)
if resp.status_code == 200:
try:
reply = resp.json().get("reply") or resp.text
except Exception:
reply = resp.text
else:
reply = f"gen_hint failed ({resp.status_code})"
except Exception as e:
reply = f"Erreur gen_hint: {e}"
await self.send_message(room.room_id, reply)
return
# Commande admin: regenere le texte de success pour debug
if body.startswith("/gen_success"):
parts = body.split()
if len(parts) == 2:
target = parts[1]
else:
target = event.sender
try:
resp = requests.post(
os.getenv("FLASK_ENDPOINT_GEN_SUCCESS", "http://localhost:5000/api/admin/gen_success"),
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
timeout=15,
)
if resp.status_code == 200:
try:
body_json = resp.json()
reply = body_json.get("reply") or body_json.get("success") or "(Pas de réponse)"
except Exception:
reply = resp.text or "(Réponse invalide du serveur)"
else:
reply = f"gen_success impossible ({resp.status_code})."
except Exception as e:
reply = f"Erreur gen_success: {e}"
await self.send_message(room.room_id, reply)
return
log.info(f"Message de {event.sender}: {event.body}")
payload = {
"sender": event.sender,
"body": event.body,
"room_id": room.room_id,
"secret": FLASK_SHARED_SECRET
}
try:
resp = requests.post(FLASK_INCOMING, json=payload, timeout=15)
resp.raise_for_status()
body = resp.json()
reply = body.get("reply", "(Pas de réponse)")
followup = body.get("followup")
except Exception as e:
log.error(f"Erreur appel Flask: {e}")
reply = "(Erreur Lia)"
await self.send_message(room.room_id, reply)
if followup:
# small pause then send followup as a separate message
await asyncio.sleep(1.0)
await self.send_message(room.room_id, followup)
async def send_message(self, room_id, text):
# Extract bracketed URLs like [https://...] to send as media later
matches = re.findall(r"\[(https?://[^\]\s]+)\]", text)
# Remove bracketed URLs from the chat text
cleaned_text = re.sub(r"\[(https?://[^\]\s]+)\]", "", text).strip()
if cleaned_text == "":
# If nothing left, keep original text as a fallback (so user still sees context)
cleaned_text = text
# Convert cleaned markdown to HTML
try:
text_html = markdown2.markdown(cleaned_text)
except Exception:
text_html = (cleaned_text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br>"))
await self.client.room_send(
room_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": cleaned_text, "format": "org.matrix.custom.html", "formatted_body": text_html}
)
# Send each extracted URL as a separate image/media event
for url in matches:
if not url:
continue
# small pause to avoid hammering the homeserver
await asyncio.sleep(0.3)
await self.send_media(room_id, url)
async def send_media(self, room_id, url):
"""Download the media at `url`, upload it to the Matrix media repo and send an m.image event.
Supports Google Drive share links by converting them to a direct download URL.
If download or upload fails, falls back to sending an m.room.message with the external URL.
"""
try:
# Convert Google Drive share URL to direct download URL if applicable
drive_match = re.search(r"/d/([a-zA-Z0-9_-]+)", url)
if drive_match:
file_id = drive_match.group(1)
download_url = f"https://drive.google.com/uc?export=download&id={file_id}"
dl_url = download_url
filename = f"{file_id}"
else:
dl_url = url
parsed = urlparse(url)
filename = os.path.basename(parsed.path) or parsed.netloc
# Download content in a thread to avoid blocking the event loop
get_fn = functools.partial(requests.get, dl_url, timeout=30, allow_redirects=True)
resp = await asyncio.to_thread(get_fn)
if resp.status_code != 200:
raise RuntimeError(f"Download failed: {resp.status_code}")
data = resp.content
mimetype = resp.headers.get("content-type") or mimetypes.guess_type(filename)[0] or "application/octet-stream"
# Try upload with small retry in case of transient error
upload_resp = None
mxc = None
for attempt in range(2):
try:
upload_resp = await self.client.upload(io.BytesIO(data), content_type=mimetype, filename=filename)
except Exception as e:
log.warning(f"upload attempt {attempt+1} failed: {e}")
upload_resp = e
# Try various ways to extract content URI
try:
if hasattr(upload_resp, "content_uri"):
mxc = upload_resp.content_uri
elif isinstance(upload_resp, dict):
mxc = upload_resp.get("content_uri") or upload_resp.get("content_uri")
elif hasattr(upload_resp, "response") and isinstance(upload_resp.response, dict):
mxc = upload_resp.response.get("content_uri") or upload_resp.response.get("content_uri")
else:
# fallback: try to stringify and look for mxc:// pattern
s = repr(upload_resp)
m = re.search(r"(mxc://[A-Za-z0-9/._=-]+)", s)
if m:
mxc = m.group(1)
except Exception:
mxc = None
if mxc:
break
# small backoff
await asyncio.sleep(0.2)
# If still no mxc, log debug info and raise
if not mxc:
log.warning("upload_resp (for debugging): %r", upload_resp)
raise RuntimeError("No mxc returned from upload")
info = {"mimetype": mimetype, "size": len(data)}
content = {
"msgtype": "m.image",
"body": filename,
"url": mxc,
"info": info,
}
await self.client.room_send(room_id, message_type="m.room.message", content=content)
return
except Exception as e:
log.warning(f"Upload/send media failed for {url}: {e}; falling back to external link")
# Fallback: send external link as a message so clients can still access it
try:
content = {"msgtype": "m.text", "body": url}
await self.client.room_send(room_id, message_type="m.room.message", content=content)
except Exception as e2:
log.error(f"Failed fallback send for {url}: {e2}")
async def run(self):
await self.login()
await self.client.sync_forever(timeout=30000)
if __name__ == "__main__":
bot = MatrixBot()
asyncio.run(bot.run())

BIN
instance/treasure.db Normal file

Binary file not shown.

53
models.py Normal file
View File

@@ -0,0 +1,53 @@
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
db = SQLAlchemy()
class Player(db.Model):
id = db.Column(db.Integer, primary_key=True)
matrix_id = db.Column(db.String(255), unique=True, nullable=False)
current_step = db.Column(db.Integer, default=1)
# Track per-player flow state and hint indices
stage = db.Column(db.String(50), nullable=True) # e.g. 'intro_needed','awaiting_code','awaiting_answer'
location_hint_index = db.Column(db.Integer, default=0)
question_hint_index = db.Column(db.Integer, default=0)
last_sent_step = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Step model matching the CSV columns expected by the application:
# step, pre_text, location, location_enigma, location_hint, code,
# question, question_hint, answer, comments, success_text
class Step(db.Model):
id = db.Column(db.Integer, primary_key=True)
step = db.Column(db.Integer, unique=True, nullable=False)
pre_text = db.Column(db.Text) # pre_texttexte to show at start of each steps
location = db.Column(db.String(255)) # location (for game master)
location_enigma = db.Column(db.Text)
location_hint = db.Column(db.String(255)) # location_hint
code = db.Column(db.String(50), nullable=False)
question = db.Column(db.Text) # question
question_hint = db.Column(db.Text) # question_hint
answer = db.Column(db.Text) # answer
comments = db.Column(db.Text) # comments
success_text = db.Column(db.Text) # success_text
# Display/media fields
image_path = db.Column(db.String(255))
audio_path = db.Column(db.String(255))
class MessageLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('player.id'))
sender = db.Column(db.String(50)) # mag, lia, admin
content = db.Column(db.Text)
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
class GameSession(db.Model):
id = db.Column(db.Integer, primary_key=True)
room_id = db.Column(db.String(255), unique=True, nullable=False)
player_matrix_id = db.Column(db.String(255), nullable=False) # seul ce matrix_id peut discuter avec l'IA
started_at = db.Column(db.DateTime, default=datetime.utcnow)

58
requirements.txt Normal file
View File

@@ -0,0 +1,58 @@
aiofiles==24.1.0
aiohappyeyeballs==2.6.1
aiohttp==3.12.15
aiohttp_socks==0.10.1
aiosignal==1.4.0
annotated-types==0.7.0
anyio==4.10.0
attrs==25.3.0
blinker==1.9.0
certifi==2025.8.3
charset-normalizer==3.4.3
click==8.2.1
distro==1.9.0
Flask==3.1.1
Flask-SQLAlchemy==3.1.1
frozenlist==1.7.0
greenlet==3.2.4
h11==0.16.0
h2==4.2.0
hpack==4.1.0
httpcore==1.0.9
httpx==0.28.1
hyperframe==6.1.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
jiter==0.10.0
jsonschema==4.25.0
jsonschema-specifications==2025.4.1
markdown2==2.5.4
MarkupSafe==3.0.2
matrix-nio==0.25.2
multidict==6.6.4
numpy==2.3.2
openai==1.99.9
pandas==2.3.1
propcache==0.3.2
pycryptodome==3.23.0
pydantic==2.11.7
pydantic_core==2.33.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-socks==2.7.2
pytz==2025.2
referencing==0.36.2
requests==2.32.4
rpds-py==0.27.0
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.43
tqdm==4.67.1
typing-inspection==0.4.1
typing_extensions==4.14.1
tzdata==2025.2
unpaddedbase64==2.1.0
urllib3==2.5.0
Werkzeug==3.1.3
yarl==1.20.1