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:
OpenSquared
2026-06-21 10:36:05 +02:00
parent 5d3ff19393
commit 4ad3a9a782
7 changed files with 457 additions and 17 deletions

View File

@@ -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

View File

@@ -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", [])

View File

@@ -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")]

View File

@@ -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(

View 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

View File

@@ -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 = () =>

View File

@@ -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>