feat: chatbot
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user