fix(dashboard,worker): vérité produit P0 — dashboard+worker+VWB export
War-room clôture DGX 2026-06-18 (recadrage Dom : graphe/apprentissage/mémoire/dashboard = surface produit P0). Le dashboard et le statut worker affichaient des états faux ; corrige pour refléter la vérité du produit. - dashboard FAISS: distingue index brut / metadata HMAC invalide / runtime / absent (plus de faux "inactif") - dashboard process-mining: 503 explicite missing_dependency (plus de message trompeur) - dashboard /api/workflows + system/status: lecture DB VWB v3 canonique (total réel = 24, plus de 0) - worker /processing/status: véridique (lit _worker_health.json) + statut "idle/armé (lazy)" distinct de "dégradé (échec)" - VWB export: N steps -> N actions/edges (dernière action n'est plus perdue) - tests: dashboard routes, worker status truthfulness, export VWB Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -189,6 +189,20 @@ SESSIONS_PATH = DATA_PATH / "sessions"
|
||||
WORKFLOWS_PATH = DATA_PATH / "workflows"
|
||||
LOGS_PATH = BASE_PATH / "logs"
|
||||
|
||||
# Source canonique des workflows (décision produit D3) : la base VWB v3
|
||||
# (SQLAlchemy/SQLite) que Léa lit déjà au runtime. Chemin absolu robuste (PAS la
|
||||
# DB fantôme vide à la racine du repo `instance/workflows.db`, schéma obsolète,
|
||||
# ni l'ancien store JSON `data/training/workflows/` créé vide sur DGX).
|
||||
# Surchargeable via RPA_VWB_DB_PATH pour les déploiements atypiques.
|
||||
def _resolve_vwb_db_path() -> Path:
|
||||
override = os.getenv("RPA_VWB_DB_PATH", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return BASE_PATH / "visual_workflow_builder" / "backend" / "instance" / "workflows.db"
|
||||
|
||||
|
||||
VWB_DB_PATH = _resolve_vwb_db_path()
|
||||
|
||||
# StorageManager
|
||||
storage = StorageManager(base_path=str(DATA_PATH))
|
||||
|
||||
@@ -261,7 +275,9 @@ def system_status():
|
||||
"""Statut du système."""
|
||||
try:
|
||||
sessions_count = len(list(SESSIONS_PATH.glob('*'))) if SESSIONS_PATH.exists() else 0
|
||||
workflows_count = len(list(WORKFLOWS_PATH.glob('*.json'))) if WORKFLOWS_PATH.exists() else 0
|
||||
# Source canonique D3 : base VWB v3 (même comptage que /api/workflows),
|
||||
# pas l'ancien store JSON `data/training/workflows/` créé vide sur DGX.
|
||||
workflows_count = len(_load_workflows_from_vwb_db())
|
||||
|
||||
dependencies_ok = True
|
||||
try:
|
||||
@@ -296,8 +312,26 @@ def system_performance():
|
||||
faiss_metadata_path = DATA_PATH / "faiss_index" / "main.metadata"
|
||||
|
||||
if faiss_index_path.exists() and faiss_metadata_path.exists():
|
||||
fm = FAISSManager.load(faiss_index_path, faiss_metadata_path)
|
||||
faiss_stats = fm.get_stats()
|
||||
try:
|
||||
fm = FAISSManager.load(faiss_index_path, faiss_metadata_path)
|
||||
faiss_stats = fm.get_stats()
|
||||
faiss_stats.setdefault("status", "active")
|
||||
faiss_stats.setdefault("metadata_status", "valid")
|
||||
except Exception as e:
|
||||
faiss_stats = _read_raw_faiss_index_stats(faiss_index_path)
|
||||
faiss_stats.update({
|
||||
"status": "metadata_invalid",
|
||||
"metadata_status": "invalid",
|
||||
"metadata_error": str(e),
|
||||
"action_required": "re-sign-or-rebuild-faiss-metadata",
|
||||
})
|
||||
elif faiss_index_path.exists():
|
||||
faiss_stats = _read_raw_faiss_index_stats(faiss_index_path)
|
||||
faiss_stats.update({
|
||||
"status": "metadata_missing",
|
||||
"metadata_status": "missing",
|
||||
"action_required": "rebuild-faiss-metadata",
|
||||
})
|
||||
else:
|
||||
faiss_stats = {"total_vectors": 0, "status": "index_not_found"}
|
||||
except Exception as e:
|
||||
@@ -319,6 +353,26 @@ def system_performance():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
def _read_raw_faiss_index_stats(index_path: Path) -> dict:
|
||||
"""Read non-authoritative FAISS stats when signed metadata cannot load."""
|
||||
try:
|
||||
import faiss
|
||||
index = faiss.read_index(str(index_path))
|
||||
return {
|
||||
"raw_index_available": True,
|
||||
"total_vectors": int(getattr(index, "ntotal", 0)),
|
||||
"dimensions": int(getattr(index, "d", 0)),
|
||||
"is_trained": bool(getattr(index, "is_trained", False)),
|
||||
"index_type": type(index).__name__.replace("Index", "") or "FAISS",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"raw_index_available": False,
|
||||
"total_vectors": 0,
|
||||
"error": f"Index FAISS illisible: {e}",
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/system/faiss/test', methods=['POST'])
|
||||
def test_faiss_index():
|
||||
"""Teste l'index FAISS avec une recherche aléatoire."""
|
||||
@@ -785,36 +839,83 @@ def rename_session_workflow(session_id):
|
||||
# API Workflows
|
||||
# =============================================================================
|
||||
|
||||
def _load_workflows_from_vwb_db() -> list:
|
||||
"""Charge les workflows depuis la base VWB v3 (source canonique D3).
|
||||
|
||||
Lit directement le SQLite que Léa interroge au runtime (cf.
|
||||
`agent_chat/app.py` → `GET /api/v3/session/state`). On compte les `steps`
|
||||
par workflow pour `nodes_count` (pas de notion d'`edges` en DAG linéaire :
|
||||
`edges_count` = max(steps-1, 0)). Robuste à l'absence de la DB ou des
|
||||
colonnes `source`/`review_status` (DB ancienne) : retourne [] sans planter.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
if not VWB_DB_PATH.exists():
|
||||
return []
|
||||
|
||||
workflows = []
|
||||
conn = sqlite3.connect(str(VWB_DB_PATH))
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Colonnes disponibles (la DB fantôme/ancienne n'a pas source/review_status)
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(workflows)")}
|
||||
has_source = 'source' in cols
|
||||
has_review = 'review_status' in cols
|
||||
|
||||
select_cols = ['id', 'name', 'description', 'created_at', 'updated_at']
|
||||
if has_source:
|
||||
select_cols.append('source')
|
||||
if has_review:
|
||||
select_cols.append('review_status')
|
||||
|
||||
# Nombre de steps par workflow (= nodes du DAG)
|
||||
step_counts = {
|
||||
row[0]: row[1]
|
||||
for row in conn.execute(
|
||||
"SELECT workflow_id, COUNT(*) FROM steps GROUP BY workflow_id"
|
||||
)
|
||||
}
|
||||
|
||||
rows = conn.execute(
|
||||
f"SELECT {', '.join(select_cols)} FROM workflows ORDER BY updated_at DESC"
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
wf_id = row['id']
|
||||
nodes_count = step_counts.get(wf_id, 0)
|
||||
workflows.append({
|
||||
'workflow_id': wf_id,
|
||||
'name': row['name'] or wf_id,
|
||||
'description': row['description'] or '',
|
||||
'nodes_count': nodes_count,
|
||||
'edges_count': max(nodes_count - 1, 0),
|
||||
'learning_state': 'OBSERVATION',
|
||||
'created_at': str(row['created_at'] or ''),
|
||||
'updated_at': str(row['updated_at'] or ''),
|
||||
'execution_count': 0,
|
||||
'source': row['source'] if has_source else 'manual',
|
||||
'review_status': row['review_status'] if has_review else '',
|
||||
'file_path': f"vwb_db://{wf_id}",
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return workflows
|
||||
|
||||
|
||||
@app.route('/api/workflows')
|
||||
def list_workflows():
|
||||
"""Liste tous les workflows."""
|
||||
"""Liste tous les workflows depuis la base VWB v3 (source canonique D3).
|
||||
|
||||
Avant ce correctif, l'endpoint globait `data/training/workflows/*.json`
|
||||
(ancien store JSON, créé vide sur DGX) et renvoyait toujours `total: 0`,
|
||||
rendant la surface « ce que Léa sait » faussement vide. On lit désormais la
|
||||
même base SQLite que Léa au runtime.
|
||||
"""
|
||||
try:
|
||||
workflows = []
|
||||
hide_unnamed = request.args.get('hide_unnamed', 'true').lower() == 'true'
|
||||
|
||||
if not WORKFLOWS_PATH.exists():
|
||||
WORKFLOWS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
return jsonify({'workflows': [], 'total': 0, 'hidden_unnamed': 0})
|
||||
|
||||
for wf_file in WORKFLOWS_PATH.glob('*.json'):
|
||||
try:
|
||||
with open(wf_file, 'r') as f:
|
||||
wf_data = json.load(f)
|
||||
|
||||
workflows.append({
|
||||
'workflow_id': wf_data.get('workflow_id', wf_file.stem),
|
||||
'name': wf_data.get('name', wf_file.stem),
|
||||
'description': wf_data.get('description', ''),
|
||||
'nodes_count': len(wf_data.get('nodes', [])),
|
||||
'edges_count': len(wf_data.get('edges', [])),
|
||||
'learning_state': wf_data.get('learning_state', 'OBSERVATION'),
|
||||
'created_at': wf_data.get('created_at', ''),
|
||||
'updated_at': wf_data.get('updated_at', ''),
|
||||
'execution_count': wf_data.get('execution_count', 0),
|
||||
'file_path': str(wf_file)
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erreur lecture workflow {wf_file}: {e}")
|
||||
workflows = _load_workflows_from_vwb_db()
|
||||
|
||||
# Filtrer les workflows "Unnamed" si demandé
|
||||
if hide_unnamed:
|
||||
@@ -1970,19 +2071,83 @@ def import_config():
|
||||
# API Streaming - Proxy vers le serveur de streaming (port 5005)
|
||||
# =============================================================================
|
||||
|
||||
STREAMING_BASE_URL = 'http://localhost:5005/api/v1/traces/stream'
|
||||
def _normalize_streaming_base_url(raw_url: str) -> str:
|
||||
"""Normalise l'URL du serveur streaming vers le préfixe API attendu."""
|
||||
base = (raw_url or 'http://localhost:5005').strip().rstrip('/')
|
||||
if base.endswith('/api/v1/traces/stream'):
|
||||
return base
|
||||
if base.endswith('/api/v1/traces'):
|
||||
return f'{base}/stream'
|
||||
return f'{base}/api/v1/traces/stream'
|
||||
|
||||
|
||||
STREAMING_BASE_URL = _normalize_streaming_base_url(
|
||||
os.getenv('RPA_STREAMING_URL')
|
||||
or os.getenv('STREAMING_BASE_URL')
|
||||
or 'http://localhost:5005'
|
||||
)
|
||||
|
||||
|
||||
def _streaming_headers():
|
||||
headers = {'Accept': 'application/json'}
|
||||
token = os.getenv('RPA_API_TOKEN', '').strip()
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
return headers
|
||||
|
||||
|
||||
def _fetch_streaming_json(endpoint, query_string=''):
|
||||
import urllib.request
|
||||
|
||||
endpoint = endpoint.strip('/')
|
||||
url = f'{STREAMING_BASE_URL}/{endpoint}'
|
||||
if query_string:
|
||||
url = f'{url}?{query_string}'
|
||||
req = urllib.request.Request(url, headers=_streaming_headers())
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
return json.loads(response.read().decode())
|
||||
|
||||
|
||||
def _streaming_status_snapshot():
|
||||
"""Compat legacy dashboard: agrège les endpoints streaming qui existent."""
|
||||
snapshot = _fetch_streaming_json('stats')
|
||||
try:
|
||||
sessions = _fetch_streaming_json('sessions')
|
||||
snapshot['sessions'] = sessions.get('sessions', sessions if isinstance(sessions, list) else [])
|
||||
except Exception as exc:
|
||||
snapshot['sessions_error'] = str(exc)
|
||||
try:
|
||||
snapshot['processing'] = _fetch_streaming_json('processing/status')
|
||||
except Exception as exc:
|
||||
snapshot['processing_error'] = str(exc)
|
||||
try:
|
||||
replays = _fetch_streaming_json('replays')
|
||||
replay_items = replays.get('replays', []) if isinstance(replays, dict) else []
|
||||
snapshot['replay'] = next((r for r in replay_items if r.get('active')), None)
|
||||
snapshot['replays'] = replay_items
|
||||
except Exception as exc:
|
||||
snapshot['replay_error'] = str(exc)
|
||||
return snapshot
|
||||
|
||||
@app.route('/api/streaming/<path:endpoint>')
|
||||
def proxy_streaming(endpoint):
|
||||
"""Proxy vers le serveur de streaming pour éviter les problèmes CORS."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
try:
|
||||
url = f'{STREAMING_BASE_URL}/{endpoint}'
|
||||
req = urllib.request.Request(url, headers={'Accept': 'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
clean_endpoint = endpoint.strip('/')
|
||||
if clean_endpoint == 'status':
|
||||
data = _streaming_status_snapshot()
|
||||
else:
|
||||
query_string = request.query_string.decode('utf-8')
|
||||
data = _fetch_streaming_json(clean_endpoint, query_string)
|
||||
return jsonify(data)
|
||||
return jsonify(data)
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
payload = json.loads(e.read().decode())
|
||||
except Exception:
|
||||
payload = {'error': e.reason}
|
||||
return jsonify(payload), e.code
|
||||
except urllib.error.URLError as e:
|
||||
return jsonify({'error': f'Serveur streaming inaccessible: {e}'}), 502
|
||||
except Exception as e:
|
||||
@@ -2273,10 +2438,17 @@ def process_mining_discover():
|
||||
load_jsonl_session,
|
||||
PM4PY_AVAILABLE,
|
||||
)
|
||||
except ImportError:
|
||||
except ImportError as exc:
|
||||
missing = getattr(exc, "name", None) or str(exc)
|
||||
return jsonify({
|
||||
'error': "Module d'analyse non disponible",
|
||||
'detail': "Le module core.analytics.process_mining_bridge est introuvable.",
|
||||
'detail': (
|
||||
"Dépendance analytics manquante pendant l'import du bridge "
|
||||
f"({missing}). Installer le bundle process-mining dans le venv DGX "
|
||||
"avant de générer la cartographie."
|
||||
),
|
||||
'missing_dependency': missing,
|
||||
'action_required': "install-process-mining-dependencies",
|
||||
}), 503
|
||||
|
||||
if not PM4PY_AVAILABLE:
|
||||
|
||||
@@ -1392,7 +1392,13 @@
|
||||
|
||||
// Status indicator
|
||||
const faissStatusEl = document.getElementById('faissStatus');
|
||||
if (faiss.error) {
|
||||
if (faiss.status === 'metadata_invalid') {
|
||||
faissStatusEl.textContent = '⚠️';
|
||||
faissStatusEl.title = 'Index brut présent, métadonnées invalides';
|
||||
} else if (faiss.status === 'metadata_missing') {
|
||||
faissStatusEl.textContent = '⚠️';
|
||||
faissStatusEl.title = 'Index brut présent, métadonnées absentes';
|
||||
} else if (faiss.error) {
|
||||
faissStatusEl.textContent = '❌';
|
||||
faissStatusEl.title = faiss.error;
|
||||
} else if (faiss.status === 'index_not_found') {
|
||||
@@ -1419,6 +1425,8 @@
|
||||
if (faiss.nlist) details.push(`nlist: ${faiss.nlist}`);
|
||||
if (faiss.nprobe) details.push(`nprobe: ${faiss.nprobe}`);
|
||||
if (faiss.metadata_count) details.push(`Métadonnées: ${faiss.metadata_count}`);
|
||||
if (faiss.metadata_status) details.push(`Metadata: ${faiss.metadata_status}`);
|
||||
if (faiss.metadata_error) details.push(`Metadata error: ${faiss.metadata_error}`);
|
||||
document.getElementById('faissDetails').textContent = details.length > 0 ?
|
||||
details.join(' • ') : (faiss.error || faiss.status || 'Aucune info disponible');
|
||||
|
||||
@@ -1434,6 +1442,12 @@
|
||||
if (faiss.status === 'index_not_found') {
|
||||
recommendations.push('📝 Traitez des sessions pour créer l\'index FAISS');
|
||||
}
|
||||
if (faiss.status === 'metadata_invalid') {
|
||||
recommendations.push('⚠️ Index brut présent mais métadonnées invalides : re-signer ou régénérer les métadonnées FAISS');
|
||||
}
|
||||
if (faiss.status === 'metadata_missing') {
|
||||
recommendations.push('⚠️ Index brut présent sans métadonnées : reconstruire les métadonnées FAISS');
|
||||
}
|
||||
if (recommendations.length > 0) {
|
||||
recoEl.innerHTML = recommendations.join('<br>');
|
||||
recoEl.style.display = 'block';
|
||||
@@ -2818,10 +2832,29 @@
|
||||
const detailsEl = document.getElementById('streamServerDetails');
|
||||
|
||||
try {
|
||||
const data = await fetchJSON(`${STREAMING_BASE}/stats`);
|
||||
const [data, processing] = await Promise.all([
|
||||
fetchJSON(`${STREAMING_BASE}/stats`),
|
||||
fetchJSON(`${STREAMING_BASE}/processing/status`).catch(e => ({error: e.message}))
|
||||
]);
|
||||
|
||||
statusEl.innerHTML = '<span style="color:#22c55e;">✅</span>';
|
||||
statusEl.title = 'Serveur streaming en ligne';
|
||||
const processingReady = processing && processing.processing_ready === true;
|
||||
// « En veille » (armé/lazy) ≠ « dégradé » : un worker neuf sans
|
||||
// session a tous ses composants à false par design (chargement à la
|
||||
// 1re session), ce n'est PAS une panne. Seul status==='degraded'
|
||||
// (init tentée et en échec) est une vraie alerte.
|
||||
const processingArmed = processing && (processing.armed === true || processing.status === 'idle');
|
||||
const processingDegraded = processing && !processing.error && processing.status === 'degraded';
|
||||
const statusHint = (processing && processing.status_hint) || '';
|
||||
statusEl.innerHTML = processingDegraded
|
||||
? '<span style="color:#f59e0b;">⚠️</span>'
|
||||
: processingArmed
|
||||
? '<span style="color:#3b82f6;">⏸️</span>'
|
||||
: '<span style="color:#22c55e;">✅</span>';
|
||||
statusEl.title = processingDegraded
|
||||
? `Streaming en ligne, worker apprentissage dégradé${statusHint ? ' — ' + statusHint : ''}`
|
||||
: processingArmed
|
||||
? `Streaming en ligne, worker en veille${statusHint ? ' — ' + statusHint : ''}`
|
||||
: 'Serveur streaming en ligne';
|
||||
|
||||
document.getElementById('streamActiveSessions').textContent = data.active_sessions || 0;
|
||||
document.getElementById('streamTotalEvents').textContent = data.total_events || 0;
|
||||
@@ -2837,6 +2870,31 @@
|
||||
if (data.events_per_second !== undefined) rows.push({label: 'Événements/sec', value: (data.events_per_second || 0).toFixed(2)});
|
||||
if (data.memory_usage_mb !== undefined) rows.push({label: 'Mémoire utilisée', value: Math.round(data.memory_usage_mb) + ' MB'});
|
||||
if (data.server_version) rows.push({label: 'Version serveur', value: data.server_version});
|
||||
if (processing && !processing.error) {
|
||||
const status = processing.status || 'unknown';
|
||||
const workerIcon = processingReady ? '✅' : (processingArmed ? '⏸️' : '⚠️');
|
||||
rows.push({
|
||||
label: 'Worker apprentissage',
|
||||
value: `${workerIcon} ${status}`
|
||||
});
|
||||
rows.push({
|
||||
label: 'Composants intelligence',
|
||||
value: processing.components_ready
|
||||
? 'prêts'
|
||||
: (processingArmed ? 'en veille (chargés à la 1re session)' : 'non prêts')
|
||||
});
|
||||
if (statusHint) {
|
||||
rows.push({label: 'Détail worker', value: statusHint});
|
||||
}
|
||||
if (processing.queue_length !== undefined) {
|
||||
rows.push({label: 'Queue apprentissage', value: processing.queue_length});
|
||||
}
|
||||
if (processing.last_cycle) {
|
||||
rows.push({label: 'Dernier cycle worker', value: new Date(processing.last_cycle).toLocaleString('fr-FR')});
|
||||
}
|
||||
} else if (processing && processing.error) {
|
||||
rows.push({label: 'Worker apprentissage', value: `❌ ${processing.error}`});
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Afficher les données brutes si les clés attendues ne sont pas présentes
|
||||
|
||||
Reference in New Issue
Block a user