feat: chatbot

This commit is contained in:
OpenSquared
2026-07-15 08:47:16 +02:00
parent da536e2638
commit ce9c0b53a9
11 changed files with 837 additions and 64 deletions

View File

@@ -1,11 +1,14 @@
"""
Free-form, read-only chat with GPT-4o about the current cockpit state.
Free-form chat with GPT-4o about the current cockpit state.
Deliberately has NO function-calling/tools wired up — a plain text-completion
call physically cannot trigger any action (no trade, no cycle, no DB write
beyond persisting the conversation itself). The system prompt also tells the
model explicitly not to claim it can act, so it doesn't mislead the user.
The only action the model can trigger is `propose_trade` — even then, it never
touches the real portfolio: the tool call just writes a 'pending' row to
ai_trade_proposals. The user has to explicitly confirm from the Trade Ideas UI
(POST /api/ai-chat/trade-proposals/{id}/confirm) before anything becomes a real
position. No other tool is wired up, so nothing else can ever be triggered from
here (no cycle, no data mutation, no close/edit of existing positions).
"""
import json
import re
import time
from typing import Dict, List, Optional
@@ -18,7 +21,8 @@ Tu as acces ci-dessous a un instantane en lecture seule de la situation actuelle
REGLES IMPORTANTES :
- Quand on te demande une idee ou un conseil de trade, PROPOSE quelque chose de concret (biais directionnel, instrument, montage d'options avec strikes/echeance si pertinent, niveaux techniques, justification tiree du contexte) - exactement comme le ferait le cycle automatique dans ses recommandations. Ne te contente pas d'observations vagues ni de renvoyer la question : prends position a partir du contexte fourni.
- La seule limite reelle est que tu ne peux EXECUTER aucune action toi-meme (aucun trade n'est passe, aucun cycle n'est declenche, aucune donnee n'est modifiee) - tes idees sont des suggestions que l'utilisateur doit valider et executer lui-meme ailleurs dans le cockpit. Ne le precise que si l'utilisateur semble croire que tu peux agir directement (ex. "achete X pour moi").
- Tu disposes de l'outil propose_trade pour enregistrer une idee concrete. Utilise-le UNIQUEMENT quand l'utilisateur demande explicitement un conseil de trade ou valide clairement une idee que tu viens de suggerer - jamais de maniere systematique a chaque message. Chaque appel cree une proposition EN ATTENTE dans Trade Ideas ; rien n'est jamais execute automatiquement.
- La seule limite reelle est que tu ne peux EXECUTER aucune action toi-meme (aucun trade n'est passe directement, aucun cycle n'est declenche, aucune position existante n'est modifiee) - tes idees sont des suggestions que l'utilisateur doit valider lui-meme. Ne le precise que si l'utilisateur semble croire que tu peux agir directement (ex. "achete X pour moi").
- Reponds en francais, de facon concise et directe, en t'appuyant sur le contexte fourni. Si une donnee demandee n'est pas dans le contexte ci-dessous, dis-le plutot que d'inventer.
=== CONTEXTE ACTUEL ===
@@ -26,11 +30,55 @@ REGLES IMPORTANTES :
=== FIN DU CONTEXTE ===
"""
TRADE_PROPOSAL_TOOL = {
"type": "function",
"function": {
"name": "propose_trade",
"description": (
"Enregistre une idee de trade concrete EN ATTENTE dans Trade Ideas. "
"N'execute RIEN et n'ouvre AUCUNE position — l'utilisateur doit explicitement "
"confirmer depuis l'interface pour que ca devienne un trade reel dans le portefeuille. "
"N'appelle cet outil que lorsque l'utilisateur demande explicitement un conseil de trade "
"ou valide clairement une idee que tu as suggeree — jamais de maniere systematique."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Titre court de l'idee"},
"underlying": {"type": "string", "description": "Ticker Yahoo Finance du sous-jacent (ex: EURUSD=X, CL=F, SPY, GC=F)"},
"strategy": {"type": "string", "description": "Nom de la strategie (ex: long call, put spread, straddle, short strangle, directionnel spot)"},
"asset_class": {"type": "string", "enum": ["indices", "forex", "commodities", "rates", "crypto", "equities"]},
"expiry_days": {"type": "integer", "description": "Horizon en jours jusqu'a l'echeance"},
"capital_invested": {"type": "number", "description": "Capital alloue en EUR"},
"legs": {
"type": "array",
"description": "Legs optionnelles du montage (liste vide pour un trade directionnel simple sans options)",
"items": {
"type": "object",
"properties": {
"strike": {"type": "number"},
"option_type": {"type": "string", "enum": ["call", "put"]},
"quantity": {"type": "integer"},
"position": {"type": "string", "enum": ["long", "short"]},
},
"required": ["strike", "option_type", "quantity", "position"],
},
},
"geo_trigger": {"type": "string", "description": "Evenement/catalyseur declencheur, si pertinent"},
"rationale": {"type": "string", "description": "Justification concise, appuyee sur le contexte fourni"},
},
"required": ["title", "underlying", "strategy", "capital_invested", "rationale"],
},
},
}
def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max_tokens: int = 1200) -> str:
def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max_tokens: int = 1200, tools: Optional[List[Dict]] = None):
"""Multi-turn variant of ai_analyzer._chat() — accepts a full message history
instead of a single system+user pair. Same client/retry/backoff logic, kept
independent so it never risks the well-tested cycle-facing _chat()."""
instead of a single system+user pair, and optionally OpenAI tool schemas.
Returns the raw SDK message object (not just its text) so callers can
inspect tool_calls. Same client/retry/backoff logic, kept independent so it
never risks the well-tested cycle-facing _chat()."""
client = get_client()
if not client:
raise RuntimeError("OpenAI API key not configured")
@@ -41,12 +89,14 @@ def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max
"temperature": 0.4,
"max_tokens": max_tokens,
}
if tools:
kwargs["tools"] = tools
last_exc: Optional[Exception] = None
for attempt in range(4):
try:
resp = client.chat.completions.create(**kwargs)
return resp.choices[0].message.content or ""
return resp.choices[0].message
except Exception as e:
last_exc = e
err_str = str(e)
@@ -59,6 +109,27 @@ def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max
raise last_exc # type: ignore[misc]
def _handle_tool_call(tc, session_id: str) -> tuple:
"""Executes one tool call. Returns (tool_result_text, trade_proposal_or_None)."""
from services.database import save_ai_trade_proposal
if tc.function.name != "propose_trade":
return "Outil inconnu.", None
try:
args = json.loads(tc.function.arguments or "{}")
proposal_id = save_ai_trade_proposal({**args, "session_id": session_id})
trade_proposal = {
"id": proposal_id,
"title": args.get("title"),
"underlying": args.get("underlying"),
"strategy": args.get("strategy"),
}
return f"Proposition enregistree (id={proposal_id}), EN ATTENTE dans Trade Ideas. Rien n'a ete execute.", trade_proposal
except Exception as e:
return f"Erreur lors de l'enregistrement de la proposition: {e}", None
def send_chat_message(
session_id: str,
message: str,
@@ -77,7 +148,29 @@ def send_chat_message(
messages.append({"role": "user", "content": message})
save_chat_message(session_id, "user", message)
reply = _chat_messages(system, messages)
save_chat_message(session_id, "assistant", reply)
return {"reply": reply, "blocks_included": list(blocks.keys())}
reply_msg = _chat_messages(system, messages, tools=[TRADE_PROPOSAL_TOOL])
trade_proposal = None
if reply_msg.tool_calls:
messages.append({
"role": "assistant",
"content": reply_msg.content,
"tool_calls": [
{"id": tc.id, "type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments}}
for tc in reply_msg.tool_calls
],
})
for tc in reply_msg.tool_calls:
tool_result, proposal = _handle_tool_call(tc, session_id)
trade_proposal = proposal or trade_proposal
messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_result})
final_msg = _chat_messages(system, messages) # no tools this round — forces a final text reply
reply_text = final_msg.content or ""
else:
reply_text = reply_msg.content or ""
save_chat_message(session_id, "assistant", reply_text)
return {"reply": reply_text, "blocks_included": list(blocks.keys()), "trade_proposal": trade_proposal}