// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Service d'analyse IA des annonces (SERVEUR) : file de travaux en mémoire * avec états persistés (§168), idempotence par empreinte (§131-132, 169), * limites/budget (§183-184), pipeline complet : * photos → Claude Sonnet 5 → validation → fusion des sources → vecteur → * mapping assemblages → moteur de coût déterministe → estimation sauvegardée. * Recalcul aux coûts d'aujourd'hui, corrections manuelles et similaires * techniques ne rappellent JAMAIS le modèle. */ import { randomUUID } from "crypto"; import { loadAssemblies } from "../catalog"; import { getEstimate, runEstimate } from "../estimate"; import type { CostEstimate } from "../types"; import { inputHash, loadListingContext, type ListingContext } from "./context"; import { prepareListingImages, purgeImageCache, type PreparedImage } from "./images"; import { applyOverrides, mergeFacts, type Conflict, type MergedFacts } from "./merge"; import { combinedConfidence, mapToCostInput, type MappingResult } from "./mapping"; import { ClaudeSonnet5Provider, type PropertyVisionModel } from "./provider"; import { PROMPT_VERSION, SCHEMA_VERSION, type ImageManifestEntry, type ModelOutput, type PropertyTechnicalAnalysis } from "./schema"; import * as store from "./store"; import { EMBEDDING_MODEL, canonicalText, cosine, costProfileVector, technicalVector } from "./vector"; import { computeGeometry } from "../geometry"; export const MAX_IMAGES = Number(process.env.PROPERTY_ANALYSIS_MAX_IMAGES ?? 40); export const DAILY_MAX = Number(process.env.AI_ANALYSIS_DAILY_MAX ?? 30); export const DAILY_BUDGET_USD = Number(process.env.AI_DAILY_BUDGET_USD ?? 15); export class AnalysisError extends Error { constructor(message: string, public status = 400, public code = "bad_request") { super(message); } } /* --------------------------------------------------------------- file */ const running = new Set(); let provider: PropertyVisionModel | null = null; export function setProvider(p: PropertyVisionModel | null): void { provider = p; } function getProvider(): PropertyVisionModel { return provider ?? (provider = new ClaudeSonnet5Provider()); } export interface StartResult { analysisId: string; status: store.AnalysisStatus; reused: boolean; version: number } /** Démarre (ou réutilise) une analyse. `force` crée une nouvelle version même si l'empreinte est connue. */ export async function startAnalysis(uid: string, opts: { force?: boolean } = {}): Promise { const ctx = loadListingContext(uid); if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found"); if (!ctx.imageUrls.length) throw new AnalysisError("Cette annonce n'a aucune photo : l'analyse multimodale n'est pas possible.", 422, "no_images"); if (!process.env.ANTHROPIC_API_KEY) throw new AnalysisError("Modèle indisponible (clé API absente côté serveur).", 503, "model_unavailable"); store.failOrphans(); // une analyse figée par un redémarrage ne doit jamais bloquer une nouvelle demande const active = store.findActive(uid); if (active) return { analysisId: active.id, status: active.status, reused: true, version: active.version }; // empreinte provisoire (sans hashes d'images) pour la réutilisation rapide ; l'empreinte finale inclut les hashes const model = getProvider().model; if (!opts.force) { // réutilisation sans retéléchargement : si toutes les photos sont déjà connues, l'empreinte finale est calculable const known = store.knownImageHashes(uid); if (ctx.imageUrls.every((u) => known.has(u))) { const full = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => known.get(u)!)); const prev = store.findCompleted(uid, full, PROMPT_VERSION, model); if (prev) return { analysisId: prev.id, status: "completed", reused: true, version: prev.version }; } } if (store.analysesStartedToday() >= DAILY_MAX) throw new AnalysisError(`Quota quotidien d'analyses atteint (${DAILY_MAX}).`, 429, "daily_quota"); const u = store.usageToday(); if (u.costUsd >= DAILY_BUDGET_USD) throw new AnalysisError("Budget quotidien d'analyse IA épuisé — réessayez demain.", 429, "budget"); const id = randomUUID(); const row = store.createAnalysis({ id, listingUid: uid, model, promptVersion: PROMPT_VERSION, schemaVersion: SCHEMA_VERSION, inputHash: `${quickHash(ctx)}:pending`, listingJson: JSON.stringify(ctx.analysisListing) }); void runPipeline(id, ctx, opts.force ?? false); return { analysisId: id, status: row.status, reused: false, version: row.version }; } function quickHash(ctx: ListingContext): string { return inputHash(ctx.baseHashInput, []).slice(0, 24); } async function runPipeline(id: string, ctx: ListingContext, force: boolean): Promise { if (running.has(id)) return; running.add(id); try { store.setStage(id, "fetching_images"); const imgs = await prepareListingImages(ctx.uid, ctx.imageUrls, MAX_IMAGES); if (!imgs.selected.length) throw new Error(`Aucune photo exploitable (${imgs.failed.length} échec(s) : ${imgs.failed.slice(0, 3).map((f) => f.error).join(" ; ")})`); // empreinte finale : toutes les photos téléchargées (y compris doublons/miniatures écartés) const fetchedHashes = new Map(imgs.all.map((i) => [i.sourceUrl, i.hash])); const known = store.knownImageHashes(ctx.uid); const hash = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => fetchedHashes.get(u) ?? known.get(u) ?? `missing:${u}`)); store.db().prepare("UPDATE listing_ai_analyses SET input_hash=? WHERE id=?").run(hash, id); store.saveImages(ctx.uid, id, imgs.all.map((i) => ({ ...i, selected: !!i.id }))); if (!force) { const prev = store.findCompleted(ctx.uid, hash, PROMPT_VERSION, getProvider().model); if (prev && prev.id !== id) { // même entrée déjà analysée : on réutilise la sortie du modèle sans rappeler Claude finishFromPrevious(id, prev, ctx, imgs.selected); return; } } store.setStage(id, "analyzing"); const assemblies = loadAssemblies(); const codes = [...assemblies.keys()]; const catalogue = [...assemblies.values()].map((a) => `${a.code} | ${a.nameEn} (${a.nameFr}) | unit: ${a.unit}`).join("\n"); const res = await getProvider().analyze({ listingContext: ctx.text, images: imgs.selected.map((i) => ({ id: i.id, mediaType: i.mediaType, data: i.data, roomHint: i.roomHint, position: i.position })), assemblyCodes: codes, assemblyCatalogueText: catalogue }); store.recordUsage({ analysisId: id, purpose: "property_analysis", model: res.model, images: imgs.selected.length, inputTokens: res.usage.inputTokens, outputTokens: res.usage.outputTokens, cacheRead: res.usage.cacheReadTokens, costUsd: res.usage.estimatedCostUsd, latencyMs: res.usage.latencyMs }); store.setStage(id, "validating"); const manifest: ImageManifestEntry[] = imgs.selected.map((i) => ({ id: i.id, position: i.position, room_hint: i.roomHint, source_url: i.sourceUrl, hash: i.hash, width: i.width, height: i.height })); store.saveOutput(id, { outputJson: JSON.stringify(res.output), imageCount: imgs.selected.length, usageJson: JSON.stringify({ ...res.usage, model: res.model, duplicatesRemoved: imgs.duplicatesRemoved, thumbnailsRemoved: imgs.thumbnailsRemoved, failed: imgs.failed }), issuesJson: JSON.stringify(res.issues), imagesJson: JSON.stringify(manifest) }); completeFromOutput(id, ctx, res.output, manifest); } catch (e) { store.setFailed(id, (e as Error).message); } finally { running.delete(id); try { purgeImageCache(); } catch { /* ignore */ } } } function finishFromPrevious(id: string, prev: store.AnalysisRow, ctx: ListingContext, selected: PreparedImage[]): void { const output = JSON.parse(prev.output_json ?? "null") as ModelOutput | null; if (!output) { store.setFailed(id, "analyse précédente sans sortie"); return; } const manifest = (JSON.parse(prev.images_json ?? "[]") as ImageManifestEntry[]).length ? (JSON.parse(prev.images_json!) as ImageManifestEntry[]) : selected.map((i) => ({ id: i.id, position: i.position, room_hint: i.roomHint, source_url: i.sourceUrl, hash: i.hash, width: i.width, height: i.height })); store.saveOutput(id, { outputJson: JSON.stringify(output), imageCount: manifest.length, usageJson: JSON.stringify({ reusedFrom: prev.id, estimatedCostUsd: 0 }), issuesJson: "[]", imagesJson: JSON.stringify(manifest) }); completeFromOutput(id, ctx, output, manifest); } /** Étapes déterministes après la sortie du modèle : fusion → vecteur → mapping → coût. */ function completeFromOutput(id: string, ctx: ListingContext, output: ModelOutput, manifest: ImageManifestEntry[]): void { store.setStage(id, "embedding"); const merged = mergeFacts(output, ctx.facts, ctx.mamh); store.saveConflicts(id, merged.conflicts); const vec = technicalVector(merged); const text = canonicalText(merged); store.saveMergedAndVector(id, JSON.stringify(merged), JSON.stringify(vec)); store.saveEmbedding(ctx.uid, id, EMBEDDING_MODEL, vec, text); store.setStage(id, "mapping_assemblies"); priceAnalysis(id, ctx, output, merged, manifest, true); } function priceAnalysis(id: string, ctx: ListingContext, output: ModelOutput, mergedBase: MergedFacts, manifest: ImageManifestEntry[], completed: boolean): CostEstimate { const overrides = store.listOverrides(id).map((o) => ({ fieldPath: o.fieldPath, value: o.value })); const merged = overrides.length ? applyOverrides(mergedBase, overrides) : mergedBase; store.saveMergedEffective(id, JSON.stringify(merged)); const assemblies = loadAssemblies(); const l = ctx.detail.listing; const mapping = mapToCostInput(merged, output, { listingUid: ctx.uid, address: l.address, municipality: l.city, lat: l.lat, lng: l.lng, unitId: ctx.mamh?.unitId ?? null, landValue: ctx.mamh?.landValue ?? null, roll: ctx.roll, assemblyCodes: new Set(assemblies.keys()) }); store.setStage(id, "pricing"); const e = ctx.detail.eval; const est = runEstimate(mapping.input, { otherReadings: { hedonic: e?.model_est ?? null, comparables: e?.comps_est ?? null, hybrid: e?.est ?? null, askingPrice: l.price, rollValue: ctx.mamh?.totalValue ?? e?.valeur_role ?? null, rollBuilding: ctx.mamh?.buildingValue ?? null, rollLand: ctx.mamh?.landValue ?? null } }); const mappedShare = output.assemblies.length ? output.assemblies.filter((a) => assemblies.has(a.assembly_code)).length / output.assemblies.length : 0.8; const combined = combinedConfidence(output, merged, est, mappedShare); const compact = { building_type: mapping.input.building.type, location: est.location.code, gross_floor_area_sqft: mapping.input.building.grossFloorAreaSqft, quality: mapping.input.building.quality, assemblies: est.lines.map((li) => ({ code: li.assemblyCode, qty: li.quantity, unit: li.unit, source: li.quantitySource })), cost_profile: costProfileVector(est.categories) }; store.saveMappingAndEstimate(id, { mappingJson: JSON.stringify({ aiQuantities: mapping.aiQuantities, notes: mapping.notes, overrides, attributeSources: mapping.input.attributeSources }), compactJson: JSON.stringify(compact), estimateId: est.id, snapshotDate: est.priceDate, combinedJson: JSON.stringify(combined), confidence: combined.overall, completed }); void manifest; return est; } /* ------------------------------------------------------------ lecture */ export interface AnalysisView { id: string; listingUid: string; version: number; status: store.AnalysisStatus; stage: string | null; error: string | null; model: string; promptVersion: string; schemaVersion: string; createdAt: string; completedAt: string | null; imageCount: number; analysis: PropertyTechnicalAnalysis | null; merged: MergedFacts | null; mapping: { aiQuantities: MappingResult["aiQuantities"]; notes: string[]; overrides: { fieldPath: string; value: unknown }[]; attributeSources: Record } | null; estimate: CostEstimate | null; costSnapshotDate: string | null; combined: ReturnType | null; conflicts: (Conflict & { id: number; status: string })[]; overrides: ReturnType; usage: Record | null; versions: ReturnType; compact: unknown; } export function viewAnalysis(row: store.AnalysisRow): AnalysisView { const output = row.output_json ? (JSON.parse(row.output_json) as ModelOutput) : null; const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null; const listing = row.listing_json ? (JSON.parse(row.listing_json) as PropertyTechnicalAnalysis["listing"]) : null; const images = row.images_json ? (JSON.parse(row.images_json) as ImageManifestEntry[]) : []; const usage = row.usage_json ? (JSON.parse(row.usage_json) as Record) : null; const estimate = row.estimate_id ? getEstimate(row.estimate_id) : null; let analysis: PropertyTechnicalAnalysis | null = null; if (output && listing) { const g = estimate ? computeGeometry(estimate.input.building) : null; analysis = { ...output, metadata: { analysis_id: row.id, listing_id: row.listing_uid, listing_source: row.listing_uid.split(":")[0], model: row.model, prompt_version: row.prompt_version, schema_version: row.schema_version, analysis_timestamp: row.completed_at ?? row.created_at, image_count: row.image_count, input_hash: row.input_hash, input_token_estimate: (usage?.inputTokens as number | undefined) ?? null, output_tokens: (usage?.outputTokens as number | undefined) ?? null, latency_ms: (usage?.latencyMs as number | undefined) ?? null }, listing, images, derived_geometry: g ? { footprint_sqft: g.footprintSqft, perimeter_ft: g.perimeterFt, exposed_perimeter_ft: g.exposedPerimeterFt, gross_wall_sqft: g.grossWallSqft, net_wall_sqft: g.netWallSqft, roof_sqft: g.roofSqft, window_count: g.windowCount, basement_floor_sqft: g.basementFloorSqft, basement_finished_sqft: g.basementFinishedSqft, garage_sqft: g.garageSqft, formulas: Object.fromEntries(estimate!.quantities.map((q) => [q.assemblyCode, q.formula])) } : { footprint_sqft: 0, perimeter_ft: 0, exposed_perimeter_ft: 0, gross_wall_sqft: 0, net_wall_sqft: 0, roof_sqft: 0, window_count: 0, basement_floor_sqft: 0, basement_finished_sqft: 0, garage_sqft: 0, formulas: {} }, }; } return { id: row.id, listingUid: row.listing_uid, version: row.version, status: row.status, stage: row.stage, error: row.error, model: row.model, promptVersion: row.prompt_version, schemaVersion: row.schema_version, createdAt: row.created_at, completedAt: row.completed_at, imageCount: row.image_count, analysis, merged, mapping: row.mapping_json ? (JSON.parse(row.mapping_json) as AnalysisView["mapping"]) : null, estimate, costSnapshotDate: row.cost_snapshot_date, combined: row.combined_json ? (JSON.parse(row.combined_json) as AnalysisView["combined"]) : null, conflicts: store.listConflicts(row.id), overrides: store.listOverrides(row.id), usage, versions: store.listVersions(row.listing_uid), compact: row.compact_json ? JSON.parse(row.compact_json) : null, }; } export function getLatest(uid: string): AnalysisView | null { store.failOrphans(); const row = store.latestForListing(uid); return row ? viewAnalysis(row) : null; } export function getById(id: string): AnalysisView | null { store.failOrphans(); const row = store.getAnalysis(id); return row ? viewAnalysis(row) : null; } /** Résumé pour la fiche et les cartes (analyse complète la plus récente). */ export function summaryForListing(uid: string): { analysisId: string; version: number; confidence: number | null; rcn: number | null; costValue: number | null; structure: string | null; foundation: string | null; roof: string | null; quality: string | null; condition: string | null; completedAt: string | null } | null { const row = store.latestCompletedForListing(uid); if (!row) return null; const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null; const est = row.estimate_id ? getEstimate(row.estimate_id) : null; const conds = merged ? Object.values(merged.conditions) : []; const cond: string | null = conds.length ? (conds.filter((c): c is NonNullable => !!c).sort()[Math.floor(conds.length / 2)] ?? null) : null; return { analysisId: row.id, version: row.version, confidence: row.confidence, rcn: est?.replacementCostNew ?? null, costValue: est?.costApproachValue ?? null, structure: merged?.structure.value ?? null, foundation: merged?.foundation.value ?? null, roof: merged?.roof.value ?? null, quality: merged?.quality.value ?? null, condition: cond, completedAt: row.completed_at }; } /* ------------------------------------------------- recalcul / overrides */ /** Recalcule le coût avec les prix d'aujourd'hui (sans rappeler le modèle). */ export function recalc(analysisId: string): AnalysisView { const row = store.getAnalysis(analysisId); if (!row) throw new AnalysisError("Analyse introuvable.", 404, "not_found"); if (row.status !== "completed" || !row.output_json || !row.merged_json) throw new AnalysisError("L'analyse n'est pas terminée.", 409, "not_completed"); const ctx = loadListingContext(row.listing_uid); if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found"); const output = JSON.parse(row.output_json) as ModelOutput; const mergedBase = mergeFacts(output, ctx.facts, ctx.mamh); // refusionne (le rôle peut avoir changé) ; overrides réappliqués dans priceAnalysis store.saveMergedBase(analysisId, JSON.stringify(mergedBase)); priceAnalysis(analysisId, ctx, output, mergedBase, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true); return viewAnalysis(store.getAnalysis(analysisId)!); } const OVERRIDABLE = new Set(["buildingType", "yearBuilt", "stories", "grossFloorAreaSqft", "footprintSqft", "foundation", "structure", "siding", "roof", "roofGeometry", "roofPitch", "windows", "windowCount", "heating", "hasAirConditioning", "hasAirExchanger", "basement", "basementFinishedPct", "garage", "kitchens", "kitchenQuality", "bathrooms", "powderRooms", "bathroomQuality", "bedrooms", "quality", "flooring", "deckSqft", "drivewaySqft", "driveway", "fenceLinFt", "pool", "units", "kitchenLinearFt", "countertopSqft", "backsplashSqft", "bathroomTileSqft", "interiorDoorCount", "conditions", "effectiveAge"]); /** Correction manuelle d'une inférence (§140-142) : conservée, tracée, puis recalcul déterministe. */ export function override(analysisId: string, fieldPath: string, value: unknown, userId: string | null): AnalysisView { const row = store.getAnalysis(analysisId); if (!row || !row.merged_json || !row.output_json) throw new AnalysisError("Analyse introuvable ou incomplète.", 404, "not_found"); const root = fieldPath.split(".")[0]; if (!OVERRIDABLE.has(root)) throw new AnalysisError(`Champ non modifiable : ${fieldPath}`, 400, "bad_field"); // base = faits avant corrections (les corrections persistées sont réappliquées dans priceAnalysis) const merged = JSON.parse(row.merged_base_json ?? row.merged_json) as MergedFacts; const effective = JSON.parse(row.merged_json) as MergedFacts; const cur = (merged as unknown as Record)[root]; const curEff = (effective as unknown as Record)[root]; void cur; store.addOverride(analysisId, fieldPath, root === "conditions" ? effective.conditions[fieldPath.split(".")[1]] ?? null : root === "effectiveAge" ? effective.effectiveAge?.effective ?? null : curEff?.value ?? null, value, userId); const ctx = loadListingContext(row.listing_uid); if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found"); priceAnalysis(analysisId, ctx, JSON.parse(row.output_json) as ModelOutput, merged, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true); return viewAnalysis(store.getAnalysis(analysisId)!); } /* ------------------------------------------------------------ similaires */ export function technicalSimilar(analysisId: string, limit = 6): { listingUid: string; analysisId: string; similarity: number; text: string | null }[] { const row = store.getAnalysis(analysisId); if (!row?.vector_json || !row.merged_json) return []; const v = JSON.parse(row.vector_json) as number[]; const m = JSON.parse(row.merged_json) as MergedFacts; const all = store.allEmbeddings(EMBEDDING_MODEL).filter((e) => e.listingUid !== row.listing_uid); const out: { listingUid: string; analysisId: string; similarity: number; text: string | null }[] = []; for (const e of all) { const om = store.getAnalysis(e.analysisId)?.merged_json; if (om) { const mm = JSON.parse(om) as MergedFacts; if (mm.buildingType.value !== m.buildingType.value) continue; if (Math.abs(mm.grossFloorAreaSqft.value - m.grossFloorAreaSqft.value) / m.grossFloorAreaSqft.value > 0.15) continue; } out.push({ listingUid: e.listingUid, analysisId: e.analysisId, similarity: Math.round(cosine(v, e.vector) * 1000) / 1000, text: e.text }); } return out.sort((a, b) => b.similarity - a.similarity).slice(0, limit); }