SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
20.7 KB · 263 lines typescript
Raw Blame History
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