Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Service d'analyse IA des annonces (SERVEUR) : file de travaux en mémoire4 * avec états persistés (§168), idempotence par empreinte (§131-132, 169),5 * limites/budget (§183-184), pipeline complet :6 * photos → Claude Sonnet 5 → validation → fusion des sources → vecteur →7 * mapping assemblages → moteur de coût déterministe → estimation sauvegardée.8 * Recalcul aux coûts d'aujourd'hui, corrections manuelles et similaires9 * techniques ne rappellent JAMAIS le modèle.10 */11import { randomUUID } from "crypto";12import { loadAssemblies } from "../catalog";13import { getEstimate, runEstimate } from "../estimate";14import type { CostEstimate } from "../types";15import { inputHash, loadListingContext, type ListingContext } from "./context";16import { prepareListingImages, purgeImageCache, type PreparedImage } from "./images";17import { applyOverrides, mergeFacts, type Conflict, type MergedFacts } from "./merge";18import { combinedConfidence, mapToCostInput, type MappingResult } from "./mapping";19import { ClaudeSonnet5Provider, type PropertyVisionModel } from "./provider";20import { PROMPT_VERSION, SCHEMA_VERSION, type ImageManifestEntry, type ModelOutput, type PropertyTechnicalAnalysis } from "./schema";21import * as store from "./store";22import { EMBEDDING_MODEL, canonicalText, cosine, costProfileVector, technicalVector } from "./vector";23import { computeGeometry } from "../geometry";2425export const MAX_IMAGES = Number(process.env.PROPERTY_ANALYSIS_MAX_IMAGES ?? 40);26export const DAILY_MAX = Number(process.env.AI_ANALYSIS_DAILY_MAX ?? 30);27export const DAILY_BUDGET_USD = Number(process.env.AI_DAILY_BUDGET_USD ?? 15);2829export class AnalysisError extends Error {30 constructor(message: string, public status = 400, public code = "bad_request") { super(message); }31}3233/* --------------------------------------------------------------- file */3435const running = new Set<string>();36let provider: PropertyVisionModel | null = null;37export function setProvider(p: PropertyVisionModel | null): void { provider = p; }38function getProvider(): PropertyVisionModel { return provider ?? (provider = new ClaudeSonnet5Provider()); }3940export interface StartResult { analysisId: string; status: store.AnalysisStatus; reused: boolean; version: number }4142/** Démarre (ou réutilise) une analyse. `force` crée une nouvelle version même si l'empreinte est connue. */43export async function startAnalysis(uid: string, opts: { force?: boolean } = {}): Promise<StartResult> {44 const ctx = loadListingContext(uid);45 if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");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 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 demande49 const active = store.findActive(uid);50 if (active) return { analysisId: active.id, status: active.status, reused: true, version: active.version };51 // empreinte provisoire (sans hashes d'images) pour la réutilisation rapide ; l'empreinte finale inclut les hashes52 const model = getProvider().model;53 if (!opts.force) {54 // réutilisation sans retéléchargement : si toutes les photos sont déjà connues, l'empreinte finale est calculable55 const known = store.knownImageHashes(uid);56 if (ctx.imageUrls.every((u) => known.has(u))) {57 const full = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => known.get(u)!));58 const prev = store.findCompleted(uid, full, PROMPT_VERSION, model);59 if (prev) return { analysisId: prev.id, status: "completed", reused: true, version: prev.version };60 }61 }62 if (store.analysesStartedToday() >= DAILY_MAX) throw new AnalysisError(`Quota quotidien d'analyses atteint (${DAILY_MAX}).`, 429, "daily_quota");63 const u = store.usageToday();64 if (u.costUsd >= DAILY_BUDGET_USD) throw new AnalysisError("Budget quotidien d'analyse IA épuisé — réessayez demain.", 429, "budget");65 const id = randomUUID();66 const row = store.createAnalysis({ id, listingUid: uid, model, promptVersion: PROMPT_VERSION, schemaVersion: SCHEMA_VERSION, inputHash: `${quickHash(ctx)}:pending`, listingJson: JSON.stringify(ctx.analysisListing) });67 void runPipeline(id, ctx, opts.force ?? false);68 return { analysisId: id, status: row.status, reused: false, version: row.version };69}7071function quickHash(ctx: ListingContext): string { return inputHash(ctx.baseHashInput, []).slice(0, 24); }7273async function runPipeline(id: string, ctx: ListingContext, force: boolean): Promise<void> {74 if (running.has(id)) return;75 running.add(id);76 try {77 store.setStage(id, "fetching_images");78 const imgs = await prepareListingImages(ctx.uid, ctx.imageUrls, MAX_IMAGES);79 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(" ; ")})`);80 // empreinte finale : toutes les photos téléchargées (y compris doublons/miniatures écartés)81 const fetchedHashes = new Map(imgs.all.map((i) => [i.sourceUrl, i.hash]));82 const known = store.knownImageHashes(ctx.uid);83 const hash = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => fetchedHashes.get(u) ?? known.get(u) ?? `missing:${u}`));84 store.db().prepare("UPDATE listing_ai_analyses SET input_hash=? WHERE id=?").run(hash, id);85 store.saveImages(ctx.uid, id, imgs.all.map((i) => ({ ...i, selected: !!i.id })));86 if (!force) {87 const prev = store.findCompleted(ctx.uid, hash, PROMPT_VERSION, getProvider().model);88 if (prev && prev.id !== id) {89 // même entrée déjà analysée : on réutilise la sortie du modèle sans rappeler Claude90 finishFromPrevious(id, prev, ctx, imgs.selected);91 return;92 }93 }94 store.setStage(id, "analyzing");95 const assemblies = loadAssemblies();96 const codes = [...assemblies.keys()];97 const catalogue = [...assemblies.values()].map((a) => `${a.code} | ${a.nameEn} (${a.nameFr}) | unit: ${a.unit}`).join("\n");98 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 });99 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 });100 store.setStage(id, "validating");101 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 }));102 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) });103 completeFromOutput(id, ctx, res.output, manifest);104 } catch (e) {105 store.setFailed(id, (e as Error).message);106 } finally {107 running.delete(id);108 try { purgeImageCache(); } catch { /* ignore */ }109 }110}111112function finishFromPrevious(id: string, prev: store.AnalysisRow, ctx: ListingContext, selected: PreparedImage[]): void {113 const output = JSON.parse(prev.output_json ?? "null") as ModelOutput | null;114 if (!output) { store.setFailed(id, "analyse précédente sans sortie"); return; }115 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 }));116 store.saveOutput(id, { outputJson: JSON.stringify(output), imageCount: manifest.length, usageJson: JSON.stringify({ reusedFrom: prev.id, estimatedCostUsd: 0 }), issuesJson: "[]", imagesJson: JSON.stringify(manifest) });117 completeFromOutput(id, ctx, output, manifest);118}119120/** Étapes déterministes après la sortie du modèle : fusion → vecteur → mapping → coût. */121function completeFromOutput(id: string, ctx: ListingContext, output: ModelOutput, manifest: ImageManifestEntry[]): void {122 store.setStage(id, "embedding");123 const merged = mergeFacts(output, ctx.facts, ctx.mamh);124 store.saveConflicts(id, merged.conflicts);125 const vec = technicalVector(merged);126 const text = canonicalText(merged);127 store.saveMergedAndVector(id, JSON.stringify(merged), JSON.stringify(vec));128 store.saveEmbedding(ctx.uid, id, EMBEDDING_MODEL, vec, text);129 store.setStage(id, "mapping_assemblies");130 priceAnalysis(id, ctx, output, merged, manifest, true);131}132133function priceAnalysis(id: string, ctx: ListingContext, output: ModelOutput, mergedBase: MergedFacts, manifest: ImageManifestEntry[], completed: boolean): CostEstimate {134 const overrides = store.listOverrides(id).map((o) => ({ fieldPath: o.fieldPath, value: o.value }));135 const merged = overrides.length ? applyOverrides(mergedBase, overrides) : mergedBase;136 store.saveMergedEffective(id, JSON.stringify(merged));137 const assemblies = loadAssemblies();138 const l = ctx.detail.listing;139 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()) });140 store.setStage(id, "pricing");141 const e = ctx.detail.eval;142 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 } });143 const mappedShare = output.assemblies.length ? output.assemblies.filter((a) => assemblies.has(a.assembly_code)).length / output.assemblies.length : 0.8;144 const combined = combinedConfidence(output, merged, est, mappedShare);145 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) };146 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 });147 void manifest;148 return est;149}150151/* ------------------------------------------------------------ lecture */152153export interface AnalysisView {154 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;155 analysis: PropertyTechnicalAnalysis | null; merged: MergedFacts | null; mapping: { aiQuantities: MappingResult["aiQuantities"]; notes: string[]; overrides: { fieldPath: string; value: unknown }[]; attributeSources: Record<string, string> } | null;156 estimate: CostEstimate | null; costSnapshotDate: string | null; combined: ReturnType<typeof combinedConfidence> | null; conflicts: (Conflict & { id: number; status: string })[]; overrides: ReturnType<typeof store.listOverrides>;157 usage: Record<string, unknown> | null; versions: ReturnType<typeof store.listVersions>; compact: unknown;158}159160export function viewAnalysis(row: store.AnalysisRow): AnalysisView {161 const output = row.output_json ? (JSON.parse(row.output_json) as ModelOutput) : null;162 const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null;163 const listing = row.listing_json ? (JSON.parse(row.listing_json) as PropertyTechnicalAnalysis["listing"]) : null;164 const images = row.images_json ? (JSON.parse(row.images_json) as ImageManifestEntry[]) : [];165 const usage = row.usage_json ? (JSON.parse(row.usage_json) as Record<string, unknown>) : null;166 const estimate = row.estimate_id ? getEstimate(row.estimate_id) : null;167 let analysis: PropertyTechnicalAnalysis | null = null;168 if (output && listing) {169 const g = estimate ? computeGeometry(estimate.input.building) : null;170 analysis = {171 ...output,172 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 },173 listing, images,174 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: {} },175 };176 }177 return {178 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,179 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,180 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,181 };182}183184export function getLatest(uid: string): AnalysisView | null {185 store.failOrphans();186 const row = store.latestForListing(uid);187 return row ? viewAnalysis(row) : null;188}189export function getById(id: string): AnalysisView | null {190 store.failOrphans();191 const row = store.getAnalysis(id);192 return row ? viewAnalysis(row) : null;193}194195/** Résumé pour la fiche et les cartes (analyse complète la plus récente). */196export 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 {197 const row = store.latestCompletedForListing(uid);198 if (!row) return null;199 const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null;200 const est = row.estimate_id ? getEstimate(row.estimate_id) : null;201 const conds = merged ? Object.values(merged.conditions) : [];202 const cond: string | null = conds.length ? (conds.filter((c): c is NonNullable<typeof c> => !!c).sort()[Math.floor(conds.length / 2)] ?? null) : null;203 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 };204}205206/* ------------------------------------------------- recalcul / overrides */207208/** Recalcule le coût avec les prix d'aujourd'hui (sans rappeler le modèle). */209export function recalc(analysisId: string): AnalysisView {210 const row = store.getAnalysis(analysisId);211 if (!row) throw new AnalysisError("Analyse introuvable.", 404, "not_found");212 if (row.status !== "completed" || !row.output_json || !row.merged_json) throw new AnalysisError("L'analyse n'est pas terminée.", 409, "not_completed");213 const ctx = loadListingContext(row.listing_uid);214 if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");215 const output = JSON.parse(row.output_json) as ModelOutput;216 const mergedBase = mergeFacts(output, ctx.facts, ctx.mamh); // refusionne (le rôle peut avoir changé) ; overrides réappliqués dans priceAnalysis217 store.saveMergedBase(analysisId, JSON.stringify(mergedBase));218 priceAnalysis(analysisId, ctx, output, mergedBase, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true);219 return viewAnalysis(store.getAnalysis(analysisId)!);220}221222const 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"]);223224/** Correction manuelle d'une inférence (§140-142) : conservée, tracée, puis recalcul déterministe. */225export function override(analysisId: string, fieldPath: string, value: unknown, userId: string | null): AnalysisView {226 const row = store.getAnalysis(analysisId);227 if (!row || !row.merged_json || !row.output_json) throw new AnalysisError("Analyse introuvable ou incomplète.", 404, "not_found");228 const root = fieldPath.split(".")[0];229 if (!OVERRIDABLE.has(root)) throw new AnalysisError(`Champ non modifiable : ${fieldPath}`, 400, "bad_field");230 // base = faits avant corrections (les corrections persistées sont réappliquées dans priceAnalysis)231 const merged = JSON.parse(row.merged_base_json ?? row.merged_json) as MergedFacts;232 const effective = JSON.parse(row.merged_json) as MergedFacts;233 const cur = (merged as unknown as Record<string, { value?: unknown } | undefined>)[root];234 const curEff = (effective as unknown as Record<string, { value?: unknown } | undefined>)[root];235 void cur;236 store.addOverride(analysisId, fieldPath, root === "conditions" ? effective.conditions[fieldPath.split(".")[1]] ?? null : root === "effectiveAge" ? effective.effectiveAge?.effective ?? null : curEff?.value ?? null, value, userId);237 const ctx = loadListingContext(row.listing_uid);238 if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");239 priceAnalysis(analysisId, ctx, JSON.parse(row.output_json) as ModelOutput, merged, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true);240 return viewAnalysis(store.getAnalysis(analysisId)!);241}242243/* ------------------------------------------------------------ similaires */244245export function technicalSimilar(analysisId: string, limit = 6): { listingUid: string; analysisId: string; similarity: number; text: string | null }[] {246 const row = store.getAnalysis(analysisId);247 if (!row?.vector_json || !row.merged_json) return [];248 const v = JSON.parse(row.vector_json) as number[];249 const m = JSON.parse(row.merged_json) as MergedFacts;250 const all = store.allEmbeddings(EMBEDDING_MODEL).filter((e) => e.listingUid !== row.listing_uid);251 const out: { listingUid: string; analysisId: string; similarity: number; text: string | null }[] = [];252 for (const e of all) {253 const om = store.getAnalysis(e.analysisId)?.merged_json;254 if (om) {255 const mm = JSON.parse(om) as MergedFacts;256 if (mm.buildingType.value !== m.buildingType.value) continue;257 if (Math.abs(mm.grossFloorAreaSqft.value - m.grossFloorAreaSqft.value) / m.grossFloorAreaSqft.value > 0.15) continue;258 }259 out.push({ listingUid: e.listingUid, analysisId: e.analysisId, similarity: Math.round(cosine(v, e.vector) * 1000) / 1000, text: e.text });260 }261 return out.sort((a, b) => b.similarity - a.similarity).slice(0, limit);262}263