SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
yesterdaylast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
20.6 KB · 260 lines typescript
Raw Blame History
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**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  const active = store.findActive(uid);49  if (active) return { analysisId: active.id, status: active.status, reused: true, version: active.version };50  // empreinte provisoire (sans hashes d'images) pour la réutilisation rapide ; l'empreinte finale inclut les hashes51  const model = getProvider().model;52  if (!opts.force) {53    // réutilisation sans retéléchargement : si toutes les photos sont déjà connues, l'empreinte finale est calculable54    const known = store.knownImageHashes(uid);55    if (ctx.imageUrls.every((u) => known.has(u))) {56      const full = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => known.get(u)!));57      const prev = store.findCompleted(uid, full, PROMPT_VERSION, model);58      if (prev) return { analysisId: prev.id, status: "completed", reused: true, version: prev.version };59    }60  }61  if (store.analysesStartedToday() >= DAILY_MAX) throw new AnalysisError(`Quota quotidien d'analyses atteint (${DAILY_MAX}).`, 429, "daily_quota");62  const u = store.usageToday();63  if (u.costUsd >= DAILY_BUDGET_USD) throw new AnalysisError("Budget quotidien d'analyse IA épuisé — réessayez demain.", 429, "budget");64  const id = randomUUID();65  const row = store.createAnalysis({ id, listingUid: uid, model, promptVersion: PROMPT_VERSION, schemaVersion: SCHEMA_VERSION, inputHash: `${quickHash(ctx)}:pending`, listingJson: JSON.stringify(ctx.analysisListing) });66  void runPipeline(id, ctx, opts.force ?? false);67  return { analysisId: id, status: row.status, reused: false, version: row.version };68}6970function quickHash(ctx: ListingContext): string { return inputHash(ctx.baseHashInput, []).slice(0, 24); }7172async function runPipeline(id: string, ctx: ListingContext, force: boolean): Promise<void> {73  if (running.has(id)) return;74  running.add(id);75  try {76    store.setStage(id, "fetching_images");77    const imgs = await prepareListingImages(ctx.uid, ctx.imageUrls, MAX_IMAGES);78    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(" ; ")})`);79    // empreinte finale : toutes les photos téléchargées (y compris doublons/miniatures écartés)80    const fetchedHashes = new Map(imgs.all.map((i) => [i.sourceUrl, i.hash]));81    const known = store.knownImageHashes(ctx.uid);82    const hash = inputHash(ctx.baseHashInput, ctx.imageUrls.map((u) => fetchedHashes.get(u) ?? known.get(u) ?? `missing:${u}`));83    store.db().prepare("UPDATE listing_ai_analyses SET input_hash=? WHERE id=?").run(hash, id);84    store.saveImages(ctx.uid, id, imgs.all.map((i) => ({ ...i, selected: !!i.id })));85    if (!force) {86      const prev = store.findCompleted(ctx.uid, hash, PROMPT_VERSION, getProvider().model);87      if (prev && prev.id !== id) {88        // même entrée déjà analysée : on réutilise la sortie du modèle sans rappeler Claude89        finishFromPrevious(id, prev, ctx, imgs.selected);90        return;91      }92    }93    store.setStage(id, "analyzing");94    const assemblies = loadAssemblies();95    const codes = [...assemblies.keys()];96    const catalogue = [...assemblies.values()].map((a) => `${a.code} | ${a.nameEn} (${a.nameFr}) | unit: ${a.unit}`).join("\n");97    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 });98    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 });99    store.setStage(id, "validating");100    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 }));101    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) });102    completeFromOutput(id, ctx, res.output, manifest);103  } catch (e) {104    store.setFailed(id, (e as Error).message);105  } finally {106    running.delete(id);107    try { purgeImageCache(); } catch { /* ignore */ }108  }109}110111function finishFromPrevious(id: string, prev: store.AnalysisRow, ctx: ListingContext, selected: PreparedImage[]): void {112  const output = JSON.parse(prev.output_json ?? "null") as ModelOutput | null;113  if (!output) { store.setFailed(id, "analyse précédente sans sortie"); return; }114  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 }));115  store.saveOutput(id, { outputJson: JSON.stringify(output), imageCount: manifest.length, usageJson: JSON.stringify({ reusedFrom: prev.id, estimatedCostUsd: 0 }), issuesJson: "[]", imagesJson: JSON.stringify(manifest) });116  completeFromOutput(id, ctx, output, manifest);117}118119/** Étapes déterministes après la sortie du modèle : fusion → vecteur → mapping → coût. */120function completeFromOutput(id: string, ctx: ListingContext, output: ModelOutput, manifest: ImageManifestEntry[]): void {121  store.setStage(id, "embedding");122  const merged = mergeFacts(output, ctx.facts, ctx.mamh);123  store.saveConflicts(id, merged.conflicts);124  const vec = technicalVector(merged);125  const text = canonicalText(merged);126  store.saveMergedAndVector(id, JSON.stringify(merged), JSON.stringify(vec));127  store.saveEmbedding(ctx.uid, id, EMBEDDING_MODEL, vec, text);128  store.setStage(id, "mapping_assemblies");129  priceAnalysis(id, ctx, output, merged, manifest, true);130}131132function priceAnalysis(id: string, ctx: ListingContext, output: ModelOutput, mergedBase: MergedFacts, manifest: ImageManifestEntry[], completed: boolean): CostEstimate {133  const overrides = store.listOverrides(id).map((o) => ({ fieldPath: o.fieldPath, value: o.value }));134  const merged = overrides.length ? applyOverrides(mergedBase, overrides) : mergedBase;135  store.saveMergedEffective(id, JSON.stringify(merged));136  const assemblies = loadAssemblies();137  const l = ctx.detail.listing;138  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()) });139  store.setStage(id, "pricing");140  const e = ctx.detail.eval;141  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 } });142  const mappedShare = output.assemblies.length ? output.assemblies.filter((a) => assemblies.has(a.assembly_code)).length / output.assemblies.length : 0.8;143  const combined = combinedConfidence(output, merged, est, mappedShare);144  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) };145  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 });146  void manifest;147  return est;148}149150/* ------------------------------------------------------------ lecture */151152export interface AnalysisView {153  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;154  analysis: PropertyTechnicalAnalysis | null; merged: MergedFacts | null; mapping: { aiQuantities: MappingResult["aiQuantities"]; notes: string[]; overrides: { fieldPath: string; value: unknown }[]; attributeSources: Record<string, string> } | null;155  estimate: CostEstimate | null; costSnapshotDate: string | null; combined: ReturnType<typeof combinedConfidence> | null; conflicts: (Conflict & { id: number; status: string })[]; overrides: ReturnType<typeof store.listOverrides>;156  usage: Record<string, unknown> | null; versions: ReturnType<typeof store.listVersions>; compact: unknown;157}158159export function viewAnalysis(row: store.AnalysisRow): AnalysisView {160  const output = row.output_json ? (JSON.parse(row.output_json) as ModelOutput) : null;161  const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null;162  const listing = row.listing_json ? (JSON.parse(row.listing_json) as PropertyTechnicalAnalysis["listing"]) : null;163  const images = row.images_json ? (JSON.parse(row.images_json) as ImageManifestEntry[]) : [];164  const usage = row.usage_json ? (JSON.parse(row.usage_json) as Record<string, unknown>) : null;165  const estimate = row.estimate_id ? getEstimate(row.estimate_id) : null;166  let analysis: PropertyTechnicalAnalysis | null = null;167  if (output && listing) {168    const g = estimate ? computeGeometry(estimate.input.building) : null;169    analysis = {170      ...output,171      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 },172      listing, images,173      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: {} },174    };175  }176  return {177    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,178    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,179    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,180  };181}182183export function getLatest(uid: string): AnalysisView | null {184  const row = store.latestForListing(uid);185  return row ? viewAnalysis(row) : null;186}187export function getById(id: string): AnalysisView | null {188  const row = store.getAnalysis(id);189  return row ? viewAnalysis(row) : null;190}191192/** Résumé pour la fiche et les cartes (analyse complète la plus récente). */193export 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 {194  const row = store.latestCompletedForListing(uid);195  if (!row) return null;196  const merged = row.merged_json ? (JSON.parse(row.merged_json) as MergedFacts) : null;197  const est = row.estimate_id ? getEstimate(row.estimate_id) : null;198  const conds = merged ? Object.values(merged.conditions) : [];199  const cond: string | null = conds.length ? (conds.filter((c): c is NonNullable<typeof c> => !!c).sort()[Math.floor(conds.length / 2)] ?? null) : null;200  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 };201}202203/* ------------------------------------------------- recalcul / overrides */204205/** Recalcule le coût avec les prix d'aujourd'hui (sans rappeler le modèle). */206export function recalc(analysisId: string): AnalysisView {207  const row = store.getAnalysis(analysisId);208  if (!row) throw new AnalysisError("Analyse introuvable.", 404, "not_found");209  if (row.status !== "completed" || !row.output_json || !row.merged_json) throw new AnalysisError("L'analyse n'est pas terminée.", 409, "not_completed");210  const ctx = loadListingContext(row.listing_uid);211  if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");212  const output = JSON.parse(row.output_json) as ModelOutput;213  const mergedBase = mergeFacts(output, ctx.facts, ctx.mamh); // refusionne (le rôle peut avoir changé) ; overrides réappliqués dans priceAnalysis214  store.saveMergedBase(analysisId, JSON.stringify(mergedBase));215  priceAnalysis(analysisId, ctx, output, mergedBase, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true);216  return viewAnalysis(store.getAnalysis(analysisId)!);217}218219const 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"]);220221/** Correction manuelle d'une inférence (§140-142) : conservée, tracée, puis recalcul déterministe. */222export function override(analysisId: string, fieldPath: string, value: unknown, userId: string | null): AnalysisView {223  const row = store.getAnalysis(analysisId);224  if (!row || !row.merged_json || !row.output_json) throw new AnalysisError("Analyse introuvable ou incomplète.", 404, "not_found");225  const root = fieldPath.split(".")[0];226  if (!OVERRIDABLE.has(root)) throw new AnalysisError(`Champ non modifiable : ${fieldPath}`, 400, "bad_field");227  // base = faits avant corrections (les corrections persistées sont réappliquées dans priceAnalysis)228  const merged = JSON.parse(row.merged_base_json ?? row.merged_json) as MergedFacts;229  const effective = JSON.parse(row.merged_json) as MergedFacts;230  const cur = (merged as unknown as Record<string, { value?: unknown } | undefined>)[root];231  const curEff = (effective as unknown as Record<string, { value?: unknown } | undefined>)[root];232  void cur;233  store.addOverride(analysisId, fieldPath, root === "conditions" ? effective.conditions[fieldPath.split(".")[1]] ?? null : root === "effectiveAge" ? effective.effectiveAge?.effective ?? null : curEff?.value ?? null, value, userId);234  const ctx = loadListingContext(row.listing_uid);235  if (!ctx) throw new AnalysisError("Annonce introuvable.", 404, "not_found");236  priceAnalysis(analysisId, ctx, JSON.parse(row.output_json) as ModelOutput, merged, JSON.parse(row.images_json ?? "[]") as ImageManifestEntry[], true);237  return viewAnalysis(store.getAnalysis(analysisId)!);238}239240/* ------------------------------------------------------------ similaires */241242export function technicalSimilar(analysisId: string, limit = 6): { listingUid: string; analysisId: string; similarity: number; text: string | null }[] {243  const row = store.getAnalysis(analysisId);244  if (!row?.vector_json || !row.merged_json) return [];245  const v = JSON.parse(row.vector_json) as number[];246  const m = JSON.parse(row.merged_json) as MergedFacts;247  const all = store.allEmbeddings(EMBEDDING_MODEL).filter((e) => e.listingUid !== row.listing_uid);248  const out: { listingUid: string; analysisId: string; similarity: number; text: string | null }[] = [];249  for (const e of all) {250    const om = store.getAnalysis(e.analysisId)?.merged_json;251    if (om) {252      const mm = JSON.parse(om) as MergedFacts;253      if (mm.buildingType.value !== m.buildingType.value) continue;254      if (Math.abs(mm.grossFloorAreaSqft.value - m.grossFloorAreaSqft.value) / m.grossFloorAreaSqft.value > 0.15) continue;255    }256    out.push({ listingUid: e.listingUid, analysisId: e.analysisId, similarity: Math.round(cosine(v, e.vector) * 1000) / 1000, text: e.text });257  }258  return out.sort((a, b) => b.similarity - a.similarity).slice(0, limit);259}260