feat: portfolio context injection + AI call log viewer
Portfolio context (portfolio_context.py): - get_open_trades_with_moves(): fetches open trades + 1d/5d yfinance price moves - get_portfolio_concentration(): counts by asset_class - build_portfolio_context_block(): formatted prompt block with strict AI instructions (no double positions, flag contradictions, avoid overweight classes) AI call logging: - ai_call_logs table in DB (run_id, call_type, system/user prompt, response, tokens, ms) - _chat() now accepts log_meta dict → saves call to DB non-blocking after each call - suggest and score_batch calls pass run_id + call_type for full traceability auto_cycle.py: - Builds portfolio context before snapshot and both AI calls - Context snapshot now includes portfolio_open_positions key SystemLogs.tsx: - "Contexte IA" tab gains sub-tabs: Contexte / Appels IA - AiCallRow: expandable with 3 panes (user prompt / system prompt / response) shows model, tokens breakdown, duration, call type badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from services.database import get_cycle_runs, get_cycle_run, set_config, get_config, list_cycle_context_snapshots, get_cycle_context_snapshot
|
||||
from services.database import get_cycle_runs, get_cycle_run, set_config, get_config, list_cycle_context_snapshots, get_cycle_context_snapshot, get_ai_call_logs
|
||||
from services.auto_cycle import get_status, trigger_manual, restart_scheduler
|
||||
|
||||
router = APIRouter(prefix="/api/cycle", tags=["cycle"])
|
||||
@@ -100,6 +100,13 @@ def get_context_snapshot(run_id: str):
|
||||
return snap
|
||||
|
||||
|
||||
@router.get("/ai-calls/{run_id}")
|
||||
def get_cycle_ai_calls(run_id: str):
|
||||
"""Return all AI calls logged for a given cycle run_id."""
|
||||
calls = get_ai_call_logs(run_id)
|
||||
return {"run_id": run_id, "calls": calls, "count": len(calls)}
|
||||
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
override_notes: Optional[str] = None # optional annotation added to the replay
|
||||
|
||||
|
||||
@@ -22,7 +22,14 @@ def get_client() -> Optional[OpenAI]:
|
||||
return _client
|
||||
|
||||
|
||||
def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool = True, max_tokens: int = 1500) -> Optional[Dict]:
|
||||
def _chat(
|
||||
system: str,
|
||||
user: str,
|
||||
model: str = "gpt-4o-mini",
|
||||
json_mode: bool = True,
|
||||
max_tokens: int = 1500,
|
||||
log_meta: Optional[Dict] = None,
|
||||
) -> Optional[Dict]:
|
||||
import time as _time
|
||||
client = get_client()
|
||||
if not client:
|
||||
@@ -35,18 +42,22 @@ def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool =
|
||||
}
|
||||
if json_mode:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
last_exc = None
|
||||
for attempt in range(4): # up to 3 retries
|
||||
result: Optional[Dict] = None
|
||||
usage = None
|
||||
t0 = _time.time()
|
||||
|
||||
for attempt in range(4):
|
||||
try:
|
||||
resp = client.chat.completions.create(**kwargs)
|
||||
content = resp.choices[0].message.content
|
||||
if json_mode:
|
||||
return json.loads(content)
|
||||
return {"text": content}
|
||||
usage = resp.usage
|
||||
result = json.loads(content) if json_mode else {"text": content}
|
||||
break # success
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
err_str = str(e)
|
||||
# 429 rate-limit: respect the retry-after hint, then back off
|
||||
if "429" in err_str or "rate_limit" in err_str:
|
||||
import re as _re
|
||||
m = _re.search(r"try again in ([\d.]+)s", err_str)
|
||||
@@ -54,7 +65,32 @@ def _chat(system: str, user: str, model: str = "gpt-4o-mini", json_mode: bool =
|
||||
_time.sleep(min(wait, 60.0))
|
||||
continue
|
||||
raise # non-429 errors propagate immediately
|
||||
raise last_exc
|
||||
|
||||
if result is None:
|
||||
raise last_exc
|
||||
|
||||
# Persist AI call log when requested (non-blocking)
|
||||
if log_meta and log_meta.get("run_id"):
|
||||
try:
|
||||
from services.database import save_ai_call_log
|
||||
duration_ms = int((_time.time() - t0) * 1000)
|
||||
save_ai_call_log(
|
||||
run_id=log_meta["run_id"],
|
||||
call_type=log_meta.get("call_type", "unknown"),
|
||||
system_prompt=system,
|
||||
user_prompt=user,
|
||||
response_json=json.dumps(result, ensure_ascii=False),
|
||||
model=model,
|
||||
tokens_prompt=usage.prompt_tokens if usage else 0,
|
||||
tokens_completion=usage.completion_tokens if usage else 0,
|
||||
duration_ms=duration_ms,
|
||||
pattern_id=log_meta.get("pattern_id"),
|
||||
pattern_name=log_meta.get("pattern_name"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── News / Article Analysis ───────────────────────────────────────────────────
|
||||
@@ -356,6 +392,8 @@ def score_patterns_with_context(
|
||||
tech_indicators_block: str = "",
|
||||
fred_block: str = "",
|
||||
price_discovery_block: str = "",
|
||||
portfolio_context_block: str = "",
|
||||
run_id: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
|
||||
if not get_client():
|
||||
@@ -577,6 +615,7 @@ Instructions de notation:
|
||||
_tech_sc_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
|
||||
_fred_sc_section = f"\n{fred_block}\n" if fred_block else ""
|
||||
_pd_sc_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
|
||||
_portfolio_sc_section = f"\n{portfolio_context_block}\n" if portfolio_context_block else ""
|
||||
|
||||
user = f"""CONTEXTE GLOBAL:
|
||||
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
|
||||
@@ -586,6 +625,7 @@ Instructions de notation:
|
||||
{_fred_sc_section}
|
||||
{_pd_sc_section}
|
||||
{_tech_sc_section}
|
||||
{_portfolio_sc_section}
|
||||
TEMPLATE DE NOTATION:
|
||||
{scoring_template}
|
||||
|
||||
@@ -765,7 +805,10 @@ TEMPLATE DE NOTATION:
|
||||
+ _return_schema
|
||||
)
|
||||
try:
|
||||
res = _chat(SYSTEM_SCORER, batch_user, model="gpt-4o", json_mode=True, max_tokens=12000)
|
||||
res = _chat(
|
||||
SYSTEM_SCORER, batch_user, model="gpt-4o", json_mode=True, max_tokens=12000,
|
||||
log_meta={"run_id": run_id, "call_type": "score_batch", "pattern_name": f"batch:{','.join(ids[:3])}"} if run_id else None,
|
||||
)
|
||||
except Exception as e:
|
||||
_scorer_log.error(f"[Scorer] GPT-4o call failed for batch {ids}: {e}")
|
||||
res = None
|
||||
@@ -977,6 +1020,8 @@ def suggest_patterns_from_market_context(
|
||||
tech_indicators_block: str = "",
|
||||
fred_block: str = "",
|
||||
price_discovery_block: str = "",
|
||||
portfolio_context_block: str = "",
|
||||
run_id: str = "",
|
||||
) -> List[Dict]:
|
||||
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
|
||||
_cycle_meta = cycle_meta or {}
|
||||
@@ -1125,6 +1170,7 @@ Règles supplémentaires:
|
||||
tech_block_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
|
||||
fred_section = f"\n{fred_block}\n" if fred_block else ""
|
||||
pd_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
|
||||
portfolio_section = f"\n{portfolio_context_block}\n" if portfolio_context_block else ""
|
||||
|
||||
user = f"""Tu es un stratège géopolitique et financier senior, expert en options.
|
||||
{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block}
|
||||
@@ -1134,6 +1180,7 @@ Règles supplémentaires:
|
||||
{fred_section}
|
||||
{pd_section}
|
||||
{tech_block_section}
|
||||
{portfolio_section}
|
||||
## Calendrier économique à venir
|
||||
{cal_block}
|
||||
|
||||
@@ -1188,7 +1235,10 @@ Retourne UNIQUEMENT ce JSON:
|
||||
]
|
||||
}}"""
|
||||
|
||||
result = _chat(SYSTEM_SCORER, user, model="gpt-4o", json_mode=True, max_tokens=4000)
|
||||
result = _chat(
|
||||
SYSTEM_SCORER, user, model="gpt-4o", json_mode=True, max_tokens=4000,
|
||||
log_meta={"run_id": run_id, "call_type": "suggest"} if run_id else None,
|
||||
)
|
||||
if not result:
|
||||
return []
|
||||
return result.get("patterns", [])
|
||||
|
||||
@@ -183,6 +183,9 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
suggest_patterns_from_market_context, score_patterns_with_context,
|
||||
ai_score_news_batch, _chat, DEFAULT_ANALYSIS_TEMPLATE,
|
||||
)
|
||||
from services.portfolio_context import (
|
||||
get_open_trades_with_moves, get_portfolio_concentration, build_portfolio_context_block,
|
||||
)
|
||||
|
||||
# KB confidence decay (non-blocking)
|
||||
try:
|
||||
@@ -447,6 +450,18 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
except Exception as _te:
|
||||
logger.warning(f"[Cycle] Tech indicators failed (non-blocking): {_te}")
|
||||
|
||||
# ── Portfolio context (open positions + recent moves) ──────────
|
||||
_portfolio_block = ""
|
||||
_open_trades_snap = []
|
||||
_portfolio_conc = {}
|
||||
try:
|
||||
_open_trades_snap = get_open_trades_with_moves()
|
||||
_portfolio_conc = get_portfolio_concentration(_open_trades_snap)
|
||||
_portfolio_block = build_portfolio_context_block(_open_trades_snap, _portfolio_conc)
|
||||
logger.info(f"[Cycle {run_id[:16]}] Portfolio context: {len(_open_trades_snap)} open trades")
|
||||
except Exception as _pe:
|
||||
logger.warning(f"[Cycle] Portfolio context failed (non-blocking): {_pe}")
|
||||
|
||||
# ── Save full context snapshot ─────────────────────────────────
|
||||
try:
|
||||
from services.ai_analyzer import apply_news_decay as _apply_decay2, partition_news_by_age as _part
|
||||
@@ -473,6 +488,14 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
"iv_context_preview": iv_context[:500] if iv_context else "",
|
||||
"calendar": calendar[:8] if calendar else [],
|
||||
"quotes_summary": {cls: [{"symbol": q.get("symbol"), "price": q.get("price"), "change_pct": q.get("change_pct")} for q in qs[:3]] for cls, qs in quotes.items()},
|
||||
"portfolio_open_positions": {
|
||||
"count": len(_open_trades_snap),
|
||||
"concentration": _portfolio_conc,
|
||||
"trades": [
|
||||
{k: v for k, v in t.items() if k != "id"}
|
||||
for t in _open_trades_snap
|
||||
],
|
||||
},
|
||||
}
|
||||
save_cycle_context_snapshot(run_id, _context_snapshot)
|
||||
logger.info(f"[Cycle {run_id[:16]}] Context snapshot saved")
|
||||
@@ -488,6 +511,8 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
tech_indicators_block=_tech_block,
|
||||
fred_block=_fred_block,
|
||||
price_discovery_block=_price_discovery_block,
|
||||
portfolio_context_block=_portfolio_block,
|
||||
run_id=run_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Cycle] Suggestion step failed: {e}")
|
||||
@@ -614,6 +639,8 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
||||
tech_indicators_block=_tech_block,
|
||||
fred_block=_fred_block,
|
||||
price_discovery_block=_price_discovery_block,
|
||||
portfolio_context_block=_portfolio_block,
|
||||
run_id=run_id,
|
||||
)
|
||||
scored_with_id = [s for s in scored if s.get("pattern_id")]
|
||||
scored_without_id = [s for s in scored if not s.get("pattern_id")]
|
||||
|
||||
@@ -407,6 +407,26 @@ def init_db():
|
||||
context_json TEXT NOT NULL
|
||||
)""")
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS ai_call_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id TEXT NOT NULL,
|
||||
call_type TEXT NOT NULL,
|
||||
pattern_id TEXT,
|
||||
pattern_name TEXT,
|
||||
system_prompt TEXT,
|
||||
user_prompt TEXT,
|
||||
response_json TEXT,
|
||||
model TEXT DEFAULT 'gpt-4o',
|
||||
tokens_prompt INTEGER DEFAULT 0,
|
||||
tokens_completion INTEGER DEFAULT 0,
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
called_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)""")
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_acl_run ON ai_call_logs(run_id, called_at DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS news_price_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_hash TEXT NOT NULL,
|
||||
@@ -2239,6 +2259,60 @@ def list_cycle_context_snapshots(limit: int = 30) -> list:
|
||||
return [{"run_id": r["run_id"], "ts": r["ts"]} for r in rows]
|
||||
|
||||
|
||||
# ── AI Call Logs ──────────────────────────────────────────────────────────────
|
||||
|
||||
def save_ai_call_log(
|
||||
run_id: str,
|
||||
call_type: str,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
response_json: str,
|
||||
model: str = "gpt-4o",
|
||||
tokens_prompt: int = 0,
|
||||
tokens_completion: int = 0,
|
||||
duration_ms: int = 0,
|
||||
pattern_id: str = None,
|
||||
pattern_name: str = None,
|
||||
) -> None:
|
||||
import json as _json
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"""INSERT INTO ai_call_logs
|
||||
(run_id, call_type, pattern_id, pattern_name, system_prompt, user_prompt,
|
||||
response_json, model, tokens_prompt, tokens_completion, duration_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(run_id, call_type, pattern_id, pattern_name,
|
||||
system_prompt[:8000], # cap system prompt at 8KB
|
||||
user_prompt[:80000], # cap user prompt at 80KB
|
||||
response_json[:50000], # cap response at 50KB
|
||||
model, tokens_prompt, tokens_completion, duration_ms)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_ai_call_logs(run_id: str) -> list:
|
||||
import json as _json
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT id, call_type, pattern_id, pattern_name, system_prompt, user_prompt,
|
||||
response_json, model, tokens_prompt, tokens_completion, duration_ms, called_at
|
||||
FROM ai_call_logs WHERE run_id = ? ORDER BY called_at ASC""",
|
||||
(run_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
# Try to parse response_json for display
|
||||
try:
|
||||
d["response"] = _json.loads(d["response_json"]) if d["response_json"] else None
|
||||
except Exception:
|
||||
d["response"] = d["response_json"]
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ── News Price Snapshots (Phase 4 — Price Discovery) ─────────────────────────
|
||||
|
||||
def save_news_price_snapshot(
|
||||
|
||||
128
backend/services/portfolio_context.py
Normal file
128
backend/services/portfolio_context.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Portfolio context builder — injects open positions into AI prompts.
|
||||
|
||||
Provides:
|
||||
- get_open_trades_with_moves(): open trades + 1d/5d price moves via yfinance
|
||||
- get_portfolio_concentration(): count per asset_class
|
||||
- build_portfolio_context_block(): formatted prompt block for AI injection
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import date, datetime
|
||||
|
||||
_log = logging.getLogger("portfolio_context")
|
||||
|
||||
|
||||
def get_open_trades_with_moves() -> List[Dict]:
|
||||
"""Fetch all open trades and compute recent underlying price moves."""
|
||||
from services.database import get_trade_entry_prices, _normalize_asset_class, _asset_class_from_ticker
|
||||
import yfinance as yf
|
||||
|
||||
trades = get_trade_entry_prices(days=365) # all open trades regardless of age
|
||||
|
||||
result = []
|
||||
for t in trades:
|
||||
sym = t.get("underlying", "")
|
||||
entry_price = t.get("entry_price")
|
||||
move_1d = move_5d = current_price = None
|
||||
|
||||
try:
|
||||
hist = yf.Ticker(sym).history(period="5d", auto_adjust=True)
|
||||
if not hist.empty:
|
||||
current_price = float(hist["Close"].iloc[-1])
|
||||
if len(hist) >= 2:
|
||||
move_1d = (hist["Close"].iloc[-1] / hist["Close"].iloc[-2] - 1) * 100
|
||||
if len(hist) >= 5:
|
||||
move_5d = (hist["Close"].iloc[-1] / hist["Close"].iloc[0] - 1) * 100
|
||||
except Exception as e:
|
||||
_log.debug(f"[PortfolioCtx] yfinance failed for {sym}: {e}")
|
||||
|
||||
# Days held / remaining
|
||||
entry_str = t.get("entry_date", "")
|
||||
horizon = t.get("horizon_days") or 30
|
||||
days_held = 0
|
||||
days_remaining = horizon
|
||||
try:
|
||||
entry_dt = datetime.strptime(entry_str, "%Y-%m-%d").date()
|
||||
days_held = (date.today() - entry_dt).days
|
||||
days_remaining = max(0, horizon - days_held)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Canonical asset class
|
||||
cls = _normalize_asset_class(t.get("asset_class") or "", sym)
|
||||
|
||||
result.append({
|
||||
"id": t.get("id"),
|
||||
"underlying": sym,
|
||||
"strategy": t.get("strategy") or "?",
|
||||
"asset_class": cls,
|
||||
"pattern_name": (t.get("pattern_name") or "")[:50],
|
||||
"entry_price": entry_price,
|
||||
"current_price": round(current_price, 4) if current_price else None,
|
||||
"move_1d_pct": round(move_1d, 2) if move_1d is not None else None,
|
||||
"move_5d_pct": round(move_5d, 2) if move_5d is not None else None,
|
||||
"score_at_entry": t.get("score_at_entry"),
|
||||
"days_held": days_held,
|
||||
"days_remaining": days_remaining,
|
||||
"horizon_days": horizon,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_portfolio_concentration(open_trades: List[Dict]) -> Dict[str, int]:
|
||||
"""Count open trades by canonical asset_class."""
|
||||
conc: Dict[str, int] = {}
|
||||
for t in open_trades:
|
||||
cls = t.get("asset_class") or "unknown"
|
||||
conc[cls] = conc.get(cls, 0) + 1
|
||||
return conc
|
||||
|
||||
|
||||
def build_portfolio_context_block(open_trades: List[Dict], concentration: Dict[str, int]) -> str:
|
||||
"""Build a prompt section describing current portfolio for injection into AI prompts."""
|
||||
if not open_trades:
|
||||
return "\n## PORTEFEUILLE ACTUEL\nAucun trade en cours — portefeuille vide.\n"
|
||||
|
||||
total = len(open_trades)
|
||||
conc_sorted = sorted(concentration.items(), key=lambda x: -x[1])
|
||||
conc_str = " | ".join(f"{cls.upper()}: {n}" for cls, n in conc_sorted)
|
||||
|
||||
lines: List[str] = [f"Total: {total} trade(s) ouvert(s) | Concentration: {conc_str}", ""]
|
||||
|
||||
for t in open_trades:
|
||||
sym = t["underlying"]
|
||||
strat = t["strategy"]
|
||||
cls = t.get("asset_class") or "?"
|
||||
held = t.get("days_held", "?")
|
||||
rem = t.get("days_remaining", "?")
|
||||
m1d_str = f"{t['move_1d_pct']:+.1f}%" if t.get("move_1d_pct") is not None else "N/A"
|
||||
m5d_str = f"{t['move_5d_pct']:+.1f}%" if t.get("move_5d_pct") is not None else "N/A"
|
||||
pat = t.get("pattern_name", "")
|
||||
entry = t.get("entry_price")
|
||||
cur = t.get("current_price")
|
||||
ep_str = f"entrée {entry:.2f} → actuel {cur:.2f}" if entry and cur else ""
|
||||
|
||||
line = f" • {sym} | {strat} [{cls}] | {held}j tenu / {rem}j restants | J-1: {m1d_str} | J-5: {m5d_str}"
|
||||
if ep_str:
|
||||
line += f" | {ep_str}"
|
||||
if pat:
|
||||
line += f" | thèse: «{pat}»"
|
||||
lines.append(line)
|
||||
|
||||
# Identify overweight classes
|
||||
overweight = [cls for cls, n in conc_sorted if n >= 3]
|
||||
ow_str = ", ".join(overweight) if overweight else "aucune"
|
||||
|
||||
block = (
|
||||
"\n## PORTEFEUILLE ACTUEL — POSITIONS OUVERTES\n"
|
||||
+ "\n".join(lines)
|
||||
+ f"\n\nClasses surpondérées (≥3 trades): {ow_str}\n"
|
||||
+ "\n⚠️ CONSIGNES IMPÉRATIVES (non négociables):\n"
|
||||
+ "1. NE PAS suggérer un nouveau trade sur un sous-jacent déjà en portefeuille — doublement interdit.\n"
|
||||
+ "2. Signaler explicitement dans 'rationale' si une suggestion CONTREDIT une position ouverte (signal de clôture potentiel).\n"
|
||||
+ "3. Éviter d'alourdir une classe surpondérée (≥3 trades) sauf catalyseur exceptionnel justifié.\n"
|
||||
+ "4. Un signal opposé à une position ouverte = opportunité de SORTIE à documenter, pas d'entrée inversée.\n"
|
||||
)
|
||||
return block
|
||||
@@ -888,6 +888,14 @@ export const useReplayCycle = () =>
|
||||
api.post(`/cycle/contexts/${runId}/replay`, { override_notes: notes ?? null }).then(r => r.data),
|
||||
})
|
||||
|
||||
export const useAiCallLogs = (runId: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ['ai-call-logs', runId],
|
||||
queryFn: () => api.get(`/cycle/ai-calls/${runId}`).then(r => r.data),
|
||||
enabled: !!runId,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// ── IV Watchlist Management ───────────────────────────────────────────────────
|
||||
|
||||
export const useWatchlistTickers = () =>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain, Play, Loader2 } from 'lucide-react'
|
||||
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain, Play, Loader2, Zap, MessageSquare, BarChart2, Clock } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, type LogFilters } from '../hooks/useApi'
|
||||
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, useAiCallLogs, type LogFilters } from '../hooks/useApi'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
@@ -76,9 +76,127 @@ function LogRow({ log }: { log: any }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── AI call type config ──────────────────────────────────────────────────────
|
||||
const AI_CALL_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
suggest: { label: 'Suggestion patterns', color: 'text-purple-300 border-purple-700/40 bg-purple-900/20', icon: <Brain className="w-3 h-3" /> },
|
||||
score_batch: { label: 'Scoring batch', color: 'text-cyan-300 border-cyan-700/40 bg-cyan-900/20', icon: <BarChart2 className="w-3 h-3" /> },
|
||||
unknown: { label: 'Appel IA', color: 'text-slate-300 border-slate-700/40 bg-slate-800', icon: <Zap className="w-3 h-3" /> },
|
||||
}
|
||||
|
||||
function AiCallRow({ call }: { call: any }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activePane, setActivePane] = useState<'user' | 'system' | 'response'>('user')
|
||||
const cfg = AI_CALL_CONFIG[call.call_type] ?? AI_CALL_CONFIG.unknown
|
||||
const ts = (() => { try { return format(new Date(call.called_at), 'HH:mm:ss', { locale: fr }) } catch { return call.called_at } })()
|
||||
const totalTokens = (call.tokens_prompt ?? 0) + (call.tokens_completion ?? 0)
|
||||
|
||||
const responseStr = useMemo(() => {
|
||||
if (!call.response) return ''
|
||||
try { return JSON.stringify(call.response, null, 2) } catch { return String(call.response) }
|
||||
}, [call.response])
|
||||
|
||||
return (
|
||||
<div className="border border-slate-800 rounded overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-slate-800/50 transition-colors text-left"
|
||||
>
|
||||
<span className={clsx('inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-[10px] font-semibold shrink-0', cfg.color)}>
|
||||
{cfg.icon} {cfg.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono shrink-0">{ts}</span>
|
||||
{call.pattern_name && (
|
||||
<span className="text-[10px] text-slate-400 truncate">— {call.pattern_name}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||
{totalTokens > 0 && (
|
||||
<span className="text-[10px] text-slate-600 font-mono">{totalTokens.toLocaleString()} tokens</span>
|
||||
)}
|
||||
{call.duration_ms > 0 && (
|
||||
<span className="text-[10px] text-slate-600 font-mono flex items-center gap-0.5">
|
||||
<Clock className="w-2.5 h-2.5" />{(call.duration_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
{expanded ? <ChevronDown className="w-3.5 h-3.5 text-slate-500" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-500" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-800">
|
||||
{/* Token breakdown */}
|
||||
{totalTokens > 0 && (
|
||||
<div className="flex gap-4 px-3 py-2 bg-slate-900/50 text-[10px] font-mono text-slate-500 border-b border-slate-800">
|
||||
<span>Modèle: <span className="text-slate-300">{call.model || 'gpt-4o'}</span></span>
|
||||
<span>Prompt: <span className="text-amber-300">{(call.tokens_prompt ?? 0).toLocaleString()} tok</span></span>
|
||||
<span>Completion: <span className="text-green-300">{(call.tokens_completion ?? 0).toLocaleString()} tok</span></span>
|
||||
<span>Total: <span className="text-white">{totalTokens.toLocaleString()} tok</span></span>
|
||||
<span>Durée: <span className="text-purple-300">{(call.duration_ms / 1000).toFixed(2)}s</span></span>
|
||||
</div>
|
||||
)}
|
||||
{/* Pane selector */}
|
||||
<div className="flex gap-0 border-b border-slate-800">
|
||||
{([
|
||||
{ key: 'user' as const, label: 'Prompt utilisateur', icon: <MessageSquare className="w-3 h-3" /> },
|
||||
{ key: 'system' as const, label: 'Prompt système', icon: <Brain className="w-3 h-3" /> },
|
||||
{ key: 'response' as const, label: 'Réponse IA', icon: <Zap className="w-3 h-3" /> },
|
||||
]).map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => setActivePane(p.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 px-3 py-1.5 text-[10px] font-medium border-b-2 -mb-px transition-colors',
|
||||
activePane === p.key
|
||||
? 'border-purple-500 text-purple-300'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{p.icon} {p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="p-3 bg-black/40 max-h-[500px] overflow-y-auto">
|
||||
{activePane === 'user' && (
|
||||
<pre className="text-[10px] font-mono text-slate-300 whitespace-pre-wrap leading-relaxed">{call.user_prompt || '(vide)'}</pre>
|
||||
)}
|
||||
{activePane === 'system' && (
|
||||
<pre className="text-[10px] font-mono text-slate-400 whitespace-pre-wrap leading-relaxed">{call.system_prompt || '(vide)'}</pre>
|
||||
)}
|
||||
{activePane === 'response' && (
|
||||
<pre className="text-[10px] font-mono text-green-300 whitespace-pre-wrap leading-relaxed">{responseStr || '(pas de réponse)'}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiCallsSection({ runId }: { runId: string }) {
|
||||
const { data, isLoading } = useAiCallLogs(runId)
|
||||
const calls: any[] = data?.calls ?? []
|
||||
|
||||
if (isLoading) return <div className="p-4 text-xs text-slate-500">Chargement des appels IA…</div>
|
||||
if (calls.length === 0) return (
|
||||
<div className="p-4 text-xs text-slate-600 text-center">
|
||||
Aucun appel IA enregistré pour ce cycle — les logs seront disponibles dès le prochain cycle.
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] text-slate-500 px-1 font-mono">
|
||||
{calls.length} appel(s) IA — cliquer pour voir prompts + réponse complète
|
||||
</div>
|
||||
{calls.map(c => <AiCallRow key={c.id} call={c} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextTab() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
||||
const [replayNotes, setReplayNotes] = useState('')
|
||||
const [rightPane, setRightPane] = useState<'context' | 'calls'>('context')
|
||||
const [replayResult, setReplayResult] = useState<any>(null)
|
||||
const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30)
|
||||
const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId)
|
||||
@@ -113,7 +231,7 @@ function ContextTab() {
|
||||
{snapshots.map(s => (
|
||||
<button
|
||||
key={s.run_id}
|
||||
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null) }}
|
||||
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null); setRightPane('context') }}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2.5 hover:bg-slate-800/60 transition-colors',
|
||||
selectedRunId === s.run_id && 'bg-purple-900/30 border-l-2 border-purple-500'
|
||||
@@ -191,12 +309,40 @@ function ContextTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context sections */}
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
||||
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
|
||||
<ContextSection key={key} label={key} value={val} />
|
||||
{/* Sub-tabs: Contexte / Appels IA */}
|
||||
<div className="flex gap-0 border-b border-slate-800 bg-slate-900/40 shrink-0">
|
||||
{([
|
||||
{ key: 'context' as const, label: 'Contexte IA', icon: <Brain className="w-3 h-3" /> },
|
||||
{ key: 'calls' as const, label: 'Appels IA', icon: <Zap className="w-3 h-3" /> },
|
||||
]).map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setRightPane(t.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 -mb-px transition-colors',
|
||||
rightPane === t.key
|
||||
? 'border-purple-500 text-purple-300'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rightPane === 'context' && (
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
||||
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
|
||||
<ContextSection key={key} label={key} value={val} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPane === 'calls' && (
|
||||
<div className="overflow-y-auto flex-1 p-3">
|
||||
<AiCallsSection runId={selectedRunId} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user