fix(analyse IA): analyses orphelines — une analyse interrompue par un redémarrage du serveur (pm2 restart, déploiement) est marquée échouée après 20 min (store.failOrphans, appelé au démarrage d'une analyse et à chaque lecture) au lieu de rester « en cours » indéfiniment
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 changed files +16 −0
modified
src/lib/cost/ai/service.ts
+3 −0
@@ -45,6 +45,7 @@ export async function startAnalysis(uid: string, opts: { force?: boolean } = {}) | ||
| 45 | 45 | if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found"); |
| 46 | 46 | if (!ctx.imageUrls.length) throw new AnalysisError("Cette annonce n'a aucune photo : l'analyse multimodale n'est pas possible.", 422, "no_images"); |
| 47 | 47 | if (!process.env.ANTHROPIC_API_KEY) throw new AnalysisError("Modèle indisponible (clé API absente côté serveur).", 503, "model_unavailable"); |
| 48 | + store.failOrphans(); // une analyse figée par un redémarrage ne doit jamais bloquer une nouvelle demande | |
| 48 | 49 | const active = store.findActive(uid); |
| 49 | 50 | if (active) return { analysisId: active.id, status: active.status, reused: true, version: active.version }; |
| 50 | 51 | // empreinte provisoire (sans hashes d'images) pour la réutilisation rapide ; l'empreinte finale inclut les hashes |
@@ -181,10 +182,12 @@ export function viewAnalysis(row: store.AnalysisRow): AnalysisView { | ||
| 181 | 182 | } |
| 182 | 183 | |
| 183 | 184 | export function getLatest(uid: string): AnalysisView | null { |
| 185 | + store.failOrphans(); | |
| 184 | 186 | const row = store.latestForListing(uid); |
| 185 | 187 | return row ? viewAnalysis(row) : null; |
| 186 | 188 | } |
| 187 | 189 | export function getById(id: string): AnalysisView | null { |
| 190 | + store.failOrphans(); | |
| 188 | 191 | const row = store.getAnalysis(id); |
| 189 | 192 | return row ? viewAnalysis(row) : null; |
| 190 | 193 | } |
modified
src/lib/cost/ai/store.ts
+13 −0
@@ -74,6 +74,19 @@ export function setStage(id: string, status: AnalysisStatus): void { | ||
| 74 | 74 | export function setFailed(id: string, error: string): void { |
| 75 | 75 | db().prepare("UPDATE listing_ai_analyses SET status='failed', stage='failed', error=?, completed_at=datetime('now') WHERE id=?").run(error.slice(0, 2000), id); |
| 76 | 76 | } |
| 77 | +/** | |
| 78 | + * Analyses orphelines : une analyse tourne dans le processus du serveur ; un | |
| 79 | + * redémarrage (pm2 restart, déploiement) l'interrompt sans transition vers | |
| 80 | + * `failed`, et l'interface resterait sur « analyse en cours » indéfiniment. | |
| 81 | + * Toute analyse non terminale plus vieille que `maxAgeMin` est marquée échouée | |
| 82 | + * (les plus longues observées : ≈ 2 min pour 27-36 photos). Renvoie le nombre corrigé. | |
| 83 | + */ | |
| 84 | +export function failOrphans(maxAgeMin = 20): number { | |
| 85 | + const r = db().prepare( | |
| 86 | + "UPDATE listing_ai_analyses SET status='failed', stage='failed', error=?, completed_at=datetime('now') WHERE status NOT IN ('completed','failed') AND COALESCE(started_at, created_at) < datetime('now', ?)", | |
| 87 | + ).run("Analyse interrompue (redémarrage du serveur pendant le traitement) — relancez l'analyse.", `-${Math.max(1, Math.round(maxAgeMin))} minutes`); | |
| 88 | + return r.changes; | |
| 89 | +} | |
| 77 | 90 | export function saveOutput(id: string, o: { outputJson: string; imageCount: number; usageJson: string; issuesJson: string; imagesJson: string }): void { |
| 78 | 91 | db().prepare("UPDATE listing_ai_analyses SET output_json=?, image_count=?, usage_json=?, issues_json=?, images_json=? WHERE id=?").run(o.outputJson, o.imageCount, o.usageJson, o.issuesJson, o.imagesJson, id); |
| 79 | 92 | } |
| 80 | 93 | |